Skip to content

Commit 739711a

Browse files
committed
Improve update command: skill updates, downgrade support, reliable bridge kill
- Don't skip package/skill updates when CLI is already up-to-date (#17) - Kill all bridge processes by name instead of graceful stop (fixes DLL lock "Access denied" on Windows) - Support version downgrades via uninstall+install fallback - Add skill update step that refreshes installed skill locations - Add --skip-skill option consistent with setup command
1 parent 7b960fc commit 739711a

2 files changed

Lines changed: 213 additions & 32 deletions

File tree

UnityCtl.Cli/SkillCommands.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,22 @@ private static string GetSkillsDirectory(bool global, string? customClaudeDir)
162162
return reader.ReadToEnd();
163163
}
164164

165+
/// <summary>
166+
/// Updates the skill file at the given path with the latest embedded content.
167+
/// Returns true if successful, false if embedded content was not found.
168+
/// </summary>
169+
public static async Task<bool> UpdateSkillFileAsync(string skillPath)
170+
{
171+
var content = GetEmbeddedSkillContent();
172+
if (content == null) return false;
173+
174+
var dir = Path.GetDirectoryName(skillPath);
175+
if (dir != null) Directory.CreateDirectory(dir);
176+
177+
await File.WriteAllTextAsync(skillPath, content);
178+
return true;
179+
}
180+
165181
public static async Task AddSkillAsync(bool global, string? claudeDir, bool force, bool json)
166182
{
167183
var skillsDir = GetSkillsDirectory(global, claudeDir);

UnityCtl.Cli/UpdateCommands.cs

Lines changed: 197 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public static Command CreateCommand()
3030

3131
var toolsOnlyOption = new Option<bool>(
3232
"--tools-only",
33-
"Only update CLI and Bridge tools (skip Unity package)"
33+
"Only update CLI and Bridge tools (skip Unity package and skill)"
3434
);
3535

3636
var packageOnlyOption = new Option<bool>(
@@ -50,11 +50,17 @@ public static Command CreateCommand()
5050
);
5151
yesOption.AddAlias("-y");
5252

53+
var skipSkillOption = new Option<bool>(
54+
"--skip-skill",
55+
"Skip Claude Code skill update"
56+
);
57+
5358
updateCommand.AddOption(checkOption);
5459
updateCommand.AddOption(toolsOnlyOption);
5560
updateCommand.AddOption(packageOnlyOption);
5661
updateCommand.AddOption(versionOption);
5762
updateCommand.AddOption(yesOption);
63+
updateCommand.AddOption(skipSkillOption);
5864

5965
updateCommand.SetHandler(async (InvocationContext context) =>
6066
{
@@ -64,9 +70,10 @@ public static Command CreateCommand()
6470
var packageOnly = context.ParseResult.GetValueForOption(packageOnlyOption);
6571
var version = context.ParseResult.GetValueForOption(versionOption);
6672
var yes = context.ParseResult.GetValueForOption(yesOption);
73+
var skipSkill = context.ParseResult.GetValueForOption(skipSkillOption);
6774
var json = ContextHelper.GetJson(context);
6875

69-
await ExecuteAsync(projectPath, check, toolsOnly, packageOnly, version, yes, json);
76+
await ExecuteAsync(projectPath, check, toolsOnly, packageOnly, version, yes, skipSkill, json);
7077
});
7178

7279
return updateCommand;
@@ -100,12 +107,6 @@ private enum UpdateResult { Success, Scheduled, Failed }
100107

101108
private static async Task<UpdateResult> RunDotnetToolUpdateAsync(string packageId, string? version = null, bool isSelf = false)
102109
{
103-
var args = $"tool update -g {packageId}";
104-
if (!string.IsNullOrEmpty(version))
105-
{
106-
args += $" --version {version}";
107-
}
108-
109110
// On Windows, updating the currently-running CLI requires a workaround:
110111
// spawn a script that waits for this process to exit, then performs the update
111112
if (isSelf && OperatingSystem.IsWindows())
@@ -114,6 +115,12 @@ private static async Task<UpdateResult> RunDotnetToolUpdateAsync(string packageI
114115
return scheduled ? UpdateResult.Scheduled : UpdateResult.Failed;
115116
}
116117

118+
var args = $"tool update -g {packageId}";
119+
if (!string.IsNullOrEmpty(version))
120+
{
121+
args += $" --version {version}";
122+
}
123+
117124
var psi = new ProcessStartInfo
118125
{
119126
FileName = "dotnet",
@@ -129,8 +136,71 @@ private static async Task<UpdateResult> RunDotnetToolUpdateAsync(string packageI
129136
using var process = Process.Start(psi);
130137
if (process == null) return UpdateResult.Failed;
131138

139+
var stderr = await process.StandardError.ReadToEndAsync();
132140
await process.WaitForExitAsync();
133-
return process.ExitCode == 0 ? UpdateResult.Success : UpdateResult.Failed;
141+
142+
if (process.ExitCode == 0)
143+
return UpdateResult.Success;
144+
145+
// If a specific version was requested and update failed, try uninstall+install
146+
// (dotnet tool update rejects versions lower than current)
147+
if (!string.IsNullOrEmpty(version))
148+
{
149+
return await UninstallAndInstallAsync(packageId, version);
150+
}
151+
152+
return UpdateResult.Failed;
153+
}
154+
catch
155+
{
156+
return UpdateResult.Failed;
157+
}
158+
}
159+
160+
private static async Task<UpdateResult> UninstallAndInstallAsync(string packageId, string version)
161+
{
162+
// Uninstall
163+
var uninstallPsi = new ProcessStartInfo
164+
{
165+
FileName = "dotnet",
166+
Arguments = $"tool uninstall -g {packageId}",
167+
UseShellExecute = false,
168+
RedirectStandardOutput = true,
169+
RedirectStandardError = true,
170+
CreateNoWindow = true
171+
};
172+
173+
try
174+
{
175+
using var uninstallProcess = Process.Start(uninstallPsi);
176+
if (uninstallProcess == null) return UpdateResult.Failed;
177+
178+
await uninstallProcess.WaitForExitAsync();
179+
if (uninstallProcess.ExitCode != 0) return UpdateResult.Failed;
180+
}
181+
catch
182+
{
183+
return UpdateResult.Failed;
184+
}
185+
186+
// Install specific version
187+
var installPsi = new ProcessStartInfo
188+
{
189+
FileName = "dotnet",
190+
Arguments = $"tool install -g {packageId} --version {version}",
191+
UseShellExecute = false,
192+
RedirectStandardOutput = true,
193+
RedirectStandardError = true,
194+
CreateNoWindow = true
195+
};
196+
197+
try
198+
{
199+
using var installProcess = Process.Start(installPsi);
200+
if (installProcess == null) return UpdateResult.Failed;
201+
202+
await installProcess.WaitForExitAsync();
203+
return installProcess.ExitCode == 0 ? UpdateResult.Success : UpdateResult.Failed;
134204
}
135205
catch
136206
{
@@ -150,7 +220,7 @@ private static async Task<bool> ScheduleSelfUpdateViaScriptAsync(string packageI
150220

151221
// Create a temporary PowerShell script that:
152222
// 1. Waits for the current process to exit
153-
// 2. Runs the dotnet tool update command
223+
// 2. Runs the dotnet tool update command (with uninstall+install fallback for downgrades)
154224
// 3. Deletes itself
155225
var tempDir = Path.GetTempPath();
156226
var scriptPath = Path.Combine(tempDir, $"unityctl-update-{Guid.NewGuid():N}.ps1");
@@ -170,8 +240,13 @@ private static async Task<bool> ScheduleSelfUpdateViaScriptAsync(string packageI
170240
}}
171241
}}
172242
173-
# Run the update
243+
# Try update first
174244
dotnet tool update -g {packageId}{versionArg} 2>&1 | Out-Null
245+
if ($LASTEXITCODE -ne 0 -and '{version}' -ne '') {{
246+
# Fallback to uninstall+install for downgrades
247+
dotnet tool uninstall -g {packageId} 2>&1 | Out-Null
248+
dotnet tool install -g {packageId}{versionArg} 2>&1 | Out-Null
249+
}}
175250
176251
# Clean up this script
177252
Remove-Item -Path $MyInvocation.MyCommand.Path -Force
@@ -217,6 +292,7 @@ private static async Task ExecuteAsync(
217292
bool packageOnly,
218293
string? targetVersion,
219294
bool yes,
295+
bool skipSkill,
220296
bool json)
221297
{
222298
var currentVersion = VersionInfo.Version;
@@ -241,6 +317,7 @@ private static async Task ExecuteAsync(
241317

242318
var isUpToDate = currentParsed >= targetParsed && targetVersion == null;
243319

320+
// Display version info and handle --check
244321
if (json)
245322
{
246323
Console.WriteLine(JsonHelper.Serialize(new
@@ -252,7 +329,7 @@ private static async Task ExecuteAsync(
252329
checkOnly
253330
}));
254331

255-
if (checkOnly || isUpToDate)
332+
if (checkOnly)
256333
return;
257334
}
258335
else
@@ -267,22 +344,29 @@ private static async Task ExecuteAsync(
267344

268345
Console.WriteLine();
269346

270-
if (isUpToDate)
347+
if (checkOnly)
271348
{
272-
Console.WriteLine("You are already running the latest version.");
349+
if (isUpToDate)
350+
Console.WriteLine("You are already running the latest version.");
351+
else
352+
{
353+
Console.WriteLine($"Update available: {currentVersion} -> {effectiveTargetVersion}");
354+
Console.WriteLine("Run 'unityctl update' to install the update.");
355+
}
273356
return;
274357
}
275358

276-
if (checkOnly)
359+
if (isUpToDate)
277360
{
278-
Console.WriteLine($"Update available: {currentVersion} -> {effectiveTargetVersion}");
279-
Console.WriteLine("Run 'unityctl update' to install the update.");
280-
return;
361+
Console.WriteLine("CLI and Bridge are already up to date.");
362+
if (toolsOnly)
363+
return;
364+
Console.WriteLine();
281365
}
282366
}
283367

284-
// Confirm update
285-
if (!yes && !json)
368+
// Confirm update (only when tools will be updated)
369+
if (!yes && !json && !isUpToDate)
286370
{
287371
Console.Write($"Update to version {effectiveTargetVersion}? [y/N]: ");
288372
var response = Console.ReadLine()?.Trim().ToLowerInvariant();
@@ -297,26 +381,40 @@ private static async Task ExecuteAsync(
297381
var results = new List<UpdateStepResult>();
298382

299383
// Update CLI and Bridge tools
300-
if (!packageOnly)
384+
if (!packageOnly && !isUpToDate)
301385
{
302386
Console.WriteLine("Updating CLI tools...");
303387
Console.WriteLine();
304388

305-
// Check if bridge is running and stop it
306-
var bridgeWasRunning = false;
389+
// Save current project's bridge config for restart later
307390
var projectRoot = projectPath ?? ProjectLocator.FindProjectRoot();
308391
BridgeConfig? bridgeConfig = null;
309392

310393
if (projectRoot != null)
311394
{
312395
bridgeConfig = ProjectLocator.ReadBridgeConfig(projectRoot);
313-
if (bridgeConfig != null)
396+
}
397+
398+
// Kill ALL bridge processes to release DLL locks
399+
var bridgeProcesses = Process.GetProcessesByName("unityctl-bridge");
400+
if (bridgeProcesses.Length > 0)
401+
{
402+
Console.WriteLine($" Stopping {bridgeProcesses.Length} bridge process(es)...");
403+
foreach (var proc in bridgeProcesses)
314404
{
315-
Console.WriteLine(" Stopping bridge daemon...");
316-
await BridgeClient.StopBridgeAsync(projectPath);
317-
bridgeWasRunning = true;
318-
// Give it a moment to shut down
319-
await Task.Delay(1000);
405+
try { proc.Kill(true); } catch { }
406+
proc.Dispose();
407+
}
408+
409+
// Wait for processes to fully exit (max 10s) to release DLL locks
410+
var sw = Stopwatch.StartNew();
411+
while (sw.Elapsed < TimeSpan.FromSeconds(10))
412+
{
413+
var remaining = Process.GetProcessesByName("unityctl-bridge");
414+
var anyLeft = remaining.Length > 0;
415+
foreach (var p in remaining) p.Dispose();
416+
if (!anyLeft) break;
417+
await Task.Delay(500);
320418
}
321419
}
322420

@@ -352,8 +450,8 @@ private static async Task ExecuteAsync(
352450
});
353451
Console.WriteLine(bridgeSuccess ? $" Updated {BridgePackageId}" : $" Failed to update {BridgePackageId}");
354452

355-
// Restart bridge if it was running
356-
if (bridgeWasRunning && bridgeSuccess)
453+
// Restart bridge if it was running for the current project
454+
if (bridgeConfig != null && bridgeSuccess)
357455
{
358456
Console.WriteLine(" Restarting bridge daemon...");
359457
await BridgeClient.StartBridgeAsync(projectPath);
@@ -403,7 +501,74 @@ private static async Task ExecuteAsync(
403501
Console.WriteLine();
404502
}
405503

406-
// Summary
504+
// Update Claude Code skill
505+
if (!toolsOnly && !packageOnly && !skipSkill)
506+
{
507+
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
508+
var globalSkillPath = Path.Combine(home, ".claude", "skills", "unity-editor", "SKILL.md");
509+
var localSkillPath = Path.Combine(Directory.GetCurrentDirectory(), ".claude", "skills", "unity-editor", "SKILL.md");
510+
511+
var globalExists = File.Exists(globalSkillPath);
512+
var localExists = File.Exists(localSkillPath);
513+
514+
if (globalExists || localExists)
515+
{
516+
Console.WriteLine("Updating Claude Code skill...");
517+
518+
try
519+
{
520+
var updated = new List<string>();
521+
522+
if (globalExists)
523+
{
524+
if (await SkillCommands.UpdateSkillFileAsync(globalSkillPath))
525+
updated.Add("global");
526+
}
527+
528+
if (localExists)
529+
{
530+
if (await SkillCommands.UpdateSkillFileAsync(localSkillPath))
531+
updated.Add("local");
532+
}
533+
534+
if (updated.Count > 0)
535+
{
536+
Console.WriteLine($" Updated skill ({string.Join(" + ", updated)})");
537+
results.Add(new UpdateStepResult
538+
{
539+
Step = "Claude Code Skill",
540+
Success = true
541+
});
542+
}
543+
else
544+
{
545+
Console.Error.WriteLine(" Could not find embedded skill content");
546+
results.Add(new UpdateStepResult
547+
{
548+
Step = "Claude Code Skill",
549+
Success = false,
550+
Error = "Embedded SKILL.md resource not found"
551+
});
552+
}
553+
}
554+
catch (Exception ex)
555+
{
556+
results.Add(new UpdateStepResult
557+
{
558+
Step = "Claude Code Skill",
559+
Success = false,
560+
Error = ex.Message
561+
});
562+
}
563+
564+
Console.WriteLine();
565+
}
566+
}
567+
568+
// Summary (only if any steps ran)
569+
if (results.Count == 0)
570+
return;
571+
407572
if (!json)
408573
{
409574
Console.WriteLine("========================================");

0 commit comments

Comments
 (0)