280 lines
14 KiB
C#
280 lines
14 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);
|
|
}
|
|
|
|
// 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 } }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
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}]";
|
|
}
|
|
}
|
|
}
|