Skip to content

feat(cli/dir): dirctl install upgrade, builtin record tracking, and highest-version name resolution - #2156

Merged
akijakya merged 10 commits into
mainfrom
feat/dirctl-upgrade
Sep 18, 2026
Merged

akijakya merged 10 commits into
mainfrom
feat/dirctl-upgrade

Conversation

@akijakya

@akijakya akijakya commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

The write side of package management: actually move packages to a new version, and make the three notions of "latest" in the codebase agree.

Builds on #2132 and #2134, both merged, which made the install manifest the single source of truth and added the commands that read it.

dirctl install upgrade [name...]

Installs the newer version of every package that has one, in place. What counts as upgradable is exactly what dirctl install outdated reports, decided by the same pkgupdate.Check call — so the two commands can never disagree about what is stale. A downgrade is never offered, and a package whose versions carry no ordering is never moved.

With no arguments it upgrades everything upgradable and skips pinned rows. Naming a package upgrades it even if pinned, and releases the pin — asking for it by name is a clearer statement than the hold it overrides. --include-pinned upgrades held packages without releasing anything, so the hold lands on the new version. Accepts --pre, --include-pinned, --agents, --project, --dry-run, --yes, and reuses agentcfg.FormatPlan/FormatSummary so its output is the same shape as install.

Resolution is one call per distinct name, not per row, so a package installed into three agents costs one call and one derive.

An upgrade is a reconcile, not an overwrite

The order is load-bearing:

  1. Every replacement is fetched and derived first. A record that cannot be pulled leaves every existing install exactly as it was — never delete a working skill and only then discover the new one cannot be had.
  2. Then the orphans go. The two artifact kinds differ in who owns the thing being written into. A skill folder is named after the record and is dirctl's alone, so installing a skill replaces its whole contents. An MCP entry sits in a config file shared with the user and other tools and cannot be replaced wholesale, so a server key the new version renamed is stripped by name from the manifest row. Derive it from the new record instead and v1's key survives in .claude.json forever — only the row knows what v1 wrote.
  3. Then install, and rewrite the row to name exactly what is now on disk, including dropping the key that was just removed. That last subtraction is why Reconcile exists rather than a plain Record: carrying a just-removed key forward would leave the row claiming something gone, and send a later uninstall hunting for it.

One package failing does not strand the rest of the run; it is reported under Skipped records and the others go ahead.

A new version can also derive byte-identical artifacts — an author bumps a version while changing only fields no artifact is built from. The plan is then empty, but the row still moves, or outdated would go on offering the same upgrade forever. The command says so rather than appearing to do nothing:

demo.acme/changelog-writer: 1.10.0 → 1.10.1 (artifacts are already identical; recorded the new version)

Skill installs are authoritative

InstallSkillBundle already replaced the skill directory outright. InstallSkill did not: it wrote one file into the folder and never looked at the rest, so a record that shipped a bundle in v1 and a plain SKILL.md in v2 orphaned v1's references/ for good. Single-file skills now match the bundle path, and an otherwise-identical SKILL.md only counts as unchanged when the folder holds nothing beside it.

The folder is dirctl's, deliberately and throughout: nothing added inside it survives a reinstall. A skill is a package pulled from the Directory, and the way to change one is to push a new version and upgrade to it, not to edit the installed copy. Preserving files added next to SKILL.md while silently overwriting an edited SKILL.md would be a half-promise.

Manifest rows for dirctl init

init installs org.agntcy/directory and recorded nothing, so the package was invisible to every command that reads the manifest: install list did not show it, and since #2134 made uninstall manifest-driven, dirctl uninstall org.agntcy/directory reported it as not installed. It is now recorded with origin: builtin — the upstream is this binary, so a version check makes no network call — and no CID, because the record is rebuilt on demand with the current timestamp and a recorded content address would look like a change on every build. init --remove clears the rows with the artifacts.

The derivation moves to cli/internal/dirpkg, because upgrade has to re-derive the built-in package locally rather than pull the published record of the same name. That distinction is load-bearing and would be invisible otherwise: the published record's MCP module carries no environment, so installing it would silently repoint dirctl mcp serve at the default address. Re-deriving also recomputes the DIRECTORY_CLIENT_* overlay from the current client context instead of replaying stale values, and removes any possibility of offering a downgrade when the local binary leads the server's published build.

Row writing moves to agentinstall.Record / Forget, so install, its piped form, init, and upgrade cannot drift apart on what a row means.

Highest-version name resolution

reference.ResolveToCID took records[0], and the naming service orders by created_at DESC, so a bare name resolved to the newest-pushed record. Push v1.9.0 after v2.0.0 and dirctl install cisco.com/agent installed v1.9.0 while install outdated called v2.0.0 the latest — so a package one version behind reported as up to date against a version install would never have chosen.

reference.Highest picks the highest orderable version instead, preferring releases over prereleases unless prereleases are all that exist, and falling back to the server's order when nothing is comparable so names tagged latest or dev keep working. Text ordering would have got it wrong anyway: the server compares version as a SQL text column, where 1.10.0 sorts below 1.9.0.

Fixed along the way

dirpkg.MCPServerEnv resolved the client config with an empty Context, so it read current_context no matter what the invocation asked for. dirctl --context prod init therefore wrote an MCP entry pointing dirctl mcp serve at a different Directory than the command itself used — the same silent repointing that installing the published record would cause, arriving by another door. The resolution moves to cliconfig.ResolveClient, which root already did inline, and dirpkg now takes the resolved config rather than finding one. ResolveClientLenient keeps the SkipValidation the old call had, so a partially-set context still yields its address instead of falling back to the local default.

pull, info, and naming verify still told users a bare name resolves to the most recently created record.

Breaking change

dirctl install <name>, dirctl pull <name>, info, export, and naming verify resolve a bare name to the highest semantic version rather than the most recently pushed record. Versions that are not valid semver still fall back to newest-pushed. Pin an exact version with <name>:<version> to get a specific record.

Installing a skill now replaces its folder's whole contents, so anything added inside a skill folder no longer survives a reinstall.

Closes #2030

Signed-off-by: András Jáky <ajaky@cisco.com>
Signed-off-by: András Jáky <ajaky@cisco.com>
Signed-off-by: András Jáky <ajaky@cisco.com>
Signed-off-by: András Jáky <ajaky@cisco.com>
Signed-off-by: András Jáky <ajaky@cisco.com>
Signed-off-by: András Jáky <ajaky@cisco.com>
@akijakya
akijakya requested a review from a team as a code owner September 16, 2026 14:15
@github-actions github-actions Bot added the size/XL Denotes a PR that changes 2000+ lines label Sep 16, 2026
@akijakya akijakya self-assigned this Sep 16, 2026
@codecov

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Upgrade grouping, shared artifacts, pin preservation, and recorded removal contain correctness issues.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds package upgrades, built-in install tracking, authoritative skill replacement, and consistent highest-version resolution.

Changes:

  • Adds manifest-driven install upgrade reconciliation.
  • Tracks dirctl init artifacts and centralizes built-in package derivation.
  • Resolves bare names to the highest semantic version.
File summaries
File Description
CHANGELOG.md Documents user-facing and breaking changes.
docs/content/dir/dir-cli-reference.md Documents upgrade and resolution behavior.
cli/util/reference/reference.go Selects the highest semantic version.
cli/util/reference/reference_test.go Tests version selection.
cli/internal/pkgstate/pkgstate.go Adds artifact subtraction and manifest updates.
cli/internal/pkgstate/pkgstate_test.go Tests manifest update behavior.
cli/internal/dirpkg/dirpkg.go Builds the built-in DIR package and MCP environment.
cli/internal/dirpkg/dirpkg_test.go Tests built-in environment derivation.
cli/internal/agentinstall/upgrade_test.go Tests end-to-end reconciliation.
cli/internal/agentinstall/record.go Centralizes manifest recording and reconciliation.
cli/internal/agentinstall/record_test.go Tests record lifecycle behavior.
cli/internal/agentinstall/orphan.go Removes obsolete artifacts during upgrades.
cli/internal/agentinstall/orphan_test.go Tests orphan removal.
cli/internal/agentcfg/skill_folder_test.go Tests authoritative skill folders.
cli/internal/agentcfg/skill_engine.go Replaces whole skill folders.
cli/config/config.go Centralizes client-config resolution.
cli/cmd/root.go Uses centralized config resolution.
cli/cmd/pull/pull.go Updates resolution help text.
cli/cmd/naming/verify.go Updates verification help text.
cli/cmd/install/upgrade.go Implements package upgrades.
cli/cmd/install/upgrade_test.go Tests upgrade grouping and filtering.
cli/cmd/install/manifest.go Uses shared manifest helpers.
cli/cmd/install/install.go Registers and documents upgrade.
cli/cmd/init/context.go Shares local connection defaults.
cli/cmd/init/agents.go Records built-in agent installations.
cli/cmd/init/agents_test.go Tests built-in manifest tracking.
cli/cmd/info/info.go Updates lookup help text.
Review details

Suppressed comments (1)

cli/cmd/init/agents.go:246

  • init --remove derives the current binary's artifact names instead of removing what the manifest row records. If a newer binary renamed or dropped the built-in MCP key, this sees the new key as absent, removes the row via Forget, and leaves the old key orphaned in the agent config. Load the built-in rows and use the manifest-driven removal path so the recorded v1 keys are removed before their rows are forgotten.
	arts, err := dirpkg.Artifacts(dirConfig(cmd))
	if err != nil {
		return err
	}

	plan := agentinstall.Uninstall(env, arts, selected, agentcfg.Global, true)
  • Files reviewed: 27/27 changed files
  • Comments generated: 6
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cli/cmd/install/upgrade.go Outdated
Comment on lines +304 to +305
if len(chosen) > 0 && !chosen[row.Entry.Agent] {
continue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6013301, by refusing the split rather than reconciling every row.

rejectSharedSkillSplits runs before anything is fetched and mirrors rejectSharedSkillSplit on the uninstall side, including the error text pointing at the agents that have to go together. Reconciling the unselected rows instead would mean ignoring --agents, which is worse: the flag would silently touch agents it was used to exclude.

Only --agents can produce the split, since every row of a package is upgraded together otherwise.

Comment thread cli/cmd/install/upgrade.go Outdated
Comment thread cli/cmd/install/upgrade.go Outdated
Comment on lines +469 to +470
group.rows = append(group.rows, row)
group.pinned = group.pinned || row.Pinned

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6013301. groupByScope no longer carries a pin at all; the group carries its rows, and recordUpgrades calls Reconcile one agent at a time with pinnedFor(write.rows, agent.ID).

Reachable exactly as you say — install <name> --pin --agents a followed by install <name> --agents b leaves one row held and one loose at the same scope.

Comment thread cli/internal/dirpkg/dirpkg.go
Comment thread docs/content/dir/dir-cli-reference.md Outdated
Four correctness fixes from review, all in `install upgrade`.

Targets were keyed on the record name, so the first row's CID decided
for every row of that name. Rows of one name do not always agree:
without --pre, a row on a release follows the highest release while a
row already on a prerelease follows its own track, so this could
install a prerelease over a release track, or rebuild a Directory row
from this binary when a built-in row shared the name. Keyed on the
resolved target now — name, version, CID and origin — so rows that
agree still share one fetch and rows that do not are kept apart.

Claude Code and Claude Desktop share one skills folder, as do Zed and
Codex CLI, so --agents could narrow a run to one of a pair and still
rewrite the other's copy — or, when the new version drops the skill,
delete the folder its row still pointed at. Refused now, the way
uninstall already refuses the same split.

HasChanges is false for a plan of skips and failures too, not only for
byte-identical artifacts, so the version-only path claimed success for
work that never happened and could bump a row whose other artifact had
failed. A write now has to be wholly unchanged to count, and blocked
groups keep their versions and say so.

A scope-wide OR collapsed per-row pins, so `--include-pinned` pinned
rows that were never held. Recording is per agent, keyed on that
agent's own row.

Refs #2030

Signed-off-by: András Jáky <ajaky@cisco.com>
`init --remove` derived the built-in artifacts from the running
binary, so it only ever removed what *this* version writes. An MCP key
an earlier dirctl wrote under another name read as already absent,
Forget dropped the row as cleared, and the old key stayed in the
agent's config with nothing left pointing at it. Removal reads the
manifest now, which also reaches an agent no longer detected here —
its files are still on disk.

pkgstate.Read is the read-only companion to Update, so init and
install share one way in rather than duplicating the load.

Refs #2030

Signed-off-by: András Jáky <ajaky@cisco.com>
The audience is a non-secret connection field, and client/oidc_grpc.go
needs it to mint GitHub Actions tokens, so a context using that flow
lost it when `dirctl mcp serve` started. The two secrets, auth_token
and spiffe_token, stay excluded.

Refs #2030

Signed-off-by: András Jáky <ajaky@cisco.com>
Left behind when the MCP server & skills prose was added above the
flag table, rendering an empty table mid-paragraph.

Refs #2030

Signed-off-by: András Jáky <ajaky@cisco.com>
@akijakya

Copy link
Copy Markdown
Member Author

All six inline findings were legitimate and are fixed, one commit per theme, each replied to in its thread.

The suppressed comment on cli/cmd/init/agents.go:246 was correct too, and is fixed in dd41ec3. init --remove derived the built-in artifacts from the running binary, so it only removed what this version writes: an MCP key an earlier dirctl had written under another name read as already absent, Forget dropped the row as cleared, and the old key stayed in the agent's config with nothing left pointing at it. Removal now reads the rows and goes through UninstallRecorded, which also reaches an agent no longer detected on the machine — its files are still on disk either way. pkgstate.Read was added as the read-only companion to Update so init and install share one way in.

The four fixes:

Commit Finding
6013301 target grouping, shared skill folder under --agents, version-only over-claiming, per-row pins
dd41ec3 init --remove removing derived rather than recorded artifacts
c437b82 OIDCAudience missing from the projected MCP environment
7f552a8 orphan table header in the init docs

The four upgrade.go findings share a commit because they interlock in that file — upgradeWrite gained its rows for the pin fix and is what versionOnly reads.

One correction to the PR body, which no longer mentions it: the note that cli/cmd, cmd/daemon and cmd/doctor could only be exercised in CI was a local toolchain problem, since resolved. go test ./... and task lint:go are both clean across every module.

@ramizpolic ramizpolic left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@akijakya
akijakya merged commit 1a83c05 into main Sep 18, 2026
36 checks passed
@akijakya
akijakya deleted the feat/dirctl-upgrade branch September 18, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Denotes a PR that changes 2000+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dirctl install upgrade, builtin record tracking, and highest-version name resolution

3 participants