mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-13 02:41:50 +08:00
feat: 添加钩子命令,简化 Claude Code 钩子操作接口,支持会话上下文加载和通知功能
This commit is contained in:
315
ccw/src/commands/hook.ts
Normal file
315
ccw/src/commands/hook.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Hook Command - CLI endpoint for Claude Code hooks
|
||||
* Provides simplified interface for hook operations, replacing complex bash/curl commands
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
interface HookOptions {
|
||||
stdin?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
type?: 'session-start' | 'context';
|
||||
}
|
||||
|
||||
interface HookData {
|
||||
session_id?: string;
|
||||
prompt?: string;
|
||||
cwd?: string;
|
||||
tool_input?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
firstLoad: string;
|
||||
loadCount: number;
|
||||
lastPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read JSON data from stdin (for Claude Code hooks)
|
||||
*/
|
||||
async function readStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = process.stdin.read()) !== null) {
|
||||
data += chunk;
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
resolve(data);
|
||||
});
|
||||
// Handle case where stdin is empty or not piped
|
||||
if (process.stdin.isTTY) {
|
||||
resolve('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session state file path
|
||||
*/
|
||||
function getSessionStateFile(sessionId: string): string {
|
||||
const stateDir = join(tmpdir(), '.ccw-sessions');
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
}
|
||||
return join(stateDir, `session-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session state from file
|
||||
*/
|
||||
function loadSessionState(sessionId: string): SessionState | null {
|
||||
const stateFile = getSessionStateFile(sessionId);
|
||||
if (!existsSync(stateFile)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(stateFile, 'utf-8');
|
||||
return JSON.parse(content) as SessionState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session state to file
|
||||
*/
|
||||
function saveSessionState(sessionId: string, state: SessionState): void {
|
||||
const stateFile = getSessionStateFile(sessionId);
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project path from hook data or current working directory
|
||||
*/
|
||||
function getProjectPath(hookCwd?: string): string {
|
||||
return hookCwd || process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Session context action - provides progressive context loading
|
||||
* First prompt: returns session overview with clusters
|
||||
* Subsequent prompts: returns intent-matched sessions
|
||||
*/
|
||||
async function sessionContextAction(options: HookOptions): Promise<void> {
|
||||
let { stdin, sessionId, prompt } = options;
|
||||
let hookCwd: string | undefined;
|
||||
|
||||
// If --stdin flag is set, read from stdin (Claude Code hook format)
|
||||
if (stdin) {
|
||||
try {
|
||||
const stdinData = await readStdin();
|
||||
if (stdinData) {
|
||||
const hookData = JSON.parse(stdinData) as HookData;
|
||||
sessionId = hookData.session_id || sessionId;
|
||||
hookCwd = hookData.cwd;
|
||||
prompt = hookData.prompt || prompt;
|
||||
}
|
||||
} catch {
|
||||
// Silently continue if stdin parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
if (!stdin) {
|
||||
console.error(chalk.red('Error: --session-id is required'));
|
||||
console.error(chalk.gray('Usage: ccw hook session-context --session-id <id>'));
|
||||
console.error(chalk.gray(' ccw hook session-context --stdin'));
|
||||
}
|
||||
process.exit(stdin ? 0 : 1);
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getProjectPath(hookCwd);
|
||||
|
||||
// Load existing session state
|
||||
const existingState = loadSessionState(sessionId);
|
||||
const isFirstPrompt = !existingState;
|
||||
|
||||
// Update session state
|
||||
const newState: SessionState = isFirstPrompt
|
||||
? {
|
||||
firstLoad: new Date().toISOString(),
|
||||
loadCount: 1,
|
||||
lastPrompt: prompt
|
||||
}
|
||||
: {
|
||||
...existingState,
|
||||
loadCount: existingState.loadCount + 1,
|
||||
lastPrompt: prompt
|
||||
};
|
||||
|
||||
saveSessionState(sessionId, newState);
|
||||
|
||||
// Determine context type and generate content
|
||||
let contextType: 'session-start' | 'context';
|
||||
let content = '';
|
||||
|
||||
// Dynamic import to avoid circular dependencies
|
||||
const { SessionClusteringService } = await import('../core/session-clustering-service.js');
|
||||
const clusteringService = new SessionClusteringService(projectPath);
|
||||
|
||||
if (isFirstPrompt) {
|
||||
// First prompt: return session overview with clusters
|
||||
contextType = 'session-start';
|
||||
content = await clusteringService.getProgressiveIndex({
|
||||
type: 'session-start',
|
||||
sessionId
|
||||
});
|
||||
} else if (prompt && prompt.trim().length > 0) {
|
||||
// Subsequent prompts with content: return intent-matched sessions
|
||||
contextType = 'context';
|
||||
content = await clusteringService.getProgressiveIndex({
|
||||
type: 'context',
|
||||
sessionId,
|
||||
prompt
|
||||
});
|
||||
} else {
|
||||
// Subsequent prompts without content: return minimal context
|
||||
contextType = 'context';
|
||||
content = ''; // No context needed for empty prompts
|
||||
}
|
||||
|
||||
if (stdin) {
|
||||
// For hooks: output content directly to stdout
|
||||
if (content) {
|
||||
process.stdout.write(content);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Interactive mode: show detailed output
|
||||
console.log(chalk.green('Session Context'));
|
||||
console.log(chalk.gray('─'.repeat(40)));
|
||||
console.log(chalk.cyan('Session ID:'), sessionId);
|
||||
console.log(chalk.cyan('Type:'), contextType);
|
||||
console.log(chalk.cyan('First Prompt:'), isFirstPrompt ? 'Yes' : 'No');
|
||||
console.log(chalk.cyan('Load Count:'), newState.loadCount);
|
||||
console.log(chalk.gray('─'.repeat(40)));
|
||||
if (content) {
|
||||
console.log(content);
|
||||
} else {
|
||||
console.log(chalk.gray('(No context generated)'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (stdin) {
|
||||
// Silent failure for hooks
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify dashboard action - send notification to running ccw view server
|
||||
*/
|
||||
async function notifyAction(options: HookOptions): Promise<void> {
|
||||
const { stdin } = options;
|
||||
let hookData: HookData = {};
|
||||
|
||||
if (stdin) {
|
||||
try {
|
||||
const stdinData = await readStdin();
|
||||
if (stdinData) {
|
||||
hookData = JSON.parse(stdinData) as HookData;
|
||||
}
|
||||
} catch {
|
||||
// Silently continue if stdin parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { notifyRefreshRequired } = await import('../tools/notifier.js');
|
||||
await notifyRefreshRequired();
|
||||
|
||||
if (!stdin) {
|
||||
console.log(chalk.green('Notification sent to dashboard'));
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
if (stdin) {
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show help for hook command
|
||||
*/
|
||||
function showHelp(): void {
|
||||
console.log(`
|
||||
${chalk.bold('ccw hook')} - CLI endpoint for Claude Code hooks
|
||||
|
||||
${chalk.bold('USAGE')}
|
||||
ccw hook <subcommand> [options]
|
||||
|
||||
${chalk.bold('SUBCOMMANDS')}
|
||||
session-context Progressive session context loading (replaces curl/bash hook)
|
||||
notify Send notification to ccw view dashboard
|
||||
|
||||
${chalk.bold('OPTIONS')}
|
||||
--stdin Read input from stdin (for Claude Code hooks)
|
||||
--session-id Session ID (alternative to stdin)
|
||||
--prompt Current prompt text (alternative to stdin)
|
||||
|
||||
${chalk.bold('EXAMPLES')}
|
||||
${chalk.gray('# Use in Claude Code hook (settings.json):')}
|
||||
ccw hook session-context --stdin
|
||||
|
||||
${chalk.gray('# Interactive usage:')}
|
||||
ccw hook session-context --session-id abc123
|
||||
|
||||
${chalk.gray('# Notify dashboard:')}
|
||||
ccw hook notify --stdin
|
||||
|
||||
${chalk.bold('HOOK CONFIGURATION')}
|
||||
${chalk.gray('Add to .claude/settings.json:')}
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "ccw hook session-context --stdin"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook command handler
|
||||
*/
|
||||
export async function hookCommand(
|
||||
subcommand: string,
|
||||
args: string | string[],
|
||||
options: HookOptions
|
||||
): Promise<void> {
|
||||
switch (subcommand) {
|
||||
case 'session-context':
|
||||
case 'context':
|
||||
await sessionContextAction(options);
|
||||
break;
|
||||
case 'notify':
|
||||
await notifyAction(options);
|
||||
break;
|
||||
case 'help':
|
||||
case undefined:
|
||||
showHelp();
|
||||
break;
|
||||
default:
|
||||
console.error(chalk.red(`Unknown subcommand: ${subcommand}`));
|
||||
console.error(chalk.gray('Run "ccw hook help" for usage information'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user