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;
+ }
+}