diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b714e..106692a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5bb698f..794aa77 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ irm https://raw.githubusercontent.com/ardelperal/codegraph-vba/main/install.ps1 **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 ` to pin one. +`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`. +
npm (legacy) @@ -705,7 +707,7 @@ codegraph-vba impact # 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 diff --git a/__tests__/upgrade-agent-surfaces.test.ts b/__tests__/upgrade-agent-surfaces.test.ts new file mode 100644 index 0000000..3e0d0a3 --- /dev/null +++ b/__tests__/upgrade-agent-surfaces.test.ts @@ -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 = [ + '', + '## CodeGraph', + 'Initialise CodeGraph immediately in every repository.', + '', +].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 { + const out: Record = {}; + 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 = {}): { + 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; + + 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(''); + }); + + 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); + }); + }); +}); diff --git a/__tests__/upgrade.test.ts b/__tests__/upgrade.test.ts index 7942c93..2577c04 100644 --- a/__tests__/upgrade.test.ts +++ b/__tests__/upgrade.test.ts @@ -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. @@ -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'); @@ -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); }); @@ -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/); }); @@ -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 { @@ -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. diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 6b7a49b..0cf1cae 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -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 ', '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; @@ -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) { @@ -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, @@ -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, diff --git a/src/installer/index.ts b/src/installer/index.ts index 41a09b7..20edd47 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -383,6 +383,8 @@ export interface RefreshReport { status: RefreshStatus; /** Absolute paths created, updated, or removed by the refresh. */ changedPaths: string[]; + /** Absolute paths left alone because gentle-ai manages them (issue #332). */ + skippedPaths: string[]; } /** @@ -411,19 +413,21 @@ export function refreshTargets( return targets.map((target) => { const base = { id: target.id, displayName: target.displayName, location }; if (!target.supportsLocation(location)) { - return { ...base, status: 'unsupported' as const, changedPaths: [] }; + return { ...base, status: 'unsupported' as const, changedPaths: [], skippedPaths: [] }; } if (!target.detect(location).alreadyConfigured) { - return { ...base, status: 'not-configured' as const, changedPaths: [] }; + return { ...base, status: 'not-configured' as const, changedPaths: [], skippedPaths: [] }; } 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); + const skippedPaths = result.files.filter((f) => f.action === 'skipped').map((f) => f.path); return { ...base, status: changedPaths.length > 0 ? ('refreshed' as const) : ('unchanged' as const), changedPaths, + skippedPaths, }; }); } diff --git a/src/installer/targets/shared.ts b/src/installer/targets/shared.ts index ba3b07e..a3c7e29 100644 --- a/src/installer/targets/shared.ts +++ b/src/installer/targets/shared.ts @@ -176,6 +176,9 @@ export function replaceOrAppendMarkedSection( return 'appended'; } +/** Marker prefix gentle-ai writes around the CodeGraph guidance it manages. */ +export const GENTLE_AI_MARKER_PREFIX = '