Add initial project files and configurations for clawd_code

This commit is contained in:
ImBenji
2026-04-03 17:48:07 +01:00
parent 7541a5279b
commit c88a1badc7
273 changed files with 28339 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import 'dart:convert';
import 'hook_types.dart';
/// Context passed to hooks for inspection/modification
class HookContext {
/// The kind of hook being executed
final HookKind kind;
/// Name of the target (tool, command, etc)
final String? targetName;
/// tool/command input as JSON
final Map<String, dynamic>? input;
/// Tool output (for post-tool hooks)
final dynamic output;
/// Exit code from command
final int? exitCode;
/// Environment variables available to hook
final Map<String, String> environment;
/// Additional context about the operation
final Map<String, dynamic> metadata;
HookContext({
required this.kind,
this.targetName,
this.input,
this.output,
this.exitCode,
Map<String, String>? environment,
Map<String, dynamic>? metadata,
}) : environment = environment ?? {},
metadata = metadata ?? {};
/// Convenient method to get display name
String get kindName => kind.displayName;
/// Convert context to JSON for passing to shell commands
String toJsonString() {
return jsonEncode({
'hook_kind': kindName,
if (targetName != null) 'target': targetName,
if (input != null) 'input': input,
if (output != null) 'output': output,
if (exitCode != null) 'exit_code': exitCode,
...metadata,
});
}
}
/// Result returned from executing a hook
class HookResult {
/// Whether the hook completed successfully
final bool success;
/// Output from the hook
final String? stdout;
final String? stderr;
/// Exit code (for command hooks)
final int? exitCode;
/// Whether hook wants to continue or block (if applicable)
final bool? shouldContinue;
/// Custom message to display
final String? message;
/// Hook-specific output for processing
final Map<String, dynamic>? hookOutput;
HookResult({
required this.success,
this.stdout,
this.stderr,
this.exitCode,
this.shouldContinue,
this.message,
this.hookOutput,
});
/// Parse hook output JSON (for structured responses)
static HookResult fromJson(
String jsonStr, {
required int exitCode,
required String stdout,
required String stderr,
}) {
try {
final parsed = jsonDecode(jsonStr) as Map<String, dynamic>;
return HookResult(
success: exitCode == 0,
stdout: stdout,
stderr: stderr,
exitCode: exitCode,
shouldContinue: parsed['continue'] as bool? ?? true,
message: parsed['message'] as String?,
hookOutput: parsed,
);
} catch (e) {
return HookResult(
success: exitCode == 0,
stdout: stdout,
stderr: stderr,
exitCode: exitCode,
);
}
}
String get displayOutput => stdout ?? stderr ?? '';
}
+260
View File
@@ -0,0 +1,260 @@
import 'dart:convert';
import 'dart:io';
import 'hook_types.dart';
/// Loads hook configuration from ~/.claude/hooks.yaml or hooks.json
class HookLoader {
/// Load hooks from config files
static Future<List<HookSpec>> loadHooks() async {
final homeDir = Platform.environment['HOME'];
if (homeDir == null) {
return [];
}
final claudeDir = Directory('$homeDir/.claude');
final hooks = <HookSpec>[];
// Try JSON first
final jsonFile = File('${claudeDir.path}/hooks.json');
if (jsonFile.existsSync()) {
try {
final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>;
hooks.addAll(_parseHooksFromConfig(data));
return hooks;
} catch (e) {
print('error loading hooks.json: $e');
}
}
// Try YAML (basic parsing without external deps)
final yamlFile = File('${claudeDir.path}/hooks.yaml');
if (yamlFile.existsSync()) {
try {
final content = await yamlFile.readAsString();
// Basic YAML-to-JSON conversion for hooks config
hooks.addAll(_parseHooksFromYaml(content));
return hooks;
} catch (e) {
print('error loading hooks.yaml: $e');
}
}
return hooks;
}
/// Parse hooks from JSON config
static List<HookSpec> _parseHooksFromConfig(Map<String, dynamic> config) {
final hooks = <HookSpec>[];
config.forEach((eventName, matchers) {
final kind = _hookKindFromString(eventName);
if (kind == null) return;
if (matchers is! List) return;
for (final matcher in matchers) {
if (matcher is! Map<String, dynamic>) continue;
final matcherString = matcher['matcher'] as String?;
final hooksList = matcher['hooks'] as List?;
if (hooksList == null) continue;
for (final hookData in hooksList) {
if (hookData is! Map<String, dynamic>) continue;
try {
final hook = _parseHookCommand(hookData);
if (hook != null) {
hooks.add(HookSpec(
kind: kind,
command: hook,
target: matcherString,
));
}
} catch (e) {
print('error parsing hook: $e');
}
}
}
});
return hooks;
}
/// Parse hooks from YAML content (basic parser)
static List<HookSpec> _parseHooksFromYaml(String content) {
// this is a simplistic parser for basic hook YAML
// for complex YAML, users should use hooks.json instead
final hooks = <HookSpec>[];
// quick and dirty YAML parsing
// look for pattern: EventName: and then " - matcher:" blocks
final lines = content.split('\n');
HookKind? currentKind;
String? currentMatcher;
for (int i = 0; i < lines.length; i++) {
final line = lines[i];
final trimmed = line.trim();
// Check for hook event (no leading spaces)
if (!line.startsWith(' ')) {
final kindMatch = trimmed.split(':')[0];
currentKind = _hookKindFromString(kindMatch);
currentMatcher = null;
continue;
}
if (currentKind == null) continue;
// Check for matcher
if (trimmed.startsWith('- matcher:')) {
currentMatcher = trimmed.substring(10).trim();
continue;
}
// Check for hooks array
if (trimmed.startsWith('hooks:')) {
// next non-empty indented lines are hooks
i++;
while (i < lines.length) {
final hookLine = lines[i];
if (!hookLine.startsWith(' ')) break;
final hookTrimmed = hookLine.trim();
if (hookTrimmed.startsWith('- type:')) {
// parse individual hook
final hookBlock = <String>[];
hookBlock.add(hookTrimmed);
// collect remaining lines for this hook
i++;
while (i < lines.length) {
final nextLine = lines[i];
if (nextLine.trim().isEmpty) {
i++;
continue;
}
if (!nextLine.startsWith(' ')) break;
hookBlock.add(nextLine.trim());
i++;
}
i--; // back up one since the outer loop will increment
try {
final hook = _parseHookFromYamlBlock(hookBlock);
if (hook != null) {
hooks.add(HookSpec(
kind: currentKind,
command: hook,
target: currentMatcher,
));
}
} catch (e) {
print('error parsing YAML hook: $e');
}
} else {
i++;
}
}
i--;
}
}
return hooks;
}
/// Parse single hook from YAML block lines
static HookCommand? _parseHookFromYamlBlock(List<String> lines) {
final map = <String, dynamic>{};
for (final line in lines) {
final colonIndex = line.indexOf(':');
if (colonIndex == -1) continue;
final key = line.substring(0, colonIndex).trim();
final value = line.substring(colonIndex + 1).trim();
// handle basic value types
if (value == 'true' || value == 'false') {
map[key] = value == 'true';
} else if (value.startsWith('[') && value.endsWith(']')) {
// simple array parsing
final items = value
.substring(1, value.length - 1)
.split(',')
.map((s) => s.trim())
.toList();
map[key] = items;
} else {
// remove quotes if present
String stringValue = value;
if ((stringValue.startsWith('"') && stringValue.endsWith('"')) ||
(stringValue.startsWith("'") && stringValue.endsWith("'"))) {
stringValue = stringValue.substring(1, stringValue.length - 1);
}
// try to parse as number
final numValue = num.tryParse(stringValue);
map[key] = numValue ?? stringValue;
}
}
return _parseHookCommand(map);
}
/// Parse a single hook command from a map
static HookCommand? _parseHookCommand(Map<String, dynamic> data) {
final type = data['type'] as String?;
switch (type) {
case 'command':
return BashCommandHook.fromMap(data);
case 'prompt':
return PromptHook.fromMap(data);
case 'http':
return HttpHook.fromMap(data);
case 'agent':
return AgentHook.fromMap(data);
default:
return null;
}
}
/// Convert hook kind string to enum
static HookKind? _hookKindFromString(String name) {
const mapping = {
'PreToolUse': HookKind.preToolUse,
'PostToolUse': HookKind.postToolUse,
'PostToolUseFailure': HookKind.postToolUseFailure,
'PermissionDenied': HookKind.permissionDenied,
'Notification': HookKind.notification,
'UserPromptSubmit': HookKind.userPromptSubmit,
'SessionStart': HookKind.sessionStart,
'SessionEnd': HookKind.sessionEnd,
'Stop': HookKind.stop,
'StopFailure': HookKind.stopFailure,
'SubagentStart': HookKind.subagentStart,
'SubagentStop': HookKind.subagentStop,
'PreCompact': HookKind.preCompact,
'PostCompact': HookKind.postCompact,
'PermissionRequest': HookKind.permissionRequest,
'Setup': HookKind.setup,
'TeammateIdle': HookKind.teammateIdle,
'TaskCreated': HookKind.taskCreated,
'TaskCompleted': HookKind.taskCompleted,
'Elicitation': HookKind.elicitation,
'ElicitationResult': HookKind.elicitationResult,
'ConfigChange': HookKind.configChange,
'InstructionsLoaded': HookKind.instructionsLoaded,
'WorktreeCreate': HookKind.worktreeCreate,
'WorktreeRemove': HookKind.worktreeRemove,
'CwdChanged': HookKind.cwdChanged,
'FileChanged': HookKind.fileChanged,
};
return mapping[name];
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'dart:convert';
import 'dart:io';
import 'hook_context.dart';
import 'hook_types.dart';
/// Executes hooks based on kind and optional target filtering
class HookRunner {
static const _defaultShell = 'bash';
static const _defaultTimeoutSecs = 10 * 60;
final List<HookSpec> hooks;
HookRunner({required this.hooks});
/// Run hooks matching the given kind and optional target
Future<List<HookResult>> runHooksForKind(
HookKind kind, {
String? targetName,
Map<String, dynamic>? input,
dynamic output,
int? exitCode,
Map<String, String>? environment,
Map<String, dynamic>? metadata,
}) async {
final results = <HookResult>[];
// Filter hooks by kind and optional target
final matchingHooks = hooks.where((h) {
if (h.kind != kind) return false;
// If target is required, filter by target
if (targetName != null && h.target != null) {
return h.target == targetName;
}
return true;
}).toList();
for (final hookSpec in matchingHooks) {
// Evaluate condition if present
if (hookSpec.command.ifCondition != null) {
if (!_evaluateCondition(hookSpec.command.ifCondition!)) {
continue;
}
}
final context = HookContext(
kind: kind,
targetName: targetName,
input: input,
output: output,
exitCode: exitCode,
environment: environment,
metadata: metadata,
);
try {
final result = await _executeHook(hookSpec, context);
results.add(result);
// If hook wants to stop, break
if (result.hookOutput != null) {
final shouldContinue = result.hookOutput!['continue'] as bool? ?? true;
if (!shouldContinue) {
break;
}
}
} catch (e) {
print('error executing hook: $e');
results.add(HookResult(
success: false,
stderr: 'error: $e',
exitCode: 1,
));
}
}
return results;
}
/// Execute a single hook
Future<HookResult> _executeHook(HookSpec spec, HookContext context) async {
final command = spec.command;
if (command is BashCommandHook) {
return _executeBashHook(command, context);
} else if (command is HttpHook) {
return _executeHttpHook(command, context);
} else if (command is PromptHook) {
// prompt hooks not implemented yet in CLI version
return HookResult(success: true);
} else if (command is AgentHook) {
// agent hooks not implemented yet in CLI version
return HookResult(success: true);
}
return HookResult(success: false, stderr: 'unsupported hook type');
}
/// Execute a bash command hook
Future<HookResult> _executeBashHook(
BashCommandHook hook,
HookContext context,
) async {
final shell = hook.shell ?? _defaultShell;
final timeout =
Duration(seconds: (hook.timeout?.toInt() ?? _defaultTimeoutSecs));
// prepare environment
final env = Map<String, String>.from(Platform.environment);
env.addAll(context.environment);
// Pass context as JSON via environment
env['HOOK_INPUT'] = context.toJsonString();
try {
final process = await Process.start(
shell,
['-c', hook.command],
environment: env,
workingDirectory: Directory.current.path,
);
// Capture output
final stdout = StringBuffer();
final stderr = StringBuffer();
process.stdout.transform(utf8.decoder).listen((data) {
stdout.write(data);
});
process.stderr.transform(utf8.decoder).listen((data) {
stderr.write(data);
});
// Wait for process with timeout
final exitCode = await process.exitCode.timeout(
timeout,
onTimeout: () {
process.kill();
return 124; // timeout exit code
},
);
final stdoutStr = stdout.toString();
final stderrStr = stderr.toString();
return HookResult.fromJson(
stdoutStr,
exitCode: exitCode,
stdout: stdoutStr,
stderr: stderrStr,
);
} catch (e) {
return HookResult(
success: false,
stderr: 'failed to execute hook: $e',
exitCode: 1,
);
}
}
/// Execute an HTTP hook
Future<HookResult> _executeHttpHook(
HttpHook hook,
HookContext context,
) async {
try {
final client = HttpClient();
final timeout = Duration(
seconds: (hook.timeout?.toInt() ?? _defaultTimeoutSecs),
);
final request = await client.postUrl(Uri.parse(hook.url)).timeout(timeout);
// add headers
if (hook.headers != null) {
hook.headers!.forEach((key, value) {
// interpolate env vars if allowed
String finalValue = value;
if (hook.allowedEnvVars != null) {
for (final varName in hook.allowedEnvVars!) {
final pattern = RegExp(r'\$\{?$varName\}?');
final envValue = Platform.environment[varName] ?? '';
finalValue = finalValue.replaceAll(pattern, envValue);
}
}
request.headers.set(key, finalValue);
});
}
request.headers.set('Content-Type', 'application/json');
// send context as JSON body
request.write(context.toJsonString());
final response = await request.close().timeout(timeout);
final responseBody = await response.transform(utf8.decoder).join();
return HookResult(
success: response.statusCode == 200,
stdout: responseBody,
exitCode: response.statusCode,
);
} catch (e) {
return HookResult(
success: false,
stderr: 'HTTP hook failed: $e',
exitCode: 1,
);
}
}
/// Evaluate condition expression (simple placeholder implementation)
/// Full implementation would parse permission rule syntax
bool _evaluateCondition(String condition) {
// basic placeholder: if condition exists and is non-empty, evaluate to true
// full implementation should parse pattern like "Bash(git *)" or "Read(*.ts)"
return condition.isNotEmpty;
}
}
+261
View File
@@ -0,0 +1,261 @@
enum HookKind {
preToolUse,
postToolUse,
postToolUseFailure,
permissionDenied,
notification,
userPromptSubmit,
sessionStart,
sessionEnd,
stop,
stopFailure,
subagentStart,
subagentStop,
preCompact,
postCompact,
permissionRequest,
setup,
teammateIdle,
taskCreated,
taskCompleted,
elicitation,
elicitationResult,
configChange,
instructionsLoaded,
worktreeCreate,
worktreeRemove,
cwdChanged,
fileChanged,
}
extension HookKindExt on HookKind {
String get displayName {
const names = {
HookKind.preToolUse: 'PreToolUse',
HookKind.postToolUse: 'PostToolUse',
HookKind.postToolUseFailure: 'PostToolUseFailure',
HookKind.permissionDenied: 'PermissionDenied',
HookKind.notification: 'Notification',
HookKind.userPromptSubmit: 'UserPromptSubmit',
HookKind.sessionStart: 'SessionStart',
HookKind.sessionEnd: 'SessionEnd',
HookKind.stop: 'Stop',
HookKind.stopFailure: 'StopFailure',
HookKind.subagentStart: 'SubagentStart',
HookKind.subagentStop: 'SubagentStop',
HookKind.preCompact: 'PreCompact',
HookKind.postCompact: 'PostCompact',
HookKind.permissionRequest: 'PermissionRequest',
HookKind.setup: 'Setup',
HookKind.teammateIdle: 'TeammateIdle',
HookKind.taskCreated: 'TaskCreated',
HookKind.taskCompleted: 'TaskCompleted',
HookKind.elicitation: 'Elicitation',
HookKind.elicitationResult: 'ElicitationResult',
HookKind.configChange: 'ConfigChange',
HookKind.instructionsLoaded: 'InstructionsLoaded',
HookKind.worktreeCreate: 'WorktreeCreate',
HookKind.worktreeRemove: 'WorktreeRemove',
HookKind.cwdChanged: 'CwdChanged',
HookKind.fileChanged: 'FileChanged',
};
return names[this] ?? 'Unknown';
}
}
// hook command types
sealed class HookCommand {
final String? ifCondition;
final double? timeout;
final String? statusMessage;
final bool? once;
HookCommand({
this.ifCondition,
this.timeout,
this.statusMessage,
this.once,
});
}
class BashCommandHook extends HookCommand {
final String command;
final String? shell;
final bool? async;
final bool? asyncRewake;
BashCommandHook({
required this.command,
this.shell,
this.async,
this.asyncRewake,
super.ifCondition,
super.timeout,
super.statusMessage,
super.once,
});
factory BashCommandHook.fromMap(Map<String, dynamic> map) {
return BashCommandHook(
command: map['command'] as String,
shell: map['shell'] as String?,
async: map['async'] as bool?,
asyncRewake: map['asyncRewake'] as bool?,
ifCondition: map['if'] as String?,
timeout: (map['timeout'] as num?)?.toDouble(),
statusMessage: map['statusMessage'] as String?,
once: map['once'] as bool?,
);
}
Map<String, dynamic> toMap() => {
'type': 'command',
'command': command,
if (shell != null) 'shell': shell,
if (async != null) 'async': async,
if (asyncRewake != null) 'asyncRewake': asyncRewake,
if (ifCondition != null) 'if': ifCondition,
if (timeout != null) 'timeout': timeout,
if (statusMessage != null) 'statusMessage': statusMessage,
if (once != null) 'once': once,
};
}
class PromptHook extends HookCommand {
final String prompt;
final String? model;
PromptHook({
required this.prompt,
this.model,
super.ifCondition,
super.timeout,
super.statusMessage,
super.once,
});
factory PromptHook.fromMap(Map<String, dynamic> map) {
return PromptHook(
prompt: map['prompt'] as String,
model: map['model'] as String?,
ifCondition: map['if'] as String?,
timeout: (map['timeout'] as num?)?.toDouble(),
statusMessage: map['statusMessage'] as String?,
once: map['once'] as bool?,
);
}
Map<String, dynamic> toMap() => {
'type': 'prompt',
'prompt': prompt,
if (model != null) 'model': model,
if (ifCondition != null) 'if': ifCondition,
if (timeout != null) 'timeout': timeout,
if (statusMessage != null) 'statusMessage': statusMessage,
if (once != null) 'once': once,
};
}
class HttpHook extends HookCommand {
final String url;
final Map<String, String>? headers;
final List<String>? allowedEnvVars;
HttpHook({
required this.url,
this.headers,
this.allowedEnvVars,
super.ifCondition,
super.timeout,
super.statusMessage,
super.once,
});
factory HttpHook.fromMap(Map<String, dynamic> map) {
return HttpHook(
url: map['url'] as String,
headers: (map['headers'] as Map<String, dynamic>?)?.cast<String, String>(),
allowedEnvVars: (map['allowedEnvVars'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
ifCondition: map['if'] as String?,
timeout: (map['timeout'] as num?)?.toDouble(),
statusMessage: map['statusMessage'] as String?,
once: map['once'] as bool?,
);
}
Map<String, dynamic> toMap() => {
'type': 'http',
'url': url,
if (headers != null) 'headers': headers,
if (allowedEnvVars != null) 'allowedEnvVars': allowedEnvVars,
if (ifCondition != null) 'if': ifCondition,
if (timeout != null) 'timeout': timeout,
if (statusMessage != null) 'statusMessage': statusMessage,
if (once != null) 'once': once,
};
}
class AgentHook extends HookCommand {
final String prompt;
final String? model;
AgentHook({
required this.prompt,
this.model,
super.ifCondition,
super.timeout,
super.statusMessage,
super.once,
});
factory AgentHook.fromMap(Map<String, dynamic> map) {
return AgentHook(
prompt: map['prompt'] as String,
model: map['model'] as String?,
ifCondition: map['if'] as String?,
timeout: (map['timeout'] as num?)?.toDouble(),
statusMessage: map['statusMessage'] as String?,
once: map['once'] as bool?,
);
}
Map<String, dynamic> toMap() => {
'type': 'agent',
'prompt': prompt,
if (model != null) 'model': model,
if (ifCondition != null) 'if': ifCondition,
if (timeout != null) 'timeout': timeout,
if (statusMessage != null) 'statusMessage': statusMessage,
if (once != null) 'once': once,
};
}
// hook spec represents a parsed hook configuration
class HookSpec {
final HookKind kind;
final HookCommand command;
final String? target; // for hooks with matchers (tool_name, notification_type, etc)
HookSpec({
required this.kind,
required this.command,
this.target,
});
String getDisplayText() {
if (command is BashCommandHook) {
return (command as BashCommandHook).command;
} else if (command is PromptHook) {
return (command as PromptHook).prompt;
} else if (command is AgentHook) {
return (command as AgentHook).prompt;
} else if (command is HttpHook) {
return (command as HttpHook).url;
}
return command.statusMessage ?? 'unknown';
}
}