Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ async fn run(report: &mut Vec<String>) -> Result<u8, String> {
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;
Expand Down
5 changes: 3 additions & 2 deletions src/manage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,12 +504,13 @@ pub fn parse(tokens: &[String]) -> Result<Request, String> {
"`{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`"
)),
}
}
Expand Down
53 changes: 53 additions & 0 deletions tests/manage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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}",
);
}
}