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
5 changes: 5 additions & 0 deletions .changeset/global-trust-opt-in.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions openspec/changes/archive/2026-11-05-global-trust-opt-in/design.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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", <entry>]`, 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", <entry>, "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", <entry>]`
- **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: ...", <entry>]`

#### 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
Original file line number Diff line number Diff line change
@@ -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: <rule>` for a rule added, rendered in green, since an addition is what the reader needs to notice.
- `REMOVE: <rule>` 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: <file>` 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 <file>`; 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(<invocation> *)`
- **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
Loading
Loading