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 TextDelta; // reply text as it streams public event Action ToolUsed; // one line describing a tool call public event Action SessionStarted; // session id public event Action 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(); 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 { "-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}]"; } } }