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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,14 @@ The setup wizard walks you through:

kcap reports the coding agents it found on this machine — which are on your `PATH`, which have config on disk, which kcap is already wired into, and whether your login shell can find `kcap` — since only the machine can know that, and on a device-code sign-in the browser is a different box entirely. **Whatever you choose there is what step 4 installs**, per agent and per choice, instead of the terminal prompt; a `--skip-<agent>` flag still wins, and "Not now" installs nothing.

That screen also asks who may read the sessions this machine records from now on, and **step 3 then reports that answer instead of asking again**. Declining every agent and still choosing an audience is a coherent answer, so the two are read separately. Answer that screen without setting an audience and step 3 keeps whatever your profile already has, rather than re-asking — its prompt defaults to org-public, so re-asking could widen a narrower setting you never changed. If the screen was never answered at all, step 3 prompts as before.

**The Import screen's figures come from this machine, and its answer runs here.** Once you have answered the Agents screen, kcap scans for past sessions and reports what it found — per repository and per history window (last 30 days / last 90 days / everything), plus how many sessions could not be attributed to a repository at all. It scans only the agents you kept: an agent you were offered and left off is skipped, while one with history on disk but nothing installed now was never offered, so its history still counts. The report is capped at 200 repositories, newest activity first, and the screen says how many it is not showing.

When you answer that screen, **the import runs in the terminal while you are still on the browser's last screen** — one pass for repositories you kept to yourself (`--private`) and one for those you shared, each scoped to the window you chose. Waiting resumes when it finishes, and step 6 below then reports what ran instead of asking again. A shared repository gets an explicit per-session visibility write, because leaning on the profile default would deliver owner-only on any server that is not using the shared GitHub App.

**The browser can ask this machine to fix a broken `PATH` while you wait.** If your login shell cannot find `kcap`, the Agents screen offers to link it, and pressing that button runs the same `kcap daemon shim ensure` you could run yourself — so on macOS **you will be asked for your admin password** while you are still looking at the browser. kcap prints a line naming what asked, before the dialog appears. The screen reports what happened either way; nothing is attempted unless you press the button, and only the named PATH fix can be requested — the browser cannot ask this machine to run anything else.
3. **Default visibility** — choose how your sessions are visible to others
3. **Default visibility** — choose how your sessions are visible to others. Answered on the browser's Agents screen, this step reports that choice rather than prompting
4. **Coding-agent hooks** — detects Claude Code and Codex CLI on `PATH`, Cursor by user-dir presence (`~/.cursor/`), GitHub Copilot CLI by `~/.copilot/` or `copilot` on `PATH`, Google Gemini CLI by `~/.gemini/` or `gemini` on `PATH`, AWS Kiro CLI by `~/.kiro/` or `kiro`/`kiro-cli` on `PATH`, Pi by `~/.pi/` or `pi` on `PATH`, SST OpenCode by `~/.config/opencode/` (or `~/.local/share/opencode/`) or `opencode` on `PATH`, and Google Antigravity by `~/.gemini/antigravity/` (GUI) or `~/.gemini/antigravity-cli/` (the `agy` CLI) or `antigravity`/`agy` on `PATH`, lists what it found, then asks **one** yes/no prompt to install kcap for every detected agent (hooks — or, for Pi/OpenCode/Antigravity, the live-ingest plugin — plus skills, instructions, and MCP) — plus a single shared set of agent skills under `~/.agents/skills/`, installed once when any of Codex, Cursor, Copilot, Gemini, Pi, or OpenCode is detected (Claude gets its skills from the bundled plugin; AWS Kiro and Google Antigravity read their own skills dirs — `~/.kiro/skills` and `~/.gemini/skills` respectively — so each gets its own copy there instead of the shared tree) — all user-wide. For Codex it also offers to enable **sandbox network access** for kcap (see below) — Codex blocks sandbox network by default, so the kcap skills can't reach the server without it. Each agent's own config-relocation environment variable is honored when set: `CLAUDE_CONFIG_DIR` (Claude), `CODEX_HOME` (Codex), `GEMINI_CLI_HOME` (Gemini — names the parent of `.gemini`), `KIRO_HOME` (Kiro), `COPILOT_HOME` (Copilot), `OPENCODE_CONFIG_DIR` (OpenCode), and `PI_CODING_AGENT_DIR` (Pi). Cursor's hooks path is fixed at `~/.cursor/hooks.json` and is not relocated.
5. **Daemon** — configure the daemon name for remote agent execution (the daemon verb is `kcap daemon`; `kcap agent` is a separate group that runs coding agents — see [Local agents](#local-agents-kcap-agent))
6. **Import past sessions** — offers (default yes) to import this repository's past sessions across every detected agent, equivalent to `kcap import --repo .`. Only shown when the current directory is a git repo with a resolvable origin remote and your authentication requirements are satisfied — which includes no-auth servers (auth provider `None`, no token needed); otherwise it's skipped with the usual `kcap import` hint. Opt out with `--skip-import`. **Answered in the browser, this step reports rather than prompts** — the screen there chooses repositories and a history window this prompt cannot express, so re-asking would offer to redo part of what already ran.
Expand Down
24 changes: 24 additions & 0 deletions docs/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ Not release notes. Each entry is written as of the change that produced it and i
code moves on; where an entry disagrees with the code, the code wins.


## The Agents screen's visibility answer reaches the profile

The flow asked who may read future sessions, recorded it on `FirstRunAgentsDecidedEvent`, served it on
the poll as `default_visibility` — and no CLI read it. The field was absent from the wire models
entirely, so it was dropped at deserialisation and `kcap setup`'s step 3 prompted unconditionally and
wrote its own answer over it. The one place in the flow that asked a question and discarded the answer.

It rides the Agents decision, so it is read off the same answer and gated the same way, and it is
validated against `AppConfig.ValidVisibilities` rather than forwarded: the value lands in profile
config and is stamped on every session afterwards, so a stop a newer server invented would be written
to a file this build owns and read back by something that may not mean the same by it. A dropped value
degrades to null, which leaves the profile as it was — the same outcome as never having asked.

**The two nulls are not the same.** The field is null both when the step is unanswered and when it was
answered and left unset, and only the first should reach the prompt: the prompt's cursor starts on
`org_public`, so a Return on a re-run would widen an existing `private` on a question the user had
already answered. An answered-but-unset screen therefore re-writes what the profile already holds,
which is the lane's contract for a null answer and a no-op for everything downstream. Whether the step
settled is what separates them, and `SetupCommand.DecideVisibility` is the one place that decides.

Declining every harness while still choosing an audience is coherent, so `IsDecline` says nothing about
the visibility. No precedence question against `--default-visibility` arises: that flag is read only
under `--no-prompt`, where the browser leg never runs.

## `--private` stamps a value

An omitted `default_visibility` is not "no default": the server's generated column reads
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# AI-2282 — Apply the Agents step's default-visibility answer

## Problem

The Agents step asks who may read the sessions this machine records from now on, records the answer on
`FirstRunAgentsDecidedEvent`, and serves it on the poll as `default_visibility`. **No CLI read it.** The
field was absent from `Capacitor.Cli.Core/FirstRun`'s wire models, so it was dropped at
deserialisation, and `SetupCommand`'s step 3 then prompted unconditionally and wrote its own answer to
the profile.

That made it the one place in the flow that asked a question and discarded the answer. The Agents
decision itself is applied; this rides the same event and was not.

AI-2215's spec named the CLI half as AI-2216, and AI-2216 merged without it — a gap between two
tickets rather than a decision.

## Where it lives

**On `FirstRunAgentsAnswer`, not beside it.** It rides the same decision and the same timestamp, so a
separate reader would need the same gate (the Agents step settled), the same null handling, and could
disagree with the choices about which decision it came from. One answer, two fields.

**Validated, never forwarded.** The value lands in profile config and is stamped on every session this
machine records afterwards. A stop a newer server invented would therefore be written to a file this
build owns and read back later by a server that may no longer mean the same thing by it — which is the
same argument the lane already makes for vendor keys and enum members, applied to the one field that
persists locally. `AppConfig.ValidVisibilities` is the closed set; AI-2215's own spec argued against a
parallel enum, and this is why it was right: the CLI already has the set, and a second spelling of it
would be a mapping table with no meaning.

A dropped value degrades to null, and what null then means depends on whether the step settled — see
below. Either way the degradation cannot widen anything: on a settled step the profile is carried
unchanged, and on an unsettled one the user is asked.

## Null is not a value, and the two nulls are not the same

The wire field is null both when the step is unanswered and when the user answered it and set nothing.
An earlier draft of this design said both fall through to the prompt. **That is wrong, and it was the
one defect an external review found here**: the prompt cannot leave the profile alone, because its
cursor starts on `org_public`, so a single Return on a re-run silently widens an existing `private` —
on a screen the user had already answered. The lane's own contract says a null answer leaves the
profile alone; falling through to the prompt violates it.

So the two nulls are separated by whether the **step settled**, which `FirstRunAgentsAnswer` already
encodes by existing at all:

| Agents step | `default_visibility` | Step 3 |
| --- | --- | --- |
| settled | a stop | applies it |
| settled | null | re-writes what the profile already holds, and says so |
| never settled | — | prompts, exactly as before |

Re-writing the profile's own value rather than skipping the write keeps `defaultVisibility` a single
non-nullable string for everything downstream — the profile write, the `saved` context, step 6's
import stamp — instead of threading a nullable through paths that have no meaning for one.

The rule is `SetupCommand.DecideVisibility`, extracted because `HandleSetupAsync`'s interactive
branches have no test coverage at all: every `HandleAsync_*` test drives `--no-prompt`, which never
reaches the browser leg. A rule a reviewer just found a defect in should not be the part that is
untestable.

**`IsDecline` says nothing about it.** Declining every harness and still choosing who may read future
sessions is a coherent answer — the screen asks two questions — so the two are read separately.

## The precedence question that turned out not to exist

The ticket expected a rule for `--default-visibility` against the browser's answer, on the precedent
that `--skip-<agent>` still wins over the Agents decision. There is no case: that flag is read **only**
under `--no-prompt`, and the browser leg is skipped under `--no-prompt` entirely (it waits on a human).
So the two can never both be present, and inventing a precedence rule would have been dead code with a
test that could only assert its own scaffolding.

Interactively the flag is ignored today, before and after this change. Making it live is a separate
behaviour change and not this ticket's.

## Copy

Step 3 reports rather than prompts, as steps 4 and 6 already do when the browser answered them. The
stop labels moved into `SetupCommand.VisibilityLabel` so the prompt's converter and the report share
one list — two lists that have to correspond are one list, and a stop described differently in two
places is how a user learns not to trust either.

## Tests

The boundary's own: every stop the wire can name is carried, null degrades to null, an unknown stop and
an empty string both degrade to null, and declining every harness still carries the answer. Two
mutations confirm they bind — ignoring the validation, and never reading the field — and both had to be
reshaped to compile, because dropping the reference outright trips IDE0005/IDE0051 as errors. Analyzer
protection is worth noting but is not test coverage.

`DecideVisibility`'s three arms are pinned separately, with three more mutations: an answered-but-unset
screen falling through to the prompt, an unsettled screen treated as answered, and the kept branch
inventing a fallback of its own instead of carrying the profile's value.

The field is also round-tripped through the source-generated JSON context against WireMock, present and
absent. Nothing else covered that: every other test builds `FirstRunFlowResponse` directly, so a naming
or AOT-binding slip would have left the profile untouched for ever with the whole suite green.
14 changes: 11 additions & 3 deletions src/Capacitor.Cli.Core/FirstRun/FirstRunAgentsAnswer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@ public sealed record FirstRunAgentsChoice(string VendorId, bool Record, bool Too
/// <param name="Unrecognised">How many entries named a vendor this build has never heard of. Dropped
/// rather than forwarded, and counted so the user can be told their CLI is behind their server rather
/// than left with a harness that silently did not get set up.</param>
/// <param name="DefaultVisibility">Who may read the sessions this machine records from now on, or null
/// to leave the profile's own value alone. Part of this answer rather than beside it, because it rides
/// the same decision and the same timestamp — see the wire model.</param>
public sealed record FirstRunAgentsAnswer(
IReadOnlyList<FirstRunAgentsChoice> Choices,
DateTimeOffset DecidedAt,
int Unrecognised) {
/// <summary>The user asked for nothing, and we understood all of it. Distinct from an answer whose
/// every entry was dropped, which asks for nothing only because this build could not read it.</summary>
int Unrecognised,
string? DefaultVisibility = null) {
/// <summary>The user asked for nothing to be installed, and we understood all of it. Distinct from
/// an answer that asks for nothing only because none of its entries are readable here.
///
/// <para>Says nothing about <see cref="DefaultVisibility"/>: declining every harness and still
/// choosing who may read future sessions is a coherent answer, so the two are read separately.</para>
/// </summary>
public bool IsDecline => Choices.Count == 0 && Unrecognised == 0;

/// <summary>Install capture for this harness. False for a vendor the answer never mentions — a
Expand Down
15 changes: 15 additions & 0 deletions src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,21 @@ public sealed record FirstRunFlowResponse {
/// </summary>
[JsonPropertyName("agents_decided_at")] public DateTimeOffset? AgentsDecidedAt { get; init; }

/// <summary>
/// The default session visibility the same decision chose, as a canonical
/// <c>default_visibility</c> value.
///
/// <para><b>Null is not a value</b>, and it is null in two situations that are not the same: the step
/// was answered and no audience set, and the step was never answered at all. Only the first says
/// anything about the profile — see <c>SetupCommand.DecideVisibility</c>, which separates them by
/// whether the step settled, because the second has told the machine nothing.</para>
///
/// <para><b>A stop this build cannot name is dropped, not written.</b> The value persists in profile
/// config and is stamped on every session afterwards, so it is mapped through
/// <c>AppConfig.ValidVisibilities</c> and degrades to null, which leaves the profile as it was.</para>
/// </summary>
[JsonPropertyName("default_visibility")] public string? DefaultVisibility { get; init; }

/// <summary>
/// What the browser is asking this machine to do, and the one field on this response the CLI acts on
/// rather than records. Absent or empty means nothing is outstanding.
Expand Down
15 changes: 14 additions & 1 deletion src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Setup;

namespace Capacitor.Cli.Core.FirstRun;
Expand Down Expand Up @@ -149,9 +150,21 @@ public static bool IsFinished(FirstRunFlowResponse view) =>
choices.Add(new FirstRunAgentsChoice(entry.Vendor, entry.Record, entry.Tools));
}

return new FirstRunAgentsAnswer(choices, decidedAt, unrecognised);
return new FirstRunAgentsAnswer(choices, decidedAt, unrecognised, Visibility(view.DefaultVisibility));
}

/// <summary>
/// The default visibility to write, or null to leave the profile alone.
///
/// <para><b>Validated against this build's own closed set, not forwarded.</b> The value lands in
/// profile config and is stamped on every session this machine records afterwards, so one a newer
/// server invented would be written to disk and then read back by a server whose meaning for it this
/// build cannot check. Null is what a dropped value degrades to, which leaves the profile untouched —
/// the same outcome as never having asked.</para>
/// </summary>
static string? Visibility(string? value) =>
value is { Length: > 0 } && AppConfig.ValidVisibilities.Contains(value) ? value : null;

/// <summary>
/// The actions the browser is asking this machine to perform, filtered to ones this build can name.
///
Expand Down
Loading