Skip to content

feat(spec): add effect= to declare what a command does to the world#739

Merged
jdx merged 3 commits into
mainfrom
feat/cmd-effect
Jul 25, 2026
Merged

feat(spec): add effect= to declare what a command does to the world#739
jdx merged 3 commits into
mainfrom
feat/cmd-effect

Conversation

@jdx

@jdx jdx commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Lets a spec declare what running a command does:

cmd "ls"        effect="read"        help="List installed tools"
cmd "use"       effect="write"       help="Install a tool and add it to the config"
cmd "uninstall" effect="destructive" help="Remove a tool"
Effect Meaning
read Only inspects state. Running it twice is the same as running it once.
write Creates or modifies state, but removes nothing the user cannot recreate.
destructive May delete or irreversibly overwrite something. Deserves a confirmation prompt.

Why in the spec

Several consumers keep reinventing this same distinction, and each one hand-maintains its own list:

  • docs and --help want to mark destructive commands
  • wrapper scripts want to confirm before running one
  • AI coding agents want an allowlist of read-only commands rather than prompting on every invocation — the Agent Skills spec has an allowed-tools field that is exactly this list, currently written by hand

That is the usual argument for putting something in a spec: one annotation, many renderings, same as help or hide today. It's structured data, so there's nothing to keep in sync and no prose to go stale.

Design notes

Not inherited by subcommands. git remote and git remote remove do different things. Inheriting would make the least safe reading of a spec the default one, which is the wrong direction for a field whose whole purpose is flagging danger.

Unset means unknown, not safe. Consumers should treat a missing effect as "ask". This keeps the field additive — existing specs are unaffected, and a partially annotated spec degrades to today's behavior.

Coarse on purpose. Three values, not a capability model. Anything finer becomes a policy language, and that belongs in the consumer.

Changes

  • SpecCommandEffect enum in lib/src/spec/effect.rs, exported as usage::SpecCommandEffect
  • SpecCommand::effect: Option<SpecCommandEffect>, plus a builder method
  • parsed as a prop (effect="read") or a child node (effect "read") — the latter so framework integrations generating KDL don't have to emit props; unknown values are a parse error pointing at the span
  • round-trips through KDL serialization
  • generated markdown renders an - **Effect**: line next to Usage and Aliases

Not in this PR

  • usage generate skill consuming it to emit allowed-tools. That's the payoff, but it's a separate discussion about output format and shouldn't ride along with the spec primitive.
  • manpage rendering.
  • framework integrations. clap/cobra/etc. can't infer this — it needs an explicit annotation from the author, same as hide.

Verified

cargo test -p usage-lib passes (275 + doctests). Four new tests cover prop parsing, child-node parsing, non-inheritance, KDL round-trip, and the error on an unknown value. Markdown output checked by hand against a sample spec.

cargo test --workspace has two pre-existing failures in usage-cli --test examples (test_usage_double_slash_execution, ..._old) — confirmed present on a clean checkout of main, unrelated to this change.

This PR was generated by an AI coding assistant.


Note

Low Risk
Additive spec field with optional semantics; no changes to parsing or runtime CLI behavior beyond new metadata and doc generation.

Overview
Adds an optional effect on commands so specs can classify side effects as read, write, or destructive (via effect="…" or a child effect node). Missing effect stays unknown; values are not inherited by subcommands.

The library introduces SpecCommandEffect, wires it through SpecCommand (parse, KDL serialize, merge), SpecCommandBuilder::effect, and the public API. Generated markdown adds a - **Effect**: line when set. Docs in docs/spec/index.md describe semantics and consumers (docs, wrappers, agents).

Tests cover parsing, non-inheritance, KDL round-trip, merge behavior, invalid values, and markdown rendering. .gitignore ignores *.pending-snap from insta.

Reviewed by Cursor Bugbot for commit e0e5192. Bugbot is set up for automated code reviews on this repo. Configure here.

Commands can now declare `effect="read"`, `"write"` or `"destructive"`:

    cmd "ls"        effect="read"
    cmd "use"       effect="write"
    cmd "uninstall" effect="destructive"

This is a coarse classification, not a permission model. Several consumers
keep reinventing the same distinction and each has to hand-maintain its own
list: docs want to mark destructive commands, wrapper scripts want to
confirm before running one, and AI coding agents want an allowlist of
read-only commands rather than prompting on every invocation. Declaring it
once in the spec means it renders everywhere, like every other spec field.

It is deliberately not inherited by subcommands — `git remote` and
`git remote remove` do different things, and inheriting would make the least
safe reading of a spec the default. An unset effect means unknown, not safe.

Also accepted as a child node (`effect "destructive"`) so integrations that
generate KDL do not have to emit props.

Generated markdown renders it as an "Effect" line next to Usage and Aliases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@jdx, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 99a0187e-1da1-4bcb-88cb-86536b72e932

📥 Commits

Reviewing files that changed from the base of the PR and between 5f731b2 and e0e5192.

📒 Files selected for processing (10)
  • .gitignore
  • docs/spec/index.md
  • lib/src/docs/markdown/cmd.rs
  • lib/src/docs/markdown/templates/cmd_template.md.tera
  • lib/src/docs/models.rs
  • lib/src/lib.rs
  • lib/src/spec/builder.rs
  • lib/src/spec/cmd.rs
  • lib/src/spec/effect.rs
  • lib/src/spec/mod.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds command-effect metadata throughout the spec pipeline.

  • Defines and publicly exports the read, write, and destructive effect enum.
  • Parses, serializes, builds, and merges optional command effects without inheritance.
  • Renders effects in generated Markdown and documents the new spec field.
  • Adds coverage for parsing, round-tripping, merge behavior, non-inheritance, validation, and Markdown rendering.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported merge omission and Markdown coverage gap are both addressed at the current head.

Important Files Changed

Filename Overview
lib/src/spec/cmd.rs Integrates command effects into parsing, serialization, defaults, and merge composition; the prior merge-loss issue is fixed and covered by tests.
lib/src/spec/effect.rs Defines the public three-value command-effect enum with consistent parsing, display, serialization, labels, and unit tests.
lib/src/docs/markdown/templates/cmd_template.md.tera Adds conditional human-readable effect labels to generated Markdown, now exercised for every effect and the unset case.
lib/src/docs/markdown/cmd.rs Adds focused snapshot coverage for all effect-rendering branches and omission when no effect is declared.
lib/src/docs/models.rs Propagates command effects into the shared documentation model used by the Markdown template.
lib/src/spec/builder.rs Adds a typed builder method for assigning command effects.
docs/spec/index.md Documents effect meanings, supported syntax, non-inheritance, and unknown-by-default semantics.

Reviews (3): Last reviewed commit: "fix(spec): drop dead ParseEffectError, u..." | Re-trigger Greptile

Comment thread lib/src/spec/cmd.rs
Comment on lines +15 to +17
{%- if cmd.effect %}
- **Effect**: {% if cmd.effect == "read" %}read-only{% elif cmd.effect == "destructive" %}destructive — may delete or irreversibly overwrite{% else %}modifies state{% endif %}
{%- endif %}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Effect rendering lacks snapshot coverage

The new template branches for read, write, and destructive are not exercised by the existing Markdown snapshots because the shared test spec declares no effects. Add cases for all three values so changes to enum serialization or template comparisons cannot silently produce incorrect labels.

Knowledge Base Used: Docs generation: rendering a Spec into markdown, manpages, and CLI help

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

merge is how included and mounted specs are composed onto a command, and
it omitted the new field, so an overlay that said nothing about effects
erased one already declared.

Also add markdown snapshot coverage for all three effect labels plus a
command with no effect, since the shared kitchen-sink spec declares none
and left the template branches unexercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx

jdx commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Both findings were real. Fixed in f41891d.

effect dropped by SpecCommand::merge — correct, and it matters: merge is how included and mounted specs are composed, so an overlay that said nothing about effects erased one already declared. Now handled like every other optional field.

Worth noting what the test turned up while I was writing it: at the root level merge replaces subcommands wholesale rather than merging them, so an overlay's cmd "uninstall" discards the base's entirely — every field, not just effect. That's pre-existing behavior and I've left it alone; the test asserts the semantics that actually exist.

Template branches unexercised — also correct, the kitchen-sink spec declares no effects. Added a snapshot covering all three labels plus a command with none, so a change to enum serialization or the template comparisons can't silently produce a wrong label.


While fixing the merge gap I audited every SpecCommand field against the three places that have to know about it. Three fields already fall through, unrelated to this PR:

field gap
deprecated not carried by merge — an included spec silently un-deprecates a command
restart_token never reaches docs::models::SpecCommand, so docs can't see it
examples not written by From<&SpecCommand> for KdlNodespec.to_string() loses them, so KDL isn't round-trip stable

Happy to open a follow-up. Making merge and both From impls destructure their source exhaustively (no ..) would turn each of these into a compile error instead of silent data loss, at no runtime cost.

This comment was generated by an AI coding assistant.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f41891d. Configure here.

Comment thread lib/src/docs/markdown/.cmd.rs.pending-snap Outdated
Comment thread lib/src/spec/effect.rs Outdated
- ParseEffectError was never constructed; strum's FromStr plus bail_parse!
  already produce the parse error, so the type was dead on arrival
- .cmd.rs.pending-snap was committed by accident and rewritten by the test
  run, breaking the "render produces no diff" CI check; untrack it and
  gitignore the pattern so it cannot happen again
- reformat the docs table with prettier to match lint:prettier

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx

jdx commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Both correct, fixed:

Unused ParseEffectError — dead on arrival. I wrote it, then used bail_parse! for the actual error so it'd carry a source span like every other parse failure, and never removed the type. Gone.

Insta pending snapshot committed — worse than noise: it isn't on main, I added it, and the test run rewrites it, which is what turned the render produces no diff CI check red. Untracked, and *.pending-snap added to .gitignore so it can't recur.

Also ran prettier -w on the docs table, which was the other half of the CI failure.

This comment was generated by an AI coding assistant.

@jdx
jdx merged commit 405cebf into main Jul 25, 2026
6 checks passed
@jdx
jdx deleted the feat/cmd-effect branch July 25, 2026 20:39
jdx added a commit to jdx/mise that referenced this pull request Jul 25, 2026
usage 3.6 added `effect=`, which lets a spec say whether a command only
inspects state, changes it, or destroys something (jdx/usage#739). Declare
it for all 172 mise commands: 89 read, 71 write, 7 destructive.

clap cannot express this, so the classification lives in one table in
src/cli/command_effects.rs and is applied to the derived spec. One list is
also the right shape for review: a safety classification is easier to audit
as a single file than as annotations spread over sixty command files.

Ten commands are deliberately unclassified, each with its reason recorded.
`run`, `exec`, `watch`, `mcp`, `en` and friends execute code the user
supplies, so their effect is whatever that code does; an unset effect means
"ask", which is both honest and the safe default.

Three tests keep the table honest: every command must be classified, no
entry may refer to a command that no longer exists, and nothing may be
classified twice. Coverage includes hidden commands — `mise global`,
`bootstrap systemd apply` and `hook-not-found` are hidden but still change
state, and the last one installs a tool when not_found_auto_install is set.

Also required:

- bump min_usage_version to 3.6, since older `usage` CLIs reject the spec
  with "unsupported cmd prop effect"
- pass the argument to `is starting_with(...)` by name in
  mise-extra.usage.kdl. tera 2.1, which usage picked up in v3.5.7, rejects
  positional test arguments, so rendering docs with usage >= 3.5.7 fails
  with "Found string but expected identifier" regardless of this change.
  main already requires 3.5.7, so this is live as soon as mise.lock moves
  off the 3.5.2 pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jdx added a commit that referenced this pull request Jul 25, 2026
BREAKING CHANGE: SpecArg, SpecFlag, SpecChoices, SpecComplete, SpecConfig,
SpecConfigProp, SpecExample and SpecMount are now #[non_exhaustive], so
downstream crates can no longer build them with a struct literal. Use the
builders and constructors instead; every field stays pub, so
`Builder::build()` followed by field assignment covers cases that map from
optional data.

Adding `effect` to SpecFlag and SpecArg failed cargo-semver-checks with
constructible_struct_adds_field: both are all-pub, so any new field is a
major. SpecCommand escaped the same fate in #739 only because it happens to
have a private cache field, and Spec was already non_exhaustive. Eight of
the twelve spec structs had the problem.

Since this release is a major either way, spend it once: after this, adding
a spec field is no longer breaking. Two fields have been added in a day and
there will be a third.

non_exhaustive is only useful if the types stay constructible, so this also
fills the gaps:

- SpecFlagBuilder/SpecArgBuilder gain usage, help_first_line and effect,
  the three fields they could not previously set
- SpecMount::new, SpecChoices::new, SpecComplete::new (+ run, descriptions),
  SpecConfig::new, SpecConfigProp::new (+ env, help, default_value)
- SpecExample::new becomes public and gains header, help and lang

Verified against the real consumer: mise builds and emits a byte-identical
spec after migrating its seven struct literals to builders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant