using System.Text.Json; using Aryx.AgentHost.Contracts; namespace Aryx.AgentHost.Services; internal static class HookConfigLoader { private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); public static async Task LoadAsync(string projectPath, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); string hooksDirectory = Path.Combine(projectPath, ".github", "hooks"); if (!Directory.Exists(hooksDirectory)) { return ResolvedHookSet.Empty; } string[] hookFiles; try { hookFiles = Directory.GetFiles(hooksDirectory, "*.json", SearchOption.TopDirectoryOnly); } catch (IOException exception) { Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}"); return ResolvedHookSet.Empty; } catch (UnauthorizedAccessException exception) { Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}"); return ResolvedHookSet.Empty; } if (hookFiles.Length == 0) { return ResolvedHookSet.Empty; } Array.Sort(hookFiles, StringComparer.OrdinalIgnoreCase); List sessionStart = []; List sessionEnd = []; List userPromptSubmitted = []; List preToolUse = []; List postToolUse = []; List errorOccurred = []; foreach (string hookFile in hookFiles) { HookConfigFile? config = await ReadHookConfigAsync(hookFile, cancellationToken).ConfigureAwait(false); if (config is null) { continue; } if (config.Version != 1) { Console.Error.WriteLine($"[aryx hooks] Skipping '{hookFile}' because it declares unsupported version '{config.Version}'."); continue; } AddHooks(sessionStart, config.Hooks.SessionStart, HookTypeNames.SessionStart, hookFile); AddHooks(sessionEnd, config.Hooks.SessionEnd, HookTypeNames.SessionEnd, hookFile); AddHooks(userPromptSubmitted, config.Hooks.UserPromptSubmitted, HookTypeNames.UserPromptSubmitted, hookFile); AddHooks(preToolUse, config.Hooks.PreToolUse, HookTypeNames.PreToolUse, hookFile); AddHooks(postToolUse, config.Hooks.PostToolUse, HookTypeNames.PostToolUse, hookFile); AddHooks(errorOccurred, config.Hooks.ErrorOccurred, HookTypeNames.ErrorOccurred, hookFile); } if ( sessionStart.Count == 0 && sessionEnd.Count == 0 && userPromptSubmitted.Count == 0 && preToolUse.Count == 0 && postToolUse.Count == 0 && errorOccurred.Count == 0) { return ResolvedHookSet.Empty; } return new ResolvedHookSet { SessionStart = [.. sessionStart], SessionEnd = [.. sessionEnd], UserPromptSubmitted = [.. userPromptSubmitted], PreToolUse = [.. preToolUse], PostToolUse = [.. postToolUse], ErrorOccurred = [.. errorOccurred], }; } private static void AddHooks( ICollection target, IReadOnlyList? definitions, string hookType, string hookFile) { if (definitions is not { Count: > 0 }) { return; } foreach (HookCommandDefinition definition in definitions) { HookCommandDefinition? normalized = NormalizeDefinition(definition, hookType, hookFile); if (normalized is not null) { target.Add(normalized); } } } private static HookCommandDefinition? NormalizeDefinition( HookCommandDefinition definition, string hookType, string hookFile) { string type = NormalizeOptionalString(definition.Type) ?? string.Empty; if (!string.Equals(type, "command", StringComparison.OrdinalIgnoreCase)) { Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because type '{definition.Type}' is unsupported."); return null; } string? bash = NormalizeOptionalString(definition.Bash); string? powerShell = NormalizeOptionalString(definition.PowerShell); if (bash is null && powerShell is null) { Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because no shell command is configured."); return null; } int? timeoutSec = definition.TimeoutSec; if (timeoutSec is <= 0) { timeoutSec = null; } IReadOnlyDictionary? env = NormalizeEnvironment(definition.Env); return new HookCommandDefinition { Type = "command", Bash = bash, PowerShell = powerShell, Cwd = NormalizeOptionalString(definition.Cwd), Env = env, TimeoutSec = timeoutSec, }; } private static async Task ReadHookConfigAsync(string hookFile, CancellationToken cancellationToken) { try { await using FileStream stream = File.OpenRead(hookFile); return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException exception) { Console.Error.WriteLine($"[aryx hooks] Failed to parse '{hookFile}': {exception.Message}"); return null; } catch (IOException exception) { Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}"); return null; } catch (UnauthorizedAccessException exception) { Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}"); return null; } } private static IReadOnlyDictionary? NormalizeEnvironment(IReadOnlyDictionary? environment) { if (environment is not { Count: > 0 }) { return null; } Dictionary normalized = new(StringComparer.Ordinal); foreach ((string key, string value) in environment) { string? normalizedKey = NormalizeOptionalString(key); if (normalizedKey is null) { continue; } normalized[normalizedKey] = value; } return normalized.Count == 0 ? null : normalized; } private static string? NormalizeOptionalString(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static JsonSerializerOptions CreateJsonOptions() { JsonSerializerOptions options = JsonSerialization.CreateWebOptions(); options.AllowTrailingCommas = true; options.PropertyNameCaseInsensitive = true; options.ReadCommentHandling = JsonCommentHandling.Skip; return options; } }