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: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,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 <args>`
is equivalent to `protostar <args>`, 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 <DIR>` 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
Expand Down
17 changes: 17 additions & 0 deletions pstar.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env pwsh
# Dev runner: build-and-run the protostar CLI in place.
# .\pstar.ps1 <args> == protostar <args>
#
# 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
16 changes: 16 additions & 0 deletions pstar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Dev runner: build-and-run the protostar CLI in place.
# ./pstar.sh <args> == protostar <args>
#
# 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 -- "$@"
33 changes: 33 additions & 0 deletions src/Protostar.Cli/Commands/CaptureCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.ComponentModel;
using Spectre.Console.Cli;

namespace Protostar.Cli.Commands;

/// <summary>
/// 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.
/// </summary>
internal sealed class CaptureCommand : Command<CaptureCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandOption("--hook <EVENT>")]
[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;
}
}
62 changes: 56 additions & 6 deletions src/Protostar.Cli/Commands/InstallCommand.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
using System.ComponentModel;
using Protostar.Cli.Hooks;
using Protostar.Cli.Install;
using Spectre.Console;
using Spectre.Console.Cli;

namespace Protostar.Cli.Commands;

/// <summary>
/// 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).
/// </summary>
internal sealed class InstallCommand : Command<InstallCommand.Settings>
{
Expand All @@ -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 <DIR>")]
[Description("Override the harness config root when installing hooks.")]
public string? HarnessHome { get; init; }
}

protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
Expand All @@ -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 "<name>.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,
Expand All @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions src/Protostar.Cli/Commands/InstallHooksCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.ComponentModel;
using Protostar.Cli.Hooks;
using Spectre.Console.Cli;

namespace Protostar.Cli.Commands;

/// <summary>
/// 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. <c>--remove</c> tears the hooks back out.
/// </summary>
internal sealed class InstallHooksCommand : Command<InstallHooksCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandOption("-H|--harness <ID>")]
[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 <DIR>")]
[Description("Override the harness config root (testing or a non-default location).")]
public string? HarnessHome { get; init; }

[CommandOption("--exe-path <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);
}
}
61 changes: 57 additions & 4 deletions src/Protostar.Cli/Commands/UninstallCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using Protostar.Cli.Hooks;
using Protostar.Cli.Install;
using Spectre.Console;
using Spectre.Console.Cli;
Expand All @@ -17,13 +18,33 @@ 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 <DIR>")]
[Description("Override the harness config root when removing hooks.")]
public string? HarnessHome { get; init; }
}

protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation)
{
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.[/]");
Expand All @@ -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)
{
Expand All @@ -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<string> 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);
}
}
Loading
Loading