Skip to content

io-cli 0.31.0 - #80

Merged
aakashpawar1999 merged 13 commits into
mainfrom
develop
Aug 30, 2026
Merged

aakashpawar1999 merged 13 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

The release PR for 0.31.0. Everything here came through #79.

io-cli reads the three manifest formats a capability bundle is actually published in — .claude-plugin/marketplace.json, .claude-plugin/plugin.json and .codex-plugin/plugin.json — so the marketplace surface 0.29.0 and 0.30.0 built can be stocked from the field instead of only by a bundle the operator wrote themselves.

cargo test --all-targets — 99 binaries, 1,666 passed, 0 failed, 36 ignored. cargo test --doc 1 passed. clippy --all-targets -- -D warnings and fmt --all -- --check clean. 8/8 on #79 across macOS, Linux and Windows.

The release record is sealed and pinned on disk. .ultraship/ is gitignored, so it is not in this diff.

Adds `src/adapt.rs`, the crate's reader for the three manifest formats a
capability bundle is actually published in: `.claude-plugin/marketplace.json`
as a repository's index, and `.claude-plugin/plugin.json` or
`.codex-plugin/plugin.json` as a bundle manifest. Every value it returns has
been through `marketplace::plain` and `marketplace::bounded`, except a hook's
command, which is filtered and deliberately not shortened because it is argv an
operator is asked to consent to.

The two remote source tags — `url` and `git-subdir` — are read as one shape
keyed on the presence of a `url` rather than on the tag. They carry the same
keys in the field, and a second reader for a second name is the shape this crate
has paid for before.

An entry in a shape the reader does not know costs that entry and not the file:
the index reports it with its reason rather than dropping it, because a shape no
fixture anticipated is found by an operator and a silent skip makes that
unreportable.

`tests/dependencies.rs` gains the JSON rule in the same commit as the first line
that reads a stranger's JSON, rather than after it. It permits
`serde_json::from_str`, `from_slice` and `from_reader` at an exact set of two
paths compared with `==` — `src/import.rs`, which has read the operator's own
Claude and Codex files since 0.21.0, and `src/adapt.rs` — with a near-miss test
so that widening the set to a stem or substring match goes red instead of going
vacuously green. Serialization is not gated: the TOML rule beside it forbids the
parse and says why, and `src/exec.rs` writing its own `--json` event lines is
not a second opinion about anybody's file.

`contract::default_skills` now anchors on `home::in_force` rather than
`home::path`, so the skills directory follows `$IO_CONFIG_HOME` exactly as the
operator's memory note already did. A skill is authored content, not a cache,
and this is the release that writes absolute paths into a home — two answers to
which home would have been written into a second surface and then described.
`home::marketplaces`'s rustdoc cited the old behaviour as its precedent and now
states its own reason instead. `HomeFixture` names the home in force as well as
the operator's home, which is what `home::adopt` does in the binary.
…lishes

`marketplace::holdings` reads three manifest formats with a stated precedence.
A native `plugin.toml` at the clone's root suppresses a foreign index and
nothing else, so an author who writes io's own manifest has said what they
publish and a stranger's index cannot speak over it — while a repository
carrying a root manifest and bundles beneath it lists all of them exactly as it
did before. Where there is no root manifest, `.claude-plugin/marketplace.json`
is the answer and the walk does not also run: a union would list bundles the
author did not publish beside the ones they did, with no way to tell which was
which, so the test counts rather than matching. Where there is neither, the walk
reads each directory as a native manifest first and a foreign one only where it
carries none.

`pluginview::candidates` is now `pluginview::visited` filtered by "carries a
manifest", which is what the walk always was. The split is load-bearing rather
than tidying: a directory holding only a `.claude-plugin/plugin.json` carries no
`plugin.toml`, so it was never in `candidates`'s answer, and a reader iterating
that list for foreign manifests would have found one exactly never. The skips
are unchanged, `.git` included, and a test asserts that reading a known path
inside an already-admitted directory is not the walk descending into a dotted
one.

`Bundle` gains an origin and the source an index named. A bundle whose source is
another repository has no local directory until an install fetches it, so its
`dir` is the clone the index is in and the source is what says so. A local
source is resolved by dropping every component that is not a plain name, so a
source of `../../etc` addresses nothing outside the clone.

`Bundle::line` names the file io actually read rather than always saying
`plugin.toml`, and `Market::held`'s zero case names all three shapes — it used
to say "no directory in it carries a plugin.toml", which was true and was the
answer for every marketplace in the field.
An index entry may place a plugin in another repository, and 238 of the official
marketplace's 291 entries do. Every one of them names a commit, so a fetch that
ignored it would install something other than what the index pinned.

`git clone --revision` landed in git 2.49 and this product names no git floor,
so a commit-pinned shallow fetch is four invocations — init, remote add, a
shallow fetch of the commit, a detached checkout of what came back — and a
tag-pinned one is a single `clone --branch`. `fetch::steps` builds all three
shapes and `fetch::argv` is unchanged, so an ordinary marketplace add still runs
the same five elements it has since 0.29.0, asserted both against the literal
and against `argv` itself.

The URL a stranger wrote never reaches git. `HOST`'s own documentation states
that the only string reaching the program is one this module built from that
constant, because an argument somebody else chose is how `ext::sh -c` becomes a
remote shell — so an entry's url is taken apart by `fetch::from_url`, judged by
`resolve`'s existing rules, and thrown away; `url` rebuilds it. A url on another
host, or one that is not two ordinary path segments, is refused. All 238 remote
entries of the official index and all ten of superpowers-marketplace are two
segments on the permitted host, so the ceiling refuses nothing that exists. A
`sha` and a `ref` are argv elements out of the same file and are judged the same
way: a value beginning with `-` is an option to a program that parses options.

`tests/dependencies.rs`'s F10 gate is re-argued rather than relaxed. The spawn
can no longer call one builder inline, because a pinned fetch is a loop over a
list of finished argvs, so `.args(argv(` is replaced by a single `run` taking a
`Vec<OsString>` parameter — which is stronger: the spawn never sees the parts
and cannot add an element even in principle. Two builder signatures are pinned
where one was, the spawn count stays at one, and `.args(` is now asserted to
appear exactly once.
…remote entry

An index entry's name becomes the id an operator types at `plugin add` and the
prefix io-harness namespaces every contribution with, so `adapt::normalised`
accepts an ASCII case fold and nothing else. `My.Plugin` is refused by name
rather than installed as `my-plugin`: a mapping that dropped or invented a
character would give a person one name in the author's file, a second in the
listing and a third inside every namespaced contribution. Two entries in one
index reaching the same id are refused naming both, in the sentence shape
`locate` already uses for a bare name two marketplaces carry — parting from it
only where the two genuinely differ, since inside one index there is no
qualifier that tells them apart and only the index's author can resolve it. The
rule is stated against `io_harness::MAX_ID` rather than a copied number.

`marketplace::fetched` brings down an entry the index placed elsewhere. A local
source is already on the disk and starts no program, which is 53 of the official
index's 291 entries. The rest clone into a dot-named directory inside the
marketplaces tree, so `markets` cannot count one as a marketplace of its own and
`plugin marketplace remove` still takes it away. A url on another host, or one
that is not two ordinary path segments, is refused before anything is started
and the refusal says what io does read; a `ref` or `sha` that could become an
option refuses the whole entry the same way. `fetch::HOST` is public so the
refusal can name it rather than spelling it a second time.
…cannot cross

`adapt::generate` writes `~/.io-cli/adapters/<owner>/<repo>/<name>/plugin.toml`
from a Claude Code or Codex bundle, with absolute canonical paths into the
clone — `Plugin::skills_dir` is `self.root.join(d)` and an absolute join discards
the root, so io-harness loads it as an ordinary bundle with no change to that
crate. The stranger's checkout is never written to, and a test asserts the clone
still carries no `plugin.toml`.

Every claim the tests make about the generated file goes through io-harness:
`Plugins::inspect` and `Config::plugins` load it, and `Plugin`'s own accessors
answer what it contributed. Reading back the string io-cli spelled would assert
that this crate can quote its own output, which is not the question.

The manifest is written and never parsed. Scalars go through `toml::Value`'s own
serializer rather than `format!("{:?}")`, which escapes for Rust's grammar: an
escaped apostrophe TOML rejects outright, Rust's brace form for control
characters where TOML wants four fixed hex digits, and a Windows path's `\U`
opening an escape inside a basic string. `src/edit.rs` remains the only module
permitted to parse TOML.

Generation is validated through io-harness before the operator is asked anything,
and a manifest it refuses is removed rather than left on disk — a manifest
io-harness rejects takes the whole configuration down, not just the bundle. Every
`${…}` is resolved or refused at generation time: `${CLAUDE_PLUGIN_ROOT}` expands
to the canonical bundle root, anything else refuses naming the field it was in,
and the assembled bytes are scanned for `${` as a backstop.

Agent frontmatter is read by a small hand-rolled reader that says in its own
rustdoc that it is deliberately not a YAML parser and lists what it does not
support. Only `name` is acted on; the markdown body becomes `AgentDef::role`.
`model` is disclosed rather than translated — `sonnet` and `opus` are one
vendor's family aliases while `AgentDef::model` is a published model id, and
translating would invent a fact the run pays for.

`marketplace::adapted_disclosure` draws every hook a foreign bundle declares with
its event, its command unshortened and the reason it will not run. The hooks are
read from the author's own file rather than from io-harness, because the
generated manifest carries none — asking io-harness what a bundle contributes
cannot answer what it declared. `LONGEST` bounds a description and deliberately
does not bound argv: a shortened command on a consent surface is the one thing
that surface must never show. A native bundle withholds nothing and says nothing.
`dependents` finds a declared bundle whose path is inside the clone, which is
every native install and no adapted one — an adapted bundle is declared under
`~/.io-cli/adapters` and only its generated manifest points into the clone. So a
marketplace of adapted bundles would have been removed with the operator told
nothing at all.

`orphaned` reads the link where it actually is: the generated manifest's own
`skills` and `templates` values, through `edit::value_at`, which is this crate's
only permitted TOML reader. Both keys are checked rather than one, because a
bundle publishing only `commands/` has a `templates` and no `skills` and a rule
reading `skills` alone would call that adapter unaffected by a removal that guts
it.

Neither the `[[plugin]]` entry nor the adapter directory is touched. A cache
being emptied does not undo a configuration decision, and an adapter is io's own
file but is still named by an entry that survives. What is owed is the
consequence, said before anything goes — and the two kinds are counted and listed
together, because the difference between them is not something the operator
asked about.

`removal_cost` is now the one call each of the three doors makes. Each of them
previously called `dependents` and then `warning`, so a release adding a second
way for a bundle to depend on a clone had to be remembered in three places —
and `src/main.rs` is linked by nothing under `tests/`, so forgetting one would
have been invisible to every gate.
`ADAPTED_MARK` is `~`, one ASCII cell in both glyph sets, sharing no stroke with
`+`, `-` or `!` so a column of marks stays readable. It takes the column from the
state marks rather than sitting beside them, because a row carries one mark and
where the manifest came from is the fact an operator cannot recover any other
way — a switched-off bundle already leads its detail with the words, and a test
now holds that claim to its own terms rather than leaving it in a doc comment.

`Listed::adapted` is a path prefix against the adapters root, which `View` now
carries. `detail` is deliberately not changed: its signature has no adapters root
and its three call sites are outside this change, and `/plugin`'s listing is what
the criterion is about.

An adapted row reserves the floor for its root ahead of the description, so the
directory holding the generated manifest is drawn rather than dropped when the
description is long — that file is what an operator opens when io-harness refuses
the bundle or its contributions are not what the clone promised, and nothing else
on the surface names it.

The mark is registered in `tests/glyphs.rs`'s existing pairing gate rather than
in a second one beside it, which meant giving that gate a `/plugin` fixture: its
presence check reads the rows of the page it renders, and the memory page can
never carry a plugin mark.

No slash command was added. `COMMANDS.len()` is 36 and group occupancy is
[7, 9, 10, 10], both unchanged and both still asserted.
…ry does

`tests/install.rs`'s fixture set `HOME` and left `IO_CONFIG_HOME` wherever
another test file had pointed it, so after `contract::default_skills` moved onto
`home::in_force` it asserted about a state the product never reaches —
`configured` answered `None` for a directory it had just installed five skills
into. `home::adopt` sets that variable above the one `Config::discover` either
arm reaches, so the fixture now does too, and restores it on drop.

Found by the full suite rather than by any single file, which is the argument for
running it after a wave of parallel work.

Also drops a redundant closure clippy refuses under `-D warnings`.
…u wrote

**The whole 0.31.0 pipeline had no caller on the install path.** `plugin
marketplace add` listed a Claude Code bundle and `plugin add` then refused it —
`chosen` resolved the name to the clone directory and `pluginview::refusal`
found no `plugin.toml` there, because the adapter is generated and nothing
generated it. Every row the release added was listable and uninstallable. Found
by running the built binary against a real repository, which is the gate 0.30.1
exists for.

`marketplace::prepared` is the missing step: resolve the word to a bundle, fetch
it where the index placed it elsewhere, and generate the adapter where the
manifest is not one io-harness reads. Native bundles are untouched by every line
of it — and nativeness is decided by the disk rather than by how the bundle was
listed, because an index entry may still point at a directory carrying io's own
manifest and generating over it would drop the author's hooks, layers and agents.

An entry clone is keyed on the marketplace that named it **and on the pin**.
`clone_at` answers `Already` before it consults the pin and nothing records which
commit a directory holds, so two entries naming one repository at two commits
gave the second the first one's code with a disclosure truthfully describing the
wrong tree. Keyed under the marketplace, `marketplace remove` now takes the
fetched repositories with it instead of leaving clones no surface lists.

**A manifest's `skills` could name any directory on the machine.** `Path::join`
replaces the base on an absolute argument and keeps `..`, so `"skills": "../../.."`
in a stranger's `plugin.json` would write the operator's home into a generated
manifest and `Skills::discover` would read it into the prompt. Plain components
only, the filter every other stranger-supplied path already went through.

**Skills reads had moved to the home in force and writes had not.** An operator
with `$IO_CONFIG_HOME` set would have had `/skills add` write where nothing
looked, and skills they already had would have stopped reaching the model with
nothing said — the notice that existed to catch it was guarded on a directory
that does not exist in exactly that case. `home::authored` is now the single
answer for every surface that reads or writes what the operator wrote, and the
notice compares the two homes rather than the resolved directory.

Also: an index-derived bundle no longer tells the operator to look in a
`plugin.json` that does not exist; the adapter directory uses the normalised id
rather than the label, so two bundles differing only in case cannot share one;
and `Listed::adapted` resolves both sides like `orphaned` does, so the mark and
the removal warning cannot disagree on a machine where the home is a symlink.
…atches

`Esc` on the install disclosure dropped the picker and the adapter with it. It
is the standard way out of every other modal in this product, so it was the one
that left a directory nobody agreed to under the operator's home, listed by no
surface and removed by no verb. It now takes the same path the "leave it" row
does.

The glyphs mark registry looked for each mark in the union of two surfaces'
rows, and `LOADED_MARK` is `+` like `READ_MARK` while `DISABLED_MARK` is `-`
like `LOOSE_MARK` and `UNREAD_MARK` — so `/plugin`'s two commonest marks were
found in the *memory page's* rows and the gate passed however `/plugin` drew
them. Each mark is now looked for on the surface that draws it, proved by making
every arm of the mark selector return `ADAPTED_MARK`: that sabotage passed
before and fails now.

Three shipped claims the code did not support. "Marked as adapted wherever it is
drawn" was true of `/plugin`'s rows and not of the marketplace listing or the
disclosure — the prose now says where the mark is and what it is for. The
skills-directory paragraphs in the configuration guide and `docs/CONTRACT.md`
said `~/.io-cli/skills` flatly, which stopped being the whole truth when skills
moved onto the home in force; they now say both, and the CHANGELOG says the
write moved with the read and that io does not move files you already own.

Four rustdoc comments naming `~/.io-cli/skills` as the default were left behind
by the same change and now describe the home in force.
io-cli 0.31.0 — install the plugins that already exist
@aakashpawar1999
aakashpawar1999 merged commit 63d23a5 into main Aug 30, 2026
13 checks passed
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