@happyskillsai/skill-drift-check
v0.2.0
Published
Passive, non-blocking drift detection for AI agent skills installed alongside a CLI — tells the user when their installed skills (and optionally the CLI itself) have fallen behind the registry, without ever making them wait on the network.
Downloads
266
Maintainers
Readme
@happyskillsai/skill-drift-check
Passive drift detection for AI agent skills installed alongside a CLI.
Your CLI updates itself. Your skills don't — and that asymmetry is structural, not an oversight. The skills a CLI installs are pinned on disk on purpose, so they sit exactly where they were installed until somebody deliberately updates them. Weeks later the user is running current tooling against stale expertise, and nothing anywhere says so.
This package is the missing half: it tells the user their installed skills have fallen behind, without ever making them wait for the answer.
npm install @happyskillsai/skill-drift-checkThe shape of the problem
Anything with a skill + CLI architecture has two things that can fall behind, on completely different clocks:
| | Drifts under npx pkg@latest? | Drifts under a bare npx pkg? | Drifts under a global or project install? |
|---|---|---|---|
| The CLI itself | No — re-resolved every run | Yes — pinned to whatever landed first | Yes |
| Its installed skills | Yes | Yes | Yes |
That middle column is the one people get wrong, including — until recently — this README. See Checking the CLI itself.
Both rows are covered here, sharing one cache and one refresh schedule, so the user gets one coherent answer rather than two banners racing on the same stderr.
The contract
A drift check must never cost the user anything. Everything below follows from that:
check()is synchronous. It reads a cached verdict and returns. Noawait, no network, no measurable latency added to the command it rode in on.- The refresh is fire-and-forget. A stale cache triggers a background probe whose result is used on the next run. Every error is swallowed — a drift probe that breaks the host command has done far more damage than the staleness it was reporting.
- A failed probe changes nothing. The previous verdict survives rather than being cleared to a false all-clear, and the next run retries.
- Verdicts expire. Because a failed probe leaves the cache alone, an offline laptop
or a dead registry would otherwise freeze the last verdict on screen forever. Past
max_age_ms(default 7 days) the check reports nothing. Silence is honest; a confident month-old claim is not.
Integration checklist
The rest of this document is organised around how the package thinks. Integration is a sequence. If you are wiring this up right now, follow these in order — each links into the reasoning behind it.
- Write your two adapters —
list_installedandcheck_registry. Read the callout about returning{}; it is the one that bites hardest. - Construct the checker and decide whether you want
watch(a single-product CLI does) andself(you almost certainly do, even undernpx). - Call
check()at the top of your command dispatch and print it. - Wire up invalidation — including the case where you don't own the lock writer, which is most integrators.
- Confirm your commands live long enough for the probe to land — see here. A CLI whose commands are all fast can ship this and never warm the cache once.
- Test it with the recipe, not by hand-crafting cache files.
- When it says nothing, find out why — the silence table distinguishes "working correctly" from "mis-wired", which look identical from outside.
If you publish a security model, what leaves the machine is written to be lifted verbatim.
Quick start
const { create_checker } = require('@happyskillsai/skill-drift-check')
const checker = create_checker({
name: 'my-cli', // namespaces the cache file
scopes: [
{ id: 'local', root: () => find_project_root() },
{ id: 'global', root: () => os.homedir() }
],
list_installed: async ({ id, root }) => read_my_lockfile(root),
check_registry: async (names) => askMyRegistry(names)
})
// At the top of your command dispatch:
const verdict = checker.check()
if (verdict) for (const line of checker.format(verdict)) process.stderr.write(` ${line}\n`)Adapters
The package owns the machinery — cache, scopes, scheduling, expiry, invalidation, verdict shape. It knows nothing about your lock format or your registry, which is what lets it work with any of them. You supply two functions:
list_installed({ id, root }) → a map of installed skills:
{ 'owner/name': { version: '1.0.0', revision: 'abc123' } }revision is any opaque token your registry uses to identify exact content — a git
commit, a content hash, a build id. Optional but recommended: it catches a republish that
reused the same version string, which a version comparison cannot see.
Return
{}for a lock file that does not exist — do not throw. "No lock yet" means "no skills installed here", which is a perfectly good answer and the normal state of the global scope on a fresh machine. A scope that throws instead is treated as unable to report, and sincemissingrequires every scope to report (see below), an adapter that throwsENOENTwill suppress the missing-skill nudge indefinitely. Reserve throwing for a lock you genuinely could not read.
check_registry(names, { id, root, signal }) → what the registry currently has:
{ 'owner/name': { latest_version: '2.0.0', revision: 'def456', unavailable: false } }Set unavailable: true for anything you couldn't answer for — auth denied, deleted,
rate-limited. It is treated as no verdict, never as "up to date".
Pass the signal through to your fetch. The refresh isn't awaited, but a pending
socket is a live handle: Node will not exit until it settles, so an unbounded adapter
stops costing latency and starts costing the user wall-clock at the end of every command.
The signal aborts after timeout_ms. This is not optional — see
Will your commands live long enough?
check_registry: async (names, { signal }) =>
(await fetch(url, { signal, method: 'POST', body: JSON.stringify({ names }) })).json()Only a registry advance counts as drift
The comparison runs against the revision recorded at install time, and never looks at the working tree. A skill whose author bumped it locally but hasn't published yet is ahead, not behind — and telling them to "update" would offer to overwrite their unpublished work. A registry that moved backwards — a yank, an unpublish, a dist-tag rolled back — is not an advance either.
A complete worked example
Everything above and below in one block: a CLI that ships one companion skill, installed into either the project or the user's home directory by a different tool.
const fs = require('fs')
const os = require('os')
const path = require('path')
const { create_checker } = require('@happyskillsai/skill-drift-check')
const SKILL = 'acme/writer'
const LOCK = ['.agents', 'skills-lock.json']
const checker = create_checker({
name: 'my-cli',
scopes: [
{ id: 'local', root: () => find_project_root() }, // walk UP to your project marker
{ id: 'global', root: () => os.homedir() }
],
watch: [SKILL], // ask about ours, never the user's other forty
invalidate_on: [LOCK.join('/')], // somebody else writes this; notice when they do
// Absent lock => {}, never a throw. A throw here suppresses the missing nudge forever.
list_installed: async ({ root }) => {
let raw
try { raw = fs.readFileSync(path.join(root, ...LOCK), 'utf8') }
catch (err) { if (err.code === 'ENOENT') return {}; throw err }
const lock = JSON.parse(raw)
return Object.fromEntries(Object.entries(lock.skills || {})
.map(([name, s]) => [name, { version: s.version, revision: s.revision }]))
},
check_registry: async (names, { signal }) => {
const res = await fetch('https://registry.example.com/skills/latest', {
method: 'POST', signal,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ names })
})
if (!res.ok) return Object.fromEntries(names.map(n => [n, { unavailable: true }]))
return res.json()
},
self: { package_name: 'my-cli', current: require('./package.json').version },
disabled: 'MY_CLI_NO_UPDATE_CHECK' // set to '1' to switch everything off
})
const FORMAT_OPTS = {
update_command: 'npx my-cli skills update',
install_skill_command: 'npx my-cli skills add',
install_command: 'npm i -g my-cli',
scope_flags: { global: '-g' } // one update run writes one scope
}
// Top of your command dispatch, before any command runs.
const report_drift = () => {
const verdict = checker.check()
if (!verdict) return
for (const line of checker.format(verdict, FORMAT_OPTS))
process.stderr.write(` ${line}\n`)
}Watching specific skills
By default every installed skill is checked, which is what a skill package manager wants. A single-product CLI usually wants the opposite: it ships one companion skill and cares only about that one, even though the same lock file lists forty others belonging to other tools.
create_checker({
name: 'instant-canvas',
watch: ['happyskillsai/instant-canvas'], // one, or several
// ...
})Three things worth knowing:
- The filter is applied before the registry probe, not at reporting time. Both give the same nudge, but filtering late would send the user's entire installed-skill inventory to a registry that has no business seeing it. Your CLI only ever asks about the skills it owns. This is a privacy guarantee, not an optimisation — see what leaves the machine.
- Matching is case-insensitive on the
owner/namecoordinate. Owner casing drifts in practice, and an allowlist that missed on a capital letter would go quiet in a way indistinguishable from "you're up to date". watch: []throws. An empty allowlist is almost always a config that failed to load, and honouring it would produce permanent, confident silence. Omitwatchentirely to watch everything; that's a different instruction and it's spelled differently.
A watched skill that isn't installed anywhere appears in verdict.skills.missing, never
in outdated — "not installed" needs an install prompt, not an update nudge.
missing is only ever reported when every reachable scope reported in. If one
scope's probe failed, "absent from the scopes I heard from" is not "absent everywhere",
and claiming otherwise would tell the user to install something they already have. On
incomplete coverage the field stays empty — same principle as an expired verdict. When a
watched skill is never reported missing and you expected it to be, that suppression is the
first thing to check: diagnostics().coverage.complete.
Known limitation. If a watched skill moves coordinates (owner rename, transfer), an exact-match allowlist stops watching it and the nudge goes quiet. This package can't detect forwarding generically. If your registry supports it, resolve the current coordinate inside
list_installedbefore returning.
Invalidate when the lock changes
A cached verdict describes a specific lock file. The moment that lock changes, the verdict is a claim you already know is wrong — so for up to a day after an update the CLI would keep naming skills the user just updated, and after an uninstall it would suggest updating a skill that's gone.
await write_my_lockfile(root, next)
checker.invalidate(root) // advisory: never throws, never creates a cache filePut this in the one place that writes your lock, not in the N commands that mutate it. A cross-cutting concern has to sit below all of its call sites, or the sites that forget it silently do without.
invalidate(root) clears every scope at that root; pass invalidate(root, scope_id) to
target one. It returns true if it actually dropped an entry, which is useful in tests
and meaningless in production.
When you don't own the lock writer
The advice above assumes you are the thing that writes the lock. Most integrators are
not — a single-product CLI ships one companion skill and some other tool installs it. In
the HappySkills ecosystem skills-lock.json is written by HappySkills, never by the CLI
integrating this package. There is no call site to put invalidate() in.
Name the lock and let the checker detect the change instead. invalidate_on takes
paths resolved against each scope root, so one entry covers every scope:
create_checker({
scopes: [
{ id: 'local', root: () => find_project_root() },
{ id: 'global', root: () => os.homedir() }
],
invalidate_on: ['.agents/skills-lock.json'], // resolved under each root above
// ...
})The checker stamps those files at the moment it records a verdict, stores the stamp with
the verdict, and re-stamps on every check(). When a stamp stops matching, that scope's
verdict is withheld and a fresh probe is scheduled immediately rather than at the next
ttl_ms — waiting out the ttl would leave the host silent for up to a day at the exact
moment its answer changed.
What that buys you, and what each piece prevents:
- The stamp is mtime and size. mtime alone misses two writes inside one filesystem tick; size alone misses a same-length edit, which is exactly what swapping a version string in place looks like.
- An absent file is a state, not an error. A deleted lock stamps as
absentand therefore counts as a change — otherwise an uninstall would leave the last verdict nudging you to update a skill that is gone. - The stamp is taken before the lock is read, not after the probe returns. A refresh reads the lock, waits on a network round-trip, then writes. An update landing inside that window would otherwise be stamped as though the probe had seen it, freezing a verdict that describes a lock nobody has any more.
- The stamp lives with the verdict, not in a side-car file. This is the trap in the obvious hand-rolled version: a side-car has no prior value the first time it runs, so it reads "no stamp" as "changed" and clears a cache that was just warmed. Here, no cached verdict means there is nothing to compare and nothing to drop — the question cannot be asked in the first place.
invalidate_on: []throws, for the same reasonwatch: []does.
The cost is one stat per configured path per scope, on a synchronous path that runs on
every host command — and it is only paid by hosts that ask for it.
If you do own the lock writer, keep calling invalidate(): it is exact where this is
inferred, and it acts at the moment of the write rather than waiting for the next check()
to notice. The two compose fine; use both if some of your scopes are written by you and
some are not.
Checking the CLI itself
create_checker({
// ...
self: { package_name: 'my-cli', current: require('./package.json').version }
})Whether this is a no-op under npx depends on the invocation, and the common case is
that it is not. npx pkg@latest re-resolves the tag every run, so the tool really is
always current and the check earns nothing. A bare npx pkg is different: npm reuses an
existing cached install whenever it satisfies the requested spec, and a bare name is
satisfied by whatever is already there. The first version a user ever ran stays pinned,
indefinitely, with no signal anywhere.
That is not theoretical. A dig through one developer machine's ~/.npm/_npx found four
versions of the same tool cached side by side, spanning a major release. The version your
process reports is the pinned one, so comparing it against the registry is the only
mechanism that will ever surface this.
If your own docs, README, or agent instructions hand anyone a bare npx spec, keep the
self-check — that is precisely the audience it exists for. It is equally load-bearing
for a global install and for a CLI in a project's devDependencies. Omit self only if
every documented invocation pins @latest.
It shares the same cache and refresh clock as the skill check, so the two can't disagree or double-nudge.
Both fields are validated at construction and a malformed self throws. self.current is
almost always require('./package.json').version, and a typo'd key or a stripped field
would otherwise switch the check off permanently and silently. Omitting self is the
supported way to not have one.
The verdict
check() returns null when there is nothing to say — see
Why am I seeing nothing? — or:
{
skills: {
outdated: [{
skill: 'owner/name',
latest: '2.0.0',
installs: [ // every stale copy, one per scope
{ scope: 'local', installed: '1.0.0' },
{ scope: 'global', installed: '0.9.0' }
]
}],
missing: ['owner/watched-but-not-installed'],
scopes: ['local', 'global'],
coverage: { reported: ['local', 'global'], expected: ['local', 'global'], complete: true }
},
self: { update_available: true, current: '1.0.0', latest: '1.2.0' }
}One skill is one entry even when it is stale in several scopes — it is one concern — but
every stale copy survives in installs, because one update run writes one scope.
Collapsing them would print a command that fixes one copy and leaves the other, so the
nudge returns unchanged next run and reads as though the command did nothing.
What format() prints
format(verdict, opts) returns ready-to-print lines — one per thing the verdict actually
reports (self-update, drift, missing).
checker.format(verdict, {
update_command: 'npx my-cli skills update', // prefixes the drift line
install_skill_command: 'npx my-cli skills add', // prefixes the missing line
install_command: 'npm i -g my-cli', // prefixes the self-update line
scope_flags: { global: '-g' } // appended per scope on the drift line
})With those options, one skill behind the registry renders as follows. The only thing that changes between the three is which scopes the stale copies were found in:
local only
1 installed skill(s) behind the registry (acme/writer). Run: npx my-cli skills update acme/writer
global only
1 installed skill(s) behind the registry (acme/writer). Run: npx my-cli skills update acme/writer -g
both
1 installed skill(s) behind the registry (acme/writer). Run: npx my-cli skills update acme/writer && npx my-cli skills update acme/writer -gThat third line is the point of scope_flags: one update run writes one scope, so a skill
stale in both places needs both commands. A single command would fix one copy and leave
the other, and the nudge would come back unchanged next run looking as though the command
had done nothing.
The other two renderers:
Update available: v1.0.0 → v1.2.0. Run: npm i -g my-cli
1 watched skill(s) not installed (acme/writer). Run: npx my-cli skills add acme/writerPast three skills the drift line summarises the names but still emits every one in the command, because a bulk "update all" usually only reaches top-level installs:
4 installed skill(s) behind the registry (a/one, a/two, a/three, +1 more). Run: npx my-cli skills update a/one a/two a/three a/fourIf your CLI has a structured output contract, emit the verdict object into it instead —
but derive both faces from the same verdict, or your human banner and your
machine-readable warning will eventually disagree about the same install. Individual
renderers (format_skill_drift, format_missing_skills, format_self_update) are
exported too, if you want to place them yourself.
Health vs news
check() answers "is there news for the user". diagnostics() answers "is the checker
actually seeing everything" — and answers it regardless of whether there is news:
checker.diagnostics()
// { coverage: { reported: ['local'], expected: ['local','global'], complete: false },
// configured_scopes: ['local','global'], cache_path: '…', disabled: false }These are deliberately separate. check() returns null when there is nothing to report
— which is exactly what a host sees when a scope's adapter throws on every run and
suppresses the missing claim permanently. Coverage hanging off the verdict would be
invisible in precisely the case worth noticing. diagnostics() schedules nothing.
coverage.expected lists the scopes that were reachable this run, not the ones you
configured. A scope whose root() throws or returns a non-string is skipped entirely —
that is the normal case for a project scope when the user is outside a project, not an
error — so it never appears in expected and never drags complete to false. To catch
a scope that never resolves anywhere, compare coverage.expected against
configured_scopes; a permanent gap between the two is a root resolver that is always
failing.
Will your commands live long enough?
The refresh is fire-and-forget, which means the current command never waits for it. It also means the probe only lands if the process is still alive when it finishes, and most CLI commands finish in milliseconds.
The probe runs in-process — nothing is spawned or detached — so how your CLI exits decides what happens:
- You let the event loop drain naturally. The pending socket is a live handle, so Node
stays up until it settles and the probe lands. The user waits for it. That wait is
bounded only if your adapter honours the
signal; if it doesn't,timeout_mscannot help you and a hung registry adds its full stall to every command. This is why passing the signal through is stated as a requirement and not a suggestion. - You call
process.exit(), or exit inside astdoutwrite callback, or otherwise tear down hard. The probe is killed mid-flight and the cache is not written. Nothing reports an error, because the whole path is designed not to.
The consequence worth knowing before you ship: a CLI whose commands are all fast and which exits explicitly can integrate this correctly and never warm the cache once. It will be permanently, silently quiet — which is indistinguishable from "everything is up to date". In practice the cache warms during slower commands (rendering, network work, anything interactive) and effectively never during fast ones, which is fine as long as you have at least one of the former.
Do not add an artificial delay to make room for the probe; that spends the user's time to
buy a notification, which is the trade this package exists to refuse. Let it ride your
slower commands, and verify with the recipe below that it
lands at least somewhere. The package deliberately does not detach the probe — fetch
exposes no handle to unref(), and detaching it would guarantee it never lands on exactly
the fast commands that dominate. Your levers are timeout_ms and honouring signal.
What leaves the machine
Originating from this package: exactly one request, and only if you configured self
and did not replace the probe.
GET https://registry.npmjs.org/<your package name>/latestNo body, no query string, no headers beyond fetch's defaults, no cookies or credentials.
The package name is one you chose and published. Supply fetch_latest_version and the
package makes no network calls of its own at all.
Originating from your own check_registry: the owner/name coordinates of the skills
that survived the watch filter. Your adapter makes that call, to your registry, with your
auth, on your terms — the package hands it a list of names and nothing else. Because the
filter is applied at enumeration rather than at reporting time, a CLI that watches one
skill asks about one skill; the other forty in the user's lock file are never named to
anyone. Omit watch and you ask about everything installed, which is the right default for
a skill package manager and the wrong one for a single-product CLI.
Never transmitted, by either path: filesystem paths, scope roots, the lock file or any
part of it beyond the watched coordinates, project or workspace contents, the versions of
unwatched skills, usernames, hostnames, machine or install identifiers, timestamps, or
usage counts. There is no analytics endpoint, no telemetry of any kind, and one runtime
dependency (semver).
Stays on disk: the cache, at checker.cache_path(). It holds the watched skill names,
their installed and latest versions, and a checked_at timestamp. Nothing reads it but
this package.
Off switch: disabled — a function returning a boolean, or the name of an environment
variable checked for '1'. When it is on, check() returns null and schedules nothing,
so no request is made from any path.
Options
| Option | Default | |
|---|---|---|
| name | required | Namespaces the cache file |
| scopes | required | [{ id, root }]; root may be a function |
| list_installed | required | Adapter → installed skills for a scope |
| check_registry | required | Adapter → current registry state |
| watch | all skills | Allowlist of owner/name; [] throws |
| invalidate_on | null | Lock paths, per scope root — drop a verdict when they change; [] throws |
| self | null | { package_name, current } to also check the CLI |
| ttl_ms | 24 h | How long before a background refresh is triggered |
| max_age_ms | 7 days | Past this, nothing is reported at all |
| disabled | null | Fn returning a boolean, or an env var name checked for '1' |
| cache_dir | XDG / ~/.config/<name> | Override the cache location |
| timeout_ms | 5000 | Abort bound on the built-in npm probe |
| fetch_impl | globalThis.fetch | Injectable fetch, for the built-in npm probe |
| fetch_latest_version | null | Replace the npm probe entirely: async (pkg) => '1.2.3' |
| now | Date.now | Injectable clock |
The last four exist so you can force a known state in tests without touching the cache file — see Testing your integration.
The cache lives at $XDG_CONFIG_HOME/<name>/drift-check.json, falling back to
~/.config/<name>/drift-check.json. That fallback applies on Windows too, where
~/.config is not the native convention — it works, but if you care, pass cache_dir
explicitly (e.g. %APPDATA%).
Scope keys and where you resolve root
Cache entries are keyed on the resolved root you hand over. If you derive that from the bare working directory, every subdirectory a user happens to run in becomes a separate cache scope — each paying its own cold start, and each reporting zero installed skills because there's no lock file down there. Resolve your root by walking up to the project marker before passing it in.
Why am I seeing nothing?
check() returning null is the normal case by design, and it is also what you see when
your wiring is wrong. Those look identical from outside, so this table is how you tell
them apart. Three of these mean you are fine; four mean something needs attention.
| You see nothing, and… | It means | How to tell |
|---|---|---|
| it's the first run in a fresh environment | Cold cache — correct. The probe is firing right now; the verdict lands on the next run | checker.cache_path() did not exist before the run and does after |
| it never warms across many runs | Your commands exit before the probe lands | A slow or long-running command warms it; a fast one doesn't. See above |
| everything is genuinely current | Working correctly | diagnostics().coverage.complete === true and the cache file has a recent checked_at |
| the machine has been offline for over a week | The verdict expired past max_age_ms — deliberate silence | A confident week-old claim would be worse. It recovers on the first successful probe |
| a lock was just installed, updated or removed | invalidate_on dropped the verdict, because it described the lock that just changed | Correct, and self-healing: a probe is already scheduled, so the next run reports again |
| a watched skill is never reported missing | A scope's adapter is throwing, so coverage is incomplete and the claim is suppressed | diagnostics().coverage.complete === false |
| one scope never contributes anything | That scope's root() threw or returned a non-string, so it is skipped entirely | An id in diagnostics().configured_scopes that never appears in coverage.expected |
| nothing is ever reported, anywhere | The kill switch is on | diagnostics().disabled === true |
diagnostics() is safe to call from a debug command or a --doctor flag: it schedules
nothing, makes no network calls, and answers even when there is no news.
Testing your integration
Do not hand-craft cache files. The on-disk shape is internal and will change; a test
written against it couples you to something you shouldn't depend on. Force the state you
want through the injection points instead — the adapters are already yours, and
cache_dir, now and fetch_latest_version cover the rest.
Two things make this package awkward to test, and both have a one-line answer:
- The verdict is always a run behind.
check()reads the cache and schedules a refresh; the result appears on the next call. So every test callscheck()twice. - The refresh is fire-and-forget, so there is no promise to await. With synchronous
fake adapters, yielding the event loop once is enough. (With real network adapters it is
not — that is what
fetch_latest_versionand fake adapters are for.)
const test = require('node:test')
const assert = require('node:assert')
const fs = require('fs'), os = require('os'), path = require('path')
const { create_checker } = require('@happyskillsai/skill-drift-check')
// A private cache per test: no cross-talk, no dependence on the developer's real cache.
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'drift-'))
// check() schedules; the verdict lands on the next call.
const warm = async (checker) => { checker.check(); await new Promise(r => setImmediate(r)) }
test('an outdated skill is reported', async () => {
const checker = create_checker({
name: 'my-cli', cache_dir: tmp(),
scopes: [{ id: 'local', root: () => process.cwd() }],
watch: ['acme/writer'],
list_installed: async () => ({ 'acme/writer': { version: '1.0.0' } }),
check_registry: async () => ({ 'acme/writer': { latest_version: '2.0.0' } })
})
assert.equal(checker.check(), null) // cold cache: nothing to say yet
await warm(checker)
const v = checker.check()
assert.equal(v.skills.outdated[0].skill, 'acme/writer')
assert.equal(v.skills.outdated[0].latest, '2.0.0')
})
test('a watched skill installed nowhere is reported missing', async () => {
const checker = create_checker({
name: 'my-cli', cache_dir: tmp(),
scopes: [{ id: 'local', root: () => process.cwd() }],
watch: ['acme/writer'],
list_installed: async () => ({}),
check_registry: async () => ({})
})
await warm(checker)
assert.deepEqual(checker.check().skills.missing, ['acme/writer'])
})
// The negative control for the test above. Without it, a checker that reports NOTHING
// passes every positive assertion you have.
test('missing is suppressed when a scope could not report', async () => {
const checker = create_checker({
name: 'my-cli', cache_dir: tmp(),
scopes: [{ id: 'local', root: () => process.cwd() },
{ id: 'global', root: () => os.homedir() }],
watch: ['acme/writer'],
list_installed: async ({ id }) => { if (id === 'global') throw new Error('unreadable'); return {} },
check_registry: async () => ({})
})
await warm(checker)
assert.equal(checker.check(), null)
assert.equal(checker.diagnostics().coverage.complete, false)
})
test('a CLI self-update is reported', async () => {
const checker = create_checker({
name: 'my-cli', cache_dir: tmp(),
scopes: [{ id: 'local', root: () => process.cwd() }],
list_installed: async () => ({}),
check_registry: async () => ({}),
self: { package_name: 'my-cli', current: '1.0.0' },
fetch_latest_version: async () => '1.2.0' // no network, no npm registry
})
await warm(checker)
assert.deepEqual(checker.check().self,
{ update_available: true, current: '1.0.0', latest: '1.2.0' })
})
// Time travel via `now` — the only way to exercise expiry without waiting a week.
test('a verdict past max_age_ms is withheld', async () => {
let t = 1_000_000
const checker = create_checker({
name: 'my-cli', cache_dir: tmp(), now: () => t,
scopes: [{ id: 'local', root: () => process.cwd() }],
watch: ['acme/writer'],
list_installed: async () => ({ 'acme/writer': { version: '1.0.0' } }),
check_registry: async () => ({ 'acme/writer': { latest_version: '2.0.0' } })
})
await warm(checker)
assert.ok(checker.check())
t += 8 * 24 * 60 * 60 * 1000 // past the 7-day ceiling
assert.equal(checker.check(), null)
})To exercise your format() options, build a verdict literal and pass it straight to
checker.format(verdict, opts) — it is a pure function of the verdict and takes no cache,
no clock and no network. That is the cheapest way to confirm scope_flags emits the
commands you expect for a skill stale in both scopes.
Test the silence too. Every assertion above that expects a nudge should have a partner asserting the nudge is withheld — on a rollback, on a local version bump, on incomplete coverage. A checker that has been accidentally switched off passes every positive test you write.
Prior art
For checking your CLI's own npm version alone,
update-notifier is mature and widely
deployed — use it. This package exists for the skills half, which it doesn't cover, and
folds in the self-check only so both share one cache and one schedule.
Contributing
This package is developed test-first, and the rules are not optional — it ships inside other people's CLIs from a version that cannot be hot-fixed. See CLAUDE.md before changing anything.
npm run verify # lint + tests + coverage floor — the gate
npm test # tests only (Node 18/20/22)Coverage floor is 100% of lines, enforced. Every change starts with a test that was watched fail first.
License
BSD-3-Clause © Nicolas Dao
