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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Breaking Changes

- `codegraph-vba upgrade` no longer rewrites agent instruction files, MCP entries or the Claude prompt hook by default. Add `--refresh-agents` to refresh them. (#332)

### Fixes

- `install` leaves an agent instruction file alone when gentle-ai manages it, so the two CodeGraph guidance blocks no longer contradict each other. (#332)


## [2.1.1] - 2026-09-19

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ irm https://raw.githubusercontent.com/ardelperal/codegraph-vba/main/install.ps1

<sub>**Upgrade any time** with `codegraph-vba upgrade` — it detects how you installed (bundle, npm, or npx) and updates in place. Add `--check` to see if an update is available, or `codegraph-vba upgrade <version>` to pin one.</sub>

<sub>`upgrade` leaves agent instruction files, MCP entries and hooks alone. When gentle-ai manages CodeGraph guidance, the fork does not write a second block into those files. To refresh what a previous install wrote, run `codegraph-vba upgrade --refresh-agents`.</sub>

<details>
<summary><b>npm (legacy)</b></summary>

Expand Down Expand Up @@ -705,7 +707,7 @@ codegraph-vba impact <symbol> # Analyze what code is affected by changin
codegraph-vba affected [files...] # Find test files affected by changes (see below)
codegraph-vba daemon # Manage background daemons — pick one to stop (alias: daemons)
codegraph-vba telemetry [on|off] # Show or change anonymous usage telemetry
codegraph-vba upgrade [version] # Update to the latest release (--check, --force)
codegraph-vba upgrade [version] # Update to the latest release (--check, --force, --refresh-agents)
codegraph-vba version # Print the installed version (also -v, --version)
codegraph-vba stats vba-rules [--json] # List every VBA extractor rule (concern, id, description, pattern, counts)
codegraph-vba help [command] # Show help, optionally for one command
Expand Down
260 changes: 260 additions & 0 deletions __tests__/upgrade-agent-surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/**
* Issue #332: `upgrade` must not rewrite agent surfaces (instruction files,
* MCP entries, hooks) by default, and `install` must leave instruction files
* that gentle-ai manages alone.
*
* Every test runs against a fixture HOME in a temp dir. HOME, USERPROFILE,
* APPDATA and XDG_CONFIG_HOME are overridden, so no real agent config is
* ever read or written.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ALL_TARGETS, getTarget } from '../src/installer/targets/registry';
import { refreshTargets } from '../src/installer';
import { runUpgrade, type UpgradeDeps } from '../src/upgrade';

const GENTLE_AI_BLOCK = [
'<!-- gentle-ai:codegraph-guidance -->',
'## CodeGraph',
'Initialise CodeGraph immediately in every repository.',
'<!-- /gentle-ai:codegraph-guidance -->',
].join('\n');

const ENV_KEYS = [
'HOME',
'USERPROFILE',
'APPDATA',
'XDG_CONFIG_HOME',
'HERMES_HOME',
'CODEGRAPH_NO_INSTALL_REFRESH',
'CODEGRAPH_NO_PROMPT_HOOK',
'CODEGRAPH_PROMPT_HOOK',
] as const;

/** Map of every file under `dir` (relative path → content). */
function snapshot(dir: string): Record<string, string> {
const out: Record<string, string> = {};
const walk = (d: string): void => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isDirectory()) walk(p);
else out[path.relative(dir, p)] = fs.readFileSync(p, 'utf-8');
}
};
walk(dir);
return out;
}

function fakeDeps(over: Partial<UpgradeDeps> = {}): {
deps: UpgradeDeps;
calls: Array<{ cmd: string; args: string[] }>;
logs: string[];
} {
const calls: Array<{ cmd: string; args: string[] }> = [];
const logs: string[] = [];
const deps: UpgradeDeps = {
currentVersion: '1.0.0',
method: { kind: 'npm', scope: 'global' },
resolveLatest: async () => '2.0.0',
run: (cmd, args) => {
calls.push({ cmd, args });
return 0;
},
capture: () => null,
hasCommand: () => true,
log: (m) => logs.push(m),
warn: (m) => logs.push(m),
error: (m) => logs.push(m),
platform: 'linux',
installedPackageVersion: '2.0.0',
orphanCleanupOverride: () => 0,
...over,
};
return { deps, calls, logs };
}

describe('Issue #332 — fixture home with a gentle-ai-managed CLAUDE.md', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-332-home-'));
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-332-cwd-'));
origCwd = process.cwd();
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
for (const k of ENV_KEYS) delete process.env[k];
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
process.env.APPDATA = path.join(tmpHome, '.config');
process.env.XDG_CONFIG_HOME = path.join(tmpHome, '.config');
process.chdir(tmpCwd);

// Claude Code is configured (MCP entry present) and gentle-ai owns its
// CLAUDE.md. Codex is configured too, with a plain AGENTS.md that has no
// gentle-ai marker: it must keep receiving the CodeGraph block.
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2),
);
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, '.claude', 'CLAUDE.md'), `# Mine\n\n${GENTLE_AI_BLOCK}\n`);
fs.mkdirSync(path.join(tmpHome, '.codex'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.codex', 'config.toml'),
'[mcp_servers.codegraph]\ncommand = "codegraph"\nargs = ["serve", "--mcp"]\n',
);
fs.writeFileSync(path.join(tmpHome, '.codex', 'AGENTS.md'), '# Codex notes\n');
});

afterEach(() => {
process.chdir(origCwd);
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});

describe('upgrade without --refresh-agents', () => {
it('leaves every agent file byte-identical and runs no refresh or hook write', async () => {
const before = snapshot(tmpHome);
const { deps, calls } = fakeDeps();

const code = await runUpgrade({}, deps);

expect(code).toBe(0);
expect(snapshot(tmpHome)).toEqual(before);
expect(calls.some((c) => c.args.includes('--refresh'))).toBe(false);
expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(false);
});

it('summary names the agent surfaces it did not touch and the opt-in flag', async () => {
const { deps, logs } = fakeDeps();

await runUpgrade({}, deps);

const summary = logs.join('\n');
expect(summary).toMatch(/--refresh-agents/);
expect(summary).toMatch(/instruction files/);
expect(summary).toMatch(/MCP entries/);
expect(summary).toMatch(/prompt hook/);
});

it('CODEGRAPH_NO_INSTALL_REFRESH and CODEGRAPH_NO_PROMPT_HOOK do not matter when not opted in', async () => {
// Kill-switches only narrow the explicit opt-in; the default path already
// skips every surface, so setting them must not change the result.
process.env.CODEGRAPH_NO_INSTALL_REFRESH = '1';
const before = snapshot(tmpHome);
const { deps, calls } = fakeDeps();

await runUpgrade({}, deps);

expect(calls.some((c) => c.args.includes('--refresh'))).toBe(false);
expect(snapshot(tmpHome)).toEqual(before);
});
});

describe('upgrade with the explicit --refresh-agents opt-in', () => {
it('still refreshes configured agents via `codegraph install --refresh`', async () => {
const { deps, calls } = fakeDeps();

const code = await runUpgrade({ refreshAgents: true }, deps);

expect(code).toBe(0);
expect(calls).toContainEqual({ cmd: 'codegraph', args: ['install', '--refresh'] });
});

it('still wires the Claude prompt hook for a configured Claude install', async () => {
const { deps } = fakeDeps();

await runUpgrade({ refreshAgents: true }, deps);

expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(true);
});

it('CODEGRAPH_NO_INSTALL_REFRESH=1 still disables the refresh under the opt-in', async () => {
process.env.CODEGRAPH_NO_INSTALL_REFRESH = '1';
const { deps, calls } = fakeDeps();

await runUpgrade({ refreshAgents: true }, deps);

expect(calls.some((c) => c.args.includes('--refresh'))).toBe(false);
});

it('CODEGRAPH_NO_PROMPT_HOOK=1 still disables the prompt hook under the opt-in', async () => {
process.env.CODEGRAPH_NO_PROMPT_HOOK = '1';
const { deps } = fakeDeps();

await runUpgrade({ refreshAgents: true }, deps);

expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(false);
});
});

describe('install and install --refresh with a gentle-ai-managed file', () => {
it('refreshTargets adds no CODEGRAPH_START block to the managed file, reports it skipped, and still updates other files', () => {
const reports = refreshTargets(ALL_TARGETS, 'global');

const claudeText = fs.readFileSync(path.join(tmpHome, '.claude', 'CLAUDE.md'), 'utf-8');
expect(claudeText).toBe(`# Mine\n\n${GENTLE_AI_BLOCK}\n`);
expect(claudeText).not.toContain('CODEGRAPH_START');

const claude = reports.find((r) => r.id === 'claude');
expect(claude?.skippedPaths).toContain(path.join(tmpHome, '.claude', 'CLAUDE.md'));

const codexAgents = fs.readFileSync(path.join(tmpHome, '.codex', 'AGENTS.md'), 'utf-8');
expect(codexAgents).toContain('<!-- CODEGRAPH_START -->');
});

it('claude install (not refresh) reports CLAUDE.md as skipped and leaves it unchanged', () => {
const claude = getTarget('claude');
if (!claude) throw new Error('claude target missing');
const before = fs.readFileSync(path.join(tmpHome, '.claude', 'CLAUDE.md'), 'utf-8');

const result = claude.install('global', { autoAllow: false });

expect(result.files).toContainEqual({
path: path.join(tmpHome, '.claude', 'CLAUDE.md'),
action: 'skipped',
});
expect(fs.readFileSync(path.join(tmpHome, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(before);
});

it('upgrade (default and --refresh-agents), install and install --refresh never change skills directories', async () => {
// Skills reach projects through the fleet propagator, never through
// tool installers (issue #332 scope note). Pin mtimes to a past date so
// any rewrite, even with identical bytes, shows up as a change.
const past = new Date('2020-01-01T00:00:00Z');
const sentinels = [
path.join(tmpHome, '.claude', 'skills', 'x', 'SKILL.md'),
path.join(tmpHome, '.agents', 'skills', 'x', 'SKILL.md'),
path.join(tmpHome, '.codex', 'skills', 'x', 'SKILL.md'),
path.join(tmpHome, '.config', 'opencode', 'skills', 'x', 'SKILL.md'),
];
for (const f of sentinels) {
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, `# sentinel ${path.relative(tmpHome, f)}\n`);
fs.utimesSync(f, past, past);
}
const state = () =>
sentinels.map((f) => ({ f, bytes: fs.readFileSync(f, 'utf-8'), mtimeMs: fs.statSync(f).mtimeMs }));
const before = state();

await runUpgrade({}, fakeDeps().deps);
await runUpgrade({ refreshAgents: true }, fakeDeps().deps);
for (const target of ALL_TARGETS) {
target.install('global', { autoAllow: true, promptHook: true });
}
refreshTargets(ALL_TARGETS, 'global');

expect(state()).toEqual(before);
for (const f of sentinels) expect(fs.existsSync(f)).toBe(true);
});
});
});
12 changes: 6 additions & 6 deletions __tests__/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
});
const code = await runUpgrade({}, deps);
const code = await runUpgrade({ refreshAgents: true }, 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.
Expand All @@ -513,7 +513,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
platform: 'win32',
hasCommand: (cmd) => cmd === 'codegraph',
});
const code = await runUpgrade({}, deps);
const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
const last = calls.runs[calls.runs.length - 1];
expect(last?.cmd).toBe('cmd.exe');
Expand All @@ -526,7 +526,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
currentVersion: '0.9.8',
// default hasCommand resolves only curl
});
const code = await runUpgrade({}, deps);
const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
});
Expand All @@ -541,7 +541,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
calls.runs.push({ cmd, args, env });
return cmd === 'codegraph' ? 1 : 0;
};
const code = await runUpgrade({}, deps);
const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
expect(calls.logs.join('\n')).toMatch(/install --refresh/);
});
Expand All @@ -568,7 +568,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
});
const code = await runUpgrade({}, deps);
const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
} finally {
Expand All @@ -583,7 +583,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
hasCommand: (cmd) => cmd === 'codegraph',
capture: () => ({ code: 0, stdout: '0.9.8\n' }), // PATH still serves the OLD version
});
const code = await runUpgrade({}, deps);
const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
// Spawning `codegraph install --refresh` would execute the shadowed stale
// binary — the exact staleness the refresh exists to heal.
Expand Down
10 changes: 7 additions & 3 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2213,7 +2213,7 @@ 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`')
.option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run by `codegraph upgrade --refresh-agents`')
.action(async (opts: {
target?: string;
location?: string;
Expand Down Expand Up @@ -2258,6 +2258,9 @@ program
changed += 1;
console.log(` ${report.displayName}: refreshed ${p}`);
}
for (const p of report.skippedPaths) {
console.log(` ${report.displayName}: skipped ${p} (managed by gentle-ai)`);
}
}
}
if (changed === 0) {
Expand Down Expand Up @@ -2393,7 +2396,8 @@ program
.description('Update CodeGraph to the latest release (or a specific version)')
.option('--check', 'Check whether an update is available without installing')
.option('-f, --force', 'Reinstall even if already on the target version')
.action(async (versionArg: string | undefined, options: { check?: boolean; force?: boolean }) => {
.option('--refresh-agents', 'Also refresh agent instruction files, MCP entries and the Claude prompt hook (off by default; gentle-ai usually manages them)')
.action(async (versionArg: string | undefined, options: { check?: boolean; force?: boolean; refreshAgents?: boolean }) => {
const up = await import('../upgrade');
const method = up.detectInstallMethod({
filename: __filename,
Expand All @@ -2402,7 +2406,7 @@ program
});
const pin = versionArg || process.env.CODEGRAPH_VERSION || undefined;
const code = await up.runUpgrade(
{ version: pin, check: options.check, force: options.force },
{ version: pin, check: options.check, force: options.force, refreshAgents: options.refreshAgents },
{
currentVersion: packageJson.version,
method,
Expand Down
Loading
Loading