diff --git a/readme.md b/readme.md index 7b48515..ac8a9ab 100644 --- a/readme.md +++ b/readme.md @@ -387,29 +387,36 @@ dnx okf -- spec -v 0.2 -o SPEC.md ## `skill` Install or remove the bundled [agent skill](skills/okf/SKILL.md) that teaches -coding agents how to run `dnx okf`. Same path rules as -[go#’s skill command](https://github.com/devlooped/go#agent-skill): +coding agents how to run `dnx okf`. Writes to `.agents/skills/okf/SKILL.md` +under the chosen base directory. ```bash -# Install to ~/.agents/skills/okf/SKILL.md (prompts for confirmation) +# Interactive: choose Local (.) vs Global (~) dnx okf -- skill -# Install for the current project under .agents/skills/okf/SKILL.md +# Install under the current directory dnx okf -- skill . -# Skip the confirmation prompt -dnx okf -- skill -y -dnx okf -- skill . --yes +# Install under the user home directory +dnx okf -- skill -g +dnx okf -- skill --global -# Remove a previously installed skill (same path rules as install) -dnx okf -- skill remove +# Skip the confirmation prompt (directory or --global required) +dnx okf -- skill . -y +dnx okf -- skill -g --yes + +# Remove a previously installed skill +dnx okf -- skill remove # only one copy → remove it; both → pick dnx okf -- skill remove . +dnx okf -- skill remove -g dnx okf -- skill remove -y ``` -With no directory, the skill is written under the user home directory. Pass a -base directory (commonly `.`) to install under that location instead. Either -form overwrites an existing install. +With no directory and no `--global`, `skill` prompts for **Local** (same +destination as `.`) or **Global** (`~\.agents\skills\okf\SKILL.md`, including +on Windows). `skill remove` with no destination removes the only installed +copy; if both Local and Global exist, it prompts. Pass a base directory or +`-g`/`--global` to skip the picker. Either form overwrites an existing install. --- diff --git a/skills/okf/SKILL.md b/skills/okf/SKILL.md index f9ed646..de77b39 100644 --- a/skills/okf/SKILL.md +++ b/skills/okf/SKILL.md @@ -46,7 +46,7 @@ Do not fetch the spec over the network. `schema` is the graph JSON Schema for | `schema [-v ver] [-o file]` | Graph JSON Schema (`okf.json` shape) to stdout or file | | `spec [-v ver] [-o file]` | OKF spec markdown to stdout or file | | `view [path]` | HTML reader + full body+nav graph | -| `skill [dir]` | Install this skill (`skill remove` to uninstall) | +| `skill [dir] [-g]` | Install this skill (`skill remove` to uninstall) | `-v` / `--version` selects a bundled format version (`latest` by default). Pass an explicit version when a bundle declares `okf_version`. Unknown diff --git a/src/Tests/SkillCommandsTests.cs b/src/Tests/SkillCommandsTests.cs index 6f0e818..b0d3e8e 100644 --- a/src/Tests/SkillCommandsTests.cs +++ b/src/Tests/SkillCommandsTests.cs @@ -27,6 +27,156 @@ public void ResolveSkillPath_uses_directory_when_provided() SkillCommands.ResolveSkillPath(".")); } + [Fact] + public void FormatScopePath_uses_dot_for_local_and_tilde_for_global() + { + var local = SkillCommands.FormatScopePath(false); + var global = SkillCommands.FormatScopePath(true); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + Assert.Equal(Path.Combine(".", ".agents", "skills", "okf", "SKILL.md"), local); + Assert.Equal(Path.Combine("~", ".agents", "skills", "okf", "SKILL.md"), global); + Assert.StartsWith("." + Path.DirectorySeparatorChar, local); + Assert.StartsWith("~" + Path.DirectorySeparatorChar, global); + Assert.DoesNotContain(home, global); + } + + [Fact] + public void ResolveInstallDestination_prompts_when_unspecified() + { + var result = SkillCommands.ResolveInstallDestination(null, global: false); + Assert.Equal(SkillCommands.DestinationKind.Prompt, result.Kind); + Assert.Null(result.Path); + } + + [Fact] + public void ResolveInstallDestination_global_uses_home() + { + var result = SkillCommands.ResolveInstallDestination(null, global: true); + Assert.Equal(SkillCommands.DestinationKind.Target, result.Kind); + Assert.Equal(SkillCommands.ResolveSkillPath(null), result.Path); + Assert.True(result.Confirm); + } + + [Fact] + public void ResolveInstallDestination_directory_uses_that_base() + { + var root = CreateTempDir(); + var result = SkillCommands.ResolveInstallDestination(root, global: false); + Assert.Equal(SkillCommands.DestinationKind.Target, result.Kind); + Assert.Equal(SkillCommands.ResolveSkillPath(root), result.Path); + Assert.True(result.Confirm); + } + + [Fact] + public void ResolveInstallDestination_rejects_directory_and_global() + { + var result = SkillCommands.ResolveInstallDestination(".", global: true); + Assert.Equal(SkillCommands.DestinationKind.Error, result.Kind); + Assert.Contains("--global", result.Error); + } + + [Fact] + public void ResolveRemoveDestination_prompts_when_both_exist() + { + var local = SkillCommands.ResolveSkillPath(CreateTempDir()); + var global = SkillCommands.ResolveSkillPath(CreateTempDir()); + WriteSkill(local); + WriteSkill(global); + + var result = SkillCommands.ResolveRemoveDestination(null, global: false, local, global); + Assert.Equal(SkillCommands.DestinationKind.Prompt, result.Kind); + } + + [Fact] + public void ResolveRemoveDestination_removes_only_local_without_confirm() + { + var local = SkillCommands.ResolveSkillPath(CreateTempDir()); + var global = SkillCommands.ResolveSkillPath(CreateTempDir()); + WriteSkill(local); + + var result = SkillCommands.ResolveRemoveDestination(null, global: false, local, global); + Assert.Equal(SkillCommands.DestinationKind.Target, result.Kind); + Assert.Equal(local, result.Path); + Assert.False(result.Confirm); + } + + [Fact] + public void ResolveRemoveDestination_removes_only_global_without_confirm() + { + var local = SkillCommands.ResolveSkillPath(CreateTempDir()); + var global = SkillCommands.ResolveSkillPath(CreateTempDir()); + WriteSkill(global); + + var result = SkillCommands.ResolveRemoveDestination(null, global: false, local, global); + Assert.Equal(SkillCommands.DestinationKind.Target, result.Kind); + Assert.Equal(global, result.Path); + Assert.False(result.Confirm); + } + + [Fact] + public void ResolveRemoveDestination_neither_exists_does_not_prompt() + { + var local = SkillCommands.ResolveSkillPath(CreateTempDir()); + var global = SkillCommands.ResolveSkillPath(CreateTempDir()); + + var result = SkillCommands.ResolveRemoveDestination(null, global: false, local, global); + Assert.Equal(SkillCommands.DestinationKind.Target, result.Kind); + Assert.False(result.Confirm); + } + + [Fact] + public void ResolveRemoveDestination_same_location_does_not_prompt() + { + var dest = SkillCommands.ResolveSkillPath(CreateTempDir()); + WriteSkill(dest); + + var result = SkillCommands.ResolveRemoveDestination(null, global: false, dest, dest); + Assert.Equal(SkillCommands.DestinationKind.Target, result.Kind); + Assert.Equal(dest, result.Path); + Assert.False(result.Confirm); + } + + [Fact] + public void ResolveRemoveDestination_explicit_directory_confirms() + { + var root = CreateTempDir(); + var result = SkillCommands.ResolveRemoveDestination(root, global: false); + Assert.Equal(SkillCommands.DestinationKind.Target, result.Kind); + Assert.Equal(SkillCommands.ResolveSkillPath(root), result.Path); + Assert.True(result.Confirm); + } + + [Fact] + public void ResolveRemoveDestination_global_flag_confirms() + { + var result = SkillCommands.ResolveRemoveDestination(null, global: true); + Assert.Equal(SkillCommands.DestinationKind.Target, result.Kind); + Assert.Equal(SkillCommands.ResolveSkillPath(null), result.Path); + Assert.True(result.Confirm); + } + + [Fact] + public void ResolveRemoveDestination_rejects_directory_and_global() + { + var result = SkillCommands.ResolveRemoveDestination(".", global: true); + Assert.Equal(SkillCommands.DestinationKind.Error, result.Kind); + Assert.Contains("--global", result.Error); + } + + [Fact] + public void RenderScopeLine_marks_selected_local_path() + { + var path = SkillCommands.FormatScopePath(false); + var selected = SkillCommands.RenderScopeLine("Local", path, selected: true); + var idle = SkillCommands.RenderScopeLine("Global", SkillCommands.FormatScopePath(true), selected: false); + + Assert.Contains("[green]●[/] Local", selected); + Assert.Contains(path, selected); + Assert.Contains("[grey]○ Global", idle); + Assert.DoesNotContain("[green]", idle); + } + [Fact] public void Bundled_skill_matches_repo_skill_file() { @@ -67,6 +217,12 @@ public void Uninstall_succeeds_when_not_installed() Assert.Equal(0, SkillCommands.Uninstall(dest)); } + static void WriteSkill(string dest) + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.WriteAllText(dest, "skill"); + } + static string CreateTempDir() { var dir = Path.Combine(Path.GetTempPath(), "okf-tests-" + Guid.NewGuid().ToString("N")); diff --git a/src/okf/SkillCommands.cs b/src/okf/SkillCommands.cs index 7a1b4cb..72e441a 100644 --- a/src/okf/SkillCommands.cs +++ b/src/okf/SkillCommands.cs @@ -1,6 +1,7 @@ using System.Text; using ConsoleAppFramework; using Spectre.Console; +using Spectre.Console.Rendering; namespace Devlooped; @@ -11,31 +12,35 @@ public class SkillCommands static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); /// Installs the bundled okf agent skill (SKILL.md) for agent tooling. - /// Optional base directory. Defaults to the user home directory. Use '.' for the current directory. Writes to .agents/skills/okf/SKILL.md under that base. + /// Optional base directory. Use '.' for the current directory. Writes to .agents/skills/okf/SKILL.md under that base. Omit together with --global to choose Local vs Global interactively. /// -y, Skip confirmation prompt. + /// -g, Install under the user home directory. [Command("skill")] - public int Skill([Argument] string? directory = null, bool yes = false) + public int Skill([Argument] string? directory = null, bool yes = false, bool global = false) { - var dest = ResolveSkillPath(directory); - - if (!yes && !AnsiConsole.Confirm($"Install okf skill to [green]{Markup.Escape(dest)}[/]?", defaultValue: true)) - return 0; - - return Install(dest); + var resolved = ResolveInstallDestination(directory, global); + return resolved.Kind switch + { + DestinationKind.Error => Fail(resolved.Error!), + DestinationKind.Prompt => PromptInstall(), + _ => ConfirmThen(resolved.Path!, yes, resolved.Confirm, Install, "Install okf skill to", "green"), + }; } /// Removes a previously installed okf agent skill. - /// Optional base directory. Defaults to the user home directory. Use '.' for the current directory. + /// Optional base directory. Use '.' for the current directory. Omit together with --global to remove the only installed copy, or choose when both Local and Global exist. /// -y, Skip confirmation prompt. + /// -g, Remove from the user home directory. [Command("skill remove")] - public int Remove([Argument] string? directory = null, bool yes = false) + public int Remove([Argument] string? directory = null, bool yes = false, bool global = false) { - var dest = ResolveSkillPath(directory); - - if (!yes && !AnsiConsole.Confirm($"Remove okf skill from [yellow]{Markup.Escape(dest)}[/]?", defaultValue: true)) - return 0; - - return Uninstall(dest); + var resolved = ResolveRemoveDestination(directory, global); + return resolved.Kind switch + { + DestinationKind.Error => Fail(resolved.Error!), + DestinationKind.Prompt => PromptRemove(), + _ => ConfirmThen(resolved.Path!, yes, resolved.Confirm, Uninstall, "Remove okf skill from", "yellow"), + }; } internal static int Install(string dest) @@ -89,4 +94,233 @@ internal static string ResolveSkillPath(string? directory) return Path.GetFullPath(Path.Combine(root, ".agents", "skills", "okf", "SKILL.md")); } -} \ No newline at end of file + + /// + /// Display path for the Local (.) or Global (~) scope. Uses + /// ~ even on Windows (never expands the user profile). + /// + internal static string FormatScopePath(bool global) + => Path.Combine(global ? "~" : ".", ".agents", "skills", "okf", "SKILL.md"); + + internal static DestinationResolution ResolveInstallDestination(string? directory, bool global) + { + if (HasDirectory(directory) && global) + return DestinationResolution.Fail("Specify a directory or --global, not both."); + + if (global) + return DestinationResolution.Target(ResolveSkillPath(null), confirm: true); + + if (HasDirectory(directory)) + return DestinationResolution.Target(ResolveSkillPath(directory), confirm: true); + + return DestinationResolution.Prompt(); + } + + internal static DestinationResolution ResolveRemoveDestination(string? directory, bool global) + => ResolveRemoveDestination(directory, global, ResolveSkillPath("."), ResolveSkillPath(null)); + + internal static DestinationResolution ResolveRemoveDestination( + string? directory, + bool global, + string localDest, + string globalDest) + { + if (HasDirectory(directory) && global) + return DestinationResolution.Fail("Specify a directory or --global, not both."); + + if (global) + return DestinationResolution.Target(ResolveSkillPath(null), confirm: true); + + if (HasDirectory(directory)) + return DestinationResolution.Target(ResolveSkillPath(directory), confirm: true); + + if (SamePath(localDest, globalDest)) + return DestinationResolution.Target(localDest, confirm: false); + + var localExists = File.Exists(localDest); + var globalExists = File.Exists(globalDest); + + if (localExists && globalExists) + return DestinationResolution.Prompt(); + + if (localExists) + return DestinationResolution.Target(localDest, confirm: false); + + if (globalExists) + return DestinationResolution.Target(globalDest, confirm: false); + + return DestinationResolution.Target(localDest, confirm: false); + } + + internal enum DestinationKind + { + Target, + Prompt, + Error, + } + + internal readonly record struct DestinationResolution( + DestinationKind Kind, + string? Path, + string? Error, + bool Confirm) + { + public static DestinationResolution Target(string path, bool confirm) + => new(DestinationKind.Target, path, null, confirm); + + public static DestinationResolution Prompt() + => new(DestinationKind.Prompt, null, null, false); + + public static DestinationResolution Fail(string message) + => new(DestinationKind.Error, null, message, false); + } + + internal enum SkillScope + { + Local, + Global, + } + + internal static string RenderScopeLine(string label, string path, bool selected) + { + var escaped = Markup.Escape(path); + return selected + ? $"[green]●[/] {label} [grey]({escaped})[/]" + : $"[grey]○ {label} ({escaped})[/]"; + } + + static int PromptInstall() + { + if (!AnsiConsole.Profile.Capabilities.Interactive) + { + return Fail("Specify a directory or pass --global when not running interactively."); + } + + var choice = PromptScope("Installation scope", + [ + (SkillScope.Local, FormatScopePath(false)), + (SkillScope.Global, FormatScopePath(true)), + ]); + + if (choice is null) + return 0; + + return Install(DestFor(choice.Value)); + } + + static int PromptRemove() + { + if (!AnsiConsole.Profile.Capabilities.Interactive) + { + return Fail("Both local and global okf skills are installed. Specify a directory or pass --global."); + } + + var choice = PromptScope("Removal scope", + [ + (SkillScope.Local, FormatScopePath(false)), + (SkillScope.Global, FormatScopePath(true)), + ]); + + if (choice is null) + return 0; + + return Uninstall(DestFor(choice.Value)); + } + + static SkillScope? PromptScope(string title, IReadOnlyList<(SkillScope Scope, string DisplayPath)> options) + { + var index = 0; + SkillScope? result = null; + + AnsiConsole.Live(RenderScopePrompt(title, options, index, showFooter: true)) + .AutoClear(false) + .Start(ctx => + { + while (true) + { + var key = AnsiConsole.Console.Input.ReadKey(intercept: true); + if (key is null) + return; + + switch (key.Value.Key) + { + case ConsoleKey.UpArrow: + index = (index + options.Count - 1) % options.Count; + break; + case ConsoleKey.DownArrow: + index = (index + 1) % options.Count; + break; + case ConsoleKey.Enter: + result = options[index].Scope; + ctx.UpdateTarget(RenderScopePrompt(title, options, index, showFooter: false)); + return; + case ConsoleKey.Escape: + ctx.UpdateTarget(Text.Empty); + return; + case ConsoleKey.C when key.Value.Modifiers.HasFlag(ConsoleModifiers.Control): + ctx.UpdateTarget(Text.Empty); + return; + default: + continue; + } + + ctx.UpdateTarget(RenderScopePrompt(title, options, index, showFooter: true)); + } + }); + + return result; + } + + static IRenderable RenderScopePrompt( + string title, + IReadOnlyList<(SkillScope Scope, string DisplayPath)> options, + int selected, + bool showFooter) + { + var rows = new List + { + new Markup($"[grey]{Markup.Escape(title)}[/]"), + }; + + for (var i = 0; i < options.Count; i++) + { + var label = options[i].Scope == SkillScope.Local ? "Local" : "Global"; + rows.Add(new Markup(RenderScopeLine(label, options[i].DisplayPath, i == selected))); + } + + if (showFooter) + rows.Add(new Markup("[grey]↑/↓ to navigate • Enter: confirm[/]")); + + return new Rows(rows); + } + + static int ConfirmThen(string dest, bool yes, bool confirm, Func action, string verb, string color) + { + if (confirm && !yes && + !AnsiConsole.Confirm($"{verb} [{color}]{Markup.Escape(dest)}[/]?", defaultValue: true)) + { + return 0; + } + + return action(dest); + } + + static string DestFor(SkillScope scope) + => scope == SkillScope.Global ? ResolveSkillPath(null) : ResolveSkillPath("."); + + static bool HasDirectory(string? directory) => !string.IsNullOrWhiteSpace(directory); + + static bool SamePath(string left, string right) + { + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), comparison); + } + + static int Fail(string message) + { + ConsoleApp.LogError(message); + return 1; + } +}