claudekit
Version:
CLI tools for Claude Code development workflow
439 lines (384 loc) • 12.5 kB
text/typescript
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import picomatch from 'picomatch';
export interface ToolInput {
file_path?: string;
path?: string;
old_string?: string;
new_string?: string;
command?: string;
content?: string;
todos?: Array<{ content: string; status: string; id: string }>;
edits?: Array<{ old_string: string; new_string: string }>;
[key: string]: unknown;
}
export interface TranscriptEntry {
type?: string;
uuid?: string;
parentUuid?: string | null;
sessionId?: string;
timestamp?: string;
message?: {
id?: string;
role?: string;
content?: Array<{
type?: string;
name?: string;
text?: string;
input?: ToolInput;
tool_use_id?: string;
}>;
};
toolUseResult?: {
newTodos?: Array<{ content: string; status: string; id?: string }>;
filePath?: string;
oldString?: string;
newString?: string;
[key: string]: unknown;
};
}
export interface ToolUse {
name: string;
input: ToolInput;
timestamp?: string | undefined;
}
export class TranscriptParser {
private readonly transcriptPath: string;
private entries: TranscriptEntry[] | null = null;
private static readonly EDITING_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
constructor(transcriptPath: string) {
this.transcriptPath = transcriptPath.replace(/^~/, homedir());
}
exists(): boolean {
return existsSync(this.transcriptPath);
}
private loadEntries(): TranscriptEntry[] {
if (this.entries) {
return this.entries;
}
if (!this.exists()) {
return [];
}
try {
const content = readFileSync(this.transcriptPath, 'utf-8');
const lines = content.split('\n').filter((line) => line.trim());
this.entries = lines
.map((line) => {
try {
return JSON.parse(line) as TranscriptEntry;
} catch {
return null;
}
})
.filter((entry): entry is TranscriptEntry => entry !== null);
return this.entries;
} catch {
return [];
}
}
/**
* Get entries from the last N conversation messages (user or assistant turns)
* Groups messages as they appear in Claude Code UI:
* - Each dot (⏺) in the UI represents one message group
* - Assistant with text starts a new message (gets a dot)
* - Following assistant with only tools is part of the same message
* - Standalone tool use (like TodoWrite) is its own message
* - User entries are included but don't create dots
*/
getRecentMessages(messageCount: number): TranscriptEntry[] {
const entries = this.loadEntries();
// First, identify UI message groups (things that get dots in the UI)
const allGroups: TranscriptEntry[][] = [];
let i = 0;
while (i < entries.length) {
const entry = entries[i];
if (!entry) {
i++;
continue;
}
const group: TranscriptEntry[] = [];
if (entry.type === 'assistant') {
const hasText =
Array.isArray(entry.message?.content) &&
entry.message.content.some((c) => c.type === 'text');
if (hasText) {
// Assistant with text - starts a UI message
// Include this entry and any following tool-only assistants
group.push(entry);
i++;
// Collect following tool-only assistants
while (i < entries.length) {
const next = entries[i];
if (next?.type === 'assistant') {
const nextHasText =
Array.isArray(next.message?.content) &&
next.message.content.some((c) => c.type === 'text');
if (!nextHasText) {
// Tool-only assistant, part of same UI message
group.push(next);
i++;
} else {
// Next assistant has text, starts new message
break;
}
} else {
// Not an assistant, ends this group
break;
}
}
allGroups.push(group);
} else {
// Tool-only assistant (like TodoWrite) - standalone UI message
group.push(entry);
allGroups.push(group);
i++;
}
} else {
// User or system entry - not a UI message but include in flow
i++;
}
}
// Now get the last N UI message groups and include intervening entries
const uiGroups = allGroups.slice(-messageCount);
if (uiGroups.length === 0) {
return [];
}
// Find the index of the first entry in our selected groups
const firstGroupFirstEntry = uiGroups[0]?.[0];
if (!firstGroupFirstEntry) {
return [];
}
const startIndex = entries.indexOf(firstGroupFirstEntry);
if (startIndex === -1) {
return [];
}
// Return all entries from that point forward (includes user entries between groups)
return entries.slice(startIndex);
}
/**
* Find tool uses in recent messages
*/
findToolUsesInRecentMessages(messageCount: number, toolNames?: string[]): ToolUse[] {
const entries = this.getRecentMessages(messageCount);
const toolUses: ToolUse[] = [];
for (const entry of entries) {
if (entry.type === 'assistant' && entry.message?.content) {
for (const content of entry.message.content) {
if (content.type === 'tool_use' && content.name !== undefined && content.name !== null) {
if (!toolNames || toolNames.includes(content.name)) {
toolUses.push({
name: content.name,
input: content.input || {},
timestamp: entry.timestamp,
});
}
}
}
}
}
return toolUses;
}
/**
* Get the most recent todo state from the transcript
*/
findLatestTodoState(): Array<{ content: string; status: string; id?: string }> | null {
const entries = this.loadEntries();
// Read from end to find most recent todo state
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (!entry) {
continue;
}
if (
entry.type === 'user' &&
entry.toolUseResult?.newTodos !== null &&
entry.toolUseResult?.newTodos !== undefined &&
Array.isArray(entry.toolUseResult.newTodos)
) {
return entry.toolUseResult.newTodos;
}
}
return null;
}
/**
* Find the most recent message containing a specific marker
* Returns the index of the message containing the marker, or -1 if not found
*/
findLastMessageWithMarker(marker: string): number {
const entries = this.loadEntries();
// Search backwards for the most recent message with the marker
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (!entry) {
continue;
}
// Check tool results (where Stop hook JSON output is captured)
if (entry.type === 'user' && entry.toolUseResult) {
// Check in reason field where the marker is included
const reason = entry.toolUseResult['reason'];
if (typeof reason === 'string' && reason.includes(marker)) {
return i;
}
}
// Check user messages (where Stop hook feedback appears in Claude Code)
if (entry.type === 'user' && entry.message?.content) {
let content = '';
if (Array.isArray(entry.message.content)) {
content = entry.message.content
.map((c) => {
if (typeof c === 'object') {
// Check for tool_result type which contains Stop hook output
if ('type' in c && c.type === 'tool_result' && 'content' in c) {
return String(c.content);
} else if ('text' in c) {
return c.text;
}
} else if (typeof c === 'string') {
return c;
}
return '';
})
.join(' ');
} else if (typeof entry.message.content === 'string') {
content = entry.message.content;
}
if (content.includes(marker)) {
return i;
}
}
}
return -1;
}
/**
* Check if a file path matches the target patterns (glob patterns or extensions)
*/
private matchesTargetPatterns(filePath: string, patterns?: string[]): boolean {
// If no patterns specified, use default code extensions
if (!patterns || patterns.length === 0) {
const defaultPatterns = [
'**/*.ts',
'**/*.tsx',
'**/*.js',
'**/*.jsx',
'**/*.mjs',
'**/*.cjs', // JavaScript/TypeScript
'**/*.py',
'**/*.pyw',
'**/*.pyx', // Python
'**/*.java',
'**/*.kt',
'**/*.scala',
'**/*.clj', // JVM languages
'**/*.c',
'**/*.cpp',
'**/*.cc',
'**/*.cxx',
'**/*.h',
'**/*.hpp', // C/C++
'**/*.cs',
'**/*.fs',
'**/*.vb', // .NET languages
'**/*.go',
'**/*.rs',
'**/*.swift',
'**/*.m',
'**/*.mm', // System languages
'**/*.rb',
'**/*.php',
'**/*.pl',
'**/*.lua',
'**/*.r', // Scripting languages
'**/*.vue',
'**/*.svelte',
'**/*.astro', // Framework files
'**/*.sol',
'**/*.dart',
'**/*.elm',
'**/*.ex',
'**/*.exs', // Other languages
];
// Create a matcher for all patterns
const isMatch = picomatch(defaultPatterns);
return isMatch(filePath);
}
// Separate positive and negative patterns
const positivePatterns: string[] = [];
const negativePatterns: string[] = [];
for (const pattern of patterns) {
if (pattern.startsWith('!')) {
negativePatterns.push(pattern.slice(1));
} else {
positivePatterns.push(pattern);
}
}
// If no positive patterns, use a match-all pattern
if (positivePatterns.length === 0) {
positivePatterns.push('**/*');
}
// Create matchers
const isMatch = picomatch(positivePatterns);
const isExcluded =
negativePatterns.length > 0 ? picomatch(negativePatterns) : (): boolean => false;
// Check if file matches positive patterns and is not excluded
return Boolean(isMatch(filePath)) && !isExcluded(filePath);
}
/**
* Check if there are file changes in a specific range of entries
*/
private hasFileChangesInRange(
startIndex: number,
endIndex: number,
targetPatterns?: string[]
): boolean {
const entries = this.loadEntries();
for (let i = startIndex; i < endIndex && i < entries.length; i++) {
const entry = entries[i];
if (!entry || entry.type !== 'assistant' || !entry.message?.content) {
continue;
}
for (const content of entry.message.content) {
if (
content.type === 'tool_use' &&
content.name !== undefined &&
content.name !== null &&
TranscriptParser.EDITING_TOOLS.includes(content.name)
) {
const filePath = (content.input?.file_path ?? content.input?.path ?? '').toString();
if (this.matchesTargetPatterns(filePath, targetPatterns)) {
return true;
}
}
}
}
return false;
}
/**
* Check if there are file changes since a specific marker position
*/
hasFileChangesSinceMarker(marker: string, targetPatterns?: string[]): boolean {
const entries = this.loadEntries();
const lastMarkerIndex = this.findLastMessageWithMarker(marker);
// If no previous marker, return false (caller should handle this case)
if (lastMarkerIndex === -1) {
return false;
}
// Check for file changes after the last marker
return this.hasFileChangesInRange(lastMarkerIndex + 1, entries.length, targetPatterns);
}
/**
* Check if there are file changes in recent messages
*/
hasRecentFileChanges(messageCount: number, targetPatterns?: string[]): boolean {
const toolUses = this.findToolUsesInRecentMessages(
messageCount,
TranscriptParser.EDITING_TOOLS
);
for (const toolUse of toolUses) {
const filePath = (toolUse.input?.file_path ?? toolUse.input?.path ?? '').toString();
if (this.matchesTargetPatterns(filePath, targetPatterns)) {
return true;
}
}
return false;
}
}