diff --git a/.changeset/global-trust-opt-in.md b/.changeset/global-trust-opt-in.md new file mode 100644 index 00000000..1a74832d --- /dev/null +++ b/.changeset/global-trust-opt-in.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Ask separately before changing your global agent settings. The installer now explains why it wants an entry in `~/.claude/settings.json`, shows the exact text, and defaults to no. Saying no still installs Taskless; your agent may just ask you to approve Taskless commands more often. diff --git a/openspec/changes/archive/2026-11-05-global-trust-opt-in/design.md b/openspec/changes/archive/2026-11-05-global-trust-opt-in/design.md new file mode 100644 index 00000000..97c6e9fa --- /dev/null +++ b/openspec/changes/archive/2026-11-05-global-trust-opt-in/design.md @@ -0,0 +1,33 @@ +## Context + +`agent-global-trust` puts project and global settings edits under one confirmation. Answering no cancels the install, so the global edit is effectively required for anyone who wants Taskless through the wizard. The launcher, its lockfile and the recorded version already exist; this change is about consent, not mechanism. + +## Goals / Non-Goals + +**Goals:** + +- A person can install Taskless and decline the edit outside the project. +- The person can tell what they are agreeing to, and why, from the prompt alone. + +**Non-Goals:** + +- Remembering a decline. The wizard runs only when a person starts it, so asking again is cheap, and a project manifest has no business recording personal state. +- Changing the entry's text, which would mark every existing entry stale. +- Writing from `init --no-interactive`, which stays read-only outside the project. + +## Decisions + +**Ask after the summary, before any write.** Every write stays behind every question, so cancelling at any step leaves everything as it was. Declining the summary ends the wizard without reaching this question. + +**Default to no.** Enter is the one key that takes no reading, so it should not change a global file. A person who skips by accident gets approval prompts, which the next wizard run offers to fix. + +**Show the exact entry.** It is prose an AI classifier reads, so a paraphrase would leave out what is actually being granted. + +**Say that the entry reaches every project.** A user-level entry follows the launcher's path, not this repository, and a manual probe on 2026-10-19 found that an allowance covering the path also passed a launcher swapped to run another package. A prompt that explained only the pin would overstate the protection, so the step says this plainly. + +**A decline is a statement, not a skip.** The red `SKIPPED (global):` block means an edit the user still owes. After a deliberate no, it would argue with the answer, so the wizard prints one line naming the cost and how to be offered it again. A file that could not be edited still warns, since that was not the person's choice. + +## Risks / Trade-offs + +- Fewer people will end up with the entry, so more will see approval prompts in auto mode. → Accepted: a repeated prompt costs a keystroke, and the prompt offers the fix on the next run. +- The explanation is long for a terminal prompt. → It appears only when the entry is absent or stale, and it is the only edit outside the project. diff --git a/openspec/changes/archive/2026-11-05-global-trust-opt-in/proposal.md b/openspec/changes/archive/2026-11-05-global-trust-opt-in/proposal.md new file mode 100644 index 00000000..3df5054b --- /dev/null +++ b/openspec/changes/archive/2026-11-05-global-trust-opt-in/proposal.md @@ -0,0 +1,32 @@ +## Why + +The wizard asks one question for every settings edit, so a person who wants Taskless but not an edit to `~/.claude/settings.json` can only cancel the install. The edit sits on two lines in that summary and nothing says why it is there or that it reaches past this project. + +An installer that rewrites a person's global agent config without a clear opt-in is what drew the complaint in [postmanlabs/postman-app-support#14370](https://github.com/postmanlabs/postman-app-support/issues/14370). Taskless does ask, but a question that bundles the global edit with the install is not much of a choice. Skipping the edit should be a real option, because it costs little: the launcher and its pin still work, and the agent asks for approval more often. + +This reverses the "one grouped settings question" decision in `agent-global-trust`. + +## What Changes + +- The global trust entry gets its own question, after the summary's confirmation and before anything is written. +- The question defaults to no, so Enter leaves the user's settings alone. +- The step explains why the entry is needed, what it covers, that it applies to every project on the machine, the exact text, how to undo it, and what skipping costs. +- Declining continues the install and writes nothing outside the project. Cancelling the prompt still cancels the install. +- A decline gets one calm line rather than the red `SKIPPED (global):` block. That block stays for `init`, and for a file the wizard could not edit. +- **BREAKING for no one**: `--no-interactive` and `--json` are unchanged, and the entry's text is unchanged, so no existing entry goes stale. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `agent-global-trust`: the entry has its own opt-in question, and declining it no longer cancels the install. +- `cli-init`: the summary's confirmation covers project edits only. + +## Impact + +- New `packages/cli/src/wizard/steps/global-trust.ts`. `src/install/global-trust.ts`, `src/wizard/index.ts`, `src/wizard/steps/summary.ts`. +- Delivery shape: **single PR**. The prompt, the wizard flow and the spec change only make sense together, and the diff fits one review. diff --git a/openspec/changes/archive/2026-11-05-global-trust-opt-in/specs/agent-global-trust/spec.md b/openspec/changes/archive/2026-11-05-global-trust-opt-in/specs/agent-global-trust/spec.md new file mode 100644 index 00000000..a3ebb986 --- /dev/null +++ b/openspec/changes/archive/2026-11-05-global-trust-opt-in/specs/agent-global-trust/spec.md @@ -0,0 +1,158 @@ +## MODIFIED Requirements + +### Requirement: The global entry is offered only in the interactive wizard + +When the entry is absent or stale, the wizard SHALL ask about it in its own step, after the summary step's confirmation and before anything is written, inside or outside the project. The step SHALL show one line: `ALLOW (global):` for an absent entry, `REPLACE (global):` for a stale one. The line SHALL name this build's package, say a call is covered only at an exact version, and name the file and key. + +Before asking, the step SHALL explain, in the same text whichever build runs it apart from the package and launcher names: + +- that the launcher and the version it runs are in the project, where they can be read; +- why a project rule does not reach the harness's classifier, and that the classifier cannot see the pin; +- that this is the only file the installer changes outside the project, and that the entry applies to every project on the machine, including a launcher at the same path in another repository; +- the entry's exact text; +- that dist-tags, version ranges, and unversioned calls are not covered, and that removing the entry undoes it; +- that Taskless installs either way, and that without the entry the harness may ask for approval or refuse Taskless commands. + +The question SHALL default to no. Declining it SHALL leave the file byte-identical and the install SHALL continue, printing one line that names the cost and how to be offered the entry again. Cancelling the prompt SHALL cancel the install and write nothing, inside or outside the project. Declining the summary's confirmation SHALL cancel the install, write nothing, and not ask this question. + +`--no-interactive` and `--json` SHALL NEVER write a file outside the project. + +When written, the edit SHALL add the entry to the list, or for a stale entry replace that one string at its position, keeping every other key and entry in the file. When the file or the `allow` list does not exist, the written list SHALL be `["$defaults", ]`, so the harness's shipped exceptions stay in force. When the list exists without `"$defaults"`, `init` SHALL NOT add it, since the user chose to replace the defaults. When the file does not parse, `init` SHALL leave it unchanged and report it as skipped. + +Nothing about the entry SHALL be recorded in the project manifest, and a declined offer SHALL NOT be remembered anywhere: every wizard run that finds the entry absent or stale SHALL offer it again. + +#### Scenario: The wizard offers the entry + +- **WHEN** the wizard installs for `.claude/` and `~/.claude/settings.json` has no entry under this build's label +- **THEN** the wizard SHALL show an `ALLOW (global):` line naming `~/.claude/settings.json` and `autoMode.allow`, with the explanation +- **AND** SHALL ask its own question, defaulting to no, after the summary's confirmation + +#### Scenario: The wizard offers to replace a stale entry + +- **WHEN** the wizard installs for `.claude/` and `autoMode.allow` is `["$defaults", "Taskless CLI: older text", "Internal Registry: ..."]` +- **THEN** the wizard SHALL show a `REPLACE (global):` line naming `~/.claude/settings.json` and `autoMode.allow` +- **AND** on confirmation the list SHALL be `["$defaults", , "Internal Registry: ..."]` + +#### Scenario: Declining writes nothing anywhere + +- **WHEN** the user declines the summary's confirmation +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** no project permission file SHALL be written +- **AND** the wizard SHALL NOT ask about the global entry + +#### Scenario: Declining the entry still installs + +- **WHEN** the user confirms the summary and declines the global entry +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** the install SHALL complete, with the project permission rule written +- **AND** the wizard SHALL print one line, and no `SKIPPED (global):` block + +#### Scenario: Cancelling the question writes nothing anywhere + +- **WHEN** the user cancels the prompt that asks about the global entry +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** no project file SHALL be written + +#### Scenario: The question defaults to no + +- **WHEN** the wizard asks about the global entry +- **THEN** pressing Enter SHALL decline it + +#### Scenario: The step shows the exact entry + +- **WHEN** the wizard asks about the global entry +- **THEN** the step SHALL show the entry's text, word for word, before the question +- **AND** SHALL say the entry applies to every project on the machine + +#### Scenario: The global entry alone is still asked + +- **WHEN** the project's permission files already hold the installed rule and the entry is absent or stale +- **THEN** the summary SHALL show no settings group and ask nothing +- **AND** the wizard SHALL ask only the global entry's question + +#### Scenario: A declined offer is asked again + +- **WHEN** the user declined the entry on an earlier wizard run and it is still absent or stale +- **THEN** the next wizard run SHALL show the step and ask again + +#### Scenario: A new list keeps the defaults + +- **WHEN** the entry is written to a `~/.claude/settings.json` with no `autoMode` key +- **THEN** `autoMode.allow` SHALL be `["$defaults", ]` +- **AND** every other key in the file SHALL be unchanged + +#### Scenario: An existing list is appended to + +- **WHEN** `autoMode.allow` is `["$defaults", "Internal Registry: ..."]` +- **THEN** the written list SHALL be `["$defaults", "Internal Registry: ...", ]` + +#### Scenario: Non-interactive never writes it + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent or stale +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards + +#### Scenario: The manifest does not record it + +- **WHEN** the wizard writes the entry +- **THEN** `.taskless/taskless.json` SHALL contain no reference to `~/.claude/settings.json` + +### Requirement: A missing global entry is warned about + +When a harness in the table is installed for and its entry is absent or stale after `init` finishes, and the person did not just decline it in the wizard, `init` SHALL print a `SKIPPED (global):` line in red, in both modes, as the reporting of permission edits colors a skip, followed by text naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. A skip is an edit the user has to make by hand, which is why it is red; `init` SHALL NOT make it for them. For a stale entry the text SHALL say the existing entry under this build's label is out of date and is to be replaced by the one shown. Under `--json` this output SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, absent, stale, was written, or was skipped with a reason. + +#### Scenario: Non-interactive warns + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** the output SHALL include a warning naming `~/.claude/settings.json`, `autoMode.allow`, and the entry text +- **AND** the warning SHALL name the installer invocation, run with no arguments in a terminal, as the way to be offered it + +#### Scenario: Non-interactive warns about a stale entry + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is stale +- **THEN** the output SHALL include a warning saying the existing entry under this build's label is out of date, with the current entry text + +#### Scenario: A skipped file warns after a wizard install + +- **WHEN** the wizard completes an install for `.claude/` and `~/.claude/settings.json` does not parse +- **THEN** the wizard SHALL print the same warning, with the reason the file was left alone + +#### Scenario: A decline is not warned about + +- **WHEN** the user declines the entry in the wizard +- **THEN** the wizard SHALL NOT print a `SKIPPED (global):` block + +#### Scenario: A present entry is silent + +- **WHEN** the entry is present +- **THEN** `init` SHALL print no global trust warning + +#### Scenario: JSON reports the state + +- **WHEN** `init --json --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** the envelope SHALL report `~/.claude/settings.json` with the entry absent + +#### Scenario: JSON reports a stale entry + +- **WHEN** `init --json --no-interactive` runs for `.claude/` and the entry is stale +- **THEN** the envelope SHALL report `~/.claude/settings.json` with state `stale` + +#### Scenario: The skip is red + +- **WHEN** color is on and `init --no-interactive` runs for `.claude/` with the entry absent +- **THEN** the output SHALL include a red `SKIPPED (global):` line naming `~/.claude/settings.json` + +### Requirement: Cleanup of the global entry runs on every install and upgrade + +The check for this build's global entry, including folding duplicates, SHALL run on every `init`, whether it is a first install or an upgrade, and SHALL NOT be a scaffold migration: migrations run on every write path, including `--no-interactive` and CI, and the entry lives outside the project. The wizard SHALL perform the whole cleanup in the one edit its global entry question confirms, shown as a single `REPLACE (global):` line, never as one line per removed string. When the entry is stale because of duplicates, the `SKIPPED (global):` output that `init` prints otherwise SHALL say how many strings carry the label and that the wizard will fold them into one. + +#### Scenario: Non-interactive names the duplicates + +- **WHEN** `init --no-interactive` runs for `.claude/` and `autoMode.allow` holds two strings carrying this build's label +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** the `SKIPPED (global):` output SHALL say two strings carry the label and that running the installer with no arguments in a terminal folds them into one + +#### Scenario: One confirmation covers the fold + +- **WHEN** the wizard finds three strings carrying this build's label +- **THEN** the wizard SHALL show one `REPLACE (global):` line for them +- **AND** declining SHALL leave the file byte-identical diff --git a/openspec/changes/archive/2026-11-05-global-trust-opt-in/specs/cli-init/spec.md b/openspec/changes/archive/2026-11-05-global-trust-opt-in/specs/cli-init/spec.md new file mode 100644 index 00000000..24d71398 --- /dev/null +++ b/openspec/changes/archive/2026-11-05-global-trust-opt-in/specs/cli-init/spec.md @@ -0,0 +1,58 @@ +## MODIFIED Requirements + +### Requirement: Init reports every permission edit + +Every time `init` edits or creates a harness permission file, its output SHALL name the file and each rule it changed, one per line, labelled so the reader cannot miss that a settings file was touched: + +``` +Edited .claude/settings.json + ALLOW: Bash(npx @taskless/cli@0.13.1 *) + REMOVE: Bash(npx @taskless/cli@0.13.0 *) +``` + +- `ALLOW: ` for a rule added, rendered in green, since an addition is what the reader needs to notice. +- `REMOVE: ` for a Taskless rule taken out because it is not one of the installed rules, whether or not the manifest recorded it, rendered in the terminal's default color, since removing Taskless's own outdated rules is expected. +- `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in red. A skipped edit is one the user has to make by hand, and the Taskless commands it would have allowed can be refused until they do, so it is the only label rendered in red. + +The same color for each label SHALL apply wherever a settings edit is shown, project or global, in the wizard and in printed output. `REPLACE`, which only the global trust entry uses, SHALL be rendered in yellow: a change to an existing entry, worth a moment's attention. + +The first line SHALL say `Created` for a file Taskless created and `Edited` for one that existed. The labels SHALL carry the meaning on their own, so the output reads the same with color off (`NO_COLOR`, no TTY). Color SHALL come from the CLI's shared color setup, so it follows the real terminal. + +The wizard SHALL show each permission edit in its summary step, before anything is written, so the user sees it before confirming, as one statement per command and file, under one heading: `ALLOW (project):`, `REMOVE (project):`, or `SKIPPED (project):`, colored as above, followed by the invocation the rule allows (so a harness whose rules come in two spellings reads as one statement) and, on the next line, `in `; a skip names the file and then the reason. Labels SHALL be padded to the widest label in the group so every statement starts in the same column, and each statement SHALL wrap under itself, on spaces only, to keep lines within 76 columns, so a version token is never split. One confirmation covers every project settings edit: `Make these changes to your settings?`, or, when stubs are also being removed, the removal question extended with `, and make these changes to your settings?`. The global trust entry is asked about in a step of its own, under the `agent-global-trust` capability. `--no-interactive` SHALL print the labelled lines above after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. + +#### Scenario: A first install names the edit + +- **WHEN** `init --no-interactive` adds a rule to an existing `.claude/settings.json` +- **THEN** the output SHALL include `Edited .claude/settings.json` followed by `ALLOW: Bash( *)` +- **AND** with color on, the `ALLOW` line SHALL be green + +#### Scenario: An upgrade shows both sides + +- **WHEN** `init` replaces a recorded `0.13.0` rule with the `0.13.1` rule +- **THEN** the output SHALL list `ALLOW:` for the `0.13.1` rule and `REMOVE:` for the `0.13.0` rule under the file's name + +#### Scenario: An unrecorded Taskless rule is reported when removed + +- **WHEN** `init` removes `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23 *)`, which the manifest did not record +- **THEN** the output SHALL list `REMOVE:` for that rule under the file's name + +#### Scenario: The wizard previews before writing + +- **WHEN** the wizard reaches its summary step for an install that will edit `.cursor/cli.json` +- **THEN** the summary SHALL show one `ALLOW (project):` statement for the invocation, with `in .cursor/cli.json` beneath it, before the user confirms +- **AND** nothing SHALL be written if the user cancels + +#### Scenario: Color off still reads + +- **WHEN** `NO_COLOR` is set +- **THEN** the lines SHALL carry the same `ALLOW:`, `REMOVE:`, and `SKIPPED:` labels with no escape sequences + +#### Scenario: JSON carries the edits as data + +- **WHEN** `init --json` edits a permission file +- **THEN** the envelope SHALL report that file's path, whether it was created, and the exact rules allowed and removed + +#### Scenario: Each label has its color + +- **WHEN** color is on and `init` reports an added rule, a removed rule, a replaced global entry, and a skipped file +- **THEN** the `ALLOW` line SHALL be green, the `REMOVE` line uncolored, the `REPLACE` line yellow, and the `SKIPPED` line red diff --git a/openspec/changes/archive/2026-11-05-global-trust-opt-in/tasks.md b/openspec/changes/archive/2026-11-05-global-trust-opt-in/tasks.md new file mode 100644 index 00000000..af6631e0 --- /dev/null +++ b/openspec/changes/archive/2026-11-05-global-trust-opt-in/tasks.md @@ -0,0 +1,11 @@ +## 1. Opt-in question + +- [x] 1.1 Add the explanation to the global trust module, from the same package and launcher names the entry uses +- [x] 1.2 Add the wizard step, defaulting to no +- [x] 1.3 Take the global entry out of the summary's question +- [x] 1.4 Continue the install on a decline, with one line in place of the red block + +## 2. Ship + +- [x] 2.1 Add the changeset +- [x] 2.2 Confirm every prior scenario survives the archive diff --git a/openspec/specs/agent-global-trust/spec.md b/openspec/specs/agent-global-trust/spec.md index c323e43a..ff427c23 100644 --- a/openspec/specs/agent-global-trust/spec.md +++ b/openspec/specs/agent-global-trust/spec.md @@ -110,7 +110,18 @@ A stale entry SHALL be replaced only where the wizard writes it, and the replace ### Requirement: The global entry is offered only in the interactive wizard -When the entry is absent or stale, the wizard SHALL show the proposed edit in its summary step, in the same group as the project permission edits and after them, as one line: `ALLOW (global):` for an absent entry, `REPLACE (global):` for a stale one. The line SHALL name this build's package, say a call is covered only at an exact version, and name the file and key. Project and global edits SHALL be covered by the one confirmation the summary step asks, defaulting to yes; there SHALL be no separate question for the global entry. Declining that confirmation SHALL cancel the install and write nothing, inside or outside the project. +When the entry is absent or stale, the wizard SHALL ask about it in its own step, after the summary step's confirmation and before anything is written, inside or outside the project. The step SHALL show one line: `ALLOW (global):` for an absent entry, `REPLACE (global):` for a stale one. The line SHALL name this build's package, say a call is covered only at an exact version, and name the file and key. + +Before asking, the step SHALL explain, in the same text whichever build runs it apart from the package and launcher names: + +- that the launcher and the version it runs are in the project, where they can be read; +- why a project rule does not reach the harness's classifier, and that the classifier cannot see the pin; +- that this is the only file the installer changes outside the project, and that the entry applies to every project on the machine, including a launcher at the same path in another repository; +- the entry's exact text; +- that dist-tags, version ranges, and unversioned calls are not covered, and that removing the entry undoes it; +- that Taskless installs either way, and that without the entry the harness may ask for approval or refuse Taskless commands. + +The question SHALL default to no. Declining it SHALL leave the file byte-identical and the install SHALL continue, printing one line that names the cost and how to be offered the entry again. Cancelling the prompt SHALL cancel the install and write nothing, inside or outside the project. Declining the summary's confirmation SHALL cancel the install, write nothing, and not ask this question. `--no-interactive` and `--json` SHALL NEVER write a file outside the project. @@ -121,13 +132,13 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline #### Scenario: The wizard offers the entry - **WHEN** the wizard installs for `.claude/` and `~/.claude/settings.json` has no entry under this build's label -- **THEN** the summary SHALL show an `ALLOW (global):` line naming `~/.claude/settings.json` and `autoMode.allow` -- **AND** the one confirmation SHALL be `Make these changes to your settings?` when nothing else is being removed +- **THEN** the wizard SHALL show an `ALLOW (global):` line naming `~/.claude/settings.json` and `autoMode.allow`, with the explanation +- **AND** SHALL ask its own question, defaulting to no, after the summary's confirmation #### Scenario: The wizard offers to replace a stale entry - **WHEN** the wizard installs for `.claude/` and `autoMode.allow` is `["$defaults", "Taskless CLI: older text", "Internal Registry: ..."]` -- **THEN** the summary SHALL show a `REPLACE (global):` line naming `~/.claude/settings.json` and `autoMode.allow` +- **THEN** the wizard SHALL show a `REPLACE (global):` line naming `~/.claude/settings.json` and `autoMode.allow` - **AND** on confirmation the list SHALL be `["$defaults", , "Internal Registry: ..."]` #### Scenario: Declining writes nothing anywhere @@ -135,16 +146,42 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline - **WHEN** the user declines the summary's confirmation - **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards - **AND** no project permission file SHALL be written +- **AND** the wizard SHALL NOT ask about the global entry + +#### Scenario: Declining the entry still installs + +- **WHEN** the user confirms the summary and declines the global entry +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** the install SHALL complete, with the project permission rule written +- **AND** the wizard SHALL print one line, and no `SKIPPED (global):` block + +#### Scenario: Cancelling the question writes nothing anywhere + +- **WHEN** the user cancels the prompt that asks about the global entry +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** no project file SHALL be written + +#### Scenario: The question defaults to no + +- **WHEN** the wizard asks about the global entry +- **THEN** pressing Enter SHALL decline it + +#### Scenario: The step shows the exact entry + +- **WHEN** the wizard asks about the global entry +- **THEN** the step SHALL show the entry's text, word for word, before the question +- **AND** SHALL say the entry applies to every project on the machine #### Scenario: The global entry alone is still asked - **WHEN** the project's permission files already hold the installed rule and the entry is absent or stale -- **THEN** the summary SHALL show only the global line and ask the same confirmation +- **THEN** the summary SHALL show no settings group and ask nothing +- **AND** the wizard SHALL ask only the global entry's question #### Scenario: A declined offer is asked again - **WHEN** the user declined the entry on an earlier wizard run and it is still absent or stale -- **THEN** the next wizard run SHALL show and ask for it again +- **THEN** the next wizard run SHALL show the step and ask again #### Scenario: A new list keeps the defaults @@ -169,7 +206,7 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline ### Requirement: A missing global entry is warned about -When a harness in the table is installed for and its entry is absent or stale after `init` finishes, `init` SHALL print a `SKIPPED (global):` line in red, in both modes, as the reporting of permission edits colors a skip, followed by text naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. A skip is an edit the user has to make by hand, which is why it is red; `init` SHALL NOT make it for them. For a stale entry the text SHALL say the existing entry under this build's label is out of date and is to be replaced by the one shown. Under `--json` this output SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, absent, stale, was written, or was skipped with a reason. +When a harness in the table is installed for and its entry is absent or stale after `init` finishes, and the person did not just decline it in the wizard, `init` SHALL print a `SKIPPED (global):` line in red, in both modes, as the reporting of permission edits colors a skip, followed by text naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. A skip is an edit the user has to make by hand, which is why it is red; `init` SHALL NOT make it for them. For a stale entry the text SHALL say the existing entry under this build's label is out of date and is to be replaced by the one shown. Under `--json` this output SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, absent, stale, was written, or was skipped with a reason. #### Scenario: Non-interactive warns @@ -187,6 +224,11 @@ When a harness in the table is installed for and its entry is absent or stale af - **WHEN** the wizard completes an install for `.claude/` and `~/.claude/settings.json` does not parse - **THEN** the wizard SHALL print the same warning, with the reason the file was left alone +#### Scenario: A decline is not warned about + +- **WHEN** the user declines the entry in the wizard +- **THEN** the wizard SHALL NOT print a `SKIPPED (global):` block + #### Scenario: A present entry is silent - **WHEN** the entry is present @@ -209,7 +251,7 @@ When a harness in the table is installed for and its entry is absent or stale af ### Requirement: Cleanup of the global entry runs on every install and upgrade -The check for this build's global entry, including folding duplicates, SHALL run on every `init`, whether it is a first install or an upgrade, and SHALL NOT be a scaffold migration: migrations run on every write path, including `--no-interactive` and CI, and the entry lives outside the project. The wizard SHALL perform the whole cleanup in the one edit its summary step confirms, shown as a single `REPLACE (global):` line, never as one line per removed string. When the entry is stale because of duplicates, the `SKIPPED (global):` output that `init` prints otherwise SHALL say how many strings carry the label and that the wizard will fold them into one. +The check for this build's global entry, including folding duplicates, SHALL run on every `init`, whether it is a first install or an upgrade, and SHALL NOT be a scaffold migration: migrations run on every write path, including `--no-interactive` and CI, and the entry lives outside the project. The wizard SHALL perform the whole cleanup in the one edit its global entry question confirms, shown as a single `REPLACE (global):` line, never as one line per removed string. When the entry is stale because of duplicates, the `SKIPPED (global):` output that `init` prints otherwise SHALL say how many strings carry the label and that the wizard will fold them into one. #### Scenario: Non-interactive names the duplicates @@ -220,5 +262,5 @@ The check for this build's global entry, including folding duplicates, SHALL run #### Scenario: One confirmation covers the fold - **WHEN** the wizard finds three strings carrying this build's label -- **THEN** the summary SHALL show one `REPLACE (global):` line for them +- **THEN** the wizard SHALL show one `REPLACE (global):` line for them - **AND** declining SHALL leave the file byte-identical diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 78f392f0..1bc06710 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -807,11 +807,11 @@ Edited .claude/settings.json - `REMOVE: ` for a Taskless rule taken out because it is not one of the installed rules, whether or not the manifest recorded it, rendered in the terminal's default color, since removing Taskless's own outdated rules is expected. - `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in red. A skipped edit is one the user has to make by hand, and the Taskless commands it would have allowed can be refused until they do, so it is the only label rendered in red. -The same color for each label SHALL apply wherever a settings edit is shown, project or global, in the wizard's summary and in printed output. `REPLACE`, which only the global trust entry uses, SHALL be rendered in yellow: a change to an existing entry, worth a moment's attention. +The same color for each label SHALL apply wherever a settings edit is shown, project or global, in the wizard and in printed output. `REPLACE`, which only the global trust entry uses, SHALL be rendered in yellow: a change to an existing entry, worth a moment's attention. The first line SHALL say `Created` for a file Taskless created and `Edited` for one that existed. The labels SHALL carry the meaning on their own, so the output reads the same with color off (`NO_COLOR`, no TTY). Color SHALL come from the CLI's shared color setup, so it follows the real terminal. -The wizard SHALL show each permission edit in its summary step, before anything is written, so the user sees it before confirming, as one statement per command and file, grouped with any global trust edit under one heading: `ALLOW (project):`, `REMOVE (project):`, or `SKIPPED (project):`, colored as above, followed by the invocation the rule allows (so a harness whose rules come in two spellings reads as one statement) and, on the next line, `in `; a skip names the file and then the reason. Labels SHALL be padded to the widest label in the group so every statement starts in the same column, and each statement SHALL wrap under itself, on spaces only, to keep lines within 76 columns, so a version token is never split. One confirmation covers every settings edit: `Make these changes to your settings?`, or, when stubs are also being removed, the removal question extended with `, and make these changes to your settings?`. `--no-interactive` SHALL print the labelled lines above after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. +The wizard SHALL show each permission edit in its summary step, before anything is written, so the user sees it before confirming, as one statement per command and file, under one heading: `ALLOW (project):`, `REMOVE (project):`, or `SKIPPED (project):`, colored as above, followed by the invocation the rule allows (so a harness whose rules come in two spellings reads as one statement) and, on the next line, `in `; a skip names the file and then the reason. Labels SHALL be padded to the widest label in the group so every statement starts in the same column, and each statement SHALL wrap under itself, on spaces only, to keep lines within 76 columns, so a version token is never split. One confirmation covers every project settings edit: `Make these changes to your settings?`, or, when stubs are also being removed, the removal question extended with `, and make these changes to your settings?`. The global trust entry is asked about in a step of its own, under the `agent-global-trust` capability. `--no-interactive` SHALL print the labelled lines above after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. #### Scenario: A first install names the edit diff --git a/packages/cli/src/install/global-trust.ts b/packages/cli/src/install/global-trust.ts index 1b260cb0..0fcdeb5a 100644 --- a/packages/cli/src/install/global-trust.ts +++ b/packages/cli/src/install/global-trust.ts @@ -39,6 +39,8 @@ import { SETTINGS_TONE } from "./settings-tone"; export interface GlobalTrustText { /** The package the entry covers. */ packageName: string; + /** How the project's launcher for that package is run. */ + launcher: string; label: string; entry: string; } @@ -59,6 +61,7 @@ function trustText( ): GlobalTrustText { return { packageName, + launcher, label, entry: `${label} The npm package ${packageName} is tooling published by ` + @@ -136,6 +139,8 @@ interface GlobalTrust { key: readonly string[]; /** The list to write when there is none, holding the entry. */ seed: readonly string[]; + /** The wizard's sentence on why a project rule does not reach this harness. */ + why: string; } const claudeCode: GlobalTrust = { @@ -156,6 +161,12 @@ const claudeCode: GlobalTrust = { // `$defaults` splices in the shipped exceptions. A new list without it // would replace them, and the user would lose every one. seed: ["$defaults", GLOBAL_TRUST_ENTRY], + why: + "Claude Code's auto mode judges each command as it sees it. It can't see " + + "that pin, or a delay you set on new releases such as min-release-age, " + + "and a project's own settings can't overrule it, so a cloned repository " + + "can't approve itself. Only your own settings can tell it " + + "that you trust this command.", }; const GLOBAL_TRUST: readonly GlobalTrust[] = [claudeCode]; @@ -310,22 +321,68 @@ function display(file: string, home: string): string { return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file; } +/** The edit the wizard offers for one missing or stale entry. */ +export interface GlobalTrustOffer { + replace: boolean; + what: string; + where: string; + /** The file, shortened, for the question and the decline. */ + file: string; + /** The paragraphs that come before the entry's text. */ + before: string[]; + /** The paragraphs that come after it. */ + after: string[]; +} + /** - * The proposed edit for each missing or stale entry, for the wizard's - * summary, beside the project's own lines: what it allows, and where it goes. - * `replace` marks a stale entry, which is swapped rather than added. + * What the wizard says about each missing or stale entry: the edit, and + * enough for a person to decide on it without reading anything else. It says + * how far the entry reaches as well as what the pin protects, because an entry + * in user settings follows the launcher's path, not this repository. */ export function describeGlobalTrust( outcomes: readonly GlobalTrustOutcome[], home: string = homedir() -): { replace: boolean; what: string; where: string }[] { +): GlobalTrustOffer[] { return outcomes .filter((outcome) => needsWrite(outcome)) - .map((outcome) => ({ - replace: outcome.state === "stale", - what: `${BUILD_TRUST.packageName} at an exact version`, - where: `in ${display(outcome.file, home)} (${outcome.key})`, - })); + .map((outcome) => { + const { packageName, launcher } = BUILD_TRUST; + const file = display(outcome.file, home); + const { why } = + GLOBAL_TRUST.find((trust) => trust.harness === outcome.harness) ?? + claudeCode; + const change = + outcome.state === "stale" + ? outcome.labelled > 1 + ? `This folds the ${String(outcome.labelled)} Taskless entries already in \`${outcome.key}\` into this one` + : `This replaces the older Taskless entry already in \`${outcome.key}\` with this one` + : `This adds one entry to \`${outcome.key}\``; + return { + replace: outcome.state === "stale", + what: `${packageName} at an exact version`, + where: `in ${file} (${outcome.key})`, + file, + before: [ + `Taskless runs through ${launcher}, a script in this repository that ` + + `starts the exact ${packageName} version recorded in ` + + ".taskless/taskless.json. You can read both, and review any change " + + "to them like other code.", + why, + `${change}. That file is the only one Taskless changes outside this ` + + "project. The entry applies to every project on this machine, " + + "including a launcher at that path in another repository, so " + + "review that script when you clone one.", + ], + after: [ + "Dist-tags such as @latest, version ranges and unversioned calls are " + + "not covered. Remove the entry at any time to undo this.", + `Taskless installs either way. Without the entry, ${outcome.harness} ` + + "may stop to ask you to approve Taskless commands, or refuse them " + + "in auto mode.", + ], + }; + }); } /** diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index db88e28a..fa25ac3e 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -41,6 +41,7 @@ import { getCliPrefix } from "../util/package-manager"; import { WizardCancelled } from "./ask"; import { getCliVersion, renderIntro } from "./intro"; import { promptCommit } from "./steps/commit"; +import { promptGlobalTrust } from "./steps/global-trust"; import { promptLocations } from "./steps/locations"; import { renderSummaryAndConfirm } from "./steps/summary"; @@ -117,16 +118,23 @@ export async function runWizard( write: false, }); - const proceed = await renderSummaryAndConfirm(diff, permissionPreview, { - outcomes: globalTrustPreview, - home: globalTrustEnvironment.home, - }); + const proceed = await renderSummaryAndConfirm(diff, permissionPreview); if (!proceed) { cancel(`Install cancelled. Run \`${getCliPrefix()} init\` to try again.`); cancelledStep = "summary"; return finish({ status: "cancelled" }); } + // Asked before anything is written, so cancelling here leaves the project + // as it was. Only a cancel stops the install: saying no is a real answer. + const offered = globalTrustPreview.some((outcome) => needsWrite(outcome)); + const trusted = + offered && + (await promptGlobalTrust( + globalTrustPreview, + globalTrustEnvironment.home + )); + // The migration's start opens a step; its summary, and the launcher // lines below, continue under it rather than each opening one of their // own, so one install reads as one block. @@ -160,23 +168,31 @@ export async function runWizard( created.push(...retired.map((path) => describeRetired(path))); if (created.length > 0) log.message(created.join("\n")); await syncPermissions(options.cwd, { ...permissionOptions, write: true }); - // Confirmed with the project edits, in the summary's one question. - if (globalTrustPreview.some((outcome) => needsWrite(outcome))) { + if (trusted) { await syncGlobalTrust({ ...globalTrustOptions, write: true }); } // Read again rather than assumed, so a skipped file, and a write that did - // not land, still warn. + // not land, still warn. An entry the person just said no to does not: the + // warning is for an edit they still owe, and they have answered. const globalTrust = await syncGlobalTrust({ ...globalTrustOptions, write: false, }); + const declined = offered && !trusted; for (const warning of globalTrustWarnings( - globalTrust, + declined + ? globalTrust.filter((outcome) => !needsWrite(outcome)) + : globalTrust, getCliPrefix(), globalTrustEnvironment.home )) { log.warn(warning); } + if (declined) { + log.info( + `Your agent settings were left as they are, so your agent may ask you to approve Taskless commands. Run \`${getCliPrefix()}\` with no arguments in a terminal to be offered this again.` + ); + } if (await isTelemetryEnabled(options.cwd)) { log.info(`${TELEMETRY_NOTICE_TITLE}\n${TELEMETRY_NOTICE}`); diff --git a/packages/cli/src/wizard/steps/global-trust.ts b/packages/cli/src/wizard/steps/global-trust.ts new file mode 100644 index 00000000..92a17bcc --- /dev/null +++ b/packages/cli/src/wizard/steps/global-trust.ts @@ -0,0 +1,57 @@ +import { confirm, log } from "@clack/prompts"; + +import { + describeGlobalTrust, + GLOBAL_TRUST_ENTRY, + type GlobalTrustOutcome, +} from "../../install/global-trust"; +import { wrap } from "../../install/notice-box"; +import { ask } from "../ask"; +import { layoutSettings, SUMMARY_COLUMNS } from "./summary"; + +const ENTRY_INDENT = " "; + +function paragraph(text: string): string { + return wrap(text, SUMMARY_COLUMNS).join("\n"); +} + +/** + * Ask whether to write the global trust entry, after explaining it. This is + * the one edit outside the project, so it never shares a question with the + * install, and it defaults to no: Enter is the one answer that takes no + * reading, and it should not change a person's own settings. + */ +export async function promptGlobalTrust( + outcomes: readonly GlobalTrustOutcome[], + home: string +): Promise { + const offers = describeGlobalTrust(outcomes, home); + log.message( + offers + .flatMap((offer) => [ + layoutSettings([ + { + label: offer.replace ? "REPLACE (global):" : "ALLOW (global):", + tone: offer.replace ? "replace" : "allow", + what: offer.what, + where: offer.where, + }, + ]).join("\n"), + ...offer.before.map((text) => paragraph(text)), + wrap(GLOBAL_TRUST_ENTRY, SUMMARY_COLUMNS - ENTRY_INDENT.length) + .map((line) => `${ENTRY_INDENT}${line}`) + .join("\n"), + ...offer.after.map((text) => paragraph(text)), + ]) + .join("\n\n") + ); + const files = offers.map((offer) => offer.file).join(", "); + return ask("global-trust", () => + confirm({ + message: offers.some((offer) => offer.replace) + ? `Update the Taskless entry in ${files}?` + : `Add the Taskless entry to ${files}?`, + initialValue: false, + }) + ); +} diff --git a/packages/cli/src/wizard/steps/summary.ts b/packages/cli/src/wizard/steps/summary.ts index b546e008..b2e4e0f8 100644 --- a/packages/cli/src/wizard/steps/summary.ts +++ b/packages/cli/src/wizard/steps/summary.ts @@ -6,11 +6,6 @@ import chalk from "chalk"; // whichever caller happened to load `wizard/intro.ts` first. import "../../util/color"; -import { - describeGlobalTrust, - needsWrite, - type GlobalTrustOutcome, -} from "../../install/global-trust"; import { commandOfRule, type PermissionEdit } from "../../install/permissions"; import { wrap } from "../../install/notice-box"; import { SETTINGS_TONE, type SettingsTone } from "../../install/settings-tone"; @@ -19,8 +14,7 @@ import { ask } from "../ask"; export async function renderSummaryAndConfirm( diff: InstallDiff, - permissions: readonly PermissionEdit[] = [], - globalTrust?: { outcomes: readonly GlobalTrustOutcome[]; home: string } + permissions: readonly PermissionEdit[] = [] ): Promise { const lines: string[] = []; @@ -56,21 +50,9 @@ export async function renderSummaryAndConfirm( lines.push(""); } - // Every settings edit, project and global, in one group under one - // question: each is a line a person can read, with where it goes. - const settingsLines = layoutSettings([ - ...describeProjectEdits(permissions), - ...(globalTrust === undefined - ? [] - : describeGlobalTrust(globalTrust.outcomes, globalTrust.home).map( - ({ replace, what, where }): SettingsLine => ({ - label: replace ? "REPLACE (global):" : "ALLOW (global):", - tone: replace ? "replace" : "allow", - what, - where, - }) - )), - ]); + // Every project settings edit under one question. The global entry reaches + // past this project, so it is asked about on its own, in a later step. + const settingsLines = layoutSettings(describeProjectEdits(permissions)); if (settingsLines.length > 0) { lines.push(chalk.bold("Agent settings"), ...settingsLines, ""); } @@ -79,9 +61,7 @@ export async function renderSummaryAndConfirm( // A settings file is the user's, so an edit to one is confirmed even when // nothing is being removed. - const editsSettings = - permissions.some((edit) => edit.skipped === null) || - (globalTrust?.outcomes.some((outcome) => needsWrite(outcome)) ?? false); + const editsSettings = permissions.some((edit) => edit.skipped === null); if (!diff.hasRemovals && !editsSettings) { return true; } @@ -98,7 +78,7 @@ export async function renderSummaryAndConfirm( const SETTINGS_QUESTION = "Make these changes to your settings?"; /** One settings edit: what it allows or removes, and where. */ -interface SettingsLine { +export interface SettingsLine { label: string; tone: SettingsTone; what: string; @@ -109,14 +89,14 @@ interface SettingsLine { * Lines inside clack's gutter stay within this many columns, so the summary * fits an 80-column terminal. */ -const SUMMARY_COLUMNS = 76; +export const SUMMARY_COLUMNS = 76; /** * Labels padded to the widest one, so every statement starts in one column, * and each statement wrapped under itself, never mid-token: a nightly version * is one long token and must stay copyable. */ -function layoutSettings(entries: readonly SettingsLine[]): string[] { +export function layoutSettings(entries: readonly SettingsLine[]): string[] { const width = Math.max(0, ...entries.map((entry) => entry.label.length)) + 1; const indent = " ".repeat(width); const lines: string[] = []; diff --git a/packages/cli/test/global-trust.test.ts b/packages/cli/test/global-trust.test.ts index b6c7f27b..42f9781e 100644 --- a/packages/cli/test/global-trust.test.ts +++ b/packages/cli/test/global-trust.test.ts @@ -340,14 +340,31 @@ describe("rendering", () => { it("describes a missing entry as a global edit", async () => { const outcomes = await sync(false); expect(describeGlobalTrust(outcomes, home)).toEqual([ - { + expect.objectContaining({ replace: false, what: "@taskless/cli at an exact version", where: "in ~/.claude/settings.json (autoMode.allow)", - }, + file: "~/.claude/settings.json", + }), ]); }); + it("says how a stale entry changes before asking to change it", async () => { + await put(JSON.stringify({ autoMode: { allow: ["Taskless CLI: old"] } })); + const [replaced] = describeGlobalTrust(await sync(false), home); + expect(replaced?.before.join(" ")).toContain( + "replaces the older Taskless entry" + ); + + await put( + JSON.stringify({ + autoMode: { allow: [GLOBAL_TRUST_ENTRY, "Taskless CLI: pasted"] }, + }) + ); + const [folded] = describeGlobalTrust(await sync(false), home); + expect(folded?.before.join(" ")).toContain("folds the 2 Taskless entries"); + }); + it("warns with the file, key, and entry", async () => { const outcomes = await sync(false); const [warning] = globalTrustWarnings( diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 6c9bf27d..2e72e182 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -38,6 +38,8 @@ const clackResponses: { /** Checked row values: harness names, or `agents` for the generic row. */ locations?: string[] | symbol; summary?: boolean | symbol; + /** The answer to the global trust entry's own question. */ + globalTrust?: boolean | symbol; commit?: string | symbol; commitMessage?: string | symbol; } = {}; @@ -51,6 +53,11 @@ const confirmMessages: string[] = []; // Captures the message of the summary confirm prompt for assertions. let summaryConfirmMessage: string | undefined; +// The default each confirm offered, in order. +const confirmDefaults: unknown[] = []; + +const GLOBAL_QUESTION = "Add the Taskless entry to ~/.claude/settings.json?"; + // Everything the wizard showed through `log.message`, in order. const loggedMessages: string[] = []; // Everything it warned through `log.warn`. @@ -87,11 +94,23 @@ vi.mock("@clack/prompts", () => ({ return Promise.resolve(clackResponses.commit); }), text: vi.fn(() => Promise.resolve(clackResponses.commitMessage)), - confirm: vi.fn(({ message }: { message: string }) => { - confirmMessages.push(message); - summaryConfirmMessage = message; - return Promise.resolve(clackResponses.summary); - }), + confirm: vi.fn( + ({ + message, + initialValue, + }: { + message: string; + initialValue?: boolean; + }) => { + confirmMessages.push(message); + confirmDefaults.push(initialValue); + if (/Taskless entry (to|in) /.test(message)) { + return Promise.resolve(clackResponses.globalTrust); + } + summaryConfirmMessage = message; + return Promise.resolve(clackResponses.summary); + } + ), })); async function exists(path: string): Promise { @@ -128,6 +147,8 @@ beforeEach(async () => { clackResponses.commitMessage = undefined; selectMessages.length = 0; confirmMessages.length = 0; + confirmDefaults.length = 0; + clackResponses.globalTrust = true; summaryConfirmMessage = undefined; loggedMessages.length = 0; warnedMessages.length = 0; @@ -329,24 +350,26 @@ describe("runWizard end-to-end", () => { }); describe("global trust", () => { - it("lists project and global edits together under one question", async () => { + it("asks about the global entry on its own, after the project's edits", async () => { clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); await runWizard({ cwd, globalTrustEnvironment }); - const summary = loggedMessages.join("\n"); - // Labels padded to the widest, statements in one column, the file on - // its own line beneath, and nothing past 76 columns. + const [summary, offer] = loggedMessages; expect(summary).toMatch( /ALLOW \(project\): npx @taskless\/cli@\S+\n {17}in \.claude\/settings\.json/ ); - expect(summary).toContain( - "ALLOW (global): @taskless/cli at an exact version\n" + - " in ~/.claude/settings.json (autoMode.allow)" + expect(summary).not.toContain("(global)"); + expect(offer).toContain( + "ALLOW (global): @taskless/cli at an exact version\n" + + " in ~/.claude/settings.json (autoMode.allow)" ); - expect(confirmMessages).toEqual(["Make these changes to your settings?"]); + expect(confirmMessages).toEqual([ + "Make these changes to your settings?", + GLOBAL_QUESTION, + ]); const settings = JSON.parse(await readFile(userSettings(), "utf8")) as { autoMode: { allow: string[] }; }; @@ -355,7 +378,29 @@ describe("runWizard end-to-end", () => { expect(warnedMessages).toEqual([]); }); - it("declining writes neither the project nor the global settings", async () => { + it("explains the entry and defaults to no", async () => { + clackResponses.locations = ["Claude Code"]; + clackResponses.summary = true; + + const { GLOBAL_TRUST_ENTRY } = + await import("../src/install/global-trust"); + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd, globalTrustEnvironment }); + + const offer = loggedMessages[1] ?? ""; + const unwrapped = offer.replaceAll(/\s+/g, " "); + expect(confirmDefaults.at(-1)).toBe(false); + expect(unwrapped).toContain(GLOBAL_TRUST_ENTRY); + expect(unwrapped).toContain("./.taskless/bin/cli.mjs"); + expect(unwrapped).toContain("min-release-age"); + expect(unwrapped).toContain("every project on this machine"); + expect(unwrapped).toContain("Taskless installs either way"); + for (const line of offer.split("\n")) { + expect(line.length).toBeLessThanOrEqual(76); + } + }); + + it("declining the summary neither writes nor asks about the entry", async () => { clackResponses.locations = ["Claude Code"]; clackResponses.summary = false; @@ -363,21 +408,57 @@ describe("runWizard end-to-end", () => { const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("cancelled"); + expect(confirmMessages).not.toContain(GLOBAL_QUESTION); expect(await exists(userSettings())).toBe(false); expect(await exists(join(cwd, ".claude", "settings.json"))).toBe(false); }); + it("declining the entry still installs, with one calm line", async () => { + clackResponses.locations = ["Claude Code"]; + clackResponses.summary = true; + clackResponses.globalTrust = false; + + const { runWizard } = await import("../src/wizard"); + const result = await runWizard({ cwd, globalTrustEnvironment }); + + expect(result.status).toBe("completed"); + expect(await exists(userSettings())).toBe(false); + expect(await exists(join(cwd, ".claude", "settings.json"))).toBe(true); + expect(warnedMessages).toEqual([]); + expect( + infoMessages.filter((message) => message.includes("ask you to approve")) + ).toHaveLength(1); + }); + + it("cancelling the entry's question writes nothing", async () => { + clackResponses.locations = ["Claude Code"]; + clackResponses.summary = true; + clackResponses.globalTrust = fakeCancelSymbol; + + const { runWizard } = await import("../src/wizard"); + const result = await runWizard({ cwd, globalTrustEnvironment }); + + expect(result).toMatchObject({ + status: "cancelled", + cancelledStep: "global-trust", + }); + expect(await exists(userSettings())).toBe(false); + expect(await exists(join(cwd, ".claude", "settings.json"))).toBe(false); + expect(await exists(join(cwd, ".taskless", "taskless.json"))).toBe(false); + }); + it("asks again on the next run after a decline", async () => { // Nothing records the answer: an absent entry is offered every time. clackResponses.locations = ["Claude Code"]; - clackResponses.summary = false; + clackResponses.summary = true; + clackResponses.globalTrust = false; const { runWizard } = await import("../src/wizard"); await runWizard({ cwd, globalTrustEnvironment }); await runWizard({ cwd, globalTrustEnvironment }); expect( - loggedMessages.filter((message) => message.includes("ALLOW (global)")) + confirmMessages.filter((message) => message === GLOBAL_QUESTION) ).toHaveLength(2); }); @@ -396,7 +477,7 @@ describe("runWizard end-to-end", () => { const summary = loggedMessages.join("\n"); expect(summary).not.toContain("ALLOW (project)"); expect(summary).toContain("ALLOW (global)"); - expect(confirmMessages).toEqual(["Make these changes to your settings?"]); + expect(confirmMessages).toEqual([GLOBAL_QUESTION]); }); it("does not offer an entry that is already there", async () => { @@ -443,7 +524,10 @@ describe("runWizard end-to-end", () => { "REPLACE (global): @taskless/cli at an exact version\n" + " in ~/.claude/settings.json (autoMode.allow)" ); - expect(confirmMessages).toEqual(["Make these changes to your settings?"]); + expect(confirmMessages).toEqual([ + "Make these changes to your settings?", + "Update the Taskless entry in ~/.claude/settings.json?", + ]); const settings = JSON.parse(await readFile(userSettings(), "utf8")) as { autoMode: { allow: string[] }; }; @@ -462,11 +546,13 @@ describe("runWizard end-to-end", () => { }); await writeFile(userSettings(), original); clackResponses.locations = ["Claude Code"]; - clackResponses.summary = false; + clackResponses.summary = true; + clackResponses.globalTrust = false; const { runWizard } = await import("../src/wizard"); - await runWizard({ cwd, globalTrustEnvironment }); + const result = await runWizard({ cwd, globalTrustEnvironment }); + expect(result.status).toBe("completed"); expect(await readFile(userSettings(), "utf8")).toBe(original); }); });