refactor(cli/dir): install by pipe, uninstall from the manifest, and project-scope tracking - #2134
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
🟡 Changes recommended
Manifest pruning, reference matching, no-op handling, and piped pinning contain unresolved correctness issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Refactors CLI package management around piped installs, manifest-driven uninstalls, and repository-specific project tracking.
Changes:
- Accepts piped install references and removes duplicated search filters.
- Uses the local manifest for uninstall and adds stale-row pruning.
- Improves raw output and suppresses unchanged plan lines.
File summaries
| File | Description |
|---|---|
docs/content/dir/dir-cli-reference.md |
Documents revised package workflows. |
cli/presenter/output.go |
Prints raw lists line-by-line. |
cli/presenter/output_test.go |
Tests raw list formatting. |
cli/internal/pkgstate/pkgstate.go |
Adds repository-path scopes. |
cli/internal/pkgstate/pkgstate_test.go |
Tests scope key uniqueness. |
cli/internal/agentinstall/remove.go |
Removes manifest-recorded artifacts. |
cli/internal/agentinstall/remove_test.go |
Tests recorded removal. |
cli/internal/agentinstall/installed.go |
Inspects recorded artifact presence. |
cli/internal/agentinstall/installed_test.go |
Tests artifact inspection. |
cli/internal/agentcfg/summary.go |
Omits unchanged summary lines. |
cli/internal/agentcfg/summary_test.go |
Tests filtered summaries. |
cli/internal/agentcfg/selection.go |
Adds agent lookup by ID. |
cli/internal/agentcfg/result.go |
Adds change detection helpers. |
cli/internal/agentcfg/plan.go |
Filters unchanged plan entries. |
cli/internal/agentcfg/plan_test.go |
Tests plan filtering. |
cli/cmd/root.go |
Skips clients for local-state commands. |
cli/cmd/install/uninstall.go |
Routes uninstall through the manifest. |
cli/cmd/install/uninstall_recorded.go |
Implements recorded uninstall flow. |
cli/cmd/install/uninstall_recorded_test.go |
Tests recorded uninstall behavior. |
cli/cmd/install/scope.go |
Maps repository paths to scopes. |
cli/cmd/install/prune.go |
Adds manifest pruning. |
cli/cmd/install/prune_test.go |
Tests pruning behavior. |
cli/cmd/install/piped.go |
Parses piped references. |
cli/cmd/install/piped_test.go |
Tests piped input handling. |
cli/cmd/install/output.go |
Adds tabular output helpers. |
cli/cmd/install/options.go |
Removes batch-search options. |
cli/cmd/install/multi.go |
Implements multi-record piped installs. |
cli/cmd/install/multi_test.go |
Updates multi-install tests. |
cli/cmd/install/manifest.go |
Records all scopes and supports edits. |
cli/cmd/install/manifest_test.go |
Tests project manifest records. |
cli/cmd/install/install.go |
Adds pipe and prune command routing. |
cli/cmd/install/install_test.go |
Tests revised command surface. |
cli/cmd/install/helpers_test.go |
Adds shared manifest test helpers. |
cli/cmd/install/flags.go |
Removes filters and retains version selection. |
CHANGELOG.md |
Records additions and breaking changes. |
.skill/SKILL.md |
Updates client requirements. |
.skill/references/install.md |
Updates package-management guidance. |
Review details
Suppressed comments (1)
cli/cmd/install/multi.go:128
- Resolving each piped reference to a record here discards whether the user explicitly supplied
:version. LaterapplyTargetssetspinnedonly fromopts.pin, soprintf 'pkg:v1.0.0\n' | dirctl install --yesis not pinned, unlike the same explicit reference passed positionally. Preserve pin intent through target selection and record it for the selected record.
- Files reviewed: 37/37 changed files
- Comments generated: 8
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
fc883ad to
d35eecc
Compare
|
All eight applied in 0d2c634, and seven of them turned out to be one bug wearing different hats: a lookup that could not be made was being read as a negative answer.
Same bug in the recorded uninstall ( Digest on a named reference was ignored (
The remaining three were wording that had drifted from behaviour: both New tests cover each: an unreadable config keeps its row through both |
Makes the install manifest the single source of truth for what is installed, and stops `dirctl install` carrying a second copy of `dirctl search`'s filter surface. Four breaking changes, all in the release notes. **Batch by search filters is gone.** `--name`, `--module`, `--skill`, `--domain`, `--locator`, `--author`, `--version` and `--limit` come off install and uninstall. Filtering is search's job, duplicating it was the only reason `uninstall` ever needed a Directory, and the filters answered the wrong question there anyway: batch uninstall searched for records matching `--author X` and removed what those records *currently* derive, not what was installed. `dirctl search -o raw | dirctl install` replaces the install half. References are read one per line, blanks and `#` comments ignored, quotes stripped so `-o jsonl` works too. `--all-versions` survives the cull, since a pipe carries every matching version and installing all of them into one agent means repeated writes to the same slug. A piped run cannot prompt — stdin is the list — so it errors without `--yes` rather than reading a CID as the answer. `uninstall` takes one reference. **`-o raw` prints one value per line** for list results. It was emitting Go's `[a b]` debug form, which nothing can split, while the search docs already advertised it as the pipeable format. **Uninstall reads the manifest.** No Directory call at either scope, so it works with the server down or after the record has been deleted upstream, and only the agents whose rows name the package appear in the plan — including one no longer detected here, since the files it was given are still on disk. A `:version` narrows to rows at that version, a bare CID matches the exact record installed, and a row whose artifacts are already gone is cleared so a package cannot get stuck. A reference with no row is reported as not installed, naming the scope searched and saying where the package is when it exists at another one. With batch gone, uninstall joins the client-setup skip. **Project installs are recorded, keyed by repository path.** `scope` was the literal `project`, which could never hold more than one repository: the key is (name, agent, scope), so the same package installed at project scope in two repositories collided and the second overwrote the first. It is now `global` or the repository's absolute path, and a row's paths resolve against the repository it names, so a project install can be listed and removed from anywhere. This drops the plan of a committed `./.dirctl/installed.json`: that file would carry one developer's absolute home paths, useless to a teammate. **`dirctl install prune`** drops rows whose artifacts are all gone, with `--dry-run`. Rows go stale when a skill folder is deleted by hand or a repository is moved. It touches nothing on disk. **Plans and summaries name only what changes.** An `unchanged` line puts an agent's config path next to the record's name, which reads as a claim that the package is installed there. Skips and failures stay, the tally still counts everything, and a no-op plan no longer asks for confirmation. Signed-off-by: András Jáky <ajaky@cisco.com>
It claimed to install every piped version of a name. It could not: the skill slug and the MCP server key both derive from the record name alone, so two versions resolve to the same folder and the same config key. Piping 1.9.0 and 1.10.0 with the flag set reported "1 added, 1 updated", left one folder holding 1.10.0, and wrote one manifest row — the key is (name, agent, scope), so the second install overwrote the first there too. Only one version of a package can be live and reachable by an agent, so install picks the highest rather than offering a flag to pretend otherwise. The dedup that was behind the flag stays, unconditionally. Signed-off-by: András Jáky <ajaky@cisco.com>
Eight review findings, seven of them one theme: a lookup that could not be made was being read as a negative answer. `Present` turned a permission error, a broken mount, an unparseable agent config, and an unknown agent all into "gone". `prune` deletes the only provenance a package has, so it was one malformed `.claude.json` away from dropping rows for artifacts that were still on disk. Inspection now carries an `Unchecked` state, `agentcfg.MCPEntryChecked` reports whether the lookup completed, and `Present` is the negation of "confirmed gone" — doubt keeps the row. The recorded uninstall had the same bug in a different shape. It took the "nothing to remove, clear the rows" path on `!HasChanges`, which is also false for skipped and failed outcomes, so an unreadable config printed "already gone" and "Cleared 1 row" while `forgetRemoved` quietly kept the row and the failure was never shown. It now needs every outcome to be ActionUnchanged. `rowMatches` ignored the digest on a named reference, so `uninstall name@digest-A` could remove a row recording digest-B. Every part the reference carries now has to agree. `runApplyCmd` still gated on `len(plan) == 0`, so a single install whose artifacts were all already correct asked the user to confirm a no-op, contrary to the documented behaviour. It uses `HasChanges` like the piped path. Also: both `uninstall` help strings said "detected agents", the opposite of what manifest-driven removal does; prune's empty case claimed every artifact was on disk when `Present` only promises one survivor; and the docs offered `dirctl install list` as a source of pipeable names, which it does not produce until #2029. Signed-off-by: András Jáky <ajaky@cisco.com>
0d2c634 to
b0cf409
Compare
Makes the install manifest the single source of truth for what is installed, and stops
dirctl installcarrying a second copy ofdirctl search's filter surface.Groundwork for #2029, which stacks on this and adds the commands that read the manifest.
Batch install becomes a pipe
--name,--module,--skill,--domain,--locator,--author,--versionand--limitcome off install and uninstall. Filtering is search's job.dirctl search --module integration/mcp -o raw | dirctl install --agents all --yesOne reference per line. Blanks and
#comments ignored, quotes stripped so-o jsonlworks, plain names accepted. At most 1000 per run. A reference that will not resolve is reported and skipped rather than aborting the run.A piped run cannot prompt, since stdin is the list, so it needs
--yesor--dry-run. Without either it errors naming the flag, instead of reading a CID as the answer.Only the highest version per name is installed, with no flag to override. The skill slug and the MCP key both derive from the record name, so two versions resolve to the same folder and the same config key — only one can be live.
uninstalltakes one reference. Removing several is a loop over the manifest, which is what knows what you have.Uninstall reads the manifest
It re-pulled the record to derive what to remove. That needed a reachable Directory to delete something local, and it computed a plan for every detected agent, so agents that never had the package appeared as
unchangedlines naming their config paths.It now makes no Directory call at either scope, and touches only the agents whose rows name the package — including one no longer detected here, since the files it was given are still on disk.
A
:versionnarrows to rows at that version, a bare CID matches the exact record installed, and a row whose artifacts are already gone is cleared so a package cannot get stuck. A reference with no row is reported as not installed, naming the scope searched and saying where the package is when it exists at another one.With batch gone,
uninstallneeds no client and joins theroot.goskip list.Project installs are recorded
scopewas the literalproject, which could never hold more than one repository: the key is (name, agent, scope), so the same package installed at project scope in two repositories collided and the second overwrote the first.It is now
globalor the repository's absolute path, and every install is recorded. A row's paths resolve against the repository it names rather than the current directory, so a project install can be listed and removed from anywhere.This drops the plan of a committed
./.dirctl/installed.json. That file would carry one developer's absolute home paths, useless to a teammate. The manifest is a local record of what happened; a file pinning what a team should have is declarative, and a separate thing. #2027 is updated to match.dirctl install pruneDrops rows whose recorded artifacts are all gone, with
--dry-run. Rows go stale when a skill folder is deleted by hand or a repository is moved.It touches nothing on disk, and drops a row only when every artifact it names is gone — a package that lost one of two is still installed.
Plans name only what changes
An
unchangedline puts an agent's config path next to the record's name, which reads as a claim that the package is installed there. Those lines go. Skips and failures stay, the tally still counts everything, and a no-op plan no longer asks for confirmation.Worth a reviewer's eye: a shared skill location reports
ActionUnchangedfor the agent that shares it — Claude Code and Claude Desktop, Zed and Codex CLI — so those agents drop out of the lines too, even though the artifact serves them. Their manifest rows are still written.Also
-o rawnow prints one value per line for list results, instead of Go's[a b]debug form that nothing can split. The search docs already advertised it as the pipeable format. Affectssearch,delete, androuting list.Breaking changes
Four, all in the release notes.
dirctl search -o raw | dirctl install;uninstalltakes one reference--all-versionsremoveduninstallno longer pulls the recordscopeisglobalor a repository pathprojectliteralCloses #2133.
Validation
cd cli && go test ./...andtask lint:go(pinned golangci-lint 2.13.2): pass, 0 issues../cmd/install/... ./internal/...also pass at-count=2, which catches the in-processoptssingleton trap.task test—server/store/ociintegration andcli/cmd/authat-count=2— reproduce on unmodifiedmain.search -o raw | install --yesinstalls the highest once and records one row; a piped run without--yeserrors naming the flag;-o jsonlpipes just as well.uninstall --projectin one repository removes only its row with the daemon stopped. Moving the other repository away leaves a row thatprunedrops.