fix(upgrade): refresh installer-written agent surfaces after a binary upgrade (#1238) (#1239)

* fix(upgrade): refresh installer-written agent surfaces after a binary upgrade

codegraph upgrade swapped the binary but never revisited what earlier
installs wrote into CLAUDE.md / AGENTS.md / GEMINI.md and the agent
configs, so sections written by a pre-1.0 installer kept teaching agents
a multi-tool surface (including tools that no longer exist) months of
releases later. The install path already self-heals everything it owns,
but nothing ever called it on upgrade.

- codegraph install --refresh: non-interactive sweep that re-runs
  install() for already-configured targets only — never a first
  install; permissions and prompt-hook choices are preserved.
- codegraph upgrade spawns it via the freshly-installed binary after a
  successful swap (the still-running old process would only rewrite its
  own stale template). Gated on PATH resolution and the
  CODEGRAPH_NO_INSTALL_REFRESH=1 kill-switch; never fatal to the
  upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(installer): clarify refresh change reporting

---------

Co-authored-by: xuing <np2v9bvbbs@privaterelay.appleid.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
This commit is contained in:
XU Pengfei
2026-07-10 10:58:55 -05:00
committed by GitHub
co-authored by Claude Fable 5 xuing Colby McHenry
parent 63eb488ed4
commit 386bff0f84
7 changed files with 334 additions and 5 deletions
+33
View File
@@ -2193,12 +2193,14 @@ program
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
.option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)')
.option('--print-config <id>', 'Print MCP config snippet for the named agent and exit (no file writes)')
.option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run automatically by `codegraph upgrade`')
.action(async (opts: {
target?: string;
location?: string;
yes?: boolean;
permissions?: boolean;
printConfig?: string;
refresh?: boolean;
}) => {
if (opts.printConfig) {
const { getTarget, listTargetIds } = await import('../installer/targets/registry');
@@ -2213,6 +2215,37 @@ program
return;
}
// --refresh: non-interactive sweep that re-writes what previous
// installs configured (instructions section, MCP entry, legacy-hook
// cleanups) for already-configured agents, so those surfaces match
// THIS binary's templates. Skips everything else — never a first
// install, never touches permissions or the prompt hook. Sweeps both
// locations unless --location narrows it.
if (opts.refresh) {
const { refreshTargets } = await import('../installer');
const { ALL_TARGETS } = await import('../installer/targets/registry');
if (opts.location && opts.location !== 'global' && opts.location !== 'local') {
error(`--location must be "global" or "local" (got "${opts.location}").`);
process.exit(1);
}
const locs: Array<'global' | 'local'> = opts.location
? [opts.location as 'global' | 'local']
: ['global', 'local'];
let changed = 0;
for (const loc of locs) {
for (const report of refreshTargets(ALL_TARGETS, loc)) {
for (const p of report.changedPaths) {
changed += 1;
console.log(` ${report.displayName}: refreshed ${p}`);
}
}
}
if (changed === 0) {
console.log('All configured agent surfaces are already current.');
}
return;
}
const { runInstallerWithOptions } = await import('../installer');
if (opts.location && opts.location !== 'global' && opts.location !== 'local') {
error(`--location must be "global" or "local" (got "${opts.location}").`);
+60
View File
@@ -359,6 +359,66 @@ export function uninstallTargets(
});
}
export type RefreshStatus = 'refreshed' | 'unchanged' | 'not-configured' | 'unsupported';
/**
* Per-target outcome of a refresh sweep. `refreshed` means at least one
* filesystem entry was created, updated, or removed; `unchanged` means the target was
* already current (every write reported byte-identical); the other two
* mirror `UninstallStatus`.
*/
export interface RefreshReport {
id: TargetId;
displayName: string;
location: Location;
status: RefreshStatus;
/** Absolute paths created, updated, or removed by the refresh. */
changedPaths: string[];
}
/**
* Pure refresh sweep — re-runs `install()` for every target that is
* ALREADY configured at `location`, so the surfaces a previous version
* wrote (the marker-fenced instructions section, the MCP server entry,
* the legacy-hook cleanups) match the binary that will serve them.
* Without this, those files keep the wording — and the tool names — of
* whatever version first wrote them, no matter how many upgrades later.
*
* Strictly a refresh, never a first install:
* - targets that aren't `alreadyConfigured` are skipped untouched;
* - permissions are not written (`autoAllow: false`) and the prompt
* hook is left as-is (`promptHook: undefined`), so choices the user
* made at install time — or by hand since — are preserved.
*
* Every write underneath is the targets' own idempotent upsert, so a
* re-run on an already-current machine reports `unchanged` everywhere.
* Exposed (and unit-tested) separately from the CLI wiring, same as
* `uninstallTargets`.
*/
export function refreshTargets(
targets: readonly AgentTarget[],
location: Location,
): RefreshReport[] {
return targets.map((target) => {
const base = { id: target.id, displayName: target.displayName, location };
if (!target.supportsLocation(location)) {
return { ...base, status: 'unsupported' as const, changedPaths: [] };
}
if (!target.detect(location).alreadyConfigured) {
return { ...base, status: 'not-configured' as const, changedPaths: [] };
}
const result = target.install(location, { autoAllow: false, promptHook: undefined });
const changedPaths = result.files
.filter((f) => f.action === 'created' || f.action === 'updated' || f.action === 'removed')
.map((f) => f.path);
return {
...base,
status: changedPaths.length > 0 ? ('refreshed' as const) : ('unchanged' as const),
changedPaths,
};
});
}
/**
* Interactive uninstaller — the inverse of `runInstallerWithOptions`.
* Asks global-vs-local first (unless `--location`/`--yes` is given),
+50 -4
View File
@@ -389,8 +389,9 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi
// an existing Claude config, and skipped entirely by the kill-switch. Never
// fatal to the upgrade.
if (code === 0) {
let probe: VersionProbe = 'inconclusive';
try {
reportResolvedVersion(latest, deps);
probe = reportResolvedVersion(latest, deps);
} catch {
/* an inconclusive probe must not fail the upgrade */
}
@@ -399,6 +400,19 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi
} catch {
/* a hook-wiring hiccup must not fail the upgrade */
}
// The refresh executes whatever `codegraph` PATH resolves. If the probe
// just proved that's a stale shadowed install, spawning it would rewrite
// the agent surfaces with the very templates the refresh exists to heal —
// skip, and point at the manual command for after the PATH is fixed.
if (probe !== 'mismatch') {
try {
selfHealInstalledSurfaces(deps);
} catch {
/* a refresh hiccup must not fail the upgrade */
}
} else {
deps.log(c.dim('Skipped refreshing agent instructions/config — run `codegraph install --refresh` once the PATH is fixed.'));
}
}
return code;
}
@@ -435,13 +449,16 @@ export function verifyResolvedVersion(latest: string, deps: UpgradeDeps): Versio
* instead of discovering it via a mysteriously unchanged `codegraph -v`.
* Inconclusive probes fall back to the old soft hint — never a scare on
* setups we can't inspect (no `codegraph` on PATH yet, exotic wrappers).
* Returns the probe result so the caller can gate the post-upgrade refresh
* (which spawns the PATH-resolved binary) on it.
*/
function reportResolvedVersion(latest: string, deps: UpgradeDeps): void {
function reportResolvedVersion(latest: string, deps: UpgradeDeps): VersionProbe {
const { method } = deps;
// A project-local npm install isn't served by PATH's `codegraph` (that
// would be some other install) — a probe could only false-alarm.
if (method.kind === 'npm' && method.scope === 'local') return;
switch (verifyResolvedVersion(latest, deps)) {
if (method.kind === 'npm' && method.scope === 'local') return 'inconclusive';
const probe = verifyResolvedVersion(latest, deps);
switch (probe) {
case 'match':
deps.log(c.green(`\`codegraph\` on your PATH now reports ${latest} — this terminal is already using it.`));
break;
@@ -454,6 +471,35 @@ function reportResolvedVersion(latest: string, deps: UpgradeDeps): void {
deps.log(c.dim('Open a new terminal if `codegraph --version` looks unchanged (PATH cache).'));
break;
}
return probe;
}
/**
* Refresh the agent surfaces previous installs wrote — the marker-fenced
* instructions sections (CLAUDE.md / AGENTS.md / GEMINI.md), MCP entries,
* legacy-hook cleanups — so they match the version that will serve them.
* Unlike the prompt hook above, this content is NOT version-agnostic: the
* templates are baked into the binary, so the still-running old process
* would only rewrite its own stale copy — the exact staleness this heals.
* We therefore spawn the freshly-installed binary (`codegraph install
* --refresh`), which is refresh-only: agents never configured stay
* untouched, and permission / prompt-hook choices are preserved. Gated on
* `codegraph` being resolvable on PATH (an npm-local install isn't) and on
* the kill-switch; never fatal to the upgrade.
*/
function selfHealInstalledSurfaces(deps: UpgradeDeps): void {
if (process.env.CODEGRAPH_NO_INSTALL_REFRESH === '1') return;
if (!deps.hasCommand('codegraph')) return;
deps.log(c.dim('Refreshing agent instruction sections and config written by previous versions…'));
// Windows installs expose codegraph through a .cmd launcher. Node cannot
// spawn .cmd files directly without a shell, so route the constant command
// through cmd.exe there (the same launcher a terminal would resolve).
const code = deps.platform === 'win32'
? deps.run('cmd.exe', ['/d', '/s', '/c', 'codegraph install --refresh'])
: deps.run('codegraph', ['install', '--refresh']);
if (code !== 0) {
deps.warn('Could not refresh the installed agent surfaces — run `codegraph install --refresh` manually.');
}
}
/**