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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2154,11 +2154,27 @@ Manage the nudges with `kcap harness`:

```bash
kcap harness list # detected / kcap-wired / dismissed, per agent
kcap harness list --json # the same report, machine-readable
kcap harness dismiss antigravity # stop asking about one agent
kcap harness dismiss --all # stop asking about every currently-detected agent
kcap harness reset antigravity # ask again (undo a dismissal)
```

`list --json` emits one JSON document on stdout and nothing else, so it can be piped — the same
contract as [`kcap import --discover --json`](#loading-historical-sessions). Every harness this build
knows is listed, present or not, so a consumer can tell "unsupported" from "not on this machine"
without carrying its own vendor list:

```json
{"harnesses":[{"vendor":"claude","label":"Claude Code","binary_on_path":true,
"config_found":false,"wired":false,"dismissed":false}]}
```

`vendor` is the stable key — the id `dismiss` and `reset` take. The two detection signals stay
apart: `binary_on_path` is the vendor's CLI on your search path, `config_found` its own user-level
data on disk, and either one means installed. A tool choosing which agents to set up wants both,
since it can then say which signal it saw; one that only needs "is it here" ORs them.

To turn off the nudges entirely (both the in-session and command-line surfaces), set
`kcap config set disable_harness_nudge true`. Dismissing is per-agent; a brand-new agent installed
after a `--all` dismiss is still offered once.
Expand Down
15 changes: 15 additions & 0 deletions docs/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ Markdig's renderer throws past 128 nested containers after parsing has returned,
nesting is budgeted at 100 before anything mutates, and normalisation runs before anything
measures a height.

## The harness list answers a machine as well as a person

A tool setting kcap up for someone has to ask which coding agents to record, and the honest option
list is the one this machine can actually produce. `kcap harness list` already knows it, so `--json`
emits the same report as one document on stdout and nothing else, the contract `kcap import
--discover --json` set.

Every harness this build knows is listed, present or not, so a consumer can tell "unsupported" from
"not installed here" without carrying its own vendor list and going stale the day a vendor is added.
The two detection signals stay apart rather than being ORed the way the nudge inventory folds them:
a caller offering someone a choice can then say which signal it saw, and one that only wants "is it
here" ORs them itself. `--json` is refused on `dismiss` and `reset` rather than ignored, because
ignoring it would hand a caller expecting JSON a line of prose on a subcommand that writes.


## A code block carries its own copy, and runs itself when it is a command

An agent that wants a command run asks for it in a fenced block, and a reader who cannot lift the
Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.Cli.Core/Resources/help-usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Configuration:
remap --remove <from> Remove a cwd remap entry

Harness setup nudges:
harness list Show which coding agents are installed and whether kcap is set up for each
harness list [--json] Show which coding agents are installed and whether kcap is set up for each
harness dismiss <vendor…>|--all Stop nudging to set kcap up for an installed-but-unconfigured harness
harness reset <vendor…>|--all Offer a previously-dismissed harness again

Expand Down
19 changes: 16 additions & 3 deletions src/Capacitor.Cli/Commands/HarnessCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,30 @@ public Task<int> HandleAsync(string[] args) {

var store = new HarnessOfferStore(config, time);

// Refused rather than ignored: a caller expecting JSON would get a line of prose back from a
// subcommand that has already written to the ledger.
if (args.Contains("--json") && args[1] != "list") {
Console.Error.WriteLine("--json only applies to `kcap harness list`.");
return Task.FromResult(1);
}

return Task.FromResult(args[1] switch {
"list" => List(harnesses, store),
"list" => List(args, harnesses, store),
"dismiss" => Dismiss(args, harnesses, store),
"reset" => Reset(args, store),
"--help" or "-h" or "help" => Help(),
_ => Unknown(args[1]),
});
}

static int List(HarnessRegistry harnesses, HarnessOfferStore store) {
static int List(string[] args, HarnessRegistry harnesses, HarnessOfferStore store) {
var ledger = store.Load();

if (args.Contains("--json")) {
Console.WriteLine(HarnessListRender.Render(harnesses, ledger));
return 0;
}

Console.WriteLine($" {"Harness",-14}{"Installed",-11}{"kcap wired",-12}Dismissed");
foreach (var h in harnesses) {
var isDismissed = ledger.Entry(h.Id) is { Declined: true };
Expand Down Expand Up @@ -118,7 +131,7 @@ static string UnknownVendor(string id) =>

static void PrintUsage() {
Console.Error.WriteLine("Usage: kcap harness <list|dismiss|reset>");
Console.Error.WriteLine(" list show detected / kcap-wired / dismissed state per harness");
Console.Error.WriteLine(" list [--json] show detected / kcap-wired / dismissed state per harness");
Console.Error.WriteLine(" dismiss <vendor…>|--all stop nudging to set kcap up for a harness");
Console.Error.WriteLine(" reset <vendor…>|--all offer a previously-dismissed harness again");
}
Expand Down
31 changes: 31 additions & 0 deletions src/Capacitor.Cli/Commands/HarnessListJson.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;

namespace Capacitor.Cli.Commands;

/// <summary>One harness's row in <c>kcap harness list --json</c>.</summary>
/// <param name="Vendor">The stable key, and the id <c>kcap harness dismiss</c> and <c>reset</c>
/// take.</param>
/// <param name="Label">Display text. May be reworded; do not key off it.</param>
/// <param name="BinaryOnPath">The vendor's CLI resolves on the search path.</param>
/// <param name="ConfigFound">The vendor's own user-level data exists here.</param>
/// <param name="Wired">kcap's hook or extension is registered with the vendor.</param>
/// <param name="Dismissed">Its setup nudge was turned down locally.</param>
/// <remarks>
/// The two detection signals stay apart, as they do in <see cref="Capacitor.Cli.Core.FirstRun.FirstRunHarnessReport"/>:
/// a caller offering the user a choice can say which signal it saw, and one that only needs "is it
/// here" ORs them itself.
/// </remarks>
public sealed record HarnessListEntryJson(
string Vendor, string Label, bool BinaryOnPath, bool ConfigFound, bool Wired, bool Dismissed);

/// <summary>Machine-readable payload for <c>kcap harness list --json</c>.</summary>
/// <remarks>
/// Every harness this build knows is listed, present or not, in the registry's own display order —
/// so a caller can tell "we do not support it" from "it is not on this machine" without carrying its
/// own vendor list.
/// </remarks>
public sealed record HarnessListJson(IReadOnlyList<HarnessListEntryJson> Harnesses);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)]
[JsonSerializable(typeof(HarnessListJson))]
public partial class HarnessListJsonContext : JsonSerializerContext;
25 changes: 25 additions & 0 deletions src/Capacitor.Cli/Commands/HarnessListRender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Text.Json;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Setup;

namespace Capacitor.Cli.Commands;

/// <summary>Pure renderer for the list payload — kept separate from I/O so it's directly testable.</summary>
internal static class HarnessListRender {
public static string Render(HarnessRegistry harnesses, HarnessOfferLedger ledger) {
var entries = new List<HarnessListEntryJson>();

foreach (var harness in harnesses) {
var agent = harnesses.Detect(harness.Id);
entries.Add(new HarnessListEntryJson(
harness.VendorId,
harness.Label,
agent.BinaryFound,
agent.InstallSignalFound,
harness.Signals.IsWired,
ledger.Entry(harness.Id) is { Declined: true }));
}

return JsonSerializer.Serialize(new HarnessListJson(entries), HarnessListJsonContext.Default.HarnessListJson);
}
}
69 changes: 69 additions & 0 deletions test/Capacitor.Cli.Tests.Unit/Commands/HarnessListJsonTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text.Json;
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Setup;
using TUnit.Assertions.Enums;

namespace Capacitor.Cli.Tests.Unit.Commands;

public class HarnessListJsonTests {
static JsonElement Render(HarnessRegistry harnesses, HarnessOfferLedger? ledger = null) =>
JsonDocument.Parse(HarnessListRender.Render(harnesses, ledger ?? new HarnessOfferLedger())).RootElement;

static JsonElement Row(JsonElement root, string vendor) =>
root.GetProperty("harnesses").EnumerateArray().Single(e => e.GetProperty("vendor").GetString() == vendor);

[Test]
public async Task Lists_every_known_harness_in_registry_order() {
var root = Render(TestHarnesses.All());
var rows = root.GetProperty("harnesses").EnumerateArray().ToList();

await Assert.That(rows.Count).IsEqualTo(HarnessRegistry.Identities.Count);
await Assert.That(rows.Select(r => r.GetProperty("vendor").GetString()!))
.IsEquivalentTo(HarnessRegistry.Identities.Select(i => i.Id.VendorId), CollectionOrdering.Matching);
}

[Test]
public async Task Renders_snake_case_row() {
var root = Render(TestHarnesses.All(detected: [HarnessId.Claude], wired: [HarnessId.Claude]));
var row = Row(root, HarnessId.Claude.VendorId);

await Assert.That(row.GetProperty("label").GetString()).IsEqualTo(HarnessRegistry.LabelOf(HarnessId.Claude));
await Assert.That(row.GetProperty("config_found").GetBoolean()).IsTrue();
await Assert.That(row.GetProperty("wired").GetBoolean()).IsTrue();
await Assert.That(row.GetProperty("dismissed").GetBoolean()).IsFalse();
}

// The signal a caller offering the user a choice needs to name, and the reason this payload does
// not fold the two the way the nudge inventory does.
[Test]
public async Task Keeps_the_two_detection_signals_apart() {
using var bin = new TempDir();
var root = Render(HarnessRegistry.Over(
TestBinaries.Searching(bin, "claude"),
TestHarnesses.Probing(HarnessId.Claude, "claude"),
TestHarnesses.Of(HarnessId.Cursor, detected: true)));

await Assert.That(Row(root, "claude").GetProperty("binary_on_path").GetBoolean()).IsTrue();
await Assert.That(Row(root, "claude").GetProperty("config_found").GetBoolean()).IsFalse();
await Assert.That(Row(root, "cursor").GetProperty("binary_on_path").GetBoolean()).IsFalse();
await Assert.That(Row(root, "cursor").GetProperty("config_found").GetBoolean()).IsTrue();
}

[Test]
public async Task An_absent_harness_is_listed_with_both_signals_false() {
var row = Row(Render(TestHarnesses.All(detected: [HarnessId.Claude])), HarnessId.Codex.VendorId);

await Assert.That(row.GetProperty("binary_on_path").GetBoolean()).IsFalse();
await Assert.That(row.GetProperty("config_found").GetBoolean()).IsFalse();
}

[Test]
public async Task Reports_a_dismissal_from_the_ledger() {
var ledger = new HarnessOfferLedger().WithDismissed([HarnessId.Cursor], DateTimeOffset.UtcNow);
var root = Render(TestHarnesses.All(detected: [HarnessId.Cursor]), ledger);

await Assert.That(Row(root, HarnessId.Cursor.VendorId).GetProperty("dismissed").GetBoolean()).IsTrue();
await Assert.That(Row(root, HarnessId.Claude.VendorId).GetProperty("dismissed").GetBoolean()).IsFalse();
}
}
Loading