From 7fe23c2260c69a23cfdc4b83a4f7dbd9535b1462 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 15 Oct 2026 16:53:25 -0700 Subject: [PATCH] feat(init): one .agents row that names the detected harnesses The picker offered .agents twice, as Codex and as Agent Skills, so a Codex user would see their harness. It now offers one .agents row labelled with the detected harnesses that read .agents/skills, in catalog order ("OpenCode, Codex, and other .agents harnesses"), naming all of them when none is detected. Gemini CLI and Crush are detected and install into .agents. OpenCode is named but does not pre-check .agents, since it already gets .opencode. Archives the agents-row-names-harnesses OpenSpec change. --- .changeset/pinned-cli-permissions.md | 2 + .../proposal.md | 28 ++++ .../specs/cli-init/spec.md | 96 ++++++++++++++ .../tasks.md | 8 ++ openspec/specs/cli-init/spec.md | 47 +++++-- packages/cli/src/agent/init.md | 5 +- packages/cli/src/install/install.ts | 120 +++++++----------- packages/cli/src/wizard/steps/locations.ts | 70 +++++----- packages/cli/test/install.test.ts | 46 ++++--- packages/cli/test/wizard-steps.test.ts | 81 +++++++----- 10 files changed, 329 insertions(+), 174 deletions(-) create mode 100644 openspec/changes/archive/2026-10-15-agents-row-names-harnesses/proposal.md create mode 100644 openspec/changes/archive/2026-10-15-agents-row-names-harnesses/specs/cli-init/spec.md create mode 100644 openspec/changes/archive/2026-10-15-agents-row-names-harnesses/tasks.md diff --git a/.changeset/pinned-cli-permissions.md b/.changeset/pinned-cli-permissions.md index 4060f254..2d7c8c02 100644 --- a/.changeset/pinned-cli-permissions.md +++ b/.changeset/pinned-cli-permissions.md @@ -7,3 +7,5 @@ Released builds now pin their own version. The installed skill and the `agent` r `init` now also reports stale Taskless pins in CI and git-hook files (GitHub Actions, GitLab CI, CircleCI, husky, lefthook, pre-commit, and the rest of the files `detect` recognizes) by file and line, through the same notice and `pinnedCli` JSON field as `package.json` pins. It recommends the version to move to and never edits the file. `init` now also allows exactly the installed CLI in each agent it installs for, so a harness that gates download-and-run commands lets Taskless through without a prompt: Claude Code (`.claude/settings.json`), Cursor (`.cursor/permissions.json` and `.cursor/cli.json`), OpenCode (`opencode.json[c]`), and Codex (`.codex/rules/taskless.rules`, when Codex is present). Each rule names one version and cannot match another. Every edit is announced, as `Edited ` with a green `ALLOW:` line, and the wizard shows it and asks before writing. An upgrade replaces only the rule Taskless recorded, a rule you wrote is never touched, and a file that denies the command or does not parse is left alone and reported. `init --json` carries the same edits under `permissions`. Agents Taskless does not install into use per-command approval through the skill. + +The setup wizard's tool list now has one `.agents/` row, labelled with the detected harnesses that read it, such as `OpenCode, Codex, and other .agents harnesses (.agents/)`, instead of separate `Codex` and `Agent Skills` rows. Gemini CLI and Crush are now detected, and install the skill into `.agents/`. diff --git a/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/proposal.md b/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/proposal.md new file mode 100644 index 00000000..cbbf01f1 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/proposal.md @@ -0,0 +1,28 @@ +## Why + +The wizard's tool picker offers `.agents/` twice: as `Codex` and as `Agent Skills`. The duplicate row exists so a Codex user sees the word "Codex", since one who saw only "Agent Skills" concluded Taskless did not support their harness. But Codex is not the only harness that reads `.agents/skills`, and a row per harness does not scale. OpenCode, Gemini CLI, and Crush all read it, and the picker names none of them. + +## What Changes + +- The picker offers one `.agents/` row, labelled with the detected harnesses that read `.agents/skills`, in catalog order: `OpenCode, Codex, and other .agents harnesses (.agents/)`. With none detected, it names all of them, so a Codex or Gemini CLI user still finds their harness on a fresh machine. +- The duplicate `Codex` row is removed, along with `uniqueShimTargets()`, which existed only to collapse it. +- Gemini CLI (`.gemini/`, `GEMINI.md`) and Crush (`.crush/`, `crush.json`, `.crushrc`, and variants) are detected and install into `.agents/`, each per its own docs. +- OpenCode is named on the `.agents/` row when detected, since it reads `.agents/skills`, but does not pre-check it: it already gets `.opencode/`, and both would give it the skill twice. + +## Delivery shape + +**Stacked, merging forward**, on top of the `pinned-cli-permissions` stack (#509, #510). The change is independent of that stack, but it edits the `init` recipe the stack also edits, so it sits on top to avoid a conflict. Safe alone in production. + +## Capabilities + +### Modified Capabilities + +- `cli-init`: the picker's `.agents/` row and its label; Gemini CLI and Crush detection. + +## Impact + +- `packages/cli/src/install/install.ts`: tool registry and shim catalog. +- `packages/cli/src/wizard/steps/locations.ts`: the row label. +- `packages/cli/src/agent/init.md`: the list of detected tools (topic v9). +- Tests for the picker and detection. +- No new harness gets a permission rule: Gemini CLI and Crush are not in the permission writer's closed list. diff --git a/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/specs/cli-init/spec.md new file mode 100644 index 00000000..e2d34792 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/specs/cli-init/spec.md @@ -0,0 +1,96 @@ +## 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 one row per directory: `Claude Code` (`.claude/`), `Cursor` (`.cursor/`), `OpenCode` (`.opencode/`), and a row for `.agents/`. A user who arrived from a harness that reads `.agents/` SHALL NOT have to know that before recognising their harness in the list, so the `.agents/` row SHALL be labelled with harness names: the detected harnesses whose own documentation says they read `.agents/skills` (OpenCode, Codex, Gemini CLI, Crush), in catalog order, followed by `and other .agents harnesses`, for example `OpenCode, Codex, and other .agents harnesses (.agents/)`. When none of them is detected, the row SHALL name all of them, so a harness is still recognisable on a machine where nothing is detected. + +Every list of directories derived from the catalog SHALL name each directory at most once, however many detected tools install into it. The pre-checked set SHALL be the union of (a) every directory recorded as a target in the install manifest (`install.targets`) that matches an offered row, and (b) every detected tool's install directory, deduplicated by 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 a detected tool installs into the entry's directory; otherwise `not detected`. A harness named on the `.agents/` row only because it reads `.agents/skills`, such as OpenCode, whose own directory is `.opencode/`, SHALL NOT make that row `detected` or pre-check it, since installing into both directories would give that harness the skill twice. 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 selected directory SHALL produce exactly one `reference` stub target; the resulting install plan always contains the single `taskless` skill and no command. The `.agents/` target SHALL be reported in the install summary as `Agent Skills`. 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: Codex is named in the tool list + +- **WHEN** the wizard renders the tool-selection multiselect with Codex detected, or with no `.agents` harness detected +- **THEN** the `.agents/` row's label SHALL name `Codex` +- **AND** exactly one entry SHALL be offered for `.agents/` + +#### Scenario: Detected .agents harnesses are named in catalog order + +- **WHEN** Codex and OpenCode are detected +- **THEN** the `.agents/` row SHALL be labelled `OpenCode, Codex, and other .agents harnesses (.agents/)` + +#### Scenario: OpenCode is named without pre-checking .agents + +- **WHEN** OpenCode is the only detected tool +- **THEN** the `.agents/` row's label SHALL name OpenCode +- **AND** `.agents/` SHALL NOT be pre-checked and SHALL carry the `not detected` hint + +#### Scenario: A directory offered by two rows is pre-checked once + +- **WHEN** the wizard reaches the tool-selection step and Codex, Gemini CLI, and Crush are all detected +- **THEN** `.agents/` SHALL appear exactly once in the pre-checked set + +#### Scenario: A directory offered by two rows installs once + +- **WHEN** the user's selection includes `.agents/` +- **THEN** the install plan SHALL contain exactly one `.agents/` target +- **AND** the `.agents/` skill stub SHALL be written once + +#### 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 + +## ADDED Requirements + +### Requirement: Gemini CLI and Crush detection signals + +Gemini CLI and Crush SHALL be detected as harnesses that read `.agents/skills`, from their own documentation: Gemini CLI reads workspace skills from `.gemini/skills/` "or the `.agents/skills/` alias", and Crush lists `.agents/skills` among its project skill directories. Each SHALL install into `.agents/`. + +Gemini CLI SHALL be detected when a `.gemini/` directory or a `GEMINI.md` file exists in the project root. Crush SHALL be detected when a `.crush/` directory, or a `.crushrc`, `crushrc`, `crush.json`, or `.crush.json` file, exists in the project root. + +#### Scenario: Gemini CLI detected by its directory + +- **WHEN** `.gemini/` exists in the project root +- **THEN** Gemini CLI SHALL be detected +- **AND** skills SHALL be installed to `.agents/skills/` + +#### Scenario: Crush detected by its directory + +- **WHEN** `.crush/` exists in the project root +- **THEN** Crush SHALL be detected +- **AND** skills SHALL be installed to `.agents/skills/` diff --git a/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/tasks.md b/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/tasks.md new file mode 100644 index 00000000..d2fc5068 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-agents-row-names-harnesses/tasks.md @@ -0,0 +1,8 @@ +## 1. One .agents row that names its harnesses + +- [x] 1.1 Detect Gemini CLI and Crush; mark the harnesses that read `.agents/skills` +- [x] 1.2 Collapse the catalog to one row per directory and drop `uniqueShimTargets()` +- [x] 1.3 Label the `.agents/` row from detection, falling back to every known reader; OpenCode is named but does not pre-check it +- [x] 1.4 Update the `init` recipe's tool list +- [x] 1.5 Tests for the label, pre-check, and detection +- [x] 1.6 Changeset; run `pnpm typecheck` and `pnpm lint` diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 718fcbbf..09e1aade 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -371,30 +371,41 @@ 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 catalog of rows: `Claude Code` (`.claude/`), `Codex` (`.agents/`), `Cursor` (`.cursor/`), `OpenCode` (`.opencode/`), and `Agent Skills` (`.agents/`). A row names a tool the user scans for, so more than one row MAY offer the same directory: Codex reads `.agents/`, and a user who arrived from Codex SHALL NOT have to know that before recognising their harness in the list. The generic `Agent Skills` row SHALL remain, since it serves harnesses this catalog does not enumerate. +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 one row per directory: `Claude Code` (`.claude/`), `Cursor` (`.cursor/`), `OpenCode` (`.opencode/`), and a row for `.agents/`. A user who arrived from a harness that reads `.agents/` SHALL NOT have to know that before recognising their harness in the list, so the `.agents/` row SHALL be labelled with harness names: the detected harnesses whose own documentation says they read `.agents/skills` (OpenCode, Codex, Gemini CLI, Crush), in catalog order, followed by `and other .agents harnesses`, for example `OpenCode, Codex, and other .agents harnesses (.agents/)`. When none of them is detected, the row SHALL name all of them, so a harness is still recognisable on a machine where nothing is detected. -Every list of directories derived from the catalog SHALL name each directory at most once, however many rows offer it. The pre-checked set SHALL be the union of (a) every directory recorded as a target in the install manifest (`install.targets`) that matches an offered row, and (b) every detected tool's install directory, deduplicated by 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. +Every list of directories derived from the catalog SHALL name each directory at most once, however many detected tools install into it. The pre-checked set SHALL be the union of (a) every directory recorded as a target in the install manifest (`install.targets`) that matches an offered row, and (b) every detected tool's install directory, deduplicated by 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 generic `Agent Skills` row MAY instead carry a hint describing it as the generic agent-skills location; a row naming a specific harness SHALL NOT carry that hint. The `installed` hint SHALL take precedence over `detected` when both apply. +Each offered entry SHALL carry an origin hint: `installed` when the entry's directory is recorded in the install manifest; otherwise `detected` when a detected tool installs into the entry's directory; otherwise `not detected`. A harness named on the `.agents/` row only because it reads `.agents/skills`, such as OpenCode, whose own directory is `.opencode/`, SHALL NOT make that row `detected` or pre-check it, since installing into both directories would give that harness the skill twice. 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 selected directory SHALL produce exactly one `reference` stub target, even when several offered rows name it; the resulting install plan always contains the single `taskless` skill and no command. A directory offered by more than one row SHALL be reported in the install summary under a single label. 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. +Each selected directory SHALL produce exactly one `reference` stub target; the resulting install plan always contains the single `taskless` skill and no command. The `.agents/` target SHALL be reported in the install summary as `Agent Skills`. 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: Codex is named in the tool list -- **WHEN** the wizard renders the tool-selection multiselect -- **THEN** a `Codex` entry SHALL be offered for `.agents/` -- **AND** a separate generic `Agent Skills` entry SHALL also be offered for `.agents/` +- **WHEN** the wizard renders the tool-selection multiselect with Codex detected, or with no `.agents` harness detected +- **THEN** the `.agents/` row's label SHALL name `Codex` +- **AND** exactly one entry SHALL be offered for `.agents/` + +#### Scenario: Detected .agents harnesses are named in catalog order + +- **WHEN** Codex and OpenCode are detected +- **THEN** the `.agents/` row SHALL be labelled `OpenCode, Codex, and other .agents harnesses (.agents/)` + +#### Scenario: OpenCode is named without pre-checking .agents + +- **WHEN** OpenCode is the only detected tool +- **THEN** the `.agents/` row's label SHALL name OpenCode +- **AND** `.agents/` SHALL NOT be pre-checked and SHALL carry the `not detected` hint #### Scenario: A directory offered by two rows is pre-checked once -- **WHEN** the wizard reaches the tool-selection step and Codex is detected +- **WHEN** the wizard reaches the tool-selection step and Codex, Gemini CLI, and Crush are all detected - **THEN** `.agents/` SHALL appear exactly once in the pre-checked set #### Scenario: A directory offered by two rows installs once -- **WHEN** the user's selection includes `.agents/`, whichever of its rows was checked +- **WHEN** the user's selection includes `.agents/` - **THEN** the install plan SHALL contain exactly one `.agents/` target - **AND** the `.agents/` skill stub SHALL be written once @@ -1051,3 +1062,21 @@ The manifest SHALL record, under `install.permissions`, each file Taskless wrote - **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record - **THEN** an upgrade SHALL leave that entry in place + +### Requirement: Gemini CLI and Crush detection signals + +Gemini CLI and Crush SHALL be detected as harnesses that read `.agents/skills`, from their own documentation: Gemini CLI reads workspace skills from `.gemini/skills/` "or the `.agents/skills/` alias", and Crush lists `.agents/skills` among its project skill directories. Each SHALL install into `.agents/`. + +Gemini CLI SHALL be detected when a `.gemini/` directory or a `GEMINI.md` file exists in the project root. Crush SHALL be detected when a `.crush/` directory, or a `.crushrc`, `crushrc`, `crush.json`, or `.crush.json` file, exists in the project root. + +#### Scenario: Gemini CLI detected by its directory + +- **WHEN** `.gemini/` exists in the project root +- **THEN** Gemini CLI SHALL be detected +- **AND** skills SHALL be installed to `.agents/skills/` + +#### Scenario: Crush detected by its directory + +- **WHEN** `.crush/` exists in the project root +- **THEN** Crush SHALL be detected +- **AND** skills SHALL be installed to `.agents/skills/` diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 81424d22..8e86f5df 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,4 +1,4 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v8) +# Topic: init (CLI v%(CLI_VERSION)s / topic v9) ## Goal Install or update the Taskless skill in this project, and migrate the @@ -133,7 +133,8 @@ first step, not the whole job. ## For a person at a terminal `%(TASKLESS_CLI)s` with no subcommand launches an interactive wizard. It -detects the installed tools (Claude Code, OpenCode, Cursor, Codex), asks +detects the installed tools (Claude Code, OpenCode, Cursor, and the +`.agents` harnesses Codex, Gemini CLI, and Crush), asks which to enable, offers a login (skippable), shows a diff against the previous install, then writes the canonical `taskless` skill once to `.taskless/` and a thin stub into each selected tool directory. Stale diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 01142518..4182d1b6 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -56,6 +56,12 @@ export interface ToolDescriptor { name: string; detect: DetectionSignal[]; installDir: string; + /** + * Whether the harness reads skills from `.agents/skills`, per its own docs. + * The wizard names every detected one on the `.agents/` row, including one + * like OpenCode that also has a directory of its own. + */ + readsAgentSkills?: boolean; } export interface EmbeddedSkill { @@ -97,6 +103,8 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: "opencode.json" }, ], installDir: ".opencode", + // opencode.ai/docs/skills: "Project agent-compatible: `.agents/skills`". + readsAgentSkills: true, }, { name: "Cursor", @@ -113,94 +121,69 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: ".codex/config.toml" }, ], installDir: ".agents", + readsAgentSkills: true, + }, + { + // gemini-cli docs/cli/skills.md: workspace skills live in `.gemini/skills/` + // "or the `.agents/skills/` alias", which takes precedence. + name: "Gemini CLI", + detect: [ + { type: "directory", path: ".gemini" }, + { type: "file", path: "GEMINI.md" }, + ], + installDir: ".agents", + readsAgentSkills: true, + }, + { + // charmbracelet/crush README: project skills include `.agents/skills`. + name: "Crush", + detect: [ + { type: "directory", path: ".crush" }, + { type: "file", path: ".crushrc" }, + { type: "file", path: "crushrc" }, + { type: "file", path: "crush.json" }, + { type: "file", path: ".crush.json" }, + ], + installDir: ".agents", + readsAgentSkills: true, }, ]; /** * A selectable stub destination. The wizard offers this fixed catalog as the - * "which tools do you want to enable Taskless for?" multiselect; every entry - * is a peer, no directory is special-cased or routed onto another. - * - * The catalog is a list of *rows the user reads*, not a list of directories: - * more than one row may name the same `dir`. Anything downstream of the - * picker works in directories and must therefore collapse the rows first, - * which is what {@link uniqueShimTargets} is for. + * "which tools do you want to enable Taskless for?" multiselect, one row per + * directory; every entry is a peer, no directory is special-cased or routed + * onto another. */ export interface ShimTarget { /** Directory the stub is written into, relative to the project root. */ dir: string; - /** Human-readable label for prompts and summaries. */ + /** Human-readable label for summaries; the picker may name more. */ label: string; /** - * Marks the catch-all row: the one offered to anyone whose harness this - * catalog does not name. At most one row carries it. - * - * It is a declared field rather than something derived, because every way - * of inferring it is a coincidence. "The last row for the directory" is - * declaration order, and "the object `uniqueShimTargets` happened to keep" - * is reference identity, so reordering the catalog or making the dedupe - * return copies would silently move the hint to a named harness with no - * type error anywhere. + * Marks the catch-all row: the one serving every harness that reads + * `.agents/skills`, named and unnamed. At most one row carries it, and the + * picker labels it with the detected harnesses that read it. */ generic?: boolean; } /** - * Codex and Agent Skills deliberately share `.agents/`. Both rows stay. - * People scan this list for the name of the tool they use, and a Codex user - * who sees only "Agent Skills" concludes Taskless does not support their - * harness, which is how we lost one. "Agent Skills" still serves anyone on a - * harness this catalog does not enumerate. Do not merge the two rows into one - * label: the duplicate directory is the point, not an oversight. + * One row per directory. `.agents/` used to have two rows, `Codex` and + * `Agent Skills`, because a Codex user who saw only "Agent Skills" concluded + * Taskless did not support their harness. The picker now labels the one + * `.agents/` row with the harnesses it detects that read `.agents/skills` + * (`OpenCode, Codex, and other .agents harnesses`), which names Codex when it + * is present and names Gemini CLI and Crush too, without a duplicate row per + * harness. See `agentsRowLabel` in `wizard/steps/locations.ts`. */ export const SHIM_TARGETS: readonly ShimTarget[] = [ { dir: ".claude", label: "Claude Code" }, - { dir: ".agents", label: "Codex" }, { dir: ".cursor", label: "Cursor" }, { dir: ".opencode", label: "OpenCode" }, { dir: ".agents", label: "Agent Skills", generic: true }, ]; -/** - * The catalog collapsed to one entry per directory, in catalog order. - * - * Every consumer that turns rows into directories or into install targets - * goes through here. Without it, a single `.agents/` selection matches both - * `.agents/` rows and the plan writes, reports, and records that directory - * twice. Deduping on `dir` (rather than special-casing Codex) keeps that - * one-entry-per-directory invariant true for any future pair of rows sharing - * a destination. - * - * What it does NOT do is reconcile the rows: the surviving entry is one whole - * row, so EVERY field comes from the last row for that directory, not just - * `label`. Today the rows carry nothing else, so nothing is silently chosen. - * A future field the pair disagreed on would be resolved by catalog order - * rather than by anyone deciding, which is a reason to keep rows sharing a - * directory identical apart from `label` and `generic`. - * - * The LAST row for a directory wins both its position and its value. The - * catalog is ordered named-harness first, generic-fallback last, so `.agents/` - * is summarised as "Agent Skills" and sorts last: the selection carries only a - * directory, never which row was ticked, and the generic label is the one that - * is accurate either way. - * - * The `delete` before the `set` is what buys the position half. `Map.set` - * alone overwrites the value but keeps the FIRST insertion's position, which - * would leave `.agents/` holding the `Codex` row's slot (second) while - * displaying the `Agent Skills` label. That reorders writes and summary lines - * in every multi-tool install, for a change that is only supposed to add a - * label, and it quietly contradicts the generic-fallback-last ordering this - * comment relies on. - */ -export function uniqueShimTargets(): ShimTarget[] { - const byDirectory = new Map(); - for (const shim of SHIM_TARGETS) { - byDirectory.delete(shim.dir); - byDirectory.set(shim.dir, shim); - } - return [...byDirectory.values()]; -} - /** Directory selected by default when no tools are detected. */ export const DEFAULT_SHIM_DIR = ".agents"; @@ -243,9 +226,7 @@ export async function detectSelectedDirectories( const tools = await detectTools(cwd); if (tools.length === 0) return [DEFAULT_SHIM_DIR]; const directories = new Set(tools.map((t) => t.installDir)); - return uniqueShimTargets() - .map((s) => s.dir) - .filter((d) => directories.has(d)); + return SHIM_TARGETS.map((s) => s.dir).filter((d) => directories.has(d)); } // --- Embedded Skills --- @@ -340,10 +321,7 @@ export function buildInstallPlan( }); } - // Iterate the deduplicated catalog: two rows share `.agents/`, and matching - // both would plan that directory twice, so it would be written twice and - // reported twice in the install summary. - for (const shim of uniqueShimTargets()) { + for (const shim of SHIM_TARGETS) { if (!selectedDirectories.includes(shim.dir)) continue; targets.push({ dir: shim.dir, diff --git a/packages/cli/src/wizard/steps/locations.ts b/packages/cli/src/wizard/steps/locations.ts index b35cc5b4..33f05c29 100644 --- a/packages/cli/src/wizard/steps/locations.ts +++ b/packages/cli/src/wizard/steps/locations.ts @@ -4,8 +4,8 @@ import { CANONICAL_DIR, DEFAULT_SHIM_DIR, SHIM_TARGETS, + TOOLS, detectTools, - uniqueShimTargets, type ToolDescriptor, } from "../../install/install"; import { readInstallState } from "../../install/state"; @@ -18,23 +18,40 @@ export interface LocationChoice { hint: string; } +/** + * The `.agents/` row's label: the detected harnesses that read + * `.agents/skills`, in catalog order, then "and other .agents harnesses", + * since many more read it than Taskless detects. + * + * With none detected it names every harness Taskless knows reads it, so a + * Codex or Gemini CLI user still finds their harness on a fresh machine. + */ +export function agentsRowLabel(detected: readonly ToolDescriptor[]): string { + const detectedNames = new Set(detected.map((tool) => tool.name)); + const readers = TOOLS.filter((tool) => tool.readsAgentSkills === true); + const named = readers.filter((tool) => detectedNames.has(tool.name)); + const names = (named.length > 0 ? named : readers).map((tool) => tool.name); + return `${names.join(", ")}, and other .agents harnesses`; +} + /** * Build the tool-selection multiselect options and the pre-checked set from * 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 row is always offered (a peer list); the canonical - * `.taskless/` store is never an entry. Rows may share a directory (Codex and - * Agent Skills both write `.agents/`), so options are one per row while the - * pre-checked set is one per directory. 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. + * One row per catalog directory; the canonical `.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). + * manifest (takes precedence), otherwise `detected` when a tool installing + * into that directory is present, otherwise `not detected`. OpenCode is named + * on the `.agents/` row when detected, since it reads `.agents/skills`, but it + * does not make that row `detected` or pre-check it: it already gets + * `.opencode/`, and both would give it the skill twice. * * @param manifestDirectories Shim directories recorded in the install * manifest; the canonical `.taskless/` directory is ignored if present. @@ -51,39 +68,20 @@ export function locationChoices( manifestDirectories.filter((d) => d !== CANONICAL_DIR) ); - // Pre-checked entries are directories, not rows, so they come from the - // deduplicated catalog. Codex and Agent Skills both offer `.agents/`, and a - // pre-check list naming it twice would hand the multiselect a duplicate - // initial value and carry the duplicate into the install plan. - const preChecked = uniqueShimTargets() - .map((s) => s.dir) - .filter( - (directory) => - installedDirectories.has(directory) || - detectedDirectories.has(directory) - ); + const preChecked = SHIM_TARGETS.map((shim) => shim.dir).filter( + (directory) => + installedDirectories.has(directory) || detectedDirectories.has(directory) + ); const initialValues = preChecked.length > 0 ? preChecked : [DEFAULT_SHIM_DIR]; - // Options, by contrast, are one per row: the whole point of a second - // `.agents/` row is that a Codex user sees the word "Codex". Rows sharing a - // directory share a value, so ticking either one selects that directory - // once, and the picker renders both as checked. - // - // The catch-all row is identified by its own `generic` flag rather than by - // object identity against the deduped catalog. That comparison was correct - // only while `uniqueShimTargets()` returned the original references AND the - // catalog happened to declare "Agent Skills" after "Codex"; either changing - // would have moved the hint onto a named harness with nothing to catch it. const options = SHIM_TARGETS.map((shim) => ({ value: shim.dir, - label: `${shim.label} (${shim.dir}/)`, + label: `${shim.generic === true ? agentsRowLabel(detected) : shim.label} (${shim.dir}/)`, hint: installedDirectories.has(shim.dir) ? "installed" : detectedDirectories.has(shim.dir) ? "detected" - : shim.generic === true - ? "generic agent skills" - : "not detected", + : "not detected", })); return { options, initialValues }; diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts index 85af10d5..c03ed21e 100644 --- a/packages/cli/test/install.test.ts +++ b/packages/cli/test/install.test.ts @@ -127,23 +127,26 @@ describe("detectSelectedDirectories", () => { expect(await detectSelectedDirectories(cwd)).toEqual([".agents"]); }); - it("returns .agents once even though two catalog rows offer it", async () => { - // Codex and Agent Skills are separate rows in the picker pointing at the - // same directory. A duplicate here would flow into the install plan. - await mkdir(join(cwd, ".codex"), { recursive: true }); - await mkdir(join(cwd, ".claude"), { recursive: true }); - const directories = await detectSelectedDirectories(cwd); - expect(directories).toEqual([".claude", ".agents"]); - expect(new Set(directories).size).toBe(directories.length); - }); - - it("keeps .agents last, after a tool declared later in the catalog", async () => { - // The discriminating case for dedupe ORDER, which the test above cannot - // see: `.claude` sorts first either way. `.cursor` is declared after the - // `Codex` row and before the `Agent Skills` row, so it lands between them - // only if the LAST row for a directory wins its position. Deduping with a - // bare `Map.set` keeps the FIRST occurrence's slot, which would put - // `.agents` second and report `[".agents", ".cursor"]`. + it.each([ + ["Gemini CLI", ".gemini"], + ["Crush", ".crush"], + ])("maps %s detection to .agents", async (_name, directory) => { + await mkdir(join(cwd, directory), { recursive: true }); + expect(await detectSelectedDirectories(cwd)).toEqual([".agents"]); + }); + + it("returns .agents once when several .agents harnesses are detected", async () => { + // Codex, Gemini CLI, and Crush all install into `.agents/`. + for (const directory of [".codex", ".gemini", ".crush", ".claude"]) { + await mkdir(join(cwd, directory), { recursive: true }); + } + expect(await detectSelectedDirectories(cwd)).toEqual([ + ".claude", + ".agents", + ]); + }); + + it("returns directories in catalog order", async () => { await mkdir(join(cwd, ".codex"), { recursive: true }); await mkdir(join(cwd, ".cursor"), { recursive: true }); expect(await detectSelectedDirectories(cwd)).toEqual([ @@ -153,13 +156,11 @@ describe("detectSelectedDirectories", () => { }); }); -describe("buildInstallPlan and duplicate shim rows", () => { +describe("buildInstallPlan with a repeated directory", () => { it("plans .agents exactly once when it is selected", () => { const plan = buildInstallPlan([".agents"], getEmbeddedSkills()); const agentsTargets = plan.targets.filter((t) => t.dir === ".agents"); expect(agentsTargets).toHaveLength(1); - // The generic row supplies the shared directory's label: the selection - // carries a directory, never which of the two rows the user ticked. expect(agentsTargets[0]!.label).toBe("Agent Skills"); }); @@ -172,10 +173,7 @@ describe("buildInstallPlan and duplicate shim rows", () => { expect(plan.targets.filter((t) => t.dir === ".claude")).toHaveLength(1); }); - it("writes .agents skill stubs once for a both-rows selection", async () => { - // What a user who ticks both `Codex` and `Agent Skills` gets: the - // multiselect collapses them onto one `.agents` value, and the plan must - // not turn that back into two targets writing the same files twice. + it("writes .agents skill stubs once", async () => { const plan = buildInstallPlan([".agents"], getEmbeddedSkills()); const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); diff --git a/packages/cli/test/wizard-steps.test.ts b/packages/cli/test/wizard-steps.test.ts index ef0b53b9..ed0401d0 100644 --- a/packages/cli/test/wizard-steps.test.ts +++ b/packages/cli/test/wizard-steps.test.ts @@ -38,37 +38,61 @@ describe("locationChoices", () => { it("offers every shim target and never the canonical .taskless store", async () => { const { locationChoices } = await import("../src/wizard/steps/locations"); const values = locationChoices([]).options.map((o) => o.value); - expect(values).toEqual([ - ".claude", - ".agents", - ".cursor", - ".opencode", - ".agents", - ]); + expect(values).toEqual([".claude", ".cursor", ".opencode", ".agents"]); expect(values).not.toContain(".taskless"); }); - it("names Codex in the picker, alongside the generic .agents/ entry", async () => { + it("names the detected .agents harnesses on the .agents/ row", async () => { const { locationChoices } = await import("../src/wizard/steps/locations"); - const labels = locationChoices([]).options.map((o) => o.label); - // Both rows are offered on purpose: a Codex user scans for "Codex", and - // "Agent Skills" still serves harnesses the catalog does not enumerate. - expect(labels).toContain("Codex (.agents/)"); - expect(labels).toContain("Agent Skills (.agents/)"); + const { TOOLS } = await import("../src/install/install"); + const named = (names: string[]) => + locationChoices(TOOLS.filter((t) => names.includes(t.name))).options.find( + (o) => o.value === ".agents" + )?.label; + + // Catalog order, whatever order they were detected in. + expect(named(["Codex", "OpenCode"])).toBe( + "OpenCode, Codex, and other .agents harnesses (.agents/)" + ); + expect(named(["Crush"])).toBe( + "Crush, and other .agents harnesses (.agents/)" + ); }); - it("puts the generic hint on the catch-all row, not on Codex", async () => { - const { locationChoices } = await import("../src/wizard/steps/locations"); - const byLabel = new Map( - locationChoices([]).options.map((o) => [o.label, o.hint]) + // On a fresh machine nothing is detected, and a Codex user scanning for + // "Codex" still has to find it. + it("names every known .agents harness when none is detected", async () => { + const { agentsRowLabel } = await import("../src/wizard/steps/locations"); + expect(agentsRowLabel([])).toBe( + "OpenCode, Codex, Gemini CLI, Crush, and other .agents harnesses" ); - // Both rows write `.agents/`, so nothing about the DIRECTORY separates - // them. The hint has to follow the row's own `generic` flag. Deriving it - // from catalog order or from object identity against the deduped catalog - // put it on whichever row happened to win, which is Codex the moment the - // two rows are reordered. - expect(byLabel.get("Agent Skills (.agents/)")).toBe("generic agent skills"); - expect(byLabel.get("Codex (.agents/)")).toBe("not detected"); + }); + + // OpenCode reads `.agents/skills`, so it is named, but it already gets + // `.opencode/`; pre-checking both would give it the skill twice. + it("names OpenCode on the .agents/ row without pre-checking it", async () => { + const { locationChoices } = await import("../src/wizard/steps/locations"); + const { TOOLS } = await import("../src/install/install"); + const opencode = TOOLS.find((t) => t.name === "OpenCode")!; + + const { options, initialValues } = locationChoices([opencode]); + const agents = options.find((o) => o.value === ".agents"); + expect(agents?.label).toMatch(/^OpenCode, and other/); + expect(agents?.hint).toBe("not detected"); + expect(initialValues).toEqual([".opencode"]); + }); + + it("pre-checks .agents/ for Gemini CLI and Crush", async () => { + const { locationChoices } = await import("../src/wizard/steps/locations"); + const { TOOLS } = await import("../src/install/install"); + for (const name of ["Gemini CLI", "Crush"]) { + const tool = TOOLS.find((t) => t.name === name)!; + const { options, initialValues } = locationChoices([tool]); + expect(initialValues, name).toEqual([".agents"]); + expect(options.find((o) => o.value === ".agents")?.hint, name).toBe( + "detected" + ); + } }); it("pre-checks .agents/ when no tools are detected", async () => { @@ -134,12 +158,7 @@ describe("locationChoices", () => { // and the first-run .agents/ default applies. expect(initialValues).toEqual([".agents"]); expect(options.map((o) => o.value)).not.toContain(".taskless"); - // Two rows share `.agents/`, so the hint is looked up by label: the - // generic hint belongs to the generic row, not to the Codex one. - expect(options.find((o) => o.label.startsWith("Agent Skills"))?.hint).toBe( - "generic agent skills" - ); - expect(options.find((o) => o.label.startsWith("Codex"))?.hint).toBe( + expect(options.find((o) => o.value === ".agents")?.hint).toBe( "not detected" ); }); @@ -149,8 +168,6 @@ describe("locationChoices", () => { const { TOOLS } = await import("../src/install/install"); const codex = TOOLS.find((t) => t.name === "Codex")!; - // Codex and Agent Skills both offer `.agents/`; the pre-checked set is - // directories, so it must name that directory once. const { initialValues } = locationChoices([codex]); expect(initialValues).toEqual([".agents"]); });