Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ jobs:
Write-Host "=== Building for Revit $rv ==="
# Clean between versions: 2025/2026 (net8) and 2027 (net10) share bin/obj.
Remove-Item ClaudeRevit\bin, ClaudeRevit\obj -Recurse -Force -ErrorAction SilentlyContinue
dotnet build ClaudeRevit\ClaudeRevit.csproj -c Release -p:RevitVersion=$rv -p:SkipDeploy=true
# Stamp the numeric version onto the assembly so the running add-in can report it and
# compare against the latest GitHub release (the update check). "v1.97" -> "1.97".
$numver = $ver.TrimStart('v')
dotnet build ClaudeRevit\ClaudeRevit.csproj -c Release -p:RevitVersion=$rv -p:SkipDeploy=true -p:Version=$numver
if ($LASTEXITCODE -ne 0) { throw "Build failed for Revit $rv" }
$src = "ClaudeRevit\bin\Release\release"
if (-not (Test-Path $src)) { throw "Staging folder not found for Revit $rv at $src" }
Expand Down
1 change: 1 addition & 0 deletions ClaudeRevit.Tests/ClaudeRevit.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Revit runtime. Keep this list to files with no Autodesk.Revit dependency. -->
<Compile Include="..\ClaudeRevit\Services\PatternArchive.cs" Link="src\PatternArchive.cs" />
<Compile Include="..\ClaudeRevit\Services\ToolSearchLogic.cs" Link="src\ToolSearchLogic.cs" />
<Compile Include="..\ClaudeRevit\Tools\Units.cs" Link="src\Units.cs" />
<Compile Include="..\ClaudeRevit\Services\ToolResultAging.cs" Link="src\ToolResultAging.cs" />
<Compile Include="..\ClaudeRevit\Services\ToolResultArchive.cs" Link="src\ToolResultArchive.cs" />
<Compile Include="..\ClaudeRevit\Services\ChatHistory.cs" Link="src\ChatHistory.cs" />
Expand Down
50 changes: 50 additions & 0 deletions ClaudeRevit.Tests/UnitsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using ClaudeRevit.Tools;
using Xunit;

namespace ClaudeRevit.Tests;

public class UnitsTests
{
[Fact]
public void FootIsExactly304Point8Mm()
{
Assert.Equal(304.8, Units.FeetToMm(1.0), 9);
Assert.Equal(1.0, Units.MmToFeet(304.8), 9);
}

[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(3.2808398950131)] // ~1 m in feet
[InlineData(123.456)]
public void LengthRoundTrips(double feet)
{
Assert.Equal(feet, Units.MmToFeet(Units.FeetToMm(feet)), 9);
}

[Fact]
public void AreaAndVolumeAreLengthSquaredAndCubed()
{
// 1 ft^2 = 0.3048^2 m^2, 1 ft^3 = 0.3048^3 m^3.
Assert.Equal(0.3048 * 0.3048, Units.SqFeetToSqM(1.0), 9);
Assert.Equal(0.3048 * 0.3048 * 0.3048, Units.CuFeetToCuM(1.0), 12);
}

[Theory]
[InlineData(0)]
[InlineData(10.5)]
[InlineData(9999.9)]
public void AreaAndVolumeRoundTrip(double v)
{
Assert.Equal(v, Units.SqMToSqFeet(Units.SqFeetToSqM(v)), 9);
Assert.Equal(v, Units.CuMToCuFeet(Units.CuFeetToCuM(v)), 9);
}

[Fact]
public void KnownMetreConversion()
{
// A 5 m wall is ~16.404 ft internally; converting back gives 5000 mm.
var feet = Units.MmToFeet(5000);
Assert.Equal(5000, Units.FeetToMm(feet), 6);
}
}
1 change: 1 addition & 0 deletions ClaudeRevit/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public Result OnStartup(UIControlledApplication application)
ToolRegistry.Instance.Register(new GetLevels());
ToolRegistry.Instance.Register(new GetSelection());
ToolRegistry.Instance.Register(new QueryElements());
ToolRegistry.Instance.Register(new FilterElements());
ToolRegistry.Instance.Register(new AnalyzeWarnings());
ToolRegistry.Instance.Register(new CreateWall());
ToolRegistry.Instance.Register(new CreateWallType());
Expand Down
6 changes: 5 additions & 1 deletion ClaudeRevit/Services/ChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,11 @@ private async Task SendViaClaudeCodeAsync(
var idList = sel.Ids.Count > 30
? string.Join(", ", sel.Ids.Take(30)) + $", … +{sel.Ids.Count - 30} more"
: string.Join(", ", sel.Ids);
ctxHeader += $"\n\nCURRENT SELECTION: {sel.Description}. Element IDs: [{idList}]";
// Inline the per-category breakdown so "what's selected?" is answerable with no tool call.
var cats = sel.CategoryCounts.Count > 0
? " — " + string.Join(", ", sel.CategoryCounts.Select(kv => $"{kv.Value}× {kv.Key}"))
: "";
ctxHeader += $"\n\nCURRENT SELECTION: {sel.Description}{cats}. Element IDs: [{idList}]";
}
contextedPrompt = ctxHeader + "\n\n---\n\nUSER REQUEST:\n" + prompt;
}
Expand Down
6 changes: 5 additions & 1 deletion ClaudeRevit/Services/ClaudeCodeBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,11 @@ private static Process Start(string file, IEnumerable<string> args, string workD
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
StandardErrorEncoding = Encoding.UTF8,
// CRITICAL: the prompt (with Cyrillic / any non-ASCII) is written to the CLI's stdin. Without
// this, .NET encodes it with the OS default (CP1251/OEM on a Russian Windows) and `claude`
// receives mojibake — the model literally can't read the request. UTF-8, no BOM.
StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)
};
foreach (var a in args) psi.ArgumentList.Add(a);
return Process.Start(psi) ?? throw new InvalidOperationException("Process.Start returned null.");
Expand Down
47 changes: 47 additions & 0 deletions ClaudeRevit/Services/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,56 @@ private static string BuildInstructions()
var experience = ExperienceStore.Digest();
if (!string.IsNullOrWhiteSpace(experience))
sb.Append("\n\n").Append(experience!.Trim());
// Full tool index in the handshake so the driving model knows every tool up front and can
// call the right one directly — no discovery round-trips even when the client defers the
// (180) tool schemas. Generated once, cached, and mirrored to a settings .md for the user.
sb.Append("\n\n").Append(ToolIndexMarkdown());
return sb.ToString();
}

private static string? _toolIndexCache;
private static string ToolCatalogPath => Path.Combine(AppDir, "tools-catalog.md");

private static string ToolIndexMarkdown()
{
if (_toolIndexCache != null) return _toolIndexCache;

var allowCode = SettingsStore.AllowCodeExecution;
var byCat = new SortedDictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var t in ToolRegistry.Instance.All)
{
if (t.RequiresCodeExecutionOptIn && !allowCode) continue;
var cat = ClaudeRevit.Tools.ToolCatalog.CategoryOf(t);
if (!byCat.TryGetValue(cat, out var list)) byCat[cat] = list = new List<string>();
list.Add($"- `{t.Name}` — {FirstSentence(t.Description)}");
}

var sb = new StringBuilder();
sb.Append("AVAILABLE TOOLS — the full set is listed here so you can call the right tool by its " +
"exact name without searching first. Schemas load on first use.\n");
foreach (var kv in byCat)
{
sb.Append("\n**").Append(kv.Key).Append("**\n");
kv.Value.Sort(StringComparer.Ordinal);
foreach (var line in kv.Value) sb.Append(line).Append('\n');
}
_toolIndexCache = sb.ToString();

try { Directory.CreateDirectory(AppDir); File.WriteAllText(ToolCatalogPath, _toolIndexCache); }
catch { /* the md mirror is a convenience, not required */ }
return _toolIndexCache;
}

// First sentence of a tool description, trimmed to keep the index compact.
private static string FirstSentence(string desc)
{
if (string.IsNullOrWhiteSpace(desc)) return "";
var s = desc.Replace('\n', ' ').Trim();
var dot = s.IndexOf(". ", StringComparison.Ordinal);
if (dot > 0) s = s.Substring(0, dot);
return s.Length > 140 ? s.Substring(0, 140).TrimEnd() + "…" : s;
}

private static async Task<(JsonNode? value, JsonObject? error)> Dispatch(string? method, JsonNode? prms, CancellationToken ct)
{
switch (method)
Expand Down
2 changes: 1 addition & 1 deletion ClaudeRevit/Services/ToolSearchLogic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed record SearchResult(List<string> Categories, string Message);
public static readonly HashSet<string> CoreToolNames = new(StringComparer.Ordinal)
{
// Query / inspection
"get_selection", "query_elements", "get_element_parameters", "get_type_parameters",
"get_selection", "query_elements", "filter_elements", "get_element_parameters", "get_type_parameters",
"get_element_locations", "get_element_bounding_box", "get_levels", "get_model_statistics",
"get_project_catalog", "get_project_info", "get_active_view_info", "list_family_types",
"list_loaded_families", "list_materials", "measure_distance", "analyze_warnings",
Expand Down
110 changes: 110 additions & 0 deletions ClaudeRevit/Services/UpdateChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;

namespace ClaudeRevit.Services;

// Reports the running add-in version and checks GitHub for a newer release. Loaded DLLs can't
// self-replace while Revit is open, so this never installs anything — it surfaces "an update is
// available" plus the installer URL, and the user updates on their own terms. All network work is
// best-effort and non-fatal: a failed check just leaves LatestVersion null.
public static class UpdateChecker
{
private const string Owner = "debug23win";
private const string Repo = "clauderevit";
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) };

// "v1.97" — from the assembly version stamped at release time; "dev" for a local build (0.0.0).
public static string CurrentVersion
{
get
{
try
{
var v = Assembly.GetExecutingAssembly().GetName().Version;
if (v == null || (v.Major == 0 && v.Minor == 0)) return "dev";
return v.Build > 0 ? $"v{v.Major}.{v.Minor}.{v.Build}" : $"v{v.Major}.{v.Minor}";
}
catch { return "dev"; }
}
}

public sealed class Result
{
public string Current = "";
public string? Latest; // "v1.98"
public bool UpdateAvailable;
public string? DownloadUrl; // the installer .exe asset, or the release page
public string? Error;
}

// Query the latest release. Cached for the process lifetime after the first success so the pane
// can call it freely.
private static Result? _cached;

public static async Task<Result> CheckAsync()
{
if (_cached is { UpdateAvailable: true }) return _cached;

var result = new Result { Current = CurrentVersion };
if (result.Current == "dev") { result.Error = "local build"; return result; }

try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"https://api.github.com/repos/{Owner}/{Repo}/releases/latest");
req.Headers.UserAgent.ParseAdd("ClaudeRevit-UpdateChecker");
req.Headers.Accept.ParseAdd("application/vnd.github+json");

using var resp = await Http.SendAsync(req);
if (!resp.IsSuccessStatusCode) { result.Error = $"HTTP {(int)resp.StatusCode}"; return result; }

using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
var root = doc.RootElement;
var tag = root.TryGetProperty("tag_name", out var t) ? t.GetString() : null;
if (string.IsNullOrWhiteSpace(tag)) { result.Error = "no tag"; return result; }

result.Latest = tag;
result.DownloadUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() : null;
if (root.TryGetProperty("assets", out var assets) && assets.ValueKind == JsonValueKind.Array)
foreach (var a in assets.EnumerateArray())
{
var name = a.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) &&
a.TryGetProperty("browser_download_url", out var u))
{ result.DownloadUrl = u.GetString(); break; }
}

result.UpdateAvailable = IsNewer(tag!, result.Current);
if (result.UpdateAvailable) _cached = result;
}
catch (Exception ex) { result.Error = ex.Message; }
return result;
}

// Compare "v1.98" vs "v1.97" numerically, component by component. Unparseable → not newer.
private static bool IsNewer(string latest, string current)
{
var a = Parse(latest);
var b = Parse(current);
for (int i = 0; i < Math.Max(a.Length, b.Length); i++)
{
var x = i < a.Length ? a[i] : 0;
var y = i < b.Length ? b[i] : 0;
if (x != y) return x > y;
}
return false;
}

private static int[] Parse(string v)
{
v = v.TrimStart('v', 'V');
var parts = v.Split('.');
var nums = new int[parts.Length];
for (int i = 0; i < parts.Length; i++)
nums[i] = int.TryParse(parts[i], out var n) ? n : 0;
return nums;
}
}
Loading
Loading