Skip to content

fix(installer): only rewrite the skills store the updated install owns - #657

Open
filip131311 wants to merge 1 commit into
mainfrom
filip/skills-refresh-scope
Open

fix(installer): only rewrite the skills store the updated install owns#657
filip131311 wants to merge 1 commit into
mainfrom
filip/skills-refresh-scope

Conversation

@filip131311

Copy link
Copy Markdown
Collaborator

Fixes #637. See my triage comment there
for the full findings — this PR fixes two defects, and the reported one is the milder.

1. The reported bug

refreshArgentSkills(projectRoot) took no scope and looped both specs, so argent update --local
rewrote the machine-wide store. Reproduced against the shipped code with a sandboxed HOME and a
stub npx:

scopes acted on: project(synced=15), global(synced=15)
npx … skills add …#v0.18.1 --skill * -y
npx … skills add …#v0.18.1 --skill * -y -g     ← global, from a --local update

Callers now name the stores their update owns, and the scope is required — a call site that
forgets one is a compile error rather than a silent write to a shared store. (Both existing call
sites did forget; the compiler caught them.)

This also closes the mirror case: argent update --global no longer rewrites a project's
committed skills-lock.json when that project runs a local install.

One trap worth naming: "is there a local install" is the wrong predicate. chooseScope returns
local for non-interactive global-mode installs too, and init-skills.ts only passes -g for
an explicitly global scope — so a project lock can legitimately track the global binary. The rule
keys on resolveInstallMode.

2. The worse bug the fix had to also close

Narrowing scopes alone would have closed this issue while leaving a deletion path open.

Obsolete skills are computed as the difference against the running package's bundled set, and the
global spec's removal carries -g. So an install older than the one that filled a store classifies
everything added since as orphaned and deletes it machine-wide. The skew is real — v0.15.0 ships 13
skills, current ships 15. Reproduced:

npx --force skills remove -y -g argent-screen-recording argent-settings-permissions

And scope-narrowing does not stop it: any run whose targets include global still prunes from the
running package's set — including plain argent update --yes on a machine with both installs, and the
tool-server's own update-argent, which pins --global while being served by the local install.

So pruning a store is now gated on ownership: allowed only when the store was last written by a
version no newer than ours (the lock records a ref per entry). An unreadable or unversioned lock
means we cannot tell, so we leave it alone. Syncing stays unconditional — it is additive and
recoverable; deletion is not.

Deliberate behaviour changes

  • A machine with only a local install stops refreshing a leftover global lock. That is correct — no
    binary owns that store — but it is a change, so the command now says so once rather than letting it
    drift silently: "Left the machine-wide skills alone — they track the global install."
  • Telemetry scope_count legitimately drops from 2 to 1 for local updates. The field still means
    "scopes this refresh acted on", so I left the event schema alone.

Repair for anyone already affected

A re-pinned global store self-heals on the next run that includes the global target and is
executed by a package at least as new as the store's tracked set — i.e. argent update --global from
the global binary. It does not heal when run by the older local install, which is what caused it.
Worth a line in the release note.

Verification

The original sandboxed repro, re-run against the fix:

update --local                 -> project            (no -g invocation at all)
update --global (global-mode)  -> project, global    (-g appears only here)

and the deletion path, against a store written by a newer install:

pruned: []      removals issued: 0

installer suite 548 passed. Existing scope tests were passing partly by accident — they touched one
store only because the other lock happened not to exist — so each now states the scope it exercises,
and the new containment tests stage both stores so acting on the wrong one is visible.

Not in this PR

  • Version provenance. buildArgentSkillsSource(getInstalledVersion()) uses the running package,
    so argent update --local invoked from the global binary still pins the project lock to the
    global's version. The fix is a per-target version, but an unreadable version falls back to an
    absolute SKILLS_DIR path, which would write a machine-specific path into a committed lock — the
    portability problem The argent init skills installation breaks skills.lock files checked into repos #208 fixed. Needs its own decision.
  • uninstall.ts computes orphans from the running package the same way, so it can strand newer skills.
    Same root cause, different command.

🤖 Generated with Claude Code

`argent update --local` re-synced the machine-wide skills store as well as the
project's. `refreshArgentSkills` took no scope and acted on every store that
tracked an argent skill, so a project-scoped update rewrote the skills every
other project on the machine uses — pinning them, via the running package's own
version, to a version the global binary is not.

A store belongs to exactly one install: the global lock to the binary on PATH,
a project's lock to whichever install serves that project. Callers now say which
stores their update owns. The scope is required rather than defaulted, so a call
site that forgets one is a compile error instead of a silent write to a shared
store. This also closes the mirror case — a global update no longer rewrites a
project's committed lock when that project runs a local install.

Note the project scope is not "the local install's scope": a non-interactive
global install writes a project lock too, and that lock tracks the global binary.
The install mode is what tells them apart.

The more damaging half was the prune. Obsolete skills are the difference against
the running package's bundled set, and the removal carried `-g` for the global
store — so an older install would classify everything added since as orphaned and
delete it machine-wide. Between v0.15.0 and now that is two real skills. Pruning
a store is now allowed only when it was last written by a version no newer than
ours; an unreadable or unversioned lock means we cannot tell, so we leave it
alone. Syncing stays unconditional — it is additive and recoverable, deletion is
not.

A store left untouched is now named in the output, so it does not just go quiet.

Fixes #637
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.

argent update --local also rewrites the global skills store

1 participant