mirror of
https://github.com/davidkaya/aryx.git
synced 2026-09-13 21:31:56 +02:00
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
212 lines
7.4 KiB
C#
212 lines
7.4 KiB
C#
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<ResolvedHookSet> 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<HookCommandDefinition> sessionStart = [];
|
|
List<HookCommandDefinition> sessionEnd = [];
|
|
List<HookCommandDefinition> userPromptSubmitted = [];
|
|
List<HookCommandDefinition> preToolUse = [];
|
|
List<HookCommandDefinition> postToolUse = [];
|
|
List<HookCommandDefinition> 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<HookCommandDefinition> target,
|
|
IReadOnlyList<HookCommandDefinition>? 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<string, string>? 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<HookConfigFile?> ReadHookConfigAsync(string hookFile, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await using FileStream stream = File.OpenRead(hookFile);
|
|
return await JsonSerializer.DeserializeAsync<HookConfigFile>(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<string, string>? NormalizeEnvironment(IReadOnlyDictionary<string, string>? environment)
|
|
{
|
|
if (environment is not { Count: > 0 })
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Dictionary<string, string> 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;
|
|
}
|
|
}
|