Changelog¶
All notable user-visible changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
Conformance work against the org's
PACKAGE_STANDARD.md
and
CODING_STANDARD.md.
No behavior of the cl-cli system itself changed; its dependency set is still
uiop alone.
Changed¶
- Test files are named after the source file they exercise. The 59
t/cases-*.lispfiles were cut by feature and had no correspondence tosrc/; they are nowt/<source>-test.lisp, ort/<source>-<aspect>-test.lispwhere one source has several aspects (for examplet/parser-value-storage-count-test.lispandt/parser-value-storage-key-value-test.lisp).t/test-support.lispandt/test-fixtures.lispare shared helpers rather than tests and becamet/helpers-support.lispandt/helpers-fixtures.lisp. No test body or test name changed. Anything loading an individualt/file by path must be updated;(asdf:test-system "cl-cli")is unaffected. - The
defpackageforms use#:designators throughout, in bothsrc/package.lispandt/package.lisp. - Source lines are wrapped at 100 columns. Long literals inside
formatcontrols use the~<newline>directive, so every generated completion script, man page, Markdown and JSON document is byte-for-byte what it was. - Sibling flake inputs are declared
flake = false. None of their flake outputs were ever read -- each is used only as a source tree for the test suite -- and carrying them as full flakes pulled their entire input graphs intoflake.lock, which stood at 82 nodes against an org range of 5-19. The lock is now 9 nodes.nixpkgsis unmoved; the sibling pins now resolve to the commits their release tags actually point to, which the previous lock had drifted away from. - The test systems were renamed.
cl-cli/testsis nowcl-cli/test, andcl-cli/tests/shell-verificationis nowcl-cli/test/shell-verification, matching the org-wide<pkg>/testconvention. The Lisp packagecl-cli/testswas renamed tocl-cli/testto match. Anything calling(asdf:test-system "cl-cli/tests")directly must be updated;(asdf:test-system "cl-cli")is unaffected. - Tests moved from
tests/tot/, and the loader moved fromtests/run-tests.lisptorun-tests.lispat the repository root. The documented invocation is nowsbcl --non-interactive --load run-tests.lisp --eval '(cl-cli/test:run-tests)' --quit. flake.nixdeclaresx86_64-linuxandaarch64-darwinonly.aarch64-linuxandx86_64-darwinwere declared but never built or tested anywhere, so the flake no longer advertises them.- Sibling flake inputs are pinned to release tags instead of tracking each repository's default branch, so an upstream push can no longer break this repository's CI without warning.
Added¶
packages.default(sbcl.buildASDFSystem),apps.test,checks.formatting(treefmt/nixfmt) andnix fmtinflake.nix.checks.defaultis the SBCL suite; the ECL suite ischecks.ecl.release.ymlandflake-update.ymlworkflows, and anix-setupcomposite action shared by all four workflows. Everyuses:is pinned to a 40-character commit SHA.
Removed¶
CODE_OF_CONDUCT.md,CONTRIBUTING.md,RELEASING.md,SECURITY.mdandSUPPORT.md. These are served org-wide from nerima-lisp/.github; the parts specific to this repository moved todocs/src/development.md.
Fixed¶
- The documentation claimed the ECL check "currently fails". It has passed since the test suite was split at the system boundary; the shell-verification half is simply excluded there.
1.0.1 - 2026-07-26¶
Three renderer bugs that 1.0.0 shipped with, found by an adversarial audit that
reported after the tag went out. All three were reproduced against the real
consumer — elvish, zsh, and GitHub's own cmark-gfm — before and after the
fix, and each now has a regression test that fails on the old behavior.
Fixed¶
- Elvish dynamic completion threw on every Tab. The renderer emitted
e:'app' __complete ...; elvish'se:external-command namespace accepts only a bare word, so quoting the app name parsed as the bareworde:concatenated with a string, producing the plain string"e:app"— which elvish then refused to call (command must be callable or string containing slash). Any app with a:completeoption was affected. Now emits(external 'app'), which takes a real string argument and so also stays correct for a name that needs quoting — dropping the quotes would have fixed the common case and broken that one.
Two things let this ship: elvish -compileonly cannot see a runtime value
error, and tests/cases-dynamic-completion.lisp asserted the broken string
verbatim. The new check in tests/cases-shell-verification.lisp runs the
generated completer and asserts on what it returns.
- zsh truncated any candidate value containing a colon. _describe splits
each entry at the first unescaped colon, and only the description half was
being sanitized. A candidate host:8080 completed as host, with 8080
folded into the description — a silently wrong insertion, on exactly the
host:port shape a candidate list tends to carry. The value half is now
backslash-escaped (it cannot be sanitized the way the description is, since
it is the text inserted on the command line). Only affected options whose
candidates carry descriptions; a bare :choices list takes the compadd
path, which was already correct.
- Markdown tables broke on a | in a value name. %md-inline-code
renders the first column of every option and argument table but did not
escape pipes, and a GFM cell ends at the first unescaped | even inside a
code span. --fmt <json|yaml|toml> split its row across the wrong cells,
destroyed the code span, dropped toml>, and lost the description column
entirely. Verified against cmark-gfm. The escape is scoped to table cells;
the per-command heading, the other caller, must not have it.
Changed¶
- The real-shell verification watchdog goes from 10s to 60s. It is not a
budget -- it exists only so a wedged child cannot block the run -- and 10s
was smaller than
pwsh's cold start inside the Nix sandbox (0.2s outside it), which turnednix flake checkintermittently red. The self-test that pins the timeout mechanism uses its own short deadline, so it still proves the watchdog fires. - The ECL check no longer preloads a newer ASDF. That was needed only for
cl-log-kit's ASDF >= 3.3.1 requirement, andcl-log-kitis no longer on ECL's path since the test systems were split — so the check now runs exactly theecl --norc --load tests/run-tests.lispline the documentation gives.
Documentation¶
docs/src/README.mdstill said "Status: Pre-1.0".RELEASING.mdanddocs/src/releasing.mdsaid:versionappears twice incl-cli.asd; the test-system split made it three, and a release that followed the old instruction would have shipped a mismatched system.- README described the dynamic-completion mechanism backwards (it is bash,
zsh, PowerShell and elvish that match the token before the cursor; fish is
the one attaching per option), and both README and the guide claimed all six
shells pass the partial word — nushell does not, and asks for the whole list
instead, so a
:completecallback sees an empty prefix there. CONTRIBUTING.mdpointed new tests attests/run-tests.lisp, which is the loader; it now names thetests/cases-*.lispfile and which of the two test systems a case belongs to, and warns against the assert-the-current-output habit that let the elvish bug through.- README's constructor list omitted
define-app/define-command.
1.0.0 - 2026-07-26¶
The API is now covered by Semantic Versioning. What that promise does and does not cover is written down in docs/src/compatibility.md, along with the supported-implementation matrix and the deprecation policy.
This release is mostly about earning that promise rather than adding features.
The breaking changes below are deliberately concentrated here, because 1.0 is
the last point at which they are free: an accessor family that could not be
read, a condition taxonomy that made a programmer's bug look like a user's,
and a machine-readable format with no version marker would all otherwise have
been permanent. Verification was rebuilt to match: nix flake check was
previously guaranteed to fail, the checks that run generated scripts through
real shells were almost entirely skipping, and no non-SBCL implementation could
compile the suite at all.
Added¶
- The positional accessor family is exported.
app-positionals,command-positionals, andmake-positionalwere all public, so callers legitimately receivedpositional-specstructs -- but not one of its slot readers was exported, leaving them opaque unless you reached throughcl-cli::. All sixteen are public now, renamed from thepositional-spec-conc-name topositional-to match theoption-/command-/app-families they sit beside. Also newly exported:command-subcommandsandcommand-default-command(nested subcommands are a documented feature andapp-default-commandwas already public),option-groupplusoption-group-members/-mode/-required-p(so a custom help renderer can render "exactly one of ..." instead of degrading to pairwise conflicts), andbuilt-in-option-specs(so it can see the synthesized--help/--version). Between them, a third-party renderer can now reproduce everything the built-in one prints. cli-missing-required-option, a subtype ofcli-missing-option-valuefor the single most common CLI mistake there is: a required option never supplied. It was previously indistinguishable from "--outputtyped with nothing after it". Being a subtype, a handler that does not need the distinction is unaffected.cli-response-file-error(with acli-response-file-error-pathreader), for the three@fileexpansion failures that previously signaled a barecli-usage-error-- "your@args.txtis missing" deserves a different message from "you mistyped a flag".- Docstrings on every exported condition class and slot reader. The
condition hierarchy is the primary integration point for a consumer CLI and
had no prose at all;
cli-invalid-option-value-causein particular was unguessable. tests/cases-public-api.lisp, which pins the exported symbol set in both directions.:exportfails silently in the direction that matters -- a misspelled name interns a dead symbol rather than signaling -- and no other test would notice, since they all call these symbols from a package that:usescl-cli. It also asserts that every exported condition is reachable fromcli-errorand thatcli-invalid-specificationstays out of thecli-usage-errorbranch.render-jsonnow emits aschemaVersionmember first, and exports+json-schema-version+(currently1). A machine-readable format with no version marker gives a consumer nothing to branch on; a tool written against today's shape would silently misread a future one. Adding it now, at the 1.0 boundary, is the last point where it costs nothing. New members may still appear in a minor release -- readers should ignore unknown members -- but a change that would break a reader of the previous shape bumps the number. This changesrender-jsonoutput; a consumer that parses the 0.3.0 document and rejects unknown keys needs updating.docs/src/compatibility.md, stating what the version number promises now that the project is on Semantic Versioning for real: the exported symbols of theCL-CLIpackage are covered,cl-cli::internals and the byte-for-byte text of rendered help/completion/man output are not, plus a supported-implementation matrix and a deprecation policy.- Dedicated unit tests for the PowerShell, Nushell, and Elvish completion
renderers (
tests/cases-completion-powershell.lisp,-nushell.lisp,-elvish.lisp). Three of the six shipped renderers previously had no assertions on what they emit at all -- only a performance benchmark and a "does it parse" smoke check. - An Elvish leg in
tests/cases-shell-verification.lisp: the generated script is compiled by realelvish -compileonly. Becauseelvishonly exposes itsedit:module interactively, the check bindsedit:to a stub namespace so the generated code itself is what gets compiled; a negative-control case asserts that a deliberately broken completer still fails, so the check cannot pass vacuously.
Changed¶
cli-invalid-specificationis no longer acli-usage-error. A newcli-errorroot splits the hierarchy in two:cli-usage-errorfor what the user typed,cli-invalid-specificationfor what the programmer declared. The idiomatic(handler-case (run-app ...) (cli-usage-error (e) (print-usage) (exit 2)))previously swallowed the developer's own malformed spec and answered it with a usage message shown to the end user. If such an error does reachrun-app, it now exits with:error-exit-code(70,EX_SOFTWARE) instead of:usage-exit-code(64,EX_USAGE) -- which is what a bug in the program actually is. In practice the condition is raised while the spec is being built, before parsing.cli-error-app/cli-error-commandare the new names for the context readers;cli-usage-error-app/-commandstill read the same slots.- The test suite is split into two ASDF systems.
cl-cli/testsis the portable core;cl-cli/tests/shell-verificationadds the checks that shell out to real tools and is the only half that needscl-process-kit. The transitivecl-log-kitdependency hard-codessb-thread:*(upstream #1), which previously made the entire suite fail to compile on any non-SBCL implementation. ECL now runs 637 tests green instead of not building.tests/run-tests.lispprints which half it loaded, so a run covering less than expected cannot be mistaken for a pass. - The fuzz suite and the benchmark budgets are now gated on the capability
each actually needs, rather than failing where it is absent: the fuzz suite
needs
cl-weave's:timeoutplatform capability, and the benchmark thresholds are absolute milliseconds calibrated against SBCL. Both report as skips with a reason. No threshold was widened and no assertion was removed. - The benchmark workloads are resized so contention on a shared CI runner
cannot fail them. One case measured 520ms idle and 2534ms against a 2000ms
budget inside a concurrent
nix flake check-- a flaky gate, and an intermittently red check gets read as noise, which is how a real regression gets waved through. Each case now targets an idle median around a tenth of its budget; the 2000ms convention itself is unchanged, since raising it is the one response that keeps the test green while destroying what it measures. nix flake checkis green again, and now means something it did not before. Theeclcheck was previously guaranteed to fail, so CI onmainwas permanently red. Alongside the split above:- the flake defines outputs for
aarch64-linux,x86_64-darwin, andaarch64-darwinas well asx86_64-linux, sonix developandnix flake checkwork on a macOS machine instead of silently checking nothing; - the checks put
bash,zsh,fish,nushell,pwsh,elvish, andmandoconPATH. The shell-verification cases skip themselves when a tool is missing, so in CI they had been skipping almost entirely -- the generated scripts were never actually run by the shells that consume them. The SBCL suite is now 664 passed, 0 skipped; - the documentation build is a check too, so a broken docs link fails CI rather than the Pages deploy.
- CI runs the flake check on
aarch64-darwinas well asx86_64-linux, so the macOS support the project claims is verified rather than assumed. consume-value-option(src/parser-option-consumption.lisp) had a provably unreachable:flag-kind guard clause -- its sole call site is the(:value :optional-value :key-value)case arm ofconsume-long-option-token, so(option-kind spec)can never be:flaginside its body. Removed as dead code; no observable behavior changed.- Bumped
cl-weave,cl-process-kit,cl-boundary-kit,cl-log-kit, andcl-json-kitto their latest upstream revisions (cl-prologwas already current). Verifiedcl-boundary-kit's new default 60-second process timeout (previously unbounded when:timeoutwas omitted) doesn't affectcl-cli: everyprocess-kit:runcall site in the test suite already passes an explicit:timeout.
Security¶
expand-response-files(:expand-response-files t) had no bound on the total bytes read across a response-file expansion -- only+response-file-max-depth+limited nesting depth, not fan-out (many files referenced at one depth) or a single huge file. A caller piping a response-file path derived from untrusted input into an app that opted into response-file expansion could exhaust memory. Added+response-file-max-total-bytes+(8 MiB, generous for any legitimate argument list) as a total-size budget checked after every file read; exceeding it signalscli-usage-errorthe same way exceeding the depth limit already did.
0.3.0 - 2026-07-25¶
Added¶
- A full MkDocs (Material) documentation site under
docs/, published to GitHub Pages vianix build .#docsand.github/workflows/docs.yml. Covers installation, a quick start, a per-topic guide (option values, option relations, commands, validation, CLI behavior, shell completion, documentation generation), an API reference, the migration guide, scope and non-goals, and the project's governance documents. define-app/define-command(src/model-dsl.lisp): a declarative, clause-based DSL overmake-app/make-command/make-option/make-positional.:option,:positional, and:commandclauses replace the nested:global-options (list ...)/:positionals (list ...)/:commands (list ...)keyword arguments;:commandclauses nest the same vocabulary for a command's own options, positionals, and subcommands, and:commands-fromsplices in an already-built command list (e.g.make-standard-commands, or another spec bound viadefine-command). Purely additive -- the functional API is unchanged and remains independently usable. See README's "Declarative DSL" section.- Adopted
cl-json-kitas a test-only dependency (mirroringcl-process-kit's test-only role): thejson renderertest suite now parsesrender-json's own output back throughcl-json-kit's independent reader as an additional correctness oracle, catching structural/escaping defects that substring-matching the writer's output can't. Not needed to usecl-cliitself.
Fixed¶
- Two README code examples called
run-appwithargvas a positional argument instead of:argv, which signalsodd number of &KEY argumentsif copy-pasted as-is.
Changed¶
- The Nushell and Elvish completion renderers' quoting functions
(
%COMPLETION-NUSHELL-QUOTE,%COMPLETION-ELVISH-QUOTE) and the Markdown documentation renderer's prose-escaping functions (%MD-ESCAPE-PROSE,%MD-SINGLE-LINE,%MD-MAX-BACKTICK-RUN, all insrc/doc-renderers-markdown.lisp) had the same double-nestedWITH-OUTPUT-TO-STRINGpattern found and fixed in bash/zsh/PowerShell's quoting functions earlier in this changelog -- each ran its own escaping pass over the already-built result of a separate control-character- stripping pass, rather than folding both into one.%MD-SINGLE-LINE's second pass turned out to be provably dead code once traced through (its input can never contain the newline/return characters it was checking for, since the upstream control-stripping pass already maps them to a space), so it now simply delegates.%MD-MAX-BACKTICK-RUNneeded care: a character control-stripping drops entirely doesn't break a backtick run in the final output, while one it maps to a space does, so its merged single pass reproduces that distinction exactly rather than just concatenating both transformations (see the new regression test intests/cases-doc-markdown.lisp, which checks a control character between two backticks counts as one run of 2 while a newline between them counts as two runs of 1). Nushell and Elvish completion rendering were separately profiled and found already fast enough (tens of microseconds per call on a 330-option benchmark app) that this fix's effect is not separately measurable there; it is applied for consistency with the other renderers, not for a measured win. No public API or observable output changed. - The PowerShell completion renderer (
RENDER-POWERSHELL-COMPLETION):%COMPLETION-POWERSHELL-QUOTEfolds control-character-stripping and single-quote-doubling into one pass instead of two nestedWITH-OUTPUT-TO-STRINGcalls (the same fix already applied to%COMPLETION-SHELL-QUOTEand%COMPLETION-ZSH-ARGUMENTS-FIELD), and%COMPLETION-POWERSHELL-COMMAND-OPTION-MAPnow builds each command's option-array literal once and reuses it across every alias instead of rebuilding it (re-quoting every token again) per alias. (Profiled all four remaining completion renderers this round: nushell and elvish are already tens of microseconds per call -- optimizing them further would be immeasurable -- so only PowerShell needed work.) Measured on a 330-option/33-command benchmark app: PowerShell rendering is roughly 1.3x faster. No public API or observable script output changed; seetests/cases-parser-benchmark.lisp. - The fish completion renderer (
RENDER-FISH-COMPLETION) no longer re-shell-quotes the same loop-invariant value on every iteration of a loop it's constant across: the app name, each level's "already seen a sibling command" condition, and each command's description are now quoted once and reused, instead of being re-quoted once per command alias, once per option candidate, or once percompleteline as before. (Profiling on a 330-option/33-command benchmark app after fixing the bash and zsh renderers, above, found fish was the next-most-expensive renderer, dominated almost entirely by this redundant re-quoting rather than the tree-copy pattern those two had; nushell and elvish were also profiled and are already fast enough -- tens of microseconds per call -- that optimizing them further would be immeasurable.) Measured: fish rendering on the same benchmark app is roughly 1.5x faster. No public API or observable script output changed; seetests/cases-parser-benchmark.lisp. - The bash and zsh completion renderers (
RENDER-BASH-COMPLETION,RENDER-ZSH-COMPLETION) write directly into one shared output stream instead of building a separate string per recursive subcommand node (each of which a parent then copied again viaWRITE-STRING) and per shell-quoted or array/case-label value (each of which was quoted into its own string, joined into a second string, then copied a third time into a parent's buffer). Profiling a 330-option/33-command benchmark app showed this quote-then-join-then-copy pattern accounting for the large majority of sampled render time in both renderers, dominated by repeatedWITH-OUTPUT-TO-STRINGsetup/copy overhead rather than the actual shell-quoting logic.%COMPLETION-SHELL-QUOTE's core loop is now also exposed as%COMPLETION-WRITE-SHELL-QUOTED, which writes straight to a caller-supplied stream, with shared%COMPLETION-WRITE-CASE-LABELS/%COMPLETION-WRITE-SPACE-JOINED-QUOTEDwriters built on top of it used by both renderers (%COMPLETION-BASH-ARRAY-LITERALand%COMPLETION-CASE-LABELS, now dead, were removed). Zsh's%COMPLETION-ZSH-ARGUMENTS-FIELD(used to build every_argumentsbracket/placeholder field) also folded its control-character-stripping and bracket-blanking into one pass instead of two nestedWITH-OUTPUT-TO-STRINGcalls, the same fix already applied to%COMPLETION-SHELL-QUOTE. Measured on the 330-option benchmark app: bash rendering is roughly 1.5x faster and zsh rendering roughly 1.5x faster than before this round of changes. No public API or observable script output changed; seetests/cases-parser-benchmark.lisp. PARSE-ARGVis dramatically faster, especially across repeated calls against the sameAPP(a REPL, a long-running server, or simply a hot loop).MAKE-APPnow precomputes and caches, once per app/command scope, the built-in-augmented option list, its name/alias lookup table, and the command-name/alias lookup table -- previously all three were rebuilt from scratch (including re-runningMAKE-OPTIONfor the built-in--help/--versionspecs) on every singlePARSE-ARGVcall. Three option- relationship validators (VALIDATE-CONDITIONAL-REQUIREMENTS,VALIDATE-REQUIRED-OPTION-GROUPS,VALIDATE-INCLUSIVE-GROUPS) now skip building their lookup tables entirely when the app declares none of the corresponding feature (conditional requirements / option groups), mirroringVALIDATE-OPTION-RELATIONSHIPS' existing early exit. The per-parse accumulating-option-value hash table is now allocated lazily, on the first value that actually needs it, instead of unconditionally on every call. Measured on a representative subcommand-dispatch benchmark: ~4.5x faster for repeated parses against one app, up to ~15x for a flag-only app with no subcommands.BUILT-IN-OPTION-SPECS-- also called directly by every help and completion renderer, not just the parser -- is cached the same way, so generating help text or a shell-completion script no longer rerunsMAKE-OPTIONfor the built-ins either. No public API or observable behavior changed; seetests/cases-parser-benchmark.lisp.CANONICAL-OPTION-NAME(the hot path for every long-option token during parsing) now strips a leading-/--and downcases the remainder in one pass instead of two separate allocations (SUBSEQthenSTRING-DOWNCASE). Shell-completion rendering is also faster on apps with many options:%COMPLETION-SHELL-QUOTE(used by the bash/zsh/fish renderers) now strips control characters and quote-escapes in a single pass rather than two nested string streams, and threeREMOVE-DUPLICATEScall sites switched from:TEST #'STRING=to:TEST #'EQUAL-- semantically identical for strings, but lets SBCL use its hash-table-based dedup instead of an O(n^2) pairwise scan. Measured on a 330-option benchmark app: shell-completion rendering is noticeably faster; no observable behavior changed.cl-clino longer depends oncl-prologat runtime. Option-relation validation (:requires,:requires-any-of,:conflicts-with) is now a plain in-memory adjacency graph (src/option-relations.lisp) with the same public behavior and error messages.cl-cli's only dependency is nowuiop;cl-prologremains a test-only dependency (used as an independent validation oracle in the test suite).- The test suite's real-shell verification (
tests/cases-shell-verification.lisp) now launches every subprocess (bash -n,zsh -n,fish --no-execute,mandoc -T lint, ...) throughcl-process-kitwith an explicit timeout instead of an unboundeduiop:run-programcall. A hung or misbehaving tool is now terminated (SIGTERM escalating to SIGKILL, in its own process group) rather than blocking the test run indefinitely; a regression test proves the timeout actually fires. Test-only; does not affectcl-cliitself, which never spawns subprocesses. - The man page and Markdown doc renderers (
%MANPAGE-OPTION-ENTRY,%MD-OPTION-TABLE) no longer rebuild a full%OPTION-TARGET-TABLEfrom scratch for every single option in their loop -- an O(n^2) hash-table- construction cost that dominated both renderers' profiles on a large app. Each loop now hoists one table build to the top and threads it through explicitly, mirroring the plain-text help renderer's existing pattern. Measured on a 330-option/33-command benchmark app:render-manpage~40% faster (682us -> 406us),render-markdown~50% faster (1073us -> 534us). No public API or observable output changed; seetests/cases-parser-benchmark.lisp. render-jsonwrites directly into a shared stream instead of each layer (%option->json,%command->json,%app->json, ...) building and returning its own string for its caller to copy again one level up -- the same allocation-cascade bug already fixed in the bash/zsh/PowerShell completion renderers, one level removed.render-json's public contract (a string when called without a stream, no return value when passed one) is unchanged. Measured on a 330-option/33-command benchmark app: ~3.7x faster (963us -> 263us per call), verified againstcl-json-kit's independent reader to confirm the output is still valid and semantically identical, not just byte-similar. Seetests/cases-parser-benchmark.lisp.MAKE-APP-time option-relation validation (:requires,:requires-any-of,:required-if,:required-unless,:conflicts-with) no longer rebuilds a full option target-table from scratch once per declared relation target, plus again independently in cycle detection and relation-graph construction -- the table is now built once perMAKE-APPcall and threaded through. Measured on a relation-heavy 100-option benchmark app:MAKE-APP~36% faster (4.822ms -> 3.109ms). Apps declaring no option relations at all also skip relation-graph construction entirely now, rather than building an empty one: ~27% fasterMAKE-APPon a 330-option/33-command app with no relations declared (1.755ms -> 1.288ms). No public API or observable behavior changed; seetests/cases-parser-benchmark.lisp.PARSE-ARGV's four option-relation validators (VALIDATE-OPTION- RELATIONSHIPS,VALIDATE-REQUIRED-OPTION-GROUPS,VALIDATE-INCLUSIVE- GROUPS,VALIDATE-CONDITIONAL-REQUIREMENTS) no longer each independently rebuild their own key/target lookup tables from the validated-specs list on every parse; the tables are now cached once per app/command scope (alongside the existing relation-graph cache) and threaded through. Measured on an app combining multiple relation kinds on overlapping options:PARSE-ARGV~37% faster (2.862us -> 1.801us per call). No public API or observable behavior changed; seetests/cases-parser-benchmark.lisp.
0.2.0 - 2026-07-20¶
Added¶
- Dynamic (runtime) completion now works in all six generated shells. Previously
only bash, zsh, and fish shelled out to the
__completecallback for a:completeoption; PowerShell, nushell, and elvish offered only a static candidate pool. PowerShell now inspects the token before the cursor, elvish branches on the previous word, and nushell attaches a per-flag custom completer — each invokingapp __complete KEY <partial>. The generated scripts were verified against realpwsh,nu, andelvish. - Terminal-aware help:
print-app-help,print-command-help, andrun-appnow accept:color :autoand:width :auto.:color :autohonorsNO_COLORandCLICOLOR_FORCEand otherwise enables ANSI styling only when the target stream is a terminal (isatty on SBCL; environment-only elsewhere);:width :autoreads$COLUMNS. Explicitt/nil/ integer values still force the decision, and the defaults remainnilso existing behavior is unchanged. option-value-sourcereports the provenance of an option value —:command-line,:env,:config, or:default(ornilwhen unset) — the analogue of clap'sArgMatches::value_source, so a handler can tell an explicit choice apart from a fallback.invocation-option-sourcesexposes the raw map.- Required options are now spelled out in the one-line usage synopsis (for
example
Usage: demo run [options] --config <FILE>) instead of being hidden inside the[options]catch-all; non-required and hidden options stay in the catch-all. run-appaccepts:usage-exit-codeand:error-exit-codeto override the exit codes returned for usage errors (default64,EX_USAGE) and other unhandled errors (default70,EX_SOFTWARE).- Dynamic (runtime) completion: an option or positional may carry a
:completefunction(lambda (partial) => candidates). Addmake-complete-command(ormake-standard-commands :include-dynamic-p t) and the generated bash, zsh, and fish completions call back into the program (app __complete KEY PARTIAL) at completion time to offer runtime candidates — no re-parsing of the command line. A candidate may be a plain value or a(value . description)cons (emitted tab-separated; fish shows the description).render-complete-replyexposes the same lookup directly, andoption-completereads the function. - Bash, zsh, and fish completion now complete nested subcommands at every level
of a command tree (
app remote <TAB>offersadd/remove), with each command's options — and its ancestors' — completing once that command is on the line. All three were verified by exercising the generated scripts under the real shells, not just syntax-checking them. - The built-in
completionanddocscommands now carry:completion-candidates, so generated shell completion suggests the shell names (demo completion <TAB>) and documentation formats (demo docs <TAB>) while the parsers still accept aliases (pwsh,nu,md,roff). :value-hint(:file/:dir) onmake-option(value-bearing) andmake-positional: the generated bash, zsh, and fish completions offer file or directory path completion at the hinted slot (compgen -f/-d,_files/_files -/, the fish directory completer). Surfaced in help ("expects a file/directory") and JSON. Readeroption-value-hint.- Positional value completion:
make-positionalnow accepts:completion-candidates(like options), and all six shell completers (bash, zsh, fish, powershell, nushell, elvish) offer a positional's:choices/:completion-candidatesvalues at its argument slot. Positional candidates also appear in the JSON schema. - Opt-in help description word-wrapping:
print-app-help,print-command-help, andrun-appaccept:width Nto word-wrap option/command/positional descriptions to column N, with continuation lines aligned under the description gutter. No terminal-width detection; off by default. :auto-helponmake-app(defaultt): passnilto suppress the built-in-h/--helpflag for a CLI that manages its own help or forwards--helpto a wrapped tool. Ahelpcommand added viamake-standard-commandsis unaffected. Readerapp-auto-help.- Greedy variadic
:value-counton a:valueoption::+(one or more) and:*(zero or more) consume every following token up to the next option-like token (--files a b c), storing the list.:+requires at least one value. Help shows<NAME>...and JSON emits"+"/"*". :see-also,:authors, and:manual-dateonmake-app: rendered as the SEE ALSO / AUTHORS sections and the.THdate ofrender-manpage, and exposed inrender-json(the first two).render-manpagealso emits standard EXIT STATUS (0 / 64 / 70) and ENVIRONMENT (env-backed options) sections, and the generated page passesmandoc -T lintcleanly. Readersapp-see-also,app-authors,app-manual-date.:required-if/:required-unlessonmake-option: conditional requirements.:required-ifmakes an option mandatory when any listed option is present;:required-unlessmakes it mandatory unless any listed option is present. Both signalcli-missing-option-value, are hidden-target safe, and render in help. Readersoption-required-if,option-required-unless.:require-commandonmake-app: parsing fails withcli-unknown-command(listing the available commands) unless a subcommand is dispatched, expressing a "subcommand mandatory" contract. Readerapp-require-command.- Invalid
:choicesvalues (for options and positionals) now append a nearest-match "Did you mean: ...?" suggestion to the error, reusing the same suggestion machinery as unknown options and commands. :value-count Non a:valueoption: consume exactly N following tokens as a parsed list (--point 1 2=>(1 2)); too few remaining tokens signalcli-missing-option-value, and with:multiple-peach occurrence contributes its own N-element list. Readeroption-value-count.:kind :key-value: each occurrence parseskey=value(a barekeyrecords valuet) and accumulates the pairs into an alist, so-D a=1 -D b=2reads as(("a" . "1") ("b" . "2"))— the compiler-define / docker-env shape.inclusive-group: an all-or-none option group (if any member is supplied, all must be), the complement ofexclusive-group; rendered as "all or none of" in help and signallingcli-missing-dependent-optionon a partial set.:allow-negative-numbersonmake-app: keeps a token that looks like a negative number (-5,-1.5) from being parsed as a short-option cluster, so it can serve as a positional or option value. Readerapp-allow-negative-numbers.:grouponmake-option: a help-section label that groups related options under their own heading in help output (mirroring a command's:group) and is exposed in JSON. Readeroption-help-group.- Response-file expansion:
make-app :expand-response-files texpands a@pathargument into the whitespace-separated arguments read from that file before parsing (recursively;@@escapes a literal leading@; a missing file is a usage error), mirroring the gcc/clap convention. Readerapp-expand-response-files. :min-count/:max-counton a restmake-positional: constrain how many values it collects (too few signalscli-missing-positional, too manycli-unexpected-argument); shown in help and JSON.:default-commandonmake-command: a command with:subcommandscan name a default subcommand dispatched when no subcommand token is present, mirroring the app-level:default-command.- Opt-in colored help:
print-app-help,print-command-help, andrun-appaccept:color tto wrap headings and names in ANSI styling. There is no automatic terminal detection, so callers stay in control; off by default. - Nested subcommands:
make-commandaccepts:subcommands(a list of command specs), so a command dispatches like a mini-app (git remote add). The next non-option token selects a subcommand; the parent command's options stay available to the whole subtree and global counters accumulate across the path. Parsing, help (path-qualified usage plus a subcommand list), the man/Markdown/ JSON renderers, andrun-appdispatch all recurse to arbitrary depth. The newinvocation-command-pathaccessor returns the full root-to-leaf chain. :choicesonmake-positional: restricts a positional to a closed set, validated before the parser runs (cli-invalid-positional-valueon mismatch) and shown in help and JSON output, matching option:choices.:help-footeronmake-command: trailing help/epilog prose for a command, mirroring the app-level:help-footerand falling back to it; reflected in the man/Markdown/JSON renderers. Readercommand-help-footer.:allow-abbreviated-optionsonmake-app: opt-in GNU-style unambiguous long-option prefix matching (--verbfor--verbose); an ambiguous prefix signalscli-unknown-optionlisting the matches. Off by default to preserve strict exact-match parsing. Readerapp-allow-abbreviated-options.render-elvish-completionandelvishsupport inrender-completionand thecompletioncommand, bringing built-in completion coverage to six shells (bash, zsh, fish, powershell, nushell, elvish).:configonparse-argvandrun-app: an optional plist ofoption-key -> valuethat supplies option defaults, slotting into the precedence chain below CLI arguments and environment variables but above a literal:default. Values are coerced like defaults (a string runs through the option parser, a list is spread, a delimited option splits a string), so a caller can layer in configuration loaded from a file without forking the parser.:deprecatedonmake-optionandmake-command(tor a reason string): the entity stays visible in help and completion but is annotated as deprecated in help, man, Markdown, and JSON output; dispatching a deprecated command makesrun-appprint a warning to stderr. Readersoption-deprecatedandcommand-deprecated.:value-delimiteronmake-option: a single-character delimiter that makes a:valueoption split one occurrence into a list (--tags a,b,c=>("a" "b" "c")), parsing each piece (honoring:type) and accumulating across occurrences. Empty pieces are dropped, and string/list:defaults and environment values are split the same way.render-nushell-completionandnushell/nusupport inrender-completionand thecompletioncommand: a Nushell module with anexport externcovering subcommands and global option flags.render-json: emits the app's declared spec (options, positionals, commands, types, ranges, delimiters, defaults) as a machine-readable JSON object for external tooling. Also available as thejsonformat of thedocscommand.- Typed values on
make-optionandmake-positional::typeselects a built-in value parser (:integer,:number,:float,:boolean, or the default:string), and:min/:maxadd inclusive bounds for numeric types. This replaces most hand-written:parserlambdas for common domain validation. The chosen type and range appear in help metadata, and both are validated atmake-*time (:typeand:parserare mutually exclusive, bounds require a numeric type, and:minmust not exceed:max). Numeric parsing binds*read-eval*off so a value can never execute reader code. :kind :countonmake-option: a repeatable counter option where each occurrence increments an integer (-vvv=> 3, or a repeated--verbose), defaulting to 0. Ideal for verbosity flags.render-manpage: generates a section-1 man page (roff) from an app spec, covering NAME, SYNOPSIS, DESCRIPTION, OPTIONS, ARGUMENTS, COMMANDS, and EXAMPLES, and honoring hidden options/commands.render-markdown: generates GitHub-flavored Markdown reference documentation (title, usage block, option/argument tables, per-command sections, examples).make-docs-commandandrender-docs: a built-indocs [FORMAT]command (parallel tocompletion [SHELL]) that prints the generated man page or Markdown to stdout;make-standard-commandsgains:include-docs-p.render-powershell-completionandpowershell/pwshsupport inrender-completionand thecompletioncommand: aRegister-ArgumentCompleter -Nativescript that offers subcommands and option tokens, narrowing to a subcommand's own options once it appears.- Option readers
option-value-type,option-value-min, andoption-value-maxfor the new typed-value metadata. :requires-any-ofonmake-option: declares that at least one of a set of alternative options must also be supplied (unlike:requires, which demands all of them). Signals the newcli-missing-any-of-optionscondition and renders asrequires one of: ...in help.make-apprejects a:requires-any-ofwhose every alternative conflicts with the option itself, since such an option could never be validly supplied.- Contributor, support, and release-process documentation.
Changed¶
- Completion renderers (
render-completion,render-bash-completion,render-zsh-completion,render-fish-completion) now return the generated script as a string when called without a stream, mirroring theformatdestination contract; passing a stream still writes to it and returns no values. Previously the no-stream form wrote to*standard-output*and returned no values, so there was no way to obtain the script as a string through the public API. - Expanded package metadata for ASDF consumers and package indexes.
- Documented the
cl-prologgit-dependency install path, sincecl-cliis not yet resolvable through a bare Quicklispquickload. - Option
:requires/:conflicts-withvalidation now reuses the Prolog rulebase built once atmake-apptime instead of rebuilding and re-querying it on everyparse-argvcall. - Option resolution, relation validation, and completion metadata now build the option and target lookup once as a hash table instead of scanning the option list linearly on every access; the dynamic-completion index and subcommand lookup are likewise cached per app, and repeated-option value accumulation is now O(1) per token rather than O(n²).
Fixed¶
- Fish completion never offered top-level command names: they were emitted with
a
__fish_seen_subcommand_from <command>guard (true only after the command was typed) instead of__fish_use_subcommand, soapp <TAB>completed nothing. Confirmed and fixed via behavioralfish -c 'complete -C ...'testing (syntax-only checks could not catch it). - Generated bash completion never completed a separated option value
(
--mode <TAB>): thecase "$prev"scan setexpect_value/value_sourcebut nothing turned them intoCOMPREPLY. The completer now consumes them and uses_init_completion -sso an attached value (--mode=<TAB>) completes through the same path. Verified against the realbash-completionlibrary. - Fish completion offered file paths alongside an option's closed
:choicesvalue set; choice/candidate value options are now-f(exclusive) so only the declared values are suggested. - An option's
:value-hintnever drove shell completion (only positionals did): bash now completes files for a plain /:file-hint value option (viacomplete -o default) and directories for a:dirhint (compgen -d), fish completes directories for a:dir-hint option, and zsh completes files/dirs for a:file/:dir-hint option (_files/_files -/). - Generated bash completion never completed a subcommand's options: the root
-*fallback returned the global options and stopped before any command case ran, and the first-word command completion pre-emptedapp -<TAB>. Both are now guarded, so subcommand (and nested-subcommand) option completion works. Found via behavioral testing (sourcing the script and inspectingCOMPREPLY). - Generated bash completion emitted an empty
caselabel (a bash syntax error) for a:valueoption that had no:choices/:completion-candidates; such an option now falls through to the shell's default file completion. The bash, zsh, and fish generators are now checked with realbash -n/zsh -n/fish --no-executein the test suite's manual verification. - Bash completion never offered attached-value candidates (
--option=value) for command-scoped options. - A short-option cluster silently dropped characters that followed a
mid-cluster
:stop-parsing-pflag or boolean instead of surfacing them as positional input. - An
:optional-valueoption configured with:consume-optional-value-p tcould not accept a separated bare-(the stdin/stdout idiom) as its value. make-appno longer accepts a positional sequence with a required positional after an optional one, since such a spec could never assign the later positional correctly.make-appnow rejects two options whose keys collide (e.g. case-differing single-character names like-a/-A) instead of letting them silently share one storage slot.make-appnow rejects a user-declared option that reuses the reserved:help/:versionkey, which previously could hijack CLI dispatch into the help/version action.extract-application-argv/application-argvno longer strip a literal application argument that happens to match a runtime marker when it appears after the--separator.make-option/make-positionalnow validate identifier safety before interning the option/positional key, instead of after.- A relation rulebase cached on a shared, reused
command-spec(per the library's own "reusable command spec" pattern) could be silently overwritten by a second, unrelatedmake-appcall that spliced in the same command object, corrupting validation for the first app. The cache now lives per-app, keyed by command object, instead of being stored on the shared command struct. - Corrected broken absolute-path documentation links and gave
SECURITY.mda concrete private vulnerability-reporting path. - Generated shell completion now emits candidates through a more robust
protocol: bash builds
COMPREPLYas an array with prefix filtering, dynamic completion reads tab-delimitedvalue<TAB>descriptionrecords withwhile read(instead ofcut -f1, which mangled values containing tabs), and PowerShell matches the current word with an ordinalStartsWith.
Security¶
- Rendered output no longer trusts declared spec metadata verbatim: help, usage, version, and deprecation text, generated shell completions, and generated man-page / Markdown documentation now strip terminal control characters (and, for Markdown, escape markup) from option names, descriptions, and values, so hostile metadata can no longer inject ANSI escape or other terminal control sequences into a user's terminal. Spec construction additionally rejects control characters in option value names.