Skip to content

Commit 210f0f1

Browse files
author
catlog22
committed
feat: 添加钩子命令,简化 Claude Code 钩子操作接口,支持会话上下文加载和通知功能
1 parent 6d3f10d commit 210f0f1

File tree

7 files changed

+433
-12
lines changed

7 files changed

+433
-12
lines changed

ccw/scripts/memory_embedder.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
sys.exit(1)
2727

2828
try:
29-
from codexlens.semantic.embedder import get_embedder
29+
from codexlens.semantic.embedder import get_embedder, clear_embedder_cache
3030
except ImportError:
3131
print("Error: CodexLens not found. Install with: pip install codexlens[semantic]", file=sys.stderr)
3232
sys.exit(1)
@@ -46,8 +46,15 @@ def __init__(self, db_path: str):
4646
self.conn = sqlite3.connect(str(self.db_path))
4747
self.conn.row_factory = sqlite3.Row
4848

49-
# Initialize embedder (uses cached singleton)
50-
self.embedder = get_embedder(profile="code")
49+
# Lazy-load embedder to avoid ~0.8s model loading for status command
50+
self._embedder = None
51+
52+
@property
53+
def embedder(self):
54+
"""Lazy-load the embedder on first access."""
55+
if self._embedder is None:
56+
self._embedder = get_embedder(profile="code")
57+
return self._embedder
5158

5259
def close(self):
5360
"""Close database connection."""
@@ -348,9 +355,21 @@ def main():
348355

349356
# Exit with error code if operation failed
350357
if "success" in result and not result["success"]:
358+
# Clean up ONNX resources before exit
359+
clear_embedder_cache()
351360
sys.exit(1)
352361

362+
# Clean up ONNX resources to ensure process can exit cleanly
363+
# This releases fastembed/ONNX Runtime threads that would otherwise
364+
# prevent the Python interpreter from shutting down
365+
clear_embedder_cache()
366+
353367
except Exception as e:
368+
# Clean up ONNX resources even on error
369+
try:
370+
clear_embedder_cache()
371+
except Exception:
372+
pass
354373
print(json.dumps({
355374
"success": False,
356375
"error": str(e)

ccw/src/cli.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { sessionCommand } from './commands/session.js';
1111
import { cliCommand } from './commands/cli.js';
1212
import { memoryCommand } from './commands/memory.js';
1313
import { coreMemoryCommand } from './commands/core-memory.js';
14+
import { hookCommand } from './commands/hook.js';
1415
import { readFileSync, existsSync } from 'fs';
1516
import { fileURLToPath } from 'url';
1617
import { dirname, join } from 'path';
@@ -229,5 +230,15 @@ export function run(argv: string[]): void {
229230
.option('--prefix <prefix>', 'Add prefix to imported memory IDs')
230231
.action((subcommand, args, options) => coreMemoryCommand(subcommand, args, options));
231232

233+
// Hook command - CLI endpoint for Claude Code hooks
234+
program
235+
.command('hook [subcommand] [args...]')
236+
.description('CLI endpoint for Claude Code hooks (session-context, notify)')
237+
.option('--stdin', 'Read input from stdin (for Claude Code hooks)')
238+
.option('--session-id <id>', 'Session ID')
239+
.option('--prompt <text>', 'Prompt text')
240+
.option('--type <type>', 'Context type: session-start, context')
241+
.action((subcommand, args, options) => hookCommand(subcommand, args, options));
242+
232243
program.parse(argv);
233244
}

ccw/src/commands/hook.ts

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
/**
2+
* Hook Command - CLI endpoint for Claude Code hooks
3+
* Provides simplified interface for hook operations, replacing complex bash/curl commands
4+
*/
5+
6+
import chalk from 'chalk';
7+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8+
import { join, dirname } from 'path';
9+
import { tmpdir } from 'os';
10+
11+
interface HookOptions {
12+
stdin?: boolean;
13+
sessionId?: string;
14+
prompt?: string;
15+
type?: 'session-start' | 'context';
16+
}
17+
18+
interface HookData {
19+
session_id?: string;
20+
prompt?: string;
21+
cwd?: string;
22+
tool_input?: Record<string, unknown>;
23+
}
24+
25+
interface SessionState {
26+
firstLoad: string;
27+
loadCount: number;
28+
lastPrompt?: string;
29+
}
30+
31+
/**
32+
* Read JSON data from stdin (for Claude Code hooks)
33+
*/
34+
async function readStdin(): Promise<string> {
35+
return new Promise((resolve) => {
36+
let data = '';
37+
process.stdin.setEncoding('utf8');
38+
process.stdin.on('readable', () => {
39+
let chunk;
40+
while ((chunk = process.stdin.read()) !== null) {
41+
data += chunk;
42+
}
43+
});
44+
process.stdin.on('end', () => {
45+
resolve(data);
46+
});
47+
// Handle case where stdin is empty or not piped
48+
if (process.stdin.isTTY) {
49+
resolve('');
50+
}
51+
});
52+
}
53+
54+
/**
55+
* Get session state file path
56+
*/
57+
function getSessionStateFile(sessionId: string): string {
58+
const stateDir = join(tmpdir(), '.ccw-sessions');
59+
if (!existsSync(stateDir)) {
60+
mkdirSync(stateDir, { recursive: true });
61+
}
62+
return join(stateDir, `session-${sessionId}.json`);
63+
}
64+
65+
/**
66+
* Load session state from file
67+
*/
68+
function loadSessionState(sessionId: string): SessionState | null {
69+
const stateFile = getSessionStateFile(sessionId);
70+
if (!existsSync(stateFile)) {
71+
return null;
72+
}
73+
try {
74+
const content = readFileSync(stateFile, 'utf-8');
75+
return JSON.parse(content) as SessionState;
76+
} catch {
77+
return null;
78+
}
79+
}
80+
81+
/**
82+
* Save session state to file
83+
*/
84+
function saveSessionState(sessionId: string, state: SessionState): void {
85+
const stateFile = getSessionStateFile(sessionId);
86+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
87+
}
88+
89+
/**
90+
* Get project path from hook data or current working directory
91+
*/
92+
function getProjectPath(hookCwd?: string): string {
93+
return hookCwd || process.cwd();
94+
}
95+
96+
/**
97+
* Session context action - provides progressive context loading
98+
* First prompt: returns session overview with clusters
99+
* Subsequent prompts: returns intent-matched sessions
100+
*/
101+
async function sessionContextAction(options: HookOptions): Promise<void> {
102+
let { stdin, sessionId, prompt } = options;
103+
let hookCwd: string | undefined;
104+
105+
// If --stdin flag is set, read from stdin (Claude Code hook format)
106+
if (stdin) {
107+
try {
108+
const stdinData = await readStdin();
109+
if (stdinData) {
110+
const hookData = JSON.parse(stdinData) as HookData;
111+
sessionId = hookData.session_id || sessionId;
112+
hookCwd = hookData.cwd;
113+
prompt = hookData.prompt || prompt;
114+
}
115+
} catch {
116+
// Silently continue if stdin parsing fails
117+
}
118+
}
119+
120+
if (!sessionId) {
121+
if (!stdin) {
122+
console.error(chalk.red('Error: --session-id is required'));
123+
console.error(chalk.gray('Usage: ccw hook session-context --session-id <id>'));
124+
console.error(chalk.gray(' ccw hook session-context --stdin'));
125+
}
126+
process.exit(stdin ? 0 : 1);
127+
}
128+
129+
try {
130+
const projectPath = getProjectPath(hookCwd);
131+
132+
// Load existing session state
133+
const existingState = loadSessionState(sessionId);
134+
const isFirstPrompt = !existingState;
135+
136+
// Update session state
137+
const newState: SessionState = isFirstPrompt
138+
? {
139+
firstLoad: new Date().toISOString(),
140+
loadCount: 1,
141+
lastPrompt: prompt
142+
}
143+
: {
144+
...existingState,
145+
loadCount: existingState.loadCount + 1,
146+
lastPrompt: prompt
147+
};
148+
149+
saveSessionState(sessionId, newState);
150+
151+
// Determine context type and generate content
152+
let contextType: 'session-start' | 'context';
153+
let content = '';
154+
155+
// Dynamic import to avoid circular dependencies
156+
const { SessionClusteringService } = await import('../core/session-clustering-service.js');
157+
const clusteringService = new SessionClusteringService(projectPath);
158+
159+
if (isFirstPrompt) {
160+
// First prompt: return session overview with clusters
161+
contextType = 'session-start';
162+
content = await clusteringService.getProgressiveIndex({
163+
type: 'session-start',
164+
sessionId
165+
});
166+
} else if (prompt && prompt.trim().length > 0) {
167+
// Subsequent prompts with content: return intent-matched sessions
168+
contextType = 'context';
169+
content = await clusteringService.getProgressiveIndex({
170+
type: 'context',
171+
sessionId,
172+
prompt
173+
});
174+
} else {
175+
// Subsequent prompts without content: return minimal context
176+
contextType = 'context';
177+
content = ''; // No context needed for empty prompts
178+
}
179+
180+
if (stdin) {
181+
// For hooks: output content directly to stdout
182+
if (content) {
183+
process.stdout.write(content);
184+
}
185+
process.exit(0);
186+
}
187+
188+
// Interactive mode: show detailed output
189+
console.log(chalk.green('Session Context'));
190+
console.log(chalk.gray('─'.repeat(40)));
191+
console.log(chalk.cyan('Session ID:'), sessionId);
192+
console.log(chalk.cyan('Type:'), contextType);
193+
console.log(chalk.cyan('First Prompt:'), isFirstPrompt ? 'Yes' : 'No');
194+
console.log(chalk.cyan('Load Count:'), newState.loadCount);
195+
console.log(chalk.gray('─'.repeat(40)));
196+
if (content) {
197+
console.log(content);
198+
} else {
199+
console.log(chalk.gray('(No context generated)'));
200+
}
201+
} catch (error) {
202+
if (stdin) {
203+
// Silent failure for hooks
204+
process.exit(0);
205+
}
206+
console.error(chalk.red(`Error: ${(error as Error).message}`));
207+
process.exit(1);
208+
}
209+
}
210+
211+
/**
212+
* Notify dashboard action - send notification to running ccw view server
213+
*/
214+
async function notifyAction(options: HookOptions): Promise<void> {
215+
const { stdin } = options;
216+
let hookData: HookData = {};
217+
218+
if (stdin) {
219+
try {
220+
const stdinData = await readStdin();
221+
if (stdinData) {
222+
hookData = JSON.parse(stdinData) as HookData;
223+
}
224+
} catch {
225+
// Silently continue if stdin parsing fails
226+
}
227+
}
228+
229+
try {
230+
const { notifyRefreshRequired } = await import('../tools/notifier.js');
231+
await notifyRefreshRequired();
232+
233+
if (!stdin) {
234+
console.log(chalk.green('Notification sent to dashboard'));
235+
}
236+
process.exit(0);
237+
} catch (error) {
238+
if (stdin) {
239+
process.exit(0);
240+
}
241+
console.error(chalk.red(`Error: ${(error as Error).message}`));
242+
process.exit(1);
243+
}
244+
}
245+
246+
/**
247+
* Show help for hook command
248+
*/
249+
function showHelp(): void {
250+
console.log(`
251+
${chalk.bold('ccw hook')} - CLI endpoint for Claude Code hooks
252+
253+
${chalk.bold('USAGE')}
254+
ccw hook <subcommand> [options]
255+
256+
${chalk.bold('SUBCOMMANDS')}
257+
session-context Progressive session context loading (replaces curl/bash hook)
258+
notify Send notification to ccw view dashboard
259+
260+
${chalk.bold('OPTIONS')}
261+
--stdin Read input from stdin (for Claude Code hooks)
262+
--session-id Session ID (alternative to stdin)
263+
--prompt Current prompt text (alternative to stdin)
264+
265+
${chalk.bold('EXAMPLES')}
266+
${chalk.gray('# Use in Claude Code hook (settings.json):')}
267+
ccw hook session-context --stdin
268+
269+
${chalk.gray('# Interactive usage:')}
270+
ccw hook session-context --session-id abc123
271+
272+
${chalk.gray('# Notify dashboard:')}
273+
ccw hook notify --stdin
274+
275+
${chalk.bold('HOOK CONFIGURATION')}
276+
${chalk.gray('Add to .claude/settings.json:')}
277+
{
278+
"hooks": {
279+
"UserPromptSubmit": [{
280+
"hooks": [{
281+
"type": "command",
282+
"command": "ccw hook session-context --stdin"
283+
}]
284+
}]
285+
}
286+
}
287+
`);
288+
}
289+
290+
/**
291+
* Main hook command handler
292+
*/
293+
export async function hookCommand(
294+
subcommand: string,
295+
args: string | string[],
296+
options: HookOptions
297+
): Promise<void> {
298+
switch (subcommand) {
299+
case 'session-context':
300+
case 'context':
301+
await sessionContextAction(options);
302+
break;
303+
case 'notify':
304+
await notifyAction(options);
305+
break;
306+
case 'help':
307+
case undefined:
308+
showHelp();
309+
break;
310+
default:
311+
console.error(chalk.red(`Unknown subcommand: ${subcommand}`));
312+
console.error(chalk.gray('Run "ccw hook help" for usage information'));
313+
process.exit(1);
314+
}
315+
}

0 commit comments

Comments
 (0)