Repository navigation
io-cli 0.31.0 - #80
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsonand.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 --doc1 passed.clippy --all-targets -- -D warningsandfmt --all -- --checkclean. 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.