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:
co-authored by
Claude Opus 4.8
parent
212dfc4b6a
commit
bd4814d8c1
+39
-82
@@ -22,14 +22,13 @@ import {
|
||||
resolveTargetFlag,
|
||||
} from './targets/registry';
|
||||
import type { AgentTarget, Location, TargetId } from './targets/types';
|
||||
import { getGlyphs } from '../ui/glyphs';
|
||||
// Import the lightweight submodules directly (not the ../sync barrel, which
|
||||
// re-exports FileWatcher and would transitively pull in ../extraction — the
|
||||
// installer must stay importable even when native modules can't load).
|
||||
import { watchDisabledReason } from '../sync/watch-policy';
|
||||
import { isGitRepo, isSyncHookInstalled, installGitSyncHook } from '../sync/git-hooks';
|
||||
import { getCodeGraphDir, codeGraphDirName, unsafeIndexRootReason } from '../directory';
|
||||
import { getTelemetry, recordIndexEvent, TELEMETRY_DOCS } from '../telemetry';
|
||||
import { getCodeGraphDir, codeGraphDirName } from '../directory';
|
||||
import { getTelemetry, TELEMETRY_DOCS } from '../telemetry';
|
||||
|
||||
// Backwards-compat: keep these named exports — downstream code may
|
||||
// import them. The shim in `config-writer.ts` continues to re-export
|
||||
@@ -48,9 +47,6 @@ export type { InstallLocation } from './config-writer';
|
||||
const importESM = new Function('specifier', 'return import(specifier)') as
|
||||
(specifier: string) => Promise<typeof import('@clack/prompts')>;
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function getVersion(): string {
|
||||
try {
|
||||
@@ -205,6 +201,31 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4¾: front-load prompt hook (Claude Code only). A UserPromptSubmit hook
|
||||
// that runs `codegraph prompt-hook` — it injects codegraph_explore context on
|
||||
// structural ("how / where / trace / impact") prompts so the agent reliably
|
||||
// reaches for the graph instead of grepping. Opt-in, default-yes. Only Claude
|
||||
// Code has UserPromptSubmit, so it's offered only when Claude is a target;
|
||||
// other targets ignore the option. `undefined` (no Claude / not asked) leaves
|
||||
// any existing hook untouched.
|
||||
let promptHook: boolean | undefined;
|
||||
if (targets.some((t) => t.id === 'claude')) {
|
||||
if (useDefaults) {
|
||||
promptHook = true; // --yes → on
|
||||
} else {
|
||||
const ans = await clack.confirm({
|
||||
message:
|
||||
'Front-load CodeGraph on “how / where / trace” prompts? Auto-injects structural context so answers need fewer steps (adds a moment to those prompts; Claude Code only).',
|
||||
initialValue: true,
|
||||
});
|
||||
if (clack.isCancel(ans)) {
|
||||
clack.cancel('Installation cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
promptHook = ans; // false → opt out; install() strips any prior hook
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: per-target install loop.
|
||||
const installedIds: TargetId[] = [];
|
||||
let sawCreated = false;
|
||||
@@ -216,7 +237,7 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const result = target.install(location, { autoAllow });
|
||||
const result = target.install(location, { autoAllow, promptHook });
|
||||
installedIds.push(target.id);
|
||||
for (const file of result.files) {
|
||||
if (file.action === 'created') sawCreated = true;
|
||||
@@ -243,14 +264,17 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: for local install, initialize the project.
|
||||
if (location === 'local') {
|
||||
await initializeLocalProject(clack, useDefaults);
|
||||
}
|
||||
|
||||
if (location === 'global') {
|
||||
clack.note('cd your-project\ncodegraph init -i', 'Quick start');
|
||||
}
|
||||
// Step 6: install wires up agents only — it deliberately does NOT index.
|
||||
// Building the per-project graph is the user's explicit `codegraph init`
|
||||
// (or `index`), so they choose what gets indexed and when, and we never
|
||||
// index a surprise directory (e.g. a shell sitting in $HOME). Same next step
|
||||
// regardless of global/local scope.
|
||||
clack.note(
|
||||
location === 'local'
|
||||
? 'codegraph init # build this project’s graph (one time; auto-syncs after)'
|
||||
: 'cd <your-project>\ncodegraph init # build a project’s graph (one time; auto-syncs after)',
|
||||
'Next: index a project',
|
||||
);
|
||||
|
||||
// Deliver buffered telemetry while we're already in a long interactive
|
||||
// command — bounded (~1.5s worst case), invisible after a multi-second install.
|
||||
@@ -490,73 +514,6 @@ async function resolveTargets(
|
||||
.filter((t): t is AgentTarget => t !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize CodeGraph in the current project (for local installs), then
|
||||
* offer the watch fallback when the live watcher won't run here (see
|
||||
* offerWatchFallback). Agent-agnostic by nature.
|
||||
*/
|
||||
async function initializeLocalProject(
|
||||
clack: typeof import('@clack/prompts'),
|
||||
useDefaults = false,
|
||||
): Promise<void> {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
// Never auto-index the home directory or a filesystem root. Running the
|
||||
// installer from `$HOME` would otherwise index the entire home tree — a
|
||||
// multi-GB index, constant watcher churn, and (pre-1.0 on macOS) fd
|
||||
// exhaustion that crashed the machine (#845). The install itself still
|
||||
// completes; we just skip the auto-index and point them at a real project.
|
||||
const unsafe = unsafeIndexRootReason(projectPath);
|
||||
if (unsafe) {
|
||||
clack.log.warn(`Skipping automatic indexing — ${projectPath} looks like ${unsafe}.`);
|
||||
clack.log.info('Indexing it would pull in caches, other projects, and your whole tree. Run "codegraph init" inside a specific project instead.');
|
||||
return;
|
||||
}
|
||||
|
||||
let CodeGraph: typeof import('../index').default;
|
||||
try {
|
||||
CodeGraph = (await import('../index')).default;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
clack.log.error(`Could not load native modules: ${msg}`);
|
||||
clack.log.info('Skipping project initialization. Run "codegraph init -i" later.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already initialized
|
||||
if (CodeGraph.isInitialized(projectPath)) {
|
||||
clack.log.info('CodeGraph already initialized in this project');
|
||||
await offerWatchFallback(clack, projectPath, { yes: useDefaults });
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
const cg = await CodeGraph.init(projectPath);
|
||||
clack.log.success('Created .codegraph/ directory');
|
||||
|
||||
// Index the project with shimmer progress (worker thread for smooth animation)
|
||||
const { createShimmerProgress } = await import('../ui/shimmer-progress');
|
||||
process.stdout.write(`\x1b[2m${getGlyphs().rail}\x1b[0m\n`);
|
||||
const progress = createShimmerProgress();
|
||||
|
||||
const result = await cg.indexAll({
|
||||
onProgress: progress.onProgress,
|
||||
});
|
||||
|
||||
await progress.stop();
|
||||
|
||||
if (result.filesErrored > 0) {
|
||||
clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} failed, ${formatNumber(result.nodesCreated)} symbols)`);
|
||||
} else {
|
||||
clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.nodesCreated)} symbols)`);
|
||||
}
|
||||
|
||||
recordIndexEvent(cg, result); // buffered; the installer flushes at the end
|
||||
|
||||
cg.close();
|
||||
|
||||
await offerWatchFallback(clack, projectPath, { yes: useDefaults });
|
||||
}
|
||||
|
||||
/**
|
||||
* When the live file watcher will be disabled for this project (e.g. WSL2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user