From e3991d429315a36fdb29d32a39a9543c6fee4ff6 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 18 May 2026 21:01:27 -0700 Subject: [PATCH 1/3] feat(cli): Remove Taskless from unchecked tool locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the `taskless init` wizard's tool-selection step manifest-aware so unchecking a location removes Taskless's stubs from it. Previously the multiselect pre-checked detected tools only, so a location Taskless had installed into — notably `.agents/`, which has no detection signal of its own — could not be unchecked, leaving the removal path the install engine already supports unreachable from the UI. - Pre-check the union of manifest-recorded and detected directories, so an installed location shows checked and can be unchecked. - Hint each entry by origin: `installed`, `detected`, or `not detected`. - Itemize the removal confirmation per target (e.g. "Remove Taskless from .claude/ (2 stubs)?") instead of a generic prompt. The removal engine itself is unchanged — this only surfaces it. The non-interactive paths and the canonical `.taskless/` store are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/uninstall-unchecked-locations.md | 11 +++ .../.openspec.yaml | 2 + .../uninstall-unchecked-locations/design.md | 70 +++++++++++++++ .../uninstall-unchecked-locations/proposal.md | 31 +++++++ .../specs/cli-init/spec.md | 73 ++++++++++++++++ .../uninstall-unchecked-locations/tasks.md | 22 +++++ packages/cli/src/wizard/steps/locations.ts | 55 +++++++++--- packages/cli/src/wizard/steps/summary.ts | 21 ++++- packages/cli/test/wizard-integration.test.ts | 63 ++++++++++++++ packages/cli/test/wizard-steps.test.ts | 87 +++++++++++++++++++ 10 files changed, 420 insertions(+), 15 deletions(-) create mode 100644 .changeset/uninstall-unchecked-locations.md create mode 100644 openspec/changes/uninstall-unchecked-locations/.openspec.yaml create mode 100644 openspec/changes/uninstall-unchecked-locations/design.md create mode 100644 openspec/changes/uninstall-unchecked-locations/proposal.md create mode 100644 openspec/changes/uninstall-unchecked-locations/specs/cli-init/spec.md create mode 100644 openspec/changes/uninstall-unchecked-locations/tasks.md diff --git a/.changeset/uninstall-unchecked-locations.md b/.changeset/uninstall-unchecked-locations.md new file mode 100644 index 0000000..3438d70 --- /dev/null +++ b/.changeset/uninstall-unchecked-locations.md @@ -0,0 +1,11 @@ +--- +"@taskless/cli": patch +--- + +Make the wizard's tool-selection step manifest-aware so unchecking a location removes Taskless from it. + +- **Manifest-driven pre-check**: the `taskless init` tool-selection multiselect now pre-checks the union of directories recorded in the install manifest and detected tool directories — previously it pre-checked detected tools only. A location Taskless already installed into (notably `.agents/`, which has no detection signal of its own) now shows checked, so it can be unchecked to remove the stubs. The install engine already performed manifest-diffed, target-scoped removal; this only surfaces it in the UI. +- **Three-state hint**: each entry is hinted by origin — `installed` (recorded in the manifest, takes precedence), `detected` (tool present), or `not detected`. +- **Itemized removal confirmation**: when unchecking a location triggers removals, the confirm prompt now names each target and its stub count (e.g. `Remove Taskless from .claude/ (2 stubs)?`) instead of a generic message. + +The non-interactive `init --no-interactive` / `update` paths are unchanged, and the canonical `.taskless/` store is never removed. diff --git a/openspec/changes/uninstall-unchecked-locations/.openspec.yaml b/openspec/changes/uninstall-unchecked-locations/.openspec.yaml new file mode 100644 index 0000000..231e3ab --- /dev/null +++ b/openspec/changes/uninstall-unchecked-locations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-18 diff --git a/openspec/changes/uninstall-unchecked-locations/design.md b/openspec/changes/uninstall-unchecked-locations/design.md new file mode 100644 index 0000000..0a57f2f --- /dev/null +++ b/openspec/changes/uninstall-unchecked-locations/design.md @@ -0,0 +1,70 @@ +## Context + +Taskless installs the consolidated `taskless` skill once into a canonical `.taskless/` store, then writes thin reference stubs into each selected tool directory (`.claude/`, `.cursor/`, `.opencode/`, `.agents/`). The install manifest at `.taskless/taskless.json` records, per target, what was written. + +`applyInstallPlan` already performs manifest-driven, target-scoped removal: any target present in the previous manifest but absent from the current plan produces an all-removals diff entry, and the apply loop `rm`s those stub files. `renderSummaryAndConfirm` already gates any diff with removals behind a `confirm()`. + +The gap is purely in the wizard's selection step. `locationChoices(detected)` derives the multiselect's pre-checked set (`initialValues`) from `detectTools()` — filesystem detection of whether a tool is present. Detection answers "where might you want Taskless?"; it does not answer "where is Taskless installed?". That second answer lives only in the manifest. Because the manifest never reaches the multiselect, a location Taskless installed into but that is not independently detected (notably `.agents/`, which has no detection signal of its own) renders unchecked — so it cannot be meaningfully unchecked, and the removal path the engine already supports is unreachable from the UI. + +## Goals / Non-Goals + +**Goals:** + +- Make the wizard's pre-checked tool set reflect where Taskless is actually installed, so unchecking a location is a real, discoverable action. +- Give each multiselect entry an accurate origin hint: `installed`, `detected`, or `not detected`. +- Itemize the removal confirmation per target so the user sees exactly which locations lose Taskless. +- Keep `locationChoices` a pure, unit-testable function. + +**Non-Goals:** + +- Full uninstall — removing the canonical `.taskless/` store or the manifest. The at-least-one-tool selection rule stays. +- Pruning empty `skills/` or `commands/tskl/` directory shells left after stub removal. +- Any change to the removal engine (`applyInstallPlan`, `computeInstallDiff`) — it already does the work. +- Any change to the non-interactive `init --no-interactive` / `update` paths — they stay detection-driven and additive. + +## Decisions + +### Pre-checked set = manifest targets ∪ detected tool directories + +`locationChoices` gains a second parameter: the directories recorded in the install manifest. `initialValues` becomes the union of (a) manifest target directories that match a `SHIM_TARGETS` entry and (b) detected tool install directories. The canonical `.taskless/` store is filtered out — it is never a `SHIM_TARGETS` entry, so it is naturally excluded. + +The existing "`.agents/` when nothing is detected" fallback still applies, but only when **both** inputs are empty (no manifest, no detection) — i.e. a genuine first run. + +_Alternative considered:_ pre-check from the manifest alone. Rejected — a first run has no manifest, and a user who adds a new tool after install should still see it offered as `detected`. The union covers both the install case and the discovery case. + +### Three-state hint derived from origin + +The per-entry hint is computed from set membership: + +``` + in manifest? + ┌──────┬──────┐ + │ yes │ no │ + ┌─────────────┼──────┼──────┤ +detected│ yes │ inst │ det │ + ? │ no │ inst │ none │ + └─────────────┴──────┴──────┘ + inst = "installed" det = "detected" + none = "not detected" (".agents/" first-run default keeps its + "generic agent skills" hint) +``` + +"installed" takes precedence over "detected" — if Taskless is there, that is the more relevant fact for an uninstall decision. + +### `locationChoices` stays pure + +The manifest is read by `promptLocations` (which already does I/O) via the existing `readInstallState`, and the resulting target directory list is passed _into_ `locationChoices` as an argument. `locationChoices` performs no filesystem access. This preserves the property the spec already calls out — the detection-and-manifest-to-choices mapping is fully unit-testable without a TTY or a filesystem fixture. + +### Itemized per-target removal confirmation + +`renderSummaryAndConfirm` already iterates `diff.entries`. When `diff.hasRemovals`, instead of the generic single-line prompt, it builds the confirmation message from the entries that carry removals — listing each target directory and its removed stub count: e.g. `Remove Taskless from .claude/ (2 stubs), .cursor/ (1 stub)?`. The diff summary block above the prompt is unchanged. No-removal installs still skip the extra confirm. + +## Risks / Trade-offs + +- **A detected-but-never-installed tool now appears pre-checked as `detected`.** This matches today's behavior (detection already pre-checks), so no regression — the union only ever adds the manifest set on top. +- **Manifest lists a directory no longer in `SHIM_TARGETS`** (e.g. a removed/renamed tool) → it is filtered out of `initialValues` and not offered. The stub, if any, is then neither shown nor removed. Acceptable: out-of-catalog directories are outside this change's scope, and the existing convergence logic is unaffected. +- **User unchecks every previously-installed tool but the at-least-one rule forces one selection** → no full uninstall. This is intended (full uninstall is a Non-Goal); the user keeps at least one stub location plus the canonical store. + +## Migration Plan + +No data migration. The manifest schema is unchanged — this change only _reads_ `install.targets`. Behavior change is confined to interactive wizard runs; the non-interactive path is byte-for-byte unchanged. Rollback is a straight revert of the wizard files. diff --git a/openspec/changes/uninstall-unchecked-locations/proposal.md b/openspec/changes/uninstall-unchecked-locations/proposal.md new file mode 100644 index 0000000..05f4e81 --- /dev/null +++ b/openspec/changes/uninstall-unchecked-locations/proposal.md @@ -0,0 +1,31 @@ +## Why + +The install engine already removes Taskless stubs from any tool directory dropped from the install plan, but the wizard's tool-selection step pre-checks entries from **tool detection** alone — not from where Taskless is actually installed. A user who wants to consolidate onto `.agents/` and drop the `.claude/` and `.cursor/` shims has no way to "uncheck" those locations, because the manifest's record of them never reaches the multiselect's pre-checked state. + +## What Changes + +- The wizard's tool-selection step SHALL pre-check the **union of** directories recorded in the install manifest (`install.targets`, excluding the canonical `.taskless/` store) **and** detected tool directories — so a location Taskless already installed into shows checked and can be unchecked. +- Each multiselect entry SHALL carry a three-state hint reflecting its origin: `installed` (in the manifest), `detected` (tool present, not yet installed), or `not detected`. +- Unchecking a manifest-recorded location SHALL remove Taskless's reference stubs from that directory on the next install, via the existing manifest-diff removal path — no new removal engine. +- The removal confirmation in the wizard summary SHALL be itemized per target (e.g. "Remove Taskless from `.claude/` (2 stubs), `.cursor/` (2 stubs)?") instead of the current generic single-line prompt. +- The at-least-one-tool selection rule is unchanged; full uninstall (removing the canonical `.taskless/` store) is explicitly out of scope. +- Empty-directory pruning is out of scope: only the stub files Taskless wrote are removed; `skills/` and `commands/tskl/` directory shells are left in place (current behavior). +- The non-interactive `init --no-interactive` / `update` paths are unchanged: they remain detection-driven, additive, and never surprise-delete a still-present tool's stubs. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `cli-init`: The "Wizard prompts the user to choose install locations" requirement changes — the pre-checked set becomes the union of manifest-recorded and detected directories, with a three-state per-entry hint. The "Wizard shows a diff-style summary before writing" requirement changes — the removal confirmation becomes itemized per target. + +## Impact + +- `packages/cli/src/wizard/steps/locations.ts` — `locationChoices` gains a manifest-targets parameter (kept pure); `promptLocations` reads install state and passes both manifest targets and detected tools down. +- `packages/cli/src/wizard/steps/summary.ts` — `renderSummaryAndConfirm` builds an itemized per-target removal confirmation message. +- `packages/cli/src/install/install.ts` / `state.ts` — read-only consumption of existing `readInstallState`; no removal-engine changes. +- Tests: `locationChoices` mapping unit tests extended for manifest-driven pre-check and the three-state hint; summary-confirmation copy assertions updated. +- No changes to the non-interactive install path, the manifest schema, or the canonical-store handling. diff --git a/openspec/changes/uninstall-unchecked-locations/specs/cli-init/spec.md b/openspec/changes/uninstall-unchecked-locations/specs/cli-init/spec.md new file mode 100644 index 0000000..7386976 --- /dev/null +++ b/openspec/changes/uninstall-unchecked-locations/specs/cli-init/spec.md @@ -0,0 +1,73 @@ +## MODIFIED Requirements + +### Requirement: Wizard prompts the user to choose install locations + +The wizard's location step SHALL be presented as a tool-selection step: "which tools do you want to enable Taskless for?". It SHALL offer a fixed multiselect of `.claude/`, `.cursor/`, `.opencode/`, and `.agents/`. The pre-checked set SHALL be the union of (a) every directory recorded as a target in the install manifest (`install.targets`) that matches one of the four offered entries, and (b) every detected tool's install directory. When the manifest records no targets AND no tools are detected, `.agents/` SHALL be pre-checked as the first-run default. The canonical `.taskless/` store SHALL NOT appear as a selectable entry and SHALL NOT be pre-checked — it is always written and is never a manifest tool-directory target. + +Each offered entry SHALL carry an origin hint: `installed` when the entry's directory is recorded in the install manifest; otherwise `detected` when the entry's tool is detected on the filesystem; otherwise `not detected` (the `.agents/` first-run default MAY instead carry a hint describing it as the generic agent-skills location). The `installed` hint SHALL take precedence over `detected` when both apply. + +Unchecking a pre-checked, manifest-recorded entry SHALL cause the resulting install plan to omit that target, so the existing manifest-diff removal path removes Taskless's reference stubs from that directory. The at-least-one-tool selection rule is unchanged: the wizard SHALL require at least one checked entry. + +Each checked entry SHALL produce one `reference` stub target; the resulting install plan always contains the single `taskless` skill (and, for `.claude/` and `.cursor/`, the `tskl` command). The function that maps detected tools and manifest targets to multiselect choices SHALL be pure — it SHALL receive both the detected tools and the manifest target list as arguments and SHALL perform no filesystem access — so the mapping is unit-testable. + +#### Scenario: Detected tools are pre-checked + +- **WHEN** the wizard reaches the tool-selection step and `.claude/` is detected +- **THEN** `.claude/` SHALL be pre-checked in the multiselect +- **AND** `.claude/` SHALL carry the `detected` hint when it is not recorded in the install manifest + +#### Scenario: Manifest-recorded locations are pre-checked + +- **WHEN** the wizard reaches the tool-selection step and the install manifest records `.agents/` as a target +- **THEN** `.agents/` SHALL be pre-checked in the multiselect +- **AND** `.agents/` SHALL carry the `installed` hint + +#### Scenario: Installed hint takes precedence over detected + +- **WHEN** the wizard reaches the tool-selection step and `.claude/` is both detected on the filesystem and recorded in the install manifest +- **THEN** `.claude/` SHALL be pre-checked +- **AND** `.claude/` SHALL carry the `installed` hint, not the `detected` hint + +#### Scenario: Unchecking an installed location removes its stubs + +- **WHEN** the install manifest records `.claude/` and `.agents/` as targets and the user unchecks `.claude/` while leaving `.agents/` checked +- **THEN** the resulting install plan SHALL omit the `.claude/` target +- **AND** the wizard summary SHALL list the `.claude/` reference stubs as removals + +#### Scenario: Agents is the default when nothing is detected or installed + +- **WHEN** the wizard reaches the tool-selection step, no tools are detected, and the install manifest records no tool-directory targets +- **THEN** `.agents/` SHALL be pre-checked + +#### Scenario: Canonical store is not a selectable entry + +- **WHEN** the wizard renders the tool-selection multiselect +- **THEN** `.taskless/` SHALL NOT appear as a selectable option +- **AND** `.taskless/` SHALL NOT be pre-checked even though the manifest records it as a target + +### Requirement: Wizard shows a diff-style summary before writing + +Before any filesystem writes, the wizard SHALL display a summary of planned actions grouped by target location. The summary SHALL include: + +- Additions: skills that will be newly written to a target +- Removals: skills previously recorded in the install manifest but not selected in the current session +- Unchanged: skills that will be overwritten with identical content (may be collapsed to a count) + +If the summary contains any removals, the wizard SHALL require an explicit `confirm()` before proceeding. The confirmation prompt SHALL be itemized per target: it SHALL name each target directory losing content and the count of stubs being removed from it (for example, "Remove Taskless from `.claude/` (2 stubs), `.cursor/` (1 stub)?"). If the summary contains no removals, the wizard MAY proceed without an extra confirm. + +#### Scenario: Additions are shown in the summary + +- **WHEN** the wizard reaches the summary step and the user has selected a location that was not in the previous install manifest +- **THEN** the summary SHALL list every skill to be added under that location + +#### Scenario: Removals trigger an itemized confirm + +- **WHEN** the summary contains at least one skill or location to be removed +- **THEN** the wizard SHALL display a confirm prompt before writing +- **AND** the confirm prompt SHALL name each target directory losing content and the count of stubs removed from it +- **AND** declining the confirm SHALL abort without writes + +#### Scenario: No-removal summaries skip the extra confirm + +- **WHEN** the summary contains only additions and unchanged entries +- **THEN** the wizard MAY proceed directly to writes without an extra confirm diff --git a/openspec/changes/uninstall-unchecked-locations/tasks.md b/openspec/changes/uninstall-unchecked-locations/tasks.md new file mode 100644 index 0000000..f2b07d7 --- /dev/null +++ b/openspec/changes/uninstall-unchecked-locations/tasks.md @@ -0,0 +1,22 @@ +## 1. Manifest-aware location choices + +- [x] 1.1 Extend `locationChoices` in `packages/cli/src/wizard/steps/locations.ts` to accept a second argument: the list of install-manifest target directories. Keep the function pure (no filesystem access). +- [x] 1.2 Compute `initialValues` as the union of manifest target directories matching a `SHIM_TARGETS` entry and detected tools' install directories; keep the `.agents/` first-run default only when both inputs are empty. +- [x] 1.3 Compute each entry's hint with `installed` (in manifest) taking precedence over `detected`, falling back to `not detected`, preserving the `.agents/` "generic agent skills" default-hint case. +- [x] 1.4 Update `promptLocations` to read install state via `readInstallState`, derive the manifest target directory list (excluding `.taskless/`), and pass it into `locationChoices`. + +## 2. Itemized removal confirmation + +- [x] 2.1 In `packages/cli/src/wizard/steps/summary.ts`, build the removal confirmation message from `diff.entries` that carry removals — naming each target directory and its removed stub count. +- [x] 2.2 Keep the diff summary block and the no-removal fast-path (`!diff.hasRemovals` returns `true`) unchanged. + +## 3. Tests + +- [x] 3.1 Extend the `locationChoices` unit tests: manifest-only pre-check, manifest ∪ detected union, `installed`-over-`detected` precedence, and the both-empty `.agents/` default. +- [x] 3.2 Add a test that an entry recorded in the manifest carries the `installed` hint and `.taskless/` is never an offered or pre-checked entry. +- [x] 3.3 Update summary-confirmation tests to assert the itemized per-target message (target names and stub counts) and that no-removal summaries skip the confirm. Added end-to-end uninstall tests in `wizard-integration.test.ts` covering stub removal on uncheck and decline-keeps-stubs. + +## 4. Verification + +- [x] 4.1 Run `pnpm typecheck` and `pnpm lint`; fix any failures. +- [x] 4.2 Run the CLI test suite (`pnpm --filter @taskless/cli test`) and confirm all tests pass. diff --git a/packages/cli/src/wizard/steps/locations.ts b/packages/cli/src/wizard/steps/locations.ts index 8e5c02d..d04bc45 100644 --- a/packages/cli/src/wizard/steps/locations.ts +++ b/packages/cli/src/wizard/steps/locations.ts @@ -1,11 +1,13 @@ import { multiselect, log } from "@clack/prompts"; import { + CANONICAL_DIR, DEFAULT_SHIM_DIR, SHIM_TARGETS, detectTools, type ToolDescriptor, } from "../../install/install"; +import { readInstallState } from "../../install/state"; import { ask } from "../ask"; /** A single entry in the tool-selection multiselect. */ @@ -17,29 +19,51 @@ export interface LocationChoice { /** * Build the tool-selection multiselect options and the pre-checked set from - * the detected tools. Pure — no prompt, no TTY — so the detection-to-choices - * mapping is unit-testable. + * the detected tools and the install manifest's recorded targets. Pure — no + * prompt, no TTY, no filesystem access — so the mapping is unit-testable. * * Every shim target is always offered (a peer list); the canonical - * `.taskless/` store is never an entry. Detected tools are pre-checked, and - * `.agents/` is pre-checked as the default when nothing is detected. + * `.taskless/` store is never an entry. The pre-checked set is the union of + * manifest-recorded shim directories and detected tools' directories, so a + * location Taskless already installed into shows checked and can be + * unchecked. `.agents/` is pre-checked as the default only when nothing is + * recorded and nothing is detected. + * + * Each entry's hint reflects its origin: `installed` when recorded in the + * manifest (takes precedence), otherwise `detected` when the tool is present, + * otherwise `not detected` (the `.agents/` default keeps a descriptive hint). + * + * @param manifestDirectories Shim directories recorded in the install + * manifest; the canonical `.taskless/` directory is ignored if present. */ -export function locationChoices(detected: ToolDescriptor[]): { +export function locationChoices( + detected: ToolDescriptor[], + manifestDirectories: readonly string[] = [] +): { options: LocationChoice[]; initialValues: string[]; } { const detectedDirectories = new Set(detected.map((t) => t.installDir)); - const initialValues = - detected.length > 0 ? [...detectedDirectories] : [DEFAULT_SHIM_DIR]; + const installedDirectories = new Set( + manifestDirectories.filter((d) => d !== CANONICAL_DIR) + ); + + const preChecked = SHIM_TARGETS.map((s) => s.dir).filter( + (directory) => + installedDirectories.has(directory) || detectedDirectories.has(directory) + ); + const initialValues = preChecked.length > 0 ? preChecked : [DEFAULT_SHIM_DIR]; const options = SHIM_TARGETS.map((shim) => ({ value: shim.dir, label: `${shim.label} (${shim.dir}/)`, - hint: detectedDirectories.has(shim.dir) - ? "detected" - : shim.dir === DEFAULT_SHIM_DIR - ? "generic agent skills" - : "not detected", + hint: installedDirectories.has(shim.dir) + ? "installed" + : detectedDirectories.has(shim.dir) + ? "detected" + : shim.dir === DEFAULT_SHIM_DIR + ? "generic agent skills" + : "not detected", })); return { options, initialValues }; @@ -52,7 +76,12 @@ export function locationChoices(detected: ToolDescriptor[]): { */ export async function promptLocations(cwd: string): Promise { const detected = await detectTools(cwd); - const { options, initialValues } = locationChoices(detected); + const installState = await readInstallState(cwd); + const manifestDirectories = Object.keys(installState.targets); + const { options, initialValues } = locationChoices( + detected, + manifestDirectories + ); while (true) { const selected = await ask("locations", () => diff --git a/packages/cli/src/wizard/steps/summary.ts b/packages/cli/src/wizard/steps/summary.ts index 3bde6a9..fa3269c 100644 --- a/packages/cli/src/wizard/steps/summary.ts +++ b/packages/cli/src/wizard/steps/summary.ts @@ -49,9 +49,26 @@ export async function renderSummaryAndConfirm( return ask("summary", () => confirm({ - message: - "Some files will be removed from locations recorded in the previous install. Proceed?", + message: buildRemovalConfirmMessage(diff), initialValue: true, }) ); } + +/** + * Build an itemized removal confirmation: one clause per target losing + * content, naming the directory and its removed stub count (skills + + * commands). Only meaningful when {@link InstallDiff.hasRemovals} is true, so + * at least one clause is produced in that case. + */ +export function buildRemovalConfirmMessage(diff: InstallDiff): string { + const clauses: string[] = []; + for (const entry of diff.entries) { + const count = entry.removals.skills.length + entry.removals.commands.length; + if (count === 0) continue; + clauses.push( + `${entry.target}/ (${String(count)} stub${count === 1 ? "" : "s"})` + ); + } + return `Remove Taskless from ${clauses.join(", ")}?`; +} diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 41b1c2a..e74d260 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -19,6 +19,9 @@ const clackResponses: { summary?: boolean | symbol; } = {}; +// Captures the message of the summary confirm prompt for assertions. +let summaryConfirmMessage: string | undefined; + vi.mock("@clack/prompts", () => ({ intro: () => {}, outro: () => {}, @@ -36,6 +39,7 @@ vi.mock("@clack/prompts", () => ({ if (message.toLowerCase().includes("log in")) { return Promise.resolve(clackResponses.auth); } + summaryConfirmMessage = message; return Promise.resolve(clackResponses.summary); }), })); @@ -58,6 +62,7 @@ beforeEach(async () => { clackResponses.locations = undefined; clackResponses.auth = undefined; clackResponses.summary = undefined; + summaryConfirmMessage = undefined; vi.stubEnv("TASKLESS_TOKEN", "stub-token"); }); @@ -166,4 +171,62 @@ describe("runWizard end-to-end", () => { expect(result.status).toBe("cancelled"); expect(result.cancelledStep).toBe("summary"); }); + + it("unchecking a previously-installed location removes its stubs", async () => { + const { runWizard } = await import("../src/wizard"); + + // First run: install Taskless into both .claude/ and .agents/. + clackResponses.locations = [".claude", ".agents"]; + clackResponses.summary = true; + await runWizard({ cwd }); + + const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + const agentsSkill = join(cwd, ".agents", "skills", "taskless", "SKILL.md"); + expect(await exists(claudeSkill)).toBe(true); + expect(await exists(agentsSkill)).toBe(true); + + // Second run: uncheck .claude/, keep .agents/ — consolidating onto .agents. + summaryConfirmMessage = undefined; + clackResponses.locations = [".agents"]; + clackResponses.summary = true; + const result = await runWizard({ cwd }); + + expect(result.status).toBe("completed"); + // The .claude/ stub is removed; the .agents/ stub is retained. + expect(await exists(claudeSkill)).toBe(false); + expect(await exists(agentsSkill)).toBe(true); + + // The removal confirmation is itemized for the unchecked location. + expect(summaryConfirmMessage).toBeDefined(); + expect(summaryConfirmMessage).toContain("Remove Taskless from"); + expect(summaryConfirmMessage).toContain(".claude/"); + expect(summaryConfirmMessage).not.toContain(".agents/"); + + // The manifest no longer records the .claude/ target. + const manifest = JSON.parse( + await readFile(join(cwd, ".taskless", "taskless.json"), "utf8") + ) as { install: { targets: Record } }; + expect(manifest.install.targets[".claude"]).toBeUndefined(); + expect(manifest.install.targets[".agents"]).toBeDefined(); + }); + + it("declining the removal confirm keeps the unchecked location's stubs", async () => { + const { runWizard } = await import("../src/wizard"); + + clackResponses.locations = [".claude", ".agents"]; + clackResponses.summary = true; + await runWizard({ cwd }); + + const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + expect(await exists(claudeSkill)).toBe(true); + + // Uncheck .claude/ but decline the removal confirm — nothing is removed. + clackResponses.locations = [".agents"]; + clackResponses.summary = false; + const result = await runWizard({ cwd }); + + expect(result.status).toBe("cancelled"); + expect(result.cancelledStep).toBe("summary"); + expect(await exists(claudeSkill)).toBe(true); + }); }); diff --git a/packages/cli/test/wizard-steps.test.ts b/packages/cli/test/wizard-steps.test.ts index 18f8901..6b6be11 100644 --- a/packages/cli/test/wizard-steps.test.ts +++ b/packages/cli/test/wizard-steps.test.ts @@ -68,4 +68,91 @@ describe("locationChoices", () => { expect(initialValues).toContain(".claude"); expect(initialValues).toContain(".agents"); }); + + it("pre-checks a manifest-recorded location with no detected tool", async () => { + const { locationChoices } = await import("../src/wizard/steps/locations"); + const { options, initialValues } = locationChoices([], [".agents"]); + expect(initialValues).toEqual([".agents"]); + expect(options.find((o) => o.value === ".agents")?.hint).toBe("installed"); + }); + + it("pre-checks the union of manifest and detected directories", async () => { + const { locationChoices } = await import("../src/wizard/steps/locations"); + const { TOOLS } = await import("../src/install/install"); + const claude = TOOLS.find((t) => t.name === "Claude Code")!; + + const { options, initialValues } = locationChoices([claude], [".agents"]); + expect(initialValues).toEqual([".claude", ".agents"]); + expect(options.find((o) => o.value === ".claude")?.hint).toBe("detected"); + expect(options.find((o) => o.value === ".agents")?.hint).toBe("installed"); + }); + + it("hints a location as installed even when its tool is also detected", async () => { + const { locationChoices } = await import("../src/wizard/steps/locations"); + const { TOOLS } = await import("../src/install/install"); + const claude = TOOLS.find((t) => t.name === "Claude Code")!; + + const { options } = locationChoices([claude], [".claude"]); + expect(options.find((o) => o.value === ".claude")?.hint).toBe("installed"); + }); + + it("ignores the canonical .taskless store recorded in the manifest", async () => { + const { locationChoices } = await import("../src/wizard/steps/locations"); + const { options, initialValues } = locationChoices([], [".taskless"]); + // .taskless is filtered out, so nothing is pre-checked from the manifest + // and the first-run .agents/ default applies. + expect(initialValues).toEqual([".agents"]); + expect(options.map((o) => o.value)).not.toContain(".taskless"); + expect(options.find((o) => o.value === ".agents")?.hint).toBe( + "generic agent skills" + ); + }); + + it("falls back to .agents/ when neither manifest nor detection has entries", async () => { + const { locationChoices } = await import("../src/wizard/steps/locations"); + expect(locationChoices([], []).initialValues).toEqual([".agents"]); + }); +}); + +function diffEntry( + target: string, + removedSkills: string[], + removedCommands: string[] = [] +) { + return { + target, + mode: "reference" as const, + additions: { skills: [], commands: [] }, + removals: { skills: removedSkills, commands: removedCommands }, + unchanged: { skills: [], commands: [] }, + }; +} + +describe("buildRemovalConfirmMessage", () => { + it("itemizes each target losing stubs with its count", async () => { + const { buildRemovalConfirmMessage } = + await import("../src/wizard/steps/summary"); + const message = buildRemovalConfirmMessage({ + entries: [ + diffEntry(".claude", ["taskless"], ["tskl.md"]), + diffEntry(".cursor", ["taskless"]), + ], + hasAdditions: false, + hasRemovals: true, + }); + expect(message).toBe( + "Remove Taskless from .claude/ (2 stubs), .cursor/ (1 stub)?" + ); + }); + + it("skips targets with no removals", async () => { + const { buildRemovalConfirmMessage } = + await import("../src/wizard/steps/summary"); + const message = buildRemovalConfirmMessage({ + entries: [diffEntry(".agents", []), diffEntry(".cursor", ["taskless"])], + hasAdditions: false, + hasRemovals: true, + }); + expect(message).toBe("Remove Taskless from .cursor/ (1 stub)?"); + }); }); From 99e0a748389bcbfc7890fab60bdcaece0e0414f7 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 18 May 2026 22:56:45 -0700 Subject: [PATCH 2/3] chore(openspec): Archive uninstall-unchecked-locations change Move the completed change under openspec/changes/archive/ and sync the two modified cli-init requirements into the main spec. Satisfies the PR OpenSpec archive gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cli-init/spec.md | 0 .../tasks.md | 0 openspec/specs/cli-init/spec.md | 37 ++++++++++++++++--- 6 files changed, 32 insertions(+), 5 deletions(-) rename openspec/changes/{uninstall-unchecked-locations => archive/2026-05-19-uninstall-unchecked-locations}/.openspec.yaml (100%) rename openspec/changes/{uninstall-unchecked-locations => archive/2026-05-19-uninstall-unchecked-locations}/design.md (100%) rename openspec/changes/{uninstall-unchecked-locations => archive/2026-05-19-uninstall-unchecked-locations}/proposal.md (100%) rename openspec/changes/{uninstall-unchecked-locations => archive/2026-05-19-uninstall-unchecked-locations}/specs/cli-init/spec.md (100%) rename openspec/changes/{uninstall-unchecked-locations => archive/2026-05-19-uninstall-unchecked-locations}/tasks.md (100%) diff --git a/openspec/changes/uninstall-unchecked-locations/.openspec.yaml b/openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/.openspec.yaml similarity index 100% rename from openspec/changes/uninstall-unchecked-locations/.openspec.yaml rename to openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/.openspec.yaml diff --git a/openspec/changes/uninstall-unchecked-locations/design.md b/openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/design.md similarity index 100% rename from openspec/changes/uninstall-unchecked-locations/design.md rename to openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/design.md diff --git a/openspec/changes/uninstall-unchecked-locations/proposal.md b/openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/proposal.md similarity index 100% rename from openspec/changes/uninstall-unchecked-locations/proposal.md rename to openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/proposal.md diff --git a/openspec/changes/uninstall-unchecked-locations/specs/cli-init/spec.md b/openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/specs/cli-init/spec.md similarity index 100% rename from openspec/changes/uninstall-unchecked-locations/specs/cli-init/spec.md rename to openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/specs/cli-init/spec.md diff --git a/openspec/changes/uninstall-unchecked-locations/tasks.md b/openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/tasks.md similarity index 100% rename from openspec/changes/uninstall-unchecked-locations/tasks.md rename to openspec/changes/archive/2026-05-19-uninstall-unchecked-locations/tasks.md diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index e91c0b9..a853c4e 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -380,22 +380,48 @@ The wizard SHALL begin by rendering an ASCII rendition of the Taskless wordmark ### Requirement: Wizard prompts the user to choose install locations -The wizard's location step SHALL be presented as a tool-selection step: "which tools do you want to enable Taskless for?". It SHALL offer a fixed multiselect of `.claude/`, `.cursor/`, `.opencode/`, and `.agents/`, with detected directories pre-checked and `.agents/` pre-checked when no tools are detected. The canonical `.taskless/` store SHALL NOT appear as a selectable entry — it is always written. Each checked entry SHALL produce one `reference` stub target; the resulting install plan always contains the single `taskless` skill (and, for `.claude/` and `.cursor/`, the `tskl` command). +The wizard's location step SHALL be presented as a tool-selection step: "which tools do you want to enable Taskless for?". It SHALL offer a fixed multiselect of `.claude/`, `.cursor/`, `.opencode/`, and `.agents/`. The pre-checked set SHALL be the union of (a) every directory recorded as a target in the install manifest (`install.targets`) that matches one of the four offered entries, and (b) every detected tool's install directory. When the manifest records no targets AND no tools are detected, `.agents/` SHALL be pre-checked as the first-run default. The canonical `.taskless/` store SHALL NOT appear as a selectable entry and SHALL NOT be pre-checked — it is always written and is never a manifest tool-directory target. + +Each offered entry SHALL carry an origin hint: `installed` when the entry's directory is recorded in the install manifest; otherwise `detected` when the entry's tool is detected on the filesystem; otherwise `not detected` (the `.agents/` first-run default MAY instead carry a hint describing it as the generic agent-skills location). The `installed` hint SHALL take precedence over `detected` when both apply. + +Unchecking a pre-checked, manifest-recorded entry SHALL cause the resulting install plan to omit that target, so the existing manifest-diff removal path removes Taskless's reference stubs from that directory. The at-least-one-tool selection rule is unchanged: the wizard SHALL require at least one checked entry. + +Each checked entry SHALL produce one `reference` stub target; the resulting install plan always contains the single `taskless` skill (and, for `.claude/` and `.cursor/`, the `tskl` command). The function that maps detected tools and manifest targets to multiselect choices SHALL be pure — it SHALL receive both the detected tools and the manifest target list as arguments and SHALL perform no filesystem access — so the mapping is unit-testable. #### Scenario: Detected tools are pre-checked - **WHEN** the wizard reaches the tool-selection step and `.claude/` is detected - **THEN** `.claude/` SHALL be pre-checked in the multiselect +- **AND** `.claude/` SHALL carry the `detected` hint when it is not recorded in the install manifest + +#### Scenario: Manifest-recorded locations are pre-checked + +- **WHEN** the wizard reaches the tool-selection step and the install manifest records `.agents/` as a target +- **THEN** `.agents/` SHALL be pre-checked in the multiselect +- **AND** `.agents/` SHALL carry the `installed` hint + +#### Scenario: Installed hint takes precedence over detected + +- **WHEN** the wizard reaches the tool-selection step and `.claude/` is both detected on the filesystem and recorded in the install manifest +- **THEN** `.claude/` SHALL be pre-checked +- **AND** `.claude/` SHALL carry the `installed` hint, not the `detected` hint + +#### Scenario: Unchecking an installed location removes its stubs + +- **WHEN** the install manifest records `.claude/` and `.agents/` as targets and the user unchecks `.claude/` while leaving `.agents/` checked +- **THEN** the resulting install plan SHALL omit the `.claude/` target +- **AND** the wizard summary SHALL list the `.claude/` reference stubs as removals -#### Scenario: Agents is the default when nothing is detected +#### Scenario: Agents is the default when nothing is detected or installed -- **WHEN** the wizard reaches the tool-selection step and no tools are detected +- **WHEN** the wizard reaches the tool-selection step, no tools are detected, and the install manifest records no tool-directory targets - **THEN** `.agents/` SHALL be pre-checked #### Scenario: Canonical store is not a selectable entry - **WHEN** the wizard renders the tool-selection multiselect - **THEN** `.taskless/` SHALL NOT appear as a selectable option +- **AND** `.taskless/` SHALL NOT be pre-checked even though the manifest records it as a target ### Requirement: Wizard explains the auth tradeoff and offers to log in @@ -446,17 +472,18 @@ Before any filesystem writes, the wizard SHALL display a summary of planned acti - Removals: skills previously recorded in the install manifest but not selected in the current session - Unchanged: skills that will be overwritten with identical content (may be collapsed to a count) -If the summary contains any removals, the wizard SHALL require an explicit `confirm()` before proceeding. If the summary contains no removals, the wizard MAY proceed without an extra confirm. +If the summary contains any removals, the wizard SHALL require an explicit `confirm()` before proceeding. The confirmation prompt SHALL be itemized per target: it SHALL name each target directory losing content and the count of stubs being removed from it (for example, "Remove Taskless from `.claude/` (2 stubs), `.cursor/` (1 stub)?"). If the summary contains no removals, the wizard MAY proceed without an extra confirm. #### Scenario: Additions are shown in the summary - **WHEN** the wizard reaches the summary step and the user has selected a location that was not in the previous install manifest - **THEN** the summary SHALL list every skill to be added under that location -#### Scenario: Removals trigger a confirm +#### Scenario: Removals trigger an itemized confirm - **WHEN** the summary contains at least one skill or location to be removed - **THEN** the wizard SHALL display a confirm prompt before writing +- **AND** the confirm prompt SHALL name each target directory losing content and the count of stubs removed from it - **AND** declining the confirm SHALL abort without writes #### Scenario: No-removal summaries skip the extra confirm From ace55ee2ffb283ee878074ec5e0f328274ca8b7e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 18 May 2026 23:21:48 -0700 Subject: [PATCH 3/3] fix(cli): Exclude canonical store from itemized removal confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildRemovalConfirmMessage used the same "Remove Taskless from …" wording for every diff entry. A diff carrying canonical-store removals (e.g. a legacy v0.6→v0.7 upgrade) would render "Remove Taskless from .taskless/ …", which is misleading — that is not an uninstall of a tool location. Skip canonical entries in the itemized list and fall back to a generic prompt when only canonical removals are present. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/wizard/steps/summary.ts | 15 ++++++++--- packages/cli/test/wizard-steps.test.ts | 33 ++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/wizard/steps/summary.ts b/packages/cli/src/wizard/steps/summary.ts index fa3269c..5687864 100644 --- a/packages/cli/src/wizard/steps/summary.ts +++ b/packages/cli/src/wizard/steps/summary.ts @@ -56,19 +56,26 @@ export async function renderSummaryAndConfirm( } /** - * Build an itemized removal confirmation: one clause per target losing - * content, naming the directory and its removed stub count (skills + - * commands). Only meaningful when {@link InstallDiff.hasRemovals} is true, so - * at least one clause is produced in that case. + * Build an itemized removal confirmation: one clause per `reference` target + * losing stubs, naming the directory and its removed stub count (skills + + * commands). Canonical-store removals are excluded — they are not an + * uninstall of a tool location, so "Remove Taskless from .taskless/" would be + * misleading; when only canonical removals are present the message falls back + * to a generic prompt. Only meaningful when {@link InstallDiff.hasRemovals} + * is true. */ export function buildRemovalConfirmMessage(diff: InstallDiff): string { const clauses: string[] = []; for (const entry of diff.entries) { + if (entry.mode === "canonical") continue; const count = entry.removals.skills.length + entry.removals.commands.length; if (count === 0) continue; clauses.push( `${entry.target}/ (${String(count)} stub${count === 1 ? "" : "s"})` ); } + if (clauses.length === 0) { + return "Some files recorded in the previous install will be removed. Proceed?"; + } return `Remove Taskless from ${clauses.join(", ")}?`; } diff --git a/packages/cli/test/wizard-steps.test.ts b/packages/cli/test/wizard-steps.test.ts index 6b6be11..6e5b4e5 100644 --- a/packages/cli/test/wizard-steps.test.ts +++ b/packages/cli/test/wizard-steps.test.ts @@ -117,11 +117,12 @@ describe("locationChoices", () => { function diffEntry( target: string, removedSkills: string[], - removedCommands: string[] = [] + removedCommands: string[] = [], + mode: "reference" | "canonical" = "reference" ) { return { target, - mode: "reference" as const, + mode, additions: { skills: [], commands: [] }, removals: { skills: removedSkills, commands: removedCommands }, unchanged: { skills: [], commands: [] }, @@ -155,4 +156,32 @@ describe("buildRemovalConfirmMessage", () => { }); expect(message).toBe("Remove Taskless from .cursor/ (1 stub)?"); }); + + it("excludes canonical-store removals from the itemized list", async () => { + const { buildRemovalConfirmMessage } = + await import("../src/wizard/steps/summary"); + const message = buildRemovalConfirmMessage({ + entries: [ + diffEntry(".taskless", ["old-skill"], [], "canonical"), + diffEntry(".cursor", ["taskless"]), + ], + hasAdditions: false, + hasRemovals: true, + }); + expect(message).toBe("Remove Taskless from .cursor/ (1 stub)?"); + expect(message).not.toContain(".taskless/"); + }); + + it("falls back to a generic prompt when only the canonical store loses files", async () => { + const { buildRemovalConfirmMessage } = + await import("../src/wizard/steps/summary"); + const message = buildRemovalConfirmMessage({ + entries: [diffEntry(".taskless", ["old-skill"], [], "canonical")], + hasAdditions: false, + hasRemovals: true, + }); + expect(message).toBe( + "Some files recorded in the previous install will be removed. Proceed?" + ); + }); });