diff --git a/CHANGELOG.md b/CHANGELOG.md index a5d9400..080aeeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,107 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.29.0] - 2026-08-29 + +A plugin can come from somewhere other than a directory you already had — and you +see what it is allowed to do before it is allowed to do it. + +**Marketplaces.** `/plugin marketplace add zeroonething/ultraship` resolves a +name to a git repository, clones it shallow into `~/.io-cli/marketplaces`, and +lists what it holds. A bundle inside one is any directory carrying a +`plugin.toml`, so there is no index file to write and none to disagree with the +directories it describes. `list` and `remove` are there too, and removing a +marketplace takes the clone and nothing else: a bundle you declared out of it +stays declared, and you are told beforehand which ones will stop loading. + +**Install by name.** `/plugin add ultraship`, or `ultraship@ultraship` where two +marketplaces carry that name, and `install` works as the same verb. `/plugin add` +still takes a path — a word is a path if it resolves to a directory carrying a +manifest, and a name otherwise, so it cannot mean different things in different +working directories. **A bare name two marketplaces carry is refused**, naming +both qualified spellings: taking the first match installs code you did not +choose. `/plugin search` reads names and descriptions across every marketplace +you have added. + +**What a bundle may do is shown before it is switched on.** A bundle contributes +skills, prompt templates, agents, MCP servers, hooks and policy layers to four +subsystems at once, and until now every one of them came from a directory you had +read yourself. The install writes the entry `enabled = false` first, so io-harness +reads, parses, validates and trust-checks the bundle for real and hands it back +contributing nothing — then shows you what it parsed, in the namespaced names you +will actually see. Saying yes flips one key. Saying no leaves the bundle +declared, off, and listed. A bundle io-harness would refuse is refused at that +point, in its own words, before you are asked anything. + +Hooks are the exception and are disclosed anyway. io-harness publishes no +accessor for them, so the disclosure reads the `[[hook]]` tables out of the +manifest and names each one's event and command. Consenting on the bare word +"hooks" is consenting to programs nobody named. Filed upstream as io-harness#223. + +**`/plugin` now shows a bundle you switched off.** io-harness 0.70.0 splits what +a configuration declared into three sets rather than two, and this interface was +reading two of them — so a bundle declared `enabled = false` appeared nowhere at +all, and a configuration whose bundles were all switched off was reported as +declaring none. It is listed under its own mark, with what switching it back on +would bring. + +**Git is asked about rather than refused, if you run the recommended posture.** +Not io-cli's fix — io-harness 0.70.0 closed the issue io-cli 0.25.0 filed against +it. Through 0.69.0 the git spawn accepted only an outright allow, so `ask before +writes` behaved exactly as `deny` did and all seven git tools were refused with +nobody consulted. You now get the question. `/commit allow` and the refusal +explanation are still there and still needed, but only for `read only`, where +`exec` is a deny and there is nothing to answer — and they are offered only where +that deny came from the posture's own default rather than from a rule, because a +rule cannot be widened by a later layer. **The live gate that asserted the old +behaviour is what caught this**, on the first real run after the pin. + +**A run that failed its verification exits 6 again.** io-harness 0.70.0 closed +this project's own issue #212 and gave that run its own outcome, which — because +the enum is `#[non_exhaustive]` — quietly moved it out of the set `io exec` maps +to a ceiling and out of the set the gate retry acts on. Both are named again, so +a failing gate is retried and reported as what it is. + +### Fixed + +- **`/plugins ` and `/servers ` reach the parse that serves them.** + The plural was accepted by the router and refused one module later, so + `/plugins install x` came back "`plugins` is not a surface io manages" while + bare `/plugins` opened the panel. Folded in `manage::parse`, which is the one + door both the slash form and `io plugins …` go through. +- **A bundle whose path holds a `"` or a `\` can be read back.** The writer + escaped both and the two readers decoded neither, so such a bundle could be + declared and then not located again — which meant the disclosure read a + different directory's manifest, and the entry could not be removed from the + surface that declared it. +- **`/mcp`, `io mcp list` and `io mcp get` say when a server is switched off.** + io-harness 0.70.0 honours `enabled` on an `[[mcp]]` entry before anything is + spawned or dialled; io-cli was reading none of it, so a server that could never + start was listed exactly like a live one and every turn quietly ran without its + tools. Writing the key is still not offered — that verb is next. +- Text out of a marketplace manifest is filtered of control characters and + bounded before it is drawn, the way output from `git` already was. A + description holding a raw newline could otherwise forge lines in `/plugin + search` — the surface used to decide whose code to install. +- Where two bundles inside one marketplace share a name, each is offered by its + own directory. The refusal used to print one spelling twice and neither + resolved, so that bundle could not be installed by name at all. + +### Dependencies + +- io-harness 0.69 → **0.70.0**. `McpServer` gains a required public `enabled` + field; `RunOutcome` gains `VerificationFailed`. One transitive crate added, + `quick-xml`, through the `documents` feature. The direct dependency set is + unchanged at ten names, and this release adds no HTTP client and no second + network path: a marketplace is fetched by running `git`. + +### Upgrading + +- **A `[[plugin]]` entry carrying `enabled` cannot be read by an io-cli built + against io-harness 0.69.0**, which refuses the whole file rather than ignoring + the key. io-cli says so at the moment it writes one. If you downgrade, remove + the `enabled` keys first. + ## [0.28.0] - 2026-08-29 Every list this interface can prune, it can now grow; and every setting whose diff --git a/Cargo.lock b/Cargo.lock index 589be0c..4f80a2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ dependencies = [ "encoding_rs", "fast-float2", "log", - "quick-xml", + "quick-xml 0.41.0", "serde", "zip", ] @@ -1006,7 +1006,7 @@ dependencies = [ "base64 0.22.1", "crc32fast", "image", - "quick-xml", + "quick-xml 0.41.0", "serde", "serde_json", "smallvec", @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.28.0" +version = "0.29.0" dependencies = [ "clap", "crossterm", @@ -1867,9 +1867,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.69.0" +version = "0.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28c9b7027129f38c071489894f9e9760b7b027a5f5025d3af81944da258ce9df" +checksum = "ed49b9252e588eecd0fb3c19696dcdd35f5ca8ac0cd97ce2be2eef7a70fdb6dc" dependencies = [ "base64 0.23.1", "calamine", @@ -1879,7 +1879,7 @@ dependencies = [ "libc", "lopdf 0.44.0", "pdf-extract", - "quick-xml", + "quick-xml 0.42.0", "regex", "reqwest", "rmcp", @@ -2977,6 +2977,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -4518,7 +4527,7 @@ dependencies = [ "md-5 0.11.0", "num-traits", "phf 0.14.0", - "quick-xml", + "quick-xml 0.41.0", "rand 0.10.2", "rgb", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index 7a19929..4b8212d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.28.0" +version = "0.29.0" 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 @@ -63,7 +63,7 @@ path = "src/lib.rs" # still correct about what this *renderer* can be handed; it is simply no longer # load-bearing on the build. Turning `barcode` back off is what would restore # it, and `documents` would then have to be spelled out as its other four. -io-harness = { version = "0.69", features = ["media", "browser", "documents"] } +io-harness = { version = "0.70", features = ["media", "browser", "documents"] } # **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** # The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 # because the composer was built out of that widget and the 0.30 line carried diff --git a/README.md b/README.md index 12c5a0c..9d2e392 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,17 @@ if this crate ever grows an HTTP client, a TLS stack, a database or a sandbox. - [Install](#install) · [First run](#first-run) · [Bringing your setup across](#bringing-your-setup-across) · [While it works](#while-it-works) - [Keys](#keys) · [Commands](#commands) · [What it costs](#what-it-costs) · [Configuration](#configuration) -- [Capability bundles](#capability-bundles) · [Hooks](#hooks) · [The fleet](#the-fleet) +- [Capability bundles](#capability-bundles) · [Marketplaces](#marketplaces) · [Hooks](#hooks) · [The fleet](#the-fleet) - [Pictures](#pictures) · [Documents](#documents) · [Background jobs](#background-jobs) - [Reading it without seeing it](#reading-it-without-seeing-it) · [Headless](#headless) - [What this release is not](#what-this-release-is-not) · [Platform support](#platform-support) · [Stability](#stability) ![A session at rest: the IO CLI card in the terminal's own scrollback, carrying -the version, the model, the permission posture and the workspace; a prompt below -it; and a two-row footer under a rule, naming the state, the model and the clock -on one row and the keys and the posture on the next.](docs/screenshot.png) +the version and the tagline above the model, the permission posture and the +workspace; a muted line saying this is a new conversation and the last one is +still in /resume; an empty prompt below it; and a two-row footer under a rule, +naming the state, the model and the clock on one row and the keys and the +posture on the next.](docs/screenshot.png) ## What you get @@ -574,7 +576,7 @@ above a row that ranked there for reasons having nothing to do with it. | `/memory` | what io remembers: the instruction files, and the agent's own notes | | `/mcp` | the MCP servers configured, and what this session has seen of each | | `/provider` | the providers configured, in the order a turn tries them | -| `/plugin` | the capability bundles loaded, what each contributed, and the ones that failed | +| `/plugin` | the capability bundles declared, the marketplaces they come from, and what failed | | `/gates` | the check a turn must pass before it is done: a command, a file, or a rubric | | `/import` | bring instructions, MCP servers, skills and a model across from another agent | @@ -1040,9 +1042,11 @@ path = "~/bundles/rust-review" ``` That is a declaration and never a scan. There is no directory io walks looking -for bundles, no registry it fetches from, and nothing that loads by being present -on disk — declaring one is the whole of installing one, and deleting the line is -the whole of removing one. +for bundles and nothing that loads by being present on disk — declaring one is +the whole of installing one, and deleting the line is the whole of removing one. +There is still no registry either; from 0.29.0 there are +[marketplaces](#marketplaces), which are repositories you name and clone, and +installing out of one writes exactly the entry above. One directory can hand over six kinds of thing at once: skills, prompt templates, `[[agent]]` definitions for a fan-out to draw children from, `[[mcp]]` servers, @@ -1063,6 +1067,15 @@ make is dropped, recorded, and otherwise silently absent while every other bundl loads. A bundle you believe is running can be gone for a week. This is where that week ends. +**From 0.29.0 there is a third list, for the same reason.** io-harness 0.70.0 +lets an entry say `enabled = false`, and a bundle written that way is read, +parsed and held to the whole trust rule while contributing nothing. It is a +state, not a failure — it is doing exactly what your file asked — so it is drawn +under its own mark with what switching it back on would bring, rather than +beside the ones you have to fix. It counts as declared, too: a configuration +whose bundles are all switched off is no longer reported as declaring none, which +is the sentence above inverted and just as misleading. + **And a bundle can be stopped from the same list.** The last row under a bundle's contributions removes its `[[plugin]]` entry, after a confirmation that names the scope and the entry it will take out. io finds that entry by matching the @@ -1103,7 +1116,74 @@ is no API by which this program can count them or say what any of them runs. `/plugin` therefore draws a row saying the bundle contributed hooks and that io cannot say what they do. The alternative was to leave the row out, which reads as a bundle with no hooks — the one reading that is false, on the contribution kind -that runs programs. +that runs programs. **The one place io does name them is a marketplace install**, +below, where it reads them out of the manifest itself — because that is the one +moment you are being asked to accept a directory you have not read. Reported +upstream as io-harness#223; the reading goes when the accessor arrives. + +## Marketplaces + +**A marketplace is a git repository you name.** It is cloned into your own home +and walked for directories carrying a `plugin.toml`: + +``` +/plugin marketplace add zeroonething/ultraship +/plugin marketplace list +/plugin marketplace remove zeroonething/ultraship +``` + +The same words work from a shell — `io plugin marketplace add …` — through one +parse. There is no index file to write and none to disagree with the directories +it describes, and io operates no registry: it hosts nothing, curates nothing and +ranks nothing. The fetch is a `git` invocation and nothing else, so this adds no +HTTP client and no network path beside io-harness's. A machine with no `git` is +told so by name, and installing from a directory you already have is unaffected. + +Installing is the verb you already had: + +``` +/plugin add ultraship +/plugin install ultraship # the same verb, another word +/plugin add ultraship@zeroonething/ultraship # when two marketplaces carry it +/plugin search review +``` + +**A bare name two marketplaces carry is refused**, naming both qualified +spellings. They are two strangers' repositories, and installing whichever the +walk reached first is installing code you did not choose. + +**Removing a marketplace removes the clone and nothing else.** A bundle you +declared out of it keeps its `[[plugin]]` entry — a cache being emptied is not a +reason to undo a decision you made about your configuration. What io owes you +instead is the consequence, so it names the bundles that will stop loading before +it deletes anything. + +### What a bundle is allowed to do is shown before it is allowed to do it + +A bundle contributes to four subsystems at once, and until 0.29.0 every one you +declared came from a directory you had read. A marketplace removes that reading, +so the install puts it back. + +The entry is written **`enabled = false`** first. io-harness then reads, parses, +validates and trust-checks the bundle for real — there is no public way to ask it +about a directory that no configuration declares — and hands it back contributing +nothing at all. What you are shown is what io-harness parsed: the skills and +template directories, the agents, the MCP servers and the policy layers, in the +**namespaced** names you will actually see in a trace and type to spawn an agent. +A bundle io-harness would refuse is refused at that point, in its own words, +before you are asked anything. + +Saying yes changes one key. Saying no leaves the bundle declared, switched off, +and listed in `/plugin` — visible, and one keystroke from being switched on if +you change your mind. + +Hooks are named here too, read from the manifest, because `enabled = false` is +still not enough to make io-harness tell you what a hook runs. Consenting on the +bare word "hooks" is consenting to programs nobody named. + +**Writing `enabled` costs something and io says so at the time.** An io-cli built +against io-harness 0.69.0 does not know the key and refuses the *whole file* +rather than ignoring it. Remove the `enabled` keys before downgrading. ## Hooks @@ -1445,26 +1525,34 @@ default io-harness will use. You are told before the turn is spent, because the author of a commit is the one thing about it that cannot be corrected afterwards without rewriting history. -### The refusal this repairs - -**Before 0.25.0 all seven tools were refused before they ran, for most operators, -and nobody was ever asked.** io-harness's git spawn checks the `exec` policy -itself and accepts only an outright allow. Every other gated act turns an *ask* -into a question on your screen and waits for it; this one returns a refusal -instead, so `ask` behaves exactly as `deny` does. `ask before writes` — the -posture the wizard recommends, and the one this README recommends with it — sets -`exec` to ask. That is the whole of the defect, and it is filed upstream as -io-harness#214. - -What io-cli does about it is name the refusal and offer one rule: `exec` allowed -for `git`, one binary, for this session. `/commit` asks that question *before* it -spends the turn, because a commit the policy was always going to refuse still -costs a real completion to discover. The rule goes through the same remembered -layer as anything else you allow for a session, so it is exactly as strong as -those and no stronger: it widens an asking default and cannot take back a deny -from a layer beneath it. One binary name is also the narrowest grant that works — -an `exec` pattern has no notion of a subcommand, so `git` says *this program may -be spawned* and nothing about any other. +### The refusal this repairs — and the half of it that is now fixed upstream + +**Through io-harness 0.69.0, all seven tools were refused before they ran, for +most operators, and nobody was ever asked.** io-harness's git spawn checked the +`exec` policy itself and accepted only an outright allow. Every other gated act +turned an *ask* into a question on your screen and waited for it; this one +returned a refusal instead, so `ask` behaved exactly as `deny` did — and `ask +before writes`, the posture the wizard recommends, sets `exec` to ask. io-cli +0.25.0 found that and filed it as io-harness#214. + +**io-harness 0.70.0 closed it, and 0.29.0 pins 0.70.0.** An asking posture now +raises an ordinary approval: you get the question, and git runs if you say so. +If you run the recommended posture, none of the rest of this section applies to +you any more. + +What is left is `read only`, where `exec` is a **deny** and there is still no +question for you to answer. There io-cli names the refusal and offers one rule: +`exec` allowed for `git`, one binary, for this session. `/commit` asks that +*before* it spends the turn, because a commit the policy was always going to +refuse still costs a real completion to discover. The rule goes through the same +remembered layer as anything else you allow for a session, so it is exactly as +strong as those and no stronger — and it is offered **only** where the deny came +from the posture's own default rather than from a rule somebody wrote, because a +later layer can add capability but can never take back a denial. Offering it +against a deny rule would be advice that can never be taken. One binary name is +also the narrowest grant that works: an `exec` pattern has no notion of a +subcommand, so `git` says *this program may be spawned* and nothing about any +other. **Under a posture that denies rather than asks, no rule is offered.** A rule is matched before a default, so the same allowance would work under `read only` too, @@ -1861,9 +1949,16 @@ io mcp edit semlith --timeout-secs 30 io mcp remove semlith io plugin add ./bundles/rust-review +io plugin add ultraship # or a name from a marketplace +io plugin install ultraship # the same verb +io plugin search review io plugin list io plugin remove ./bundles/rust-review +io plugin marketplace add zeroonething/ultraship +io plugin marketplace list +io plugin marketplace remove zeroonething/ultraship + io config get run.max_steps io config set run.max_steps 40 io config set app.io-cli.gates.command cargo test --all-features @@ -1907,6 +2002,13 @@ refuse one by name — the change goes to the file that declares the entry, and scope chosen here would aim a position counted in one file's array at another file's. +**`io plugin add ` from a marketplace declares the bundle and stops there.** +It writes the entry switched off, prints the disclosure to standard error, and +exits zero; switching it on is `/plugin`, in a session, where there is somebody to +ask. There is deliberately no `--yes`: consent to a stranger's code is not a flag +a script sets on your behalf. A path you already have is unaffected — `io plugin +add ./bundles/rust-review` behaves exactly as it did. + **`io config list` prints the origin column**, tab-separated after the value: `user`, `project`, `local`, or `default` for a key no file names. There is no flag to drop it. A value without the file that decided it is half an answer — that is @@ -2218,6 +2320,19 @@ entirely, is named outright — `/plugin add `, or `io plugin add ` a shell — and is refused by the same check rather than by a shallower one. See [Capability bundles](#capability-bundles). +**From 0.29.0 the same verb also takes a name.** `/plugin add ultraship` installs +a bundle out of a marketplace you have added, and `ultraship@zeroonething/ultraship` +says which one where two carry that name — a bare name two marketplaces carry is +refused rather than resolved, because taking the first match installs code you did +not choose. `install` is accepted as the same verb. **A word is a path if it +resolves to a directory carrying a manifest, and a name otherwise**: the rule asks +the disk rather than the spelling, so one word cannot mean a directory on a machine +that has one and a marketplace bundle on a machine that does not. + +Installing by name **declares the bundle switched off and shows you what it would +bring before it brings it** — see [Marketplaces](#marketplaces). Installing by +path does not: that directory is one you already have. + **`/profile`** switches to a named `[profile.]` for the session, and `--profile ` picks one for a single run without writing anything. diff --git a/docs/config.example.toml b/docs/config.example.toml index 3fde07d..f6f8cd4 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -285,17 +285,33 @@ net = "deny" # --- Capability bundles (0.20.0) -------------------------------------------- # # A bundle is a directory with a `plugin.toml` in it, and it is loaded because -# THIS FILE NAMES IT. There is no directory io walks looking for bundles and no -# registry it fetches from: a `[[plugin]]` entry is the whole of installing one, -# and deleting the entry is the whole of removing one. +# THIS FILE NAMES IT. There is no directory io walks looking for bundles: a +# `[[plugin]]` entry is the whole of installing one, and deleting the entry is +# the whole of removing one. +# +# THERE IS STILL NO REGISTRY, and from 0.29.0 that sentence needs its second +# half. `/plugin marketplace add /` clones a git repository into +# `~/.io-cli/marketplaces` and lists the directories in it that carry a +# `plugin.toml`, so a bundle can be found without knowing its path. io operates +# no index, hosts nothing, curates nothing and ranks nothing — a marketplace is +# a repository you named, on your disk, and installing out of one still writes +# exactly the `[[plugin]]` entry below. The clone is a `git` invocation; there +# is no HTTP client here and no network path beside io-harness's. # # One directory can contribute six kinds of thing at once — skills, prompt # templates, [[agent]] definitions, [[mcp]] servers, [[hook]] tables and policy -# layers — which is why there is a surface for it. `/plugin` lists what loaded, -# what each bundle contributed, and every bundle that was declared and DROPPED -# with io-harness's own reason beside it. That loader has no error path: a -# dropped bundle is recorded and otherwise silently absent while every other one -# loads. +# layers — which is why there is a surface for it. `/plugin` lists THREE sets: +# what loaded and what each bundle contributed; what was declared `enabled = +# false`, under its own mark, with what switching it back on would bring; and +# every bundle that was declared and DROPPED with io-harness's own reason beside +# it. That loader has no error path: a dropped bundle is recorded and otherwise +# silently absent while every other one loads. +# +# `enabled` is io-harness 0.70.0's, and it defaults to true, so an entry written +# before it existed means what it always did. WRITING IT COSTS SOMETHING: an +# io-cli built against io-harness 0.69.0 does not know the key and refuses the +# WHOLE FILE rather than ignoring it. Remove the `enabled` keys before +# downgrading. io says this at the moment it writes one. # # WHICH FILE DECLARES A BUNDLE DECIDES WHAT IT MAY CONTRIBUTE. A bundle named in # the project-scoped io.toml — the file a `git clone` delivers — may contribute diff --git a/docs/screenshot.png b/docs/screenshot.png index 5f60eb5..d78b135 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/src/app.rs b/src/app.rs index 7a2217d..df3637a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -714,16 +714,26 @@ impl App { /// Allow `git` for the rest of this session. /// - /// **The only door to this rule, and it exists because the ordinary one is - /// shut.** Every other entry in `remembered` arrives through + /// **The only door to this rule, and the reason it exists narrowed under + /// io-harness 0.70.0.** Every other entry in `remembered` arrives through /// [`App::answer_approval`]: the policy said `Ask`, a question reached the - /// operator, and they answered it with *this session*. The harness's git - /// tools never take that path. `Git::run` refuses anything short of - /// `Effect::Allow` before an approver is consulted at all — see - /// [`crate::approval::refuses_git`] — so under the posture the wizard - /// recommends the seven git tools are refused without anyone ever being - /// asked, and there is no question for an answer to attach to. This method is - /// the answer with no question in front of it. + /// operator, and they answered it with *this session*. + /// + /// Through 0.69.0 the harness's git tools never took that path — `Git::run` + /// refused anything short of `Effect::Allow` before an approver was consulted + /// at all, so under the posture the wizard recommends the seven git tools were + /// refused without anyone being asked. io-cli 0.25.0 found that and reported it + /// as io-harness#214, and **0.70.0 closed it**: an asking posture now raises an + /// ordinary approval, so that operator gets a question and this method is not + /// what answers it. + /// + /// What is left is the posture whose `exec` is a **deny** — `read only` — where + /// there is still no question for an answer to attach to. This method remains + /// the answer with no question in front of it, for the case where the deny came + /// from a tier default rather than from a rule; `crate::commit::asked` reads + /// `Verdict::rule` to tell those apart, because under a deny *rule* the + /// allowance can never win and offering it would print advice that can never be + /// taken. /// /// Idempotent, and by value rather than by count: the rule is a fact about /// the session and not a tally of how many times it was asked for. Pressing diff --git a/src/approval.rs b/src/approval.rs index 0dfae8f..f88d789 100644 --- a/src/approval.rs +++ b/src/approval.rs @@ -743,31 +743,52 @@ pub fn session_policy( /// name is the whole vocabulary a rule about git has. pub const GIT: &str = "git"; -/// Will this policy refuse a git spawn? -/// -/// `Ask` counts as a refusal here, and that is the entire point. Every other -/// gated act in the harness turns `Effect::Ask` into a question for the -/// [`Approver`]; git's spawn does not. `Git::run`, at `src/tools/git.rs:549-561` -/// of io-harness 0.69.0, checks `self.policy.check(Act::Exec, &self.program)` and -/// returns `Error::Refused` on **anything that is not `Allow`**, before any -/// approver exists to be consulted. So the question a caller needs answered is -/// not *does this ask* but *does this fall short of allow*, which is what this -/// answers. -pub fn refuses_git(policy: &io_harness::Policy) -> bool { - policy.check(Act::Exec, GIT).effect != Effect::Allow -} +// **`refuses_git` was here, and io-harness 0.70.0 both fixed the defect it +// described and removed the reason it existed.** +// +// It answered `check(Act::Exec, GIT).effect != Effect::Allow`, and its doc +// explained that `Ask` had to count as a refusal because `Git::run` returned +// `Error::Refused` on anything short of `Allow` before any approver was +// consulted — the behaviour io-cli 0.25.0 found and reported as io-harness#214. +// +// 0.70.0 closed that issue at all four sites carrying the comparison: `Ask` on +// `Act::Exec` now raises an approval like every other gated act. So the +// inequality this function was built on stopped describing anything — under the +// posture the wizard recommends, git is *asked about* and then runs, and the +// live arm that asserted a refusal came back with `refusals []`. +// +// It is deleted rather than corrected because it had **no production caller** — +// only tests — and because the question it answered is now answered better by +// observation than by prediction. [`crate::app::App::note_git`] fires on an +// `EventKind::Refused` the harness actually emitted, so it cannot explain a +// refusal that did not happen; and `crate::commit::asked` reads the verdict's +// own `rule` field to decide whether the allowance can help. A predicate that +// guessed at the answer in front of them was a sixth instance of the shape this +// product has shipped five times: public, tested, and called by nothing. +// +// What the tests assert now is `check(Act::Exec, GIT).effect` itself, which is +// the fact, and [`git_allowance`] below is unchanged: it is still what lifts a +// `Deny` that came from a tier default rather than from a rule. /// The rule that lets the git tools run at all. /// -/// [`crate::settings::Posture::AskWrites`] sets `exec: Effect::Ask`, and it is the -/// posture the wizard recommends — so on the reading in [`refuses_git`] the seven -/// git tools are refused, for most operators, without anyone ever being asked. The -/// repair is a rule, not a special case in the spawn path: io-cli offers this -/// through the same `remembered` layer [`effective_policy`] already builds, so a -/// git allowance is exactly as strong as any other thing the operator allowed for -/// the session — it widens an *asking* default and still cannot re-allow a deny -/// from a layer beneath it, because layers add capability and never take a denial -/// back. +/// **The posture this was built for stopped needing it, and the one that still +/// does is the other one.** [`crate::settings::Posture::AskWrites`] sets +/// `exec: Effect::Ask`, and through io-harness 0.69.0 that was a hard refusal in +/// the git spawn — the seven git tools refused for most operators without anyone +/// ever being asked, which io-cli 0.25.0 reported as io-harness#214. 0.70.0 fixed +/// it: an asking posture now raises an approval and git runs when the operator +/// says so, with no rule needed. +/// +/// What still reaches here is a posture whose `exec` is `Deny` — `read only` — and +/// a deny is only liftable when it came from a **tier default** rather than from a +/// rule, which is the discriminator `crate::commit::asked` reads off +/// `Verdict::rule`. The repair is still a rule and not a special case in the spawn +/// path: io-cli offers this through the same `remembered` layer +/// [`effective_policy`] already builds, so a git allowance is exactly as strong as +/// any other thing the operator allowed for the session — and it still cannot +/// re-allow a deny from a layer beneath it, because layers add capability and +/// never take a denial back. /// /// The pattern is one binary name. `Act::Exec` matching (see [`GIT`]) has no /// notion of a subcommand, so `"git"` is the narrowest expressible grant that diff --git a/src/cli.rs b/src/cli.rs index ba46b0e..530f7da 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -82,7 +82,8 @@ pub enum Command { /// Add, list, inspect, change or remove an MCP server, without opening a /// session. Mcp(Manage), - /// Add, list or remove a capability bundle, without opening a session. + /// Add, list, search for or remove a capability bundle, and manage the + /// marketplaces bundles come from, without opening a session. Plugin(Manage), /// Read or write one configuration key, without opening a session. Config(Manage), diff --git a/src/commands.rs b/src/commands.rs index 96aff72..d72baee 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -221,15 +221,16 @@ pub const COMMANDS: &[(&str, &str)] = &[ // which is the argument for it being visible at all, and the argument for it // being under "configure" rather than "inspect". // - // **What it does not do is add one, and the description says "loaded" rather - // than implying otherwise.** Declaring a bundle means naming a directory, - // which is a path an operator types far more comfortably into their own file - // than into a picker; removing one means finding the right entry among three - // scope files, which is exactly the part a surface can do better than a - // person. So this does the second and not the first. + // **0.29.0 adds where a bundle comes from, and it is a verb inside this + // command rather than a command of its own.** `Configure` is nine of ten and + // the product has one free slot; a marketplace is not a tenth surface, it is + // the repository the bundles on this surface were fetched from, so it belongs + // under the list it feeds. `/plugin marketplace add|list|remove` is read by + // `manage::parse` — the same parse `io plugin marketplace …` goes through — + // and the picker offers the same three verbs without anything being typed. ( "/plugin", - "the capability bundles loaded, what each contributed, and the ones that failed", + "the capability bundles declared, the marketplaces they come from, and what failed", ), ( "/import", @@ -1234,6 +1235,15 @@ pub enum Action { /// declared in the project file that contributes `[[hook]]` or `[[mcp]]` is /// refused **whole**, and the sentence is what names the two files it could /// move to instead. + /// + /// **Since 0.29.0 the panel also offers the marketplaces bundles come from.** + /// That is one extra row on this surface — the verbs live behind it, under + /// their own picker — and the same three verbs are typed as + /// `/plugin marketplace add|list|remove`, which routes to [`Action::Manage`] + /// and is therefore read by the same parse `io plugin marketplace …` goes + /// through. Nothing about a marketplace is written into a configuration file: + /// adding one clones a repository into `~/.io-cli/marketplaces` and removing + /// one deletes that clone, leaving every `[[plugin]]` entry exactly as it was. Plugin, /// Bring an operator's work across from another agent they already use. /// @@ -1920,6 +1930,11 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // media for one reading, not two decisions: the write verbs, where a // disagreement would land in an operator's file, all go through the one // parse. + // **`servers` is `mcp` there too.** The line travels verbatim and + // `manage::parse` folds the plural onto the singular itself — see the fold + // at the top of it — because `io servers add …` from a shell never passes + // through here. Routing a spelling the parse then refuses is the shape + // this arm shipped with until 0.29.0. "mcp" | "servers" if matches!( input.split_whitespace().nth(1), @@ -1962,8 +1977,23 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // `/plugins` is admitted for the same reason `/servers` and `/providers` // are: the thing being listed is plural, so the plural is what a hand // reaches for, and refusing it teaches nothing. + // `marketplace` is here beside `add` and `remove` because it is the same + // kind of word: a verb with arguments, read by `manage::parse` and by + // nothing else. A bare `/plugin marketplace` reaches the parse too and is + // refused there by name — which is better than opening the bundle list + // over a line that asked for something else. + // `install` is `add`'s other spelling and `search` is a read across the + // marketplaces; both are `manage::parse`'s words, so both route here rather + // than opening the panel over a line that asked for something else. + // **And `plugins` is `plugin` on the far side as well**: the fold lives at + // the top of `manage::parse`, where `io plugins install x` from a shell + // reaches it too. Admitting a spelling here that the parse refuses teaches + // less than refusing it outright, which is what this arm did until 0.29.0. "plugin" | "plugins" - if matches!(input.split_whitespace().nth(1), Some("add" | "remove")) => + if matches!( + input.split_whitespace().nth(1), + Some("add" | "install" | "remove" | "search" | "marketplace") + ) => { Action::Manage(input.to_string()) } diff --git a/src/exec.rs b/src/exec.rs index 1ec5a9e..cf80f16 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -69,14 +69,20 @@ pub const UNFINISHED: u8 = 5; /// this is the first ending this subcommand has ever been able to tell apart /// from them. /// -/// It cannot be decided by [`code`], because io-harness has no `RunOutcome` -/// variant for a run whose criterion answered no. Such a run surfaces as -/// `StepCapReached` — the criterion is re-evaluated after every step and the loop -/// only ends early when it *passes*, so a gate that never passes spends the whole -/// budget. (Not as `Finished`: that variant requires `Verification::None`, so a -/// gated contract can never return it.) The verdict is therefore read from the -/// store after the run and applied by [`verified_code`]. Reported upstream as -/// io-harness#212. +/// **io-harness#212 is closed, and 0.70.0 is where this code stops being an +/// inference.** Until that release the harness had no `RunOutcome` variant for a +/// run whose criterion answered no: such a run surfaced as `StepCapReached` — +/// the criterion is re-evaluated after every step and the loop only ends early +/// when it *passes*, so a gate that never passes spends the whole budget — and +/// the verdict had to be read from the store afterwards by [`verified_code`]. +/// 0.70.0 adds `RunOutcome::VerificationFailed { steps }` for exactly that run, +/// and narrows `StepCapReached` to mean only that nothing judged the work. +/// +/// So [`code`] now decides it directly, and [`verified_code`] stays as the +/// second route rather than the only one. **The two must agree, and they do:** +/// both answer this constant. Keeping the store route is not redundancy — a +/// gate can fail on a run that ended for some other reason entirely, and only +/// the recorded `GateOutcome` sees that one. pub const UNVERIFIED: u8 = 6; /// The exit status for a run that reached the harness. @@ -118,6 +124,17 @@ pub fn code(outcome: &RunOutcome) -> u8 { | RunOutcome::CostBudgetExceeded { .. } | RunOutcome::BudgetCeilingReached { .. } => CEILING, + // io-harness 0.70.0, and it is this crate's own issue #212 coming back + // implemented. It is **not** a ceiling: the run did reach its step cap, + // but the fact worth reporting is that the work was judged and did not + // hold up, which is what `UNVERIFIED` has meant since 0.24.0. Mapping it + // to `CEILING` would move exactly the runs that code was invented for + // from 6 to 3 on a pin bump — a published exit table changing meaning + // underneath an operator's CI. `StepCapReached` above now means only + // that nothing judged the work, which is why the two can be told apart + // here at all. + RunOutcome::VerificationFailed { .. } => UNVERIFIED, + // `AwaitingApproval` stays unreachable from here while approvals are // denied rather than deferred; the other two are reachable, because a // question about intent and a proposed plan pass through no approver at @@ -184,6 +201,10 @@ pub fn describe(outcome: &RunOutcome) -> String { RunOutcome::Success { steps } => ("finished, and its verification passed", steps), RunOutcome::Finished { steps } => ("finished", steps), RunOutcome::StepCapReached { steps } => ("stopped at the step cap", steps), + RunOutcome::VerificationFailed { steps } => ( + "stopped at the step cap, and its verification failed", + steps, + ), RunOutcome::TimeBudgetExceeded { steps } => ("stopped at the time budget", steps), RunOutcome::CostBudgetExceeded { steps } => ("stopped at the token budget", steps), RunOutcome::BudgetCeilingReached { steps } => { diff --git a/src/fetch.rs b/src/fetch.rs new file mode 100644 index 0000000..256274b --- /dev/null +++ b/src/fetch.rs @@ -0,0 +1,467 @@ +//! Bringing a marketplace onto the disk — the one `git` this crate runs. +//! +//! **This is the second module in `src/` permitted a process spawn, and +//! `tests/dependencies.rs` names it by path to permit it.** The gate is amended +//! rather than relaxed: the permitted set is a set of *exact paths*, a third file +//! naming the literal still fails and fails naming both permitted paths, and the +//! aliased spellings stay banned everywhere — this module included, which is why +//! the import below is written out one name per line. The shape is 0.17.0's and +//! 0.26.0's amendment of the provider gate: an exemption by path, held to +//! properties that are asserted rather than promised. +//! +//! **Why a spawn at all.** io-harness owns git and publishes no way to run it: +//! `Git`, `Git::run` and `GitCmd` are all `pub(crate)` in its `tools/git.rs`, and +//! that file's own tests assert that no argv the harness can build ever carries +//! `clone`, `fetch` or `push`. Its engine exists for a workspace the agent is +//! already inside, not for bringing a new one down, so there is no governed call +//! to reach for. The alternative is an HTTP client — an eleventh direct +//! dependency, a TLS stack under it, and a second network path beside the one +//! io-harness owns, which is the thing this product exists not to grow. Cloning +//! also makes updating a marketplace a pull and pinning one a ref rather than two +//! features this crate would otherwise have to invent. +//! +//! `product.yaml:253-258` records the price honestly: that `git` is present is an +//! **assumption about developer machines, not a fact this repository has +//! checked**. So a machine without it gets [`Fetched::NoGit`] and the sentence +//! [`Fetched::sentence`] writes, never a panic and never a stack trace — and the +//! route that installs a plugin from a directory the operator already has does not +//! go through here at all. +//! +//! **The argv is a value, not a string.** [`argv`] is a pure function returning +//! five owned elements — `clone`, `--depth`, `1`, the URL, the destination — and +//! nothing in this file ever splices a name, a URL or a path into a larger string +//! for a program to take apart again. No shell is invoked, so there is no quoting +//! question to get wrong and no metacharacter in a repository name to think about. +//! `tests/fetch.rs` asserts the URL and the path arrive as their own elements, +//! byte for byte, rather than trusting that they do, and `tests/dependencies.rs` +//! asserts this file builds no argument with `format!` — which is the sabotage F10 +//! names by name. +//! +//! **Nothing io-harness drives can reach this.** The module names none of the +//! types the event stream, the conversation or the trace are made of, exactly as +//! `src/shell.rs` names none of them, and `tests/dependencies.rs` asserts that of +//! both permitted modules in one loop. A marketplace is fetched because the +//! operator typed a name. +//! +//! **A failure at any point leaves no half-cloned directory presented as a +//! marketplace, and the mechanism is a rename rather than a cleanup.** git clones +//! into `~/.io-cli/.fetching/` and the finished tree is moved into +//! `~/.io-cli/marketplaces//` in one step. Both paths are inside the +//! operator's own home, so the move is a same-filesystem rename and there is no +//! cross-device copy that could itself stop half way. +//! +//! Removing the destination on failure would have been fewer lines, and it covers +//! every failure this process survives to see. It covers none of the ones it does +//! not: a `kill -9`, a panic in another thread, or the machine losing power in the +//! middle of a clone is exactly how a directory holding half a `plugin.toml` ends +//! up under a path the listing walks and the installer trusts. The staging +//! directory is dot-named and sits *outside* the marketplaces tree for the same +//! reason — whatever is left in it after a kill can never be counted as a +//! marketplace, because nothing walks it. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +// One name per line, and `std::process::Command` written out in full. A braced or +// aliased import would put a spawn in a file where the literal never appears, +// which is the evasion `tests/dependencies.rs` forbids in every file — this one +// included, because a permission that can be spelled around is not a permission. +use std::process::Command; +use std::process::Stdio; + +/// The program, and the whole of it. +/// +/// A constant rather than a literal at the call site so that +/// `tests/dependencies.rs` can assert both halves of the property F10 states: that +/// this is the string `git`, and that the single `Command::new` in this file takes +/// *this* and not a name computed from anything. A program that came from a +/// variable is how a spawn stops being a spawn of git. +pub const PROGRAM: &str = "git"; + +/// The only forge a bare `/` resolves against. +/// +/// **A stated ceiling, not an oversight.** The release adds a marketplace *by +/// name*, and one host is what makes a name short enough to type and to say out +/// loud. An operator on another forge is not served by this release, and the +/// upgrade is one more accepted grammar in [`resolve`] — not a change here, and +/// not a URL passed through unread: an argument that reaches `git clone` having +/// been typed by somebody is how `ext::sh -c …` becomes a remote shell, and +/// [`resolve`] refuses everything that is not two ordinary path segments precisely +/// so that this string is the only host that can ever be reached. +const HOST: &str = "https://github.com/"; + +/// The variable that stops git asking for a credential on a terminal io-cli owns. +/// +/// **A `/dev/null` stdin is not enough, and that is the whole reason this is +/// here.** git does not prompt on stdin: it opens `/dev/tty` directly, which is +/// still this process's terminal — in raw mode, with an inline viewport whose +/// absolute screen row was computed once and is maintained afterwards by +/// arithmetic alone. A credential prompt written there scrolls the screen behind +/// ratatui's back, and on this renderer the transcript *is* the scrollback and +/// cannot be redrawn, which is the damage `src/shell.rs` documents at length as +/// its reason never to hand the terminal over. Setting this makes git fail with a +/// sentence instead, and that sentence arrives here as the clone's own stderr. +/// +/// It is an environment variable and not an argument, so the argv stays exactly +/// the five elements [`argv`] returns. +const NO_PROMPT: &str = "GIT_TERMINAL_PROMPT"; + +/// What an operator is told when there is no git to run. +/// +/// Named, and about their machine rather than about this program. The second +/// sentence is the part that matters: this is the *only* thing io-cli does that +/// needs git, so the answer is not "io-cli does not work here". +const NO_GIT: &str = "no `git` on PATH — a marketplace is a repository io-cli clones, so \ + adding one needs git installed. Installing a plugin from a directory \ + you already have does not."; + +/// A marketplace named the way an operator says it: an owner and a repository. +/// +/// Two owned strings rather than one borrowed `&str`, because both halves become +/// path components and an argv element that outlives the text they were parsed +/// from. Kept separate rather than stored joined so that neither the URL nor the +/// destination has to take a string apart again to find them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Named { + /// The account or organisation that holds the repository. + pub owner: String, + /// The repository itself, with no `.git` on the end — [`resolve`] takes it + /// off, so the name that reaches a path and the name an operator typed are + /// the same name. + pub repo: String, +} + +/// What a fetch did. +/// +/// Four endings, and each is a different sentence to a different person: it +/// worked; it was already here; this machine has no git; git ran and said no. The +/// last two are deliberately not one variant — "git could not be started" and +/// "git started and refused" are the difference between installing a tool and +/// fixing a name, and folding them together is how an operator is told to install +/// something they already have. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Fetched { + /// The clone is in place, at this path. + Cloned(PathBuf), + /// Something is already at the destination, and it was left exactly as it + /// was. **Never cloned over**: the directory may hold a marketplace an + /// operator has installed plugins from, and a `[[plugin]] path` in their + /// configuration points into it. + Already(PathBuf), + /// There is no `git` on `PATH`. + NoGit, + /// git ran and did not succeed — or could not be started for a reason that is + /// not its absence, which is the same thing to the operator and a different + /// thing to anyone reading a bug report. + Failed { + /// The exit code, or `None` where the platform reported none — which on + /// unix means a signal ended it, and here also means the failure was not + /// an exit at all. + status: Option, + /// What git printed, with control characters dropped. + /// + /// **Cleaned at construction rather than at the point it is drawn.** This + /// is output from a program io-cli did not write, and on this renderer the + /// scrollback is the transcript: a `\x1b[3J` inside it would erase the + /// session's whole history. The same rule, and the same reason, as the + /// captured output of a `!` line. + stderr: String, + }, +} + +impl Fetched { + /// Which ending an [`std::io::Error`] from the spawn itself is. + /// + /// **The distinction is the whole reason this is a function.** `NotFound` from + /// a spawn means the program is not on `PATH`, which is the one failure this + /// release has to name in the operator's own words; every other kind — + /// `PermissionDenied` on a `git` that is not executable, and everything the + /// platform can invent — is a real error whose text is more useful than any + /// sentence written here would be. Mapping them all to [`Fetched::NoGit`] + /// would tell an operator with git installed to install git, and + /// `tests/fetch.rs` asserts that it does not. + #[must_use] + pub fn unstartable(error: &std::io::Error) -> Self { + if error.kind() == std::io::ErrorKind::NotFound { + return Fetched::NoGit; + } + Fetched::Failed { + status: None, + stderr: error.to_string(), + } + } + + /// One line for the operator, or `None` where there is nothing to say. + /// + /// `None` for [`Fetched::Cloned`] on purpose: the surface that asked for the + /// fetch says what it now has, and a success line written here as well would + /// be the same fact twice in two vocabularies. + /// + /// Assembled by pushing rather than by interpolation, which is the same + /// discipline the argv is held to and is asserted the same way — this file + /// builds no string out of a name with `format!`, anywhere, so there is one + /// rule to read rather than an exception to check. + #[must_use] + pub fn sentence(&self) -> Option { + match self { + Fetched::Cloned(_) => None, + Fetched::Already(here) => { + let mut said = String::from("that marketplace is already here: "); + said.push_str(&here.display().to_string()); + Some(said) + } + Fetched::NoGit => Some(String::from(NO_GIT)), + Fetched::Failed { status, stderr } => { + let mut said = String::from("git could not clone that"); + // The **last** non-blank line, because git narrates on stderr — + // `Cloning into '…'` first, then a `fatal:` that says what + // actually went wrong. Taking the first line would report the + // narration and hide the reason. + match stderr.lines().rev().find(|line| !line.trim().is_empty()) { + Some(reason) => { + said.push_str(": "); + said.push_str(reason.trim()); + } + None => { + // A silent failure still gets a number, because "git could + // not clone that" on its own is a sentence an operator can + // do nothing with. + if let Some(code) = status { + said.push_str(" (exited "); + said.push_str(&code.to_string()); + said.push(')'); + } + } + } + Some(said) + } + } + } +} + +/// What an operator typed, if it is an owner and a repository. +/// +/// **A pure function, and the only place a name is judged.** `None` means "that is +/// not a marketplace name" and never "that marketplace does not exist" — nothing +/// here touches the network or the disk, so this is answerable, and asserted, with +/// no fixture at all. +/// +/// Accepted: `owner/repo`, with a trailing `/` and a trailing `.git` taken off, +/// because both are what a paste leaves behind. Everything else is refused, +/// including a full URL — a URL has more than two segments and lands in the same +/// refusal, which is the outcome that matters: the only string that can reach +/// `git clone` is one this function built out of `HOST`, this module's own +/// constant. +/// +/// **Two of the refusals are the load-bearing ones.** A segment starting with `-` +/// would become an *option* the moment it were passed to a program that parses +/// options, whatever the argv looked like when it left here. A segment containing +/// `..` would leave the marketplaces directory the moment it were joined onto a +/// path. Neither is a hypothetical: both are what a name is for, if the person +/// choosing the name is choosing it against you. +#[must_use] +pub fn resolve(text: &str) -> Option { + let text = text.trim().trim_end_matches('/'); + let (owner, repo) = text.split_once('/')?; + let repo = repo.strip_suffix(".git").unwrap_or(repo); + // A third segment is a URL, a path, or a name for something this release does + // not have. Refused here rather than silently keeping the first two. + if repo.contains('/') { + return None; + } + if !segment(owner) || !segment(repo) { + return None; + } + Some(Named { + owner: owner.to_string(), + repo: repo.to_string(), + }) +} + +/// Whether one half of a name is a name at all. +/// +/// The permitted alphabet is what a forge permits and no more: letters, digits, +/// `-`, `_` and `.`. Stated as what is *allowed* rather than as a list of what is +/// forbidden, which is the only direction that stays correct when somebody thinks +/// of a character nobody here did. +fn segment(text: &str) -> bool { + !text.is_empty() + && !text.starts_with('-') + && !text.starts_with('.') + && !text.contains("..") + && text.chars().all(ordinary) +} + +/// Whether one character may appear in a name. +fn ordinary(glyph: char) -> bool { + glyph.is_ascii_alphanumeric() || glyph == '-' || glyph == '_' || glyph == '.' +} + +/// The repository a name points at. +/// +/// One element of the argv, built once, from a [`Named`] that [`resolve`] has +/// already judged — so there is no character in it that could mean anything to +/// anything downstream. +#[must_use] +pub fn url(named: &Named) -> String { + let mut url = String::from(HOST); + url.push_str(&named.owner); + url.push('/'); + url.push_str(&named.repo); + url.push_str(".git"); + url +} + +/// Where a marketplace lives once it is here. +/// +/// Two levels — `/` — rather than one flattened name, so two owners +/// may carry a repository of the same name and neither has to be qualified on +/// disk. `None` where the operator's home directory cannot be determined, which is +/// the answer [`crate::home::path`] gives for the same reason. +#[must_use] +pub fn at(named: &Named) -> Option { + Some( + crate::home::marketplaces()? + .join(&named.owner) + .join(&named.repo), + ) +} + +/// The argv, as five owned elements. +/// +/// **This is the property F10 is about, and it is a function so that it can be +/// asserted rather than described.** `url` and `into` are handed through as whole +/// elements — not quoted, not escaped, not joined to anything, not spliced into a +/// line for something else to split again. There is no shell between here and the +/// program, so a space, a quote or a semicolon in either is a space, a quote or a +/// semicolon in one argument and cannot be anything else. +/// +/// `OsString` rather than `String` because a destination is a path, and a path on +/// neither unix nor Windows is guaranteed to be UTF-8. Converting it to a `String` +/// to build an argument would be the first step of exactly the interpolation this +/// function exists to make impossible. +#[must_use] +pub fn argv(url: &str, into: &Path) -> Vec { + vec![ + OsString::from("clone"), + // Shallow, which is the whole of the bound: a marketplace is read for the + // directories in it, and its history is somebody else's. + OsString::from("--depth"), + OsString::from("1"), + OsString::from(url), + into.as_os_str().to_os_string(), + ] +} + +/// Clone `url` into `into`, assembling it at `staging` first. +/// +/// Both paths are the caller's, which is what lets `tests/fetch.rs` drive every +/// ending of this function against a temporary directory and a local repository — +/// a decision that lives in the driver is a decision nothing under `tests/` can +/// reach, and this crate has shipped that mistake before. +/// +/// The order is the property: an existing destination is answered **before** +/// anything is spawned, so a marketplace an operator already has is never cloned +/// over and never even risked; the staging directory is cleared before the clone, +/// because a previous run that was killed may have left one; and it is cleared +/// again afterwards whatever happened, so the only thing that can survive this +/// call is a complete tree at `into`. +pub fn clone(url: &str, into: &Path, staging: &Path) -> Fetched { + if into.exists() { + return Fetched::Already(into.to_path_buf()); + } + + // Best effort, and deliberately not fatal on its own: git creates the leading + // directories of its destination itself, and if it cannot, its own error is a + // better sentence than one invented here. + if let Some(parent) = staging.parent() { + let _ = crate::home::create(parent); + } + // Whatever a killed run left. git refuses a destination that exists and is not + // empty, so this is not tidiness — without it the second attempt after a crash + // fails for a reason that has nothing to do with the marketplace. + let _ = std::fs::remove_dir_all(staging); + + // All three streams are spelled out even though `output()` already wires them + // this way. The wiring is the property that keeps the viewport safe — the + // child gets no tty and writes nothing to the screen — and a reader checking + // that should not have to know a default. + let output = Command::new(PROGRAM) + .args(argv(url, staging)) + .env(NO_PROMPT, "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(); + + let fetched = match output { + Err(error) => Fetched::unstartable(&error), + Ok(output) if output.status.success() => match settle(staging, into) { + Ok(()) => Fetched::Cloned(into.to_path_buf()), + // A clone that worked and a move that did not is still a failure with + // nothing at the destination, which is the only shape this module + // promises. The reason is the filesystem's own words. + Err(error) => Fetched::Failed { + status: None, + stderr: error.to_string(), + }, + }, + Ok(output) => Fetched::Failed { + status: output.status.code(), + stderr: readable(&output.stderr), + }, + }; + + // Unconditional, and a no-op on the path that succeeded — the rename already + // took the directory away. On every other path this is what makes the promise + // true for the failures this process lives to see; the rename is what makes it + // true for the ones it does not. + let _ = std::fs::remove_dir_all(staging); + fetched +} + +/// Move a finished clone into place. +/// +/// A rename, and the reason it can be one is that both paths are inside the +/// operator's own home: there is no cross-device case to fall back from, so unlike +/// [`crate::home`]'s own mover this needs no copy-and-verify path. The +/// destination's parent is created first, because a rename onto a path whose +/// parent does not exist fails on every platform. +fn settle(staging: &Path, into: &Path) -> std::io::Result<()> { + if let Some(parent) = into.parent() { + crate::home::create(parent)?; + } + std::fs::rename(staging, into) +} + +/// Captured stderr, made safe to put in a terminal. +/// +/// Text and only text: the control characters go, for the reason +/// [`Fetched::Failed`]'s field documents. Line structure is kept, because +/// [`Fetched::sentence`] reads the last line and a single squashed string has no +/// last line. +fn readable(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes) + .lines() + .map(|line| line.chars().filter(|glyph| !glyph.is_control()).collect()) + .collect::>() + .join("\n") +} + +/// Fetch the marketplace `named` into the operator's own home. +/// +/// `None` where the home cannot be located — the answer [`crate::home::path`] +/// gives, for the reason it gives it: a program that invents a path when it has no +/// home writes into somebody else's. +/// +/// **The staging directory carries this process's own id, and that is not +/// belt-and-braces.** io-cli's session lock is keyed on a conversation rather than +/// on the home (`src/lock.rs`), so two terminals in two repositories are two +/// processes that contend over nothing and share one `~/.io-cli`. A fixed staging +/// path would have them assembling two clones in one directory, and the one that +/// finished second would find the first one's tree. +pub fn fetch(named: &Named) -> Option { + let into = at(named)?; + let staging = crate::home::staging()?.join(std::process::id().to_string()); + Some(clone(&url(named), &into, &staging)) +} diff --git a/src/home.rs b/src/home.rs index 05df373..8dfbbcf 100644 --- a/src/home.rs +++ b/src/home.rs @@ -39,6 +39,34 @@ const FILE: &str = "io.toml"; /// only way an operator finds out where to put a skill without reading a document. const SKILLS: &str = "skills"; +/// The marketplaces directory, created with the home for the reason [`SKILLS`] is. +/// +/// A marketplace is a git repository the operator named, cloned to +/// `/marketplaces//` — two levels, so two owners may carry a +/// repository of the same name and neither has to be qualified on disk. +/// +/// Created here as well as by [`crate::fetch`], which has to make it anyway +/// because [`adopt`] does nothing at all for an operator who named their own +/// location. What creating it here buys is the same thing `skills/` buys: an +/// operator who opens their own home finds the directory before they have added +/// anything to it, which is how they learn where marketplaces go without reading a +/// document. +const MARKETPLACES: &str = "marketplaces"; + +/// Where a clone is assembled before it becomes a marketplace. +/// +/// **Dot-named, and deliberately outside [`MARKETPLACES`].** What is in here is by +/// definition unfinished: a process killed in the middle of a clone leaves a +/// directory holding part of somebody's repository, and if that directory sat +/// under `marketplaces/` it would be walked as an `` and its contents +/// counted as bundles. Outside it, nothing walks it and the worst a kill can leave +/// is wasted disk that the next fetch removes. +/// +/// Not created by [`adopt`]: it exists only while a fetch is in flight, and a home +/// carrying an empty `.fetching` would be one more thing for an operator to +/// wonder about — the same argument that keeps [`MEMORY`] out of [`adopt`]. +const STAGING: &str = ".fetching"; + /// The operator's own guidance file, beside the configuration file it belongs to. /// /// Named here rather than in [`crate::memory`] because the name is a fact about @@ -175,6 +203,34 @@ pub fn path() -> Option { Some(operator()?.join(DIR)) } +/// Where marketplaces are cloned to, whether or not the directory exists yet. +/// +/// Derived from [`path`] rather than from [`in_force`], the way +/// [`crate::contract::skills_dir`] derives the default skills directory +/// (`src/contract.rs:528`): a marketplace is io-cli's own cache of other people's +/// repositories, and an operator who pointed `$IO_CONFIG_HOME` somewhere else +/// moved their *configuration*. Following that variable here would put the clones +/// wherever the configuration is and leave the ones already fetched invisible. +/// +/// **The directory is not promised to exist.** [`adopt`] returns `None` without +/// creating anything whenever either variable is set, so every caller creates it +/// rather than assuming — which [`crate::fetch::clone`] does on the way past. +#[must_use] +pub fn marketplaces() -> Option { + Some(path()?.join(MARKETPLACES)) +} + +/// Where a clone is assembled before it is renamed into [`marketplaces`]. +/// +/// Same home, so the rename that finishes a fetch is a same-filesystem rename with +/// no cross-device case to fall back from — which is the whole reason this is here +/// rather than in the platform's temporary directory, where `/tmp` on a Linux box +/// is routinely a different filesystem from `$HOME`. +#[must_use] +pub fn staging() -> Option { + Some(path()?.join(STAGING)) +} + /// What the operator named, before anything of io-cli's own is set. /// /// [`adopt`] decides on this and nothing else: a variable that is there at all is @@ -272,6 +328,10 @@ pub fn adopt() -> Option { // is a home with no skills in it, while a home that could not be created at // all is a product with nowhere to live. let _ = create(&home.join(SKILLS)); + // The same best-effort, non-fatal shape, and for the same reason: a home + // without a marketplaces directory is a home nobody has added a marketplace + // to yet, and `crate::fetch` makes it when somebody does. + let _ = create(&home.join(MARKETPLACES)); let mut report = Report { home: home.clone(), diff --git a/src/lib.rs b/src/lib.rs index 23d843c..6fc39b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,21 @@ //! never asked. [`approval::git_allowance`] is the single rule that lifts it, and //! it names one binary. //! +//! Since 0.29.0 a capability bundle can come from somewhere other than a path the +//! operator typed. [`fetch`] resolves a name to a repository and clones it into +//! the operator's own home, and it is the **only module besides [`shell`] that +//! runs a program at all** — the harness owns git and publishes no way to run one +//! of its subcommands, so there is no governed call to reach for and the +//! alternative would have been an HTTP client. `tests/dependencies.rs` permits the +//! two by exact path and holds each to properties it asserts rather than trusts. +//! +//! Everything that happens once a clone is on the disk is [`marketplace`] and not +//! [`fetch`], deliberately: `src/fetch.rs` is held to properties asserted over its +//! own text, so listing a marketplace, reading the two keys of a `plugin.toml` and +//! deleting a directory — none of which needs a program — are kept outside that +//! boundary. Both are driven from one place, [`manage`], so `/plugin marketplace +//! add …` and `io plugin marketplace add …` are one reading of one sentence. +//! //! [io-harness]: https://docs.rs/io-harness pub mod app; @@ -65,6 +80,7 @@ pub mod exec; pub mod export; pub mod failure; pub mod fanout; +pub mod fetch; pub mod fleet; pub mod fuzzy; pub mod gates; @@ -76,6 +92,7 @@ pub mod keys; pub mod lock; pub mod manage; pub mod markdown; +pub mod marketplace; pub mod memory; pub mod page; pub mod picker; diff --git a/src/lock.rs b/src/lock.rs index d8a55d8..bdbb426 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -42,9 +42,11 @@ //! //! **Naming the holder cannot mean naming the operating system's process.** //! `tests/dependencies.rs` asserts the direct dependency set in both directions -//! and forbids `nix` outright, and a process spawn is permitted only in -//! `crate::shell`, which may not name a `Store` and is callable only from the -//! driver. (Spelled that way round deliberately: that gate sweeps the raw text +//! and forbids `nix` outright, and a process spawn is permitted only in the two +//! modules that gate names by exact path — `crate::shell`, which may not name a +//! `Store` and is callable only from the driver, and from 0.29.0 `crate::fetch`, +//! which may not name one either and knows nothing of a session. Neither is this +//! module. (Spelled that way round deliberately: that gate sweeps the raw text //! of every source file for the type's full name, comments included, so a module //! that wrote it out to say it was *not* spawning would be listed as a module //! that spawns.) So there is no `kill(pid, 0)`, no `ps` and no `tasklist`, and diff --git a/src/main.rs b/src/main.rs index 534fb06..6ac99cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -728,10 +728,22 @@ fn import_rows( /// A bundle that declares no skills directory is absent rather than present and /// empty: it contributed nothing here, and a row saying so would be a row about /// the absence of a thing the operator never asked for. +/// +/// **And neither is a bundle declared `enabled = false`, which is the one thing +/// reading off `pluginview` costs.** From 0.29.0 `view().plugins` carries the +/// switched-off bundles too, so that `/plugin` can show an operator what they +/// declared — io-harness's own `Plugins::iter` never did, and `skill_dirs` is +/// built off it. The filter is what keeps the two readings the same: a +/// switched-off bundle contributes nothing to a turn, so offering the model its +/// skills would put a name in the palette that `discover_skills` never folded in, +/// and the run would fail on a skill the surface said was there. It would also +/// make `/plugin` report a missing skills directory as a per-turn error for a +/// bundle no turn touches. fn bundle_skills(config: &Config) -> Vec<(String, std::path::PathBuf)> { io_cli::pluginview::view(config) .plugins .into_iter() + .filter(|listed| listed.enabled) .filter_map(|listed| listed.skills.map(|dir| (listed.id, dir))) .collect() } @@ -1745,19 +1757,34 @@ async fn loop_over Result>( Pick::PluginEntry { id, bundle, + enabled, action_at, } => { // Every row but one is a fact to read. if index == *action_at { + // **One act, one wording, and the flag decides it + // once.** The row this descends from already says + // "stop declaring this bundle" over a switched-off + // one, because it is not loading and offering to + // stop that names an act nobody can take — and + // until 0.29.0 the confirmation it opened still + // said `Stop loading {id}?`. Two labels for one + // act, one of them false about the bundle in front + // of the operator, on the screen where they commit. + let verb = if *enabled { "loading" } else { "declaring" }; match io_cli::pluginview::declared_at(session.root(), bundle) { Some((scope, at)) => { descended = Some(( Picker::new( - format!("Stop loading {id}?"), + format!("Stop {verb} {id}?"), vec![ - Row::new("leave it".to_string()), + // `store::LEAVE_IT` rather than + // a literal, so the label and + // the `store::acts` test of it + // cannot drift. + Row::new(io_cli::store::LEAVE_IT.to_string()), Row::with_detail( - "stop loading it".to_string(), + format!("stop {verb} it"), format!( "removes `plugin[{at}]` from the {} \ scope; the directory is left alone", @@ -1802,9 +1829,10 @@ async fn loop_over Result>( scope, index: at, } => { - // Row 0 is "leave it", which is the default and does - // nothing — the same shape `/skills`' toggle uses. - if index == 1 { + // `store::acts` rather than `index == 1`: the decision + // lives in the library, where a test can sabotage it, + // and row 0 declines whatever it is called. + if io_cli::store::acts(index) { let edit = io_cli::pluginview::remove(*at); match io_cli::configure::write(session.root(), *scope, &[edit]) { Ok(()) => { @@ -1826,11 +1854,63 @@ async fn loop_over Result>( } } } - // The add verb, and the one row on this surface that is not - // a bundle. It is checked first because `add_at` is past - // the end of both lists and every branch below indexes into - // one of them. - Pick::Plugins { view: _, add_at } if index == *add_at => { + // The consent a marketplace install waits on. `at` and not + // `index`, for `Pick::PluginRemove`'s reason: the outer + // `index` is the row that was chosen and the entry's own + // position in the file is a different number entirely. + Pick::PluginEnable { + id, + scope, + index: at, + } => { + if io_cli::store::acts(index) { + // **One key.** `pluginview::enable` is an + // `Edit::set` on `plugin[at].enabled`, so the path + // this entry declares, every sibling entry and + // every unrelated section come through byte for + // byte — see `src/edit.rs`, which replaces a + // value's own span and copies the rest. + match io_cli::configure::write( + session.root(), + *scope, + &[io_cli::pluginview::enable(*at)], + ) { + Ok(()) => app.record( + Tone::Success, + format!( + "{id} is switched on; what it contributes is in \ + `/plugin`, and it is in force from the next turn", + ), + ), + Err(refusal) => app.record(Tone::Refused, refusal), + } + } else { + // **Declined leaves it declared, off and visible**, + // and saying so is the whole difference between a + // decline and a bundle that quietly went away. The + // entry is not removed: `/plugin` lists it under + // `pluginview::DISABLED_MARK` with what switching + // it on would bring, which is the one edit an + // operator can undo in a keystroke if they can see + // it. + app.record( + Tone::Muted, + format!( + "{id} is left declared and switched off — nothing of it \ + is in this session; `/plugin` lists it, and switching it \ + on there is one keystroke", + ), + ); + } + } + // The two verb rows, and the only rows on this surface that + // are not bundles. They are checked first because both sit + // past the end of both lists and every branch below indexes + // into one of them. Each is compared against **its own** + // recorded index: neither is derived from the other, so a + // row inserted between them cannot make one of these arms + // answer for the other's row. + Pick::Plugins { add_at, .. } if index == *add_at => { let found = io_cli::pluginview::candidates(session.root()); if found.is_empty() { // **Naming where it looked.** "No bundles found" is @@ -1870,6 +1950,19 @@ async fn loop_over Result>( )); } } + // The marketplaces, behind their own row rather than mixed + // into the list above: a marketplace is not a bundle, and a + // list an operator chooses a bundle out of must not have + // rows in it that are something else. `/plugin marketplace + // list` opens this same surface through `Action::Manage`, + // built by the same function, so the keystroke and the typed + // line cannot draw two different lists. + Pick::Plugins { market_at, .. } if index == *market_at => { + match marketplaces_picker(screen.width(), &app.theme.glyphs) { + Ok(surface) => descended = Some(surface), + Err(refusal) => app.record(Tone::Refused, refusal), + } + } // Row 0 is "leave it", so the candidate's own position is // one less — bound to its own name rather than to `index`, // which is how a confirmation acts on the wrong row. @@ -1916,7 +2009,7 @@ async fn loop_over Result>( } } } - Pick::Plugins { view, add_at: _ } => { + Pick::Plugins { view, .. } => { // **Answered from the reading the rows were drawn // from, and `/skills` does the opposite on purpose.** // There, a row names a file the operator may have @@ -1927,8 +2020,11 @@ async fn loop_over Result>( // and re-reading would answer a different question // than the one the operator asked by choosing a row. // - // The split is `pluginview::rows`'s own: loaded first, - // refused after, no headings, so the index is direct. + // The split is `pluginview::rows`'s own: `view.plugins` + // first — loaded then switched off — refused after, no + // headings, so the index is direct. The two verb rows + // past the end of both lists are answered by the guarded + // arms above and never reach here. if let Some(plugin) = view.plugins.get(index) { // `descended`, not `picker`: the assignment at the // end of this match installs it, while `kind` @@ -1941,10 +2037,18 @@ async fn loop_over Result>( // A bundle is several *lists* — its agents, its // servers, its policy layers — and a sentence // holding three lists is a sentence nobody reads. + // The hooks by name where the manifest is still + // readable, and `pluginview`'s honest placeholder + // where it is not — `hooks` answers an empty + // slice for a directory it cannot open, which is + // the same fact the placeholder states. Read + // here rather than in `pluginview`, which opens + // no manifest by rule. let mut rows = io_cli::pluginview::detail( plugin, screen.width(), &app.theme.glyphs, + &io_cli::marketplace::hooks(&plugin.root), ); // **The action's index is taken before the row is // pushed, never worked out afterwards.** Every @@ -1955,7 +2059,16 @@ async fn loop_over Result>( // was already there, which cannot be wrong. let action_at = rows.len(); rows.push(Row::with_detail( - "stop loading this bundle".to_string(), + // A switched-off bundle is not loading, so + // offering to stop loading it names an action + // nobody can take. The entry is what both + // verbs actually remove. + if plugin.enabled { + "stop loading this bundle" + } else { + "stop declaring this bundle" + } + .to_string(), "removes its `[[plugin]]` entry".to_string(), )); descended = Some(( @@ -1963,6 +2076,10 @@ async fn loop_over Result>( Pick::PluginEntry { id: plugin.id.clone(), bundle: plugin.root.clone(), + // Carried, so the confirmation this + // descends into words the act the same way + // the row above it did. + enabled: plugin.enabled, action_at, }, )); @@ -1989,6 +2106,165 @@ async fn loop_over Result>( ); } } + // The add row, and the one place in this release that asks + // for free text. A marketplace is named `/` + // and there is no list to choose one from — nothing on this + // machine knows what exists on a forge — so the composer is + // prefilled with the verb and the operator types the name, + // which is `Pick::McpEdit`'s own shape and the same one + // surface this product takes free text through. The line + // then goes back through `manage::parse`, so a name typed + // here is judged by the function that judges every other. + Pick::Marketplaces { add_at, .. } if index == *add_at => { + app.record( + Tone::Muted, + "a marketplace is a GitHub repository of capability bundles; \ + type its `/` after the verb and press Enter" + .to_string(), + ); + app.composer.set("/plugin marketplace add "); + } + // Index `i` is `markets[i]`, which is `marketplace::rows`' + // positional contract, and the add row above is the only + // row that is not one. + Pick::Marketplaces { markets, .. } => { + if let Some(market) = markets.get(index) { + if market.bundles.is_empty() { + // Said before the pane opens rather than drawn + // as a row in it: a placeholder row inside the + // list would be an index that maps to no + // bundle, which is the whole class of defect + // this surface is arranged against. + app.record( + Tone::Muted, + format!( + "no directory in {} carries a {}; it may be laid out \ + deeper than io walks, or it may not be a marketplace", + market.root.display(), + io_cli::pluginview::MANIFEST, + ), + ); + } + let mut rows = io_cli::marketplace::bundle_rows( + market, + screen.width(), + &app.theme.glyphs, + ); + // Taken before the row is pushed. See + // `Pick::Marketplace`. + let remove_at = rows.len(); + rows.push(Row::with_detail( + "remove this marketplace".to_string(), + "deletes the clone; no `[[plugin]]` entry is removed" + .to_string(), + )); + descended = Some(( + Picker::new(market.name(), rows), + Pick::Marketplace { + market: market.clone(), + remove_at, + }, + )); + } + } + Pick::Marketplace { market, remove_at } => { + if index == *remove_at { + // **What the removal costs, worked out before it is + // offered and never after.** A bundle declared + // straight out of this clone keeps its `[[plugin]]` + // entry — which is F3 — and stops loading, which is + // F3 read the other way round. Computed here rather + // than in the confirmation's own arm because the + // clone still exists at this point, so the entries + // inside it can still be found. + let inside = io_cli::marketplace::dependents( + &io_cli::pluginview::view(&config), + &market.root, + ); + // `record` and never `say`: the footer is one slot + // and is gone on the next keystroke, and this is a + // consequence the operator has to still be able to + // read after they have answered. + if let Some(said) = io_cli::marketplace::warning(&inside) { + app.record(Tone::Warning, said); + } + descended = Some(( + Picker::new( + format!("Remove {}?", market.name()), + vec![ + // Row 0 declines, and it is + // `store::LEAVE_IT` rather than a + // literal so that the label and the + // `store::acts` test of it cannot drift. + Row::new(io_cli::store::LEAVE_IT.to_string()), + Row::with_detail( + "delete the clone".to_string(), + format!( + "removes {}; every `[[plugin]]` entry is left \ + exactly as it is", + market.root.display() + ), + ), + ], + ), + Pick::MarketplaceRemove { + named: market.named.clone(), + }, + )); + } else if let Some(bundle) = market.bundles.get(index) { + app.record( + Tone::Muted, + format!( + "{} — {} · {}", + bundle.label(), + bundle.line(), + bundle.dir.display(), + ), + ); + // **Declared through the verb that already declares + // a bundle, not through a second writer.** It is + // the same parse, the same `pluginview::refusal` + // and the same `configure::write` every other + // declaration goes through. + // + // **By its qualified name and not by its path**, + // which is `marketplace::matching`'s own spelling + // and always resolves. That is not cosmetic: the + // name is what `marketplace::chosen` reads as + // `Chosen::Held`, and `Held` is what makes the + // entry `enabled = false` and earns the operator + // the disclosure before a stranger's bundle + // contributes to six subsystems. Prefilling the + // directory would take the path reading, which + // exists for a directory the operator wrote + // themselves, and switch this one straight on. + // **One speller, and this was the second.** The + // qualified name is `marketplace::offer`'s to + // write: it answers the *shortest unambiguous* + // spelling, which is the marketplace where the + // label is unique in that clone and the bundle's + // own directory where it is not. Built here by + // hand, this line produced `