Architecture
The modules, how a project is found, the task model and the five areas — what exists today.
What exists today. opi lists a project’s package.json scripts and runs the
one you pick, and offers five areas beside that: health, security, updates,
clean, and named workflows.
Modules
src/
├── main.rs entry point, wiring, all user-facing output
├── cli.rs argument parsing → Invocation
├── manifest.rs package.json → Manifest, searching upwards
├── cargo.rs Cargo.toml → a Rust project and its commands
├── workspace.rs workspace patterns → member manifests
├── project.rs Manifest + directory → Project (name, package manager)
├── task.rs Manifest + members → Vec<Task>, grouped and ordered
├── run.rs Task → the running script
├── check.rs detect a project's tools, run them concurrently
├── audit.rs package manager audit → parsed advisories
├── supply.rs signatures, release age, licenses — asked of the package manager
├── outdated.rs package manager and cargo outdated → updates by semver jump
├── clean.rs removable artefacts, measured before they are offered
└── workflow.rs named sequences of checks, plus repository gates
Flow
flowchart TD
Args["std::env::args"] --> Cli["cli::parse → Invocation"]
Npm["manifest::discover\npackage.json"] --> Task["task::Task"]
Rust["cargo::discover\nCargo.toml"] --> Task
Npm --> Project["project::Project\nname, package manager"]
Rust --> Project
Cli --> Npm
Cli --> Rust
Task -->|"Exec::Script"| Run["run::execute"]
Task -->|"Exec::Direct"| Run
Task --> Menu["runemark::Menu"]
Menu -->|"Selected"| Run
Menu -->|"Unavailable"| Render["Menu::render → stdout"]
Menu -->|"Hotkey"| Areas["health · security\nupdates · clean"]
Cli --> Areas
Run -->|"exec, replaces this process"| Child["pnpm run dev · cargo test"]
Each manifest is parsed once and the result is shared by project detection, the task model and the checks. Nothing re-reads a file.
Both discoveries run, and either may come back empty; only both being empty is
an error. A Rust-only project stands on an empty Manifest, which answers every
question about scripts with “none” and needs no special case downstream.
The opi block inside it is deliberately held as unparsed JSON and read
leniently, field by field. Typed into a struct, one field of the wrong type
would fail the whole parse and make opi useless in a project whose scripts
are perfectly fine; read this way, a favorite that is a string costs that one
field.
It carries per-script metadata — description, group, favorite, confirm —
and the clean path list. All of it is refinement: nothing here may be required
for a screen to work, or the zero-configuration rule is broken.
Finding the project
Manifest::discover searches the working directory and then its parents, so
opi works from anywhere inside a project, as npm does. Everything
downstream resolves against the directory the manifest was found in.
cargo::discover does the same for Cargo.toml, and both run. A
repository may be more than one kind of project at once — measured across 231
directories here, 133 carry a package.json, 51 a Cargo.toml, and twelve
both — so neither is allowed to win. A repository with only a Cargo.toml is a
project too; 39 of them were previously turned away with “No package.json
found”.
The nearest Cargo.toml is not the answer in a workspace. It is whichever
crate the caller happens to stand in, and scoping everything to it costs the two
things the areas exist for: target/ lives at the workspace root, so Clean would
miss the largest directory in the repository — 509 MB in the one this was found
on — and the checks would cover one crate instead of all of them. So the nearest
manifest establishes that this is a Rust project at all, and the search keeps
rising for one that declares a [workspace] table. The outermost wins.
Two facts are then read from two places. The name belongs to the workspace root,
which is why opi inside a crate names the repository. Whether cargo run is
offered is asked of the directory the caller stands in, because run::execute
sets no directory and cargo therefore starts where they are: a binary crate
inside a workspace keeps its entry, while the virtual root — where cargo run
could not pick a binary — does not offer one.
Reading [workspace] members was deliberately not needed for any of this, and
so no TOML parser was taken. It would only be needed to offer something per
crate, and there is nothing to offer: cargo clippy at the root already covers
every member, unlike npm, where each package carries its own tools.
A .NET marker appeared in none of those directories on its own, so that half of the original plan is not built. The same reasoning retired Knip and taze.
Package manager detection searches upwards too. In a workspace the lockfile and
the packageManager field live at the root, so a member package carries no
evidence of its own — defaulting to npm there would run the wrong package
manager in a pnpm monorepo.
Workspaces
workspace.rs reads member patterns from pnpm-workspace.yaml or the
workspaces field, expands them against the filesystem, and loads each
member’s manifest. Members without scripts are dropped, as is the root when a
workspace lists . among its own packages.
Only the packages: block of the YAML is read. Real files carry several
unrelated top-level keys whose values are also lists — minimumReleaseAgeExclude
and trustPolicyExclude among them — and collecting every - item would turn
version pins into workspace packages.
Each member becomes a Group::Workspace, sorted after everything the root
owns.
A member script whose name the root also defines is left out. The root already wins that name on the command line, so listing both offers a choice the interface cannot honour, and in practice the root’s script wraps the members’. Measured on one real project this removed 12 of 18 member entries, none of which carried a description; what remained was exactly what the root cannot reach. A member left with nothing shows no group at all.
Addressing a member
Root and member scripts share names in every workspace repository measured, so
a bare name is not enough. opi dev runs the root’s script; opi blog/dev
runs the member’s, and a scoped package answers to its short name as well
(blog/dev reaches @casoon/blog). An exact match on the whole name is tried
first, so a script literally containing a slash would still win.
Each package manager spells the member differently, and in a different
position: pnpm --filter <m> run <s>, yarn workspace <m> run <s>,
npm run <s> --workspace=<m>, bun run --filter <m> <s>.
The task model is the single abstraction
Task is what the interactive list, the command line and the search all
operate on. It carries the workspace member it belongs to, if any, and how to
start it — Exec::Script goes through the package manager, Exec::Direct
names its own program. That second variant is what lets a second ecosystem
exist without a second list.
A Rust project defines no scripts, so its commands are a fixed set rather than
something read out of the manifest. The list offers run, build, check,
test, clippy, fmt and doc; cargo run only where something is runnable.
Health checks what these repositories actually run in CI: fmt --check,
clippy with warnings denied, rustdoc with warnings denied, test, and
metadata --locked where Cargo.lock is committed.
Cargo.toml is read without a TOML parser. Two facts are wanted — the package
name and whether a binary exists — and a dependency to learn them would cost
more than they are worth. opi dev and selecting dev from the list reach
run::execute by different routes but with the same value.
This is deliberate: if the two ever need separate handling, the boundary has
been broken. Built-in actions and tool checks are meant to join Task rather
than arrive as a parallel type.
Grouping
task::Group derives Ord, so group order is the order its variants are
declared — Development, Build, Preview, Quality, then unrecognised
prefixes alphabetically, then the catch-all. There is no separate sort
function to keep in step.
A project may override the derived group, or lift a script out of it entirely.
A group naming one opi already knows takes that group’s fixed place, so
configuration refines the meaning-first order rather than escaping it; a
favorite becomes its own group at the top, because sorting first within a
group of two barely shows.
A favourite in a workspace member stays with its member: lifting it into the root’s Favorites would lose which package it belongs to, and its id would no longer say.
Two rules are less obvious than they look:
- A script with no prefix that heads a family joins that family.
deploybelongs withdeploy:bloganddeploy:starter, not in the catch-all. - Lifecycle hooks npm runs on its own (
prebuildwherebuildexists) are hidden. The check is guarded against recognised groups, becausepreviewstrips toview.
Execution
run::execute replaces the process through exec. The script inherits the
terminal, the signals and the exit status, so Ctrl-C reaches a dev server
instead of killing a wrapper, and opi build && … works in a shell chain.
opi does not return to its list afterwards, because it no longer exists.
This is why opi is Unix-only — see constraints.md.
npm is the only package manager given a -- before forwarded arguments; it
needs the separator to tell its own flags from the script’s and strips it,
while the others would pass a literal -- through.
The areas
Health, security, updates and clean are reachable two ways: a hotkey in the
list, and a flag; the workflows and --hooks are flags only. Never a bare
word — health, clean, release and commit are all script names in real
projects, and the bare word stays theirs.
| Area | Key | Flag | What it does |
|---|---|---|---|
| Health | H |
--health |
Runs every detected check concurrently |
| Security | S |
--security |
Secret scan, a parsed dependency audit, signatures, release age, licenses |
| Updates | U |
--updates |
Outdated dependencies, split by semver jump, and the offer to take them |
| Clean | C |
--clean |
Removable artefacts, with sizes |
| Workflows | — | --check commit/push/release, --hooks |
A named subset, the build, git gates; push as the pre-push hook |
Security and updates ask whether there is an npm project at all.
Project::package_manager always holds a value — the absence of every signal
still produces the npm fallback — so it cannot answer that, and a Rust-only
project would otherwise run npm audit in a directory with no package.json
and relay npm’s complaint about it. Project::npm carries what only the
discovery knew. It is a different question from Detected::is_certain: a
project with neither a lockfile nor a packageManager field is uncertain but
real, and npm audit answers for it.
Where nothing answers, the area says so instead of printing an empty section,
because a blank dependency list reads like “no findings”. OutdatedError carries
a NoManifest for that; AuditError no longer needs one, since Rust now has an
audit of its own.
All four package managers are audited, in three shapes. npm and pnpm share
npm’s v6 format; bun has its own, measured against 1.3.3: a map from package
name to a list of advisories, which npm’s object of one each cannot
express. yarn has a third, measured against 4.18.0: newline-separated JSON,
one object per advisory, value naming the package and children carrying
the rest under capitalised keys. The three are told apart once, by which
manager was asked, rather than by trying one parser and falling back — a
malformed report would otherwise produce a confusing error about the wrong
format.
bun and yarn each name only the vulnerable range and never a patched one, so
their findings carry no remedy. Deriving “update to 4.17.21” from <4.17.21
would be inventing the one number that has to be right.
yarn’s shape has one more difference: a clean run prints nothing at all,
where the other three always print at least an empty report. So for yarn
alone, empty stdout with nothing on stderr is the clean case rather than a
failed one — the same distinction --updates already made for npm and pnpm.
One package can now arrive with several advisories — lodash had five in the fixture — and they are condensed to one line per package and severity, which is what decides what to do about them. The severity counts are therefore counts of vulnerable dependencies, not of advisories; the tool’s own output has the full list.
Updates does the same, through cargo outdated — another external
subcommand, found the same way. Two sections rather than one merged list: the
split into safe and breaking is this area’s ordering principle and it holds
inside an ecosystem, but across two it would file tokio beside vite under
“Safe to take” with only the name saying which is which, and “Take the safe
ones” could name only one of the two commands that would do it. Rust’s section
carries no next step at all, because cargo update writes the lockfile.
Jump is untouched by any of this and never learns where a version came from —
the second source fills the same Update. The one rule that looked like it
would need changing, cargo’s 0.x minor being breaking, was already there and
already applied to both: npm treats 0.x the same way.
Two things about cargo outdated’s output had to be measured rather than
assumed, and both would have produced a wrong list:
- Without
--root-deps-onlyevery entry is transitive. On one real project all 17 were, namedparent->childand none of them in any manifest. That flag is what makes this the same questionnpm outdatedanswers. - A workspace emits one JSON object per member, newline separated, not one document, so the output is read line by line and a crate several members share is listed once.
pnpm is asked with -r where the project declares a workspace, or it
answers for the root package.json alone: measured on a real repository, 2 of
11 outdated packages, and {} — reported as “everything is current” — where
the root declares no dependencies of its own. A false acquittal is worse than
no answer, which is the same standard by which --updates refuses to guess at
bun’s and yarn’s output.
The question is workspace::declared, not whether workspace::members returned
anything: that drops members without scripts, and a package with no scripts
still has dependencies.
npm needs no flag. It walks the installed tree rather than the manifests, so it already sees every member — measured both ways on a two-member workspace, with identical results. Of 23 npm projects here one is a workspace; of 133 npm projects 79 are pnpm workspaces, which is where the bug lived.
pnpm’s JSON keys by package name and so holds one entry per name. A package at
two versions in two members loses one of them, though pnpm outdated -r prints
both rows in its own table. Which packages need attention is still right and
pnpm update -r still moves both; only the version shown is one of the two.
Naming the member the JSON happens to carry would claim the other is fine.
compat, the latest semver-compatible version, is ignored. It reports what the
requirement allows — a pinned =1.0.100 shows --- though 1.0.151 is
compatible — while Jump answers the question being asked, identically for
both ecosystems.
Updates can apply what it found, by delegating. opi calls the package
manager with a list of names and writes nothing itself — see
constraints.md for what that rules out and why.
The choice offered is not safe against breaking. That is the list’s ordering,
and it is the wrong question for the action: measured on one project, three
dependencies, two of them “safe”, and pnpm update moved none of them, because
an exact pin and a ~ range each already had what they asked for. The real
choice is staying inside the declared ranges against raising them, so
that is what the menu says, each line carrying its own count and omitted where
that count is zero — across 13 pnpm projects measured, 4 had nothing in range.
Raising is pnpm-only, and a package that hangs in a member needs -r there too:
without it pnpm changes nothing and still says “Already up to date”.
bun gets one line of its own: Choose in bun’s own list, which hands over to
bun update --interactive. opi has no list to offer there — bun outdated
prints a table and ignores --json — but bun’s interactive update has one, so
the choice goes where the list is rather than being withheld. It is offered
without a finding to justify it, because opi cannot know whether anything is
outdated; bun’s list says so when nothing is. In a workspace it gets -r for
the same reason pnpm does: measured with bun 1.3.3, the interactive list leaves
a member’s packages off without it. pnpm has an interactive update too, but
there “Decide per package” already asks the same question beside the findings.
After a successful update the commit workflow runs. That chain — update,
install, check, what is red now — is the reason the action is worth having at
all; a key that only saves typing pnpm update would not have been worth
reopening the decision for. Measured at 1.4s here and 10.6s on the largest
workspace, so it is not a wait worth asking about first.
Security shows one section per ecosystem present, named the way health names
its checks: npm’s keeps the plain Dependencies, Rust’s is Dependencies (rust). cargo audit is not part of the toolchain but an external subcommand,
so cargo::has_subcommand looks for a cargo-audit executable on PATH — the
way cargo finds one itself. Running cargo audit and reading “no such command”
out of its stderr would be a parser on an undocumented format. An absent
subcommand is named along with the cargo install that adds it, rather than
leaving the section empty.
The Rust advisory carries no severity, and none is invented. Measured
against cargo-audit 0.22.1, the JSON has no severity key at all — only a
cvss vector string — while the tool’s own console output prints
Severity: 7.5 (high), because it scores the vector itself. Three ways to get
that number were available and all are refused: scoring the vector here would
reimplement CVSS inside a tool whose rule is to reimplement nothing, reading it
out of the console text would be the parser this project does not write, and
relaying the console output raw would bury the findings — 103 lines for three
advisories, nearly all of it dependency trees, against a 20-line cap. So the
JSON is parsed for what it does carry (crate, version, RUSTSEC id, patched
range) and the report’s next step is cargo audit, where the severity lives.
That is also why every Rust advisory counts and flips the exit code: there is no
severity to grade them by, and cargo audit fails on any of them itself. Only
vulnerabilities are read — warnings (unmaintained, unsound) are
informational and have no counterpart on the npm side.
Rust checks are scoped as rust even in a Rust-only project: in a repository
carrying both manifests, “Tests” would otherwise mean two different things on
two lines. Rust’s target/ is a clean candidate but not a heavy one — unlike
node_modules it is rebuilt by the next build rather than by a network round
trip.
A script the project marked confirm is asked about before it runs. Without a
terminal that refuses rather than assuming yes — skipping the question where it
cannot be asked would remove the protection in exactly the case it exists for —
and --yes is how to say it out loud in a script.
Checks
check.rs implements none of them. It detects which tool a package depends on,
finds its binary in a node_modules/.bin at or above that package, runs it, and
relays the result. Which tools to support was measured across 133 real projects
rather than taken from the plan — Knip, prominent there, was present in one.
yarn’s Plug’n’Play linker writes no node_modules, so for yarn a tool not found
on disk is started through yarn run <tool>, which resolves a dependency’s
binary the way a declared script would. Whether it is really installed cannot
be answered from the filesystem there, so a missing one surfaces when the check
runs — as the same “could not run” a missing binary produces elsewhere.
One project prerequisite is checked: the lockfile. Of the five the plan
listed, four were measured never to fire here — pnpm and rustup switch to the
pinned version themselves, and every engines.node was a lower bound the
installed Node cleared — while 72 of 104 pnpm lockfiles, 5 of 23 npm and 2 of
49 Cargo ones were out of step with their manifest. Three more npm projects
failed npm ci for other reasons — a workspace: protocol npm does not know,
peer conflicts — which a CI would hit just the same; the check relays npm’s
own words, so they read as what they are. The package manager is
asked with a command measured to change nothing and to fail only on drift:
pnpm install --frozen-lockfile --lockfile-only --offline, npm ci --dry-run,
bun install --frozen-lockfile --dry-run, cargo metadata --locked. --offline
keeps pnpm’s off the network without costing detection — a mismatched
specifier is seen before anything is resolved. yarn has none: yarn install --immutable is a full install. The check needs the manager’s own lockfile to
be there; without it there is nothing to drift, and npm ci would fail for its
absence instead.
Checks run per workspace member, not only at the root, and in the member’s own directory: a monorepo keeps TypeScript and its test runner in the packages, and pnpm does not hoist their binaries.
A non-zero exit means findings, which is a successful run with a result. A tool that will not start is reported apart from that, since a broken install needs a different remedy.
Where several tools answer for the same concern, the first detected one runs and its name is shown, so a result is never anonymous.
Deleting
clean.rs is the only code in opi that removes data, and is narrow by
construction: directories inside the project only, never through a symlink,
and a path from opi.clean that escapes the project is refused rather than
corrected. A candidate contained in another candidate is dropped, or its bytes
would be counted twice. node_modules is never bundled with build artefacts.
Presentation
Every user-facing string comes from runemark.
Where a Report is used, and where it is not
runemark’s Report carries a verdict, metrics, grouped findings and next
steps. It is used for the two screens whose data is parsed — the dependency
audit and the update list — where it earns its keep: severity counts become
metrics, the safe/breaking split becomes two groups rather than a sentence
under a list, and an advisory’s fixed version range becomes a Remedy.
It is deliberately not used for health, workflows or the secret scan, and the reason is the same for all three: those relay a tool’s own output rather than parsing it. Tried both ways against real output:
- Everything in one
Findingcollapses the newlines, so a tool’s box rules and its indentation — which file, which line — turn into a run-on paragraph. - One
Findingper line prefixes each with a bullet, flattening the same indentation, and reports a count of lines as if it were a count of findings.
Two further reasons hold for health specifically. Its results stream: each check prints as it finishes so a slow test run does not look like a hang — 5.6 seconds on one real workspace — while a report is built and rendered once. And its status marks belong on the streamed lines, where a report has no say.
A third reason used to hold and no longer does: a Metric carried a tone but no
symbol, so with colour off a failing one read exactly like a passing one. That
gap was closed in runemark 0.6 by Metric::with_verdict, which the audit’s
severity counts now use. It does not change health’s answer — streaming and raw
output still decide it — but it is why those counts are legible in a pipe.
- The list is a
runemark::Menu, built inmain::build_menu. Item ids are script names, so a selection is ready to run. - Past 15 entries or 5 groups it is built with
Layout::Tabs, which puts the groups in a row above the list and shows only the active one’s entries. The thresholds aremain’s, not runemark’s: a menu knows how many entries it has, not how much of the screen its caller is willing to spend. Why a count rather than the terminal’s height is in decisions.md. - Workspace packages share one tab, marked off by a divider, via
Group::in_tab. One each does not scale — the largest workspace measured here has 52 packages against 6 action groups — and it put two questions on one row as peers: the action tabs answer what you are doing, a package tab answers where. Inside the shared tab each package keeps its name as a heading, soGroup::Workspacestill decides what belongs together; only the row is shortened. Menu::with_summarycarries the line under the heading — entries, groups, packages where a workspace has any, and any task runner files beside the manifest (also Makefile), which are named but never read. Two of those three stop being countable off the screen once the groups are tabs.- A list larger than the terminal is fitted to it by runemark: taller lists scroll, and entries are shortened rather than wrapped. Within a tab that still holds; the tabs page between groups, the viewport scrolls inside one.
/filters the menu across every group, tabs or not, and leaves the tab row while it runs. Matching lives in runemark and works on what the menu displays;task::suggestionsis a separate thing, correcting a mistyped name on the command line where there is no list to filter.- Interactive selection needs runemark’s
selectfeature and both stdout and stderr to be terminals — stdout decides whether output is being captured, and the menu draws its frames on stderr. - Where that does not hold,
Menu::renderprints the same layout as plain text. One menu definition serves both paths. - Errors are
runemark::ErrorBlockon stderr.