feat: support project hook commands

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 13:23:01 +01:00
co-authored by Copilot
parent f459cc7291
commit dff97efd58
10 changed files with 1428 additions and 13 deletions
@@ -0,0 +1,220 @@
using System.ComponentModel;
using System.Diagnostics;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal interface IHookCommandRunner
{
Task<string?> RunAsync(
HookCommandDefinition hook,
string inputJson,
string projectPath,
CancellationToken cancellationToken);
}
internal sealed class HookCommandRunner : IHookCommandRunner
{
private const int DefaultTimeoutSeconds = 30;
public static HookCommandRunner Instance { get; } = new();
public async Task<string?> RunAsync(
HookCommandDefinition hook,
string inputJson,
string projectPath,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(hook);
ArgumentNullException.ThrowIfNull(inputJson);
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
string? commandText = SelectCommandText(hook);
if (commandText is null)
{
Console.Error.WriteLine("[aryx hooks] Skipping hook because no compatible shell command is configured for this platform.");
return null;
}
string workingDirectory = ResolveWorkingDirectory(projectPath, hook.Cwd);
ProcessStartInfo startInfo = CreateStartInfo(commandText, workingDirectory);
ApplyEnvironment(startInfo, hook.Env);
using Process process = new()
{
StartInfo = startInfo,
};
try
{
if (!process.Start())
{
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}'.");
return null;
}
}
catch (Win32Exception exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
return null;
}
catch (InvalidOperationException exception)
{
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
return null;
}
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync();
Task<string> stderrTask = process.StandardError.ReadToEndAsync();
try
{
await process.StandardInput.WriteAsync(inputJson).ConfigureAwait(false);
await process.StandardInput.FlushAsync().ConfigureAwait(false);
process.StandardInput.Close();
}
catch (IOException exception)
{
TryKillProcess(process);
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
return null;
}
catch (ObjectDisposedException exception)
{
TryKillProcess(process);
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
return null;
}
TimeSpan timeout = TimeSpan.FromSeconds(hook.TimeoutSec ?? DefaultTimeoutSeconds);
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
TryKillProcess(process);
await DrainOutputAsync(process, stdoutTask, stderrTask).ConfigureAwait(false);
Console.Error.WriteLine($"[aryx hooks] Hook command timed out after {(int)timeout.TotalSeconds} seconds: '{commandText}'.");
return null;
}
string stdout = await stdoutTask.ConfigureAwait(false);
string stderr = await stderrTask.ConfigureAwait(false);
if (process.ExitCode != 0)
{
string detail = string.IsNullOrWhiteSpace(stderr) ? $"exit code {process.ExitCode}" : stderr.Trim();
Console.Error.WriteLine($"[aryx hooks] Hook command failed for '{commandText}': {detail}");
return null;
}
return stdout;
}
private static async Task DrainOutputAsync(Process process, Task<string> stdoutTask, Task<string> stderrTask)
{
try
{
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (InvalidOperationException)
{
// Process already exited or could not be waited on.
}
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
}
private static ProcessStartInfo CreateStartInfo(string commandText, string workingDirectory)
{
ProcessStartInfo startInfo = new()
{
WorkingDirectory = workingDirectory,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
if (OperatingSystem.IsWindows())
{
startInfo.FileName = "powershell.exe";
startInfo.ArgumentList.Add("-NoLogo");
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-NonInteractive");
startInfo.ArgumentList.Add("-ExecutionPolicy");
startInfo.ArgumentList.Add("Bypass");
startInfo.ArgumentList.Add("-Command");
startInfo.ArgumentList.Add(commandText);
return startInfo;
}
startInfo.FileName = "bash";
startInfo.ArgumentList.Add("-lc");
startInfo.ArgumentList.Add(commandText);
return startInfo;
}
private static void ApplyEnvironment(ProcessStartInfo startInfo, IReadOnlyDictionary<string, string>? environment)
{
if (environment is not { Count: > 0 })
{
return;
}
foreach ((string key, string value) in environment)
{
startInfo.Environment[key] = value;
}
}
private static string ResolveWorkingDirectory(string projectPath, string? configuredCwd)
{
if (string.IsNullOrWhiteSpace(configuredCwd))
{
return Path.GetFullPath(projectPath);
}
string resolved = Path.IsPathRooted(configuredCwd)
? configuredCwd
: Path.Combine(projectPath, configuredCwd);
return Path.GetFullPath(resolved);
}
private static string? SelectCommandText(HookCommandDefinition hook)
{
if (OperatingSystem.IsWindows())
{
return NormalizeOptionalString(hook.PowerShell);
}
return NormalizeOptionalString(hook.Bash);
}
private static void TryKillProcess(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
// Process already exited.
}
catch (NotSupportedException)
{
// The platform does not support process tree termination.
}
}
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}