From 4575e4897024d6c6b295f69868ba0543c1303ee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:49:48 +0000 Subject: [PATCH 01/10] Claude Code launcher: resolve exe across install locations, readable errors Revit is a GUI process with a narrower PATH than the user's shell, so a bare "claude" from an npm/native install often isn't found. The benchmark's Claude Code (MCP) rows all failed instantly with an unreadable CP866-mojibaked cmd.exe "not recognized as a command" message. Resolve the executable to a full path first (PATH + Windows extensions, then npm-global %APPDATA%\npm, native-install, and ~/.claude/local locations), route .cmd/.bat shims through cmd.exe by full path (no more "not recognized"), emit a clean actionable error when nothing is found, and set UTF-8 stderr so any real error is legible. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- ClaudeRevit/Services/ClaudeCodeBackend.cs | 92 +++++++++++++++++++---- 1 file changed, 77 insertions(+), 15 deletions(-) diff --git a/ClaudeRevit/Services/ClaudeCodeBackend.cs b/ClaudeRevit/Services/ClaudeCodeBackend.cs index 29486b4..ed6f015 100644 --- a/ClaudeRevit/Services/ClaudeCodeBackend.cs +++ b/ClaudeRevit/Services/ClaudeCodeBackend.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.Linq; using System.Text; using System.Text.Json; @@ -53,30 +54,42 @@ public static async Task RunAsync( } var result = new Result(); - Process proc; - try + + // Revit is a GUI process — its PATH is often narrower than the user's shell, so a bare + // "claude" from an npm/native install frequently isn't found. Resolve to a full path across + // the common install locations FIRST; that also avoids cmd.exe's localized (and, on a Russian + // Windows, mojibaked) "'claude' is not recognized as a command" error leaking into results. + var resolved = Resolve(exe); + if (resolved == null) { - proc = Start(exe, args, workDir); + result.Error = + $"Claude Code CLI not found ('{exe}'). Install it (npm i -g @anthropic-ai/claude-code), " + + "run 'claude login' once, then set the full path to claude.cmd/claude.exe in Settings."; + return result; } - catch (Win32Exception) + + var viaCmd = resolved.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) || + resolved.EndsWith(".bat", StringComparison.OrdinalIgnoreCase); + + Process proc; + try { - // npm-installed `claude` is a .cmd shim — not directly launchable; go through cmd.exe. - try + if (viaCmd) { - var viaCmd = new List { "/c", exe }; - viaCmd.AddRange(args); - proc = Start("cmd.exe", viaCmd, workDir); + // .cmd/.bat shims aren't PE images — must run through cmd.exe. The path is real, so + // cmd won't print a "not recognized" error. + var cmdArgs = new List { "/c", resolved }; + cmdArgs.AddRange(args); + proc = Start("cmd.exe", cmdArgs, workDir); } - catch (Exception ex) + else { - result.Error = $"Can't launch Claude Code ('{exe}'). Install it and run 'claude login', " + - $"or set the full path in Settings. ({ex.Message})"; - return result; + proc = Start(resolved, args, workDir); } } catch (Exception ex) { - result.Error = $"Can't launch Claude Code ('{exe}'): {ex.Message}"; + result.Error = $"Can't launch Claude Code ('{resolved}'): {ex.Message}"; return result; } @@ -168,6 +181,54 @@ private static void ParseLine(string line, Result result, Action onText, catch { /* non-JSON or partial line — ignore */ } } + // Find the `claude` executable. Honours an explicit path, then PATH (+ Windows extensions), + // then the well-known npm-global and native-install locations that Revit's PATH usually misses. + // Returns a full path, or null if nothing exists. + private static string? Resolve(string exe) + { + if (string.IsNullOrWhiteSpace(exe)) exe = "claude"; + + // Explicit path (has a directory separator) — trust it if it exists. + if (exe.IndexOf(Path.DirectorySeparatorChar) >= 0 || exe.IndexOf('/') >= 0) + return File.Exists(exe) ? exe : null; + + var exts = new[] { "", ".cmd", ".exe", ".bat", ".ps1" }; + + // Search each PATH entry. + var pathVar = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var dir in pathVar.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(dir)) continue; + foreach (var ext in exts) + { + try { var p = Path.Combine(dir, exe + ext); if (File.Exists(p)) return p; } + catch { /* bad PATH entry */ } + } + } + + // Common install locations the GUI PATH tends to omit. + string? Env(string v) => Environment.GetEnvironmentVariable(v); + var candidates = new List + { + // npm global (default prefix) + Env("APPDATA") is { } ad ? Path.Combine(ad, "npm", exe + ".cmd") : null, + Env("APPDATA") is { } ad2 ? Path.Combine(ad2, "npm", exe + ".ps1") : null, + // native installer / local install + Env("LOCALAPPDATA") is { } la ? Path.Combine(la, "Programs", "claude", exe + ".exe") : null, + Env("USERPROFILE") is { } up ? Path.Combine(up, ".claude", "local", exe + ".exe") : null, + Env("USERPROFILE") is { } up2 ? Path.Combine(up2, ".claude", "local", exe) : null, + Env("USERPROFILE") is { } up3 ? Path.Combine(up3, ".local", "bin", exe) : null, + // unix-y (in case Revit ever runs elsewhere) + "/usr/local/bin/" + exe, + "/usr/bin/" + exe, + }; + foreach (var c in candidates) + { + if (c != null) { try { if (File.Exists(c)) return c; } catch { } } + } + return null; + } + private static Process Start(string file, IEnumerable args, string workDir) { var psi = new ProcessStartInfo @@ -179,7 +240,8 @@ private static Process Start(string file, IEnumerable args, string workD RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, - StandardOutputEncoding = Encoding.UTF8 + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 }; foreach (var a in args) psi.ArgumentList.Add(a); return Process.Start(psi) ?? throw new InvalidOperationException("Process.Start returned null."); From 95a7ed127d2bbe470af9f6023dfeace22d91459e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:59:23 +0000 Subject: [PATCH 02/10] Settings: add Claude Code executable path field (MCP section) The ClaudeCodeExe setting existed in the store but had no UI, so there was no way to point Revit at a claude.cmd it can't find on its narrower GUI PATH. Add the field under the MCP config box with a hint (run 'where claude', usually %APPDATA%\npm\claude.cmd), wired to load/save + RU/EN localization. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- ClaudeRevit/UI/SettingsWindow.xaml | 7 +++++++ ClaudeRevit/UI/SettingsWindow.xaml.cs | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/ClaudeRevit/UI/SettingsWindow.xaml b/ClaudeRevit/UI/SettingsWindow.xaml index e0e175f..cd459ac 100644 --- a/ClaudeRevit/UI/SettingsWindow.xaml +++ b/ClaudeRevit/UI/SettingsWindow.xaml @@ -186,6 +186,13 @@ FontFamily="Consolas" FontSize="11" Height="120" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" /> + + + + diff --git a/ClaudeRevit/UI/SettingsWindow.xaml.cs b/ClaudeRevit/UI/SettingsWindow.xaml.cs index d900d3f..ba93f55 100644 --- a/ClaudeRevit/UI/SettingsWindow.xaml.cs +++ b/ClaudeRevit/UI/SettingsWindow.xaml.cs @@ -56,6 +56,7 @@ public SettingsWindow() TaskDiagBox.IsChecked = SettingsStore.ShowTaskDiagnostics; McpBox.IsChecked = SettingsStore.McpEnabled; McpPortBox.Text = SettingsStore.McpPort.ToString(); + ClaudeCodeExeBox.Text = SettingsStore.ClaudeCodeExe; UpdateMcpConfig(); AltCompactToolsBox.IsChecked = SettingsStore.AltCompactTools; @@ -206,6 +207,12 @@ private void ApplyLanguage() "Exposes the Revit tools over a local MCP server so Claude Code / Claude Desktop — authenticated with your Claude Pro/Max subscription — can drive Revit, putting cost on the subscription instead of the pay-per-token API. The in-Revit chat pane still uses your API key. Security: the server listens only on 127.0.0.1 and requires the token below; anyone who has it can edit your model (and run C# if code execution is on). Paste the config below into Claude Code’s MCP settings.", "Выставляет инструменты Revit через локальный MCP-сервер, чтобы Claude Code / Claude Desktop (авторизованные вашей подпиской Pro/Max) могли рулить Revit — стоимость идёт на подписку, а не на потокенный API. Панель чата в Revit по-прежнему на API-ключе. Безопасность: сервер слушает только 127.0.0.1 и требует токен ниже; у кого он есть — тот может править вашу модель (и запускать C#, если включено выполнение кода). Вставьте конфиг ниже в настройки MCP в Claude Code."); McpPortLabel.Text = L("Port:", "Порт:"); + ClaudeCodeExeLabel.Text = L( + "Claude Code executable (for the in-pane / benchmark subscription path)", + "Путь к Claude Code (для панели / бенчмарка по подписке)"); + ClaudeCodeExeNote.Text = L( + "Leave as \"claude\" if it is on PATH. Revit’s process often can’t see it — if the benchmark says “Claude Code CLI not found”, put the full path here (run \"where claude\" in a terminal; usually %APPDATA%\\npm\\claude.cmd).", + "Оставьте \"claude\", если он в PATH. Процесс Revit часто его не видит — если бенчмарк пишет «Claude Code CLI not found», впишите полный путь (в терминале выполните \"where claude\"; обычно %APPDATA%\\npm\\claude.cmd)."); ToolGroupsHeader.Text = L("Active tool groups (fewer = fewer tokens per request)", "Активные группы инструментов (меньше = меньше токенов на запрос)"); ToolGroupsNote.Text = L( @@ -365,6 +372,7 @@ private void SaveButton_Click(object sender, RoutedEventArgs e) if (int.TryParse(McpPortBox.Text, out var mcpPort) && mcpPort is > 0 and < 65536) SettingsStore.McpPort = mcpPort; SettingsStore.McpEnabled = McpBox.IsChecked == true; + SettingsStore.ClaudeCodeExe = ClaudeCodeExeBox.Text?.Trim() ?? ""; try { McpServer.ApplyFromSettings(); } catch (Exception ex) { Log.Error("MCP apply failed", ex); } SettingsStore.AltCompactTools = AltCompactToolsBox.IsChecked == true; SettingsStore.UiLanguage = _lang; From 51f033d03873d58f0b5286b7d7ed584f22e0f6e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:04:02 +0000 Subject: [PATCH 03/10] Claude Code resolver: auto-detect the CLI bundled inside Claude Desktop Claude Desktop (the MSIX Store app) ships a working headless claude.exe under %LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\claude-code\\claude.exe, but it is not on PATH, so 'where claude' finds nothing and users assume the CLI is missing. Glob that location (newest version folder) so desktop-only users get the subscription path with no npm install and no manual path entry. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- ClaudeRevit/Services/ClaudeCodeBackend.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/ClaudeRevit/Services/ClaudeCodeBackend.cs b/ClaudeRevit/Services/ClaudeCodeBackend.cs index ed6f015..cf10580 100644 --- a/ClaudeRevit/Services/ClaudeCodeBackend.cs +++ b/ClaudeRevit/Services/ClaudeCodeBackend.cs @@ -226,6 +226,29 @@ private static void ParseLine(string line, Result result, Action onText, { if (c != null) { try { if (File.Exists(c)) return c; } catch { } } } + + // Claude Desktop (the MSIX Store app) BUNDLES the Claude Code CLI under its package dir at + // %LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\claude-code\\claude.exe + // Users who only have the desktop app still have a working headless claude.exe here — it's just + // not on PATH. Glob for it and take the newest version folder. + try + { + var packages = Env("LOCALAPPDATA") is { } lad ? Path.Combine(lad, "Packages") : null; + if (packages != null && Directory.Exists(packages)) + { + var best = Directory.EnumerateDirectories(packages, "Claude_*") + .Select(pkg => Path.Combine(pkg, "LocalCache", "Roaming", "Claude", "claude-code")) + .Where(Directory.Exists) + .SelectMany(cc => Directory.EnumerateDirectories(cc)) + .Select(ver => Path.Combine(ver, "claude.exe")) + .Where(File.Exists) + .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase) // newest version last-sorts first + .FirstOrDefault(); + if (best != null) return best; + } + } + catch { /* enumeration raced or access denied */ } + return null; } From e03dce7c1c577ede11f891497c86f544299fcc50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:17:36 +0000 Subject: [PATCH 04/10] Benchmark: MCP-connection diagnostics + judge on the subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code (MCP) benchmark launched but did nothing (1 round, 0 tokens, no model changes) — the CLI wasn't connecting to our MCP server, but the runner only showed 'Judge unavailable', hiding why. - Parse the init event's mcp_servers status and the result is_error/subtype in ClaudeCodeBackend; surface them in the benchmark reason (e.g. '[MCP: clauderevit=failed — no Revit tools reached, nothing built]'). - Add ClaudeCodeBackend.CompleteAsync: a one-shot no-tools completion on the subscription. Route the benchmark judge through it (checkbox 'Judge on subscription', default on) so grading costs nothing on the API — it still grades from the objective probe only. - Make --mcp-config/--allowedTools optional in RunAsync (judge needs neither). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- ClaudeRevit/Services/BenchmarkRunner.cs | 34 ++++++++++++-- ClaudeRevit/Services/ClaudeCodeBackend.cs | 57 +++++++++++++++++++++-- ClaudeRevit/UI/BenchmarkWindow.xaml | 3 ++ ClaudeRevit/UI/BenchmarkWindow.xaml.cs | 1 + 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/ClaudeRevit/Services/BenchmarkRunner.cs b/ClaudeRevit/Services/BenchmarkRunner.cs index 1c25475..dc7ed68 100644 --- a/ClaudeRevit/Services/BenchmarkRunner.cs +++ b/ClaudeRevit/Services/BenchmarkRunner.cs @@ -46,6 +46,7 @@ public static async Task RunAsync( bool resetBetweenTasks, int maxRoundsPerTask, int maxSecondsPerTask, + bool judgeViaClaudeCode, Action onStatus, Action onResult, CancellationToken ct) @@ -57,13 +58,19 @@ public static async Task RunAsync( // "claudecode" tests the MCP path end to end: the local `claude` CLI (on the subscription) // drives the Revit tools through our MCP server. One impartial judge chat for the whole run. var isClaudeCode = modelTag == "claudecode"; + // The judge can run on the subscription too (same claude.exe, no tools) so grading costs + // nothing on the API — needed since the account may sit at $0. It still grades from the + // objective probe only, so it stays impartial. string ccConfig = "", ccWorkDir = "", ccExe = ""; + if (isClaudeCode || judgeViaClaudeCode) + { + ccExe = SettingsStore.ClaudeCodeExe; + ccWorkDir = McpServer.ClientWorkDir(); + } if (isClaudeCode) { McpServer.Start(); ccConfig = McpServer.WriteClientConfig(); - ccWorkDir = McpServer.ClientWorkDir(); - ccExe = SettingsStore.ClaudeCodeExe; } var judgeChat = new ChatService(ephemeral: true); @@ -85,6 +92,7 @@ public static async Task RunAsync( // The model runs the task ONCE and recovers from any errors on its own — no judge hints. string finalText = ""; string? error = null; + string? ccDiag = null; var budgetStopped = false; long inTok = 0, outTok = 0; var rounds = 0; @@ -110,6 +118,14 @@ public static async Task RunAsync( error = res.Error; inTok = res.InputTokens; outTok = res.OutputTokens; rounds = res.NumTurns; modelUsed = "claude-code"; + // Surface WHY a run did nothing: MCP connection status + result flag. A launch + // with no tool calls and no tokens usually means MCP never connected. + ccDiag = $"MCP: {res.McpStatus ?? "no init event"}" + + (res.IsError ? "; result=ERROR" : res.Subtype is { } st ? $"; result={st}" : ""); + if ((res.McpStatus == null || + res.McpStatus.IndexOf("connect", StringComparison.OrdinalIgnoreCase) < 0) && + outTok == 0) + ccDiag += " — no Revit tools reached, nothing built"; } catch (OperationCanceledException) { @@ -152,9 +168,12 @@ public static async Task RunAsync( onStatus($"{task.Id} · {task.Title} · grading…"); var verdict = error != null ? new Verdict(false, 0, "Run error: " + Truncate(error, 200), true) - : await JudgeAsync(judgeChat, judgeModel, task, before, after, finalText, ct); + : await JudgeAsync(judgeChat, judgeModel, task, before, after, finalText, + judgeViaClaudeCode, ccExe, ccWorkDir, ct); if (budgetStopped) verdict = verdict with { Reason = "[stopped — task budget hit] " + verdict.Reason }; + if (ccDiag != null) + verdict = verdict with { Reason = "[" + ccDiag + "] " + verdict.Reason }; if (resetBetweenTasks) await ResetAsync(baselineIds, ct); @@ -208,7 +227,8 @@ private static async Task StatsAsync(CancellationToken ct) private static async Task JudgeAsync( ChatService chat, string judgeModel, BenchmarkTask task, - string before, string after, string finalText, CancellationToken ct) + string before, string after, string finalText, + bool viaClaudeCode, string ccExe, string ccWorkDir, CancellationToken ct) { const string sys = "You are an impartial QA grader for a Revit modelling agent. Grade ONLY from the objective " + @@ -226,7 +246,11 @@ private static async Task JudgeAsync( $"AGENT'S CLAIMED RESULT (unverified):\n{Truncate(finalText, 1000)}\n\nGrade now."; try { - var raw = await chat.RawCompleteAsync(judgeModel, sys, user, ct); + // On the subscription (no API cost) the CLI has no system-prompt flag in the same shape, + // so fold the grader instructions into the prompt; ParseVerdict tolerates surrounding prose. + var raw = viaClaudeCode + ? await ClaudeCodeBackend.CompleteAsync(ccExe, sys + "\n\n" + user, ccWorkDir, ct) + : await chat.RawCompleteAsync(judgeModel, sys, user, ct); return ParseVerdict(raw); } catch (Exception ex) diff --git a/ClaudeRevit/Services/ClaudeCodeBackend.cs b/ClaudeRevit/Services/ClaudeCodeBackend.cs index cf10580..e30813e 100644 --- a/ClaudeRevit/Services/ClaudeCodeBackend.cs +++ b/ClaudeRevit/Services/ClaudeCodeBackend.cs @@ -32,6 +32,12 @@ public sealed class Result public int NumTurns; public double CostUsd; public long DurationMs; + // Diagnostics: MCP server connection status from the init event ("clauderevit=connected"), + // and whether the final result was flagged an error. Explains a run that launched but did + // nothing (e.g. MCP failed to connect → no Revit tools → no work). + public string? McpStatus; + public bool IsError; + public string? Subtype; } public static async Task RunAsync( @@ -43,10 +49,20 @@ public static async Task RunAsync( "-p", "--output-format", "stream-json", "--verbose", - "--include-partial-messages", - "--mcp-config", mcpConfigPath, - "--allowedTools", allowedToolsGlob + "--include-partial-messages" }; + // MCP + tools are for the "drive Revit" path. The judge runs with neither (empty) — a pure + // text-grading call — so skip the flags; an empty allowedTools glob denies every tool. + if (!string.IsNullOrWhiteSpace(mcpConfigPath)) + { + args.Add("--mcp-config"); + args.Add(mcpConfigPath); + } + if (!string.IsNullOrWhiteSpace(allowedToolsGlob)) + { + args.Add("--allowedTools"); + args.Add(allowedToolsGlob); + } if (!string.IsNullOrWhiteSpace(resumeSessionId)) { args.Add("--resume"); @@ -125,6 +141,20 @@ public static async Task RunAsync( return result; } + // A one-shot, no-tools text completion on the subscription — used for the impartial benchmark + // judge so grading costs nothing on the API. The bogus allowedTools glob matches no tool, so in + // headless (-p) mode every built-in tool is auto-denied and the model just returns text. + public static async Task CompleteAsync(string exe, string prompt, string workDir, CancellationToken ct) + { + var res = await RunAsync( + exe, prompt, workDir, mcpConfigPath: "", resumeSessionId: null, + allowedToolsGlob: "__deny_all_tools__", + onText: _ => { }, onTool: _ => { }, ct); + if (string.IsNullOrEmpty(res.Text) && !string.IsNullOrEmpty(res.Error)) + throw new InvalidOperationException(res.Error); + return res.Text; + } + // Each stdout line is one JSON event. We pull: the session id (to resume the conversation next // turn), streamed assistant text deltas, tool-call names, and the final result text. private static void ParseLine(string line, Result result, Action onText, Action onTool) @@ -141,10 +171,31 @@ private static void ParseLine(string line, Result result, Action onText, var type = root.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null; + // The init event lists the MCP servers Claude Code tried to attach and whether each + // connected — the single most useful signal when a run launches but does no work. + if (type == "system" && root.TryGetProperty("mcp_servers", out var servers) && + servers.ValueKind == JsonValueKind.Array) + { + var parts = new List(); + foreach (var s in servers.EnumerateArray()) + { + var name = s.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() : "?"; + var status = s.TryGetProperty("status", out var st) && st.ValueKind == JsonValueKind.String + ? st.GetString() : "?"; + parts.Add($"{name}={status}"); + } + if (parts.Count > 0) result.McpStatus = string.Join(", ", parts); + } + if (type == "result") { if (root.TryGetProperty("result", out var res) && res.ValueKind == JsonValueKind.String) result.Text = res.GetString() ?? result.Text; + if (root.TryGetProperty("is_error", out var ie) && ie.ValueKind == JsonValueKind.True) + result.IsError = true; + if (root.TryGetProperty("subtype", out var sub) && sub.ValueKind == JsonValueKind.String) + result.Subtype = sub.GetString(); if (root.TryGetProperty("num_turns", out var nt) && nt.TryGetInt32(out var ntv)) result.NumTurns = ntv; if (root.TryGetProperty("total_cost_usd", out var c) && c.TryGetDouble(out var cv)) result.CostUsd = cv; if (root.TryGetProperty("duration_ms", out var dm) && dm.TryGetInt64(out var dmv)) result.DurationMs = dmv; diff --git a/ClaudeRevit/UI/BenchmarkWindow.xaml b/ClaudeRevit/UI/BenchmarkWindow.xaml index e5e9624..fe017f7 100644 --- a/ClaudeRevit/UI/BenchmarkWindow.xaml +++ b/ClaudeRevit/UI/BenchmarkWindow.xaml @@ -43,6 +43,9 @@ + { From 2245d7ad96c0f69dc175b781b5c5de34d414ff21 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:20:14 +0000 Subject: [PATCH 05/10] Resolver: prefer the native-installer path (~/.local/bin/claude.exe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native Claude Code installer (irm https://claude.ai/install.ps1 | iex) drops claude.exe in %USERPROFILE%\.local\bin — add the .exe variant of that path (was only checked without extension) and rank it ahead of other local installs, so a native install is picked up even before Revit's PATH refreshes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- ClaudeRevit/Services/ClaudeCodeBackend.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ClaudeRevit/Services/ClaudeCodeBackend.cs b/ClaudeRevit/Services/ClaudeCodeBackend.cs index e30813e..5fd5e99 100644 --- a/ClaudeRevit/Services/ClaudeCodeBackend.cs +++ b/ClaudeRevit/Services/ClaudeCodeBackend.cs @@ -264,11 +264,13 @@ private static void ParseLine(string line, Result result, Action onText, // npm global (default prefix) Env("APPDATA") is { } ad ? Path.Combine(ad, "npm", exe + ".cmd") : null, Env("APPDATA") is { } ad2 ? Path.Combine(ad2, "npm", exe + ".ps1") : null, - // native installer / local install + // native installer (irm https://claude.ai/install.ps1 | iex) → %USERPROFILE%\.local\bin\claude.exe + Env("USERPROFILE") is { } upn ? Path.Combine(upn, ".local", "bin", exe + ".exe") : null, + Env("USERPROFILE") is { } up3 ? Path.Combine(up3, ".local", "bin", exe) : null, + // other local installs Env("LOCALAPPDATA") is { } la ? Path.Combine(la, "Programs", "claude", exe + ".exe") : null, Env("USERPROFILE") is { } up ? Path.Combine(up, ".claude", "local", exe + ".exe") : null, Env("USERPROFILE") is { } up2 ? Path.Combine(up2, ".claude", "local", exe) : null, - Env("USERPROFILE") is { } up3 ? Path.Combine(up3, ".local", "bin", exe) : null, // unix-y (in case Revit ever runs elsewhere) "/usr/local/bin/" + exe, "/usr/bin/" + exe, From baf1424a6b055ca46557424bb752d2af2e3dc8d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:46:09 +0000 Subject: [PATCH 06/10] Chat pane: drive Revit from Claude Code on the subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'Claude Code (subscription)' entry to the chat model picker. When picked, SendAsync routes to the local Claude Code CLI (via ClaudeCodeBackend) instead of the API: it starts the MCP server on demand, writes the client config, streams the CLI's narration into the pane, and keeps the CLI session id so follow-up messages continue the same conversation (--resume). ClearHistory resets it. The work runs on the user's Claude Pro/Max subscription — zero API cost — while they type in the familiar Revit pane. Assistant label reads 'Claude Code'. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RCGB6xWx3ZtmWZRQEJtN6B --- ClaudeRevit/Services/ChatService.cs | 73 +++++++++++++++++++++++++++++ ClaudeRevit/UI/ChatPaneView.xaml | 1 + ClaudeRevit/UI/ChatPaneView.xaml.cs | 9 ++-- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/ClaudeRevit/Services/ChatService.cs b/ClaudeRevit/Services/ChatService.cs index c0b30ef..f401a2b 100644 --- a/ClaudeRevit/Services/ChatService.cs +++ b/ClaudeRevit/Services/ChatService.cs @@ -206,6 +206,10 @@ public ChatService(bool ephemeral) // Set by the chat pane: progress ping each tool-call round (current, max) for the status line. public Action? OnRound; + // Claude Code (subscription) mode keeps the CLI's session id so follow-up messages continue the + // same conversation via --resume. Reset on ClearHistory. + private string? _claudeCodeSessionId; + public void RecreateClient() => _client = null; public void ClearHistory() @@ -219,6 +223,7 @@ public void ClearHistory() _execCsharpOk = 0; _promoteNudged = false; _revealedCategories.Clear(); + _claudeCodeSessionId = null; if (!_ephemeral) HistoryStore.Clear(); } @@ -260,6 +265,62 @@ public async Task RawCompleteAsync(string modelTag, string systemPrompt, return string.Concat(msg.Content.Select(b => b.TryPickText(out var t) ? t.Text : "")); } + // Subscription mode: run the local Claude Code CLI headless, letting it drive the Revit tools + // through our in-process MCP server. Streams its narration into the pane and keeps the CLI's + // session id so follow-up messages continue the same conversation (--resume). Costs nothing on + // the API — the work runs on the user's Claude Pro/Max subscription. + private async Task SendViaClaudeCodeAsync( + ObservableCollection conversation, string prompt, Dispatcher ui, CancellationToken ct) + { + if (!McpServer.IsRunning) + { + try { McpServer.Start(); } + catch (Exception ex) + { + throw new InvalidOperationException( + "Couldn't start the MCP server that Claude Code needs — free the port in " + + "Settings → MCP and try again. (" + ex.Message + ")"); + } + } + var config = McpServer.WriteClientConfig(); + var workDir = McpServer.ClientWorkDir(); + var exe = SettingsStore.ClaudeCodeExe; + + ChatMessage? bubble = null; + void Append(string piece) + { + if (string.IsNullOrEmpty(piece)) return; + ui.InvokeAsync(() => + { + if (bubble == null) { bubble = new ChatMessage { Role = "assistant", Text = "" }; conversation.Add(bubble); } + bubble.Text += piece; + }); + } + + var toolCount = 0; + var res = await ClaudeCodeBackend.RunAsync( + exe, prompt, workDir, config, resumeSessionId: _claudeCodeSessionId, + allowedToolsGlob: "mcp__clauderevit__*", + onText: Append, + onTool: _ => { toolCount++; OnRound?.Invoke(toolCount, toolCount); }, + ct); + + _claudeCodeSessionId = res.SessionId ?? _claudeCodeSessionId; + + if (!string.IsNullOrEmpty(res.Error)) + { + Append((bubble == null ? "" : "\n\n") + "⚠ " + res.Error); + return; + } + // Nothing streamed (e.g. a short answer delivered only in the final result event) — show it. + if (bubble == null && !string.IsNullOrEmpty(res.Text)) + Append(res.Text); + + if (SettingsStore.ShowTaskDiagnostics && bubble != null) + Append($"\n\n— claude-code · {res.NumTurns} turns · {res.DurationMs / 1000.0:0.0}s" + + (res.McpStatus != null ? $" · MCP {res.McpStatus}" : "")); + } + public async Task SendAsync( ObservableCollection conversation, string model, @@ -268,6 +329,18 @@ public async Task SendAsync( string? imageMime = null) { var ui = Dispatcher.CurrentDispatcher; + + // Subscription path: the local Claude Code CLI drives the Revit tools through our MCP server. + // It runs its OWN agent loop, so bypass the whole Anthropic/alt pipeline (no API key, no tool + // schemas, no history compaction here) and just stream its output into the pane. + if (model == "claudecode") + { + var userText = conversation.LastOrDefault(m => m.Role == "user")?.Text ?? ""; + if (string.IsNullOrWhiteSpace(userText)) return; + await SendViaClaudeCodeAsync(conversation, userText, ui, ct); + return; + } + bool alt = IsAlt(model); if (alt && !OpenAIBackend.IsConfigured) throw new InvalidOperationException( diff --git a/ClaudeRevit/UI/ChatPaneView.xaml b/ClaudeRevit/UI/ChatPaneView.xaml index 9d01a75..6f4c377 100644 --- a/ClaudeRevit/UI/ChatPaneView.xaml +++ b/ClaudeRevit/UI/ChatPaneView.xaml @@ -51,6 +51,7 @@ Haiku 4.5 (fastest) Sonnet 4.6 Opus 4.7 + Claude Code (subscription) Alt model (set up in ⚙)