diff --git a/.gitignore b/.gitignore index a82dbde..f1543ac 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ scratch/ AGENTS.md GEMINI.md .cch/ + +# Debug / scratch artifacts (never commit) +patch.diff +fix-await.ps1 +fix_tests.py diff --git a/package.json b/package.json index 1fadf5d..acb5083 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-habits", - "version": "0.9.3", + "version": "0.9.4", "description": "The tool-agnostic memory layer for AI coding agents. Learns your coding habits from real edits and carries them across Claude Code, Cursor, Codex, Gemini, Cline, and any Git workflow.", "license": "MIT", "author": "Shreyan Basu Ray", diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 7a72216..03a7d32 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import readline from 'readline'; import { storagePaths, readHabitsMd, parseHabits, writeHabitsMd, serialiseHabits, writeSnapshot, appendHistory, ensureDirs, getPaths, type StorageContext @@ -33,10 +34,30 @@ export interface SessionFile { tool: 'claude-code' | 'codex' | 'gemini' | 'kimi'; } +async function existsAsync(p: string): Promise { + try { + await fs.promises.access(p); + return true; + } catch { + return false; + } +} + +async function isDirectoryAsync(p: string): Promise { + try { + const stat = await fs.promises.stat(p); + return stat.isDirectory(); + } catch { + return false; + } +} + // Claude Code stores project sessions at ~/.claude/projects// // where is the absolute project directory with / replaced by -. function encodeProjectPath(absPath: string): string { - return absPath.replace(/\//g, '-'); + let normalized = absPath.replace(/\\/g, '/'); + normalized = normalized.replace(/^([a-zA-Z]):/, '$1'); + return normalized.replace(/\//g, '-'); } function bootstrappedPath(ctx?: StorageContext): string { @@ -63,64 +84,81 @@ function writeBootstrapped(ids: string[], ctx?: StorageContext): void { }); } -function readFirstLine(filePath: string): string { - const chunkSize = 65536; // 64KB - const buffer = Buffer.alloc(chunkSize); - let fd: number | null = null; - try { - fd = fs.openSync(filePath, 'r'); - const bytesRead = fs.readSync(fd, buffer, 0, chunkSize, 0); - const content = buffer.toString('utf-8', 0, bytesRead); - const index = content.indexOf('\n'); - if (index !== -1) { - return content.substring(0, index); - } - return content; - } catch { - return ''; - } finally { - if (fd !== null) { - try { - fs.closeSync(fd); - } catch { } - } - } +async function readFirstLine(filePath: string): Promise { + return new Promise((resolve) => { + let resolved = false; + const stream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + rl.on('line', (line) => { + if (!resolved) { + resolved = true; + resolve(line); + rl.close(); + stream.destroy(); + } + }); + rl.on('error', () => { + if (!resolved) { + resolved = true; + resolve(''); + rl.close(); + stream.destroy(); + } + }); + rl.on('close', () => { + if (!resolved) { + resolved = true; + resolve(''); + } + }); + }); } -function findJsonlFiles(dir: string): string[] { +async function findJsonlFiles(dir: string): Promise { const results: string[] = []; - if (!fs.existsSync(dir)) return results; + if (!(await existsAsync(dir))) return results; try { - const list = fs.readdirSync(dir); - for (const file of list) { - const filePath = path.join(dir, file); - const stat = fs.statSync(filePath); - if (stat && stat.isDirectory()) { - results.push(...findJsonlFiles(filePath)); - } else if (file.endsWith('.jsonl')) { - results.push(filePath); - } - } + const list = await fs.promises.readdir(dir); + await Promise.all( + list.map(async (file) => { + const filePath = path.join(dir, file); + try { + if (await isDirectoryAsync(filePath)) { + const subResults = await findJsonlFiles(filePath); + results.push(...subResults); + } else if (file.endsWith('.jsonl')) { + results.push(filePath); + } + } catch { /* ignore */ } + }) + ); } catch { // ignore } return results; } -function findWireJsonlFiles(dir: string): string[] { +async function findWireJsonlFiles(dir: string): Promise { const results: string[] = []; - if (!fs.existsSync(dir)) return results; + if (!(await existsAsync(dir))) return results; try { - const list = fs.readdirSync(dir); - for (const file of list) { - const filePath = path.join(dir, file); - const stat = fs.statSync(filePath); - if (stat && stat.isDirectory()) { - results.push(...findWireJsonlFiles(filePath)); - } else if (file === 'wire.jsonl') { - results.push(filePath); - } - } + const list = await fs.promises.readdir(dir); + await Promise.all( + list.map(async (file) => { + const filePath = path.join(dir, file); + try { + if (await isDirectoryAsync(filePath)) { + const subResults = await findWireJsonlFiles(filePath); + results.push(...subResults); + } else if (file === 'wire.jsonl') { + results.push(filePath); + } + } catch { /* ignore */ } + }) + ); } catch { // ignore } @@ -129,15 +167,19 @@ function findWireJsonlFiles(dir: string): string[] { function isProjectSubpath(targetCwd: string, projectDir: string): boolean { try { - const resolvedCwd = path.resolve(targetCwd); - const resolvedProj = path.resolve(projectDir); + let resolvedCwd = path.resolve(targetCwd); + let resolvedProj = path.resolve(projectDir); + if (process.platform === 'win32') { + resolvedCwd = resolvedCwd.toLowerCase(); + resolvedProj = resolvedProj.toLowerCase(); + } return resolvedCwd === resolvedProj || resolvedCwd.startsWith(resolvedProj + path.sep); } catch { return false; } } -export function discoverSessions(projectDir?: string): SessionFile[] { +export async function discoverSessions(projectDir?: string): Promise { const cwd = projectDir ?? process.cwd(); const resolvedProj = path.resolve(cwd); const sessions: SessionFile[] = []; @@ -145,9 +187,9 @@ export function discoverSessions(projectDir?: string): SessionFile[] { // 1. Claude Code const encoded = encodeProjectPath(resolvedProj); const claudeDir = path.join(CLAUDE_PROJECTS_DIR, encoded); - if (fs.existsSync(claudeDir) && fs.statSync(claudeDir).isDirectory()) { + if (await existsAsync(claudeDir) && await isDirectoryAsync(claudeDir)) { try { - const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.jsonl')); + const files = (await fs.promises.readdir(claudeDir)).filter(f => f.endsWith('.jsonl')); for (const f of files) { sessions.push({ sessionId: f.replace('.jsonl', ''), @@ -160,55 +202,59 @@ export function discoverSessions(projectDir?: string): SessionFile[] { // 2. Gemini CLI const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); - if (fs.existsSync(geminiTmpDir) && fs.statSync(geminiTmpDir).isDirectory()) { + if (await existsAsync(geminiTmpDir) && await isDirectoryAsync(geminiTmpDir)) { try { - const subdirs = fs.readdirSync(geminiTmpDir); - for (const dir of subdirs) { - const subpath = path.join(geminiTmpDir, dir); - if (!fs.statSync(subpath).isDirectory()) continue; - const projectRootFile = path.join(subpath, '.project_root'); - if (!fs.existsSync(projectRootFile)) continue; - const content = fs.readFileSync(projectRootFile, 'utf-8').trim(); - if (content && isProjectSubpath(content, resolvedProj)) { - const chatsDir = path.join(subpath, 'chats'); - if (fs.existsSync(chatsDir) && fs.statSync(chatsDir).isDirectory()) { - const files = fs.readdirSync(chatsDir).filter(f => f.endsWith('.jsonl')); - for (const f of files) { - sessions.push({ - sessionId: f.replace('.jsonl', ''), - filePath: path.join(chatsDir, f), - tool: 'gemini', - }); + const subdirs = await fs.promises.readdir(geminiTmpDir); + await Promise.all( + subdirs.map(async (dir) => { + const subpath = path.join(geminiTmpDir, dir); + if (!(await isDirectoryAsync(subpath))) return; + const projectRootFile = path.join(subpath, '.project_root'); + if (!(await existsAsync(projectRootFile))) return; + const content = (await fs.promises.readFile(projectRootFile, 'utf-8')).trim(); + if (content && isProjectSubpath(content, resolvedProj)) { + const chatsDir = path.join(subpath, 'chats'); + if (await existsAsync(chatsDir) && await isDirectoryAsync(chatsDir)) { + const files = (await fs.promises.readdir(chatsDir)).filter(f => f.endsWith('.jsonl')); + for (const f of files) { + sessions.push({ + sessionId: f.replace('.jsonl', ''), + filePath: path.join(chatsDir, f), + tool: 'gemini', + }); + } } } - } - } + }) + ); } catch { /* ignore */ } } // 3. Codex CLI const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - if (fs.existsSync(codexSessionsDir) && fs.statSync(codexSessionsDir).isDirectory()) { + if (await existsAsync(codexSessionsDir) && await isDirectoryAsync(codexSessionsDir)) { try { - const jsonlFiles = findJsonlFiles(codexSessionsDir); - for (const filePath of jsonlFiles) { - const firstLine = readFirstLine(filePath); - if (!firstLine) continue; - try { - const obj = JSON.parse(firstLine); - if (obj.type === 'session_meta' && obj.payload) { - const sessionCwd = obj.payload.cwd; - if (sessionCwd && isProjectSubpath(sessionCwd, resolvedProj)) { - const baseName = path.basename(filePath); - sessions.push({ - sessionId: baseName.replace('.jsonl', ''), - filePath, - tool: 'codex', - }); + const jsonlFiles = await findJsonlFiles(codexSessionsDir); + await Promise.all( + jsonlFiles.map(async (filePath) => { + const firstLine = await readFirstLine(filePath); + if (!firstLine) return; + try { + const obj = JSON.parse(firstLine); + if (obj.type === 'session_meta' && obj.payload) { + const sessionCwd = obj.payload.cwd; + if (sessionCwd && isProjectSubpath(sessionCwd, resolvedProj)) { + const baseName = path.basename(filePath); + sessions.push({ + sessionId: baseName.replace('.jsonl', ''), + filePath, + tool: 'codex', + }); + } } - } - } catch { /* ignore */ } - } + } catch { /* ignore */ } + }) + ); } catch { /* ignore */ } } @@ -219,9 +265,9 @@ export function discoverSessions(projectDir?: string): SessionFile[] { ]; for (const kimiDir of [...new Set(kimiDirs)]) { const indexFile = path.join(kimiDir, 'session_index.jsonl'); - if (fs.existsSync(indexFile)) { + if (await existsAsync(indexFile)) { try { - const content = fs.readFileSync(indexFile, 'utf-8'); + const content = await fs.promises.readFile(indexFile, 'utf-8'); for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; @@ -234,7 +280,7 @@ export function discoverSessions(projectDir?: string): SessionFile[] { if (sessionDir) { const resolvedSessionDir = path.isAbsolute(sessionDir) ? sessionDir : path.resolve(kimiDir, sessionDir); const wirePath = path.join(resolvedSessionDir, 'agents', 'main', 'wire.jsonl'); - if (fs.existsSync(wirePath)) { + if (await existsAsync(wirePath)) { sessions.push({ sessionId, filePath: wirePath, @@ -250,29 +296,31 @@ export function discoverSessions(projectDir?: string): SessionFile[] { // Fallback: scan sessions/ directories directly if index is missing or doesn't have all entries const sessionsDir = path.join(kimiDir, 'sessions'); - if (fs.existsSync(sessionsDir) && fs.statSync(sessionsDir).isDirectory()) { + if (await existsAsync(sessionsDir) && await isDirectoryAsync(sessionsDir)) { try { - const wireFiles = findWireJsonlFiles(sessionsDir); - for (const wirePath of wireFiles) { - const sessionDir = path.dirname(path.dirname(path.dirname(wirePath))); - const statePath = path.join(sessionDir, 'state.json'); - if (fs.existsSync(statePath)) { - try { - const stateObj = JSON.parse(fs.readFileSync(statePath, 'utf-8')); - const workDir = stateObj.workDir ?? stateObj.work_dir ?? stateObj.cwd; - if (workDir && isProjectSubpath(workDir, resolvedProj)) { - const sessionId = path.basename(sessionDir); - if (!sessions.some(s => s.sessionId === sessionId)) { - sessions.push({ - sessionId, - filePath: wirePath, - tool: 'kimi', - }); + const wireFiles = await findWireJsonlFiles(sessionsDir); + await Promise.all( + wireFiles.map(async (wirePath) => { + const sessionDir = path.dirname(path.dirname(path.dirname(wirePath))); + const statePath = path.join(sessionDir, 'state.json'); + if (await existsAsync(statePath)) { + try { + const stateObj = JSON.parse(await fs.promises.readFile(statePath, 'utf-8')); + const workDir = stateObj.workDir ?? stateObj.work_dir ?? stateObj.cwd; + if (workDir && isProjectSubpath(workDir, resolvedProj)) { + const sessionId = path.basename(sessionDir); + if (!sessions.some(s => s.sessionId === sessionId)) { + sessions.push({ + sessionId, + filePath: wirePath, + tool: 'kimi', + }); + } } - } - } catch { /* ignore */ } - } - } + } catch { /* ignore */ } + } + }) + ); } catch { /* ignore */ } } } @@ -280,88 +328,58 @@ export function discoverSessions(projectDir?: string): SessionFile[] { return sessions; } -export function extractSignalsFromTranscript( +export async function extractSignalsFromTranscript( transcriptPath: string, sessionId: string, tool: 'claude-code' | 'codex' | 'gemini' | 'kimi' = 'claude-code' -): Signal[] { +): Promise { const signals: Signal[] = []; - let content: string; - try { - content = fs.readFileSync(transcriptPath, 'utf-8'); - } catch { - return []; - } - - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; + if (!(await existsAsync(transcriptPath))) return []; - let obj: Record; - try { - obj = JSON.parse(trimmed) as Record; - } catch { - continue; - } - - if (tool === 'claude-code' || tool === 'kimi') { - if (obj['type'] !== 'assistant') continue; - - const msg = obj['message'] as Record | undefined; - if (!msg) continue; - const blocks = msg['content'] as unknown[]; - if (!Array.isArray(blocks)) continue; + try { + const fileStream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); - for (const block of blocks) { - const b = block as Record; - if (b['type'] !== 'tool_use') continue; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; - let toolName = String(b['name'] ?? ''); - if (toolName === 'WriteFile' || toolName === 'write_file') { - toolName = 'Write'; - } else if (toolName === 'StrReplaceFile' || toolName === 'str_replace' || toolName === 'replace') { - toolName = 'Edit'; - } + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + continue; + } - if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'MultiEdit') continue; + if (tool === 'claude-code' || tool === 'kimi') { + if (obj['type'] !== 'assistant') continue; - const toolInput = (b['input'] ?? {}) as Record; - const rawPath = String(toolInput['file_path'] ?? toolInput['path'] ?? ''); - const safePath = sanitizeFilePath(rawPath); + const msg = obj['message'] as Record | undefined; + if (!msg) continue; + const blocks = msg['content'] as unknown[]; + if (!Array.isArray(blocks)) continue; - let diff = buildDiff(toolName, safePath, toolInput); - if (!diff || isNoise(diff)) continue; - diff = redact(diff); + for (const block of blocks) { + const b = block as Record; + if (b['type'] !== 'tool_use') continue; - const language = detectLanguage(safePath); - const ts = String(obj['timestamp'] ?? new Date().toISOString()); + let toolName = String(b['name'] ?? ''); + if (toolName === 'WriteFile' || toolName === 'write_file') { + toolName = 'Write'; + } else if (toolName === 'StrReplaceFile' || toolName === 'str_replace' || toolName === 'replace') { + toolName = 'Edit'; + } - signals.push({ - ts, - session_id: sessionId, - type: 'edit', - file: redact(safePath), - diff, - ...(language ? { language } : {}), - }); - } - } else if (tool === 'gemini') { - if (obj['type'] === 'gemini' && Array.isArray(obj['toolCalls'])) { - for (const call of obj['toolCalls']) { - const toolName = String(call['name'] ?? ''); - if (toolName !== 'write_file' && toolName !== 'edit_file' && toolName !== 'replace_file_content' && toolName !== 'modify_file') continue; - - const rawInput = { - tool_name: toolName, - tool_input: call['args'] ?? {}, - session_id: sessionId, - }; + if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'MultiEdit') continue; - const norm = fromGemini(rawInput); - const safePath = sanitizeFilePath(norm.filePath); - if (!safePath) continue; + const toolInput = (b['input'] ?? {}) as Record; + const rawPath = String(toolInput['file_path'] ?? toolInput['path'] ?? ''); + const safePath = sanitizeFilePath(rawPath); - let diff = buildDiffFromNormalized(norm); + let diff = buildDiff(toolName, safePath, toolInput); if (!diff || isNoise(diff)) continue; diff = redact(diff); @@ -377,55 +395,90 @@ export function extractSignalsFromTranscript( ...(language ? { language } : {}), }); } - } - } else if (tool === 'codex') { - const payload = obj['payload'] as Record | undefined; - const isFunctionCall = obj['type'] === 'function_call' || (obj['type'] === 'response_item' && payload?.['type'] === 'function_call'); - if (isFunctionCall) { - const call = (obj['type'] === 'function_call' ? obj : payload) as Record; - const toolName = String(call['name'] ?? call['tool'] ?? ''); - if (toolName === 'write_file' || toolName === 'edit_file' || toolName === 'replace_file_content' || toolName === 'modify_file' || toolName === 'create_file') { - let args: Record = {}; - try { - const argsStr = String(call['arguments'] ?? '{}'); - args = JSON.parse(argsStr) as Record; - } catch { - // ignore + } else if (tool === 'gemini') { + if (obj['type'] === 'gemini' && Array.isArray(obj['toolCalls'])) { + for (const call of obj['toolCalls']) { + const toolName = String(call['name'] ?? ''); + if (toolName !== 'write_file' && toolName !== 'edit_file' && toolName !== 'replace_file_content' && toolName !== 'modify_file') continue; + + const rawInput = { + tool_name: toolName, + tool_input: call['args'] ?? {}, + session_id: sessionId, + }; + + const norm = fromGemini(rawInput); + const safePath = sanitizeFilePath(norm.filePath); + if (!safePath) continue; + + let diff = buildDiffFromNormalized(norm); + if (!diff || isNoise(diff)) continue; + diff = redact(diff); + + const language = detectLanguage(safePath); + const ts = String(obj['timestamp'] ?? new Date().toISOString()); + + signals.push({ + ts, + session_id: sessionId, + type: 'edit', + file: redact(safePath), + diff, + ...(language ? { language } : {}), + }); } + } + } else if (tool === 'codex') { + const payload = obj['payload'] as Record | undefined; + const isFunctionCall = obj['type'] === 'function_call' || (obj['type'] === 'response_item' && payload?.['type'] === 'function_call'); + if (isFunctionCall) { + const call = (obj['type'] === 'function_call' ? obj : payload) as Record; + const toolName = String(call['name'] ?? call['tool'] ?? ''); + if (toolName === 'write_file' || toolName === 'edit_file' || toolName === 'replace_file_content' || toolName === 'modify_file' || toolName === 'create_file') { + let args: Record = {}; + try { + const argsStr = String(call['arguments'] ?? '{}'); + args = JSON.parse(argsStr) as Record; + } catch { + // ignore + } - const rawInput = { - tool: toolName, - tool_name: toolName, - file_path: args['file_path'] ?? args['path'] ?? args['file'], - content: args['content'], - old_string: args['old_string'], - new_string: args['new_string'], - diff: args['diff'], - session_id: sessionId - }; - - const norm = fromCodex(rawInput); - const safePath = sanitizeFilePath(norm.filePath); - if (!safePath) continue; - - let diff = buildDiffFromNormalized(norm); - if (!diff || isNoise(diff)) continue; - diff = redact(diff); - - const language = detectLanguage(safePath); - const ts = String(obj['timestamp'] ?? new Date().toISOString()); - - signals.push({ - ts, - session_id: sessionId, - type: 'edit', - file: redact(safePath), - diff, - ...(language ? { language } : {}), - }); + const rawInput = { + tool: toolName, + tool_name: toolName, + file_path: args['file_path'] ?? args['path'] ?? args['file'], + content: args['content'], + old_string: args['old_string'], + new_string: args['new_string'], + diff: args['diff'], + session_id: sessionId + }; + + const norm = fromCodex(rawInput); + const safePath = sanitizeFilePath(norm.filePath); + if (!safePath) continue; + + let diff = buildDiffFromNormalized(norm); + if (!diff || isNoise(diff)) continue; + diff = redact(diff); + + const language = detectLanguage(safePath); + const ts = String(obj['timestamp'] ?? new Date().toISOString()); + + signals.push({ + ts, + session_id: sessionId, + type: 'edit', + file: redact(safePath), + diff, + ...(language ? { language } : {}), + }); + } } } } + } catch { + return []; } return signals; @@ -437,7 +490,7 @@ export async function bootstrap(opts?: { ctx?: StorageContext; }): Promise { const ctx = opts?.ctx; - const sessions = discoverSessions(opts?.projectDir); + const sessions = await discoverSessions(opts?.projectDir); const alreadyDone = readBootstrapped(ctx); const newSessions = sessions.filter(s => !alreadyDone.has(s.sessionId)); @@ -457,7 +510,7 @@ export async function bootstrap(opts?: { const processedIds: string[] = []; for (const session of newSessions) { - const sigs = extractSignalsFromTranscript(session.filePath, session.sessionId, session.tool); + const sigs = await extractSignalsFromTranscript(session.filePath, session.sessionId, session.tool); if (sigs.length > 0) sessionsWithEdits++; allSignals.push(...sigs); processedIds.push(session.sessionId); diff --git a/src/capture.ts b/src/capture.ts index 38fb4cf..5b4a86f 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -1,4 +1,4 @@ -import { redact, isNoise, detectLanguage } from './hook'; +import { redact, isNoise, detectLanguage, MAX_DIFF_BYTES } from './hook'; import { sanitizeFilePath, appendSignal, Signal } from './storage'; export interface CaptureOptions { @@ -13,12 +13,16 @@ export function captureFromCli(opts: CaptureOptions): boolean { if (!rawFile) return false; const file = sanitizeFilePath(rawFile); - const diff = redact(opts.diff); + let diff = redact(opts.diff); if (!diff || isNoise(diff)) { return false; } + if (diff.length > MAX_DIFF_BYTES) { + diff = diff.slice(0, MAX_DIFF_BYTES) + '\n... (truncated)'; + } + const language = detectLanguage(file); const sessionId = opts.session?.trim() || `cli-${Date.now()}`; const source = (opts.source?.trim() || 'cli') as Signal['source']; diff --git a/src/cli-ui.ts b/src/cli-ui.ts index 3168e44..d0fd1cc 100644 --- a/src/cli-ui.ts +++ b/src/cli-ui.ts @@ -21,7 +21,16 @@ export function c(code: string, text: string): string { // paths. Returns the path unchanged when it is not under the home directory. export function tildePath(p: string): string { const home = os.homedir(); - return p === home || p.startsWith(home + '/') ? '~' + p.slice(home.length) : p; + if (process.platform === 'win32') { + const normalizedP = p.toLowerCase().replace(/\\/g, '/'); + const normalizedHome = home.toLowerCase().replace(/\\/g, '/'); + if (normalizedP === normalizedHome || normalizedP.startsWith(normalizedHome + '/')) { + return '~' + p.slice(home.length); + } + } else if (p === home || p.startsWith(home + '/')) { + return '~' + p.slice(home.length); + } + return p; } // Strip terminal control characters (ESC, BEL, CSI sequences, C0/C1 controls) from diff --git a/src/cli.ts b/src/cli.ts index a34e267..d194bce 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,7 +17,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { execSync } from 'child_process'; +import { execSync, spawn } from 'child_process'; import { storagePaths, initHabitsMd, initLog, readHabitsMd, readSignals, parseHabits, writeHabitsMd, serialiseHabits, writeSnapshot, @@ -49,7 +49,7 @@ import { detectInstalledTools, isAntigravityMigrated } from './detect'; import { SUPPORTED_TOOLS } from './supported'; import { explainProviderError } from './provider-errors'; -export const VERSION = '0.9.3'; +export const VERSION = '0.9.4'; // Turn a provider failure into a plain-language, actionable hint. Returns // undefined for non-provider errors so the caller can rethrow them. @@ -450,7 +450,7 @@ export async function cmdInit(providerFlag?: string): Promise { const hasExistingHabits = Object.values(habitsEmpty).some(h => h.length > 0); if (providerReady && !hasExistingHabits) { - const sessions = discoverSessions(); + const sessions = await discoverSessions(); if (sessions.length > 0) { process.stdout.write('\n'); process.stdout.write( @@ -1126,12 +1126,12 @@ export async function cmdUninstall(yes: boolean): Promise { for (const tool of detected) { try { if (tool.id === 'gemini') { - const { postAdded } = deregisterJsonHooks(tool.settingsPath); - if (postAdded) process.stdout.write(` ${tick} Removed hooks from Gemini CLI (${tool.settingsPath})\n`); + const { postRemoved } = deregisterJsonHooks(tool.settingsPath); + if (postRemoved) process.stdout.write(` ${tick} Removed hooks from Gemini CLI (${tool.settingsPath})\n`); } else if (tool.id === 'codex') { const jsonFile = path.join(path.dirname(tool.settingsPath), 'hooks.json'); - const { postAdded } = deregisterJsonHooks(jsonFile); - if (postAdded) process.stdout.write(` ${tick} Removed hooks from Codex CLI (${jsonFile})\n`); + const { postRemoved } = deregisterJsonHooks(jsonFile); + if (postRemoved) process.stdout.write(` ${tick} Removed hooks from Codex CLI (${jsonFile})\n`); } else if (tool.id === 'kimi') { if (deregisterKimiHooks(tool.settingsPath)) { process.stdout.write(` ${tick} Removed hooks from Kimi Code CLI (${tool.settingsPath})\n`); @@ -1183,8 +1183,18 @@ export async function cmdUninstall(yes: boolean): Promise { const parsed = JSON.parse(globalList) as { dependencies?: Record }; if (parsed.dependencies?.['cc-habits']) { try { - execSync('npm uninstall -g cc-habits', { stdio: 'pipe' }); - process.stdout.write(` ${tick} Removed global npm installation (cch binary)\n`); + if (process.platform === 'win32') { + // On Windows, the binary is locked while running, so spawn a detached script + // that waits for this process to exit and then deletes it. + spawn('cmd.exe', ['/c', 'timeout /t 1 /nobreak >nul && npm uninstall -g cc-habits'], { + detached: true, + stdio: 'ignore', + }).unref(); + process.stdout.write(` ${tick} Spawned background process to remove global npm installation (cch binary)\n`); + } else { + execSync('npm uninstall -g cc-habits', { stdio: 'pipe' }); + process.stdout.write(` ${tick} Removed global npm installation (cch binary)\n`); + } } catch (e) { process.stdout.write(` ${dash} Could not auto-remove the global binary. Run manually:\n npm uninstall -g cc-habits\n`); } @@ -1807,7 +1817,7 @@ export async function cmdLint(filePath: string, asJson: boolean): Promise { - const sessions = discoverSessions(); + const sessions = await discoverSessions(); if (sessions.length === 0) { process.stdout.write(c(DIM, ' No developer tool sessions found for this project.\n')); @@ -2005,7 +2015,7 @@ export async function cmdGitCapture(range?: string): Promise { // Stay silent (like cmdCapture) so a disabled tool adds no output per commit; // `cch status` is the place that reports the disabled state. if (isGloballyDisabled()) return 0; - const { signalsCaptured, captured } = runGitCapture(range); + const { signalsCaptured, captured } = await runGitCapture(range); if (signalsCaptured > 0) { process.stdout.write(` captured ${signalsCaptured} git commit signal${signalsCaptured === 1 ? '' : 's'} to the local capture log:\n`); // Show what landed, capped so a big commit does not flood the terminal. Full diff --git a/src/git-collector.ts b/src/git-collector.ts index 9a7d672..19a348b 100644 --- a/src/git-collector.ts +++ b/src/git-collector.ts @@ -1,10 +1,13 @@ -import { execFileSync } from 'child_process'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { captureFromCli } from './capture'; import { readHistory, readSignals } from './storage'; +const execFileAsync = promisify(execFile); + // SECURITY: this module runs automatically from the post-commit git hook, so it // processes untrusted repository content (commit ranges and file names). Never -// build shell command strings here. We use execFileSync with an argument array +// build shell command strings here. We use execFileAsync with an argument array // so no shell is involved and metacharacters in file names or refs are passed // literally to git, never interpreted. We also validate the user-supplied range // to block git option-injection (a ref starting with "-"). @@ -14,22 +17,26 @@ import { readHistory, readSignals } from './storage'; // contain the usual ref characters plus a single ".." range separator. const SAFE_REF = /^[A-Za-z0-9_/][A-Za-z0-9_./~^-]*(\.\.[A-Za-z0-9_./~^-]+)?$/; -function git(args: string[], cwdOverride?: string, ignoreOutput = false): string { - return execFileSync('git', args, { - encoding: 'utf-8', +async function git(args: string[], cwdOverride?: string, ignoreOutput = false): Promise { + const options = { + encoding: 'utf-8' as const, + maxBuffer: 10 * 1024 * 1024, // 10MB ...(cwdOverride ? { cwd: cwdOverride } : {}), - ...(ignoreOutput ? { stdio: 'ignore' as const } : {}), - }) as unknown as string; + }; + if (ignoreOutput) { + await execFileAsync('git', args, options); + return ''; + } + const { stdout } = await execFileAsync('git', args, options); + return stdout; } export interface GitCaptureResult { signalsCaptured: number; - // What was captured, newest-file-last, so the CLI can show the user exactly - // what landed in the log without making them open `cch log`. captured: Array<{ file: string; commit: string }>; } -export function runGitCapture(range?: string, cwdOverride?: string): GitCaptureResult { +export async function runGitCapture(range?: string, cwdOverride?: string): Promise { let signalsCaptured = 0; const captured: Array<{ file: string; commit: string }> = []; @@ -37,14 +44,13 @@ export function runGitCapture(range?: string, cwdOverride?: string): GitCaptureR let commitRange = range?.trim(); if (commitRange) { // Reject anything that is not a plain ref/range. This blocks both shell - // metacharacters (defence in depth, execFileSync already neutralizes them) - // and git option-injection via a leading dash. + // metacharacters and git option-injection via a leading dash. if (!SAFE_REF.test(commitRange)) { return { signalsCaptured: 0, captured }; } } else { try { - git(['rev-parse', '--verify', 'HEAD~1'], cwdOverride, true); + await git(['rev-parse', '--verify', 'HEAD~1'], cwdOverride, true); commitRange = 'HEAD~1..HEAD'; } catch { commitRange = 'HEAD'; @@ -54,46 +60,49 @@ export function runGitCapture(range?: string, cwdOverride?: string): GitCaptureR try { let commits: string[] = []; if (commitRange.includes('..') || commitRange.includes('~')) { - const output = git(['log', '--reverse', '--format=%H', commitRange], cwdOverride); + const output = await git(['log', '--reverse', '--format=%H', commitRange], cwdOverride); commits = output.split('\n').map(c => c.trim()).filter(Boolean); } else { - const sha = git(['rev-parse', commitRange], cwdOverride).trim(); + const sha = (await git(['rev-parse', commitRange], cwdOverride)).trim(); if (sha) commits = [sha]; } for (const sha of commits) { let parent = `${sha}~1`; try { - git(['rev-parse', '--verify', parent], cwdOverride, true); + await git(['rev-parse', '--verify', parent], cwdOverride, true); } catch { // Fallback to the empty-tree SHA when no parent exists (first commit). parent = '4b825dc642cb6eb9a0ff12f406d9b61400b5d465'; } // "--" separates revisions from pathspecs so a file named like an option - // cannot be misread, and execFileSync means the name is never shell-parsed. - const filesOutput = git(['diff', '--name-only', parent, sha], cwdOverride); + // cannot be misread. + const filesOutput = await git(['diff', '--name-only', parent, sha], cwdOverride); const files = filesOutput.split('\n').map(f => f.trim()).filter(Boolean); - for (const file of files) { - try { - const diff = git(['diff', parent, sha, '--', file], cwdOverride); - if (diff.trim()) { - const didCapture = captureFromCli({ - file, - diff, - session: `git-${sha.slice(0, 7)}`, - source: 'git' - }); - if (didCapture) { - signalsCaptured++; - captured.push({ file, commit: sha.slice(0, 7) }); + // Concurrently diff the modified files + await Promise.all( + files.map(async (file) => { + try { + const diff = await git(['diff', parent, sha, '--', file], cwdOverride); + if (diff.trim()) { + const didCapture = captureFromCli({ + file, + diff, + session: `git-${sha.slice(0, 7)}`, + source: 'git' + }); + if (didCapture) { + signalsCaptured++; + captured.push({ file, commit: sha.slice(0, 7) }); + } } + } catch { + // Skip files where git diff fails (binary, deleted, renamed edge cases). } - } catch { - // Skip files where git diff fails (binary, deleted, renamed edge cases). - } - } + }) + ); } } catch { // Silent fail. The post-commit hook must never break a commit. diff --git a/src/hook.ts b/src/hook.ts index 56aeeb7..df51fac 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -40,7 +40,7 @@ import { validatePayload, logSchemaWarning, logUnknownEvent, KNOWN_UNSUPPORTED_E const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']); const MIN_SIGNALS = 3; const MIN_DIFF_LEN = 20; -const MAX_DIFF_BYTES = 4096; +export const MAX_DIFF_BYTES = 4096; // Bound stdin reads: a legitimate Claude Code hook payload is always small. // 4 MB is generous even for a large Write payload; anything bigger is anomalous. const MAX_STDIN_BYTES = 4 * 1024 * 1024; // 4 MB @@ -95,59 +95,31 @@ export function detectLanguage(filePath: string): string | undefined { } // Diff builder ───────────────────────────────────────────────────────────── -export function buildDiff(toolName: string, filePath: string, toolInput: Record): string { +function formatDiff( + toolName: string, + rawFilePath: string, + newContent?: string, + oldContent?: string, + edits?: Array> +): string { + const filePath = sanitizeFilePath(rawFilePath); let diff = ''; if (toolName === 'Write') { - const content = String(toolInput['content'] ?? ''); + const content = newContent ?? ''; diff = `+++ ${filePath}\n` + content.split('\n').map(ln => `+${ln}`).join('\n'); - } else if (toolName === 'Edit') { - const old = String(toolInput['old_string'] ?? ''); - const nw = String(toolInput['new_string'] ?? ''); - if (old === nw) return ''; // D7: zero-info edit - diff = - `--- ${filePath}\n` + - old.split('\n').map(ln => `-${ln}`).join('\n') + - '\n' + - nw.split('\n').map(ln => `+${ln}`).join('\n'); } else if (toolName === 'MultiEdit') { - const edits = (toolInput['edits'] ?? []) as Array>; - if (edits.length === 0) return ''; // D6: empty edits - const parts = edits.map(e => { + const arr = edits ?? []; + if (arr.length === 0) return ''; + const parts = arr.map(e => { const old = String(e['old_string'] ?? ''); const nw = String(e['new_string'] ?? ''); return old.split('\n').map(ln => `-${ln}`).join('\n') + '\n' + nw.split('\n').map(ln => `+${ln}`).join('\n'); }); diff = `--- ${filePath}\n` + parts.join('\n---\n'); - } - if (diff.length > MAX_DIFF_BYTES) diff = diff.slice(0, MAX_DIFF_BYTES) + '\n... (truncated)'; - return diff; -} - -export function buildDiffFromNormalized(input: NormalizedHookInput): string { - if (input.diff) { - let diff = input.diff; - if (diff.length > MAX_DIFF_BYTES) diff = diff.slice(0, MAX_DIFF_BYTES) + '\n... (truncated)'; - return diff; - } - const toolName = input.toolName; - const filePath = input.filePath; - let diff = ''; - if (toolName === 'Write') { - const content = input.newContent ?? ''; - diff = `+++ ${filePath}\n` + content.split('\n').map(ln => `+${ln}`).join('\n'); - } else if (toolName === 'MultiEdit') { - const edits = input.edits ?? []; - if (edits.length === 0) return ''; - const parts = edits.map(e => { - const old = e.old_string ?? ''; - const nw = e.new_string ?? ''; - return old.split('\n').map(ln => `-${ln}`).join('\n') + '\n' + nw.split('\n').map(ln => `+${ln}`).join('\n'); - }); - diff = `--- ${filePath}\n` + parts.join('\n---\n'); } else { // Edit/default - const old = input.oldContent ?? ''; - const nw = input.newContent ?? ''; + const old = oldContent ?? ''; + const nw = newContent ?? ''; if (old === nw) return ''; diff = `--- ${filePath}\n` + @@ -159,6 +131,34 @@ export function buildDiffFromNormalized(input: NormalizedHookInput): string { return diff; } +export function buildDiff(toolName: string, filePath: string, toolInput: Record): string { + if (toolName === 'MultiEdit') { + const edits = (toolInput['edits'] ?? []) as Array>; + return formatDiff(toolName, filePath, undefined, undefined, edits); + } + return formatDiff( + toolName, + filePath, + toolInput['new_string'] !== undefined ? String(toolInput['new_string'] ?? '') : String(toolInput['content'] ?? ''), + toolInput['old_string'] !== undefined ? String(toolInput['old_string'] ?? '') : undefined + ); +} + +export function buildDiffFromNormalized(input: NormalizedHookInput): string { + if (input.diff) { + let diff = input.diff; + if (diff.length > MAX_DIFF_BYTES) diff = diff.slice(0, MAX_DIFF_BYTES) + '\n... (truncated)'; + return diff; + } + return formatDiff( + input.toolName, + input.filePath, + input.newContent, + input.oldContent, + input.edits ? input.edits.map(e => ({ old_string: e.old_string, new_string: e.new_string })) : undefined + ); +} + // Per-repo opt-out (Responsible AI) ───────────────────────────────────────── // A developer working on code they can't send to a third-party API (employer // code, regulated data) needs a way to stop capture for that tree. Two switches: @@ -166,7 +166,31 @@ export function buildDiffFromNormalized(input: NormalizedHookInput): string { // • the CC_HABITS_DISABLE env var (truthy). // When either is set, the PostToolUse and Stop hooks become no-ops: nothing is // captured, nothing is sent, no marker is printed. +let cachedIgnore: boolean | null = null; + +export async function checkIgnoreAsync(): Promise { + if (isGloballyDisabled()) { + cachedIgnore = true; + return true; + } + const v = (process.env['CC_HABITS_DISABLE'] ?? '').toLowerCase(); + if (v && v !== '0' && v !== 'false' && v !== 'off') { + cachedIgnore = true; + return true; + } + try { + const ignorePath = path.join(process.cwd(), '.cc-habits-ignore'); + await fs.promises.access(ignorePath); + cachedIgnore = true; + return true; + } catch { + cachedIgnore = false; + return false; + } +} + export function captureDisabled(): boolean { + if (cachedIgnore !== null) return cachedIgnore; if (isGloballyDisabled()) return true; const v = (process.env['CC_HABITS_DISABLE'] ?? '').toLowerCase(); if (v && v !== '0' && v !== 'false' && v !== 'off') return true; @@ -714,7 +738,9 @@ function triggerBoundaryRegex(term: string, cache?: Map): RegExp if (cached) return cached; const escaped = term.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); const startBoundary = /^[a-zA-Z0-9_]/.test(term) ? '\\b' : ''; - const endBoundary = /[a-zA-Z0-9_]$/.test(term) ? 's?\\b' : ''; + const endBoundary = /[a-zA-Z0-9_]$/.test(term) + ? (term.toLowerCase().endsWith('s') ? '\\b' : 's?\\b') + : ''; const re = new RegExp(startBoundary + escaped + endBoundary, 'i'); cache?.set(term, re); return re; @@ -915,7 +941,8 @@ export function processSessionStart(ctx?: StorageContext): string | null { } // stdin/stdout wrappers ──────────────────────────────────────────────────── -export function handleSessionStart(adapter = 'claude-code'): void { +export async function handleSessionStart(adapter = 'claude-code'): Promise { + await checkIgnoreAsync(); // SessionStart hooks may receive a small JSON payload on stdin, but we only // need local state, so drain and ignore it. Always exit 0 so a session never // fails to start because of cc-habits. @@ -956,7 +983,8 @@ export function handleSessionStart(adapter = 'claude-code'): void { process.stdin.on('error', () => process.exit(0)); } -export function handlePostToolUse(adapter = 'claude-code'): void { +export async function handlePostToolUse(adapter = 'claude-code'): Promise { + await checkIgnoreAsync(); let raw = ''; let oversized = false; process.stdin.setEncoding('utf-8'); @@ -989,6 +1017,7 @@ export function handlePostToolUse(adapter = 'claude-code'): void { } export async function handleStop(): Promise { + await checkIgnoreAsync(); const raw = await new Promise(resolve => { let buf = ''; let oversized = false; @@ -1045,7 +1074,8 @@ export async function handleStop(): Promise { process.exit(0); } -export function handleUserPromptSubmit(): void { +export async function handleUserPromptSubmit(): Promise { + await checkIgnoreAsync(); let raw = ''; let oversized = false; process.stdin.setEncoding('utf-8'); @@ -1092,10 +1122,10 @@ export function hookMain(): void { const wrap = async (): Promise => { try { - if (event === 'post-tool-use') handlePostToolUse(adapter); + if (event === 'post-tool-use') await handlePostToolUse(adapter); else if (event === 'stop') await handleStop(); - else if (event === 'user-prompt-submit') handleUserPromptSubmit(); - else if (event === 'session-start') handleSessionStart(adapter); + else if (event === 'user-prompt-submit') await handleUserPromptSubmit(); + else if (event === 'session-start') await handleSessionStart(adapter); else if (KNOWN_UNSUPPORTED_EVENTS.has(event)) { // Deliberate no-op (e.g. subagent-stop). See hook-schema.ts for why: // Claude Code does not fire PostToolUse for subagent tool calls, so there diff --git a/src/index.ts b/src/index.ts index c7f9db8..2f56e5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,10 +33,10 @@ import { tightenLegacyModes } from './storage'; // Print follow-up suggestions to stderr so stdout pipes stay clean. Only when // the command succeeded and we are attached to an interactive terminal. -function printNextSteps(command: string, args: string[], code: number): void { +async function printNextSteps(command: string, args: string[], code: number): Promise { if (code !== 0 || !process.stderr.isTTY) return; if (args.includes('--json')) return; - const steps = nextSteps(command, args); + const steps = await nextSteps(command, args); if (!steps || steps.length === 0) return; process.stderr.write(`\n\n ${c(BOLD + CYAN, 'Next:')}\n`); for (const s of steps) { @@ -422,7 +422,7 @@ async function main(): Promise { process.exit(1); } - printNextSteps(command, args, code); + await printNextSteps(command, args, code); await printUpdateNotice(args); process.exit(code); } diff --git a/src/install.ts b/src/install.ts index 7f1dd5f..e83101b 100644 --- a/src/install.ts +++ b/src/install.ts @@ -128,6 +128,13 @@ export interface HookRegistration { sessionStartAdded?: boolean; } +export interface HookDeregistration { + postRemoved: boolean; + stopRemoved: boolean; + promptRemoved: boolean; + sessionStartRemoved?: boolean; +} + export function registerHooks(hookBin?: string, ctx?: InstallContext): HookRegistration { const bin = hookBin ?? resolveHookBinaryPath(); const { postToolUse, stop, userPromptSubmit, sessionStart } = makeHooks(bin); @@ -904,17 +911,17 @@ export function uninstallGlobalGitTemplateHook(): boolean { } } -export function deregisterJsonHooks(targetFile: string): HookRegistration { +export function deregisterJsonHooks(targetFile: string): HookDeregistration { let settings: Record = {}; if (fs.existsSync(targetFile)) { try { settings = JSON.parse(fs.readFileSync(targetFile, 'utf-8')); } catch { - return { postAdded: false, stopAdded: false, promptAdded: false }; + return { postRemoved: false, stopRemoved: false, promptRemoved: false }; } } - if (!settings['hooks']) return { postAdded: false, stopAdded: false, promptAdded: false }; + if (!settings['hooks']) return { postRemoved: false, stopRemoved: false, promptRemoved: false }; const hooks = settings['hooks'] as Record; const clean = (event: string): boolean => { @@ -936,10 +943,10 @@ export function deregisterJsonHooks(targetFile: string): HookRegistration { } return { - postAdded: postRemoved, - stopAdded: stopRemoved, - promptAdded: promptRemoved, - sessionStartAdded: sessionStartRemoved, + postRemoved, + stopRemoved, + promptRemoved, + sessionStartRemoved, }; } diff --git a/src/menu.ts b/src/menu.ts index ec56212..51d3ac9 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -57,7 +57,11 @@ export function nextIndex( // Render the menu as a plain string (no cursor control) so tests can assert it. export function renderMenu(items: MenuItem[], selected: number): string { - const width = Math.max(...items.map(it => it.label.length)); + let width = 0; + for (let i = 0; i < items.length; i++) { + const len = items[i]!.label.length; + if (len > width) width = len; + } const lines = items.map((item, i) => { const pointer = i === selected ? '❯' : ' '; // Pad the raw label first, then colorize, so ANSI codes do not skew width. diff --git a/src/migrate.ts b/src/migrate.ts index 47c919b..6023490 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -46,7 +46,11 @@ function rewriteClaudeMdImport( readBytes = fs.readSync(fd, buf, 0, st.size, 0); } let content = buf.subarray(0, readBytes).toString('utf-8'); - const newImport = `@import ${preferencesFilePath}`; + const normPrefs = preferencesFilePath.replace(/\\/g, '/'); + const normHabits = habitsFilePath.replace(/\\/g, '/'); + const normOldDir = path.join(OLD_DIR, 'habits.md').replace(/\\/g, '/'); + + const newImport = `@import ${normPrefs}`; // Already correct, idempotent no-op. if (content.includes(newImport)) { fs.closeSync(fd); @@ -54,16 +58,24 @@ function rewriteClaudeMdImport( } let updated = false; // Pattern 1: legacy pre-rename install (~/.claude/habits/habits.md). - const oldDirImport = `@import ${path.join(OLD_DIR, 'habits.md')}`; + const oldDirImport = `@import ${normOldDir}`; + const oldDirImportBackslash = oldDirImport.replace(/\//g, '\\'); if (content.includes(oldDirImport)) { content = content.replace(oldDirImport, newImport); updated = true; + } else if (content.includes(oldDirImportBackslash)) { + content = content.replace(oldDirImportBackslash, newImport); + updated = true; } // Pattern 2: v0.6.x install (~/.cc-habits/habits.md, current storage, old import). - const currentHabitsImport = `@import ${habitsFilePath}`; + const currentHabitsImport = `@import ${normHabits}`; + const currentHabitsImportBackslash = currentHabitsImport.replace(/\//g, '\\'); if (content.includes(currentHabitsImport)) { content = content.replace(currentHabitsImport, newImport); updated = true; + } else if (content.includes(currentHabitsImportBackslash)) { + content = content.replace(currentHabitsImportBackslash, newImport); + updated = true; } if (!updated) { fs.closeSync(fd); diff --git a/src/providers/claude-cli.ts b/src/providers/claude-cli.ts index a9a19ac..dec6a7a 100644 --- a/src/providers/claude-cli.ts +++ b/src/providers/claude-cli.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'child_process'; +import { spawn } from 'child_process'; import { Provider, ProviderRateLimitError, @@ -12,44 +12,62 @@ export class ClaudeCliProvider implements Provider { name = 'claude-cli'; async generate(prompt: string, opts: { maxTokens: number; timeoutMs: number }): Promise { - const result = spawnSync('claude', ['-p', prompt], { - timeout: opts.timeoutMs, - encoding: 'utf-8', - }); + return new Promise((resolve, reject) => { + const child = spawn('claude', ['-p', '-'], { + timeout: opts.timeoutMs, + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + + child.on('error', (err: any) => { + if (err.code === 'ENOENT') { + reject(new ProviderNotInstalledError(this.name)); + } else if (err.code === 'ETIMEDOUT') { + reject(new ProviderTimeoutError(this.name, opts.timeoutMs)); + } else { + reject(err); + } + }); + + child.on('close', (status, signal) => { + if (signal === 'SIGTERM') { + reject(new ProviderTimeoutError(this.name, opts.timeoutMs)); + return; + } - if (result.error) { - if ((result.error as any).code === 'ENOENT') { - throw new ProviderNotInstalledError(this.name); - } - if (result.signal === 'SIGTERM' || (result.error as any).code === 'ETIMEDOUT') { - throw new ProviderTimeoutError(this.name, opts.timeoutMs); - } - throw result.error; - } - - const stderr = result.stderr || ''; - const stdout = result.stdout || ''; - const combined = (stdout + '\n' + stderr).toLowerCase(); - - if (result.status !== 0) { - if (combined.includes('quota') || combined.includes('credit') || combined.includes('balance exhausted')) { - throw new ProviderQuotaError(this.name, result.stderr || undefined); - } - if (combined.includes('rate limit') || combined.includes('429') || combined.includes('too many requests')) { - throw new ProviderRateLimitError(this.name); - } - if ( - combined.includes('auth') || - combined.includes('login') || - combined.includes('unauthorized') || - combined.includes('key') || - combined.includes('token') - ) { - throw new ProviderAuthError(this.name, result.stderr || undefined); - } - throw new Error(`claude CLI failed with exit code ${result.status}: ${result.stderr || result.stdout}`); - } - - return result.stdout || ''; + const combined = (stdout + '\n' + stderr).toLowerCase(); + + if (status !== 0) { + if (combined.includes('quota') || combined.includes('credit') || combined.includes('balance exhausted')) { + reject(new ProviderQuotaError(this.name, stderr || undefined)); + return; + } + if (combined.includes('rate limit') || combined.includes('429') || combined.includes('too many requests')) { + reject(new ProviderRateLimitError(this.name)); + return; + } + if ( + combined.includes('auth') || + combined.includes('login') || + combined.includes('unauthorized') || + combined.includes('key') || + combined.includes('token') + ) { + reject(new ProviderAuthError(this.name, stderr || undefined)); + return; + } + reject(new Error(`claude CLI failed with exit code ${status}: ${stderr || stdout}`)); + return; + } + + resolve(stdout || ''); + }); + + child.stdin.write(prompt); + child.stdin.end(); + }); } } diff --git a/src/providers/codex-cli.ts b/src/providers/codex-cli.ts index f2dc166..fa96033 100644 --- a/src/providers/codex-cli.ts +++ b/src/providers/codex-cli.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'child_process'; +import { spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -38,59 +38,77 @@ export class CodexCliProvider implements Provider { ); try { - const result = spawnSync( - 'codex', - ['exec', '--skip-git-repo-check', '-s', 'read-only', '--color', 'never', '-o', outFile, '-'], - { - input: prompt, - timeout: opts.timeoutMs, - encoding: 'utf-8', - maxBuffer: 32 * 1024 * 1024, - }, - ); + const response = await new Promise((resolve, reject) => { + const child = spawn( + 'codex', + ['exec', '--skip-git-repo-check', '-s', 'read-only', '--color', 'never', '-o', outFile, '-'], + { + timeout: opts.timeoutMs, + } + ); - if (result.error) { - if ((result.error as NodeJS.ErrnoException).code === 'ENOENT') { - throw new ProviderNotInstalledError(this.name); - } - if (result.signal === 'SIGTERM' || (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { - throw new ProviderTimeoutError(this.name, opts.timeoutMs); - } - throw result.error; - } + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); - const stderr = result.stderr || ''; - const stdout = result.stdout || ''; - const combined = (stdout + '\n' + stderr).toLowerCase(); + child.on('error', (err: any) => { + if (err.code === 'ENOENT') { + reject(new ProviderNotInstalledError(this.name)); + } else if (err.code === 'ETIMEDOUT') { + reject(new ProviderTimeoutError(this.name, opts.timeoutMs)); + } else { + reject(err); + } + }); - if (result.status !== 0) { - if (combined.includes('quota') || combined.includes('credit') || combined.includes('balance exhausted')) { - throw new ProviderQuotaError(this.name, result.stderr || undefined); - } - if (combined.includes('rate limit') || combined.includes('429') || combined.includes('too many requests')) { - throw new ProviderRateLimitError(this.name); - } - if ( - combined.includes('not logged in') || - combined.includes('unauthorized') || - combined.includes('authenticate') || - combined.includes('login') || - combined.includes('auth') - ) { - throw new ProviderAuthError(this.name, result.stderr || undefined); - } - throw new Error(`codex CLI failed with exit code ${result.status}: ${result.stderr || result.stdout}`); - } + child.on('close', (status, signal) => { + if (signal === 'SIGTERM') { + reject(new ProviderTimeoutError(this.name, opts.timeoutMs)); + return; + } - // Prefer the captured final message; fall back to stdout if the file is - // empty or absent (older Codex builds, or -o unsupported). - let response = ''; - try { - if (fs.existsSync(outFile)) response = fs.readFileSync(outFile, 'utf-8').trim(); - } catch { - // fall through to stdout - } - return response || stdout; + const combined = (stdout + '\n' + stderr).toLowerCase(); + + if (status !== 0) { + if (combined.includes('quota') || combined.includes('credit') || combined.includes('balance exhausted')) { + reject(new ProviderQuotaError(this.name, stderr || undefined)); + return; + } + if (combined.includes('rate limit') || combined.includes('429') || combined.includes('too many requests')) { + reject(new ProviderRateLimitError(this.name)); + return; + } + if ( + combined.includes('not logged in') || + combined.includes('unauthorized') || + combined.includes('authenticate') || + combined.includes('login') || + combined.includes('auth') + ) { + reject(new ProviderAuthError(this.name, stderr || undefined)); + return; + } + reject(new Error(`codex CLI failed with exit code ${status}: ${stderr || stdout}`)); + return; + } + + // Prefer the captured final message; fall back to stdout if the file is + // empty or absent (older Codex builds, or -o unsupported). + let response = ''; + try { + if (fs.existsSync(outFile)) response = fs.readFileSync(outFile, 'utf-8').trim(); + } catch { + // fall through to stdout + } + resolve(response || stdout); + }); + + child.stdin.write(prompt); + child.stdin.end(); + }); + + return response; } finally { try { if (fs.existsSync(outFile)) fs.unlinkSync(outFile); diff --git a/src/providers/gemini-cli.ts b/src/providers/gemini-cli.ts index 17b6b15..8f75f4f 100644 --- a/src/providers/gemini-cli.ts +++ b/src/providers/gemini-cli.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'child_process'; +import { spawn } from 'child_process'; import { Provider, ProviderRateLimitError, @@ -12,44 +12,62 @@ export class GeminiCliProvider implements Provider { name = 'gemini-cli'; async generate(prompt: string, opts: { maxTokens: number; timeoutMs: number }): Promise { - const result = spawnSync('gemini', ['-p', prompt], { - timeout: opts.timeoutMs, - encoding: 'utf-8', - }); + return new Promise((resolve, reject) => { + const child = spawn('gemini', ['-p', '-'], { + timeout: opts.timeoutMs, + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + + child.on('error', (err: any) => { + if (err.code === 'ENOENT') { + reject(new ProviderNotInstalledError(this.name)); + } else if (err.code === 'ETIMEDOUT') { + reject(new ProviderTimeoutError(this.name, opts.timeoutMs)); + } else { + reject(err); + } + }); + + child.on('close', (status, signal) => { + if (signal === 'SIGTERM') { + reject(new ProviderTimeoutError(this.name, opts.timeoutMs)); + return; + } - if (result.error) { - if ((result.error as any).code === 'ENOENT') { - throw new ProviderNotInstalledError(this.name); - } - if (result.signal === 'SIGTERM' || (result.error as any).code === 'ETIMEDOUT') { - throw new ProviderTimeoutError(this.name, opts.timeoutMs); - } - throw result.error; - } - - const stderr = result.stderr || ''; - const stdout = result.stdout || ''; - const combined = (stdout + '\n' + stderr).toLowerCase(); - - if (result.status !== 0) { - if (combined.includes('quota') || combined.includes('credit') || combined.includes('balance exhausted')) { - throw new ProviderQuotaError(this.name, result.stderr || undefined); - } - if (combined.includes('rate limit') || combined.includes('429') || combined.includes('too many requests')) { - throw new ProviderRateLimitError(this.name); - } - if ( - combined.includes('auth') || - combined.includes('login') || - combined.includes('unauthorized') || - combined.includes('key') || - combined.includes('token') - ) { - throw new ProviderAuthError(this.name, result.stderr || undefined); - } - throw new Error(`gemini CLI failed with exit code ${result.status}: ${result.stderr || result.stdout}`); - } - - return result.stdout || ''; + const combined = (stdout + '\n' + stderr).toLowerCase(); + + if (status !== 0) { + if (combined.includes('quota') || combined.includes('credit') || combined.includes('balance exhausted')) { + reject(new ProviderQuotaError(this.name, stderr || undefined)); + return; + } + if (combined.includes('rate limit') || combined.includes('429') || combined.includes('too many requests')) { + reject(new ProviderRateLimitError(this.name)); + return; + } + if ( + combined.includes('auth') || + combined.includes('login') || + combined.includes('unauthorized') || + combined.includes('key') || + combined.includes('token') + ) { + reject(new ProviderAuthError(this.name, stderr || undefined)); + return; + } + reject(new Error(`gemini CLI failed with exit code ${status}: ${stderr || stdout}`)); + return; + } + + resolve(stdout || ''); + }); + + child.stdin.write(prompt); + child.stdin.end(); + }); } } diff --git a/src/storage.ts b/src/storage.ts index bc6c0eb..d879076 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -280,8 +280,14 @@ function safeAppend(filePath: string, content: string): void { } } else { // Windows fallback: lstat guard is best-effort (TOCTOU race still possible there). - if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) { - throw new Error(`refusing to append through symlink: ${filePath}`); + try { + if (fs.lstatSync(filePath).isSymbolicLink()) { + throw new Error(`refusing to append through symlink: ${filePath}`); + } + } catch (e: any) { + if (e.code !== 'ENOENT') { + throw e; + } } fs.appendFileSync(filePath, content, { encoding: 'utf-8', mode: FILE_MODE }); } @@ -293,18 +299,8 @@ function safeAppend(filePath: string, content: string): void { // so a rotation failure never blocks the caller. function trimIfNeeded(filePath: string, maxLines: number): void { try { - // Single stat for the size check: the previous existsSync + statSync pair did - // two stat syscalls on every append (the common path is "well under the limit, - // return"). One statSync covers both: an absent file throws ENOENT, which the - // inner catch turns into an early return. Also closes the existsSync/statSync - // TOCTOU gap where the file could vanish between the two calls. - let size: number; - try { - size = fs.statSync(filePath).size; - } catch { - return; // absent or unstattable: nothing to trim - } - if (size <= LOG_ROTATE_BYTES) return; + const st = fs.statSync(filePath); + if (st.size <= LOG_ROTATE_BYTES) return; const all = fs.readFileSync(filePath, 'utf-8') .split('\n') .filter(l => l.trim()); @@ -374,26 +370,59 @@ export function appendSignal(signal: Signal, ctx?: StorageContext): void { trimIfNeeded(paths.logFile, LOG_ROTATE_LINES); } +// Open a file read-only (no symlink follow) and return its raw bytes, size-guarded +// to MAX_LOG_READ_BYTES. Returns null when the file is absent. Operating on the +// open fd (rather than re-stat/re-read by path) closes the existsSync -> stat -> +// read TOCTOU race CodeQL flags, and O_NOFOLLOW refuses to open a symlink. +function readFileBytesSafe(path: string, ctx?: StorageContext): Buffer | null { + let fd: number | null = null; + try { + const oNoFollow = (fs.constants as Record)['O_NOFOLLOW'] ?? 0; + fd = fs.openSync(path, fs.constants.O_RDONLY | oNoFollow); + const st = fs.fstatSync(fd); + if (st.size > MAX_LOG_READ_BYTES) { + logError(`readFileBytesSafe: ${path} exceeds ${MAX_LOG_READ_BYTES} bytes; skipping read`, ctx); + fs.closeSync(fd); + return null; + } + const buf = fs.readFileSync(fd); + fs.closeSync(fd); + return buf; + } catch (e) { + if (fd !== null) { try { fs.closeSync(fd); } catch {} } + if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') return null; + throw e; + } +} + export function readSignals(sessionId?: string, ctx?: StorageContext): Signal[] { const paths = getPaths(ctx); - if (!fs.existsSync(paths.logFile)) return []; - // Guard against reading a runaway log file that could exhaust process memory. - const stat = fs.statSync(paths.logFile); - if (stat.size > MAX_LOG_READ_BYTES) { - // Log the oversized-file event and return empty rather than crash. - logError(`readSignals: log.jsonl exceeds ${MAX_LOG_READ_BYTES} bytes; skipping read`, ctx); - return []; - } - const lines = fs.readFileSync(paths.logFile, 'utf-8').split('\n'); + const buf = readFileBytesSafe(paths.logFile, ctx); + if (!buf) return []; const signals: Signal[] = []; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const sig = JSON.parse(trimmed) as Signal; - if (!sessionId || sig.session_id === sessionId) signals.push(sig); - } catch { - // skip malformed line + let start = 0; + for (let i = 0; i < buf.length; i++) { + if (buf[i] === 10) { // '\n' + const line = buf.toString('utf-8', start, i).trim(); + start = i + 1; + if (!line) continue; + try { + const sig = JSON.parse(line) as Signal; + if (!sessionId || sig.session_id === sessionId) signals.push(sig); + } catch { + // skip malformed line + } + } + } + if (start < buf.length) { + const line = buf.toString('utf-8', start, buf.length).trim(); + if (line) { + try { + const sig = JSON.parse(line) as Signal; + if (!sessionId || sig.session_id === sessionId) signals.push(sig); + } catch { + // skip malformed line + } } } return signals; @@ -672,18 +701,23 @@ export function appendHistory(entry: HistoryEntry, ctx?: StorageContext): void { export function readHistory(ctx?: StorageContext): HistoryEntry[] { const paths = getPaths(ctx); - if (!fs.existsSync(paths.historyFile)) return []; - const stat = fs.statSync(paths.historyFile); - if (stat.size > MAX_LOG_READ_BYTES) { - logError(`readHistory: .history.jsonl exceeds ${MAX_LOG_READ_BYTES} bytes; skipping read`, ctx); - return []; - } - const lines = fs.readFileSync(paths.historyFile, 'utf-8').split('\n'); + const buf = readFileBytesSafe(paths.historyFile, ctx); + if (!buf) return []; const out: HistoryEntry[] = []; - for (const line of lines) { - const t = line.trim(); - if (!t) continue; - try { out.push(JSON.parse(t) as HistoryEntry); } catch { /* skip */ } + let start = 0; + for (let i = 0; i < buf.length; i++) { + if (buf[i] === 10) { // '\n' + const line = buf.toString('utf-8', start, i).trim(); + start = i + 1; + if (!line) continue; + try { out.push(JSON.parse(line) as HistoryEntry); } catch { /* skip */ } + } + } + if (start < buf.length) { + const line = buf.toString('utf-8', start, buf.length).trim(); + if (line) { + try { out.push(JSON.parse(line) as HistoryEntry); } catch { /* skip */ } + } } return out; } diff --git a/src/suggestions.ts b/src/suggestions.ts index c0697ae..55b1b06 100644 --- a/src/suggestions.ts +++ b/src/suggestions.ts @@ -68,7 +68,7 @@ interface SystemState { hasPastSessions: boolean; } -function getSystemState(): SystemState { +async function getSystemState(): Promise { let disabled = false; let hasProvider = false; let hasHabits = false; @@ -105,7 +105,7 @@ function getSystemState(): SystemState { } catch {} try { - hasPastSessions = discoverSessions().length > 0; + hasPastSessions = (await discoverSessions()).length > 0; } catch {} return { @@ -120,8 +120,8 @@ function getSystemState(): SystemState { } // lines to print after a successful command, or undefined for none. -export function nextSteps(command: string, args: string[]): string[] | undefined { - const state = getSystemState(); +export async function nextSteps(command: string, args: string[]): Promise { + const state = await getSystemState(); if (state.disabled && command !== 'on') { return ['cch on enable cc-habits (resume capture and prompt injection)']; diff --git a/src/sync.ts b/src/sync.ts index a10e3d0..3df08cb 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -92,7 +92,7 @@ export function renderBlockOrNull(minConfidence = DEFAULT_MIN_CONFIDENCE): strin const cats = parseHabits(readHabitsMd()); const active = activeHabits(cats, minConfidence); if (Object.keys(active).length === 0) return null; - return renderPortableBody(cats, minConfidence); + return renderPortableBody(cats, minConfidence, active); } // Insert or replace the cc-habits block in existing content. Everything outside the @@ -241,7 +241,7 @@ export function readSyncTargets(): SyncTarget[] { if (!fs.existsSync(storagePaths.configFile)) return []; try { const text = fs.readFileSync(storagePaths.configFile, 'utf-8'); - const m = text.match(/sync_targets\s*:\s*\[?([^\]\n#]+)\]?/); + const m = text.match(/^sync_targets\s*:\s*\[?([^\]\n#]+)\]?/m); if (m) { const allowed = new Set(['agents', 'cursor', 'copilot', 'gemini', 'cline', 'aider', 'continue', 'jetbrains', 'windsurf', 'kilo']); return m[1] diff --git a/tests-ts/bootstrap.test.ts b/tests-ts/bootstrap.test.ts index aabd5b7..0b5c682 100644 --- a/tests-ts/bootstrap.test.ts +++ b/tests-ts/bootstrap.test.ts @@ -84,7 +84,7 @@ afterEach(() => { // Signal extraction from transcripts ─────────────────────────────────────── describe('extractSignalsFromTranscript', () => { - it('extracts Edit tool calls as signals', () => { + it('extracts Edit tool calls as signals', async () => { const transcript = path.join(tmpDir, 'session.jsonl'); const lines = [ makeLine('permission-mode', null, { permissionMode: 'default' }), @@ -97,7 +97,7 @@ describe('extractSignalsFromTranscript', () => { ]; fs.writeFileSync(transcript, lines.join('\n')); - const signals = extractSignalsFromTranscript(transcript, 'sess-1'); + const signals = await extractSignalsFromTranscript(transcript, 'sess-1'); expect(signals).toHaveLength(2); expect(signals[0].session_id).toBe('sess-1'); expect(signals[0].file).toContain('app.ts'); @@ -105,7 +105,7 @@ describe('extractSignalsFromTranscript', () => { expect(signals[0].language).toBe('ts'); }); - it('extracts Write tool calls as signals', () => { + it('extracts Write tool calls as signals', async () => { const transcript = path.join(tmpDir, 'session.jsonl'); const content = 'export function hello(): string {\n return "hello";\n}\n'; const lines = [ @@ -113,12 +113,12 @@ describe('extractSignalsFromTranscript', () => { ]; fs.writeFileSync(transcript, lines.join('\n')); - const signals = extractSignalsFromTranscript(transcript, 'sess-2'); + const signals = await extractSignalsFromTranscript(transcript, 'sess-2'); expect(signals).toHaveLength(1); expect(signals[0].diff).toContain('+export function hello'); }); - it('skips noise (trivial edits under MIN_DIFF_LEN)', () => { + it('skips noise (trivial edits under MIN_DIFF_LEN)', async () => { const transcript = path.join(tmpDir, 'session.jsonl'); const lines = [ makeLine('assistant', [editBlock('a.ts', 'x', 'y')]), @@ -126,11 +126,11 @@ describe('extractSignalsFromTranscript', () => { ]; fs.writeFileSync(transcript, lines.join('\n')); - const signals = extractSignalsFromTranscript(transcript, 'sess-3'); + const signals = await extractSignalsFromTranscript(transcript, 'sess-3'); expect(signals).toHaveLength(0); }); - it('redacts PII in extracted signals', () => { + it('redacts PII in extracted signals', async () => { const transcript = path.join(tmpDir, 'session.jsonl'); const lines = [ makeLine('assistant', [ @@ -139,13 +139,13 @@ describe('extractSignalsFromTranscript', () => { ]; fs.writeFileSync(transcript, lines.join('\n')); - const signals = extractSignalsFromTranscript(transcript, 'sess-4'); + const signals = await extractSignalsFromTranscript(transcript, 'sess-4'); expect(signals).toHaveLength(1); expect(signals[0].diff).toContain(''); expect(signals[0].diff).not.toContain('user@example.com'); }); - it('ignores non-assistant messages and non-tool blocks', () => { + it('ignores non-assistant messages and non-tool blocks', async () => { const transcript = path.join(tmpDir, 'session.jsonl'); const lines = [ makeLine('user', [{ type: 'text', text: 'do something' }]), @@ -154,16 +154,16 @@ describe('extractSignalsFromTranscript', () => { ]; fs.writeFileSync(transcript, lines.join('\n')); - const signals = extractSignalsFromTranscript(transcript, 'sess-5'); + const signals = await extractSignalsFromTranscript(transcript, 'sess-5'); expect(signals).toHaveLength(0); }); - it('returns empty array for missing file', () => { - const signals = extractSignalsFromTranscript('/nonexistent/path.jsonl', 'sess-x'); + it('returns empty array for missing file', async () => { + const signals = await extractSignalsFromTranscript('/nonexistent/path.jsonl', 'sess-x'); expect(signals).toHaveLength(0); }); - it('extracts tool calls from Gemini CLI transcripts', () => { + it('extracts tool calls from Gemini CLI transcripts', async () => { const transcript = path.join(tmpDir, 'gemini-session.jsonl'); const lines = [ JSON.stringify({ @@ -183,7 +183,7 @@ describe('extractSignalsFromTranscript', () => { ]; fs.writeFileSync(transcript, lines.join('\n')); - const signals = extractSignalsFromTranscript(transcript, 'gemini-sess', 'gemini'); + const signals = await extractSignalsFromTranscript(transcript, 'gemini-sess', 'gemini'); expect(signals).toHaveLength(1); expect(signals[0].session_id).toBe('gemini-sess'); expect(signals[0].file).toContain('app.ts'); @@ -191,7 +191,7 @@ describe('extractSignalsFromTranscript', () => { expect(signals[0].language).toBe('ts'); }); - it('extracts tool calls from Codex CLI transcripts', () => { + it('extracts tool calls from Codex CLI transcripts', async () => { const transcript = path.join(tmpDir, 'codex-session.jsonl'); const lines = [ JSON.stringify({ @@ -207,7 +207,7 @@ describe('extractSignalsFromTranscript', () => { ]; fs.writeFileSync(transcript, lines.join('\n')); - const signals = extractSignalsFromTranscript(transcript, 'codex-sess', 'codex'); + const signals = await extractSignalsFromTranscript(transcript, 'codex-sess', 'codex'); expect(signals).toHaveLength(1); expect(signals[0].session_id).toBe('codex-sess'); expect(signals[0].file).toContain('utils.ts'); @@ -215,7 +215,7 @@ describe('extractSignalsFromTranscript', () => { expect(signals[0].language).toBe('ts'); }); - it('extracts tool calls from Kimi Code CLI transcripts', () => { + it('extracts tool calls from Kimi Code CLI transcripts', async () => { const transcript = path.join(tmpDir, 'kimi-session.jsonl'); const lines = [ JSON.stringify({ @@ -239,7 +239,7 @@ describe('extractSignalsFromTranscript', () => { ]; fs.writeFileSync(transcript, lines.join('\n')); - const signals = extractSignalsFromTranscript(transcript, 'kimi-sess', 'kimi'); + const signals = await extractSignalsFromTranscript(transcript, 'kimi-sess', 'kimi'); expect(signals).toHaveLength(1); expect(signals[0].session_id).toBe('kimi-sess'); expect(signals[0].file).toContain('app.ts'); @@ -250,7 +250,7 @@ describe('extractSignalsFromTranscript', () => { // Session discovery ─────────────────────────────────────────────────────── describe('discoverSessions', () => { - it('finds session JSONL files in the project directory', () => { + it('finds session JSONL files in the project directory', async () => { const projectPath = '/tmp/test-project'; const encoded = projectPath.replace(/\//g, '-'); const sessDir = path.join(fakeProjectsDir, encoded); @@ -266,7 +266,7 @@ describe('discoverSessions', () => { expect(encoded).toBe('-tmp-test-project'); }); - it('discovers Gemini CLI sessions', () => { + it('discovers Gemini CLI sessions', async () => { const projectPath = path.join(tmpDir, 'my-gemini-project'); fs.mkdirSync(projectPath, { recursive: true }); @@ -280,7 +280,7 @@ describe('discoverSessions', () => { const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); try { - const found = discoverSessions(projectPath); + const found = await discoverSessions(projectPath); expect(found).toHaveLength(1); expect(found[0].tool).toBe('gemini'); expect(found[0].sessionId).toBe('session-1'); @@ -290,7 +290,7 @@ describe('discoverSessions', () => { } }); - it('discovers Codex CLI sessions matching the project directory', () => { + it('discovers Codex CLI sessions matching the project directory', async () => { const projectPath = path.join(tmpDir, 'my-codex-project'); fs.mkdirSync(projectPath, { recursive: true }); @@ -307,7 +307,7 @@ describe('discoverSessions', () => { const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); try { - const found = discoverSessions(projectPath); + const found = await discoverSessions(projectPath); expect(found).toHaveLength(1); expect(found[0].tool).toBe('codex'); expect(found[0].sessionId).toBe('rollout-abc'); @@ -317,7 +317,7 @@ describe('discoverSessions', () => { } }); - it('discovers Kimi Code CLI sessions via session_index.jsonl', () => { + it('discovers Kimi Code CLI sessions via session_index.jsonl', async () => { const projectPath = path.join(tmpDir, 'my-kimi-project'); fs.mkdirSync(projectPath, { recursive: true }); @@ -337,7 +337,7 @@ describe('discoverSessions', () => { const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); try { - const found = discoverSessions(projectPath); + const found = await discoverSessions(projectPath); expect(found).toHaveLength(1); expect(found[0].tool).toBe('kimi'); expect(found[0].sessionId).toBe('kimi-sess-1'); @@ -347,7 +347,7 @@ describe('discoverSessions', () => { } }); - it('discovers Kimi Code CLI sessions via fallback directory scanning', () => { + it('discovers Kimi Code CLI sessions via fallback directory scanning', async () => { const projectPath = path.join(tmpDir, 'my-kimi-project-2'); fs.mkdirSync(projectPath, { recursive: true }); @@ -363,7 +363,7 @@ describe('discoverSessions', () => { const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); try { - const found = discoverSessions(projectPath); + const found = await discoverSessions(projectPath); expect(found).toHaveLength(1); expect(found[0].tool).toBe('kimi'); expect(found[0].sessionId).toBe('kimi-sess-2'); @@ -393,7 +393,7 @@ describe('bootstrap pipeline', () => { fs.writeFileSync(transcript, lines.join('\n')); // Extract signals manually and verify they work - const signals = extractSignalsFromTranscript(transcript, 'test-session'); + const signals = await extractSignalsFromTranscript(transcript, 'test-session'); expect(signals.length).toBeGreaterThanOrEqual(3); }); @@ -425,8 +425,8 @@ describe('multi-session graduation', () => { fs.writeFileSync(t1, makeEdits('a').join('\n')); fs.writeFileSync(t2, makeEdits('b').join('\n')); - const sig1 = extractSignalsFromTranscript(t1, 'session-1'); - const sig2 = extractSignalsFromTranscript(t2, 'session-2'); + const sig1 = await extractSignalsFromTranscript(t1, 'session-1'); + const sig2 = await extractSignalsFromTranscript(t2, 'session-2'); const all = [...sig1, ...sig2]; // Verify signals come from distinct sessions diff --git a/tests-ts/codex-cli-provider.test.ts b/tests-ts/codex-cli-provider.test.ts index 297b37d..7ae8eef 100644 --- a/tests-ts/codex-cli-provider.test.ts +++ b/tests-ts/codex-cli-provider.test.ts @@ -1,26 +1,14 @@ -/** - * Tests for the Codex CLI provider and the --provider validation that lets it be - * selected. - * - * CodexCliProvider.generate() shells out to `codex exec` via spawnSync; we mock - * spawnSync to exercise the success path (final-message file capture) and each - * typed-error classification without invoking the real Codex binary. - * validateProviderFlag() is pure and gates `cch init --provider `. - * - * Setup/teardown: spawnSync is mocked per-suite and restored after each case; the - * success case stubs fs so the captured-output file read is deterministic. - */ - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; -// spawnSync is a named ESM export and cannot be spied in place, so mock the +// spawn is a named ESM export and cannot be spied in place, so mock the // module and keep every other export intact. vi.mock('child_process', async (importActual) => { const actual = await importActual(); - return { ...actual, spawnSync: vi.fn() }; + return { ...actual, spawnSync: vi.fn(), spawn: vi.fn() }; }); -import { spawnSync } from 'child_process'; +import { spawn } from 'child_process'; import fs from 'fs'; import { CodexCliProvider } from '../src/providers/codex-cli'; import { @@ -35,33 +23,63 @@ import { validateProviderFlag, VALID_PROVIDERS } from '../src/cli-provider'; const OPTS = { maxTokens: 1024, timeoutMs: 30_000 }; describe('CodexCliProvider.generate', () => { - const spawnSpy = vi.mocked(spawnSync); + const spawnSpy = vi.mocked(spawn); + let lastStdinWrite: string = ''; beforeEach(() => { spawnSpy.mockReset(); + lastStdinWrite = ''; }); afterEach(() => { vi.restoreAllMocks(); }); + function setSpawnMock(status: number | null, stdout: string, stderr: string, signal: string | null = null, error: Error | null = null) { + spawnSpy.mockImplementation(((command: string, args: string[], options: any) => { + const child: any = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { + write: vi.fn((data: string) => { + lastStdinWrite = data; + }), + end: vi.fn(() => { + process.nextTick(() => { + if (error) { + child.emit('error', error); + return; + } + process.nextTick(() => { + if (stdout) child.stdout.emit('data', Buffer.from(stdout)); + if (stderr) child.stderr.emit('data', Buffer.from(stderr)); + process.nextTick(() => { + child.emit('close', status, signal); + }); + }); + }); + }) + }; + return child; + }) as any); + } + it('returns the captured final-message file on success', async () => { // codex writes the agent's final message to the -o file; stub that read. const readSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); const fileSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('{"rules":[]}' as never); const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined); - spawnSpy.mockReturnValue({ status: 0, stdout: 'event chatter', stderr: '', signal: null } as never); + setSpawnMock(0, 'event chatter', ''); const out = await new CodexCliProvider().generate('prompt', OPTS); expect(out).toBe('{"rules":[]}'); - // The prompt is piped via stdin, not argv, and the sandbox is read-only. - const [bin, args, opts] = spawnSpy.mock.calls[0] as [string, string[], Record]; + const [bin, args] = spawnSpy.mock.calls[0] as [string, string[], Record]; expect(bin).toBe('codex'); expect(args).toContain('exec'); expect(args).toContain('-s'); expect(args).toContain('read-only'); - expect(opts.input).toBe('prompt'); + expect(lastStdinWrite).toBe('prompt'); expect(unlinkSpy).toHaveBeenCalled(); // temp file cleaned up readSpy.mockRestore(); fileSpy.mockRestore(); @@ -69,37 +87,37 @@ describe('CodexCliProvider.generate', () => { it('falls back to stdout when the capture file is empty/absent', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); - spawnSpy.mockReturnValue({ status: 0, stdout: 'plain answer', stderr: '', signal: null } as never); + setSpawnMock(0, 'plain answer', ''); const out = await new CodexCliProvider().generate('p', OPTS); expect(out).toBe('plain answer'); }); it('throws ProviderNotInstalledError when codex is not on PATH', async () => { - spawnSpy.mockReturnValue({ error: Object.assign(new Error('spawn'), { code: 'ENOENT' }) } as never); + setSpawnMock(null, '', '', null, Object.assign(new Error('spawn'), { code: 'ENOENT' })); await expect(new CodexCliProvider().generate('p', OPTS)).rejects.toBeInstanceOf(ProviderNotInstalledError); }); it('throws ProviderTimeoutError on SIGTERM/ETIMEDOUT', async () => { - spawnSpy.mockReturnValue({ error: Object.assign(new Error('t'), { code: 'ETIMEDOUT' }), signal: 'SIGTERM' } as never); + setSpawnMock(null, '', '', 'SIGTERM'); await expect(new CodexCliProvider().generate('p', OPTS)).rejects.toBeInstanceOf(ProviderTimeoutError); }); it('classifies a not-logged-in failure as ProviderAuthError', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); - spawnSpy.mockReturnValue({ status: 1, stdout: '', stderr: 'Error: not logged in', signal: null } as never); + setSpawnMock(1, '', 'Error: not logged in'); await expect(new CodexCliProvider().generate('p', OPTS)).rejects.toBeInstanceOf(ProviderAuthError); }); it('classifies a quota failure as ProviderQuotaError', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); - spawnSpy.mockReturnValue({ status: 1, stdout: '', stderr: 'quota exceeded', signal: null } as never); + setSpawnMock(1, '', 'quota exceeded'); await expect(new CodexCliProvider().generate('p', OPTS)).rejects.toBeInstanceOf(ProviderQuotaError); }); it('classifies a rate-limit failure as ProviderRateLimitError', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); - spawnSpy.mockReturnValue({ status: 1, stdout: '', stderr: 'HTTP 429 too many requests', signal: null } as never); + setSpawnMock(1, '', 'HTTP 429 too many requests'); await expect(new CodexCliProvider().generate('p', OPTS)).rejects.toBeInstanceOf(ProviderRateLimitError); }); }); diff --git a/tests-ts/command-surface.test.ts b/tests-ts/command-surface.test.ts index fe46962..bab740f 100644 --- a/tests-ts/command-surface.test.ts +++ b/tests-ts/command-surface.test.ts @@ -86,22 +86,23 @@ describe('command surface: MENU_ITEMS pipeline order', () => { describe('command surface: nextSteps hint coverage', () => { const pipelineCommands = ['init', 'learn', 'view', 'sync', 'capture']; - it('returns a non-empty hint for each pipeline command', () => { + it('returns a non-empty hint for each pipeline command', async () => { for (const command of pipelineCommands) { - const steps = nextSteps(command, []); + const steps = await nextSteps(command, []); expect(steps, `expected a hint for '${command}'`).toBeDefined(); expect(steps!.length).toBeGreaterThan(0); } }); - it('export points at cch import on the receiving side', () => { - expect(nextSteps('export', [])?.some(s => s.includes('cch import'))).toBe(true); + it('export points at cch import on the receiving side', async () => { + const steps = await nextSteps('export', []); + expect(steps?.some(s => s.includes('cch import'))).toBe(true); }); - it('keeps the description column at or after 22 for every "cch " hint', () => { + it('keeps the description column at or after 22 for every "cch " hint', async () => { const checked = [...pipelineCommands, 'status', 'on', 'off', 'bootstrap', 'export']; for (const command of checked) { - const steps = nextSteps(command, []) ?? []; + const steps = (await nextSteps(command, [])) ?? []; for (const line of steps) { if (!line.startsWith('cch ')) continue; assertColumnContract(line); diff --git a/tests-ts/faq.test.ts b/tests-ts/faq.test.ts index 7828985..53f3288 100644 --- a/tests-ts/faq.test.ts +++ b/tests-ts/faq.test.ts @@ -71,9 +71,10 @@ const { execFileSyncMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn() })); -vi.mock('child_process', () => ({ - execFileSync: execFileSyncMock -})); +vi.mock('child_process', async (importActual) => { + const actual = await importActual(); + return { ...actual, execFileSync: execFileSyncMock }; +}); describe('openBrowser (security)', () => { beforeEach(() => { diff --git a/tests-ts/git-security.test.ts b/tests-ts/git-security.test.ts index b841478..cd26bf0 100644 --- a/tests-ts/git-security.test.ts +++ b/tests-ts/git-security.test.ts @@ -60,7 +60,7 @@ afterEach(() => { }); describe('git-collector command-injection hardening', () => { - it.skipIf(!GIT)('does not execute a command-substitution file name on capture', () => { + it.skipIf(!GIT)('does not execute a command-substitution file name on capture', async () => { // A repository file whose name embeds a shell command substitution. Under the // old string-interpolated execSync this executed when the file was processed. const sentinel = path.join(repoDir, 'PWNED_FILE'); @@ -69,33 +69,33 @@ describe('git-collector command-injection hardening', () => { gitIn(repoDir, ['add', '-A']); gitIn(repoDir, ['commit', '-m', 'add file with hostile name']); - runGitCapture(undefined, repoDir); + await runGitCapture(undefined, repoDir); expect(fs.existsSync(sentinel)).toBe(false); }); - it.skipIf(!GIT)('does not execute a backtick file name on capture', () => { + it.skipIf(!GIT)('does not execute a backtick file name on capture', async () => { const sentinel = path.join(repoDir, 'PWNED_BACKTICK'); const evilName = '`touch PWNED_BACKTICK`.txt'; fs.writeFileSync(path.join(repoDir, evilName), 'const a = 1\nconst b = 2\n'); gitIn(repoDir, ['add', '-A']); gitIn(repoDir, ['commit', '-m', 'add file with backtick name']); - runGitCapture(undefined, repoDir); + await runGitCapture(undefined, repoDir); expect(fs.existsSync(sentinel)).toBe(false); }); - it.skipIf(!GIT)('rejects a malicious --range and runs nothing', () => { + it.skipIf(!GIT)('rejects a malicious --range and runs nothing', async () => { const sentinel = path.join(repoDir, 'PWNED_RANGE'); fs.writeFileSync(path.join(repoDir, 'ok.ts'), 'const a = 1\n'); gitIn(repoDir, ['add', '-A']); gitIn(repoDir, ['commit', '-m', 'init']); // Range with shell metacharacters and a leading-dash option-injection attempt. - const res1 = runGitCapture(`HEAD; touch ${sentinel}`, repoDir); - const res2 = runGitCapture('$(touch PWNED_RANGE)', repoDir); - const res3 = runGitCapture('--output=/tmp/whatever', repoDir); + const res1 = await runGitCapture(`HEAD; touch ${sentinel}`, repoDir); + const res2 = await runGitCapture('$(touch PWNED_RANGE)', repoDir); + const res3 = await runGitCapture('--output=/tmp/whatever', repoDir); expect(res1.signalsCaptured).toBe(0); expect(res2.signalsCaptured).toBe(0); @@ -104,12 +104,12 @@ describe('git-collector command-injection hardening', () => { expect(fs.existsSync(path.join(repoDir, 'PWNED_RANGE'))).toBe(false); }); - it.skipIf(!GIT)('still captures a normally named file', () => { + it.skipIf(!GIT)('still captures a normally named file', async () => { fs.writeFileSync(path.join(repoDir, 'real.ts'), 'export const value = 42\nexport const other = 7\n'); gitIn(repoDir, ['add', '-A']); gitIn(repoDir, ['commit', '-m', 'add real file']); - const res = runGitCapture(undefined, repoDir); + const res = await runGitCapture(undefined, repoDir); // The capture path should run without error and record the legitimate edit. expect(res.signalsCaptured).toBeGreaterThanOrEqual(1); }); diff --git a/tests-ts/global-toggle.test.ts b/tests-ts/global-toggle.test.ts index d11c70e..9c922a1 100644 --- a/tests-ts/global-toggle.test.ts +++ b/tests-ts/global-toggle.test.ts @@ -137,16 +137,16 @@ describe('Simplified cmdTombstone', () => { }); describe('Smart suggestions', () => { - it('suggests cch on for all commands when globally disabled', () => { + it('suggests cch on for all commands when globally disabled', async () => { setGloballyDisabled(true); - const steps = nextSteps('view', []); + const steps = await nextSteps('view', []); expect(steps).toEqual(['cch on enable cc-habits (resume capture and prompt injection)']); }); - it('suggests cch sync when globally enabled', () => { + it('suggests cch sync when globally enabled', async () => { setGloballyDisabled(false); - const steps = nextSteps('view', []); + const steps = await nextSteps('view', []); expect(steps).toContain('cch sync share habits with your other tools'); }); }); diff --git a/tests-ts/global.test.ts b/tests-ts/global.test.ts index fb50f44..ffa0131 100644 --- a/tests-ts/global.test.ts +++ b/tests-ts/global.test.ts @@ -30,6 +30,7 @@ function makeEdit(file: string, old: string, nw: string, session: string): void beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-habits-global-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); // cmdInit below installs the post-commit git hook, and that path is resolved // from the current working directory, not from installPaths. Without pinning // cwd to a throwaway repo, running the suite appends the hook line to the @@ -46,7 +47,7 @@ beforeEach(() => { storagePaths.historyFile = path.join(tmpDir, 'habits', '.history.jsonl'); storagePaths.provenanceFile = path.join(tmpDir, 'habits', '.provenance.json'); storagePaths.configFile = path.join(tmpDir, 'habits', 'config.yml'); - const claudeDir = path.join(tmpDir, 'dot_claude'); + const claudeDir = path.join(tmpDir, '.claude'); fs.mkdirSync(claudeDir, { recursive: true }); installPaths.claudeDir = claudeDir; installPaths.settingsFile = path.join(claudeDir, 'settings.json'); diff --git a/tests-ts/integration.test.ts b/tests-ts/integration.test.ts index 4ee18ea..3edb04f 100644 --- a/tests-ts/integration.test.ts +++ b/tests-ts/integration.test.ts @@ -38,6 +38,7 @@ const FAKE_UPDATES = [ beforeEach(() => { const baseTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-habits-test-')); + vi.spyOn(os, 'homedir').mockReturnValue(baseTmp); tmpDir = path.join(baseTmp, '.cc-habits'); fs.mkdirSync(tmpDir, { recursive: true }); // cmdInit installs the post-commit git hook, and that path is resolved from @@ -58,7 +59,7 @@ beforeEach(() => { storagePaths.historyFile = path.join(tmpDir, '.history.jsonl'); storagePaths.provenanceFile = path.join(tmpDir, '.provenance.json'); storagePaths.configFile = path.join(tmpDir, 'config.yml'); - const claudeDir = path.join(tmpDir, 'dot_claude'); + const claudeDir = path.join(baseTmp, '.claude'); fs.mkdirSync(claudeDir, { recursive: true }); installPaths.claudeDir = claudeDir; installPaths.settingsFile = path.join(claudeDir, 'settings.json'); diff --git a/tests-ts/migrate.test.ts b/tests-ts/migrate.test.ts index 26fc09a..24c9e3f 100644 --- a/tests-ts/migrate.test.ts +++ b/tests-ts/migrate.test.ts @@ -45,7 +45,9 @@ function nonExistentOldDir(): string { describe('runMigration, CLAUDE.md @import rewrite (Fix 2)', () => { it('rewrites @import to preferences.md when no legacy dir exists (v0.6.x user)', () => { // Seed CLAUDE.md with the exact string that would have been written by v0.6.x install. - const oldImport = `@import ${storagePaths.habitsFile}`; + const habitsNormalized = storagePaths.habitsFile.replace(/\\/g, '/'); + const prefsNormalized = storagePaths.preferencesFile.replace(/\\/g, '/'); + const oldImport = `@import ${habitsNormalized}`; fs.writeFileSync(claudeMd, `# My project\n\n${oldImport}\n`); const result = runMigration(false, nonExistentOldDir()); @@ -54,14 +56,15 @@ describe('runMigration, CLAUDE.md @import rewrite (Fix 2)', () => { expect(result.migrated).toBe(false); // no file copy, legacy dir absent const content = fs.readFileSync(claudeMd, 'utf-8'); - expect(content).toContain(`@import ${storagePaths.preferencesFile}`); - expect(content).not.toContain(`@import ${storagePaths.habitsFile}`); + expect(content).toContain(`@import ${prefsNormalized}`); + expect(content).not.toContain(`@import ${habitsNormalized}`); // Surrounding user content preserved. expect(content).toContain('# My project'); }); it('is idempotent: second runMigration is a no-op when already pointing at preferences.md', () => { - const newImport = `@import ${storagePaths.preferencesFile}`; + const prefsNormalized = storagePaths.preferencesFile.replace(/\\/g, '/'); + const newImport = `@import ${prefsNormalized}`; fs.writeFileSync(claudeMd, `${newImport}\n`); const result = runMigration(false, nonExistentOldDir()); diff --git a/tests-ts/v031.test.ts b/tests-ts/v031.test.ts index 23cd80f..2f52d85 100644 --- a/tests-ts/v031.test.ts +++ b/tests-ts/v031.test.ts @@ -125,21 +125,24 @@ describe('looksLikeEnvVar', () => { // Next-step hints ───────────────────────────────────────────────────────────── describe('nextSteps mapping', () => { - it('suggests sync after view', () => { - const steps = nextSteps('view', []); + it('suggests sync after view', async () => { + const steps = await nextSteps('view', []); expect(steps?.some(s => s.includes('sync'))).toBe(true); }); - it('suggests learn after capture', () => { - expect(nextSteps('capture', [])?.some(s => s.includes('learn'))).toBe(true); + it('suggests learn after capture', async () => { + const steps = await nextSteps('capture', []); + expect(steps?.some(s => s.includes('learn'))).toBe(true); }); - it('suggests import on the other side after export', () => { - expect(nextSteps('export', [])?.some(s => s.includes('cch import'))).toBe(true); + it('suggests import on the other side after export', async () => { + const steps = await nextSteps('export', []); + expect(steps?.some(s => s.includes('cch import'))).toBe(true); }); - it('returns nothing for commands without a follow-up', () => { - expect(nextSteps('reset', [])).toBeUndefined(); + it('returns nothing for commands without a follow-up', async () => { + const steps = await nextSteps('reset', []); + expect(steps).toBeUndefined(); }); }); diff --git a/tests-ts/v040.test.ts b/tests-ts/v040.test.ts index fb6f2df..90f4883 100644 --- a/tests-ts/v040.test.ts +++ b/tests-ts/v040.test.ts @@ -123,8 +123,8 @@ describe('v0.3.0: runGitCapture and shouldTriggerGitLearn', () => { expect(shouldTriggerGitLearn()).toBe(false); }); - it('runGitCapture handles non-git directory gracefully', () => { - const res = runGitCapture(undefined, tmpDir); + it('runGitCapture handles non-git directory gracefully', async () => { + const res = await runGitCapture(undefined, tmpDir); expect(res.signalsCaptured).toBe(0); }); });