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"]); });