Releases: toiroakr/politty
Release list
v0.11.3
Patch Changes
- bc7bcfc: Add
SkillCommandOptions.descriptionsto override the description text of theskillscommand and each of its built-in subcommands (sync/add/remove/list), letting a host CLI brand the skills command without re-wrapping the command tree by hand. Keys refer to the subcommand's canonical role, independent of anycommandMaprename —descriptions.addstill applies aftercommandMap.addrenames the subcommand to something else. Omittingdescriptions(or any of its keys) preserves politty's existing default text exactly.
v0.11.2
Patch Changes
-
21e5dce: Add
args.$source(name)to expose whether a resolved arg value came from an explicit CLI token ("cli"), afield.envfallback ("env"), or neither ("default"). This lets a command'srun()handler distinguish an explicitly-typed value from an environment-variable fallback without re-deriving flag spellings from the schema, and works correctly forpositionalfields even when the typed value happens to equal the env var.$sourcecorrectly resolves both camelCase and kebab-case field name lookups, and correctly reports"default"for a local field that collides with a same-named global field but is resolved via the local schema's own default.$source's parameter is typed as a plainstringrather than a schema-derived key, since a stricter type broke type-checking for the documented discriminated-unionargspattern.Field names starting with
"$"are now rejected at command-definition time (ReservedFieldNameError), since that prefix is reserved for framework-injected helpers like$sourceand is unusable as a real CLI flag anyway (an unquoted$namegets shell-expanded before the program sees it). -
c199535: Fix global/local arg collisions on a shared field name. Two changes:
- Command definitions where a global field and a same-named local field have different definitions (different type bucket, positional vs. flag, or different enum values) now throw
FieldTypeConflictErrorat validation time — previously this silently passed even though the two fields didn't actually agree on what values are valid. - When the definitions are identical, a flag shared between
globalArgsand a command's own schema now resolves correctly regardless of where it's typed — including when the command's own field is required and has no default. Previously, typing the flag before the subcommand parsed it correctly as the global value, but the command's own same-named field — having received nothing of its own — would either fail local validation (if required) or unconditionally get overwritten by its own default during the final args merge, discarding what the user actually typed. Typing the same flag after the subcommand already worked correctly and is unaffected.
Also fixes an unrelated bug found while implementing the above: a
promptresolver returning{ field: undefined }for a field it chose not to prompt for could previously clobber a real CLI/env value already provided for that field.Two smaller follow-ups to the same-name conflict detection:
validateCommand()now accepts aglobalArgsoption and checks every command and subcommand in the tree against it, so aFieldTypeConflictError/case-variant collision on a rarely-invoked subcommand can be caught upfront instead of only at the moment that subcommand actually gets parsed.- The same-named field comparison now also considers whether the field is positional, so a global flag and a same-named local positional argument are correctly treated as conflicting rather than as identical.
- Command definitions where a global field and a same-named local field have different definitions (different type bucket, positional vs. flag, or different enum values) now throw
-
48d54d9: Improve
withSkillCommand's type safety and customizability for host CLIs:withSkillCommand's return type now reflects the injectedskillssubcommand (subCommands.skillsis typed asAnyCommandand no longer optional), so consumers no longer need anas AnyCommandcast to access it.SkillCommandOptions.globalArgsaccepts the same schema passed torunMain/runCommand'sglobalArgs. When it already declares a same-named non-positional booleanverbose/jsonfield,skills add/skills sync's--verboseandskills list's--jsonare automatically omitted from their own schema, so the host's global flag of the same name takes priority — no manual configuration needed. (A same-named non-boolean field doesn't trigger this; politty itself rejects that combination withFieldTypeConflictErrorat parse time, so such a field must be renamed on one side instead.)SkillFlagOverrides.verbose.aliasrenames or disablesskills add/skills sync --verbose's short alias — a collision independent ofglobalArgs's field-name auto-detection (e.g. the host's-vbelongs to an unrelated flag, not one namedverbose).SkillCommandOptions.commandMaplets a host CLI renameskills add/skills removeand control their aliases:{ add?: string[]; remove?: string[] }, where the first array element becomes the subcommand's dispatched name and the rest become aliases. Default:add: ["add", "install"],remove: ["remove", "uninstall"].withSkillCommandthrows if any resulting name or alias collides withsync/listor with each other, or isn't a safe token (empty string, leading dash, whitespace, etc.).SkillCommandOptions.unknownKeys("strict"|"strip"|"passthrough", default"strip") controls unknown-flag handling foradd/sync/remove/list's own schemas uniformly — set"strict"to match a host CLI that usesz.strictObject()throughout."passthrough"matchesz.object().passthrough(): no warning, but (unlike"strip") the flag's value is kept on the parsed args under its raw CLI name instead of dropped. Never affects values that legitimately arrive viaglobalArgs.
v0.11.1
Patch Changes
-
cdab697: Fix two argv-parsing bugs that silently produced wrong values instead of erroring:
- An option expecting a value (e.g.
z.coerce.number()) followed by a negative-number-looking token, such as--count -5, no longer treats the flag as booleantrueand mis-parses the following token as combined short flags. The token is now consumed as the option's value. Other dash-prefixed tokens (--, or another flag like--verbose) are left alone so they aren't silently swallowed as a literal value. --flag=true/--flag=falsenow correctly coerce to booleans forz.boolean()-typed fields instead of failing validation with "expected boolean, received string".
- An option expecting a value (e.g.
-
c69cee6: Fix the internal-subcommand bypass in
runMainincorrectly matching prototype-inherited property names such as__proto__,__defineGetter__, or__lookupGetter__. Previously, invoking a CLI with one of these as the first positional (e.g.mycli __proto__) would silently skip the user-providedsetup/cleanup/prompthooks even though no such subcommand was ever registered, because the lookup read throughObject.prototypeinstead of checking for an own property. -
fd67602: Fix
engines.nodeto accurately reflect the runtime requirement. The package usesnode:util'sstyleText, which was added in Node 20.12.0 / 21.7.0; the previous>=18declaration allowed installs that crashed on import withSyntaxError: The requested module 'node:util' does not provide an export named 'styleText'. The build target was also updated fromnode18tonode20.12to match. -
6515894: Fix subcommand resolution (
resolveSubcommand,resolveSubcommandWithAlias, and the shell-completion context parser) incorrectly matching prototype-inherited property names such as__proto__orconstructoras if they were registered subcommands. This follows up on the same class of bug fixed inrunMain's internal-subcommand bypass, applying theObject.hasOwnguard to the remaining lookups that read throughObject.prototype. -
df62418: Add the missing MIT LICENSE file (the package has declared
"license": "MIT"without shipping the license text) and set the previously emptyauthorfield inpackage.json. Also remove the stalepackage-lock.jsonthat had been accidentally committed to this pnpm-managed project, and pin thepkg-pr-newpreview-publish tool to an exact version instead of runningpnpm dlx's latest unconditionally in apull-requests: writejob.
v0.11.0
Minor Changes
-
50013d2: Disable default boolean negation unless
negation: trueis set.Boolean options no longer accept
--no-<name>or--no<Name>by default. Setnegation: trueto enable and advertise the default negation form, setnegationto a string to use a custom negation name, or leave it unset / set it tofalseto reject default negation.
v0.10.1
Patch Changes
- b4b1dd0:
onUnknownSubcommandis no longer invoked for a command that defines its ownrun. Such a command's first positional is a real argument, so an installed<cli>-<name>plugin must never shadow it — which previously could make the command's meaning depend on what was on PATH. Plugin dispatch now only happens for pure subcommand-group commands (norun).
v0.10.0
Minor Changes
-
b45b891: Positional tokens not consumed by the schema are now surfaced rather than silently ignored.
For commands with subcommands, any unconsumed bare token that is not a known subcommand name is treated as an unknown subcommand attempt and exits with code 1 (with a did-you-mean suggestion when a similar name exists). Tokens after
--, dash-prefixed tokens, and tokens that match a known subcommand name are excluded from this check — they fall through to theunknownKeysModepositional handling instead.For commands without subcommands, behaviour follows the schema's
unknownKeysMode:strict(z.strictObject/.strict()): exits with code 1strip/ default (z.object): emits a warning and continuespassthrough(z.looseObject/.passthrough()): silently ignores (no change)
Schema-less commands (no
argsdefined) now correctly capture all tokens — including flag-like ones such as-x— as positionals so stray tokens are still detected.
Patch Changes
-
ebbd553: Extract shared
resolveLongOption()for long-option resolution, eliminating duplicated negation rules between argv-parser and subcommand-scanner. -
0429277: Unify the options-rendering paths in
src/docsthrough a shared(rows × columns)intermediate (toOptionRows+emitMarkdownTable/emitMarkdownList). The markdown table and list renderers, their*FromArrayvariants, andrenderArgsTable's column-filtered path now share one place where per-option display decisions (negation handling, alias ordering, placeholder resolution) live.Rendered output is unchanged except for one cosmetic detail:
renderArgsTable(args, { columns })now emits the canonical fixed-width table separator (|--------|...) instead of header-length, space-padded dashes (| ------ | ... |). The two forms render identically as Markdown.
v0.9.2
Patch Changes
- 3d338d5: Show subcommands as
<command>(required) when the parent command has norunhandler, and[command](optional) when it does
v0.9.1
Patch Changes
- 860dbe2:
skills addnow acceptsinstallandskills removeacceptsuninstallas aliases, matching the verbs most package-manager-trained users reach for first. Both spellings dispatch to the same command, so existing invocations continue to work; help output lists the aliases under each command. - b631ccd: Address Copilot review feedback on
politty/skillscanSourceDirnow wraps the source directory'sstatSyncin try/catch instead of guarding withexistsSync. Permission/IO errors (EACCES/EPERM) on the source directory are surfaced asread-failedScanErrors with the original error message, where previously they were silently misclassified asmissing-source.assertSafeNamein the installer now also rejects names longer than 64 characters, matching the frontmatter schema's documented 1..64 length constraint. The check stays a deliberately independent (defense-in-depth) duplicate of the schema rather than a shared import.skills list --jsonno longer interleaves scan-error summary lines into stdout. The machine-readable JSON payload stays the only thing on stdout in--jsonmode; per-error stderr warnings still fire so operators can see what was skipped. Previously a malformed source SKILL.md could corrupt the JSON output.listStatusnow reservesmissingfor dangling canonical symlinks (the only stateremoveOwnedSkill's cleanup path can actually clean up). A real directory at.agents/skills/<name>without a readable SKILL.md is now reported asunstamped, which routes it through the no-clobber guard instead of incorrectly promising the slot can be reaped.installSkillnow refuses up-front when the source path overlaps any install destination — the canonical.agents/skills/<name>slot or anySYMLINK_TARGETSagent slot (e.g..claude/skills/<name>) — in bothmode: "copy"andmode: "symlink". Previously only copy-mode overlap with the canonical slot was checked: a source sitting at an agent slot survived the canonical check and then got rm-rf'd bypopulateAgentDirs's ownclearInstallSlotonce the stamp matched, taking the source data with it (and in symlink mode creating a canonical↔agent symlink loop). The copy-mode case where the destination ended up inside the source — recursing until the path/disk limit was hit because the cyclic-symlink detector does not catch it (no symlink involved) — is covered by the same guard.- The overlap-guard helper
pathsOverlapis now boundary-aware: only..and..<sep>...count as "escaping" the outer path. The previousstartsWith("..")test misclassified valid same-level siblings whose names happened to start with..(e.g. a source at.agents/skills/<name>/..backup) as outside, so the guard missed real overlaps andclearInstallSlotwould have rm-rf'd the canonical slot while the source was nested inside it. findOwnedInstalledSkillsnow distinguishes spec-incompatible legacy names from IO errors when reading an installed skill's ownership. Previously thetry { readInstalledOwnership } catch { continue }swallowed every throw, so EACCES/EPERM on an installedSKILL.mdleft an unreadable orphan in place whileskills syncreported success. Names that can't have been produced by this CLI (failing the spec pattern or the 1..64 length) are now filtered out by a local defense-in-depth duplicate of the schema before the call; the catch path is reserved for real IO failures and emits a stderr warning per affected slot.logScanErrorsnow treatsread-failederrors whosepath === sourceDiras directory-level failures (matchingscanFailedAtRoot). AreaddirSync(sourceDir)failure such as EACCES previously rendered as "Skipping skill at " with no stdout summary, masking a broken source directory as a single malformed skill.skills remove(no-argument path) now branches onscanFailedAtRootbefore printing its no-op summary, matchingadd/list/sync. A missing or unreadablesourceDirpreviously rendered as "No skills found in source directory; nothing to remove." — a legitimate-empty-bundle message that contradicted the directory-level warningloadSkillsalready emitted.removeInstalledSlotandclearInstallSlotnow only swallow ENOENT/ENOTDIR from the slot'slstatSync; other failures (EACCES/EPERM/IO) propagate. Previously a permissions or IO failure on the slot was silently treated as "nothing to do", souninstallSkill/installSkillwould report success while leaving the slot intact — and a subsequent install would then fail the no-clobber guard with no actionable context.findOwnedInstalledSkillsnow only treats ENOENT/ENOTDIR on.agents/skillsas a legitimate "no installed skills" result. OtherreaddirSyncfailures (EACCES/EPERM/IO) are warned to stderr — previously every throw was swallowed, so a permission failure on the install root madesync's orphan reconciliation and--excludevalidation silently skip the installed-skill universe, masking a permission problem as success.- The no-clobber guard in
installSkill's caller (addSkill) now uses slot presence + non-dangling-symlink instead ofhasInstalledSkill, and mirrors the route-to-source check used bycleanupBrokenSlot/findOwnedInstalledSkillswhen classifying a dangling canonical as reapable. Previously a live symlink at.agents/skills/<name>whose target lacks aSKILL.md(foreign / partial install) passed the guard (becausehasInstalledSkillresolves the symlink and finds no file) and was silently unlinked byclearInstallSlot, even thoughlistStatusalready classifies the same state asunstamped. The route check additionally protects the dangling case in the shared.agents/skills/namespace: a dangling canonical pointing at another politty-based CLI's (now-uninstalled) source dir is no longer treated as "almost certainly ours" — only dangling symlinks that still route into this CLI'ssourceDirare reaped, matching the rule the other two callers already enforce. The three paths now agree. - Re-synced
package-lock.jsonwithpackage.jsonso npm-based installs (npm ci) can resolve the runtimeyamldependency. The lockfile had been left untouched sinceyamlwas promoted to a top-level dependency, so anyone installing the package via npm would have ended up without a critical scanner dependency. pnpm-lock.yaml was already in sync. removeInstalledSlotandclearInstallSlotnow refuse to unlink a foreign symlink at an agent-specific slot (.claude/skills/<name>). The two functions accept arestrictSymlinkTooption that the agent-slot call sites set to the canonical slot, and unlinking proceeds only when the existing symlink routes there. Previously, a symlink another tool installed at the same agent path was silently unlinked:uninstallSkilldid so duringskills remove/sync, andpopulateAgentDirs'sclearInstallSlotdid so duringskills add/sync. Stamped canonical slots stay under unconditional ownership (the stamp resolves the ambiguity); the unstamped/dangling cases are handled by the dangling-route guard below.cleanupBrokenSlot(the dangling-symlink reaper invoked byskills remove/syncwhen our canonical is broken) now applies the same route-to-our-canonical check to each agent-slot symlink before unlinking. Previously the sweep treated any dangling agent-slot symlink with the same skill name as ours; a foreign tool's dangling symlink pointing at its own canonical (in the shared.claude/skills/<name>namespace) would have been silently deleted.cleanupBrokenSlotandfindOwnedInstalledSkillsnow confirm a dangling canonical symlink still routes into this CLI'ssourceDirbefore treating it as ours..agents/skills/<name>is a namespace shared by every politty-based CLI (each writes its own ownership stamp); a dangling canonical has no stamp to read, so without the routing check we would silently reap a foreign CLI's stale install duringskills remove/sync(and include it in--excludevalidation). The lexical containment check resolves the deepest existing ancestor of the (dangling) target throughrealpathSyncso it survives macOS/tmp→/private/tmpremapping, projects mounted via a symlink, and pnpm-stylenode_moduleshops. Live canonical symlinks still go through the ownership-stamp path.removeInstalledSlotnow verifies the canonical-slot symlink's routed-to SKILL.md carriesexpectedStampbefore unlinking, matching the route-check that already gates agent-specific slots. Previously a programmaticuninstallSkill(name, cwd, { expectedOwnership })would unconditionally unlink a live canonical symlink, so another politty-based CLI's live install in the shared.agents/skills/<name>namespace could be deleted.expectedStamp === nullkeeps the legacy permissive behaviour for teardown helpers that opt out of ownership checks.docs/skill-management.mdno longer claimsskills remove(no-argument form) cleans up every skill owned by the CLI. The implementation iterates only skills currently discovered insourceDir, so owned orphans (skills the CLI previously installed but no longer ships) survive a bareremove— they requireskills syncor naming them explicitly. The previous wording invited users to expectremoveto behave likesync's orphan reconciliation.installSkillnow runs the source-vs-destination overlap guard beforemkdirSync(canonicalParent)and eachSYMLINK_TARGETSparent'smkdirSync. Previously a failing overlap install (typically a single-skill source rooted at the project directory, sosourcePath⊇cwd) still left freshly-created.agents/skills/and<agent>/skills/directories inside the source tree before throwing. The pre-check uses a deepest-existing-ancestor + lexical-tail resolution so the comparison still catches/tmp↔/private/tmprealpath remaps when the destination parents don't exis...
v0.9.0
Minor Changes
- b7c8ebc: Reduced installation size (2.9MB to approx. 630KB, approx. 78% reduction)
- Excluded source maps (.map) from the distribution
- Explicitly excluded build artifacts such as build cache (tsconfig.tsbuildinfo) using the files field
- BREAKING: Discontinued CJS distribution and changed to ESM-only (loading via require() is no longer possible, removed .cjs and index.d.cts)
Patch Changes
- dfd1241: Removed the
string-widthruntime dependency- Replaced it with a lightweight built-in implementation (using Node's
stripVTControlCharactersfor ANSI stripping) used by the Markdown renderer polittynow has zero runtime dependencies
- Replaced it with a lightweight built-in implementation (using Node's
v0.8.0
Minor Changes
- 16b8503: Make
files-mode documentation fully generated (marker-free) by default and resolve template links by specificity.- Fully generated
filesoutput by default (breaking).files-mode generation no longer emits<!-- politty:...:start/end -->markers; each file is regenerated as a whole. WithtargetCommands, only files containing a target command are processed, but each is rebuilt in full. Set the newcustomizable: trueoption onGenerateDocConfigwhen you want to hand-edit the output and have politty preserve your edits via markers (in-place section updates). Whencustomizableis set, a command whose generated output gains a section the file lacks is reported as a non-fatal warning (run withPOLITTY_DOCS_DOCTOR=true POLITTY_DOCS_UPDATE=trueto insert it, or leave it removed to opt the section out).path/rootDocoutput still uses markers;templatesremain marker-free. - Specificity-based link resolution. Cross-output links now point at the output that renders a command most specifically — a dedicated per-command page (
{{politty:command:config}}) wins over a full-tree page ({{politty:command}}) for that command and its descendants, regardless of registration order. - Adds a
markerlessoption toDefaultRendererOptions.
- Fully generated
Patch Changes
- eea3f6a: Add template-based documentation generation. A new
templatesoption onGenerateDocConfigmaps output paths to template files containing{{politty:...}}placeholders; the output is fully generated from the template and contains no politty markers. Templates can exclude specific placeholders withpolitty.excludefront matter.