diff --git a/README.md b/README.md
index 03c4822..2635b7b 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ mv skillview-osx-arm /usr/local/bin/skillview
skillview
```
-SkillView requires **GitHub CLI 2.94.0 or newer** — the release that ships the full `gh skill` surface (`skill list`, `install --all`, `update --all`, nested-directory discovery). Standalone releases are Native AOT and self-contained, so they do **not** need a separate .NET runtime.
+SkillView requires **GitHub CLI 2.95.0 or newer** — 2.94.0 first shipped the full `gh skill` surface (`skill list`, `install --all`, `update --all`, nested-directory discovery), and 2.95.0 made `gh skill update` atomic and in-place ([cli/cli#13449](https://github.com/cli/cli/pull/13449)), which SkillView's Updates tab relies on. Standalone releases are Native AOT and self-contained, so they do **not** need a separate .NET runtime.
It ships as both:
@@ -73,7 +73,7 @@ SkillView builds on GitHub CLI's preview `gh skill` support. If you are new to t
## Requirements
-- **GitHub CLI** `gh` **2.94.0 or newer**
+- **GitHub CLI** `gh` **2.95.0 or newer**
- a working `gh` setup; `gh auth login` is recommended
- a terminal with normal ANSI TUI support; truecolor (24-bit) terminals get the full warm palette, others fall back to the nearest 256-color match
diff --git a/src/SkillView.Core/Cli/CliDispatcher.cs b/src/SkillView.Core/Cli/CliDispatcher.cs
index b88bcb0..bccb746 100644
--- a/src/SkillView.Core/Cli/CliDispatcher.cs
+++ b/src/SkillView.Core/Cli/CliDispatcher.cs
@@ -83,7 +83,7 @@ internal static string RenderDoctorText(EnvironmentReport r, AppOptions options)
sb.AppendLine($"gh version : {r.GhVersionRaw ?? "(unknown)"}");
sb.AppendLine($"gh minimum : {GhBinaryLocator.MinimumVersion}{(r.GhMeetsMinimum ? " ✓" : " ✗ too old")}");
sb.AppendLine($"gh auth : {AuthSummary(r.Auth)}");
- sb.AppendLine($"gh skill : {(r.GhSkillAvailable ? "present (gh ≥ 2.94 — full skill surface)" : "(not detected)")}");
+ sb.AppendLine($"gh skill : {(r.GhSkillAvailable ? "present (gh ≥ 2.95 — full skill surface)" : "(not detected)")}");
sb.AppendLine($"debug : {options.Debug}");
sb.AppendLine($"log directory : {r.LogDirectory ?? "(unset)"}");
sb.AppendLine($"scan roots : {(options.ScanRoots.Count == 0 ? "(default)" : string.Join(", ", options.ScanRoots))}");
@@ -131,7 +131,7 @@ internal static string RenderDoctorJson(EnvironmentReport r, AppOptions options)
writer.WriteEndArray();
writer.WriteEndObject();
- // gh ≥ 2.94 is required, so the full `gh skill` flag surface is
+ // gh ≥ 2.95 is required, so the full `gh skill` flag surface is
// guaranteed once the command is present — a single availability
// bool replaces the old per-flag capability probe.
writer.WriteBoolean("ghSkillAvailable", r.GhSkillAvailable);
diff --git a/src/SkillView.Core/Environment/EnvironmentProbe.cs b/src/SkillView.Core/Environment/EnvironmentProbe.cs
index 4bb79c8..ef2b34f 100644
--- a/src/SkillView.Core/Environment/EnvironmentProbe.cs
+++ b/src/SkillView.Core/Environment/EnvironmentProbe.cs
@@ -8,7 +8,7 @@ namespace SkillView.Diagnostics;
/// `EnvironmentReport`. Each probe tolerates the previous failing — a missing
/// `gh` yields an empty report rather than throwing.
///
-/// SkillView requires gh ≥ 2.94.0, which guarantees the full `gh skill`
+/// SkillView requires gh ≥ 2.95.0, which guarantees the full `gh skill`
/// surface, so there is no per-flag capability probe — a single
/// `gh skill --help` smoke check confirms the command is present.
public sealed class EnvironmentProbe
diff --git a/src/SkillView.Core/Environment/EnvironmentReport.cs b/src/SkillView.Core/Environment/EnvironmentReport.cs
index ac4b48f..1923b9c 100644
--- a/src/SkillView.Core/Environment/EnvironmentReport.cs
+++ b/src/SkillView.Core/Environment/EnvironmentReport.cs
@@ -5,7 +5,7 @@ namespace SkillView.Diagnostics;
/// Composite environment snapshot used by Doctor (CLI + TUI) and startup
/// checks. Built by `EnvironmentProbe`.
///
-/// SkillView requires gh ≥ 2.94.0 (see ),
+/// SkillView requires gh ≥ 2.95.0 (see ),
/// which guarantees the full `gh skill` surface — `list`/`install --all`/
/// `update --all` and their flags — so there is no per-flag capability probe:
/// meeting the minimum implies the feature set.
diff --git a/src/SkillView.Core/Gh/GhBinaryLocator.cs b/src/SkillView.Core/Gh/GhBinaryLocator.cs
index adc5313..94c1662 100644
--- a/src/SkillView.Core/Gh/GhBinaryLocator.cs
+++ b/src/SkillView.Core/Gh/GhBinaryLocator.cs
@@ -9,11 +9,15 @@ namespace SkillView.Gh;
/// the version meets SkillView's hard minimum.
public sealed class GhBinaryLocator
{
- /// Minimum supported `gh` — 2.94.0 ships the full `gh skill` surface
+ /// Minimum supported `gh`. 2.94.0 first shipped the full `gh skill` surface
/// SkillView relies on (`skill list`, `install --all`, `update --all`,
- /// nested-dir discovery). Meeting this minimum guarantees the feature set,
- /// so SkillView does not probe individual flags.
- public static readonly SemVer MinimumVersion = new(2, 94, 0);
+ /// nested-dir discovery), but 2.95.0 (cli/cli#13449) fixes a data-loss bug
+ /// where `gh skill update` could relocate namespaced skills and delete the
+ /// original install directory — and SkillView's Updates tab drives
+ /// `gh skill update --all`. The minimum is therefore 2.95.0 so every user
+ /// gets the atomic in-place update path. Meeting this minimum guarantees the
+ /// feature set, so SkillView does not probe individual flags.
+ public static readonly SemVer MinimumVersion = new(2, 95, 0);
private readonly ProcessRunner _runner;
private readonly Logger _logger;
diff --git a/src/SkillView.Core/Gh/GhSkillInstallService.cs b/src/SkillView.Core/Gh/GhSkillInstallService.cs
index 1806b34..301f59a 100644
--- a/src/SkillView.Core/Gh/GhSkillInstallService.cs
+++ b/src/SkillView.Core/Gh/GhSkillInstallService.cs
@@ -1,10 +1,11 @@
+using System.Collections.Immutable;
using SkillView.Gh.Models;
using SkillView.Logging;
using SkillView.Subprocess;
namespace SkillView.Gh;
-/// Wraps `gh skill install`. SkillView requires gh ≥ 2.94.0, so every flag it
+/// Wraps `gh skill install`. SkillView requires gh ≥ 2.95.0, so every flag it
/// emits (`--all`, `--allow-hidden-dirs`, `--upstream`, `--agent` repeatable,
/// `--from-local`, `--scope`, `--dir`, `--pin`, `--force`) is guaranteed to
/// exist — there is no per-flag capability gating. A custom directory maps to
@@ -62,6 +63,133 @@ public async Task InstallAsync(
};
}
+ /// Discover the skills a repository offers without installing anything.
+ /// Runs `gh skill install ` with no skill name and no `--all`; in a
+ /// non-interactive context (stdout is captured, so gh sees a non-TTY) gh
+ /// ≥ 2.95.0 lists the discovered skills as tab-separated rows instead of
+ /// erroring (cli/cli#13548). The repo is the only thing installed-from, but
+ /// nothing is written to disk — this is a read-only discovery call.
+ public async Task ListRepoSkillsAsync(
+ string ghPath,
+ string repo,
+ string? version = null,
+ bool allowHiddenDirs = false,
+ CancellationToken cancellationToken = default)
+ {
+ var args = BuildListArgs(repo, version, allowHiddenDirs);
+ var result = await _runner.RunAsync(ghPath, args, cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (!result.Succeeded)
+ {
+ _logger.Warn("gh.skill.install.list", $"exit={result.ExitCode} err={result.StdErr.Trim()}");
+ return RepoSkillListing.Failure(repo, version, result.ExitCode, result.StdErr.Trim(), args);
+ }
+
+ var skills = ParseRepoSkillListing(result.StdOut);
+ _logger.Info("gh.skill.install.list", $"{repo} → {skills.Length} skill(s) discovered");
+ return new RepoSkillListing
+ {
+ Repo = repo,
+ Version = version,
+ Skills = skills,
+ Succeeded = true,
+ ExitCode = 0,
+ ErrorMessage = null,
+ CommandLine = args,
+ };
+ }
+
+ internal static IReadOnlyList BuildListArgs(
+ string repo,
+ string? version,
+ bool allowHiddenDirs)
+ {
+ // Deliberately no skill name and no `--all`: that combination triggers
+ // gh's non-interactive "list available skills" path (cli/cli#13548).
+ var args = new List { "skill", "install" };
+ args.Add(string.IsNullOrEmpty(version) ? repo : $"{repo}@{version}");
+ if (allowHiddenDirs)
+ {
+ args.Add("--allow-hidden-dirs");
+ }
+ return args;
+ }
+
+ /// Parse the tab-separated `SKILL\tDESCRIPTION` rows gh emits when the
+ /// install picker is rendered to a pipe (cli/cli#13548). Tolerant by
+ /// design: blank lines are skipped, a `name`-only line (no tab) keeps an
+ /// empty description, and an optional `SKILL`/`NAME` header row is dropped.
+ internal static ImmutableArray ParseRepoSkillListing(string? stdout)
+ {
+ if (string.IsNullOrWhiteSpace(stdout))
+ {
+ return ImmutableArray.Empty;
+ }
+
+ var builder = ImmutableArray.CreateBuilder();
+ var seen = new HashSet(StringComparer.Ordinal);
+ var lines = stdout.Replace("\r\n", "\n").Split('\n');
+ foreach (var raw in lines)
+ {
+ var line = raw.TrimEnd();
+ if (line.Length == 0)
+ {
+ continue;
+ }
+
+ var tab = line.IndexOf('\t');
+ var name = (tab >= 0 ? line[..tab] : line).Trim();
+ var description = tab >= 0 ? line[(tab + 1)..].Trim() : string.Empty;
+ if (name.Length == 0)
+ {
+ continue;
+ }
+
+ // Drop a header row if gh ever emits one in TTY-table mode.
+ if (description.Length > 0
+ && (name.Equals("SKILL", StringComparison.OrdinalIgnoreCase)
+ || name.Equals("NAME", StringComparison.OrdinalIgnoreCase))
+ && (description.Equals("DESCRIPTION", StringComparison.OrdinalIgnoreCase)))
+ {
+ continue;
+ }
+
+ if (seen.Add(name))
+ {
+ builder.Add(new RepoSkill(name, description));
+ }
+ }
+
+ return builder.ToImmutable();
+ }
+
+ /// How to install a chosen subset of a repo's discovered skills. When the
+ /// user keeps every discovered skill checked, a single `--all` install is
+ /// cheaper and matches the existing install-all path; otherwise each
+ /// selected skill is installed by name.
+ public readonly record struct InstallPlan(bool UseAll, ImmutableArray SkillNames)
+ {
+ public bool IsEmpty => !UseAll && SkillNames.IsDefaultOrEmpty;
+ }
+
+ /// Pure: map (all discovered skills, set of checked names) to an
+ /// . Selecting every skill collapses to `--all`.
+ public static InstallPlan BuildInstallPlan(
+ IReadOnlyList discovered,
+ IReadOnlyCollection selectedNames)
+ {
+ var selected = discovered
+ .Where(s => selectedNames.Contains(s.Name))
+ .Select(s => s.Name)
+ .ToImmutableArray();
+
+ if (selected.Length > 0 && selected.Length == discovered.Count)
+ {
+ return new InstallPlan(UseAll: true, ImmutableArray.Empty);
+ }
+
+ return new InstallPlan(UseAll: false, selected);
+ }
+
internal static IReadOnlyList BuildArgs(
string repo,
string? skillName,
diff --git a/src/SkillView.Core/Gh/GhSkillSearchService.cs b/src/SkillView.Core/Gh/GhSkillSearchService.cs
index beb5272..d2112ab 100644
--- a/src/SkillView.Core/Gh/GhSkillSearchService.cs
+++ b/src/SkillView.Core/Gh/GhSkillSearchService.cs
@@ -6,7 +6,7 @@
namespace SkillView.Gh;
-/// Wraps `gh skill search --json`. SkillView requires gh ≥ 2.94.0, which
+/// Wraps `gh skill search --json`. SkillView requires gh ≥ 2.95.0, which
/// exposes `--json`, `--owner`, and `--page` (it paginates rather than the
/// pre-2.94 `--limit`). `--owner`/`--page` emit unconditionally; `Limit` is
/// applied client-side as a result cap.
diff --git a/src/SkillView.Core/Gh/GhSkillUpdateService.cs b/src/SkillView.Core/Gh/GhSkillUpdateService.cs
index 0a7adc8..d5a85c9 100644
--- a/src/SkillView.Core/Gh/GhSkillUpdateService.cs
+++ b/src/SkillView.Core/Gh/GhSkillUpdateService.cs
@@ -5,7 +5,7 @@
namespace SkillView.Gh;
-/// Wraps `gh skill update`. SkillView requires gh ≥ 2.94.0, so `--dry-run`,
+/// Wraps `gh skill update`. SkillView requires gh ≥ 2.95.0, so `--dry-run`,
/// `--all`, `--force`, and `--unpin` are all guaranteed; flags emit
/// unconditionally and positional skill names pass straight through.
///
diff --git a/src/SkillView.Core/Gh/Models/RepoSkill.cs b/src/SkillView.Core/Gh/Models/RepoSkill.cs
new file mode 100644
index 0000000..01010b3
--- /dev/null
+++ b/src/SkillView.Core/Gh/Models/RepoSkill.cs
@@ -0,0 +1,7 @@
+namespace SkillView.Gh.Models;
+
+/// One skill discovered inside a single repository, as surfaced by
+/// `gh skill install ` run non-interactively (gh ≥ 2.95.0,
+/// cli/cli#13548). When stdout is piped, gh renders the picker contents as
+/// tab-separated `SKILL\tDESCRIPTION` rows; this is the parsed shape.
+public sealed record RepoSkill(string Name, string Description);
diff --git a/src/SkillView.Core/Gh/Models/RepoSkillListing.cs b/src/SkillView.Core/Gh/Models/RepoSkillListing.cs
new file mode 100644
index 0000000..d4fe47c
--- /dev/null
+++ b/src/SkillView.Core/Gh/Models/RepoSkillListing.cs
@@ -0,0 +1,32 @@
+using System.Collections.Immutable;
+
+namespace SkillView.Gh.Models;
+
+/// Result of discovering the skills available in a repository via
+/// `gh skill install ` run non-interactively (gh ≥ 2.95.0,
+/// cli/cli#13548). On success, holds the parsed rows;
+/// on failure the listing is empty and carries the
+/// trimmed stderr.
+public sealed record RepoSkillListing
+{
+ public required string Repo { get; init; }
+ public string? Version { get; init; }
+ public required ImmutableArray Skills { get; init; }
+ public required bool Succeeded { get; init; }
+ public required int ExitCode { get; init; }
+ public string? ErrorMessage { get; init; }
+ public required IReadOnlyList CommandLine { get; init; }
+
+ public static RepoSkillListing Failure(
+ string repo, string? version, int exitCode, string? error, IReadOnlyList commandLine) =>
+ new()
+ {
+ Repo = repo,
+ Version = version,
+ Skills = ImmutableArray.Empty,
+ Succeeded = false,
+ ExitCode = exitCode,
+ ErrorMessage = error,
+ CommandLine = commandLine,
+ };
+}
diff --git a/src/SkillView.Core/Inventory/LocalInventoryService.cs b/src/SkillView.Core/Inventory/LocalInventoryService.cs
index 25eb5f0..0b2ede6 100644
--- a/src/SkillView.Core/Inventory/LocalInventoryService.cs
+++ b/src/SkillView.Core/Inventory/LocalInventoryService.cs
@@ -47,7 +47,8 @@ public async Task CaptureAsync(
var roots = _resolver.Resolve(new ScanRootResolver.Options(
CurrentDirectory: Environment.CurrentDirectory,
HomeDirectory: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- CustomRoots: options.ScanRoots));
+ CustomRoots: options.ScanRoots,
+ ClaudeUserConfigDir: Environment.GetEnvironmentVariable("CLAUDE_CONFIG_DIR")));
_logger.Info("inventory", $"scan roots resolved: {roots.Length}");
@@ -56,7 +57,7 @@ public async Task CaptureAsync(
fsSw.Stop();
_logger.Info("inventory", $"filesystem scan found {scanned.Length} skill(s) in {fsSw.ElapsedMilliseconds}ms");
- // `gh skill list` is the primary inventory source (gh ≥ 2.94 is
+ // `gh skill list` is the primary inventory source (gh ≥ 2.95 is
// required, so it's always available); the filesystem scan above
// supplements it with symlink/anomaly/package data gh doesn't emit.
var usedGhList = false;
diff --git a/src/SkillView.Core/Inventory/ScanRootResolver.cs b/src/SkillView.Core/Inventory/ScanRootResolver.cs
index cc1fa20..2fb028f 100644
--- a/src/SkillView.Core/Inventory/ScanRootResolver.cs
+++ b/src/SkillView.Core/Inventory/ScanRootResolver.cs
@@ -28,7 +28,8 @@ public sealed class ScanRootResolver
public sealed record Options(
string CurrentDirectory,
string HomeDirectory,
- IReadOnlyList CustomRoots);
+ IReadOnlyList CustomRoots,
+ string? ClaudeUserConfigDir = null);
/// Emits scan roots that actually exist on disk. `Options.CurrentDirectory`
/// is used to probe for a git working tree.
@@ -53,6 +54,19 @@ public ImmutableArray Resolve(Options opts)
TryAdd(builder, seen, path, Scope.User, agent);
}
+ // gh ≥ 2.95 (cli/cli#13523) writes Claude Code user-scope skills to
+ // `$CLAUDE_CONFIG_DIR/skills` when that env var is set, instead of the
+ // default `~/.claude/skills`. Mirror that here so the filesystem scan
+ // corroborates whatever `gh skill list` reports. We add it alongside
+ // the default location (rather than replacing it) so any leftover
+ // skills in `~/.claude/skills` still surface as orphans. TryAdd dedupes
+ // when CLAUDE_CONFIG_DIR happens to point back at `~/.claude`.
+ if (!string.IsNullOrWhiteSpace(opts.ClaudeUserConfigDir))
+ {
+ var claudeConfigSkills = Path.Combine(opts.ClaudeUserConfigDir, "skills");
+ TryAdd(builder, seen, claudeConfigSkills, Scope.User, "claude");
+ }
+
foreach (var custom in opts.CustomRoots)
{
if (string.IsNullOrWhiteSpace(custom)) continue;
diff --git a/src/SkillView.Core/Ui/DoctorScreen.cs b/src/SkillView.Core/Ui/DoctorScreen.cs
index 8355805..b6f6cec 100644
--- a/src/SkillView.Core/Ui/DoctorScreen.cs
+++ b/src/SkillView.Core/Ui/DoctorScreen.cs
@@ -46,7 +46,7 @@ public static string Render(EnvironmentReport r)
sb.AppendLine("## `gh skill`");
sb.AppendLine();
sb.AppendLine(r.GhSkillAvailable
- ? "✅ `gh skill` present. SkillView requires gh ≥ 2.94.0, so the full " +
+ ? "✅ `gh skill` present. SkillView requires gh ≥ 2.95.0, so the full " +
"`skill` surface (`list`, `install --all`, `update --all`, nested-dir " +
"discovery) is available."
: "❌ `gh skill` subcommand not detected on this gh install.");
diff --git a/src/SkillView.Core/Ui/HelpOverlay.cs b/src/SkillView.Core/Ui/HelpOverlay.cs
index e8d576a..be78e3e 100644
--- a/src/SkillView.Core/Ui/HelpOverlay.cs
+++ b/src/SkillView.Core/Ui/HelpOverlay.cs
@@ -26,7 +26,7 @@ internal static string BuildMarkdown() => """
- **/** — focus the Discover query box
- **i** — install selected via compact confirm
- **I** — install selected via advanced wizard
-- **A** — install *all* skills from the selected repo (gh 2.94+)
+- **A** — discover & pick which skills to install from the selected repo (gh 2.95+)
- **o** — open the selected repo in the browser
## Installed
@@ -46,7 +46,8 @@ internal static string BuildMarkdown() => """
- **P** — cycle Installed pin filter
- **G** — cycle Installed scope filter (pushes `--scope` to gh)
- **e** — toggle raw / rendered preview
-- **l / r** — show / hide logs
+- **l** — show / hide logs
+- **r** — refresh the active tab
- **q / Esc** — quit at root · close modal otherwise
_Press **Esc**, **Enter**, or **?** to close._
diff --git a/src/SkillView.Core/Ui/InstallScreen.cs b/src/SkillView.Core/Ui/InstallScreen.cs
index 8a83da4..0e2e5a8 100644
--- a/src/SkillView.Core/Ui/InstallScreen.cs
+++ b/src/SkillView.Core/Ui/InstallScreen.cs
@@ -19,7 +19,7 @@ public sealed record InstallRequest(string Repo, string? SkillName, bool AllowHi
/// Phase 4 install dialog. Consumes an `InstallRequest` (repo + skill) and
/// runs `gh skill install` with the flags the user has chosen. SkillView
-/// requires gh ≥ 2.94.0, so all flags (`--upstream`, `--allow-hidden-dirs`,
+/// requires gh ≥ 2.95.0, so all flags (`--upstream`, `--allow-hidden-dirs`,
/// `--from-local`, …) are always available and shown.
public sealed class InstallScreen
{
@@ -130,7 +130,7 @@ void InvokeIfActive(Action action)
Text = "→ blank uses the latest release",
};
- // `--upstream` overrides the recorded source URL. gh ≥ 2.94 is
+ // `--upstream` overrides the recorded source URL. gh ≥ 2.95 is
// required, so every flag here is guaranteed and always shown.
var upstreamLabel = new Label { Text = "Upstream :", X = 0, Y = 3 };
var upstreamField = new TextField
diff --git a/src/SkillView.Core/Ui/RemoveScreen.cs b/src/SkillView.Core/Ui/RemoveScreen.cs
index 4e20f31..4de6a4a 100644
--- a/src/SkillView.Core/Ui/RemoveScreen.cs
+++ b/src/SkillView.Core/Ui/RemoveScreen.cs
@@ -68,6 +68,16 @@ public void Show()
using var wizard = new Wizard
{
Title = $"Remove — {_target.Name}",
+ // Wizard inherits Dialog's Dim.Auto sizing, which collapses to each
+ // step's *natural* content size. Every step here lays its content
+ // out with Dim.Fill()/Pos.AnchorEnd(), which contribute nothing to
+ // an Auto measurement, so without an explicit size the wizard
+ // shrinks to the lone fixed label and hides the radio list and the
+ // Review/Confirm markdown. Pin a percentage size (matching the
+ // explicit-size pattern in InstallConfirmModal) so the Fill content
+ // gets real space; the 25-col help padding still fits comfortably.
+ Width = Dim.Percent(80),
+ Height = Dim.Percent(70),
};
var chooseStep = new WizardStep
@@ -162,6 +172,20 @@ public void Show()
void RefreshEvaluation()
{
+ // The radio the user *sees* is the source of truth. The
+ // OptionSelector builds its checkbox subviews lazily during layout
+ // and can raise a transient ValueChanged that leaves both the cached
+ // selectedIndex and the selector's own Value out of sync with the
+ // checkbox that is visually selected (so the default lands on the
+ // first executable option visually, but the evaluation stays on the
+ // blocked first option). Read the checked checkbox directly so
+ // Review/Confirm always match what's on screen.
+ var checkedIndex = CurrentSelection(choicePicker, selectedIndex);
+ if (checkedIndex >= 0 && checkedIndex < targets.Length)
+ {
+ selectedIndex = checkedIndex;
+ }
+
var target = targets[selectedIndex];
currentEvaluation = Evaluate(target);
choiceDescription.Text = target.Description;
@@ -196,6 +220,33 @@ void RefreshEvaluation()
}
};
+ // Re-sync the evaluation from the live selection whenever the wizard
+ // moves between steps. This guarantees the Review and Confirm steps are
+ // rebuilt from whatever the radio currently shows, even if an init-time
+ // ValueChanged left selectedIndex stale before the user advanced.
+ wizard.StepChanged += (_, _) => RefreshEvaluation();
+
+ // The OptionSelector opens with keyboard focus on its first checkbox,
+ // which may differ from the checked default (FindInitialSelection picks
+ // the first *executable* option, often not the first one). Pressing
+ // Enter to advance would then "activate" the focused-but-unchecked
+ // checkbox and silently change the selection (e.g. from Unlink back to a
+ // blocked full remove). Align the focused item with the checked value so
+ // Enter advances without mutating the choice.
+ choicePicker.HasFocusChanged += (_, _) =>
+ {
+ if (!choicePicker.HasFocus)
+ {
+ return;
+ }
+
+ var checkedIndex = CurrentSelection(choicePicker, selectedIndex);
+ if (checkedIndex >= 0 && checkedIndex < targets.Length && choicePicker.FocusedItem != checkedIndex)
+ {
+ choicePicker.FocusedItem = checkedIndex;
+ }
+ };
+
wizard.Accepting += (_, e) =>
{
e.Handled = true;
@@ -291,6 +342,27 @@ private RemoveService.BatchRemoveReport Execute(RemoveTargetEvaluation evaluatio
return _remove.RemoveMany(evaluation.Items.Select(item => item.Validation));
}
+ /// Returns the index of the checkbox the is
+ /// currently showing as checked. The selector's own Value can desync
+ /// from its visual state during lazy subview layout, so the checked subview
+ /// is the reliable read of what the user actually sees selected. Falls back
+ /// to when no checkbox is checked yet.
+ private static int CurrentSelection(OptionSelector picker, int fallback)
+ {
+ var index = 0;
+ foreach (var box in picker.SubViews.OfType())
+ {
+ if (box.Value == CheckState.Checked)
+ {
+ return index;
+ }
+
+ index++;
+ }
+
+ return fallback;
+ }
+
private int FindInitialSelection(ImmutableArray targets)
{
for (var i = 0; i < targets.Length; i++)
diff --git a/src/SkillView.Core/Ui/RepoSkillPickerModal.cs b/src/SkillView.Core/Ui/RepoSkillPickerModal.cs
new file mode 100644
index 0000000..4095f40
--- /dev/null
+++ b/src/SkillView.Core/Ui/RepoSkillPickerModal.cs
@@ -0,0 +1,360 @@
+using System.Collections.Immutable;
+using System.Drawing;
+using SkillView.Gh;
+using SkillView.Gh.Models;
+using SkillView.Logging;
+using SkillView.Ui.Theming;
+using Terminal.Gui.App;
+using Terminal.Gui.Drivers;
+using Terminal.Gui.Input;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+namespace SkillView.Ui;
+
+/// Per-repo discovery picker. Given a repo's already-discovered skills
+/// (from `gh skill install ` run non-interactively — gh ≥ 2.95.0,
+/// cli/cli#13548), shows a checklist so the user installs a chosen subset
+/// instead of the blunt all-or-one choices. Selecting every skill collapses
+/// to a single `gh skill install --all`; a subset installs each by name.
+///
+/// Scope and agent pickers mirror and reuse
+/// its pure option-builder so the two install paths stay consistent. The modal
+/// operates on data the caller already fetched in the background, so it never
+/// runs discovery on the UI thread.
+internal sealed class RepoSkillPickerModal
+{
+ internal enum Outcome
+ {
+ Cancelled,
+ Installed,
+ Failed,
+ }
+
+ internal sealed record Result(Outcome Outcome, int InstalledCount, int FailedCount, string? FirstError);
+
+ private readonly IApplication _app;
+ private readonly GhSkillInstallService _install;
+ private readonly Logger _logger;
+ private readonly string _ghPath;
+ private readonly InstallRequest _request;
+ private readonly ImmutableArray _skills;
+
+ internal RepoSkillPickerModal(
+ IApplication app,
+ GhSkillInstallService install,
+ Logger logger,
+ string ghPath,
+ InstallRequest request,
+ ImmutableArray skills)
+ {
+ _app = app;
+ _install = install;
+ _logger = logger;
+ _ghPath = ghPath;
+ _request = request;
+ _skills = skills;
+ }
+
+ internal Result Show()
+ {
+ var outcome = Outcome.Cancelled;
+ var installedCount = 0;
+ var failedCount = 0;
+ string? firstError = null;
+
+ var dialog = new Dialog
+ {
+ Title = $" Install skills from {_request.Repo} ",
+ Width = Dim.Percent(70),
+ Height = Dim.Percent(85),
+ };
+ dialog.SchemeName = SchemeNames.Dialog;
+
+ var header = new Label
+ {
+ X = 1, Y = 0,
+ Text = $"Select skills to install — {_skills.Length} found (Space toggles · A all · N none):",
+ };
+
+ // Scrollable checklist of discovered skills, pre-checked. The frame
+ // leaves the bottom rows for scope/agents/status/buttons.
+ var listFrame = new FrameView
+ {
+ X = 1, Y = 1,
+ Width = Dim.Fill(1),
+ Height = Dim.Fill(10),
+ };
+ listFrame.ViewportSettings |= ViewportSettingsFlags.HasVerticalScrollBar;
+
+ var skillBoxes = new CheckBox[_skills.Length];
+ for (var i = 0; i < _skills.Length; i++)
+ {
+ var skill = _skills[i];
+ var label = string.IsNullOrEmpty(skill.Description)
+ ? skill.Name
+ : $"{skill.Name} — {skill.Description}";
+ var box = new CheckBox
+ {
+ X = 0, Y = i,
+ Width = Dim.Fill(),
+ Text = label,
+ Value = CheckState.Checked,
+ };
+ skillBoxes[i] = box;
+ listFrame.Add(box);
+ }
+ listFrame.SetContentSize(new Size(1, Math.Max(_skills.Length, 1)));
+
+ var scopeLabel = new Label { X = 1, Y = Pos.AnchorEnd(9), Text = "Scope:" };
+ var scopeSelector = new OptionSelector
+ {
+ X = 9, Y = Pos.AnchorEnd(9),
+ Orientation = Orientation.Horizontal,
+ Labels = new List { "Project", "User (global)", "Custom path" },
+ Value = InstallAgentCatalog.HasProjectScopeCandidate(Environment.CurrentDirectory) ? 0 : 1,
+ };
+ var customPathLabel = new Label { X = 1, Y = Pos.AnchorEnd(8), Text = "Path:", Visible = false };
+ var customPathField = new TextField
+ {
+ X = 9, Y = Pos.AnchorEnd(8), Width = Dim.Fill(2),
+ Text = string.Empty, Visible = false,
+ };
+ TuiHelpers.ConfigureTextInput(customPathField, SkillViewStyling.DialogSchemeName);
+
+ var agentsLabel = new Label { X = 1, Y = Pos.AnchorEnd(6), Text = "Agents:" };
+ var home = Environment.GetEnvironmentVariable("HOME")
+ ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var preChecked = InstallAgentCatalog.DetectInstalledGhIds(home ?? string.Empty);
+ var entries = InstallAgentCatalog.Entries;
+ var agentBoxes = new CheckBox[entries.Length];
+ const int colWidth = 14;
+ const int perRow = 4;
+ for (var i = 0; i < entries.Length; i++)
+ {
+ var entry = entries[i];
+ agentBoxes[i] = new CheckBox
+ {
+ X = 9 + (i % perRow) * colWidth,
+ Y = Pos.AnchorEnd(6) + (i / perRow),
+ Text = entry.Label,
+ Value = preChecked.Contains(entry.GhId) ? CheckState.Checked : CheckState.UnChecked,
+ };
+ }
+
+ var status = new Label
+ {
+ X = 1, Y = Pos.AnchorEnd(3), Width = Dim.Fill(2), Text = " ready",
+ };
+ var spinner = new SpinnerView
+ {
+ X = Pos.AnchorEnd(2), Y = Pos.AnchorEnd(3),
+ Width = 1, Height = 1, Visible = false, AutoSpin = false,
+ Style = new SpinnerStyle.Dots(),
+ };
+
+ var installButton = new Button
+ {
+ X = Pos.Center() - 14, Y = Pos.AnchorEnd(1), Text = "Install", IsDefault = true,
+ };
+ var cancelButton = new Button
+ {
+ X = Pos.Center() + 4, Y = Pos.AnchorEnd(1), Text = "Cancel",
+ };
+
+ int CheckedCount() => skillBoxes.Count(b => b.Value == CheckState.Checked);
+
+ string? CurrentValidationError()
+ {
+ if (CheckedCount() == 0)
+ {
+ return "select at least one skill";
+ }
+ return InstallConfirmModal.ValidateSelection(
+ scopeSelector.Value ?? 0,
+ customPathField.Text.ToString() ?? string.Empty);
+ }
+
+ void RefreshValidity()
+ {
+ var error = CurrentValidationError();
+ installButton.Enabled = !spinner.Visible && error is null;
+ if (!spinner.Visible)
+ {
+ status.Text = error is null ? $" {CheckedCount()}/{_skills.Length} selected" : $" {error}";
+ }
+ }
+
+ scopeSelector.ValueChanged += (_, _) =>
+ {
+ var isCustom = scopeSelector.Value == 2;
+ customPathLabel.Visible = isCustom;
+ customPathField.Visible = isCustom;
+ RefreshValidity();
+ };
+ customPathField.TextChanged += (_, _) => RefreshValidity();
+
+ void SetAll(CheckState state)
+ {
+ foreach (var box in skillBoxes) box.Value = state;
+ RefreshValidity();
+ }
+
+ installButton.Accepting += async (_, ev) =>
+ {
+ ev.Handled = true;
+ if (spinner.Visible) return;
+
+ var error = CurrentValidationError();
+ if (error is not null)
+ {
+ status.Text = $" {error}";
+ return;
+ }
+
+ var checkedNames = new HashSet(StringComparer.Ordinal);
+ for (var i = 0; i < skillBoxes.Length; i++)
+ {
+ if (skillBoxes[i].Value == CheckState.Checked)
+ {
+ checkedNames.Add(_skills[i].Name);
+ }
+ }
+
+ var plan = GhSkillInstallService.BuildInstallPlan(_skills, checkedNames);
+ if (plan.IsEmpty)
+ {
+ status.Text = " select at least one skill";
+ return;
+ }
+
+ spinner.Visible = true;
+ spinner.AutoSpin = true;
+ installButton.Enabled = false;
+
+ var selectedAgentIds = new List();
+ for (var i = 0; i < entries.Length; i++)
+ {
+ if (agentBoxes[i].Value == CheckState.Checked)
+ {
+ selectedAgentIds.Add(entries[i].GhId);
+ }
+ }
+
+ var baseOptions = InstallConfirmModal.BuildOptionsFromSelection(
+ scopeSelector.Value ?? 0,
+ customPathField.Text.ToString() ?? string.Empty,
+ selectedAgentIds,
+ installAll: plan.UseAll) with
+ {
+ AllowHiddenDirs = _request.AllowHiddenDirs,
+ };
+
+ try
+ {
+ if (plan.UseAll)
+ {
+ status.Text = $" installing all {_skills.Length} skills…";
+ var r = await _install.InstallAsync(_ghPath, _request.Repo, null, baseOptions).ConfigureAwait(false);
+ if (r.Succeeded) installedCount = _skills.Length;
+ else { failedCount = _skills.Length; firstError ??= r.ErrorMessage; }
+ }
+ else
+ {
+ for (var i = 0; i < plan.SkillNames.Length; i++)
+ {
+ var name = plan.SkillNames[i];
+ var idx = i;
+ _app.Invoke(() => status.Text = $" installing {idx + 1}/{plan.SkillNames.Length}: {name}…");
+ var r = await _install.InstallAsync(_ghPath, _request.Repo, name, baseOptions).ConfigureAwait(false);
+ if (r.Succeeded) installedCount++;
+ else { failedCount++; firstError ??= r.ErrorMessage; }
+ }
+ }
+
+ _app.Invoke(() =>
+ {
+ spinner.AutoSpin = false;
+ spinner.Visible = false;
+ if (failedCount == 0)
+ {
+ outcome = Outcome.Installed;
+ _app.RequestStop();
+ }
+ else if (installedCount > 0)
+ {
+ // Partial success — report and let the user close.
+ outcome = Outcome.Installed;
+ status.Text = $" installed {installedCount}, {failedCount} failed — see logs (l)";
+ installButton.Enabled = false;
+ }
+ else
+ {
+ outcome = Outcome.Failed;
+ var snippet = TuiHelpers.ErrorSnippet(firstError);
+ status.Text = snippet.Length > 0
+ ? $" install failed: {snippet}"
+ : " install failed — see logs (l)";
+ RefreshValidity();
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("install.picker", ex.Message);
+ _app.Invoke(() =>
+ {
+ spinner.AutoSpin = false;
+ spinner.Visible = false;
+ outcome = installedCount > 0 ? Outcome.Installed : Outcome.Failed;
+ status.Text = $" install failed: {TuiHelpers.ErrorSnippet(ex.Message)}";
+ RefreshValidity();
+ });
+ }
+ };
+
+ cancelButton.Accepting += (_, ev) =>
+ {
+ ev.Handled = true;
+ outcome = Outcome.Cancelled;
+ _app.RequestStop();
+ };
+
+ dialog.KeyDown += (_, key) =>
+ {
+ if (key.KeyCode == KeyCode.Esc)
+ {
+ key.Handled = true;
+ outcome = Outcome.Cancelled;
+ _app.RequestStop();
+ }
+ else if (!customPathField.HasFocus && key.AsRune.Value is 'a' or 'A')
+ {
+ key.Handled = true;
+ SetAll(CheckState.Checked);
+ }
+ else if (!customPathField.HasFocus && key.AsRune.Value is 'n' or 'N')
+ {
+ key.Handled = true;
+ SetAll(CheckState.UnChecked);
+ }
+ };
+
+ dialog.Add(header, listFrame, scopeLabel, scopeSelector, customPathLabel, customPathField, agentsLabel);
+ foreach (var box in agentBoxes) dialog.Add(box);
+ dialog.Add(status, spinner, installButton, cancelButton);
+
+ TuiHelpers.ApplyScheme(SkillViewStyling.DialogSchemeName,
+ dialog, header, listFrame, scopeLabel, scopeSelector,
+ customPathLabel, customPathField, agentsLabel, status, spinner,
+ installButton, cancelButton);
+ foreach (var box in skillBoxes) TuiHelpers.ApplyScheme(SkillViewStyling.DialogSchemeName, box);
+ foreach (var box in agentBoxes) TuiHelpers.ApplyScheme(SkillViewStyling.DialogSchemeName, box);
+
+ RefreshValidity();
+ _app.Run(dialog);
+ dialog.Dispose();
+
+ return new Result(outcome, installedCount, failedCount, firstError);
+ }
+}
diff --git a/src/SkillView.Core/Ui/SkillViewApp.cs b/src/SkillView.Core/Ui/SkillViewApp.cs
index 3a89c42..5ae200c 100644
--- a/src/SkillView.Core/Ui/SkillViewApp.cs
+++ b/src/SkillView.Core/Ui/SkillViewApp.cs
@@ -128,7 +128,8 @@ internal SkillViewApp(
SetStatus,
Invoke,
RunBackground,
- FocusSearchFromInstalled);
+ FocusSearchFromInstalled,
+ RefreshActiveTab);
}
internal static bool ShouldOpenInstalledOnStartup(InventorySnapshot snapshot) => snapshot.Skills.Length > 0;
@@ -518,7 +519,8 @@ private bool OnWindowShortcut(Key key)
return true;
}
if (rune.Value == 'q' || rune.Value == 'Q') { _app?.RequestStop(); return true; }
- if (rune.Value == 'l' || rune.Value == 'L' || rune.Value == 'r' || rune.Value == 'R') { ToggleRightPane(); return true; }
+ if (rune.Value == 'l' || rune.Value == 'L') { ToggleRightPane(); return true; }
+ if (rune.Value == 'r' || rune.Value == 'R') { RefreshActiveTab(); return true; }
if (rune.Value == 'e' || rune.Value == 'E') { TogglePreviewMode(); return true; }
if (rune.Value == 'd' || rune.Value == 'D') { EnterDoctor(); return true; }
// winget-tui keybindings:
@@ -527,7 +529,8 @@ private bool OnWindowShortcut(Key key)
// The Installed view is reached via `2` (jump-to-tab) or ←/→ cycling.
if (rune.Value == 'I') { StageInstall(forceAdvanced: true); return true; }
if (rune.Value == 'i') { StageInstall(forceAdvanced: false); return true; }
- // A → install every skill in the selected result's repo (gh 2.94 --all).
+ // A → discover the skills in the selected result's repo and pick which
+ // to install (gh ≥ 2.95 non-interactive listing, cli/cli#13548).
if (rune.Value == 'A' && _activeTab == SkillViewTab.Discover) { StageInstallAll(); return true; }
if (rune.Value == 'o' || rune.Value == 'O') { OpenSelected(); return true; }
// `u` jumps to the Changes tab (embedded). The actual single-row vs.
@@ -1532,6 +1535,32 @@ private void RefreshShellChrome()
UpdateStatusStrip(string.IsNullOrEmpty(_currentStatus) ? _defaultStatus : _currentStatus, TuiHelpers.NotificationLevel.Info);
}
+ /// Reload the active tab's data. Invalidates the shared `gh skill list`
+ /// cache first so the reload reflects on-disk changes (e.g. a removal made
+ /// outside this process). Discover re-runs the current query if one is set.
+ private void RefreshActiveTab()
+ {
+ _services.ListAdapter.Invalidate();
+ switch (_activeTab)
+ {
+ case SkillViewTab.Installed when _installedTab is not null:
+ SetStatus("refreshing inventory…");
+ _ = _installedTab.LoadAsync();
+ break;
+ case SkillViewTab.Changes when _changesTab is not null:
+ SetStatus("refreshing updates…");
+ _ = _changesTab.LoadAsync();
+ break;
+ case SkillViewTab.Discover:
+ if (!string.IsNullOrEmpty(_queryField?.Text.Trim()))
+ {
+ SetStatus("refreshing results…");
+ SubmitSearch();
+ }
+ break;
+ }
+ }
+
private void ToggleRightPane()
{
if (_previewPane is null || _rightFrame is null)
@@ -1702,9 +1731,9 @@ private void StageInstall(bool forceAdvanced = false)
forceAdvanced: forceAdvanced);
}
- /// Stage an install of *every* skill in the selected result's repo
- /// (`gh skill install --all`). gh ≥ 2.94 is required, so `--all`
- /// is always available.
+ /// Discover the skills in the selected result's repo and open the picker
+ /// so the user installs a chosen subset (gh ≥ 2.95 non-interactive
+ /// listing, cli/cli#13548). Falls back to install-all if discovery fails.
private void StageInstallAll()
{
if (_resultsTable is null || _results.Count == 0)
@@ -1724,7 +1753,7 @@ private void StageInstallAll()
SetStatus("no repo on selected row");
return;
}
- _workflows.OpenInstallAllDialog(
+ _workflows.OpenRepoDiscoveryDialog(
new InstallRequest(
Repo: pick.Repo,
SkillName: null,
diff --git a/src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs b/src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs
index ceda7a1..7672822 100644
--- a/src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs
+++ b/src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs
@@ -21,6 +21,7 @@ internal sealed class SkillViewWorkflowCoordinator
private readonly Action _invoke;
private readonly Action, string> _runBackground;
private readonly Action _focusSearchFromInstalled;
+ private readonly Action _refreshActiveTab;
public SkillViewWorkflowCoordinator(
TuiServices services,
@@ -35,7 +36,8 @@ public SkillViewWorkflowCoordinator(
Action setStatusWithLevel,
Action invoke,
Action, string> runBackground,
- Action focusSearchFromInstalled)
+ Action focusSearchFromInstalled,
+ Action refreshActiveTab)
{
_services = services;
_options = options;
@@ -50,6 +52,7 @@ public SkillViewWorkflowCoordinator(
_invoke = invoke;
_runBackground = runBackground;
_focusSearchFromInstalled = focusSearchFromInstalled;
+ _refreshActiveTab = refreshActiveTab;
}
/// Open the install flow. By default takes the compact one-screen modal
@@ -163,6 +166,74 @@ public void OpenInstallAllDialog(InstallRequest request)
}
}
+ /// Discover the skills a repo offers (gh ≥ 2.95.0, cli/cli#13548) and let
+ /// the user pick a subset to install via .
+ /// Discovery is a read-only `gh skill install ` (no skill, no --all)
+ /// run off the UI thread; on success the populated picker opens. If
+ /// discovery fails or the repo lists no skills, falls back to the blunt
+ /// install-all modal so the `A` shortcut never dead-ends.
+ public void OpenRepoDiscoveryDialog(InstallRequest request)
+ {
+ var app = _getApp();
+ var ghPath = _getGhPath();
+ var report = _getLastReport();
+ if (app is null || ghPath is null || report is null)
+ {
+ return;
+ }
+
+ _setBusy($"discovering skills in {request.Repo}…");
+ _runBackground(async cancellationToken =>
+ {
+ var listing = await _services.InstallService
+ .ListRepoSkillsAsync(ghPath, request.Repo, version: null, request.AllowHiddenDirs, cancellationToken)
+ .ConfigureAwait(false);
+ _invoke(() =>
+ {
+ _clearBusy();
+ if (!listing.Succeeded)
+ {
+ _setStatusWithLevel(
+ $"could not list skills in {request.Repo} (exit {listing.ExitCode}) — opening install-all",
+ TuiHelpers.NotificationLevel.Warn);
+ OpenInstallAllDialog(request);
+ return;
+ }
+ if (listing.Skills.IsDefaultOrEmpty)
+ {
+ _setStatusWithLevel(
+ $"no skills discovered in {request.Repo}",
+ TuiHelpers.NotificationLevel.Warn);
+ return;
+ }
+
+ var picker = new RepoSkillPickerModal(
+ app,
+ _services.InstallService,
+ _services.Logger,
+ ghPath,
+ request,
+ listing.Skills);
+ var result = picker.Show();
+ if (result.Outcome == RepoSkillPickerModal.Outcome.Installed && result.InstalledCount > 0)
+ {
+ _services.ListAdapter.Invalidate();
+ var suffix = result.FailedCount > 0 ? $" ({result.FailedCount} failed)" : string.Empty;
+ _setStatusWithLevel(
+ $"installed {result.InstalledCount} skill(s) from {request.Repo}{suffix} — rescanning…",
+ result.FailedCount > 0 ? TuiHelpers.NotificationLevel.Warn : TuiHelpers.NotificationLevel.Success);
+ QueueInventoryRescan(report, successStatus: "installed — inventory now {0} skill(s)");
+ }
+ else if (result.Outcome == RepoSkillPickerModal.Outcome.Failed)
+ {
+ _setStatusWithLevel(
+ $"install failed — {TuiHelpers.ErrorSnippet(result.FirstError)}".TrimEnd(),
+ TuiHelpers.NotificationLevel.Error);
+ }
+ });
+ }, "discover");
+ }
+
public void ShowCleanupScreen()
{
var app = _getApp();
@@ -188,9 +259,15 @@ public void ShowCleanupScreen()
snapshot.ScannedRoots,
snapshot.Skills);
screen.Show();
- if (screen.RemovedCount > 0)
+ if (screen.RemovedCount > 0 || screen.IgnoredCount > 0)
{
_services.ListAdapter.Invalidate();
+ // Cleanup can remove or ignore skills while the user is
+ // sitting on the Installed/Updates list. The screen runs as
+ // a modal overlay (not a tab switch), so nothing reloads the
+ // underlying tab on its own — refresh it here so the list
+ // reflects what cleanup just changed.
+ _refreshActiveTab();
}
_setStatus($"cleanup: removed {screen.RemovedCount}, ignored {screen.IgnoredCount}");
diff --git a/src/SkillView.Core/Ui/Tabs/InstalledTabView.cs b/src/SkillView.Core/Ui/Tabs/InstalledTabView.cs
index f5fb11c..4ec1649 100644
--- a/src/SkillView.Core/Ui/Tabs/InstalledTabView.cs
+++ b/src/SkillView.Core/Ui/Tabs/InstalledTabView.cs
@@ -570,6 +570,11 @@ private void OnKeyDown(object? sender, Key key)
if (i >= 0 && i < _rows.Count && _snapshot is not null)
{
_onRemove(_rows[i], _snapshot);
+ // The remove flow runs modally and invalidates the list
+ // cache on success, but nothing reloads this tab — so the
+ // removed row lingers until the tab is re-activated. Reload
+ // now to reflect the post-remove inventory.
+ _ = LoadAsync();
}
break;
}
diff --git a/tests/SkillView.Tests/Cli/CliDispatcherJsonSnapshotTests.cs b/tests/SkillView.Tests/Cli/CliDispatcherJsonSnapshotTests.cs
index 3103cd5..8f097b2 100644
--- a/tests/SkillView.Tests/Cli/CliDispatcherJsonSnapshotTests.cs
+++ b/tests/SkillView.Tests/Cli/CliDispatcherJsonSnapshotTests.cs
@@ -29,8 +29,8 @@ public class CliDispatcherJsonSnapshotTests
private static EnvironmentReport SampleReport() => new()
{
GhPath = "/usr/bin/gh",
- GhVersionRaw = "gh version 2.94.0",
- GhVersion = new SemVer(2, 94, 0),
+ GhVersionRaw = "gh version 2.95.0",
+ GhVersion = new SemVer(2, 95, 0),
GhMeetsMinimum = true,
Auth = GhAuthStatus.Unknown,
GhSkillAvailable = true,
diff --git a/tests/SkillView.Tests/Gh/GhBinaryLocatorTests.cs b/tests/SkillView.Tests/Gh/GhBinaryLocatorTests.cs
index afe3d5b..2ef7423 100644
--- a/tests/SkillView.Tests/Gh/GhBinaryLocatorTests.cs
+++ b/tests/SkillView.Tests/Gh/GhBinaryLocatorTests.cs
@@ -6,19 +6,19 @@ namespace SkillView.Tests.Gh;
public class GhBinaryLocatorTests
{
[Fact]
- public void MinimumVersion_is_2_94_0()
+ public void MinimumVersion_is_2_95_0()
{
Assert.Equal(2, GhBinaryLocator.MinimumVersion.Major);
- Assert.Equal(94, GhBinaryLocator.MinimumVersion.Minor);
+ Assert.Equal(95, GhBinaryLocator.MinimumVersion.Minor);
Assert.Equal(0, GhBinaryLocator.MinimumVersion.Patch);
}
[Theory]
- [InlineData("2.93.0", false)]
- [InlineData("2.93.9", false)]
- [InlineData("2.94.0", true)]
- [InlineData("2.94.0-rc.1", true)] // SemVer strips the pre-release tag → 2.94.0
- [InlineData("2.94.3", true)]
+ [InlineData("2.94.0", false)]
+ [InlineData("2.94.3", false)]
+ [InlineData("2.95.0", true)]
+ [InlineData("2.95.0-rc.1", true)] // SemVer strips the pre-release tag → 2.95.0
+ [InlineData("2.95.4", true)]
[InlineData("3.0.0", true)]
[InlineData("2.92.0", false)]
[InlineData("2.0.0", false)]
diff --git a/tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs b/tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs
index b6acb86..b08cfd2 100644
--- a/tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs
+++ b/tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs
@@ -1,4 +1,6 @@
+using System.Linq;
using SkillView.Gh;
+using SkillView.Gh.Models;
using Xunit;
namespace SkillView.Tests.Gh;
@@ -125,4 +127,93 @@ public void BuildArgs_EmptyAgentEntriesAreSkipped()
new GhSkillInstallService.Options(Agents: new[] { "", " ", "claude" }));
Assert.Single(args, x => x == "--agent");
}
+
+ // --- discovery listing (gh ≥ 2.95, cli/cli#13548) --------------------
+
+ [Fact]
+ public void BuildListArgs_HasNoSkillNameAndNoAll()
+ {
+ // The bare repo (no skill, no --all) is what triggers gh's
+ // non-interactive listing path.
+ var args = GhSkillInstallService.BuildListArgs("owner/repo", version: null, allowHiddenDirs: false);
+ Assert.Equal(new[] { "skill", "install", "owner/repo" }, args);
+ }
+
+ [Fact]
+ public void BuildListArgs_VersionConcatenatedAndHiddenDirsFlag()
+ {
+ var args = GhSkillInstallService.BuildListArgs("owner/repo", "v1.2.0", allowHiddenDirs: true);
+ Assert.Equal(new[] { "skill", "install", "owner/repo@v1.2.0", "--allow-hidden-dirs" }, args);
+ Assert.DoesNotContain("--all", args);
+ }
+
+ [Fact]
+ public void ParseRepoSkillListing_ParsesTabSeparatedRows()
+ {
+ var stdout = "code-review\tReviews pull requests\ngit-commit\tWrites commit messages\n";
+ var skills = GhSkillInstallService.ParseRepoSkillListing(stdout);
+
+ Assert.Equal(2, skills.Length);
+ Assert.Equal("code-review", skills[0].Name);
+ Assert.Equal("Reviews pull requests", skills[0].Description);
+ Assert.Equal("git-commit", skills[1].Name);
+ Assert.Equal("Writes commit messages", skills[1].Description);
+ }
+
+ [Fact]
+ public void ParseRepoSkillListing_ToleratesBlankLinesNameOnlyHeaderAndCrlf()
+ {
+ var stdout = "SKILL\tDESCRIPTION\r\n\r\nlonely-skill\r\ncode-review\tReviews PRs\r\n";
+ var skills = GhSkillInstallService.ParseRepoSkillListing(stdout);
+
+ // Header dropped, blank line skipped, name-only line kept with empty desc.
+ Assert.Equal(2, skills.Length);
+ Assert.Equal("lonely-skill", skills[0].Name);
+ Assert.Equal("", skills[0].Description);
+ Assert.Equal("code-review", skills[1].Name);
+ }
+
+ [Fact]
+ public void ParseRepoSkillListing_DeduplicatesByNameAndHandlesEmpty()
+ {
+ Assert.Empty(GhSkillInstallService.ParseRepoSkillListing(null));
+ Assert.Empty(GhSkillInstallService.ParseRepoSkillListing(" \n \n"));
+
+ var dup = GhSkillInstallService.ParseRepoSkillListing("a\tone\na\ttwo\nb\tthree");
+ Assert.Equal(2, dup.Length);
+ Assert.Equal("one", dup.Single(s => s.Name == "a").Description);
+ }
+
+ [Fact]
+ public void BuildInstallPlan_AllSelectedCollapsesToAll()
+ {
+ var discovered = new[] { new RepoSkill("a", ""), new RepoSkill("b", "") };
+ var plan = GhSkillInstallService.BuildInstallPlan(
+ discovered, new HashSet { "a", "b" });
+
+ Assert.True(plan.UseAll);
+ Assert.True(plan.SkillNames.IsDefaultOrEmpty);
+ Assert.False(plan.IsEmpty);
+ }
+
+ [Fact]
+ public void BuildInstallPlan_SubsetInstallsByName()
+ {
+ var discovered = new[] { new RepoSkill("a", ""), new RepoSkill("b", ""), new RepoSkill("c", "") };
+ var plan = GhSkillInstallService.BuildInstallPlan(
+ discovered, new HashSet { "a", "c" });
+
+ Assert.False(plan.UseAll);
+ Assert.Equal(new[] { "a", "c" }, plan.SkillNames);
+ }
+
+ [Fact]
+ public void BuildInstallPlan_NoneSelectedIsEmpty()
+ {
+ var discovered = new[] { new RepoSkill("a", "") };
+ var plan = GhSkillInstallService.BuildInstallPlan(discovered, new HashSet());
+
+ Assert.False(plan.UseAll);
+ Assert.True(plan.IsEmpty);
+ }
}
diff --git a/tests/SkillView.Tests/Inventory/ScanRootResolverTests.cs b/tests/SkillView.Tests/Inventory/ScanRootResolverTests.cs
index a249572..e6a181d 100644
--- a/tests/SkillView.Tests/Inventory/ScanRootResolverTests.cs
+++ b/tests/SkillView.Tests/Inventory/ScanRootResolverTests.cs
@@ -59,6 +59,70 @@ public void Skips_project_seeds_outside_git()
Assert.DoesNotContain(roots, r => r.Scope == Scope.Project);
}
+ [Fact]
+ public void Resolves_claude_user_scope_from_CLAUDE_CONFIG_DIR_when_set()
+ {
+ // gh ≥ 2.95 (cli/cli#13523) writes Claude user-scope skills to
+ // $CLAUDE_CONFIG_DIR/skills; the scanner must look there too.
+ using var temp = new TempHome();
+ var configDir = Path.Combine(temp.Home, "xdg-claude");
+ var configSkills = Path.Combine(configDir, "skills");
+ Directory.CreateDirectory(configSkills);
+
+ var resolver = new ScanRootResolver();
+ var roots = resolver.Resolve(new ScanRootResolver.Options(
+ CurrentDirectory: temp.Home,
+ HomeDirectory: temp.Home,
+ CustomRoots: Array.Empty(),
+ ClaudeUserConfigDir: configDir));
+
+ Assert.Contains(roots, r =>
+ r.AgentHint == "claude"
+ && r.Scope == Scope.User
+ && ScanRootResolver.NormalizeKey(r.Path) == ScanRootResolver.NormalizeKey(configSkills));
+ }
+
+ [Fact]
+ public void Scans_both_default_and_CLAUDE_CONFIG_DIR_claude_roots()
+ {
+ // The override is additive: a leftover ~/.claude/skills still surfaces.
+ using var temp = new TempHome();
+ Directory.CreateDirectory(Path.Combine(temp.Home, ".claude", "skills"));
+ var configDir = Path.Combine(temp.Home, "xdg-claude");
+ Directory.CreateDirectory(Path.Combine(configDir, "skills"));
+
+ var resolver = new ScanRootResolver();
+ var roots = resolver.Resolve(new ScanRootResolver.Options(
+ CurrentDirectory: temp.Home,
+ HomeDirectory: temp.Home,
+ CustomRoots: Array.Empty(),
+ ClaudeUserConfigDir: configDir));
+
+ var claudeUserRoots = roots
+ .Where(r => r.AgentHint == "claude" && r.Scope == Scope.User)
+ .ToArray();
+ Assert.Equal(2, claudeUserRoots.Length);
+ }
+
+ [Fact]
+ public void Ignores_CLAUDE_CONFIG_DIR_when_unset_or_missing()
+ {
+ using var temp = new TempHome();
+ Directory.CreateDirectory(Path.Combine(temp.Home, ".claude", "skills"));
+
+ var resolver = new ScanRootResolver();
+ var roots = resolver.Resolve(new ScanRootResolver.Options(
+ CurrentDirectory: temp.Home,
+ HomeDirectory: temp.Home,
+ CustomRoots: Array.Empty(),
+ ClaudeUserConfigDir: null));
+
+ var claudeUserRoots = roots
+ .Where(r => r.AgentHint == "claude" && r.Scope == Scope.User)
+ .ToArray();
+ Assert.Single(claudeUserRoots);
+ }
+
[Fact]
public void Adds_custom_roots_when_they_exist()
{
diff --git a/tests/SkillView.Tests/Ui/DoctorScreenTests.cs b/tests/SkillView.Tests/Ui/DoctorScreenTests.cs
index 104471b..e943da0 100644
--- a/tests/SkillView.Tests/Ui/DoctorScreenTests.cs
+++ b/tests/SkillView.Tests/Ui/DoctorScreenTests.cs
@@ -14,8 +14,8 @@ public void Render_ShowsGhSkillPresent()
var report = new EnvironmentReport
{
GhPath = "/usr/bin/gh",
- GhVersionRaw = "gh version 2.94.0",
- GhVersion = new SemVer(2, 94, 0),
+ GhVersionRaw = "gh version 2.95.0",
+ GhVersion = new SemVer(2, 95, 0),
GhMeetsMinimum = true,
Auth = GhAuthStatus.Unknown,
GhSkillAvailable = true,
@@ -26,7 +26,7 @@ public void Render_ShowsGhSkillPresent()
Assert.Contains("## Environment", body);
Assert.Contains("| gh | `/usr/bin/gh` |", body);
- Assert.Contains("| version | `gh version 2.94.0`", body);
+ Assert.Contains("| version | `gh version 2.95.0`", body);
Assert.Contains("## `gh skill`", body);
Assert.Contains("`gh skill` present", body);
}
@@ -37,8 +37,8 @@ public void Render_MarksGhSkillAbsent()
var report = new EnvironmentReport
{
GhPath = "/usr/bin/gh",
- GhVersionRaw = "gh version 2.94.0",
- GhVersion = new SemVer(2, 94, 0),
+ GhVersionRaw = "gh version 2.95.0",
+ GhVersion = new SemVer(2, 95, 0),
GhMeetsMinimum = true,
Auth = GhAuthStatus.Unknown,
GhSkillAvailable = false,