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
157 changes: 157 additions & 0 deletions tests/hooks/post-tool-use-auto-update.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { spawnSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '../..');
const hookScript = join(
repoRoot,
'understand-anything-plugin',
'hooks',
'post-tool-use-auto-update.mjs',
);
const hooksConfig = JSON.parse(
readFileSync(
join(repoRoot, 'understand-anything-plugin', 'hooks', 'hooks.json'),
'utf8',
),
);

function runHook({
command = 'git commit -m "test"',
autoUpdate = true,
configContents,
createConfig = true,
createGraph = true,
dataDirName = '.understand-anything',
input,
pluginRoot,
} = {}) {
const projectRoot = mkdtempSync(join(tmpdir(), 'ua-post-tool-use-'));
const dataDir = join(projectRoot, dataDirName);
mkdirSync(dataDir);
if (createConfig) {
writeFileSync(
join(dataDir, 'config.json'),
configContents ?? JSON.stringify({ autoUpdate }),
);
}
if (createGraph) writeFileSync(join(dataDir, 'knowledge-graph.json'), '{}');

const resolvedPluginRoot = pluginRoot ?? join(projectRoot, 'plugin root');
const result = spawnSync(process.execPath, [hookScript], {
cwd: projectRoot,
input:
input ??
JSON.stringify({
tool_name: 'Bash',
tool_input: { command },
}),
env: { ...process.env, CLAUDE_PLUGIN_ROOT: resolvedPluginRoot },
encoding: 'utf8',
});

rmSync(projectRoot, { recursive: true, force: true });
return { ...result, pluginRoot: resolvedPluginRoot };
}

describe('PostToolUse auto-update hook', () => {
it('is registered as the Bash PostToolUse handler', () => {
const registration = hooksConfig.hooks.PostToolUse[0];

expect(registration.matcher).toBe('Bash');
expect(registration.hooks).toEqual([
{
type: 'command',
command:
'node "${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use-auto-update.mjs"',
},
]);
});

it('injects the auto-update instruction as PostToolUse additional context', () => {
const result = runHook();
const output = JSON.parse(result.stdout);

expect(result.status).toBe(0);
expect(result.stderr).toBe('');
expect(output).toEqual({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: expect.stringContaining(
'[understand-anything] Commit detected',
),
},
});
expect(output.hookSpecificOutput.additionalContext).toContain(
`${result.pluginRoot}/hooks/auto-update-prompt.md`,
);
});

it.each(['commit', 'merge', 'cherry-pick', 'rebase'])(
'recognizes git %s as a graph-changing operation',
(operation) => {
const result = runHook({ command: `git ${operation} example` });

expect(result.status).toBe(0);
expect(
JSON.parse(result.stdout).hookSpecificOutput.hookEventName,
).toBe('PostToolUse');
},
);

it('preserves Windows-style plugin paths as valid JSON', () => {
const pluginRoot = 'C:\\Users\\Example Person\\understand-anything';
const result = runHook({ pluginRoot });
const output = JSON.parse(result.stdout);

expect(output.hookSpecificOutput.additionalContext).toContain(
`${pluginRoot}/hooks/auto-update-prompt.md`,
);
});

it('supports the legacy .ua data directory', () => {
const result = runHook({ dataDirName: '.ua' });

expect(result.status).toBe(0);
expect(JSON.parse(result.stdout).hookSpecificOutput.hookEventName).toBe(
'PostToolUse',
);
});

it('stays silent for unrelated Bash commands', () => {
const result = runHook({ command: 'git status --short' });

expect(result.status).toBe(0);
expect(result.stdout).toBe('');
});

it('stays silent when automatic updates are disabled', () => {
const result = runHook({ autoUpdate: false });

expect(result.status).toBe(0);
expect(result.stdout).toBe('');
});

it.each([
['malformed stdin', { input: '{not-json' }],
['a missing config', { createConfig: false }],
['a malformed config', { configContents: '{not-json' }],
['a missing graph', { createGraph: false }],
])('stays silent for %s', (_scenario, options) => {
const result = runHook(options);

expect(result.status).toBe(0);
expect(result.stderr).toBe('');
expect(result.stdout).toBe('');
});
});
2 changes: 1 addition & 1 deletion understand-anything-plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "UA_DIR=.understand-anything; [ -d \"$UA_DIR\" ] || UA_DIR=.ua; INPUT=$(cat); CMD=$(printf '%s' \"$INPUT\" | node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{}})\" 2>/dev/null); printf '%s' \"$CMD\" | grep -qE 'git\\s+(commit|merge|cherry-pick|rebase)' && [ -f $UA_DIR/config.json ] && grep -q '\"autoUpdate\".*true' $UA_DIR/config.json && [ -f $UA_DIR/knowledge-graph.json ] && echo \"[understand-anything] Commit detected with auto-update enabled. You MUST read the file at ${CLAUDE_PLUGIN_ROOT}/hooks/auto-update-prompt.md and execute its instructions to incrementally update the knowledge graph. Do not ask the user for confirmation — just do it.\" || true"
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use-auto-update.mjs\""
}
]
}
Expand Down
57 changes: 57 additions & 0 deletions understand-anything-plugin/hooks/post-tool-use-auto-update.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { existsSync, readFileSync } from 'node:fs';

const COMMIT_COMMAND = /git\s+(commit|merge|cherry-pick|rebase)/;

function findDataDir() {
return existsSync('.understand-anything') ? '.understand-anything' : '.ua';
}

function autoUpdateEnabled(dataDir) {
try {
const config = JSON.parse(readFileSync(`${dataDir}/config.json`, 'utf8'));
return config.autoUpdate === true;
} catch {
return false;
}
}

async function readStdin() {
let input = '';
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) input += chunk;
return input;
}

async function main() {
let payload;
try {
payload = JSON.parse(await readStdin());
} catch {
return;
}

const command = payload?.tool_input?.command;
if (typeof command !== 'string' || !COMMIT_COMMAND.test(command)) return;

const dataDir = findDataDir();
if (!autoUpdateEnabled(dataDir)) return;
if (!existsSync(`${dataDir}/knowledge-graph.json`)) return;

const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? '';
const additionalContext =
`[understand-anything] Commit detected with auto-update enabled. ` +
`You MUST read the file at ${pluginRoot}/hooks/auto-update-prompt.md ` +
'and execute its instructions to incrementally update the knowledge graph. ' +
'Do not ask the user for confirmation — just do it.';

process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext,
},
}),
);
}

await main();