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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/SkillView.Core/Cli/CliDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))}");
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/SkillView.Core/Environment/EnvironmentProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/SkillView.Core/Environment/EnvironmentReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="GhBinaryLocator.MinimumVersion"/>),
/// SkillView requires gh ≥ 2.95.0 (see <see cref="GhBinaryLocator.MinimumVersion"/>),
/// 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.
Expand Down
12 changes: 8 additions & 4 deletions src/SkillView.Core/Gh/GhBinaryLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
130 changes: 129 additions & 1 deletion src/SkillView.Core/Gh/GhSkillInstallService.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -62,6 +63,133 @@ public async Task<InstallResult> InstallAsync(
};
}

/// Discover the skills a repository offers without installing anything.
/// Runs `gh skill install <repo>` 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<RepoSkillListing> 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<string> 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<string> { "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<RepoSkill> ParseRepoSkillListing(string? stdout)
{
if (string.IsNullOrWhiteSpace(stdout))
{
return ImmutableArray<RepoSkill>.Empty;
}

var builder = ImmutableArray.CreateBuilder<RepoSkill>();
var seen = new HashSet<string>(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<string> SkillNames)
{
public bool IsEmpty => !UseAll && SkillNames.IsDefaultOrEmpty;
}

/// Pure: map (all discovered skills, set of checked names) to an
/// <see cref="InstallPlan"/>. Selecting every skill collapses to `--all`.
public static InstallPlan BuildInstallPlan(
IReadOnlyList<RepoSkill> discovered,
IReadOnlyCollection<string> 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<string>.Empty);
}

return new InstallPlan(UseAll: false, selected);
}

internal static IReadOnlyList<string> BuildArgs(
string repo,
string? skillName,
Expand Down
2 changes: 1 addition & 1 deletion src/SkillView.Core/Gh/GhSkillSearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/SkillView.Core/Gh/GhSkillUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
7 changes: 7 additions & 0 deletions src/SkillView.Core/Gh/Models/RepoSkill.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SkillView.Gh.Models;

/// One skill discovered inside a single repository, as surfaced by
/// `gh skill install <repo>` 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);
32 changes: 32 additions & 0 deletions src/SkillView.Core/Gh/Models/RepoSkillListing.cs
Original file line number Diff line number Diff line change
@@ -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 <repo>` run non-interactively (gh ≥ 2.95.0,
/// cli/cli#13548). On success, <see cref="Skills"/> holds the parsed rows;
/// on failure the listing is empty and <see cref="ErrorMessage"/> carries the
/// trimmed stderr.
public sealed record RepoSkillListing
{
public required string Repo { get; init; }
public string? Version { get; init; }
public required ImmutableArray<RepoSkill> Skills { get; init; }
public required bool Succeeded { get; init; }
public required int ExitCode { get; init; }
public string? ErrorMessage { get; init; }
public required IReadOnlyList<string> CommandLine { get; init; }

public static RepoSkillListing Failure(
string repo, string? version, int exitCode, string? error, IReadOnlyList<string> commandLine) =>
new()
{
Repo = repo,
Version = version,
Skills = ImmutableArray<RepoSkill>.Empty,
Succeeded = false,
ExitCode = exitCode,
ErrorMessage = error,
CommandLine = commandLine,
};
}
5 changes: 3 additions & 2 deletions src/SkillView.Core/Inventory/LocalInventoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public async Task<InventorySnapshot> 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}");

Expand All @@ -56,7 +57,7 @@ public async Task<InventorySnapshot> 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;
Expand Down
16 changes: 15 additions & 1 deletion src/SkillView.Core/Inventory/ScanRootResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ public sealed class ScanRootResolver
public sealed record Options(
string CurrentDirectory,
string HomeDirectory,
IReadOnlyList<string> CustomRoots);
IReadOnlyList<string> CustomRoots,
string? ClaudeUserConfigDir = null);

/// Emits scan roots that actually exist on disk. `Options.CurrentDirectory`
/// is used to probe for a git working tree.
Expand All @@ -53,6 +54,19 @@ public ImmutableArray<ScanRoot> 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;
Expand Down
2 changes: 1 addition & 1 deletion src/SkillView.Core/Ui/DoctorScreen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
5 changes: 3 additions & 2 deletions src/SkillView.Core/Ui/HelpOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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._
Expand Down
4 changes: 2 additions & 2 deletions src/SkillView.Core/Ui/InstallScreen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading