diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e13ac5..61a159b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.30.1] - 2026-08-30 + +### Fixed + +**`io skill` did not exist.** 0.30.0 documented `io skill add|list|remove` in the +README and the CHANGELOG, and shipped a working parse, a working plan, a working +arm in the session — and a binary that answered `error: unrecognized subcommand +'skill'`. The argv door is the `Subcommand` enum in `src/cli.rs`, and nothing had +added `skill` to it; `manage::parse`'s own list of surfaces was missing it too, so +even once clap routed the word the parse refused it. + +Every one of 0.30.0's 1,609 tests passed over this, because they all enter through +`manage::parse` and the routing that was missing sits in front of it. It was found +by running the published artifact, which is the last gate and the only one that +sees the product as a user meets it. + +`io skill add ./x.md`, `io skill list` and `io skill remove ` now work. +`/skills add …` in a session was never affected — that path does not go through +clap. + +A test now asks `clap` itself which subcommands it will route and compares that +against the surfaces `manage::parse` accepts, so the two cannot disagree again. + ## [0.30.0] - 2026-08-30 The rest of the verb matrix closes: after this release there is nothing io diff --git a/Cargo.lock b/Cargo.lock index a3e1368..6cfe889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.30.0" +version = "0.30.1" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 2c62468..184cdfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.30.0" +version = "0.30.1" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness diff --git a/src/cli.rs b/src/cli.rs index 530f7da..a225ff7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -87,6 +87,16 @@ pub enum Command { Plugin(Manage), /// Read or write one configuration key, without opening a session. Config(Manage), + /// Install, list or remove a skill, without opening a session. + /// + /// **Missing until 0.30.1, and `io skill add` did not exist because of it.** + /// `manage::parse` accepted the surface, `manage::plan` answered for it and + /// the session's own arm ran it — but clap knows only the subcommands named + /// in this enum, so the argv door answered `unrecognized subcommand 'skill'` + /// for a verb the README and the CHANGELOG both documented. Nothing under + /// `tests/` links `src/main.rs` and nothing tested clap's routing, so the + /// whole suite was green over a door that did not open. + Skill(Manage), } /// The three management subcommands' arguments, handed through untouched. diff --git a/src/main.rs b/src/main.rs index a47f090..1f0628a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -200,6 +200,7 @@ async fn run(report: &mut Vec) -> Result { Some(Subcommand::Mcp(args)) => Some(("mcp", &args.words)), Some(Subcommand::Plugin(args)) => Some(("plugin", &args.words)), Some(Subcommand::Config(args)) => Some(("config", &args.words)), + Some(Subcommand::Skill(args)) => Some(("skill", &args.words)), _ => None, } { let (surface, rest) = words; diff --git a/src/manage.rs b/src/manage.rs index 50b9a94..3bbafe8 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -504,12 +504,13 @@ pub fn parse(tokens: &[String]) -> Result { "`{unknown}` is not a verb `{surface}` takes; it takes {}", verbs(surface) )), - ("mcp" | "plugin" | "config", None) => Err(format!( + ("mcp" | "plugin" | "config" | "skill", None) => Err(format!( "`{surface}` needs a verb after it; it takes {}", verbs(surface) )), (unknown, _) => Err(format!( - "`{unknown}` is not a surface io manages; they are `mcp`, `plugin` and `config`" + "`{unknown}` is not a surface io manages; they are `mcp`, `plugin`, `skill` and \ + `config`" )), } } diff --git a/tests/manage.rs b/tests/manage.rs index e1c478e..0e03c5a 100644 --- a/tests/manage.rs +++ b/tests/manage.rs @@ -867,3 +867,56 @@ fn f14_the_headless_listing_prints_the_deciding_file() { "the policy preflight reached stdout, which puts prose in a pipe a script is reading" ); } + +/// **Every surface `manage::parse` accepts is a subcommand `clap` will route.** +/// +/// 0.30.0 shipped `io skill add` in the README, the CHANGELOG and +/// `manage::verbs`, with a working parse, a working plan and a working arm in the +/// session — and clap answering `error: unrecognized subcommand 'skill'`, because +/// the door is the `Subcommand` enum in `src/cli.rs` and nothing had added it +/// there. Every one of the 1,609 tests passed: they all entered through +/// `manage::parse`, which is downstream of the routing that was missing. +/// +/// **Found by running the published binary, not by the suite.** This is the gate +/// that makes the next one cheaper — it asks clap itself what it will accept, +/// rather than reading `src/cli.rs` as text, so it cannot be satisfied by a +/// comment. +/// +/// Sabotage: delete `Skill(Manage)` from `cli::Subcommand`. Only this fails. +#[test] +fn every_managed_surface_has_a_subcommand_clap_will_route() { + use clap::CommandFactory as _; + + let command = io_cli::cli::Cli::command(); + let routed: Vec = command + .get_subcommands() + .map(|sub| sub.get_name().to_string()) + .collect(); + + for surface in ["mcp", "plugin", "config", "skill"] { + assert!( + routed.iter().any(|name| name == surface), + "`manage::parse` accepts `{surface}` and clap does not route it, so \ + `io {surface} …` answers `unrecognized subcommand` for a verb the \ + product documents. clap routes: {routed:?}", + ); + // And the surface really is one the parse accepts — otherwise this test + // would pass by asserting a name nothing else knows. + let refusal = manage::parse(&[surface.to_string()]) + .expect_err("a bare surface with no verb is refused"); + // **The needle is the refusal's real words.** The first version of this + // assertion looked for "io does not manage" and the sentence says "is not + // a surface io manages", so it passed over exactly the state it was written + // to catch — clap routing a surface the parse then rejects. Taken from the + // bytes, which is this repository's own rule about prose needles. + assert!( + !refusal.contains("is not a surface io manages"), + "`{surface}` is routed by clap but is not a surface `manage::parse` \ + knows, so the door opens onto a refusal: {refusal}", + ); + assert!( + refusal.contains("needs a verb after it"), + "a bare `{surface}` should ask for a verb: {refusal}", + ); + } +}