Files
QuickClaude/ClaudeRunner.cs
T

288 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace QuickClaude
{
// Runs one turn of Claude Code in headless mode and reports what happens.
public class ClaudeRunner
{
public event Action<string> TextDelta; // reply text as it streams
public event Action<string> ToolUsed; // one line describing a tool call
public event Action<string> SessionStarted; // session id
public event Action<string> Finished; // null when fine, otherwise an error message
Process proc;
public bool Running => proc != null && !proc.HasExited;
const string StylePrompt =
"Your replies appear in a tiny plain-text note on the user's Windows desktop. " +
"Keep answers short and conversational. Write plain text only: no markdown headings, " +
"no bold or italics, no tables, no code fences unless he asks for code. " +
"Never use em dashes or en dashes.";
const string ToolsPrompt =
"You are running inside QuickClaude, a small note window on the user's Windows PC. " +
"You have web search, web fetch, and access to files in the user's home folder " +
"(Downloads, Documents, Desktop and so on). Use them when they help instead of saying you can't. " +
"Whether you can change files depends on the user's \"Claude can\" setting in QuickClaude's tray menu; " +
"if Write or Edit is unavailable, say they can switch it to \"Edit files in the work folder\". " +
"For anything on the web, use the WebSearch and WebFetch tools (load them with ToolSearch first if they " +
"aren't loaded yet). They work here without asking the user, even if earlier in this conversation they were blocked. " +
"Never use PowerShell, Bash, curl or Invoke-WebRequest to search the web or fetch web pages. " +
"Never put the user's email address, email or website domain, name, account or organization details, " +
"or other personal information from your context into web searches or URLs you fetch, " +
"unless the user specifically asks you to search for them. " +
"Shell commands (Bash or PowerShell) are allowed for other things, but each one first shows the user a confirm box; " +
"if they decline, carry on without it. " +
"When the user asks you to open something, run exactly one PowerShell Start-Process command with one quoted target " +
"and nothing else on the line (no arguments, no second command); those open without a confirm box. " +
"A website: Start-Process \"https://x.com\" (always the full https:// URL). " +
"A folder: Start-Process \"C:\\wallpapers\" (the full path). " +
"A program: Start-Process mspaint (just its name). If QuickClaude answers that the name isn't on the PATH and gives " +
"a Start Menu shortcut, run Start-Process with that shortcut's full path, exactly as given.";
// Run without prompts. Bash and PowerShell stay out so they go through the confirm box hook.
// Write and Edit only when the user picked "Edit files" or "Auto"; "Only look" removes them outright.
const string ReadTools = "WebSearch,WebFetch,Read,Glob,Grep";
const string EditTools = "Write,Edit,NotebookEdit";
public static string FindClaude(string preferred)
{
if (!string.IsNullOrWhiteSpace(preferred) && File.Exists(preferred)) return preferred;
var candidates = new List<string>();
var path = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in path.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
candidates.Add(Path.Combine(dir.Trim(), "claude.exe"));
candidates.Add(Path.Combine(dir.Trim(), "claude.cmd"));
}
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
candidates.Add(Path.Combine(home, ".local", "bin", "claude.exe"));
candidates.Add(Path.Combine(appData, "npm", "claude.cmd"));
candidates.Add(Path.Combine(home, "scoop", "shims", "claude.exe"));
foreach (var c in candidates)
if (File.Exists(c)) return c;
// Copies of Claude Code that the Claude desktop app keeps for itself
foreach (var root in new[] { Path.Combine(appData, "Claude", "claude-code"), Path.Combine(local, "Claude", "claude-code") })
{
try
{
if (!Directory.Exists(root)) continue;
var found = Directory.GetFiles(root, "claude.exe", SearchOption.AllDirectories)
.OrderByDescending(f => File.GetLastWriteTimeUtc(f)).FirstOrDefault();
if (found != null) return found;
}
catch { }
}
return null;
}
public async Task RunAsync(string claudePath, string prompt, string workDir, string sessionId, string mode)
{
var psi = new ProcessStartInfo
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Directory.Exists(workDir) ? workDir : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
StandardInputEncoding = new UTF8Encoding(false),
StandardOutputEncoding = new UTF8Encoding(false),
StandardErrorEncoding = new UTF8Encoding(false),
};
var args = new List<string> {
"-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages",
"--append-system-prompt", StylePrompt + "\n\n" + ToolsPrompt,
};
bool canEdit = mode == "acceptEdits" || mode == "auto";
args.Add("--allowedTools"); args.Add(canEdit ? ReadTools + "," + EditTools : ReadTools);
if (!canEdit) { args.Add("--disallowedTools"); args.Add(EditTools); }
var home = Environment.GetEnvironmentVariable("USERPROFILE");
if (!string.IsNullOrEmpty(home) && Directory.Exists(home)) { args.Add("--add-dir"); args.Add(home); }
// Claude Code otherwise keeps a resumed conversation's first system prompt, so prompt
// changes here would never reach older conversations. Needs Claude Code v2.1.257+.
args.Add("--system-prompt-snapshot"); args.Add("off");
if (!string.IsNullOrEmpty(sessionId)) { args.Add("--resume"); args.Add(sessionId); }
if (!string.IsNullOrEmpty(mode) && mode != "default") { args.Add("--permission-mode"); args.Add(mode); }
args.Add("--settings"); args.Add(ConfirmHookSettings());
if (claudePath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase))
{
psi.FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
psi.ArgumentList.Add("/c");
psi.ArgumentList.Add(claudePath);
}
else psi.FileName = claudePath;
foreach (var a in args) psi.ArgumentList.Add(a);
var stderr = new StringBuilder();
bool gotText = false, gotResult = false;
string error = null;
try
{
proc = Process.Start(psi);
}
catch (Exception ex)
{
Finished?.Invoke("Couldn't start Claude Code: " + ex.Message);
return;
}
var errTask = Task.Run(async () =>
{
string l;
while ((l = await proc.StandardError.ReadLineAsync()) != null) stderr.AppendLine(l);
});
try
{
await proc.StandardInput.WriteAsync(prompt);
proc.StandardInput.Close();
}
catch { }
string line;
while ((line = await proc.StandardOutput.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
var type = Str(root, "type");
bool main = !root.TryGetProperty("parent_tool_use_id", out var parent) || parent.ValueKind == JsonValueKind.Null;
if (type == "system" && Str(root, "subtype") == "init")
{
var sid = Str(root, "session_id");
if (!string.IsNullOrEmpty(sid)) SessionStarted?.Invoke(sid);
}
else if (type == "stream_event" && main && root.TryGetProperty("event", out var ev))
{
var evType = Str(ev, "type");
if (evType == "content_block_delta" && ev.TryGetProperty("delta", out var d) && Str(d, "type") == "text_delta")
{
var t = Str(d, "text");
if (!string.IsNullOrEmpty(t)) { gotText = true; TextDelta?.Invoke(t); }
}
else if (evType == "content_block_start" && ev.TryGetProperty("content_block", out var cb) && Str(cb, "type") == "text")
{
TextDelta?.Invoke("\u0001"); // marker: a new text block starts
}
}
else if (type == "assistant" && main && root.TryGetProperty("message", out var msg)
&& msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
{
foreach (var block in content.EnumerateArray())
if (Str(block, "type") == "tool_use")
ToolUsed?.Invoke(DescribeTool(block));
}
else if (type == "result")
{
gotResult = true;
var sid = Str(root, "session_id");
if (!string.IsNullOrEmpty(sid)) SessionStarted?.Invoke(sid);
bool isError = root.TryGetProperty("is_error", out var ie) && ie.ValueKind == JsonValueKind.True;
var result = Str(root, "result");
if (isError) error = string.IsNullOrEmpty(result) ? "Claude Code reported an error." : result;
else if (!gotText && !string.IsNullOrEmpty(result)) TextDelta?.Invoke(result);
}
}
catch { /* ignore lines that aren't JSON */ }
}
await errTask;
proc.WaitForExit();
int code = proc.ExitCode;
proc.Dispose();
proc = null;
if (error == null && !gotResult)
{
var e = stderr.ToString().Trim();
if (code == -1 || code == 137 || code == 143) error = "Stopped.";
else error = string.IsNullOrEmpty(e) ? $"Claude Code exited without an answer (code {code})." : e;
}
Finished?.Invoke(error);
}
// File writes also go through a guard hook ("QuickClaude.exe --guard-write") that blocks the
// Desktop and AppData, where OpenRules lets things open without asking.
// Shell commands go through QuickClaude's own confirm box: a PreToolUse hook that runs
// "QuickClaude.exe --confirm" (exec form, so no shell quoting). The dialog denies after
// 2 minutes; the hook timeout is a bit longer so Claude Code never cuts it off first.
// Written to a file rather than passed inline, since cmd /c (for claude.cmd) mangles JSON quotes.
static string ConfirmHookSettings()
{
var exe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule.FileName;
var json = JsonSerializer.Serialize(new
{
hooks = new
{
PreToolUse = new[]
{
new
{
matcher = "Bash|PowerShell|Monitor", // Monitor also runs shell commands
hooks = new[] { new { type = "command", command = exe, args = new[] { "--confirm" }, timeout = 150 } }
},
new
{
// Never lets Claude write to the Desktop or AppData (see WriteGuard)
matcher = "Write|Edit|MultiEdit|NotebookEdit",
hooks = new[] { new { type = "command", command = exe, args = new[] { "--guard-write" }, timeout = 30 } }
}
}
}
});
var path = Path.Combine(Settings.Folder, "hook-settings.json");
Directory.CreateDirectory(Settings.Folder);
File.WriteAllText(path, json, new UTF8Encoding(false));
return path;
}
public void Stop()
{
try { if (Running) proc.Kill(true); } catch { }
}
static string Str(JsonElement e, string name) =>
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
static string DescribeTool(JsonElement block)
{
var name = Str(block, "name") ?? "tool";
string detail = null;
if (block.TryGetProperty("input", out var input))
{
foreach (var key in new[] { "description", "file_path", "path", "pattern", "url", "query", "command", "prompt" })
{
detail = Str(input, key);
if (!string.IsNullOrEmpty(detail)) break;
}
}
if (detail != null)
{
detail = detail.Replace("\r", " ").Replace("\n", " ");
if (detail.Length > 70) detail = detail.Substring(0, 67) + "...";
return $"[{name}: {detail}]";
}
return $"[{name}]";
}
}
}