From 47e086921f48361ee2822d01066101cb9954ced0 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Mon, 1 Jun 2026 18:59:30 -0500 Subject: [PATCH 1/2] feat(cli): install capture hooks into the harness (PROT-8) Add `protostar install-hooks`, which detects supported harnesses and wires protostar's capture hooks into their config idempotently. Harness integrations live behind an IHarness provider boundary (HarnessRegistry + ClaudeCodeHarness), so adding a harness is a single class and harness-specific knowledge stays isolated from the command and orchestration layers. - install-hooks: interactive multi-select, or non-interactive via --harness/--all/--yes; supports --dry-run and --remove. - capture (hidden): the stub installed hooks invoke; reads the hook payload from stdin and acknowledges. Registry sync lands in a later ticket. - ClaudeCodeHarness wires PostToolUse (Skill) and SessionStart through a surgical settings.json merge that preserves unrelated settings and is idempotent (managed entries recognised by a marker, replaced not duplicated). - install/uninstall now wire and remove hooks as part of the lifecycle (--no-hooks to opt out). install also copies the full build output for a framework-dependent build so the installed binary actually runs, and uninstall clears protostar's own directory safely. - Harness paths resolve from --harness-home / PROTOSTAR_HARNESS_ROOT / CLAUDE_CONFIG_DIR / ~/.claude so integration is testable against a fake harness without touching the real one. - Reqnroll acceptance scenarios (Scenario Outline, one row per harness) cover install, idempotency, preservation of existing settings, dry-run, remove, the capture stub, and the install/uninstall lifecycle. --- src/Protostar.Cli/Commands/CaptureCommand.cs | 33 +++ src/Protostar.Cli/Commands/InstallCommand.cs | 62 +++++- .../Commands/InstallHooksCommand.cs | 60 ++++++ .../Commands/UninstallCommand.cs | 61 +++++- .../Harness/ClaudeCodeHarness.cs | 193 ++++++++++++++++++ src/Protostar.Cli/Harness/HarnessLocation.cs | 16 ++ src/Protostar.Cli/Harness/HarnessRegistry.cs | 17 ++ src/Protostar.Cli/Harness/IHarness.cs | 34 +++ src/Protostar.Cli/Hooks/HookInstallService.cs | 140 +++++++++++++ src/Protostar.Cli/Program.cs | 5 + .../Features/Install.feature | 14 +- .../Features/InstallHooks.feature | 80 ++++++++ .../Features/Uninstall.feature | 14 +- .../Steps/CliSteps.cs | 56 ++++- .../Support/HarnessSandbox.cs | 50 +++++ test/README.md | 23 ++- 16 files changed, 831 insertions(+), 27 deletions(-) create mode 100644 src/Protostar.Cli/Commands/CaptureCommand.cs create mode 100644 src/Protostar.Cli/Commands/InstallHooksCommand.cs create mode 100644 src/Protostar.Cli/Harness/ClaudeCodeHarness.cs create mode 100644 src/Protostar.Cli/Harness/HarnessLocation.cs create mode 100644 src/Protostar.Cli/Harness/HarnessRegistry.cs create mode 100644 src/Protostar.Cli/Harness/IHarness.cs create mode 100644 src/Protostar.Cli/Hooks/HookInstallService.cs create mode 100644 test/Protostar.Cli.Acceptance/Features/InstallHooks.feature create mode 100644 test/Protostar.Cli.Acceptance/Support/HarnessSandbox.cs diff --git a/src/Protostar.Cli/Commands/CaptureCommand.cs b/src/Protostar.Cli/Commands/CaptureCommand.cs new file mode 100644 index 0000000..70f302c --- /dev/null +++ b/src/Protostar.Cli/Commands/CaptureCommand.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands; + +/// +/// Invoked by an installed harness hook when a captured event fires (e.g. PostToolUse on the Skill +/// tool). Reads the hook's JSON payload from stdin and acknowledges it. This is the capture seam; +/// syncing the skill to the registry lands in a later ticket. It never blocks the harness and +/// always exits 0. +/// +internal sealed class CaptureCommand : Command +{ + public sealed class Settings : CommandSettings + { + [CommandOption("--hook ")] + [Description("The harness event that triggered capture (e.g. PostToolUse, SessionStart).")] + public string? Hook { get; init; } + } + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) + { + // Only read stdin when it is actually piped (a real hook invocation). Guards against blocking + // if a user runs this by hand in a terminal. + var payload = Console.IsInputRedirected ? Console.In.ReadToEnd() : string.Empty; + var hook = settings.Hook ?? "unknown"; + + // A quiet acknowledgement. For a successful hook, Claude Code surfaces stdout in the + // transcript only, so this never leaks into the model's context. + Console.WriteLine($"protostar capture: {hook} ({payload.Length} bytes)"); + return 0; + } +} diff --git a/src/Protostar.Cli/Commands/InstallCommand.cs b/src/Protostar.Cli/Commands/InstallCommand.cs index 36d2dce..b509353 100644 --- a/src/Protostar.Cli/Commands/InstallCommand.cs +++ b/src/Protostar.Cli/Commands/InstallCommand.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Protostar.Cli.Hooks; using Protostar.Cli.Install; using Spectre.Console; using Spectre.Console.Cli; @@ -6,9 +7,11 @@ namespace Protostar.Cli.Commands; /// -/// Self-installs the running binary: copies this executable into a per-user directory and (unless -/// told not to) ensures that directory is on PATH. Designed to be run from the downloaded -/// self-contained binary — `protostar install`. +/// Self-installs the running binary: copies it into a per-user directory and (unless told not to) +/// ensures that directory is on PATH. A published self-contained single-file binary is copied as +/// one file; a framework-dependent build (e.g. a local `dotnet build`, where the .exe is just an +/// apphost that needs its .dll beside it) has its whole build output copied so the install actually +/// runs. Then capture hooks are wired into detected harnesses (opt out with --no-hooks). /// internal sealed class InstallCommand : Command { @@ -21,6 +24,14 @@ public sealed class Settings : CommandSettings [CommandOption("--no-modify-path")] [Description("Do not add the install directory to PATH.")] public bool NoModifyPath { get; init; } + + [CommandOption("--no-hooks")] + [Description("Do not install capture hooks into detected harnesses.")] + public bool NoHooks { get; init; } + + [CommandOption("--harness-home ")] + [Description("Override the harness config root when installing hooks.")] + public string? HarnessHome { get; init; } } protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) @@ -39,13 +50,23 @@ protected override int Execute(CommandContext context, Settings settings, Cancel { AnsiConsole.MarkupLine($"[green]protostar[/] is already installed at [grey]{Markup.Escape(dest)}[/]."); ReportPath(dir, settings.NoModifyPath); - return 0; + return InstallHooksTail(settings, dest); } + // A framework-dependent build leaves ".dll" beside the apphost ".exe"; that whole set + // must travel together or the installed launcher cannot find its program. A single-file + // self-contained publish has no such sibling and is copied alone. + var sourceDir = Path.GetDirectoryName(source)!; + var isSingleFile = !File.Exists(Path.Combine(sourceDir, Path.GetFileNameWithoutExtension(source) + ".dll")); + try { Directory.CreateDirectory(dir); - File.Copy(source, dest, overwrite: true); + if (isSingleFile) + File.Copy(source, dest, overwrite: true); + else + CopyDirectory(sourceDir, dir); + if (!OperatingSystem.IsWindows()) { File.SetUnixFileMode(dest, @@ -61,8 +82,37 @@ protected override int Execute(CommandContext context, Settings settings, Cancel } AnsiConsole.MarkupLine($"Installed [aqua]protostar[/] [grey]v{CliInfo.Version}[/] → [grey]{Markup.Escape(dest)}[/]"); + if (!isSingleFile) + AnsiConsole.MarkupLine("[grey]Framework-dependent build: copied the full build output; requires the .NET runtime.[/]"); ReportPath(dir, settings.NoModifyPath); - return 0; + return InstallHooksTail(settings, dest); + } + + private static void CopyDirectory(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + foreach (var file in Directory.EnumerateFiles(sourceDir)) + File.Copy(file, Path.Combine(destDir, Path.GetFileName(file)), overwrite: true); + foreach (var sub in Directory.EnumerateDirectories(sourceDir)) + CopyDirectory(sub, Path.Combine(destDir, Path.GetFileName(sub))); + } + + // After placing the binary, wire capture hooks into every detected harness (non-interactive, + // pointing the hooks at the binary we just installed). Opt out with --no-hooks. + private static int InstallHooksTail(Settings settings, string dest) + { + if (settings.NoHooks) + return 0; + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[grey]Installing capture hooks into detected harnesses...[/]"); + return new HookInstallService().Install(new HookInstallService.Options + { + RootOverride = settings.HarnessHome, + All = true, + NonInteractive = true, + ExePathOverride = dest, + }); } private static void ReportPath(string dir, bool noModifyPath) diff --git a/src/Protostar.Cli/Commands/InstallHooksCommand.cs b/src/Protostar.Cli/Commands/InstallHooksCommand.cs new file mode 100644 index 0000000..08c3703 --- /dev/null +++ b/src/Protostar.Cli/Commands/InstallHooksCommand.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using Protostar.Cli.Hooks; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands; + +/// +/// Detects supported harnesses and installs protostar's capture hooks into the selected ones, +/// idempotently. With no selection flags and a TTY it prompts (space to toggle); otherwise it acts +/// non-interactively. --remove tears the hooks back out. +/// +internal sealed class InstallHooksCommand : Command +{ + public sealed class Settings : CommandSettings + { + [CommandOption("-H|--harness ")] + [Description("Target a specific harness by id (repeatable). Implies non-interactive.")] + public string[]? Harness { get; init; } + + [CommandOption("--all")] + [Description("Select all detected harnesses without prompting.")] + public bool All { get; init; } + + [CommandOption("-y|--yes")] + [Description("Non-interactive: skip the prompt and use all detected harnesses.")] + public bool Yes { get; init; } + + [CommandOption("--harness-home ")] + [Description("Override the harness config root (testing or a non-default location).")] + public string? HarnessHome { get; init; } + + [CommandOption("--exe-path ")] + [Description("Path to the protostar binary the hooks should invoke. Defaults to this binary.")] + public string? ExePath { get; init; } + + [CommandOption("--dry-run")] + [Description("Show what would change without writing.")] + public bool DryRun { get; init; } + + [CommandOption("--remove")] + [Description("Remove protostar's capture hooks instead of installing them.")] + public bool Remove { get; init; } + } + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) + { + var options = new HookInstallService.Options + { + RootOverride = settings.HarnessHome, + HarnessIds = settings.Harness, + All = settings.All, + NonInteractive = settings.Yes || settings.Harness is { Length: > 0 }, + DryRun = settings.DryRun, + ExePathOverride = settings.ExePath, + }; + + var service = new HookInstallService(); + return settings.Remove ? service.Uninstall(options) : service.Install(options); + } +} diff --git a/src/Protostar.Cli/Commands/UninstallCommand.cs b/src/Protostar.Cli/Commands/UninstallCommand.cs index 669765f..9581240 100644 --- a/src/Protostar.Cli/Commands/UninstallCommand.cs +++ b/src/Protostar.Cli/Commands/UninstallCommand.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Protostar.Cli.Hooks; using Protostar.Cli.Install; using Spectre.Console; using Spectre.Console.Cli; @@ -17,6 +18,14 @@ public sealed class Settings : CommandSettings [CommandOption("--no-modify-path")] [Description("Do not remove the install directory from PATH.")] public bool NoModifyPath { get; init; } + + [CommandOption("--no-hooks")] + [Description("Do not remove capture hooks from detected harnesses.")] + public bool NoHooks { get; init; } + + [CommandOption("--harness-home ")] + [Description("Override the harness config root when removing hooks.")] + public string? HarnessHome { get; init; } } protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) @@ -24,6 +33,18 @@ protected override int Execute(CommandContext context, Settings settings, Cancel var dir = settings.Dir ?? InstallLocations.DefaultDir(); var dest = Path.Combine(dir, InstallLocations.ExecutableName); + // Remove the capture hooks first: they point at the binary we are about to delete, so + // leaving them would dangle. Opt out with --no-hooks. + if (!settings.NoHooks) + { + new HookInstallService().Uninstall(new HookInstallService.Options + { + RootOverride = settings.HarnessHome, + All = true, + NonInteractive = true, + }); + } + if (!File.Exists(dest)) { AnsiConsole.MarkupLine($"[grey]Nothing to remove — {Markup.Escape(dest)} does not exist.[/]"); @@ -32,10 +53,22 @@ protected override int Execute(CommandContext context, Settings settings, Cancel try { - File.Delete(dest); - // Remove the directory only if we created it and it is now empty. - if (Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) - Directory.Delete(dir); + if (OwnsDirectory(dir)) + { + // A protostar-dedicated directory: clear it entirely (a multi-file install left its + // .dll and runtime config here too). + Directory.Delete(dir, recursive: true); + } + else + { + // A shared or custom directory: remove only protostar's own files so we never delete + // unrelated binaries that happen to live alongside it. + foreach (var file in OwnFiles(dir)) + if (File.Exists(file)) + File.Delete(file); + if (Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) + Directory.Delete(dir); + } } catch (Exception ex) { @@ -48,4 +81,24 @@ protected override int Execute(CommandContext context, Settings settings, Cancel AnsiConsole.MarkupLine($"Removed [aqua]protostar[/] from [grey]{Markup.Escape(dir)}[/]."); return 0; } + + // True when the directory is protostar's own (the default location, or a dir literally named + // "protostar"), so clearing it wholesale is safe. + private static bool OwnsDirectory(string dir) + { + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (string.Equals(Path.GetFullPath(dir), Path.GetFullPath(InstallLocations.DefaultDir()), comparison)) + return true; + var leaf = new DirectoryInfo(dir).Name; + return string.Equals(leaf, "protostar", comparison); + } + + // The files a protostar install owns: the launcher plus the framework-dependent companions. + private static IEnumerable OwnFiles(string dir) + { + var name = Path.GetFileNameWithoutExtension(InstallLocations.ExecutableName); + yield return Path.Combine(dir, InstallLocations.ExecutableName); + foreach (var ext in new[] { ".dll", ".deps.json", ".runtimeconfig.json", ".pdb" }) + yield return Path.Combine(dir, name + ext); + } } diff --git a/src/Protostar.Cli/Harness/ClaudeCodeHarness.cs b/src/Protostar.Cli/Harness/ClaudeCodeHarness.cs new file mode 100644 index 0000000..1044fee --- /dev/null +++ b/src/Protostar.Cli/Harness/ClaudeCodeHarness.cs @@ -0,0 +1,193 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Protostar.Cli.Harness; + +/// +/// Claude Code integration. Capture hooks live in settings.json under hooks: a +/// PostToolUse hook matching the Skill tool (fires after each skill use — the capture +/// trigger) and a SessionStart hook (the seam for the future suggestion/push-back loop). +/// Every edit is surgical (via ) so unrelated user settings and hooks are +/// preserved, and re-running is idempotent: protostar-managed entries are recognised by a marker in +/// their command and replaced rather than duplicated. +/// +internal sealed class ClaudeCodeHarness : IHarness +{ + public string Id => "claude-code"; + public string DisplayName => "Claude Code"; + + /// Hook commands containing this token are protostar-managed and safe to replace/remove. + private const string Marker = "capture --hook"; + + // Relaxed encoder so a quoted Windows exe path serialises as readable \" rather than ", + // matching how Claude Code writes its own settings.json. + private static readonly JsonSerializerOptions WriteOptions = + new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + public bool TryLocate(string? rootOverride, out HarnessLocation location) + { + var (configDir, explicitSource) = ResolveConfigDir(rootOverride); + location = new HarnessLocation(configDir, Path.Combine(configDir, "settings.json")); + // An explicitly chosen root (flag or env var) signals intent, so treat it as present even if + // the directory does not exist yet. Only the default ~/.claude requires real detection. + return explicitSource || Directory.Exists(configDir); + } + + // Resolution order: --harness-home > PROTOSTAR_HARNESS_ROOT > CLAUDE_CONFIG_DIR > ~/.claude. + // The redirectable roots are what make harness integration testable without touching the real + // harness (see the acceptance suite). The bool reports whether the root came from an explicit + // source rather than the ~/.claude default. + private static (string dir, bool explicitSource) ResolveConfigDir(string? rootOverride) + { + if (!string.IsNullOrWhiteSpace(rootOverride)) + return (rootOverride, true); + + var generic = Environment.GetEnvironmentVariable("PROTOSTAR_HARNESS_ROOT"); + if (!string.IsNullOrWhiteSpace(generic)) + return (generic, true); + + var claude = Environment.GetEnvironmentVariable("CLAUDE_CONFIG_DIR"); + if (!string.IsNullOrWhiteSpace(claude)) + return (claude, true); + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return (Path.Combine(home, ".claude"), false); + } + + public HookChangeSet InstallHooks(HarnessLocation location, string exePath, bool dryRun) + { + var root = LoadSettings(location.SettingsPath); + var existed = File.Exists(location.SettingsPath); + var before = root.ToJsonString(); + + // Clear any prior protostar entries (without tidying empties) then re-add, so a second run + // reproduces byte-identical output and an upgraded exe path is picked up. + RemoveManaged(root, cleanupEmpties: false); + AddManaged(root, exePath); + + if (string.Equals(before, root.ToJsonString(), StringComparison.Ordinal)) + return new HookChangeSet(HookChange.Unchanged, location.SettingsPath, "capture hooks already up to date"); + + if (!dryRun) + WriteSettings(location, root); + + return existed + ? new HookChangeSet(HookChange.Updated, location.SettingsPath, "updated capture hooks") + : new HookChangeSet(HookChange.Added, location.SettingsPath, "wrote capture hooks"); + } + + public HookChangeSet RemoveHooks(HarnessLocation location, bool dryRun) + { + if (!File.Exists(location.SettingsPath)) + return new HookChangeSet(HookChange.Unchanged, location.SettingsPath, "no settings file"); + + var root = LoadSettings(location.SettingsPath); + var before = root.ToJsonString(); + + RemoveManaged(root, cleanupEmpties: true); + + if (string.Equals(before, root.ToJsonString(), StringComparison.Ordinal)) + return new HookChangeSet(HookChange.Unchanged, location.SettingsPath, "no protostar hooks present"); + + if (!dryRun) + WriteSettings(location, root); + + return new HookChangeSet(HookChange.Removed, location.SettingsPath, "removed capture hooks"); + } + + // ── hook construction ──────────────────────────────────────────────────────── + + private static void AddManaged(JsonObject root, string exePath) + { + var hooks = GetOrAddObject(root, "hooks"); + AddCommandHook(hooks, "PostToolUse", matcher: "Skill", Command(exePath, "PostToolUse")); + AddCommandHook(hooks, "SessionStart", matcher: null, Command(exePath, "SessionStart")); + } + + // Quote the exe path so one containing spaces survives the harness's shell invocation. + private static string Command(string exePath, string hook) => $"\"{exePath}\" capture --hook {hook}"; + + private static void AddCommandHook(JsonObject hooks, string eventName, string? matcher, string command) + { + var group = new JsonObject(); + if (matcher is not null) + group["matcher"] = matcher; + group["hooks"] = new JsonArray(new JsonObject { ["type"] = "command", ["command"] = command }); + GetOrAddArray(hooks, eventName).Add(group); + } + + private static void RemoveManaged(JsonObject root, bool cleanupEmpties) + { + if (root["hooks"] is not JsonObject hooks) + return; + + foreach (var entry in hooks.ToList()) + { + if (entry.Value is not JsonArray groups) + continue; + for (var i = groups.Count - 1; i >= 0; i--) + if (groups[i] is JsonObject g && g["hooks"] is JsonArray hs && + hs.Any(h => h is JsonObject ho && IsManaged(ho))) + groups.RemoveAt(i); + } + + if (!cleanupEmpties) + return; + + foreach (var key in hooks.Select(kv => kv.Key).ToList()) + if (hooks[key] is JsonArray a && a.Count == 0) + hooks.Remove(key); + if (hooks.Count == 0) + root.Remove("hooks"); + } + + private static bool IsManaged(JsonObject hookEntry) + { + var cmd = hookEntry["command"]?.GetValue(); + return cmd is not null + && cmd.Contains(Marker, StringComparison.Ordinal) + && cmd.Contains("protostar", StringComparison.OrdinalIgnoreCase); + } + + // ── settings.json IO ────────────────────────────────────────────────────────── + + private static JsonObject LoadSettings(string path) + { + if (!File.Exists(path)) + return new JsonObject(); + try + { + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject(); + } + catch (JsonException) + { + // Never clobber a settings file we cannot parse — surface it to the caller instead. + throw new InvalidOperationException($"Could not parse {path} as JSON; leaving it untouched."); + } + } + + private static void WriteSettings(HarnessLocation location, JsonObject root) + { + Directory.CreateDirectory(location.ConfigDir); + File.WriteAllText(location.SettingsPath, root.ToJsonString(WriteOptions)); + } + + private static JsonObject GetOrAddObject(JsonObject parent, string key) + { + if (parent[key] is JsonObject existing) + return existing; + var created = new JsonObject(); + parent[key] = created; + return created; + } + + private static JsonArray GetOrAddArray(JsonObject parent, string key) + { + if (parent[key] is JsonArray existing) + return existing; + var created = new JsonArray(); + parent[key] = created; + return created; + } +} diff --git a/src/Protostar.Cli/Harness/HarnessLocation.cs b/src/Protostar.Cli/Harness/HarnessLocation.cs new file mode 100644 index 0000000..1cd03ad --- /dev/null +++ b/src/Protostar.Cli/Harness/HarnessLocation.cs @@ -0,0 +1,16 @@ +namespace Protostar.Cli.Harness; + +/// A resolved harness install: where its config lives and where hook settings are stored. +internal sealed record HarnessLocation(string ConfigDir, string SettingsPath); + +/// How an install/remove changed a harness's settings. +internal enum HookChange +{ + Unchanged, + Added, + Updated, + Removed, +} + +/// Outcome of installing or removing capture hooks against one harness. +internal sealed record HookChangeSet(HookChange Change, string SettingsPath, string Detail); diff --git a/src/Protostar.Cli/Harness/HarnessRegistry.cs b/src/Protostar.Cli/Harness/HarnessRegistry.cs new file mode 100644 index 0000000..17dd0b6 --- /dev/null +++ b/src/Protostar.Cli/Harness/HarnessRegistry.cs @@ -0,0 +1,17 @@ +namespace Protostar.Cli.Harness; + +/// +/// The set of harnesses protostar knows how to wire. This is the single extension point: to +/// support a new harness, implement and add it to . +/// +internal static class HarnessRegistry +{ + public static IReadOnlyList All { get; } = + [ + new ClaudeCodeHarness(), + ]; + + /// Find a harness by its id (case-insensitive), or null if unknown. + public static IHarness? ById(string id) => + All.FirstOrDefault(h => string.Equals(h.Id, id, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/Protostar.Cli/Harness/IHarness.cs b/src/Protostar.Cli/Harness/IHarness.cs new file mode 100644 index 0000000..13dd344 --- /dev/null +++ b/src/Protostar.Cli/Harness/IHarness.cs @@ -0,0 +1,34 @@ +namespace Protostar.Cli.Harness; + +/// +/// A coding harness protostar can wire capture hooks into (Claude Code, and others in future). +/// Each integration lives behind this boundary so harness-specific knowledge — config locations, +/// settings schema, hook event names — stays isolated. Supporting a new harness means adding one +/// implementation and registering it in ; the command and +/// orchestration layers never change. +/// +internal interface IHarness +{ + /// Stable slug used as the --harness selector value, e.g. claude-code. + string Id { get; } + + /// Human-readable name, e.g. Claude Code. + string DisplayName { get; } + + /// + /// Resolve where this harness keeps its config and decide whether it is present. + /// (from --harness-home) takes precedence over the + /// harness's own environment variables and defaults. Returns false when the harness is + /// not detected (and was not explicitly pointed at via an override). + /// + bool TryLocate(string? rootOverride, out HarnessLocation location); + + /// + /// Idempotently add protostar's capture hooks, preserving all other settings. + /// is the absolute protostar binary the hooks should invoke. + /// + HookChangeSet InstallHooks(HarnessLocation location, string exePath, bool dryRun); + + /// Remove protostar's capture hooks, leaving all other settings untouched. + HookChangeSet RemoveHooks(HarnessLocation location, bool dryRun); +} diff --git a/src/Protostar.Cli/Hooks/HookInstallService.cs b/src/Protostar.Cli/Hooks/HookInstallService.cs new file mode 100644 index 0000000..648f605 --- /dev/null +++ b/src/Protostar.Cli/Hooks/HookInstallService.cs @@ -0,0 +1,140 @@ +using Protostar.Cli.Harness; +using Spectre.Console; + +namespace Protostar.Cli.Hooks; + +/// +/// Orchestrates capture-hook install/remove across harnesses: detect what's present, let the user +/// choose (interactively, or via flags for non-interactive/CI use), then apply and report. Shared +/// by the standalone install-hooks command and the install/uninstall lifecycle +/// so there is one code path, not a command invoking another. +/// +internal sealed class HookInstallService +{ + public sealed record Options + { + /// --harness-home: override the harness config root. + public string? RootOverride { get; init; } + + /// --harness: target these harness ids explicitly (implies non-interactive). + public IReadOnlyList? HarnessIds { get; init; } + + /// --all: select every detected harness without prompting. + public bool All { get; init; } + + /// --yes or a non-TTY context: skip the prompt, default to all detected. + public bool NonInteractive { get; init; } + + /// --dry-run: report intended changes without writing. + public bool DryRun { get; init; } + + /// Path to the protostar binary the hooks should invoke. Defaults to this process. + public string? ExePathOverride { get; init; } + } + + public int Install(Options opts) => Run(opts, remove: false); + + public int Uninstall(Options opts) => Run(opts, remove: true); + + private int Run(Options opts, bool remove) + { + if (!TryResolveTargets(opts, out var targets, out var exitCode)) + return exitCode; + if (targets.Count == 0) + return 0; + + string? exePath = null; + if (!remove) + { + exePath = opts.ExePathOverride ?? Environment.ProcessPath; + if (exePath is null || !File.Exists(exePath)) + { + AnsiConsole.MarkupLine("[red]Could not determine the protostar binary for the hook command.[/]"); + return 1; + } + } + + var failed = false; + foreach (var (harness, location) in targets) + { + try + { + var result = remove + ? harness.RemoveHooks(location, opts.DryRun) + : harness.InstallHooks(location, exePath!, opts.DryRun); + Report(harness, result, opts.DryRun); + } + catch (Exception ex) + { + failed = true; + AnsiConsole.MarkupLine($"[red]{Markup.Escape(harness.DisplayName)}: {Markup.Escape(ex.Message)}[/]"); + } + } + + return failed ? 1 : 0; + } + + // Build the list of (harness, location) to act on. Explicit --harness ids are targeted even if + // not currently present (the user asked for them); otherwise we detect and select. + private static bool TryResolveTargets( + Options opts, + out List<(IHarness harness, HarnessLocation location)> targets, + out int exitCode) + { + targets = []; + exitCode = 0; + + if (opts.HarnessIds is { Count: > 0 }) + { + foreach (var id in opts.HarnessIds) + { + var harness = HarnessRegistry.ById(id); + if (harness is null) + { + AnsiConsole.MarkupLine($"[red]Unknown harness '{Markup.Escape(id)}'.[/] Known: {string.Join(", ", HarnessRegistry.All.Select(h => h.Id))}"); + exitCode = 1; + return false; + } + harness.TryLocate(opts.RootOverride, out var location); + targets.Add((harness, location)); + } + return true; + } + + var detected = new List<(IHarness harness, HarnessLocation location)>(); + foreach (var harness in HarnessRegistry.All) + if (harness.TryLocate(opts.RootOverride, out var location)) + detected.Add((harness, location)); + + if (detected.Count == 0) + { + AnsiConsole.MarkupLine("[grey]No supported harnesses detected. Nothing to do.[/]"); + return true; // empty targets, exit 0 + } + + var interactive = !opts.NonInteractive && !opts.All && AnsiConsole.Profile.Capabilities.Interactive; + targets = interactive ? PromptForHarnesses(detected) : detected; + return true; + } + + private static List<(IHarness harness, HarnessLocation location)> PromptForHarnesses( + List<(IHarness harness, HarnessLocation location)> detected) + { + var prompt = new MultiSelectionPrompt() + .Title("Install protostar capture hooks into which harness(es)?") + .NotRequired() + .InstructionsText("[grey](space to toggle, enter to confirm)[/]"); + foreach (var (harness, _) in detected) + prompt.AddChoice(harness.DisplayName).Select(); + + var chosen = AnsiConsole.Prompt(prompt); + return detected.Where(t => chosen.Contains(t.harness.DisplayName)).ToList(); + } + + private static void Report(IHarness harness, HookChangeSet result, bool dryRun) + { + var tag = dryRun ? "[grey](dry-run)[/] " : string.Empty; + AnsiConsole.MarkupLine( + $"{tag}[aqua]{Markup.Escape(harness.DisplayName)}[/]: {Markup.Escape(result.Detail)} [grey]({Markup.Escape(result.SettingsPath)})[/]"); + } +} diff --git a/src/Protostar.Cli/Program.cs b/src/Protostar.Cli/Program.cs index 860ee90..47c79c9 100644 --- a/src/Protostar.Cli/Program.cs +++ b/src/Protostar.Cli/Program.cs @@ -14,6 +14,11 @@ .WithDescription("Install protostar to a per-user directory and add it to PATH."); config.AddCommand("uninstall") .WithDescription("Remove an installed protostar binary."); + config.AddCommand("install-hooks") + .WithDescription("Detect supported harnesses and install protostar capture hooks idempotently."); + config.AddCommand("capture") + .WithDescription("Capture a harness hook event (invoked by installed hooks).") + .IsHidden(); }); return app.Run(args); diff --git a/test/Protostar.Cli.Acceptance/Features/Install.feature b/test/Protostar.Cli.Acceptance/Features/Install.feature index 9ef428c..e3cbf38 100644 --- a/test/Protostar.Cli.Acceptance/Features/Install.feature +++ b/test/Protostar.Cli.Acceptance/Features/Install.feature @@ -5,15 +5,23 @@ Feature: Installing protostar Scenario: Installing into a clean sandbox directory Given a clean install sandbox - When I run protostar with "install --dir {installDir} --no-modify-path" + When I run protostar with "install --dir {installDir} --no-modify-path --no-hooks" Then the exit code is 0 And the output contains "Installed" And a protostar binary exists in the install dir Scenario: Re-installing into the same directory succeeds Given a clean install sandbox - When I run protostar with "install --dir {installDir} --no-modify-path" - And I run protostar with "install --dir {installDir} --no-modify-path" + When I run protostar with "install --dir {installDir} --no-modify-path --no-hooks" + And I run protostar with "install --dir {installDir} --no-modify-path --no-hooks" Then the exit code is 0 And the output contains "Installed" And a protostar binary exists in the install dir + + Scenario: Installing also wires capture hooks into a detected harness + Given a clean install sandbox + And a fake claude-code harness + When I run protostar with "install --dir {installDir} --no-modify-path --harness-home {harnessHome}" + Then the exit code is 0 + And a protostar binary exists in the install dir + And the harness settings contain "capture --hook" diff --git a/test/Protostar.Cli.Acceptance/Features/InstallHooks.feature b/test/Protostar.Cli.Acceptance/Features/InstallHooks.feature new file mode 100644 index 0000000..4513087 --- /dev/null +++ b/test/Protostar.Cli.Acceptance/Features/InstallHooks.feature @@ -0,0 +1,80 @@ +Feature: Installing capture hooks into a harness + As an operator + I want protostar install-hooks to wire capture into my harness idempotently + So that skill use is captured automatically without manual editing + + Scenario Outline: Installing hooks writes the capture entries + Given a fake harness + When I run protostar with "install-hooks --harness --harness-home {harnessHome} --yes" + Then the exit code is 0 + And the harness settings contain "PostToolUse" + And the harness settings contain "SessionStart" + And the harness settings contain "capture --hook" + + Examples: + | harness | + | claude-code | + + Scenario Outline: Re-running install-hooks is idempotent + Given a fake harness + When I run protostar with "install-hooks --harness --harness-home {harnessHome} --yes" + And I run protostar with "install-hooks --harness --harness-home {harnessHome} --yes" + Then the exit code is 0 + And the harness has 1 protostar PostToolUse hooks + + Examples: + | harness | + | claude-code | + + Scenario Outline: Existing settings and user hooks are preserved + Given a fake harness with settings: + """ + { + "model": "opus", + "hooks": { + "PostToolUse": [ + { "matcher": "Bash", "hooks": [ { "type": "command", "command": "echo mine" } ] } + ] + } + } + """ + When I run protostar with "install-hooks --harness --harness-home {harnessHome} --yes" + Then the exit code is 0 + And the harness settings contain "opus" + And the harness settings contain "echo mine" + And the harness settings contain "capture --hook" + And the harness has 1 protostar PostToolUse hooks + + Examples: + | harness | + | claude-code | + + Scenario Outline: A dry run writes nothing + Given a fake harness + When I run protostar with "install-hooks --harness --harness-home {harnessHome} --yes --dry-run" + Then the exit code is 0 + And the harness has no settings file + + Examples: + | harness | + | claude-code | + + Scenario Outline: Removing hooks leaves other settings intact + Given a fake harness with settings: + """ + { "model": "opus" } + """ + When I run protostar with "install-hooks --harness --harness-home {harnessHome} --yes" + And I run protostar with "install-hooks --harness --harness-home {harnessHome} --yes --remove" + Then the exit code is 0 + And the harness settings contain "opus" + And the harness has 0 protostar PostToolUse hooks + + Examples: + | harness | + | claude-code | + + Scenario: The capture command acknowledges a hook event + When I run protostar with "capture --hook PostToolUse" + Then the exit code is 0 + And the output contains "protostar capture" diff --git a/test/Protostar.Cli.Acceptance/Features/Uninstall.feature b/test/Protostar.Cli.Acceptance/Features/Uninstall.feature index 3ae5870..f409201 100644 --- a/test/Protostar.Cli.Acceptance/Features/Uninstall.feature +++ b/test/Protostar.Cli.Acceptance/Features/Uninstall.feature @@ -5,14 +5,22 @@ Feature: Uninstalling protostar Scenario: Uninstalling removes the installed binary Given a clean install sandbox - When I run protostar with "install --dir {installDir} --no-modify-path" - And I run protostar with "uninstall --dir {installDir} --no-modify-path" + When I run protostar with "install --dir {installDir} --no-modify-path --no-hooks" + And I run protostar with "uninstall --dir {installDir} --no-modify-path --no-hooks" Then the exit code is 0 And the output contains "Removed" And no protostar binary exists in the install dir Scenario: Uninstalling when nothing is installed is a no-op Given a clean install sandbox - When I run protostar with "uninstall --dir {installDir} --no-modify-path" + When I run protostar with "uninstall --dir {installDir} --no-modify-path --no-hooks" Then the exit code is 0 And the output contains "Nothing to remove" + + Scenario: Uninstalling also removes capture hooks from the harness + Given a clean install sandbox + And a fake claude-code harness + When I run protostar with "install --dir {installDir} --no-modify-path --harness-home {harnessHome}" + And I run protostar with "uninstall --dir {installDir} --no-modify-path --harness-home {harnessHome}" + Then the exit code is 0 + And the harness has 0 protostar PostToolUse hooks diff --git a/test/Protostar.Cli.Acceptance/Steps/CliSteps.cs b/test/Protostar.Cli.Acceptance/Steps/CliSteps.cs index cb2498d..a343f91 100644 --- a/test/Protostar.Cli.Acceptance/Steps/CliSteps.cs +++ b/test/Protostar.Cli.Acceptance/Steps/CliSteps.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using CliWrap.Buffered; using Protostar.Cli.Acceptance.Support; using Reqnroll; @@ -7,14 +8,19 @@ namespace Protostar.Cli.Acceptance.Steps; /// /// Step definitions that drive the real protostar binary as a user would and assert on its -/// stdout/stderr, exit code, and filesystem side effects. One per scenario. +/// stdout/stderr, exit code, and filesystem side effects. One and one +/// per scenario. /// [Binding] public sealed class CliSteps : IDisposable { + private readonly HarnessSandbox _harness; private Sandbox? _sandbox; private BufferedCommandResult? _result; + // Reqnroll resolves one HarnessSandbox per scenario and injects it here. + public CliSteps(HarnessSandbox harness) => _harness = harness; + private Sandbox Sandbox => _sandbox ??= new Sandbox(); private BufferedCommandResult Result => @@ -34,15 +40,51 @@ public async Task WhenIRunWithNoArguments() => [When("I run protostar with {string}")] public async Task WhenIRunWith(string argLine) { - // Split on spaces, then substitute the {installDir} placeholder as a whole token so a - // sandbox path containing spaces still arrives as a single argument. + // Split on spaces, then substitute placeholders as whole tokens so a sandbox path + // containing spaces still arrives as a single argument. var args = argLine .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(token => token == "{installDir}" ? Sandbox.InstallDir : token) + .Select(token => token switch + { + "{installDir}" => Sandbox.InstallDir, + "{harnessHome}" => _harness.ConfigDir, + _ => token, + }) .ToList(); _result = await CliRunner.RunAsync(args); } + [Given(@"a fake (\S+) harness")] + public void GivenAFakeHarness(string harness) + { + // The HarnessSandbox ctor already created an empty config dir; the id is captured for the + // Scenario Outline (one row per harness) and reserved for future per-harness layouts. + _ = harness; + } + + [Given(@"a fake (\S+) harness with settings:")] + public void GivenAFakeHarnessWithSettings(string harness, string settings) => + _harness.WriteSettings(settings); + + [Then("the harness settings contain {string}")] + public void ThenTheHarnessSettingsContain(string text) => + Assert.Contains(text, _harness.ReadSettings()); + + [Then("the harness has no settings file")] + public void ThenTheHarnessHasNoSettingsFile() => + Assert.False(File.Exists(_harness.SettingsPath), + $"Did not expect a settings file at '{_harness.SettingsPath}'."); + + [Then("the harness has {int} protostar PostToolUse hooks")] + public void ThenTheHarnessHasNManagedHooks(int expected) + { + var groups = JsonNode.Parse(_harness.ReadSettings())?["hooks"]?["PostToolUse"]?.AsArray(); + var count = groups?.Count(g => + g?["hooks"]?.AsArray()?.Any(h => + (h?["command"]?.GetValue() ?? string.Empty).Contains("capture --hook")) == true) ?? 0; + Assert.Equal(expected, count); + } + [Then("the exit code is {int}")] public void ThenTheExitCodeIs(int expected) => Assert.Equal(expected, Result.ExitCode); @@ -65,5 +107,9 @@ public void ThenNoBinaryExists() => Assert.False(File.Exists(Sandbox.InstalledBinary), $"Did not expect a binary at '{Sandbox.InstalledBinary}'."); - public void Dispose() => _sandbox?.Dispose(); + public void Dispose() + { + _sandbox?.Dispose(); + _harness.Dispose(); + } } diff --git a/test/Protostar.Cli.Acceptance/Support/HarnessSandbox.cs b/test/Protostar.Cli.Acceptance/Support/HarnessSandbox.cs new file mode 100644 index 0000000..764358f --- /dev/null +++ b/test/Protostar.Cli.Acceptance/Support/HarnessSandbox.cs @@ -0,0 +1,50 @@ +namespace Protostar.Cli.Acceptance.Support; + +/// +/// A throwaway fake harness layout in a temp directory. Scenarios point the CLI at +/// via --harness-home so hook-install scenarios assert on the +/// produced settings.json without ever touching the developer's real harness. One instance +/// per scenario (Reqnroll resolves and disposes it); removed on dispose. +/// +public sealed class HarnessSandbox : IDisposable +{ + private bool _disposed; + + public HarnessSandbox() + { + Root = Path.Combine(Path.GetTempPath(), "protostar-harness", Guid.NewGuid().ToString("n")); + ConfigDir = Path.Combine(Root, ".claude"); + Directory.CreateDirectory(ConfigDir); + } + + /// Root of the fixture; removed on dispose. + public string Root { get; } + + /// The harness config dir, passed to the CLI as --harness-home. + public string ConfigDir { get; } + + /// Where the harness keeps its settings (and where hooks are written). + public string SettingsPath => Path.Combine(ConfigDir, "settings.json"); + + /// Current settings.json text, or empty string if none exists yet. + public string ReadSettings() => File.Exists(SettingsPath) ? File.ReadAllText(SettingsPath) : string.Empty; + + /// Seed an initial settings.json (for "existing settings are preserved" scenarios). + public void WriteSettings(string json) => File.WriteAllText(SettingsPath, json); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try + { + if (Directory.Exists(Root)) + Directory.Delete(Root, recursive: true); + } + catch + { + // Best-effort cleanup; a leaked temp dir must never fail a test. + } + } +} diff --git a/test/README.md b/test/README.md index 2725020..8af5fc0 100644 --- a/test/README.md +++ b/test/README.md @@ -34,9 +34,20 @@ specific binary instead (for example a published self-contained build), set `PRO PROTOSTAR_BIN=/path/to/protostar dotnet test ``` -### Extending to harness integrations - -When the CLI gains harness integration (hook install, skill discovery), make every harness path -(config dir, settings file, skills dir) redirectable via an environment variable or flag, then add -a `Support/Harness` fixture beside `Sandbox` that builds a fake harness layout in a temp dir. Use a -Gherkin `Scenario Outline` with one Examples row per harness. See PROT-41 for the full plan. +### Harness integrations + +Hook install (PROT-8) follows the redirectable-root pattern PROT-41 planned for: + +- `Support/HarnessSandbox.cs` — a throwaway fake harness layout (a `.claude/` config dir) in a temp + dir, the harness analogue of `Sandbox`. Scenarios point the CLI at it with `--harness-home`, so + hook scenarios assert on the produced `settings.json` without touching the real harness. +- `Features/InstallHooks.feature` — uses a `Scenario Outline` with one Examples row per harness + (just `claude-code` today) so the same template validates every supported harness as more are + added. +- The CLI resolves the harness config dir from `--harness-home` > `PROTOSTAR_HARNESS_ROOT` > + `CLAUDE_CONFIG_DIR` > `~/.claude`. Tests use `--harness-home`; the env vars exist for real use. +- Binary install/uninstall scenarios pass `--no-hooks` so they stay pure binary tests; the + hook-wiring done by `install`/`uninstall` is covered by its own scenarios pointed at the fixture. + +Assertions check that the written config has the expected shape (schema conformance). Actually +launching Claude Code to confirm the hook fires is a separate manual smoke, out of scope here. From ed0f74ae626aae3287f6d072c5e59661a3812b2a Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Mon, 1 Jun 2026 18:59:30 -0500 Subject: [PATCH 2/2] chore: add pstar dev runner and document local development Add `pstar.ps1` / `pstar.sh` that build-and-run the CLI in place, so manual testing does not need the full `dotnet run --project` invocation. Document a safe scratch-harness workflow (PROTOSTAR_HARNESS_ROOT pointing at a gitignored .dev/) so install-hooks and install can be exercised without editing the real ~/.claude, plus the dev-build install behaviour and the capture stdin caveat. Ignore .dev/ and out/. --- .gitignore | 4 ++++ README.md | 40 ++++++++++++++++++++++++++++++++++++++++ pstar.ps1 | 17 +++++++++++++++++ pstar.sh | 16 ++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 pstar.ps1 create mode 100755 pstar.sh diff --git a/.gitignore b/.gitignore index e81e677..55ea709 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ obj/ [Rr]elease/ publish/ artifacts/ +out/ + +# Local dev scratch (e.g. throwaway harness via PROTOSTAR_HARNESS_ROOT, test installs) +.dev/ # Native AOT / native build intermediates *.ilk diff --git a/README.md b/README.md index 2599477..f3251d4 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,46 @@ dotnet publish src/Protostar.Cli -c Release -r win-x64 --self-contained true \ ./out/protostar install ``` +## Develop + +For manual testing there is a thin dev runner so you do not retype the project path. `pstar ` +is equivalent to `protostar `, building in place first: + +```powershell +.\pstar.ps1 --help # Windows / PowerShell +.\pstar.ps1 install-hooks --yes --dry-run +``` + +```bash +./pstar.sh --help # Linux / macOS +./pstar.sh install-hooks --yes --dry-run +``` + +**Installing a dev build.** `protostar install` from a local build (what `pstar` runs) copies the +whole build output and produces a *framework-dependent* install: it works on any machine with the +.NET runtime (so, your dev box), but is not portable. For a standalone binary that needs no runtime, +publish a self-contained single file first (see "Build from source"), then `install` that. + +**Testing hook/install commands safely.** `install-hooks` (and `install`) write into your real +`~/.claude` by default. To exercise them without touching it, point the harness at a throwaway +scratch dir — the CLI resolves every harness path from `PROTOSTAR_HARNESS_ROOT` (and you can also +pass `--harness-home ` per command): + +```powershell +$env:PROTOSTAR_HARNESS_ROOT = "$PWD\.dev\harness" # scratch; .dev/ is gitignored +.\pstar.ps1 install-hooks --yes +Get-Content .dev\harness\settings.json # inspect what was written +.\pstar.ps1 install-hooks --yes --remove # tear it back out +``` + +`.dev/` is gitignored, so scratch installs and harness fixtures never get committed. To run the +acceptance suite, `dotnet test` from the repo root. + +> The `capture` command is invoked by an installed hook (the real binary) and reads its payload +> from stdin. Piping stdin through the dev runner can hang, because `dotnet run` does not forward +> stdin's end-of-input. To test `capture` by hand, run the built binary directly, e.g. +> `echo '{}' | ./src/Protostar.Cli/bin/Debug/net10.0/protostar capture --hook PostToolUse`. + ## Releasing Releases are automated with [release-please](https://github.com/googleapis/release-please). You never diff --git a/pstar.ps1 b/pstar.ps1 new file mode 100644 index 0000000..0304351 --- /dev/null +++ b/pstar.ps1 @@ -0,0 +1,17 @@ +#!/usr/bin/env pwsh +# Dev runner: build-and-run the protostar CLI in place. +# .\pstar.ps1 == protostar +# +# Examples: +# .\pstar.ps1 --help +# .\pstar.ps1 install-hooks --yes --dry-run +# +# Safe manual testing of hook/install commands: point the harness at a throwaway scratch dir so you +# never edit your real ~/.claude. The CLI honors PROTOSTAR_HARNESS_ROOT for every harness path: +# $env:PROTOSTAR_HARNESS_ROOT = "$PWD\.dev\harness" +# .\pstar.ps1 install-hooks --yes +# Get-Content .dev\harness\settings.json +$ErrorActionPreference = 'Stop' +$project = Join-Path $PSScriptRoot 'src/Protostar.Cli' +dotnet run --project $project -v quiet -- @args +exit $LASTEXITCODE diff --git a/pstar.sh b/pstar.sh new file mode 100755 index 0000000..d06706e --- /dev/null +++ b/pstar.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Dev runner: build-and-run the protostar CLI in place. +# ./pstar.sh == protostar +# +# Examples: +# ./pstar.sh --help +# ./pstar.sh install-hooks --yes --dry-run +# +# Safe manual testing of hook/install commands: point the harness at a throwaway scratch dir so you +# never edit your real ~/.claude. The CLI honors PROTOSTAR_HARNESS_ROOT for every harness path: +# export PROTOSTAR_HARNESS_ROOT="$PWD/.dev/harness" +# ./pstar.sh install-hooks --yes +# cat .dev/harness/settings.json +set -euo pipefail +dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec dotnet run --project "$dir/src/Protostar.Cli" -v quiet -- "$@"