Skip to content

io-cli 0.15.0 — one home, ~/.io-cli - #41

Merged
aakashpawar1999 merged 10 commits into
developfrom
feat/0.15.0
Aug 25, 2026
Merged

io-cli 0.15.0 — one home, ~/.io-cli#41
aakashpawar1999 merged 10 commits into
developfrom
feat/0.15.0

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

Everything io-cli keeps moves into one directory with one name, on every platform.

Before this release the answer to "where does it live" was three answers, none of
them the product's name: ~/.config/io, $XDG_CONFIG_HOME/io, %APPDATA%\io
with the run store beside whichever applied, undocumented and untested. Now there
is ~/.io-cli, holding the configuration file, the run store, the durable memory
that is rows inside that store, and a skills directory created with the home.

The migration is the part to read. On the first 0.15.0 run an existing install
is moved into the home and each file that moved is named on screen. Nothing is
overwritten — a file already in the home is the one that stays. Nothing is
deleted, and a file copied across filesystems has its copy checked before the
original goes. The store's -wal and -shm move with it, because SQLite opens an
orphaned runs.db without complaining and simply does not contain the last
session. An operator who has set IO_CONFIG or IO_CONFIG_HOME is not moved at
all and is not told about a migration that did not happen to them.

It is io-harness's own discovery doing the work — IO_CONFIG, then
IO_CONFIG_HOME, then the platform's place, read at call time — so io-cli names
the second rung once, before the first read, rather than growing a second
configuration system beside the harness's.

Also here: /status gains a home row naming the directory in force and what
decided it; a leading ~ in a skills path is expanded, because io-harness
substitutes ${env:} and ${file:} and nothing else and the shipped example was
a directory whose name is one character; and the palette lists the same skills the
turn hands the agent.

What the audit and the wave found

  • Skills::discover returns Err on a missing directory rather than walking
    away from one
    , and discover_skills propagates that at run start. An
    unconditional default would have failed every turn for every operator who had
    never made the directory. The contract said the opposite; it was corrected before
    any of it shipped.
  • Bundles could not live in the home on io-harness 0.66Plugins::load is
    pub(crate) and Config::plugins() hardcodes the config's own directory. Out of
    this release by construction, not by preference.
  • Running the binary found two defects 719 tests could not. A configuration
    file the harness refuses ends the run at the discovery; an unreadable store ends
    it at Store::open. Both are before either arm reaches the place the report was
    written, so an operator saw an error naming a path they had never seen with
    nothing saying their install had just moved. The report is now owed until
    delivered.

Verification

  • cargo test --all-targets --no-fail-fast — exit 0, 57 binaries, 719 passed, 0
    failed, 24 ignored
    (0.14.0 finished at 696/24)
  • cargo fmt --all -- --check and cargo clippy --all-targets -- -D warnings
    both exit 0
  • cargo test --doc — exit 0
  • Six sabotage arms run; four killed exactly their own criterion, one over-killed
    and is recorded as such, one is Windows-only and runs on CI
  • Four end-to-end runs of the built binary, the last against the release binary
    built after the version bump
  • No new dependency: the direct set is still ten names

The UltraShip record is sealed and pinned
(sha256:51848093d751c7ccee5cd96977453c069a1386d1865127a22a06b7255d9bdcc4,
validate exit 0 over 45 checks). It lives on disk only — .ultraship/ is
gitignored in this repository, so its absence from this diff is by design rather
than an omission.

… into it

io-harness resolves $IO_CONFIG, then $IO_CONFIG_HOME, then the platform's own
place, reading the environment at call time — and exposes no caller-supplied
home. So the home is named by setting that variable once, before the first
Config::discover, and the run store follows because settings::store_path derives
it from the configuration file's own directory.

The migration moves io.toml and the store together, and the store's -wal and
-shm with it: a runs.db moved without its write-ahead log opens without
complaint and simply does not contain the last session. Nothing is moved onto a
file that exists, and a source is removed only after its copy has been read back.

An operator who set either variable is not moved at all. A variable pointing at
io-cli's own home reads as 'default' rather than as their choice, so a status row
cannot credit them for this crate's default — which is what an origin read from
the raw environment did, because adopt is what put the variable there.
home::adopt runs immediately before Config::discover, the only discovery
either arm reaches, because user_path reads the environment at call time: a
config discovered first would come from the old location while the store
answered from the new one, which is a silently emptied /resume.

The report is committed through App::record rather than App::say — it belongs
to the conversation, not to the keystroke that happened to be next — and takes
its own Tone::Muted vec rather than joining the notices, which are Warning and
would paint a permanent yellow line at every launch. In the headless arm the
same lines go to stderr before the handoff, never to stdout.

tests/structure.rs pins the ordering by reading src/main.rs with every comment
stripped first, so a comment naming adopt cannot satisfy the gate.
Spawns the built binary, because the printing lives in main.rs and no
integration test links it. `exec --policy ask-writes` is refused on exec::main's
first line, before a store, a session or a provider — so the run terminates
with no credential and no network while still executing everything that runs
before the branch, which is where the report is written.
…s a home

Skills::discover does not walk away from a directory that is not there — it
returns Err, and discover_skills propagates that at run start, before the first
completion. So an unconditional default would have failed every turn for every
operator who had never made the directory. adopt creates <home>/skills with the
home, and the default is filtered on is_dir() besides.

io-harness substitutes ${env:} and ${file:} and nothing else, so a ~ written in
[run] skills or [app.io-cli] skills reached Skills::discover as a directory one
character long. The expansion happens once, after both keys have had their say.
attach.rs's own twelve lines of it now call the same helper.

Precedence, weakest to strongest: <home>/skills, [run] skills, [app.io-cli] skills.
…ded it

Derived from home::in_force and never from home::path: under IO_CONFIG the file
is somewhere io-cli did not choose, and reporting the home this crate would have
picked would be wrong in the one case the row exists for.

tests/plain.rs asserts every row of the plain page is ASCII, and the new row
carries a directory read from the environment — an assertion about whoever runs
the suite. Pinned to a literal, not to a temporary directory, because a
temporary directory is $TMPDIR and that is the same ambient answer one step on.
…nnot work

The resolution order is io-harness's and unchanged; what changed is that io-cli
answers its second rung by default. The migration and its opt-out are stated up
front, along with the fact that the variable is set in io-cli's own process and
inherited by every child a session starts.

The skills example named ~/.config/io/skills, which was wrong twice: the
location moved, and io-harness expands no tilde at all.

The README's discovery prose had no gate, which is how it could go stale. The
new one derives the directory name from home::path and walks the four rungs by
byte position, so a README naming all four in the wrong order fails.
The version and the notes. The migration is the first thing the notes say,
because it moves files io-cli did not create on the first run of the new
binary, and rolling back is a variable or a move rather than a loss.
Found by running the binary, twice. With a file io-harness cannot parse the run
ends at the discovery; with an unreadable store it ends at Store::open. Both are
before either arm reaches the place the report was written, so an operator saw an
error naming a path they had never seen, one keystroke after their old directory
emptied, with nothing anywhere saying their install had just moved. That is the
'my sessions are gone' reading this release exists to prevent.

The report is now owed until somebody delivers it: run fills a vec main owns, io
exec drains it onto stderr, a session takes it into the scrollback, and main says
whatever is left. Patching each early return would have left the next one out.

The test spawns the binary, because the ordering lives in main.rs and nothing
under tests/ links it. Its sabotage — report only after a successful discovery —
fails it and nothing else.
…oing it

Windows CI found this and this machine could not. Windows refuses to rename a
file another process holds open, so a second io running while this one starts
would let io.toml reach the new home while runs.db stayed in the old one — a
configuration and a store in two directories, and a /resume that finds nothing.
That partial state is worse than not migrating at all, and adopt was ignoring the
failed move.

Now the variable is set only once every file that had to move has moved. A file
that will not move takes everything already moved back with it, leaves the
environment alone, and is named on screen with the directory still in force.

f4's fixture was the shape that hid it: it held the store open across the move,
which is legal on unix and impossible on Windows. It now builds the store in a
staging directory, copies it while the write-ahead log is live, and drops the
connection before anything moves — which is byte for byte what a crashed io
leaves, on either platform, with nothing holding it.
@aakashpawar1999
aakashpawar1999 merged commit 07cc7d8 into develop Aug 25, 2026
8 checks passed
@aakashpawar1999 aakashpawar1999 mentioned this pull request Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant