feat(installer): stop auto-indexing on install + ship opt-in front-load prompt hook

`codegraph install` no longer indexes the current directory — it wires up agents
only, and building a project's graph is always the explicit `codegraph init` /
`index`. Removes the global-vs-local inconsistency (a local install silently
indexed, a global one didn't) and the docs/behavior mismatch (#826). README
updated to match; the stale `init --index` note (indexing is default now) fixed.

Adds an opt-in Claude Code front-load hook: a `UserPromptSubmit` hook that runs
the new hidden `codegraph prompt-hook`, which injects codegraph_explore context
for structural ("how / where / trace / impact") prompts so the agent answers
from the graph instead of grepping to rebuild it. Prompted at install
(default-yes; Claude-only — the only agent with prompt hooks), removed on
uninstall, and `codegraph upgrade` self-heals it onto an already-configured
global Claude install. Strictly additive + degradable: non-structural prompts,
un-indexed projects, and any failure are silent no-ops. Disable without
uninstalling via CODEGRAPH_NO_PROMPT_HOOK=1.

7 new installer-targets contract tests (write / idempotent / opt-out round-trip /
sibling-preserved / uninstall / legacy-independent). Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-06-21 12:36:41 -05:00
co-authored by Claude Opus 4.8
parent 212dfc4b6a
commit bd4814d8c1
8 changed files with 333 additions and 98 deletions
+85 -8
View File
@@ -121,6 +121,18 @@ class ClaudeCodeTarget implements AgentTarget {
const hookCleanup = cleanupLegacyHooks(loc);
if (hookCleanup.action === 'removed') files.push(hookCleanup);
// 2c. Front-load prompt hook (Claude UserPromptSubmit). Opt-in via the
// installer prompt (default-yes): `promptHook === true` writes it;
// `=== false` strips any a prior install wrote so opting out round-trips
// (and an upgrade re-run honors the new choice); `undefined` leaves it
// untouched for callers that don't manage it.
if (opts.promptHook === true) {
files.push(writePromptHookEntry(loc));
} else if (opts.promptHook === false) {
const removed = removePromptHookEntry(loc);
if (removed.action === 'removed') files.push(removed);
}
// 3. CLAUDE.md instructions — the short marker-fenced CodeGraph
// block (#704). The MCP initialize instructions reach only the main
// agent; CLAUDE.md is what Task-tool subagents (and non-MCP
@@ -187,6 +199,10 @@ class ClaudeCodeTarget implements AgentTarget {
const hookCleanup = cleanupLegacyHooks(loc);
if (hookCleanup.action === 'removed') files.push(hookCleanup);
// 2c. Remove the front-load prompt hook this installer may have written.
const promptHookCleanup = removePromptHookEntry(loc);
if (promptHookCleanup.action === 'removed') files.push(promptHookCleanup);
// 3. Instructions — strip the legacy CodeGraph block if present.
files.push(removeInstructionsEntry(loc));
@@ -278,6 +294,16 @@ function isLegacyCodegraphHookCommand(command: unknown): boolean {
);
}
/**
* The front-load prompt-hook command the installer writes into Claude's
* `UserPromptSubmit` (see writePromptHookEntry). Matched by substring so an
* `npx @colbymchenry/codegraph prompt-hook` form is recognized too.
*/
const PROMPT_HOOK_COMMAND = 'codegraph prompt-hook';
function isPromptHookCommand(command: unknown): boolean {
return typeof command === 'string' && command.includes(PROMPT_HOOK_COMMAND);
}
/**
* Remove stale codegraph auto-sync hooks from Claude `settings.json`.
*
@@ -293,7 +319,10 @@ function isLegacyCodegraphHookCommand(command: unknown): boolean {
* Exported so it can be unit-tested directly and reused by both
* `install` (an upgrade self-heals) and `uninstall`.
*/
export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number] {
function removeHookCommandsMatching(
loc: Location,
match: (command: unknown) => boolean,
): WriteResult['files'][number] {
const file = settingsJsonPath(loc);
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
@@ -303,7 +332,7 @@ export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number]
return { path: file, action: 'unchanged' };
}
// Pass 1: drop the legacy command(s) from inside every matcher group.
// Pass 1: drop matching command(s) from inside every matcher group.
let removedAny = false;
for (const event of Object.keys(hooks)) {
const groups = hooks[event];
@@ -311,18 +340,17 @@ export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number]
for (const group of groups) {
if (!group || !Array.isArray(group.hooks)) continue;
const before = group.hooks.length;
group.hooks = group.hooks.filter(
(h: any) => !isLegacyCodegraphHookCommand(h?.command),
);
group.hooks = group.hooks.filter((h: any) => !match(h?.command));
if (group.hooks.length !== before) removedAny = true;
}
}
if (!removedAny) return { path: file, action: 'unchanged' };
// Pass 2: prune empty matcher groups, then events with no groups
// left, then an empty top-level `hooks`. Guarded by `removedAny` so
// we never restructure a settings.json that had no codegraph hooks.
// Pass 2: prune empty matcher groups, then events with no groups left,
// then an empty top-level `hooks`. Guarded by `removedAny` so we never
// restructure a settings.json that had no matching hooks. Sibling hooks
// (a different command in the group, or a different event) survive.
for (const event of Object.keys(hooks)) {
const groups = hooks[event];
if (!Array.isArray(groups)) continue;
@@ -337,6 +365,24 @@ export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number]
return { path: file, action: 'removed' };
}
/**
* Remove stale codegraph auto-sync hooks (`mark-dirty` / `sync-if-dirty`) that a
* pre-0.8 install wrote. Exported for direct unit-testing; reused by both
* `install` (an upgrade self-heals) and `uninstall`.
*/
export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number] {
return removeHookCommandsMatching(loc, isLegacyCodegraphHookCommand);
}
/**
* Remove the front-load `UserPromptSubmit` hook this installer writes (see
* writePromptHookEntry). Used by `uninstall`, and by `install` when the user
* opts out, so the choice round-trips.
*/
export function removePromptHookEntry(loc: Location): WriteResult['files'][number] {
return removeHookCommandsMatching(loc, isPromptHookCommand);
}
export function writePermissionsEntry(loc: Location): WriteResult['files'][number] {
const file = settingsJsonPath(loc);
const settings = readJsonFile(file);
@@ -359,6 +405,37 @@ export function writePermissionsEntry(loc: Location): WriteResult['files'][numbe
return { path: file, action: created ? 'created' : 'updated' };
}
/**
* Write the front-load `UserPromptSubmit` hook into Claude `settings.json` —
* a `command` hook that runs `codegraph prompt-hook`, which injects
* codegraph_explore context for structural prompts so the agent reliably uses
* the graph. Idempotent: if our command is already wired under UserPromptSubmit
* the file is left byte-for-byte untouched and reported `unchanged`. Sibling
* hooks (the user's own, or other events) are preserved. Opt-in — the installer
* only calls this when the user accepts the prompt (default-yes).
*/
export function writePromptHookEntry(loc: Location): WriteResult['files'][number] {
const file = settingsJsonPath(loc);
const created = !fs.existsSync(file);
const settings = readJsonFile(file);
if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
settings.hooks = {};
}
if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = [];
const already = settings.hooks.UserPromptSubmit.some(
(g: any) => g && Array.isArray(g.hooks) && g.hooks.some((h: any) => isPromptHookCommand(h?.command)),
);
if (already) return { path: file, action: 'unchanged' };
settings.hooks.UserPromptSubmit.push({
hooks: [{ type: 'command', command: PROMPT_HOOK_COMMAND }],
});
writeJsonFile(file, settings);
return { path: file, action: created ? 'created' : 'updated' };
}
/**
* Strip the marker-delimited CodeGraph block from CLAUDE.md if a prior
* install wrote one. Codegraph no longer maintains an instructions file
+7
View File
@@ -68,6 +68,13 @@ export interface InstallOptions {
* target has no permissions concept this option is a no-op.
*/
autoAllow: boolean;
/**
* Front-load prompt hook (Claude `UserPromptSubmit`) that injects
* codegraph_explore context for structural prompts. `true` installs it,
* `false` removes any prior install (so opt-out round-trips), `undefined`
* leaves it untouched. Targets without a prompt-hook concept ignore it.
*/
promptHook?: boolean;
}
export interface AgentTarget {