223 lines
9.4 KiB
C#
223 lines
9.4 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
using System.Windows.Threading;
|
|
|
|
namespace QuickClaude
|
|
{
|
|
// QuickClaude.exe --confirm: Claude Code's PreToolUse hook for shell commands.
|
|
// Reads the hook JSON from stdin, asks the user, prints an allow/deny decision.
|
|
public static class ConfirmHook
|
|
{
|
|
public static int Run()
|
|
{
|
|
string toolName = "Bash", command = null, cwd = null;
|
|
try
|
|
{
|
|
using var stdin = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
|
|
using var doc = JsonDocument.Parse(stdin.ReadToEnd());
|
|
var root = doc.RootElement;
|
|
toolName = Str(root, "tool_name") ?? toolName;
|
|
cwd = Str(root, "cwd");
|
|
if (root.TryGetProperty("tool_input", out var input)) command = Str(input, "command");
|
|
}
|
|
catch { }
|
|
|
|
// Nothing to show: say nothing, so Claude Code's normal permission rules decide
|
|
if (string.IsNullOrEmpty(command)) return 0;
|
|
|
|
new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
|
|
var win = new ConfirmWindow(toolName, command, cwd);
|
|
win.ShowDialog();
|
|
|
|
string decision = win.Allowed ? "allow" : "deny";
|
|
string reason = win.Allowed ? "The user allowed this command."
|
|
: win.TimedOut ? "The user didn't answer within 2 minutes, so the command was not run. Carry on without it."
|
|
: "The user declined to run this command. Carry on without it.";
|
|
|
|
var json = JsonSerializer.Serialize(new
|
|
{
|
|
hookSpecificOutput = new
|
|
{
|
|
hookEventName = "PreToolUse",
|
|
permissionDecision = decision,
|
|
permissionDecisionReason = reason
|
|
}
|
|
});
|
|
using var stdout = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false));
|
|
stdout.Write(json);
|
|
return 0;
|
|
}
|
|
|
|
static string Str(JsonElement e, string name) =>
|
|
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
|
}
|
|
|
|
public class ConfirmWindow : Window
|
|
{
|
|
public bool Allowed { get; private set; }
|
|
public bool TimedOut { get; private set; }
|
|
|
|
static readonly TimeSpan Limit = TimeSpan.FromMinutes(2);
|
|
internal static readonly Brush Text = Brushes.White;
|
|
internal static readonly Brush Faint = new SolidColorBrush(Color.FromArgb(140, 255, 255, 255));
|
|
internal static readonly Brush Panel = new SolidColorBrush(Color.FromArgb(225, 20, 20, 20));
|
|
internal static readonly Brush Edge = new SolidColorBrush(Color.FromArgb(70, 255, 255, 255));
|
|
internal static readonly Brush EdgeBright = new SolidColorBrush(Color.FromArgb(200, 255, 255, 255));
|
|
internal static readonly Brush Hover = new SolidColorBrush(Color.FromArgb(40, 255, 255, 255));
|
|
internal static readonly Brush Press = new SolidColorBrush(Color.FromArgb(70, 255, 255, 255));
|
|
|
|
readonly DispatcherTimer timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
|
readonly DateTime deadline = DateTime.UtcNow + Limit;
|
|
readonly TextBlock countdown = new TextBlock();
|
|
|
|
public ConfirmWindow(string toolName, string command, string cwd)
|
|
{
|
|
Title = "QuickClaude - run command?";
|
|
WindowStyle = WindowStyle.None;
|
|
AllowsTransparency = true;
|
|
Background = Brushes.Transparent;
|
|
ResizeMode = ResizeMode.NoResize;
|
|
Topmost = true;
|
|
ShowInTaskbar = true;
|
|
Width = 520;
|
|
SizeToContent = SizeToContent.Height;
|
|
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
|
FontFamily = MainWindow.Mono;
|
|
FontSize = MainWindow.TextSize;
|
|
Foreground = Text;
|
|
|
|
var header = new TextBlock
|
|
{
|
|
Text = "clod wants to run " + toolName + (string.IsNullOrEmpty(cwd) ? "" : " in " + cwd),
|
|
Foreground = Faint,
|
|
TextWrapping = TextWrapping.Wrap
|
|
};
|
|
|
|
// The exact command, selectable, scrolls if it's very long
|
|
var cmd = new TextBox
|
|
{
|
|
Text = command,
|
|
IsReadOnly = true,
|
|
Background = Brushes.Transparent,
|
|
Foreground = Text,
|
|
CaretBrush = Text,
|
|
BorderThickness = new Thickness(0),
|
|
Padding = new Thickness(0),
|
|
FocusVisualStyle = null,
|
|
FontFamily = MainWindow.Mono,
|
|
FontSize = MainWindow.TextSize,
|
|
TextWrapping = TextWrapping.Wrap,
|
|
MaxHeight = 320,
|
|
Margin = new Thickness(0, 8, 0, 8),
|
|
Template = MainWindow.BareTextBoxTemplate,
|
|
IsUndoEnabled = false
|
|
};
|
|
|
|
countdown.Foreground = Faint;
|
|
countdown.VerticalAlignment = VerticalAlignment.Center;
|
|
UpdateCountdown();
|
|
|
|
var deny = MakeButton("Deny");
|
|
var allow = MakeButton("Allow");
|
|
deny.Click += (s, e) => Finish(false);
|
|
allow.Click += (s, e) => Finish(true);
|
|
allow.Margin = new Thickness(8, 0, 0, 0);
|
|
|
|
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
|
buttons.Children.Add(deny);
|
|
buttons.Children.Add(allow);
|
|
|
|
var bottom = new Grid();
|
|
bottom.Children.Add(countdown);
|
|
bottom.Children.Add(buttons);
|
|
|
|
var stack = new StackPanel();
|
|
stack.Children.Add(header);
|
|
stack.Children.Add(cmd);
|
|
stack.Children.Add(bottom);
|
|
|
|
var frame = new Border
|
|
{
|
|
Background = Panel,
|
|
BorderBrush = Edge,
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(0),
|
|
Padding = new Thickness(12),
|
|
Child = stack
|
|
};
|
|
frame.MouseLeftButtonDown += (s, e) => { try { DragMove(); } catch { } };
|
|
Content = frame;
|
|
|
|
PreviewKeyDown += (s, e) => { if (e.Key == Key.Escape) { e.Handled = true; Finish(false); } };
|
|
timer.Tick += (s, e) =>
|
|
{
|
|
if (DateTime.UtcNow >= deadline) { TimedOut = true; Finish(false); }
|
|
else UpdateCountdown();
|
|
};
|
|
Loaded += (s, e) => { timer.Start(); Activate(); deny.Focus(); };
|
|
Closed += (s, e) => timer.Stop();
|
|
}
|
|
|
|
void UpdateCountdown()
|
|
{
|
|
var left = deadline - DateTime.UtcNow;
|
|
if (left < TimeSpan.Zero) left = TimeSpan.Zero;
|
|
countdown.Text = $"denies in {(int)left.TotalMinutes}:{left.Seconds:00}";
|
|
}
|
|
|
|
void Finish(bool allowed)
|
|
{
|
|
Allowed = allowed;
|
|
timer.Stop();
|
|
Close();
|
|
}
|
|
|
|
static Button MakeButton(string text) => new Button
|
|
{
|
|
Content = text,
|
|
Foreground = Text,
|
|
Background = Brushes.Transparent,
|
|
BorderBrush = Edge,
|
|
Padding = new Thickness(14, 4, 14, 4),
|
|
FocusVisualStyle = null,
|
|
Cursor = Cursors.Hand,
|
|
Template = ButtonTemplate
|
|
};
|
|
|
|
// Square, flat button: brighter edge when focused, light fill on hover. No theme chrome.
|
|
static readonly ControlTemplate ButtonTemplate = MakeButtonTemplate();
|
|
|
|
static ControlTemplate MakeButtonTemplate()
|
|
{
|
|
var bd = new FrameworkElementFactory(typeof(Border), "bd");
|
|
bd.SetValue(Border.BorderThicknessProperty, new Thickness(1));
|
|
bd.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Control.BorderBrushProperty));
|
|
bd.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Control.BackgroundProperty));
|
|
bd.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Control.PaddingProperty));
|
|
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
|
|
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
|
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
|
|
bd.AppendChild(cp);
|
|
|
|
var t = new ControlTemplate(typeof(Button)) { VisualTree = bd };
|
|
var hover = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true };
|
|
hover.Setters.Add(new Setter(Border.BackgroundProperty, Hover, "bd"));
|
|
var pressed = new Trigger { Property = System.Windows.Controls.Primitives.ButtonBase.IsPressedProperty, Value = true };
|
|
pressed.Setters.Add(new Setter(Border.BackgroundProperty, Press, "bd"));
|
|
var focused = new Trigger { Property = UIElement.IsKeyboardFocusedProperty, Value = true };
|
|
focused.Setters.Add(new Setter(Border.BorderBrushProperty, EdgeBright, "bd"));
|
|
t.Triggers.Add(hover);
|
|
t.Triggers.Add(pressed);
|
|
t.Triggers.Add(focused);
|
|
t.Seal();
|
|
return t;
|
|
}
|
|
}
|
|
}
|