npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-check

The 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. No await, 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.

  1. Write your two adapterslist_installed and check_registry. Read the callout about returning {}; it is the one that bites hardest.
  2. Construct the checker and decide whether you want watch (a single-product CLI does) and self (you almost certainly do, even under npx).
  3. Call check() at the top of your command dispatch and print it.
  4. Wire up invalidation — including the case where you don't own the lock writer, which is most integrators.
  5. 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.
  6. Test it with the recipe, not by hand-crafting cache files.
  7. When it says nothing, find out whythe 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 since missing requires every scope to report (see below), an adapter that throws ENOENT will 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/name coordinate. 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. Omit watch entirely 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_installed before 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 file

Put 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 absent and 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 reason watch: [] 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 -g

That 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/writer

Past 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/four

If 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_ms cannot 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 a stdout write 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>/latest

No 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 calls check() 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_version and 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