Problem
air upgrade only runs npm install -g @pulsemcp/air-cli@latest — it does not touch the extensions installed under air-cli's managed extension directory (e.g. ~/.air/node_modules/). Because the air monorepo does lockstep version bumps across all 8 packages, this leaves users with a CLI on the latest version and extensions multiple major versions behind, which produces hard-to-diagnose runtime errors.
Reproduction
Starting state — long-stale install, then air upgrade:
$ npm ls -g
@pulsemcp/air-cli 0.4.1 ← upgraded by `air upgrade`
@pulsemcp/air-adapter-claude 0.4.1 ← present globally but not the one loaded
@pulsemcp/air-provider-github 0.4.1
@pulsemcp/air-secrets-env 0.4.1
$ ls ~/.air/node_modules/@pulsemcp/
@pulsemcp/air-adapter-claude 0.0.25 ← actual loaded extensions
@pulsemcp/air-provider-github 0.0.25
@pulsemcp/air-secrets-env 0.0.25
@pulsemcp/air-secrets-file 0.0.25
@pulsemcp/air-core 0.0.25
$ cat ~/.air/package.json
{
"dependencies": {
"@pulsemcp/air-adapter-claude": "^0.0.25",
"@pulsemcp/air-provider-github": "^0.0.25",
...
}
}
air start claude then fails with:
Error: Provider for "github://" cannot resolve directory paths — it lacks resolveCatalogDir().
Upgrade the provider extension or replace the URI with a vendored relative path.
Even though the new global air-provider-github@0.4.1 implements resolveCatalogDir(), the extension loader uses createRequire(join(airJsonDir, "__placeholder.js")) where airJsonDir defaults to ~/.air/, so the v0.0.25 copy in ~/.air/node_modules/ is what's resolved.
Two underlying gaps
-
air upgrade doesn't update extensions. It needs to additionally rewrite ~/.air/package.json (or whichever <airJsonDir>/package.json the user's config resolves from) and reinstall — pinning each extension to a version compatible with the new CLI. Since the monorepo is lockstep, pinning to the CLI version is the simplest correct behavior.
-
air install's isPackageInstalled() only checks directory existence, never version compatibility. From packages/sdk/src/install.ts:
function isPackageInstalled(specifier, prefix) {
const name = stripVersion(specifier);
return existsSync(join(prefix, "node_modules", name));
}
So even if the user runs air install after the CLI bump, it sees ~/.air/node_modules/@pulsemcp/air-provider-github exists and reports Already installed, never bumping. Pinned ^0.0.25 constraints in the package.json compound this — npm install alone in that dir won't upgrade either.
Proposed fix
air upgrade should:
- Upgrade
air-cli (today's behavior).
- Read the resolved
air.json's extensions array.
- For each extension, rewrite the corresponding constraint in
<airJsonDir>/package.json to a version range compatible with the upgraded CLI (e.g., ^<cli-major>.<cli-minor>.0, since the monorepo bumps in lockstep).
- Run
npm install --prefix <airJsonDir> (or equivalent), letting npm pull the new versions.
air install's isPackageInstalled should also be tightened so a user can force a re-check / reinstall when CLI compatibility shifts — at minimum, parsing <prefix>/node_modules/<name>/package.json and comparing against the desired constraint.
Workaround
For users hitting this now:
npm install --prefix ~/.air \
@pulsemcp/air-adapter-claude@latest \
@pulsemcp/air-provider-github@latest \
@pulsemcp/air-secrets-env@latest \
@pulsemcp/air-secrets-file@latest
(Adjust the package list to match your ~/.air/air.json extensions array.)
Related
This is likely subsumed by the broader air update/air upgrade unification work — filing separately so the install-location and version-aware install semantics are tracked explicitly regardless of how the command shape lands.
Problem
air upgradeonly runsnpm install -g @pulsemcp/air-cli@latest— it does not touch the extensions installed under air-cli's managed extension directory (e.g.~/.air/node_modules/). Because the air monorepo does lockstep version bumps across all 8 packages, this leaves users with a CLI on the latest version and extensions multiple major versions behind, which produces hard-to-diagnose runtime errors.Reproduction
Starting state — long-stale install, then
air upgrade:air start claudethen fails with:Even though the new global
air-provider-github@0.4.1implementsresolveCatalogDir(), the extension loader usescreateRequire(join(airJsonDir, "__placeholder.js"))whereairJsonDirdefaults to~/.air/, so the v0.0.25 copy in~/.air/node_modules/is what's resolved.Two underlying gaps
air upgradedoesn't update extensions. It needs to additionally rewrite~/.air/package.json(or whichever<airJsonDir>/package.jsonthe user's config resolves from) and reinstall — pinning each extension to a version compatible with the new CLI. Since the monorepo is lockstep, pinning to the CLI version is the simplest correct behavior.air install'sisPackageInstalled()only checks directory existence, never version compatibility. Frompackages/sdk/src/install.ts:So even if the user runs
air installafter the CLI bump, it sees~/.air/node_modules/@pulsemcp/air-provider-githubexists and reportsAlready installed, never bumping. Pinned^0.0.25constraints in the package.json compound this —npm installalone in that dir won't upgrade either.Proposed fix
air upgradeshould:air-cli(today's behavior).air.json'sextensionsarray.<airJsonDir>/package.jsonto a version range compatible with the upgraded CLI (e.g.,^<cli-major>.<cli-minor>.0, since the monorepo bumps in lockstep).npm install --prefix <airJsonDir>(or equivalent), letting npm pull the new versions.air install'sisPackageInstalledshould also be tightened so a user can force a re-check / reinstall when CLI compatibility shifts — at minimum, parsing<prefix>/node_modules/<name>/package.jsonand comparing against the desired constraint.Workaround
For users hitting this now:
npm install --prefix ~/.air \ @pulsemcp/air-adapter-claude@latest \ @pulsemcp/air-provider-github@latest \ @pulsemcp/air-secrets-env@latest \ @pulsemcp/air-secrets-file@latest(Adjust the package list to match your
~/.air/air.jsonextensions array.)Related
This is likely subsumed by the broader
air update/air upgradeunification work — filing separately so the install-location and version-aware install semantics are tracked explicitly regardless of how the command shape lands.