Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 84 additions & 48 deletions src/adapters/claude-code.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
import type { ActionEnvelope } from '../types/action.js';
import type { ActionEnvelope, ActionType, ActionContext } from '../types/action.js';
import type { SkillIdentity } from '../types/skill.js';
import type { HookAdapter, HookInput } from './types.js';
import { getString } from './common.js';

/**
* Tool name action type mapping for Claude Code
* Tool name -> action type mapping for Claude Code
*/
const TOOL_ACTION_MAP: Record<string, string> = {
const TOOL_ACTION_MAP: Record<string, ActionType> = {
Bash: 'exec_command',
Write: 'write_file',
Edit: 'write_file',
Read: 'read_file',
WebFetch: 'network_request',
WebSearch: 'network_request',
};
Expand All @@ -23,115 +26,148 @@ export class ClaudeCodeAdapter implements HookAdapter {
readonly name = 'claude-code';

parseInput(raw: unknown): HookInput {
const data = raw as Record<string, unknown>;
const hookEvent = (data.hook_event_name as string) || '';
const data = (raw !== null && typeof raw === 'object') ? raw as Record<string, unknown> : {};
const hookEvent = getString(data, 'hook_event_name');
const toolInput = (data.tool_input !== null && typeof data.tool_input === 'object')
? data.tool_input as Record<string, unknown>
: {};
return {
toolName: (data.tool_name as string) || '',
toolInput: (data.tool_input as Record<string, unknown>) || {},
toolName: getString(data, 'tool_name'),
toolInput,
eventType: hookEvent.startsWith('Post') ? 'post' : 'pre',
sessionId: data.session_id as string | undefined,
cwd: data.cwd as string | undefined,
sessionId: getString(data, 'session_id') || undefined,
cwd: getString(data, 'cwd') || undefined,
raw: data,
};
}

mapToolToActionType(toolName: string): string | null {
return TOOL_ACTION_MAP[toolName] || null;
mapToolToActionType(toolName: string): ActionType | null {
return TOOL_ACTION_MAP[toolName] ?? null;
}

buildEnvelope(input: HookInput, initiatingSkill?: string | null): ActionEnvelope | null {
const actionType = this.mapToolToActionType(input.toolName);
if (!actionType) return null;

const actor = {
skill: {
id: initiatingSkill || 'claude-code-session',
source: initiatingSkill || 'claude-code',
version_ref: '0.0.0',
artifact_hash: '',
},
const skill: SkillIdentity = {
id: initiatingSkill || 'claude-code-session',
source: initiatingSkill || 'claude-code',
version_ref: '0.0.0',
artifact_hash: '',
};

const context = {
const context: ActionContext = {
session_id: input.sessionId || `hook-${Date.now()}`,
user_present: true,
env: 'prod' as const,
env: 'prod',
time: new Date().toISOString(),
initiating_skill: initiatingSkill || undefined,
};

// Build action data based on type
let actionData: Record<string, unknown>;

switch (actionType) {
case 'exec_command':
actionData = {
command: (input.toolInput.command as string) || '',
args: [],
cwd: input.cwd,
return {
actor: { skill },
action: {
type: actionType,
data: {
command: getString(input.toolInput as Record<string, unknown>, 'command'),
args: [],
cwd: input.cwd,
},
},
context,
};
break;

case 'write_file':
actionData = {
path: (input.toolInput.file_path as string) || '',
return {
actor: { skill },
action: {
type: actionType,
data: {
path: getString(input.toolInput as Record<string, unknown>, 'file_path'),
},
},
context,
};

case 'read_file':
return {
actor: { skill },
action: {
type: actionType,
data: {
path: getString(input.toolInput as Record<string, unknown>, 'file_path'),
},
},
context,
};
break;

case 'network_request':
actionData = {
method: 'GET',
url: (input.toolInput.url as string) || (input.toolInput.query as string) || '',
case 'network_request': {
const ti = input.toolInput as Record<string, unknown>;
return {
actor: { skill },
action: {
type: actionType,
data: {
method: 'GET' as const,
url: getString(ti, 'url') || getString(ti, 'query'),
},
},
context,
};
break;
}

default:
return null;
}

return {
actor,
action: { type: actionType, data: actionData },
context,
} as unknown as ActionEnvelope;
}

async inferInitiatingSkill(input: HookInput): Promise<string | null> {
const data = input.raw as Record<string, unknown>;
const transcriptPath = data.transcript_path as string | undefined;
const data = (input.raw !== null && typeof input.raw === 'object')
? input.raw as Record<string, unknown>
: {};
const transcriptPath = getString(data, 'transcript_path');
if (!transcriptPath) return null;

let fd: number | null = null;
try {
const fd = openSync(transcriptPath, 'r');
fd = openSync(transcriptPath, 'r');
const stat = fstatSync(fd);
const TAIL_SIZE = 4096;
const start = Math.max(0, stat.size - TAIL_SIZE);
const buf = Buffer.alloc(Math.min(TAIL_SIZE, stat.size));
readSync(fd, buf, 0, buf.length, start);
closeSync(fd);
fd = null;

const tail = buf.toString('utf-8');
const lines = tail.split('\n').filter(Boolean);

for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (entry.type === 'tool_use' && entry.name === 'Skill' && entry.input?.skill) {
if (entry.type === 'tool_use' && entry.name === 'Skill' && typeof entry.input?.skill === 'string') {
return entry.input.skill;
}
if (entry.role === 'assistant' && Array.isArray(entry.content)) {
for (const block of entry.content) {
if (block.type === 'tool_use' && block.name === 'Skill' && block.input?.skill) {
if (block.type === 'tool_use' && block.name === 'Skill' && typeof block.input?.skill === 'string') {
return block.input.skill;
}
}
}
} catch {
// Not valid JSON
// Not valid JSON line — skip
}
}
} catch {
// Can't read transcript
} finally {
// Ensure file descriptor is always closed
if (fd !== null) {
try { closeSync(fd); } catch { /* ignore */ }
}
}
return null;
}
Expand Down
Loading