Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixes
- `codegraph upgrade` now also refreshes what previous versions installed into your agents — the CodeGraph section in CLAUDE.md / AGENTS.md / GEMINI.md and the MCP entry — so upgrading no longer leaves agents following instructions written for tools that have since been renamed or removed. Refresh-only: agents you never configured are not touched, and your permission and hook choices are preserved. Also available manually as `codegraph install --refresh`, and skippable with `CODEGRAPH_NO_INSTALL_REFRESH=1`.

## [1.3.1] - 2026-07-09

Expand Down
85 changes: 84 additions & 1 deletion __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
import { uninstallTargets } from '../src/installer';
import { uninstallTargets, refreshTargets } from '../src/installer';
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude';

Expand Down Expand Up @@ -1396,6 +1396,89 @@ describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => {
});
});

describe('Installer — refreshTargets sweep (codegraph install --refresh)', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };

beforeEach(() => {
tmpHome = mkTmpDir('rf-home');
tmpCwd = mkTmpDir('rf-cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});

afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});

it('rewrites a stale instructions block a previous version left, and reports refreshed', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true });

// Simulate the file as an old install left it: same markers, the old
// multi-tool wording.
const claudeMd = path.join(tmpHome, '.claude', 'CLAUDE.md');
fs.writeFileSync(claudeMd, LEGACY_BLOCK + '\n');

const reports = refreshTargets([claude], 'global');
expect(reports[0].status).toBe('refreshed');
expect(reports[0].changedPaths).toContain(claudeMd);

const md = fs.readFileSync(claudeMd, 'utf-8');
expect(md).not.toContain('codegraph_search');
expect(md).toContain('codegraph_explore');
});

it('never performs a first install — unconfigured agents stay untouched', () => {
const reports = refreshTargets(ALL_TARGETS, 'global');
for (const t of ALL_TARGETS) {
const r = reports.find((x) => x.id === t.id)!;
expect(r.status).toBe(t.supportsLocation('global') ? 'not-configured' : 'unsupported');
expect(r.changedPaths).toEqual([]);
expect(t.detect('global').alreadyConfigured).toBe(false);
}
});

it('preserves the user\'s permission choices (refresh never writes permissions)', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true });

// The user has since trimmed the allowlist by hand.
const settingsPath = path.join(tmpHome, '.claude', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
settings.permissions.allow = [];
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');

refreshTargets([claude], 'global');

const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(after.permissions.allow).toEqual([]);
});

it('is idempotent — a second sweep on a current machine reports unchanged everywhere', () => {
for (const t of ALL_TARGETS) {
if (t.supportsLocation('global')) t.install('global', { autoAllow: true });
}
const first = refreshTargets(ALL_TARGETS, 'global');
// Fresh installs are already current, so even the first sweep may be
// all-unchanged; what matters is the second definitely is.
const second = refreshTargets(ALL_TARGETS, 'global');
for (const r of [...first, ...second]) {
expect(['unchanged', 'refreshed']).toContain(r.status);
}
for (const r of second) {
expect(r.status).toBe('unchanged');
expect(r.changedPaths).toEqual([]);
}
});
});

describe('Installer — Cursor rules file cleanup on uninstall', () => {
let tmpHome: string;
let tmpCwd: string;
Expand Down
91 changes: 91 additions & 0 deletions __tests__/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,97 @@ describe('runUpgrade', () => {
});
});

// ---------------------------------------------------------------------------
// Post-upgrade self-heal of installed agent surfaces
// ---------------------------------------------------------------------------

describe('post-upgrade refresh of installed agent surfaces', () => {
it('runs `codegraph install --refresh` via the NEW binary after a successful npm upgrade', async () => {
const { deps, calls } = makeDeps({
method: { kind: 'npm', scope: 'global' },
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
});
const code = await runUpgrade({}, deps);
expect(code).toBe(0);
// The refresh is spawned AFTER the binary swap, so the fresh install
// (with the current templates) does the writing — not this process.
const last = calls.runs[calls.runs.length - 1];
expect(last?.cmd).toBe('codegraph');
expect(last?.args).toEqual(['install', '--refresh']);
});

it('runs the Windows .cmd launcher through cmd.exe', async () => {
const { deps, calls } = makeDeps({
method: { kind: 'npm', scope: 'global' },
currentVersion: '0.9.8',
platform: 'win32',
hasCommand: (cmd) => cmd === 'codegraph',
});
const code = await runUpgrade({}, deps);
expect(code).toBe(0);
const last = calls.runs[calls.runs.length - 1];
expect(last?.cmd).toBe('cmd.exe');
expect(last?.args).toEqual(['/d', '/s', '/c', 'codegraph install --refresh']);
});

it('skips the refresh when `codegraph` is not resolvable on PATH', async () => {
const { deps, calls } = makeDeps({
method: { kind: 'npm', scope: 'global' },
currentVersion: '0.9.8',
// default hasCommand resolves only curl
});
const code = await runUpgrade({}, deps);
expect(code).toBe(0);
expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
});

it('a failing refresh warns but does not fail the upgrade', async () => {
const { deps, calls } = makeDeps({
method: { kind: 'npm', scope: 'global' },
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
});
deps.run = (cmd, args, env) => {
calls.runs.push({ cmd, args, env });
return cmd === 'codegraph' ? 1 : 0;
};
const code = await runUpgrade({}, deps);
expect(code).toBe(0);
expect(calls.logs.join('\n')).toMatch(/install --refresh/);
});

it('does not run after a failed upgrade', async () => {
const { deps, calls } = makeDeps(
{
method: { kind: 'npm', scope: 'global' },
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
},
1
);
const code = await runUpgrade({}, deps);
expect(code).toBe(1);
expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
});

it('respects the CODEGRAPH_NO_INSTALL_REFRESH kill-switch', async () => {
process.env.CODEGRAPH_NO_INSTALL_REFRESH = '1';
try {
const { deps, calls } = makeDeps({
method: { kind: 'npm', scope: 'global' },
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
});
const code = await runUpgrade({}, deps);
expect(code).toBe(0);
expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
} finally {
delete process.env.CODEGRAPH_NO_INSTALL_REFRESH;
}
});
});

// ---------------------------------------------------------------------------
// Re-index staleness — real index, real metadata stamp
// ---------------------------------------------------------------------------
Expand Down
33 changes: 33 additions & 0 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2187,12 +2187,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');
Expand All @@ -2207,6 +2209,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}").`);
Expand Down
60 changes: 60 additions & 0 deletions src/installer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
33 changes: 33 additions & 0 deletions src/upgrade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,43 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi
} catch {
/* a hook-wiring hiccup must not fail the upgrade */
}
try {
selfHealInstalledSurfaces(deps);
} catch {
/* a refresh hiccup must not fail the upgrade */
}
}
return code;
}

/**
* 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.');
}
}

/**
* Wire the Claude `UserPromptSubmit` front-load hook on upgrade for an
* already-configured global Claude install. No-op when Claude isn't configured,
Expand Down