QuickClaude 1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+273
@@ -0,0 +1,273 @@
|
||||
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.";
|
||||
|
||||
// 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}]";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user