diff --git a/CHANGELOG.md b/CHANGELOG.md index 47045ea..9b4f12a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.8.1 — 2026-10-18 + +Fixes from testing on a real project. + +- `adolens` works in any new terminal after an install or upgrade with the script (`get.ps1`, `install.ps1`, + `adolens upgrade --install` on such an installation) too: the program folder is added to the user's PATH, as the + setup EXE already did; `uninstall.ps1` takes it off. + +- Tool arguments in another JSON shape are read instead of failing with a bare "An error occurred invoking": + `ids=714800` (a number for text), `workItems=[1,2]`, `file=1`, `top="20"`, `draft="true"`. This was behind the failing + `ado_update` (even with only a comment), `ado_pr_update action=create` with work items and `ado_attachments file=`. + An argument that still cannot be read says which and why. +- `ado_attachments`: two files with the same name are listed by number to pick one. +- `ado_file history=true`: full commit ids (they work in `ado_changes` and `ado_file version=`), changes that came in + with a merge are listed too, and the branch read is named. +- `ado_file` without a version names the branch it read (the default one). +- `ado_sprint`: a sprint over the Azure DevOps limit of 20 000 work items says so instead of a raw server error; with + no current sprint the answer starts with NO CURRENT SPRINT; items in a state this project's process does not know + (e.g. from another project) are put in done / in progress by the state's usual name. + ## 0.8.0 — 2026-10-18 Fewer tokens in every Claude Code session: about 1.6k instead of 5.5–7k. diff --git a/install.ps1 b/install.ps1 index 2b20b04..1d1658f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -102,6 +102,21 @@ else { } Say "version: $(& $exe version)" +# "adolens" in any new terminal: the program folder on the user's PATH, as the setup EXE does. Read and written raw +# (REG_EXPAND_SZ) so entries like %USERPROFILE%\... stay as they are. +$binFull = [IO.Path]::GetFullPath($binDst).TrimEnd('\') +$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) +$userPath = [string]$envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) +$entries = @($userPath -split ';' | Where-Object { $_ }) +if (-not ($entries | Where-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\') -ieq $binFull })) { + $envKey.SetValue('Path', (($entries + $binFull) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString) + # Tells Windows (Explorer, new terminals) that the environment changed. + [Environment]::SetEnvironmentVariable('ADOLENS_PATH_REFRESH', '1', 'User') + [Environment]::SetEnvironmentVariable('ADOLENS_PATH_REFRESH', $null, 'User') + Say "added to PATH: $binFull ('adolens' works in terminals opened from now on)" +} +$envKey.Close() + Head '3/6 Azure DevOps connection' & $exe status --quiet --offline $state = $LASTEXITCODE diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 56b0a77..8acb247 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -23,7 +23,7 @@ ADO Lens for Azure DevOps Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP Copyright © 2026 WillowDev - 0.8.0 + 0.8.1 diff --git a/src/AdoLens/Attachments.cs b/src/AdoLens/Attachments.cs index 7142b24..04ed2cb 100644 --- a/src/AdoLens/Attachments.cs +++ b/src/AdoLens/Attachments.cs @@ -73,6 +73,9 @@ void From(string? html, string where) return ($"{head}: {list.Count} file(s)\n" + string.Join('\n', list.Select((a, i) => $"{i + 1}. {a.Name}{Size(a.Size)}{(a.Comment is { Length: > 0 } c ? $" - {c}" : "")}")) + "\nfile= reads one (documents as text, images and scans shown); save=true saves it to disk (any size).", []); + var same = list.Select((a, i) => (a, i)).Where(x => x.a.Name.Equals(file.Trim(), StringComparison.OrdinalIgnoreCase)).ToList(); + if (same.Count > 1) + return ($"{same.Count} files named {same[0].a.Name} on #{id}: pick one by number - {string.Join(", ", same.Select(x => $"file={x.i + 1}{Size(x.a.Size)}"))}", []); var pick = int.TryParse(file, out var n) && n >= 1 && n <= list.Count ? list[n - 1] : list.FirstOrDefault(a => a.Name.Equals(file.Trim(), StringComparison.OrdinalIgnoreCase)) ?? list.FirstOrDefault(a => a.Name.Contains(file.Trim(), StringComparison.OrdinalIgnoreCase)); diff --git a/src/AdoLens/Boards.cs b/src/AdoLens/Boards.cs index c4df9f7..a3aad0a 100644 --- a/src/AdoLens/Boards.cs +++ b/src/AdoLens/Boards.cs @@ -110,7 +110,13 @@ public static int WorkingDays(DateTime from, DateTime to, HashSet wor /// Ids in the sprint of the team and the parent of each child (tasks under their requirement). public async Task<(List Ids, Dictionary Parent)> SprintItemsAsync(string project, string team, Iteration it, CancellationToken ct) { - var root = await client.GetAsync($"{Scope(project, team)}_apis/work/teamsettings/iterations/{it.Id}/workitems", ct); + JsonElement root; + try { root = await client.GetAsync($"{Scope(project, team)}_apis/work/teamsettings/iterations/{it.Id}/workitems", ct); } + catch (AdoApiException ex) when (ex.Message.Contains("VS402337", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"{it.Name} holds more than 20 000 work items (the Azure DevOps limit for one query) - it is probably a " + + "collecting iteration, not a sprint; pick another with sprint=, or ask with ado_query"); + } var ids = new List(); var parent = new Dictionary(); foreach (var r in Json.Arr(root, "workItemRelations")) @@ -188,9 +194,22 @@ static string Category(Snapshot s, JsonElement wi) { var type = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? ""); var state = WorkItems.Field(wi, "System.State"); - return type?.States.FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category ?? "InProgress"; + return type?.States.FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category + // A type or state this project does not know (an item of another project in the sprint): any type with that state. + ?? s.Types.SelectMany(t => t.States).FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category + ?? GuessCategory(state); } + /// The category of a state no process here knows, by its usual names. + internal static string GuessCategory(string? state) => (state ?? "").Trim().ToLowerInvariant() switch + { + "done" or "closed" or "completed" or "finished" or "zamknięty" or "zakończony" or "gotowe" => "Completed", + "removed" or "cut" or "usunięty" => "Removed", + "new" or "proposed" or "to do" or "nowy" => "Proposed", + "resolved" => "Resolved", + _ => "InProgress", + }; + static string Group(string category) => category switch { "Proposed" => "not started", @@ -225,6 +244,9 @@ public static async Task SprintAsync(Lens lens, string? connection, stri var s = (await session.Meta.GetAsync(name, ct)).Snapshot; var sb = new StringBuilder(); + var asked = (sprint ?? "current").Trim(); + if (asked.Equals("current", StringComparison.OrdinalIgnoreCase) && it.Start is { } s0 && it.Finish is { } f0 && (day < s0 || day > f0)) + sb.AppendLine("NO CURRENT SPRINT for the team today - this is the " + (day > f0 ? "last one that ended:" : "next one:")); sb.Append($"{it.Name} ({t})"); if (it.Start is { } start && it.Finish is { } finish) { diff --git a/src/AdoLens/Knowledge.cs b/src/AdoLens/Knowledge.cs index bd10ce7..b163fd8 100644 --- a/src/AdoLens/Knowledge.cs +++ b/src/AdoLens/Knowledge.cs @@ -223,12 +223,19 @@ public static async Task FileAsync(Lens lens, string? connection, string ? $"&versionDescriptor.version={version}&versionDescriptor.versionType=commit" : $"&versionDescriptor.version={Uri.EscapeDataString(version)}&versionDescriptor.versionType=branch"; text = await Content(session.Client, $"_apis/git/repositories/{file.RepoId}/items?path={Uri.EscapeDataString(file.Path)}&includeContent=true{v}", ct); - where = $"{file.RepoName}: {file.Path}" + (version is null ? "" : $" @ {version}"); + where = $"{file.RepoName}: {file.Path} @ " + (version ?? $"{await DefaultBranchAsync(session.Client, file.RepoId!, ct)} (default branch; version= for another)"); } if (text is null) return $"no file {where}"; return Show(where, file.Path, text, part, detail == "full"); } + /// The default branch of a repository without "refs/heads/", or "the default branch" when unknown. + static async Task DefaultBranchAsync(AdoClient client, string repoId, CancellationToken ct) + { + try { return Json.Str(await client.GetAsync($"_apis/git/repositories/{repoId}", ct), "defaultBranch")?.Replace("refs/heads/", "") ?? "the default branch"; } + catch (AdoApiException) { return "the default branch"; } + } + internal static async Task Content(AdoClient client, string url, CancellationToken ct) { try { return Json.Str(await client.GetAsync(url, ct), "content"); } @@ -293,7 +300,7 @@ static async Task HistoryAsync(Session session, FileRef file, string? br else { var b = branch is null ? "" : $"&searchCriteria.itemVersion.version={Uri.EscapeDataString(branch)}&searchCriteria.itemVersion.versionType=branch"; - var list = await client.GetListAsync($"_apis/git/repositories/{file.RepoId}/commits?searchCriteria.itemPath={Uri.EscapeDataString(file.Path)}&searchCriteria.$top={top}{b}", ct); + var list = await client.GetListAsync($"_apis/git/repositories/{file.RepoId}/commits?searchCriteria.itemPath={Uri.EscapeDataString(file.Path)}&searchCriteria.$top={top}&searchCriteria.historyMode=fullHistorySimplifyMerges{b}", ct); // The pull request of each commit: "Merged PR n" in a merge commit, else the PR that brought the commit in. var prOf = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var c in list) @@ -334,13 +341,13 @@ static async Task HistoryAsync(Session session, FileRef file, string? br var message = Json.Str(c, "comment"); var shaKey = Json.Str(c, "commitId") ?? ""; var pr = MergedPr().IsMatch(message ?? "") && prOf.TryGetValue(shaKey, out var n) ? $"PR {n}" - : prOf.TryGetValue(shaKey, out var via) ? $"commit {(shaKey.Length > 7 ? shaKey[..7] : shaKey)} (PR {via})" : null; + : prOf.TryGetValue(shaKey, out var via) ? $"commit {shaKey} (PR {via})" : null; var sha = Json.Str(c, "commitId") ?? ""; return new Change(Date(Json.Str(Json.Obj(c, "author"), "date")), Json.Str(Json.Obj(c, "author"), "name"), - pr ?? $"commit {(sha.Length > 7 ? sha[..7] : sha)}", message, items[i]); + pr ?? $"commit {sha}", message, items[i]); })); } - var where = file.Tfvc ? file.Path : $"{file.RepoName}: {file.Path}{(branch is null ? "" : $" ({branch})")}"; + var where = file.Tfvc ? file.Path : $"{file.RepoName}: {file.Path} ({branch ?? await DefaultBranchAsync(client, file.RepoId!, ct)})"; if (changes.Count == 0) return $"no history for {where}"; var all = changes.SelectMany(c => c.Items).Distinct().ToList(); diff --git a/src/AdoLens/Program.cs b/src/AdoLens/Program.cs index 0118fc3..6f82662 100644 --- a/src/AdoLens/Program.cs +++ b/src/AdoLens/Program.cs @@ -162,11 +162,13 @@ async Task RunServerAsync() }) .WithStdioServerTransport() .WithActiveTools(lens.Settings) - .WithRequestFilters(f => f.AddListToolsFilter(next => (context, ct) => - { - ToolGroups.ForClient(lens.Settings, context.Server); - return next(context, ct); - })) + .WithRequestFilters(f => f + .AddListToolsFilter(next => (context, ct) => + { + ToolGroups.ForClient(lens.Settings, context.Server); + return next(context, ct); + }) + .AddCallToolFilter(ToolArguments.Errors)) .WithPrompts(Prompts.TypesFor(lens.Settings)); await builder.Build().RunAsync(); return 0; diff --git a/src/AdoLens/ToolArguments.cs b/src/AdoLens/ToolArguments.cs new file mode 100644 index 0000000..ee11ecf --- /dev/null +++ b/src/AdoLens/ToolArguments.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace AdoLens; + +/// +/// How tool arguments are read. Claude does not always send the JSON type of the schema: ids=714800 for a text +/// parameter, workItems=[1,2], file=1, top="20", draft="true". Without this the MCP SDK fails before the tool runs and +/// Claude only gets "An error occurred invoking …". A number or a list given for text becomes that text ("714800", +/// "1;2"), a number given as text is read as a number, "true"/"false" as a yes/no. +/// +public static class ToolArguments +{ + public static readonly JsonSerializerOptions Json = Create(); + + static JsonSerializerOptions Create() + { + var o = new JsonSerializerOptions(ModelContextProtocol.McpJsonUtilities.DefaultOptions) + { + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + o.Converters.Add(new LenientString()); + o.Converters.Add(new LenientBool()); + o.MakeReadOnly(); + return o; + } + + /// + /// A tool that reads its arguments leniently but shows Claude the plain schema (with a custom converter the schema + /// would lose its types, and number-as-text would add patterns to every number). + /// + public static McpServerTool Tool(MethodInfo method, IServiceProvider services) + { + var tool = McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = services, SerializerOptions = Json }); + tool.ProtocolTool.InputSchema = McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = services }).ProtocolTool.InputSchema; + return tool; + } + + /// Text of a value given in any JSON shape: a list becomes "a;b". + public static string Text(JsonElement e) => e.ValueKind switch + { + JsonValueKind.String => e.GetString() ?? "", + JsonValueKind.Array => string.Join(";", e.EnumerateArray().Select(Text).Where(s => s.Length > 0)), + JsonValueKind.Null or JsonValueKind.Undefined => "", + _ => e.GetRawText(), + }; + + sealed class LenientString : JsonConverter + { + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) return reader.GetString(); + if (reader.TokenType == JsonTokenType.Null) return null; + using var doc = JsonDocument.ParseValue(ref reader); + return Text(doc.RootElement); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => writer.WriteStringValue(value); + } + + sealed class LenientBool : JsonConverter + { + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number => reader.GetDouble() != 0, + JsonTokenType.String => (reader.GetString() ?? "").Trim().ToLowerInvariant() switch + { + "true" or "yes" or "1" or "tak" => true, + "false" or "no" or "0" or "nie" or "" => false, + var s => throw new JsonException($"'{s}' is not true or false"), + }, + _ => throw new JsonException("expected true or false"), + }; + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) => writer.WriteBooleanValue(value); + } + + /// + /// A failure outside the tool's own code (an argument that cannot be read at all) comes back as text Claude can act + /// on instead of the SDK's bare "An error occurred". + /// + public static McpRequestFilter Errors => next => async (context, ct) => + { + try { return await next(context, ct); } + catch (Exception ex) when (ex is ArgumentException or JsonException or FormatException or InvalidOperationException or NotSupportedException) + { + var tool = context.Params?.Name ?? "tool"; + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"{tool}: the arguments could not be read ({ex.GetType().Name}): {ex.Message}" }], + }; + } + }; +} diff --git a/src/AdoLens/ToolGroups.cs b/src/AdoLens/ToolGroups.cs index 71d3d05..d568c19 100644 --- a/src/AdoLens/ToolGroups.cs +++ b/src/AdoLens/ToolGroups.cs @@ -29,7 +29,7 @@ public static void ForClient(Settings settings, McpServer server) if (server.ServerOptions.ToolCollection is not { } tools) return; using (tools.DeferChangedEvents()) foreach (var method in Toolsets.Methods(Toolsets.Active(settings))) - tools.TryAdd(McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = server.Services })); + tools.TryAdd(ToolArguments.Tool(method, server.Services!)); } public static string Load(Settings settings, McpServer server, string? enable) @@ -68,7 +68,7 @@ public static string Load(Settings settings, McpServer server, string? enable) var added = new List(); foreach (var method in Toolsets.Methods([group])) { - var tool = McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = server.Services }); + var tool = ToolArguments.Tool(method, server.Services!); if (tools.TryAdd(tool)) added.Add(Toolsets.ToolName(method)); } result.AppendLine(added.Count == 0 diff --git a/src/AdoLens/Toolsets.cs b/src/AdoLens/Toolsets.cs index d9d57b1..7c2810d 100644 --- a/src/AdoLens/Toolsets.cs +++ b/src/AdoLens/Toolsets.cs @@ -87,7 +87,7 @@ public static Microsoft.Extensions.DependencyInjection.IMcpServerBuilder WithAct { foreach (var method in Methods(Loaded(settings))) Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton(builder.Services, - sp => McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = sp })); + sp => ToolArguments.Tool(method, sp)); return builder; } diff --git a/tests/AdoLens.Tests/ArgumentTests.cs b/tests/AdoLens.Tests/ArgumentTests.cs new file mode 100644 index 0000000..47ee48d --- /dev/null +++ b/tests/AdoLens.Tests/ArgumentTests.cs @@ -0,0 +1,48 @@ +using System.Text.Json; + +namespace AdoLens.Tests; + +public class ArgumentTests +{ + static T? Read(string json) => JsonSerializer.Deserialize(json, ToolArguments.Json); + + [Fact] + public void Arguments_in_another_JSON_shape_are_still_read() + { + // Claude sends ids=714800 or workItems=[1,2] for text parameters, numbers and yes/no as text. + Assert.Equal("714800", Read("714800")); + Assert.Equal("1;2", Read("[1, 2]")); + Assert.Equal("a;b", Read("[\"a\", \"b\"]")); + Assert.Equal("true", Read("true")); + Assert.Null(Read("null")); + Assert.Equal(5, Read("\"5\"")); + Assert.Equal(3, Read("3")); + Assert.True(Read("\"true\"")); + Assert.False(Read("\"no\"")); + Assert.True(Read("1")); + Assert.Throws(() => Read("\"maybe\"")); + } + + [Fact] + public void Lenient_tools_still_show_typed_schemas() + { + var method = typeof(WorkTools).GetMethod(nameof(WorkTools.Update))!; + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton(services, + new Lens(new Settings(), "0.0.0", Path.GetTempPath(), _ => null, _ => null)); + var sp = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services); + var schema = ToolArguments.Tool(method, sp).ProtocolTool.InputSchema.GetRawText(); + Assert.Contains("\"type\":[\"string\",\"null\"]", schema.Replace(" ", "")); + Assert.DoesNotContain("\"pattern\"", schema); + Assert.DoesNotContain("\"lens\"", schema); + } + + [Theory] + [InlineData("Done", "Completed")] + [InlineData("Closed", "Completed")] + [InlineData("Removed", "Removed")] + [InlineData("New", "Proposed")] + [InlineData("Do wdrożenia", "InProgress")] + public void Unknown_states_get_a_category_by_their_usual_name(string state, string category) => + Assert.Equal(category, BoardQueries.GuessCategory(state)); +} diff --git a/tests/AdoLens.Tests/KnowledgeTests.cs b/tests/AdoLens.Tests/KnowledgeTests.cs index bb9256c..6d872e9 100644 --- a/tests/AdoLens.Tests/KnowledgeTests.cs +++ b/tests/AdoLens.Tests/KnowledgeTests.cs @@ -131,9 +131,9 @@ public async Task A_file_found_by_name_shows_X_methods_and_one_method_on_request public async Task File_history_names_the_work_items_behind_each_change() { var text = await Knowledge.FileAsync(lens, null, null, "/src/AxClass/CreditCheck.xml", "erp", null, null, true, 15, null); - Assert.Contains("history of erp: /src/AxClass/CreditCheck.xml: 2 changes", text); + Assert.Contains("history of erp: /src/AxClass/CreditCheck.xml (the default branch): 2 changes", text); Assert.Contains("- 2026-10-10 Ben Kowalski - PR 482: Credit limit check -> #1 Login page (New)", text); - Assert.Contains("- 2026-09-01 Anna Nowak - commit bbbbbbb: Typo", text); + Assert.Contains("- 2026-09-01 Anna Nowak - commit bbbbbbbb22: Typo", text); Assert.Contains("1 of 2 changes have no work item", text); } @@ -250,7 +250,7 @@ public async Task A_large_attachment_gets_a_link_and_is_not_downloaded() public async Task The_history_of_a_whole_repository_or_folder() { var text = await Knowledge.FileAsync(lens, null, null, "/", "erp", null, null, true, 15, null); - Assert.Contains("history of erp: /: 2 changes", text); + Assert.Contains("history of erp: / (the default branch): 2 changes", text); Assert.Contains("PR 482: Credit limit check -> #1 Login page (New)", text); Assert.Contains("is a folder", await Knowledge.FileAsync(lens, null, null, "/", "erp", null, null, false, 15, null)); } diff --git a/tests/AdoLens.Tests/SetupTests.cs b/tests/AdoLens.Tests/SetupTests.cs index 23b9df7..cbf3b8f 100644 --- a/tests/AdoLens.Tests/SetupTests.cs +++ b/tests/AdoLens.Tests/SetupTests.cs @@ -209,8 +209,8 @@ public void Pat_round_trips_through_windows_credential_manager() try { Assert.Null(CredentialStore.Read(name)); - CredentialStore.Write(name, "contoso", "token-Ä…Ä™-123"); - Assert.Equal("token-Ä…Ä™-123", CredentialStore.Read(name)); + CredentialStore.Write(name, "contoso", "token-ąę-123"); + Assert.Equal("token-ąę-123", CredentialStore.Read(name)); CredentialStore.Write(name, "contoso", "replaced"); Assert.Equal("replaced", CredentialStore.Read(name)); Assert.True(CredentialStore.Delete(name)); diff --git a/uninstall.ps1 b/uninstall.ps1 index ee31674..a8bc7fe 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -54,6 +54,18 @@ if (Test-Path $skillSrc) { } } +# The program folder off the user's PATH (raw, so other %VARIABLE% entries stay as they are). +$binFull = [IO.Path]::GetFullPath((Join-Path $InstallDir 'bin')).TrimEnd('\') +$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) +$userPath = [string]$envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) +$kept = @($userPath -split ';' | Where-Object { $_ -and [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\') -ine $binFull }) +if ($kept.Count -ne @($userPath -split ';' | Where-Object { $_ }).Count) { + $envKey.SetValue('Path', ($kept -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString) + [Environment]::SetEnvironmentVariable('ADOLENS_PATH_REFRESH', '1', 'User') + [Environment]::SetEnvironmentVariable('ADOLENS_PATH_REFRESH', $null, 'User') + Write-Host " removed $binFull from PATH" +} +$envKey.Close() if ($RemoveData -and (Test-Path $data)) { Remove-Item $data -Recurse -Force; Write-Host " removed $data" } # This script lives in the folder it removes: delete everything else now, the folder itself after it exits. Get-ChildItem $InstallDir -Force | Where-Object { $_.FullName -ne $PSCommandPath } | Remove-Item -Recurse -Force