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,