diff --git a/.changeset/harness-picker-rows.md b/.changeset/harness-picker-rows.md new file mode 100644 index 00000000..9c63485d --- /dev/null +++ b/.changeset/harness-picker-rows.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +The setup wizard lists every supported harness by name (Claude Code, OpenCode, Cursor, Codex, Gemini CLI, Crush, Devin, and other Agent Skills harnesses), so you no longer need to know which directory your harness reads. Harnesses found in the project are listed first and pre-checked; harnesses found on this machine come next, unchecked. Installing stays project-local. Checking Codex now also writes its permission rule, even in a project without `.codex/`. Devin is new: it installs into `.devin/` when the project has one, and `.agents/` otherwise. diff --git a/openspec/changes/archive/2026-10-20-harness-picker-rows/.openspec.yaml b/openspec/changes/archive/2026-10-20-harness-picker-rows/.openspec.yaml new file mode 100644 index 00000000..9076a660 --- /dev/null +++ b/openspec/changes/archive/2026-10-20-harness-picker-rows/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-20 diff --git a/openspec/changes/archive/2026-10-20-harness-picker-rows/design.md b/openspec/changes/archive/2026-10-20-harness-picker-rows/design.md new file mode 100644 index 00000000..4b73be47 --- /dev/null +++ b/openspec/changes/archive/2026-10-20-harness-picker-rows/design.md @@ -0,0 +1,58 @@ +## Context + +The wizard's tool step (`wizard/steps/locations.ts`) offers one row per install directory from `SHIM_TARGETS`. The harness catalog (`TOOLS` in `install/install.ts`) is used only for detection, and its harnesses surface in the picker only as names on the `.agents/` row, and only when detected. The install plan, the manifest (`install.targets`) and the summary are all keyed by directory, and that part works: it is the picker that hides harnesses. + +## Goals / Non-Goals + +**Goals:** + +- Every catalog harness is a visible, checkable row whatever is detected. +- A harness used on this machine but not yet configured in this project is visible near the top, unchecked. +- Checking a harness runs that harness's own setup (today, only Codex has any beyond its directory). + +**Non-Goals:** + +- Writing anything outside the project because of machine detection. User-level skill directories stay untouched. +- Changing the manifest, the install plan, `update`, or `--no-interactive` selection, all of which stay keyed by directory. +- Preventing a harness from loading the skill twice when it reads another harness's directory (Cursor and OpenCode read `.claude/skills`). That is the harness's choice. + +## Decisions + +**Rows are harnesses; the plan stays directories.** Each row's value is a harness name (or `agents` for the generic row). A pure `resolveSelection(values, projectDetected)` maps checked rows to the directories to install into (deduplicated, in `SHIM_TARGETS` order) and the checked harness names. `buildInstallPlan` and the manifest are unchanged, so two checked rows sharing `.agents/` still produce one target. + +_Alternative:_ record harnesses in the manifest. Rejected: `update` and `--no-interactive` would need a second source of truth beside detection, for no behavior the user asked for. + +**A harness may prefer its own directory when the project already has it.** `ToolDescriptor` gains `ownDir`, used instead of `installDir` when the harness is detected in the project. Only Devin sets it: `installDir: ".agents"`, `ownDir: ".devin"`. Devin's docs show project skills in `.devin/skills/` and also list `.agents/skills/`; a project that already has `.devin/` gets the skill there, and Taskless never creates `.devin/` in a project without it. Since Devin is detected by `.devin/`, "detected in project" and "`.devin/` exists" coincide. `SHIM_TARGETS` gains `{ dir: ".devin", label: "Devin" }` so the plan and summary know the directory. + +**Machine detection is a per-harness function of home and environment.** `ToolDescriptor.machine({ home, env })` returns candidate directories; the harness is machine-detected when any is a directory. Functions rather than signal records, because each override has a different shape (`CLAUDE_CONFIG_DIR` replaces `~/.claude`; `GEMINI_CLI_HOME` replaces the home that `.gemini` sits in): + +| Harness | Directory | Override | +| ----------- | ----------------------------------------- | ------------------------------------------ | +| Claude Code | `~/.claude` | `CLAUDE_CONFIG_DIR` | +| Codex | `~/.codex` | `CODEX_HOME` | +| Cursor | `~/.cursor` | `CURSOR_CONFIG_DIR` | +| OpenCode | `~/.config/opencode` | `OPENCODE_CONFIG_DIR` | +| Gemini CLI | `~/.gemini` | `GEMINI_CLI_HOME` (parent of `.gemini`) | +| Crush | `~/.config/crush`, `~/.local/share/crush` | `CRUSH_GLOBAL_CONFIG`, `CRUSH_GLOBAL_DATA` | +| Devin | `~/.config/devin` | none documented | + +`home` and `env` are injected (the wizard already carries `GlobalTrustEnvironment`), so tests use a temporary directory. Windows-only locations (`%APPDATA%\devin`) are not checked; a miss there only leaves the row in "Other harnesses". + +**Claude Code has no `.agents/` row.** The issue proposed a "Claude Multi-Harness (`.agents`)" row for machines with Claude Code and a project with no Claude files. Claude Code reads `AGENTS.md` in that case but never anything under `.agents/`, so a skill there is invisible to it. Claude Code always installs into `.claude/`, and `readsAgentSkills` is unchanged. + +**Sections, pre-check, hints.** `locationChoices(projectDetected, machineDetected, manifestDirectories)` stays pure and returns `{ groups, initialValues }`: + +- Group order: `Detected in this project`, `Detected on this machine`, `Other harnesses`. A harness detected in both is in the first. The generic row is always in `Other harnesses`. Empty groups are omitted. Rows within a group follow catalog order. +- Pre-checked: every project-detected row; plus, for each manifest-recorded directory no pre-checked row already covers, the row that installs into it (the generic row for `.agents/`). When that leaves nothing checked, the generic row is checked, which is today's `.agents/` default. +- The section header says where a harness was detected, so the `detected`/`not detected` hints go. The only hint is `installed`, on a pre-checked row whose directory the manifest records. +- Labels name the directory the row installs into: `Codex (.agents/)`, `Devin (.devin/)` or `Devin (.agents/)`, `Other Agent Skills harnesses (.agents/)`. + +`groupMultiselect` with `selectableGroups: false` renders headers that are not toggles. The empty-selection loop is unchanged. + +**Codex's rule follows the selection.** `SyncPermissionsOptions.codexDetected` becomes `codex`: in the wizard, whether the Codex row was checked; in `--no-interactive`, whether Codex is detected in the project. `harnessesFor` adds Codex whenever `codex` is true, since checking Codex always selects `.agents/`. Once written, `.codex/rules/taskless.rules` makes `.codex/` exist, so later `--no-interactive` runs keep the rule. + +## Risks / Trade-offs + +- [The picker is longer: eight rows instead of four] → Sections put the relevant rows first; the remaining rows are below. +- [An override env var points at a file, not a directory] → Treated as not detected; it only affects which section the row is in. +- [A user checks Codex in a project without `.codex/`, so `.codex/rules/` is created] → This is the requested behavior; the summary already lists the file before confirming. diff --git a/openspec/changes/archive/2026-10-20-harness-picker-rows/proposal.md b/openspec/changes/archive/2026-10-20-harness-picker-rows/proposal.md new file mode 100644 index 00000000..e7a955c9 --- /dev/null +++ b/openspec/changes/archive/2026-10-20-harness-picker-rows/proposal.md @@ -0,0 +1,31 @@ +## Why + +A Codex user on 0.13 did not find Codex in the tool list and picked `.agents` only because they happened to know Codex reads it; OpsIn said the same earlier ("I didn't see it by default so I didn't think it's available"). Users know which harness they run, not which skill directory it reads. `80f3934a` merged the Codex row into one `.agents/` row labelled with detected harnesses, which falls back to "Agent Skills" when nothing is detected and names no harness at all (#553). + +## What Changes + +- The wizard's tool step offers **one row per harness**: Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Crush, Devin, plus a generic "Other Agent Skills harnesses" row for `.agents/`. Several rows can share a directory; the install plan still has one target per directory. +- The rows are grouped with `groupMultiselect` into **Detected in this project**, **Detected on this machine**, and **Other harnesses**. Project-detected rows are pre-checked; machine-detected rows are listed unchecked; a harness found in both places appears once, in the project section. +- **Machine detection** checks each harness's documented home-directory location, honouring its documented environment override. It changes only which section a row appears in. Installing stays project-local: nothing is written outside the project because of it, and `update`, the manifest and the version pin behave as before. +- **Devin** joins the catalog: detected by `.devin/` in the project and `~/.config/devin/` on the machine. Its docs place project skills in `.devin/skills`, and it also reads `.agents/skills`, so it installs into `.devin/` when the project has `.devin/` and into `.agents/` otherwise. +- **Claude Code always installs into `.claude/`.** Claude Code never reads `.agents/` (code.claude.com/docs/en/memory: "Not read: … anything under a `.agents/` directory"), so the multi-harness Claude row the issue floated is not offered. +- **Checking the Codex row writes Codex's permission rule.** Today `.codex/rules/taskless.rules` is written only when `.agents/` is picked and `.codex/` already exists. `--no-interactive` keeps using project detection, which the written rules file itself satisfies on later runs. +- `agentsRowLabel` and the "one row per directory" reasoning at `SHIM_TARGETS` are removed; the comment records why a row per harness is what makes a harness discoverable. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-init`: the location step offers a row per harness in three sections; machine-level detection; Devin detection and install directory; Codex's permission rule follows the Codex row. + +## Impact + +- `packages/cli/src/install/install.ts`: harness catalog gains home signals and Devin; `SHIM_TARGETS` gains `.devin`; comment at the old line 179 rewritten. +- `packages/cli/src/wizard/steps/locations.ts`: rows, sections, pre-check and hints; `promptLocations` returns directories and checked harnesses. +- `packages/cli/src/wizard/index.ts`, `packages/cli/src/commands/init.ts`, `packages/cli/src/install/permissions.ts`: Codex rule driven by the selection. +- Tests: `test/wizard-steps.test.ts`, `test/install.test.ts`, permissions tests. +- Delivery shape: single PR. The change is one reviewable diff, safe on its own, with a `patch` changeset (pre-1.0, no consumer must react). diff --git a/openspec/changes/archive/2026-10-20-harness-picker-rows/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-20-harness-picker-rows/specs/cli-init/spec.md new file mode 100644 index 00000000..4e41f9d9 --- /dev/null +++ b/openspec/changes/archive/2026-10-20-harness-picker-rows/specs/cli-init/spec.md @@ -0,0 +1,181 @@ +## ADDED Requirements + +### Requirement: Devin detection signals + +Devin SHALL be detected when a `.devin/` directory exists in the project root. Devin reads project skills from `.devin/skills` and from `.agents/skills`. When Devin is detected in the project, its skill SHALL be installed to `.devin/skills//SKILL.md`; otherwise selecting Devin SHALL install to `.agents/skills//SKILL.md`. Taskless SHALL NOT create `.devin/` in a project that does not already have it. Devin SHALL receive no permission rule and no commands. + +#### Scenario: Devin detected by .devin directory + +- **WHEN** `.devin/` exists as a directory in the project root +- **THEN** Devin SHALL be detected +- **AND** its skill SHALL be installed to `.devin/skills/` + +#### Scenario: Devin without .devin installs into .agents + +- **WHEN** the wizard's Devin row is checked and `.devin/` does not exist in the project root +- **THEN** the install plan SHALL contain an `.agents/` target +- **AND** the install plan SHALL NOT contain a `.devin/` target + +### Requirement: Machine-level harness detection + +The wizard SHALL detect which catalog harnesses are present on this machine from each harness's documented home-directory location, honouring its documented environment override: Claude Code `~/.claude` (`CLAUDE_CONFIG_DIR`), Codex `~/.codex` (`CODEX_HOME`), Cursor `~/.cursor` (`CURSOR_CONFIG_DIR`), OpenCode `~/.config/opencode` (`OPENCODE_CONFIG_DIR`), Gemini CLI `.gemini` under the home directory or under `GEMINI_CLI_HOME`, Crush `~/.config/crush` (`CRUSH_GLOBAL_CONFIG`) or `~/.local/share/crush` (`CRUSH_GLOBAL_DATA`), Devin `~/.config/devin`. A harness SHALL be machine-detected when any of its locations is a directory. + +Machine detection SHALL only decide which section of the tool-selection step a harness's row is shown in. It SHALL NOT pre-check a row, SHALL NOT change where a skill is installed, and SHALL NOT cause any file to be written outside the project root. The home directory and environment SHALL be passed in, so detection is testable without the real home directory. + +#### Scenario: A harness in the home directory is machine-detected + +- **WHEN** `~/.codex/` exists and the project has no Codex signal +- **THEN** Codex SHALL be machine-detected + +#### Scenario: An override relocates the home signal + +- **WHEN** `CLAUDE_CONFIG_DIR` names an existing directory and `~/.claude/` does not exist +- **THEN** Claude Code SHALL be machine-detected + +#### Scenario: Machine detection writes nothing outside the project + +- **WHEN** the wizard completes an install with machine-detected harnesses checked +- **THEN** every skill file written SHALL be inside the project root + +### Requirement: Wizard offers a row per harness + +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 catalog harness, whatever is detected: Claude Code, OpenCode, Cursor, Codex, Gemini CLI, Crush, Devin, in catalog order, and a final generic row `Other Agent Skills harnesses (.agents/)`. Each row's label SHALL name the harness and the directory it installs into, for example `Codex (.agents/)`. Several rows MAY install into the same directory. + +The rows SHALL be grouped into sections, in this order, omitting an empty section: `Detected in this project` (harnesses with a project detection signal), `Detected on this machine` (harnesses machine-detected but not detected in the project), and `Other harnesses` (the rest, and always the generic row). A harness detected in both the project and the machine SHALL appear once, in `Detected in this project`. Section headers SHALL NOT be selectable. + +The pre-checked rows SHALL be (a) every row in `Detected in this project`, and (b) for each directory recorded as a target in the install manifest (`install.targets`) that no row from (a) installs into, the row that installs into it, which for `.agents/` is the generic row. When neither applies, the generic row SHALL be pre-checked as the first-run default. Machine-detected rows SHALL NOT be pre-checked. A pre-checked row whose directory is recorded in the manifest SHALL carry the hint `installed`; no other hint is shown. 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. + +Claude Code SHALL always install into `.claude/`, since Claude Code does not read skills from `.agents/`. + +The checked rows SHALL resolve to the set of directories they install into, each directory once, and each selected directory SHALL produce exactly one `reference` stub target; the resulting install plan always contains the single `taskless` skill and no command. Unchecking every row that installs into a manifest-recorded directory 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 wizard SHALL require at least one checked row. The `.agents/` target SHALL be reported in the install summary as `Agent Skills`. The function that maps detected harnesses and manifest targets to rows SHALL be pure: it SHALL receive the project-detected harnesses, the machine-detected harnesses and the manifest target list as arguments and SHALL perform no filesystem access, so the mapping is unit-testable. + +#### Scenario: Codex is visible with nothing detected + +- **WHEN** the wizard renders the tool-selection step with no harness detected in the project or on the machine +- **THEN** a row labelled `Codex (.agents/)` SHALL be offered +- **AND** a row SHALL be offered for every catalog harness + +#### Scenario: Project-detected harnesses are pre-checked in their own section + +- **WHEN** `.claude/` and `.codex/` exist in the project root +- **THEN** the Claude Code and Codex rows SHALL be in `Detected in this project` +- **AND** both SHALL be pre-checked + +#### Scenario: Machine-detected harnesses are listed unchecked + +- **WHEN** Gemini CLI is machine-detected and not detected in the project +- **THEN** the Gemini CLI row SHALL be in `Detected on this machine` +- **AND** it SHALL NOT be pre-checked + +#### Scenario: A harness detected in both places appears once + +- **WHEN** Claude Code is detected in the project and on the machine +- **THEN** exactly one Claude Code row SHALL be offered, in `Detected in this project` + +#### Scenario: Two checked rows sharing a directory install once + +- **WHEN** the Codex and Gemini CLI rows are both checked +- **THEN** the install plan SHALL contain exactly one `.agents/` target + +#### Scenario: Manifest-recorded locations are pre-checked + +- **WHEN** the install manifest records `.agents/` as a target and no harness that installs into `.agents/` is detected in the project +- **THEN** the generic row SHALL be pre-checked +- **AND** it SHALL carry the `installed` hint + +#### Scenario: Unchecking an installed location removes its stubs + +- **WHEN** the install manifest records `.claude/` and `.agents/` as targets and the user unchecks Claude Code while leaving a row that installs into `.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: The generic row is the default when nothing is detected or installed + +- **WHEN** no harness is detected in the project and the install manifest records no tool-directory targets +- **THEN** the generic `Other Agent Skills harnesses (.agents/)` row SHALL be the only pre-checked row + +#### Scenario: Claude Code on the machine only still installs into .claude + +- **WHEN** Claude Code is machine-detected, the project has no Claude Code signal, and the user checks the Claude Code row +- **THEN** the install plan SHALL contain a `.claude/` target and no `.agents/` target + +#### Scenario: Canonical store is not a selectable entry + +- **WHEN** the wizard renders the tool-selection step +- **THEN** `.taskless/` SHALL NOT appear as a selectable option +- **AND** no row SHALL be pre-checked because the manifest records `.taskless/` + +## MODIFIED Requirements + +### Requirement: Init allows the installed CLI in each harness's permission file + +`taskless init`, in the wizard and in `--no-interactive`, SHALL write a rule allowing exactly the installed CLI invocation to the project permission file of each harness it installs the skill for, from this table: + +| Harness | When | File | Rule | +| --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | `.claude/` selected | `.claude/settings.json` | `permissions.allow`: `Bash( *)` | +| Cursor (editor) | `.cursor/` selected | `.cursor/permissions.json` | `terminalAllowlist`: `` | +| Cursor (CLI) | `.cursor/` selected | `.cursor/cli.json` | `permissions.allow`: `Shell(npx:)` and `Shell(npx: *)` | +| OpenCode | `.opencode/` selected | the existing `opencode.json` or `opencode.jsonc`, at the root or under `.opencode/`, else `.opencode/opencode.json` | object form: `permission.bash[" *"]: "allow"`; array form: `{ "action": "shell", "resource": " *", "effect": "allow" }` | +| Codex | the wizard's Codex row is checked; in `--no-interactive`, Codex is detected | `.codex/rules/taskless.rules` | `prefix_rule(pattern=["npx", ""], decision="allow")` | + +Each rule SHALL use that harness's word-bounded form, so it matches the invocation alone or followed by arguments and SHALL NOT match a longer version string. No rule SHALL wildcard the package name or version. The generic `.agents/` target has no permission file and SHALL receive none. The table is exhaustive: `init` SHALL NOT write a permission rule for any harness not listed in it. Harnesses that read the skill from `.agents/` (other than Codex), harnesses with no file-based allowlist such as Cline, and harnesses whose allowlist can only allow a whole tool such as Crush SHALL receive none. + +In OpenCode's object form the entry SHALL be placed after every existing key, since the last matching rule wins; in the array form it SHALL be placed first. + +How each edit is reported is defined by the requirement that init reports every permission edit. + +#### Scenario: An unlisted harness gets no rule + +- **WHEN** `init` runs in a project with a harness configuration directory that is not in the table +- **THEN** `init` SHALL NOT create or edit any file in it + +#### Scenario: Claude Code gets a pinned rule + +- **WHEN** `init` installs for `.claude/` with invocation `npx @taskless/cli@0.13.0` +- **THEN** `.claude/settings.json` SHALL contain `Bash(npx @taskless/cli@0.13.0 *)` in `permissions.allow` +- **AND** every other key and entry in the file SHALL be unchanged + +#### Scenario: The rule does not match another version + +- **WHEN** the rule for `npx @taskless/cli@0.13.0` is written for any harness +- **THEN** under that harness's documented matching it SHALL NOT match `npx @taskless/cli@0.13.01` + +#### Scenario: OpenCode object form puts the rule last + +- **WHEN** `opencode.json` has `permission.bash` with `"*": "ask"` +- **THEN** the Taskless key SHALL follow `"*"` in the written object + +#### Scenario: OpenCode array form puts the rule first + +- **WHEN** the OpenCode file uses the array form under `permissions` +- **THEN** the Taskless entry SHALL be the first element + +#### Scenario: Codex gets a rules file only when detected + +- **WHEN** `init --no-interactive` runs and no Codex detection signal is present +- **THEN** `init` SHALL NOT create `.codex/` + +#### Scenario: Checking the Codex row writes its rules file + +- **WHEN** the wizard's Codex row is the only checked row and the project has no `.codex/` +- **THEN** the skill SHALL be installed to `.agents/skills/` +- **AND** `.codex/rules/taskless.rules` SHALL be written + +#### Scenario: Another .agents row does not write Codex's rules file + +- **WHEN** the wizard's generic or Gemini CLI row is checked, the Codex row is not, and the project has no `.codex/` +- **THEN** `init` SHALL NOT create `.codex/` + +#### Scenario: OpenCode comments survive + +- **WHEN** the OpenCode file is `opencode.jsonc` with comments +- **THEN** the written file SHALL keep every comment + +## REMOVED Requirements + +### Requirement: Wizard prompts the user to choose install locations + +**Reason**: Its one-row-per-directory picker named a harness only when detected, so a Codex user with nothing detected saw only "Agent Skills" and concluded Codex was unsupported (#553). + +**Migration**: Replaced by "Wizard offers a row per harness", which keeps the manifest pre-check, the at-least-one rule, the `.taskless/` exclusion and the one-target-per-directory plan. diff --git a/openspec/changes/archive/2026-10-20-harness-picker-rows/tasks.md b/openspec/changes/archive/2026-10-20-harness-picker-rows/tasks.md new file mode 100644 index 00000000..222ac2de --- /dev/null +++ b/openspec/changes/archive/2026-10-20-harness-picker-rows/tasks.md @@ -0,0 +1,20 @@ +## 1. Catalog + +- [x] 1.1 Add `machine({ home, env })` to every `ToolDescriptor` per the design's table, with a source comment per harness; add `detectMachineTools(environment)`; verify with tests over a temporary home, including an override and an override naming a missing directory +- [x] 1.2 Add Devin (`.devin/` signal, `installDir: ".agents"`, `ownDir: ".devin"`) and `{ dir: ".devin", label: "Devin" }` to `SHIM_TARGETS`; resolve `ownDir` for project-detected tools in `detectSelectedDirectories`; verify with `detectTools`/`detectSelectedDirectories` tests +- [x] 1.3 Rewrite the `SHIM_TARGETS` comment (formerly `install.ts:179`) to say why a row per harness is what makes a harness discoverable; drop `ShimTarget.generic` + +## 2. Picker + +- [x] 2.1 Replace `agentsRowLabel` and the per-directory rows in `locationChoices` with harness rows, sections, pre-check and the `installed` hint per the spec; add pure `resolveSelection`; rewrite `test/wizard-steps.test.ts` to cover every scenario in the modified requirement +- [x] 2.2 `promptLocations` uses `groupMultiselect` (`selectableGroups: false`), takes the global-trust environment for machine detection, and returns `{ directories, harnesses }` + +## 3. Codex permissions + +- [x] 3.1 Replace `codexDetected` with `codex` in `SyncPermissionsOptions` and `harnessesFor`; the wizard passes whether the Codex row was checked, `--no-interactive` passes whether Codex is detected; verify with permissions tests for both new scenarios + +## 4. Verification and release + +- [x] 4.1 `pnpm typecheck`, `pnpm lint`, `pnpm --filter @taskless/cli test` +- [x] 4.2 Run the built wizard in a scratch project with a scratch `HOME` (Codex on the machine only), check the Codex row alone, and confirm `.agents/skills/taskless/SKILL.md` and `.codex/rules/taskless.rules` are written and nothing outside the project changes +- [x] 4.3 Add a `patch` changeset; archive the change on this PR diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 2900998b..519607ab 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -358,87 +358,6 @@ The wizard SHALL begin by rendering an ASCII rendition of the Taskless wordmark - **WHEN** `NO_COLOR=1` is set in the environment - **THEN** the banner SHALL be rendered without ANSI color escapes -### 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/)`. The row SHALL name only detected harnesses: an undetected harness SHALL NOT be named, since the harnesses that read `.agents/skills` outnumber what fits on one row. When none of them is detected, the row SHALL be labelled `Agent Skills (.agents/)`. - -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 -- **THEN** the `.agents/` row's label SHALL name `Codex` -- **AND** exactly one entry SHALL be offered for `.agents/` - -#### Scenario: Undetected .agents harnesses are not named - -- **WHEN** the wizard renders the tool-selection multiselect with no `.agents` harness detected -- **THEN** the `.agents/` row SHALL be labelled `Agent Skills (.agents/)` -- **AND** the label SHALL NOT name OpenCode, Codex, Gemini CLI, or Crush - -#### 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 - ### 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: @@ -933,13 +852,13 @@ Each pin SHALL be reported through the same notice and the same `pinnedCli` enve `taskless init`, in the wizard and in `--no-interactive`, SHALL write a rule allowing exactly the installed CLI invocation to the project permission file of each harness it installs the skill for, from this table: -| Harness | When | File | Rule | -| --------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| Claude Code | `.claude/` selected | `.claude/settings.json` | `permissions.allow`: `Bash( *)` | -| Cursor (editor) | `.cursor/` selected | `.cursor/permissions.json` | `terminalAllowlist`: `` | -| Cursor (CLI) | `.cursor/` selected | `.cursor/cli.json` | `permissions.allow`: `Shell(npx:)` and `Shell(npx: *)` | -| OpenCode | `.opencode/` selected | the existing `opencode.json` or `opencode.jsonc`, at the root or under `.opencode/`, else `.opencode/opencode.json` | object form: `permission.bash[" *"]: "allow"`; array form: `{ "action": "shell", "resource": " *", "effect": "allow" }` | -| Codex | `.agents/` selected and Codex detected | `.codex/rules/taskless.rules` | `prefix_rule(pattern=["npx", ""], decision="allow")` | +| Harness | When | File | Rule | +| --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | `.claude/` selected | `.claude/settings.json` | `permissions.allow`: `Bash( *)` | +| Cursor (editor) | `.cursor/` selected | `.cursor/permissions.json` | `terminalAllowlist`: `` | +| Cursor (CLI) | `.cursor/` selected | `.cursor/cli.json` | `permissions.allow`: `Shell(npx:)` and `Shell(npx: *)` | +| OpenCode | `.opencode/` selected | the existing `opencode.json` or `opencode.jsonc`, at the root or under `.opencode/`, else `.opencode/opencode.json` | object form: `permission.bash[" *"]: "allow"`; array form: `{ "action": "shell", "resource": " *", "effect": "allow" }` | +| Codex | the wizard's Codex row is checked; in `--no-interactive`, Codex is detected | `.codex/rules/taskless.rules` | `prefix_rule(pattern=["npx", ""], decision="allow")` | Each rule SHALL use that harness's word-bounded form, so it matches the invocation alone or followed by arguments and SHALL NOT match a longer version string. No rule SHALL wildcard the package name or version. The generic `.agents/` target has no permission file and SHALL receive none. The table is exhaustive: `init` SHALL NOT write a permission rule for any harness not listed in it. Harnesses that read the skill from `.agents/` (other than Codex), harnesses with no file-based allowlist such as Cline, and harnesses whose allowlist can only allow a whole tool such as Crush SHALL receive none. @@ -975,7 +894,18 @@ How each edit is reported is defined by the requirement that init reports every #### Scenario: Codex gets a rules file only when detected -- **WHEN** `.agents/` is selected and no Codex detection signal is present +- **WHEN** `init --no-interactive` runs and no Codex detection signal is present +- **THEN** `init` SHALL NOT create `.codex/` + +#### Scenario: Checking the Codex row writes its rules file + +- **WHEN** the wizard's Codex row is the only checked row and the project has no `.codex/` +- **THEN** the skill SHALL be installed to `.agents/skills/` +- **AND** `.codex/rules/taskless.rules` SHALL be written + +#### Scenario: Another .agents row does not write Codex's rules file + +- **WHEN** the wizard's generic or Gemini CLI row is checked, the Codex row is not, and the project has no `.codex/` - **THEN** `init` SHALL NOT create `.codex/` #### Scenario: OpenCode comments survive @@ -1315,3 +1245,108 @@ The notice SHALL NOT be printed when every owned path is committed, when the pro - **WHEN** a user runs `taskless init` in a directory that is not in a git repository - **THEN** no notice SHALL be printed + +### Requirement: Devin detection signals + +Devin SHALL be detected when a `.devin/` directory exists in the project root. Devin reads project skills from `.devin/skills` and from `.agents/skills`. When Devin is detected in the project, its skill SHALL be installed to `.devin/skills//SKILL.md`; otherwise selecting Devin SHALL install to `.agents/skills//SKILL.md`. Taskless SHALL NOT create `.devin/` in a project that does not already have it. Devin SHALL receive no permission rule and no commands. + +#### Scenario: Devin detected by .devin directory + +- **WHEN** `.devin/` exists as a directory in the project root +- **THEN** Devin SHALL be detected +- **AND** its skill SHALL be installed to `.devin/skills/` + +#### Scenario: Devin without .devin installs into .agents + +- **WHEN** the wizard's Devin row is checked and `.devin/` does not exist in the project root +- **THEN** the install plan SHALL contain an `.agents/` target +- **AND** the install plan SHALL NOT contain a `.devin/` target + +### Requirement: Machine-level harness detection + +The wizard SHALL detect which catalog harnesses are present on this machine from each harness's documented home-directory location, honouring its documented environment override: Claude Code `~/.claude` (`CLAUDE_CONFIG_DIR`), Codex `~/.codex` (`CODEX_HOME`), Cursor `~/.cursor` (`CURSOR_CONFIG_DIR`), OpenCode `~/.config/opencode` (`OPENCODE_CONFIG_DIR`), Gemini CLI `.gemini` under the home directory or under `GEMINI_CLI_HOME`, Crush `~/.config/crush` (`CRUSH_GLOBAL_CONFIG`) or `~/.local/share/crush` (`CRUSH_GLOBAL_DATA`), Devin `~/.config/devin`. A harness SHALL be machine-detected when any of its locations is a directory. + +Machine detection SHALL only decide which section of the tool-selection step a harness's row is shown in. It SHALL NOT pre-check a row, SHALL NOT change where a skill is installed, and SHALL NOT cause any file to be written outside the project root. The home directory and environment SHALL be passed in, so detection is testable without the real home directory. + +#### Scenario: A harness in the home directory is machine-detected + +- **WHEN** `~/.codex/` exists and the project has no Codex signal +- **THEN** Codex SHALL be machine-detected + +#### Scenario: An override relocates the home signal + +- **WHEN** `CLAUDE_CONFIG_DIR` names an existing directory and `~/.claude/` does not exist +- **THEN** Claude Code SHALL be machine-detected + +#### Scenario: Machine detection writes nothing outside the project + +- **WHEN** the wizard completes an install with machine-detected harnesses checked +- **THEN** every skill file written SHALL be inside the project root + +### Requirement: Wizard offers a row per harness + +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 catalog harness, whatever is detected: Claude Code, OpenCode, Cursor, Codex, Gemini CLI, Crush, Devin, in catalog order, and a final generic row `Other Agent Skills harnesses (.agents/)`. Each row's label SHALL name the harness and the directory it installs into, for example `Codex (.agents/)`. Several rows MAY install into the same directory. + +The rows SHALL be grouped into sections, in this order, omitting an empty section: `Detected in this project` (harnesses with a project detection signal), `Detected on this machine` (harnesses machine-detected but not detected in the project), and `Other harnesses` (the rest, and always the generic row). A harness detected in both the project and the machine SHALL appear once, in `Detected in this project`. Section headers SHALL NOT be selectable. + +The pre-checked rows SHALL be (a) every row in `Detected in this project`, and (b) for each directory recorded as a target in the install manifest (`install.targets`) that no row from (a) installs into, the row that installs into it, which for `.agents/` is the generic row. When neither applies, the generic row SHALL be pre-checked as the first-run default. Machine-detected rows SHALL NOT be pre-checked. A pre-checked row whose directory is recorded in the manifest SHALL carry the hint `installed`; no other hint is shown. 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. + +Claude Code SHALL always install into `.claude/`, since Claude Code does not read skills from `.agents/`. + +The checked rows SHALL resolve to the set of directories they install into, each directory once, and each selected directory SHALL produce exactly one `reference` stub target; the resulting install plan always contains the single `taskless` skill and no command. Unchecking every row that installs into a manifest-recorded directory 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 wizard SHALL require at least one checked row. The `.agents/` target SHALL be reported in the install summary as `Agent Skills`. The function that maps detected harnesses and manifest targets to rows SHALL be pure: it SHALL receive the project-detected harnesses, the machine-detected harnesses and the manifest target list as arguments and SHALL perform no filesystem access, so the mapping is unit-testable. + +#### Scenario: Codex is visible with nothing detected + +- **WHEN** the wizard renders the tool-selection step with no harness detected in the project or on the machine +- **THEN** a row labelled `Codex (.agents/)` SHALL be offered +- **AND** a row SHALL be offered for every catalog harness + +#### Scenario: Project-detected harnesses are pre-checked in their own section + +- **WHEN** `.claude/` and `.codex/` exist in the project root +- **THEN** the Claude Code and Codex rows SHALL be in `Detected in this project` +- **AND** both SHALL be pre-checked + +#### Scenario: Machine-detected harnesses are listed unchecked + +- **WHEN** Gemini CLI is machine-detected and not detected in the project +- **THEN** the Gemini CLI row SHALL be in `Detected on this machine` +- **AND** it SHALL NOT be pre-checked + +#### Scenario: A harness detected in both places appears once + +- **WHEN** Claude Code is detected in the project and on the machine +- **THEN** exactly one Claude Code row SHALL be offered, in `Detected in this project` + +#### Scenario: Two checked rows sharing a directory install once + +- **WHEN** the Codex and Gemini CLI rows are both checked +- **THEN** the install plan SHALL contain exactly one `.agents/` target + +#### Scenario: Manifest-recorded locations are pre-checked + +- **WHEN** the install manifest records `.agents/` as a target and no harness that installs into `.agents/` is detected in the project +- **THEN** the generic row SHALL be pre-checked +- **AND** it SHALL carry the `installed` hint + +#### Scenario: Unchecking an installed location removes its stubs + +- **WHEN** the install manifest records `.claude/` and `.agents/` as targets and the user unchecks Claude Code while leaving a row that installs into `.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: The generic row is the default when nothing is detected or installed + +- **WHEN** no harness is detected in the project and the install manifest records no tool-directory targets +- **THEN** the generic `Other Agent Skills harnesses (.agents/)` row SHALL be the only pre-checked row + +#### Scenario: Claude Code on the machine only still installs into .claude + +- **WHEN** Claude Code is machine-detected, the project has no Claude Code signal, and the user checks the Claude Code row +- **THEN** the install plan SHALL contain a `.claude/` target and no `.agents/` target + +#### Scenario: Canonical store is not a selectable entry + +- **WHEN** the wizard renders the tool-selection step +- **THEN** `.taskless/` SHALL NOT appear as a selectable option +- **AND** no row SHALL be pre-checked because the manifest records `.taskless/` diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index f2c79b52..03d3f05c 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -193,8 +193,9 @@ 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, and the -`.agents` harnesses Codex, Gemini CLI, and Crush), asks +offers a row per harness (Claude Code, OpenCode, Cursor, Codex, Gemini +CLI, Crush, Devin, and other `.agents` harnesses), listing the ones found in +the project first, pre-checked, then the ones found on this machine, 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/commands/init.ts b/packages/cli/src/commands/init.ts index fa0bb220..c90a7694 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -549,7 +549,7 @@ async function runNonInteractive( .map((target) => target.dir); const permissions = await syncPermissions(cwd, { selected, - codexDetected: detected.some((tool) => tool.name === "Codex"), + codex: detected.some((tool) => tool.name === "Codex"), invocation: buildInvocation(), launcher: buildLauncherInvocation(), write: true, diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 1799c255..8e988dbd 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -20,6 +20,7 @@ import { } from "./canonical"; import { CLI_VERSION } from "../version"; import { parseFrontmatter } from "./frontmatter"; +import type { GlobalTrustEnvironment } from "./global-trust"; import { computeInstallDiff, readInstallState, @@ -55,20 +56,25 @@ export interface DetectionSignal { } /** - * A detectable AI tool. Used only for detection — the install destination is - * `installDir`, which is matched against the fixed {@link SHIM_TARGETS} - * catalog to decide which directories to pre-check in the wizard. + * A harness the wizard offers a row for. `detect` finds it in the project, + * `machine` on this machine; the skill goes into `installDir`, one of the + * {@link SHIM_TARGETS} directories. */ export interface ToolDescriptor { name: string; detect: DetectionSignal[]; + /** + * The directories whose presence means the harness is used on this + * machine, honouring the override its docs name. Only decides which section + * of the picker the harness is listed in: nothing is written there. + */ + machine: (environment: GlobalTrustEnvironment) => string[]; 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. + * The harness's own directory, used instead of `installDir` when the + * harness is detected in the project. Taskless never creates it. */ - readsAgentSkills?: boolean; + ownDir?: string; } export interface EmbeddedSkill { @@ -91,7 +97,20 @@ export interface ToolStatus { skills: SkillStatus[]; } -// --- Tool Registry (detection only) --- +// --- Tool Registry --- + +/** + * `env[name]` when it is set, else `fallback`. Set but empty counts as unset, + * or the path would resolve against the cwd. + */ +function override( + env: GlobalTrustEnvironment["env"], + name: string, + fallback: string +): string { + const value = env[name]; + return value === undefined || value === "" ? fallback : value; +} export const TOOLS: ToolDescriptor[] = [ { @@ -100,6 +119,14 @@ export const TOOLS: ToolDescriptor[] = [ { type: "directory", path: ".claude" }, { type: "file", path: "CLAUDE.md" }, ], + // code.claude.com/docs/en/env-vars: `CLAUDE_CONFIG_DIR` "Override the + // configuration directory (default: `~/.claude`)". + machine: ({ home, env }) => [ + override(env, "CLAUDE_CONFIG_DIR", join(home, ".claude")), + ], + // Claude Code never reads `.agents/` (code.claude.com/docs/en/memory: + // "Not read: ... anything under a `.agents/` directory"), so it always + // gets `.claude/`, even in a project with no Claude files yet. installDir: ".claude", }, { @@ -109,9 +136,11 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: "opencode.jsonc" }, { type: "file", path: "opencode.json" }, ], + // opencode.ai/docs/config: global config in `~/.config/opencode/`. + machine: ({ home, env }) => [ + override(env, "OPENCODE_CONFIG_DIR", join(home, ".config", "opencode")), + ], installDir: ".opencode", - // opencode.ai/docs/skills: "Project agent-compatible: `.agents/skills`". - readsAgentSkills: true, }, { name: "Cursor", @@ -119,6 +148,11 @@ export const TOOLS: ToolDescriptor[] = [ { type: "directory", path: ".cursor" }, { type: "file", path: ".cursorrules" }, ], + // cursor.com/docs/cli/reference/configuration: `~/.cursor/cli-config.json`, + // relocated by `CURSOR_CONFIG_DIR`. + machine: ({ home, env }) => [ + override(env, "CURSOR_CONFIG_DIR", join(home, ".cursor")), + ], installDir: ".cursor", }, { @@ -127,8 +161,11 @@ export const TOOLS: ToolDescriptor[] = [ { type: "directory", path: ".codex" }, { type: "file", path: ".codex/config.toml" }, ], + // Codex docs, environment variables: `CODEX_HOME` defaults to `~/.codex`. + machine: ({ home, env }) => [ + override(env, "CODEX_HOME", join(home, ".codex")), + ], installDir: ".agents", - readsAgentSkills: true, }, { // gemini-cli docs/cli/skills.md: workspace skills live in `.gemini/skills/` @@ -138,8 +175,12 @@ export const TOOLS: ToolDescriptor[] = [ { type: "directory", path: ".gemini" }, { type: "file", path: "GEMINI.md" }, ], + // gemini-cli docs/reference/configuration.md: `GEMINI_CLI_HOME` names the + // directory "The CLI will create a `.gemini` folder inside". + machine: ({ home, env }) => [ + join(override(env, "GEMINI_CLI_HOME", home), ".gemini"), + ], installDir: ".agents", - readsAgentSkills: true, }, { // charmbracelet/crush README: project skills include `.agents/skills`. @@ -151,44 +192,58 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: "crush.json" }, { type: "file", path: ".crush.json" }, ], + // charmbracelet/crush README: config in `~/.config/crush/`, state in + // `~/.local/share/crush/`, moved by `CRUSH_GLOBAL_CONFIG` and + // `CRUSH_GLOBAL_DATA`. + machine: ({ home, env }) => [ + override(env, "CRUSH_GLOBAL_CONFIG", join(home, ".config", "crush")), + override( + env, + "CRUSH_GLOBAL_DATA", + join(home, ".local", "share", "crush") + ), + ], + installDir: ".agents", + }, + { + // docs.devin.ai/cli/reference/configuration/global-vs-local: user config + // in `~/.config/devin/`, project config in `.devin/`. Its skills page + // places project skills in `.devin/skills/` and also reads + // `.agents/skills/`, so a project that already has `.devin/` gets the + // skill there and any other gets `.agents/`. + name: "Devin", + detect: [{ type: "directory", path: ".devin" }], + machine: ({ home }) => [join(home, ".config", "devin")], installDir: ".agents", - readsAgentSkills: true, + ownDir: ".devin", }, ]; /** - * A selectable stub destination. The wizard offers this fixed catalog as the - * "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. + * A stub destination: the install plan, the manifest and the summary are + * keyed by directory, however many harnesses share one. */ export interface ShimTarget { /** Directory the stub is written into, relative to the project root. */ dir: string; - /** Human-readable label for summaries; the picker may name more. */ + /** Human-readable label for summaries. */ label: string; - /** - * 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; } /** - * 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`. + * The directories, in plan order. The picker does not offer these: it offers + * a row per harness in {@link TOOLS}, several of which install into + * `.agents/`. Users know which harness they run, not which skill directory it + * reads, so a harness without a row of its own is a harness they conclude + * Taskless does not support. A single `.agents/` row labelled "Agent Skills" + * cost exactly that: a Codex user did not find Codex (#553). */ export const SHIM_TARGETS: readonly ShimTarget[] = [ { dir: ".claude", label: "Claude Code" }, { dir: ".cursor", label: "Cursor" }, { dir: ".opencode", label: "OpenCode" }, - { dir: ".agents", label: "Agent Skills", generic: true }, + { dir: ".devin", label: "Devin" }, + { dir: ".agents", label: "Agent Skills" }, ]; /** Directory selected by default when no tools are detected. */ @@ -223,6 +278,38 @@ export async function detectTools(cwd: string): Promise { return results.filter((t): t is ToolDescriptor => t !== undefined); } +/** The harnesses used on this machine, by their home-directory locations. */ +export async function detectMachineTools( + environment: GlobalTrustEnvironment +): Promise { + const results = await Promise.all( + TOOLS.map(async (tool) => { + const found = await Promise.all( + tool.machine(environment).map((path) => + stat(path) + .then((s) => s.isDirectory()) + .catch(() => false) + ) + ); + return found.some(Boolean) ? tool : undefined; + }) + ); + return results.filter((t): t is ToolDescriptor => t !== undefined); +} + +/** + * Where `tool` installs: its own directory when it is detected in the + * project and has one, else its `installDir`. + */ +export function installDirectoryFor( + tool: ToolDescriptor, + projectDetected: boolean +): string { + return projectDetected && tool.ownDir !== undefined + ? tool.ownDir + : tool.installDir; +} + /** * The shim directories to pre-select for a project: the install directories * of every detected tool, or `.agents/` when nothing is detected. @@ -232,7 +319,7 @@ export async function detectSelectedDirectories( ): Promise { const tools = await detectTools(cwd); if (tools.length === 0) return [DEFAULT_SHIM_DIR]; - const directories = new Set(tools.map((t) => t.installDir)); + const directories = new Set(tools.map((t) => installDirectoryFor(t, true))); return SHIM_TARGETS.map((s) => s.dir).filter((d) => directories.has(d)); } diff --git a/packages/cli/src/install/permissions.ts b/packages/cli/src/install/permissions.ts index 9371998a..02416b73 100644 --- a/packages/cli/src/install/permissions.ts +++ b/packages/cli/src/install/permissions.ts @@ -708,7 +708,7 @@ const openCode: Harness = { * allow), so a deny elsewhere still wins without this file needing to look * for it. */ -const codex: Harness = { +const codexHarness: Harness = { ...fixed(CODEX_FILE), rules: (invocation) => [ `prefix_rule(pattern = ${JSON.stringify(invocation.split(" "))}, decision = "allow")`, @@ -761,23 +761,19 @@ const codex: Harness = { }, }; -const HARNESSES = [claudeCode, cursorEditor, cursorCli, openCode, codex]; +const HARNESSES = [claudeCode, cursorEditor, cursorCli, openCode, codexHarness]; /** * The harnesses that get a rule for this install: one per directory Taskless - * installed the skill into, and Codex only when `.agents/` was selected AND - * Codex is in the project, since `.agents/` is read by many harnesses and - * names none of them. + * installed the skill into, and Codex when it was chosen, since `.agents/` is + * read by many harnesses and names none of them. */ -function harnessesFor( - selected: readonly string[], - codexDetected: boolean -): Harness[] { +function harnessesFor(selected: readonly string[], codex: boolean): Harness[] { const harnesses: Harness[] = []; if (selected.includes(".claude")) harnesses.push(claudeCode); if (selected.includes(".cursor")) harnesses.push(cursorEditor, cursorCli); if (selected.includes(".opencode")) harnesses.push(openCode); - if (selected.includes(".agents") && codexDetected) harnesses.push(codex); + if (codex) harnesses.push(codexHarness); return harnesses; } @@ -832,8 +828,11 @@ async function writeRecords(cwd: string, records: Records): Promise { export interface SyncPermissionsOptions { /** Tool directories the skill is installed into. */ selected: readonly string[]; - /** Whether Codex is present in the project. */ - codexDetected: boolean; + /** + * Whether Codex gets its rule: the wizard's Codex row was checked, or, + * without the wizard, Codex is detected in the project. + */ + codex: boolean; /** The invocation to allow: this build's pinned CLI. */ invocation: string; /** @@ -872,7 +871,7 @@ export async function syncPermissions( return name !== undefined && name !== own; }; const desired = new Map(); - for (const harness of harnessesFor(options.selected, options.codexDetected)) { + for (const harness of harnessesFor(options.selected, options.codex)) { desired.set(await harness.locate(cwd), { harness, rules: [ diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index b6254230..06411127 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -8,7 +8,6 @@ import { ensureTasklessDirectory } from "../filesystem/directory"; import { applyInstallPlan, buildInstallPlan, - detectTools, getEmbeddedSkills, planToStateTargets, } from "../install/install"; @@ -70,8 +69,17 @@ export async function runWizard( console.error(renderIntro()); intro(" Taskless setup "); + const globalTrustEnvironment = options.globalTrustEnvironment ?? { + home: homedir(), + env: process.env, + }; + try { - locations = await promptLocations(options.cwd); + const selection = await promptLocations( + options.cwd, + globalTrustEnvironment + ); + locations = selection.directories; // Catalog has one entry now (`taskless`); install all embedded skills. const plan = buildInstallPlan(locations, getEmbeddedSkills()); @@ -84,12 +92,11 @@ export async function runWizard( // Previewed before anything is written, so every settings file the // install will touch is on screen before the user confirms. - const detected = await detectTools(options.cwd); const permissionOptions = { selected: plan.targets .filter((target) => target.mode === "reference") .map((target) => target.dir), - codexDetected: detected.some((tool) => tool.name === "Codex"), + codex: selection.harnesses.includes("Codex"), invocation: buildInvocation(), launcher: buildLauncherInvocation(), }; @@ -98,10 +105,6 @@ export async function runWizard( write: false, }); - const globalTrustEnvironment = options.globalTrustEnvironment ?? { - home: homedir(), - env: process.env, - }; const globalTrustOptions = { selected: permissionOptions.selected, environment: globalTrustEnvironment, diff --git a/packages/cli/src/wizard/steps/locations.ts b/packages/cli/src/wizard/steps/locations.ts index b9867d81..9537420c 100644 --- a/packages/cli/src/wizard/steps/locations.ts +++ b/packages/cli/src/wizard/steps/locations.ts @@ -1,96 +1,167 @@ -import { multiselect, log } from "@clack/prompts"; +import { groupMultiselect, log } from "@clack/prompts"; import { CANONICAL_DIR, DEFAULT_SHIM_DIR, SHIM_TARGETS, TOOLS, + detectMachineTools, detectTools, + installDirectoryFor, type ToolDescriptor, } from "../../install/install"; +import type { GlobalTrustEnvironment } from "../../install/global-trust"; import { readInstallState } from "../../install/state"; import { ask } from "../ask"; -/** A single entry in the tool-selection multiselect. */ +/** A single row in the tool-selection step. */ export interface LocationChoice { value: string; label: string; - hint: string; + 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. - * - * Only detected harnesses are named. Listing every harness that reads - * `.agents/skills` grows with the catalog and buries the row, so with none - * detected the row keeps its catalog label, `fallback`. - */ -export function agentsRowLabel( - detected: readonly ToolDescriptor[], - fallback: string -): string { - const detectedNames = new Set(detected.map((tool) => tool.name)); - const names = TOOLS.filter( - (tool) => tool.readsAgentSkills === true && detectedNames.has(tool.name) - ).map((tool) => tool.name); - if (names.length === 0) return fallback; - const list = names.length === 1 ? `${names[0]} ` : `${names.join(", ")}, `; - return `${list}and other .agents harnesses`; +/** The value of the row for every `.agents/` harness without its own row. */ +export const GENERIC_ROW = "agents"; + +export const PROJECT_SECTION = "Detected in this project"; +export const MACHINE_SECTION = "Detected on this machine"; +export const OTHER_SECTION = "Other harnesses"; + +interface Row { + value: string; + label: string; + dir: string; +} + +/** Every row, in catalog order, the generic row last. */ +function rows(projectDetected: ReadonlySet): Row[] { + const harnessRows = TOOLS.map((tool) => { + const directory = installDirectoryFor(tool, projectDetected.has(tool.name)); + return { + value: tool.name, + label: `${tool.name} (${directory}/)`, + dir: directory, + }; + }); + return [ + ...harnessRows, + { + value: GENERIC_ROW, + label: `Other Agent Skills harnesses (${DEFAULT_SHIM_DIR}/)`, + dir: DEFAULT_SHIM_DIR, + }, + ]; } /** - * 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. + * Build the tool-selection rows and the pre-checked set. Pure — no prompt, + * no TTY, no filesystem access — so the mapping is unit-testable. * - * 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. + * One row per harness, so a user finds the harness they run without knowing + * which skill directory it reads; several rows may install into one + * directory. Rows are grouped by where the harness was detected: in the + * project, on this machine only, or not at all. The generic `.agents/` row is + * always in the last group. * - * Each entry's hint reflects its origin: `installed` when recorded in the - * 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. + * Pre-checked: every project-detected row, then for each manifest-recorded + * directory none of those covers, the row installing into it (the generic row + * for `.agents/`). With nothing either way, the generic row. Machine-detected + * rows are never pre-checked: they only move up the list. The section says + * where a harness was found, so the one hint is `installed`. * * @param manifestDirectories Shim directories recorded in the install * manifest; the canonical `.taskless/` directory is ignored if present. */ export function locationChoices( - detected: ToolDescriptor[], + detected: readonly ToolDescriptor[], + machineDetected: readonly ToolDescriptor[] = [], manifestDirectories: readonly string[] = [] ): { - options: LocationChoice[]; + groups: Record; initialValues: string[]; } { - const detectedDirectories = new Set(detected.map((t) => t.installDir)); - const installedDirectories = new Set( + const projectNames = new Set(detected.map((tool) => tool.name)); + const machineNames = new Set(machineDetected.map((tool) => tool.name)); + const installed = new Set( manifestDirectories.filter((d) => d !== CANONICAL_DIR) ); + const all = rows(projectNames); - const preChecked = SHIM_TARGETS.map((shim) => shim.dir).filter( - (directory) => - installedDirectories.has(directory) || detectedDirectories.has(directory) + const checked = new Set( + all.filter((row) => projectNames.has(row.value)).map((row) => row.value) + ); + // Taken before the manifest loop, so it holds only the project-detected + // rows: a recorded directory is pre-checked through its own row only when + // no detected harness already installs into it. + const covered = new Set( + all.filter((row) => checked.has(row.value)).map((row) => row.dir) ); - const initialValues = preChecked.length > 0 ? preChecked : [DEFAULT_SHIM_DIR]; - - const options = SHIM_TARGETS.map((shim) => ({ - value: shim.dir, - label: `${shim.generic === true ? agentsRowLabel(detected, shim.label) : shim.label} (${shim.dir}/)`, - hint: installedDirectories.has(shim.dir) - ? "installed" - : detectedDirectories.has(shim.dir) - ? "detected" - : "not detected", - })); - - return { options, initialValues }; + for (const shim of SHIM_TARGETS) { + if (!installed.has(shim.dir) || covered.has(shim.dir)) continue; + const row = + shim.dir === DEFAULT_SHIM_DIR + ? all.find((r) => r.value === GENERIC_ROW) + : all.find((r) => r.dir === shim.dir); + if (row !== undefined) checked.add(row.value); + } + if (checked.size === 0) checked.add(GENERIC_ROW); + + const groups: Record = {}; + for (const row of all) { + const section = projectNames.has(row.value) + ? PROJECT_SECTION + : machineNames.has(row.value) + ? MACHINE_SECTION + : OTHER_SECTION; + const choice: LocationChoice = { value: row.value, label: row.label }; + if (checked.has(row.value) && installed.has(row.dir)) { + choice.hint = "installed"; + } + (groups[section] ??= []).push(choice); + } + + // Sections in a fixed order, whatever order the rows filled them in. + const ordered: Record = {}; + for (const section of [PROJECT_SECTION, MACHINE_SECTION, OTHER_SECTION]) { + if (groups[section] !== undefined) ordered[section] = groups[section]; + } + + return { + groups: ordered, + initialValues: all + .filter((row) => checked.has(row.value)) + .map((row) => row.value), + }; +} + +/** What the checked rows install. */ +export interface LocationSelection { + /** Directories to install into, each once, in plan order. */ + directories: string[]; + /** The checked harness rows, by name; the generic row is not a harness. */ + harnesses: string[]; +} + +/** + * Resolve checked row values to directories and harnesses. Pure, and given + * the project-detected harnesses since those decide where Devin installs. + */ +export function resolveSelection( + values: readonly string[], + detected: readonly ToolDescriptor[] +): LocationSelection { + const all = rows(new Set(detected.map((tool) => tool.name))); + const picked = all.filter((row) => values.includes(row.value)); + const directories = new Set(picked.map((row) => row.dir)); + return { + directories: SHIM_TARGETS.map((shim) => shim.dir).filter((d) => + directories.has(d) + ), + harnesses: picked + .map((row) => row.value) + .filter((value) => value !== GENERIC_ROW), + }; } /** @@ -98,22 +169,28 @@ export function locationChoices( * always written and is not offered here — these choices only control which * tool directories receive a reference stub. */ -export async function promptLocations(cwd: string): Promise { +export async function promptLocations( + cwd: string, + environment: GlobalTrustEnvironment +): Promise { const detected = await detectTools(cwd); + const machineDetected = await detectMachineTools(environment); const installState = await readInstallState(cwd); const manifestDirectories = Object.keys(installState.targets); - const { options, initialValues } = locationChoices( + const { groups, initialValues } = locationChoices( detected, + machineDetected, manifestDirectories ); while (true) { const selected = await ask("locations", () => - multiselect({ + groupMultiselect({ message: "Which tools do you want to enable Taskless for?", - options, + options: groups, initialValues, required: false, + selectableGroups: false, }) ); @@ -122,6 +199,6 @@ export async function promptLocations(cwd: string): Promise { continue; } - return selected; + return resolveSelection(selected, detected); } } diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts index c03ed21e..568c021a 100644 --- a/packages/cli/test/install.test.ts +++ b/packages/cli/test/install.test.ts @@ -7,6 +7,7 @@ import { applyInstallPlan, buildInstallPlan, checkStaleness, + detectMachineTools, detectSelectedDirectories, detectTools, getEmbeddedSkills, @@ -103,7 +104,66 @@ describe("detectTools", () => { }); }); +async function names(env: Record = {}): Promise { + const tools = await detectMachineTools({ home: cwd, env }); + return tools.map((tool) => tool.name); +} + +describe("detectMachineTools", () => { + it("finds nothing in an empty home", async () => { + expect(await names()).toEqual([]); + }); + + it.each([ + ["Claude Code", ".claude"], + ["OpenCode", ".config/opencode"], + ["Cursor", ".cursor"], + ["Codex", ".codex"], + ["Gemini CLI", ".gemini"], + ["Crush", ".local/share/crush"], + ["Devin", ".config/devin"], + ])("detects %s by ~/%s", async (name, directory) => { + await mkdir(join(cwd, directory), { recursive: true }); + expect(await names()).toEqual([name]); + }); + + it("follows an override", async () => { + const elsewhere = join(cwd, "elsewhere"); + await mkdir(elsewhere); + expect(await names({ CLAUDE_CONFIG_DIR: elsewhere })).toEqual([ + "Claude Code", + ]); + }); + + it("finds Gemini CLI under GEMINI_CLI_HOME", async () => { + await mkdir(join(cwd, "gemini-home", ".gemini"), { recursive: true }); + expect(await names({ GEMINI_CLI_HOME: join(cwd, "gemini-home") })).toEqual([ + "Gemini CLI", + ]); + }); + + it("does not fall back to home when an override names a missing directory", async () => { + await mkdir(join(cwd, ".codex")); + expect(await names({ CODEX_HOME: join(cwd, "missing") })).toEqual([]); + }); + + it("treats an empty override as unset", async () => { + await mkdir(join(cwd, ".codex")); + expect(await names({ CODEX_HOME: "" })).toEqual(["Codex"]); + }); + + it("does not count a file", async () => { + await writeFile(join(cwd, ".cursor"), "", "utf8"); + expect(await names()).toEqual([]); + }); +}); + describe("detectSelectedDirectories", () => { + it("maps Devin detection to .devin", async () => { + await mkdir(join(cwd, ".devin"), { recursive: true }); + expect(await detectSelectedDirectories(cwd)).toEqual([".devin"]); + }); + it("defaults to .agents when no tools are detected", async () => { expect(await detectSelectedDirectories(cwd)).toEqual([".agents"]); }); diff --git a/packages/cli/test/permissions.test.ts b/packages/cli/test/permissions.test.ts index bc1aa96e..c715d01f 100644 --- a/packages/cli/test/permissions.test.ts +++ b/packages/cli/test/permissions.test.ts @@ -64,7 +64,7 @@ function sync( options: Partial & { selected: string[] } ): ReturnType { return syncPermissions(cwd, { - codexDetected: false, + codex: false, invocation: V1, write: true, ...options, @@ -361,15 +361,14 @@ describe("the release and the nightly side by side", () => { { harness: "Codex", selected: [".agents"], - codexDetected: true, + codex: true, files: [".codex/rules/taskless.rules"], }, ])( "keeps each other's rules in $harness", - async ({ selected, codexDetected = false, files, seed }) => { + async ({ selected, codex = false, files, seed }) => { if (seed !== undefined) await put(files[0] ?? "", seed); - const run = (invocation: string) => - sync({ selected, codexDetected, invocation }); + const run = (invocation: string) => sync({ selected, codex, invocation }); await run(V1); await run(N1); await run(N2); @@ -562,8 +561,9 @@ describe("OpenCode replacing a stale rule", () => { }); describe("Codex", () => { - it("writes its own rules file when Codex is detected", async () => { - await sync({ selected: [".agents"], codexDetected: true }); + it("writes its own rules file when Codex is chosen", async () => { + // No `.codex/` in the project: checking the Codex row is enough. + await sync({ selected: [".agents"], codex: true }); expect(await text(".codex/rules/taskless.rules")).toContain( 'prefix_rule(pattern = ["npx","@taskless/cli@0.13.0"], decision = "allow")' @@ -581,7 +581,7 @@ describe("Codex", () => { "# Written by Taskless: allows exactly the installed Taskless CLI.\n" + 'prefix_rule(pattern = ["npx","@taskless/cli@0.12.0"], decision = "allow")\n' ); - const edits = await sync({ selected: [".agents"], codexDetected: true }); + const edits = await sync({ selected: [".agents"], codex: true }); expect(edits[0]?.removed).toEqual([ 'prefix_rule(pattern = ["npx","@taskless/cli@0.12.0"], decision = "allow")', @@ -597,7 +597,7 @@ describe("Codex", () => { "# Written by Taskless, in some older wording.\n" + 'prefix_rule(pattern = ["npx","@taskless/cli@0.12.0"], decision = "allow")\n' ); - const edits = await sync({ selected: [".agents"], codexDetected: true }); + const edits = await sync({ selected: [".agents"], codex: true }); expect(edits[0]?.skipped).toBeNull(); expect(await text(".codex/rules/taskless.rules")).not.toContain("0.12.0"); @@ -605,7 +605,7 @@ describe("Codex", () => { it("leaves a rules file at its path that Taskless did not write", async () => { await put(".codex/rules/taskless.rules", "# theirs\n"); - const edits = await sync({ selected: [".agents"], codexDetected: true }); + const edits = await sync({ selected: [".agents"], codex: true }); expect(edits[0]?.skipped).toMatch(/not written by Taskless/); expect(await text(".codex/rules/taskless.rules")).toBe("# theirs\n"); @@ -744,7 +744,7 @@ describe("launcher rules", () => { it("writes each harness's launcher form", async () => { await sync({ selected: [".cursor", ".opencode", ".agents"], - codexDetected: true, + codex: true, launcher: RL, }); expect(await json(".cursor/permissions.json")).toEqual({ diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index f31a3065..3367b57a 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -32,6 +32,7 @@ const cancelSpy = vi.fn(); // Clack mock responses are set per-test via these mutable refs. const clackResponses: { + /** Checked row values: harness names, or `agents` for the generic row. */ locations?: string[] | symbol; summary?: boolean | symbol; commit?: string | symbol; @@ -75,7 +76,7 @@ vi.mock("@clack/prompts", () => ({ }, note: () => {}, isCancel: (value: unknown) => value === fakeCancelSymbol, - multiselect: vi.fn(() => Promise.resolve(clackResponses.locations)), + groupMultiselect: vi.fn(() => Promise.resolve(clackResponses.locations)), select: vi.fn(({ message }: { message: string }) => { selectMessages.push(message); return Promise.resolve(clackResponses.commit); @@ -138,7 +139,7 @@ afterEach(async () => { describe("runWizard end-to-end", () => { it("installs all bundled skills to selected location and records manifest", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -165,7 +166,7 @@ describe("runWizard end-to-end", () => { it("completes without a token and never offers to log in", async () => { vi.stubEnv("TASKLESS_TOKEN", ""); - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const clack = await import("@clack/prompts"); @@ -183,7 +184,7 @@ describe("runWizard end-to-end", () => { }); it("says once that it collects telemetry, and how to opt out", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -195,7 +196,7 @@ describe("runWizard end-to-end", () => { }); it("says nothing about telemetry when it is already off", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; telemetryEnabled = false; @@ -206,7 +207,7 @@ describe("runWizard end-to-end", () => { }); it("re-running with the same location is idempotent", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -244,7 +245,7 @@ describe("runWizard end-to-end", () => { }); it("shows the permission edit before writing, and asks", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = false; const { runWizard } = await import("../src/wizard"); @@ -268,7 +269,7 @@ describe("runWizard end-to-end", () => { join(cwd, ".claude", "settings.json"), JSON.stringify({ permissions: { allow: [stale] } }) ); - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = false; const { runWizard } = await import("../src/wizard"); @@ -289,7 +290,7 @@ describe("runWizard end-to-end", () => { describe("global trust", () => { it("lists project and global edits together under one question", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -315,7 +316,7 @@ describe("runWizard end-to-end", () => { }); it("declining writes neither the project nor the global settings", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = false; const { runWizard } = await import("../src/wizard"); @@ -328,7 +329,7 @@ describe("runWizard end-to-end", () => { it("asks again on the next run after a decline", async () => { // Nothing records the answer: an absent entry is offered every time. - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = false; const { runWizard } = await import("../src/wizard"); @@ -341,7 +342,7 @@ describe("runWizard end-to-end", () => { }); it("asks about the global entry alone when the project is current", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); // A first run with no home settings writes both; remove the global one. @@ -366,7 +367,7 @@ describe("runWizard end-to-end", () => { autoMode: { allow: ["$defaults", GLOBAL_TRUST_ENTRY] }, }); await writeFile(userSettings(), original); - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -392,7 +393,7 @@ describe("runWizard end-to-end", () => { }, }) ); - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -420,7 +421,7 @@ describe("runWizard end-to-end", () => { autoMode: { allow: ["$defaults", "Taskless CLI: mine"] }, }); await writeFile(userSettings(), original); - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = false; const { runWizard } = await import("../src/wizard"); @@ -431,7 +432,7 @@ describe("runWizard end-to-end", () => { }); it("writes the permission rule once confirmed", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -447,7 +448,7 @@ describe("runWizard end-to-end", () => { }); it("cancelling the summary confirm writes nothing", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = false; // Seed install state with a stale skill name that is no longer in the @@ -478,7 +479,7 @@ describe("runWizard end-to-end", () => { const { runWizard } = await import("../src/wizard"); // First run: install Taskless into both .claude/ and .agents/. - clackResponses.locations = [".claude", ".agents"]; + clackResponses.locations = ["Claude Code", "agents"]; clackResponses.summary = true; await runWizard({ cwd, globalTrustEnvironment }); @@ -489,7 +490,7 @@ describe("runWizard end-to-end", () => { // Second run: uncheck .claude/, keep .agents/ — consolidating onto .agents. summaryConfirmMessage = undefined; - clackResponses.locations = [".agents"]; + clackResponses.locations = ["agents"]; clackResponses.summary = true; const result = await runWizard({ cwd, globalTrustEnvironment }); @@ -515,7 +516,7 @@ describe("runWizard end-to-end", () => { it("declining the removal confirm keeps the unchecked location's stubs", async () => { const { runWizard } = await import("../src/wizard"); - clackResponses.locations = [".claude", ".agents"]; + clackResponses.locations = ["Claude Code", "agents"]; clackResponses.summary = true; await runWizard({ cwd, globalTrustEnvironment }); @@ -523,7 +524,7 @@ describe("runWizard end-to-end", () => { expect(await exists(claudeSkill)).toBe(true); // Uncheck .claude/ but decline the removal confirm — nothing is removed. - clackResponses.locations = [".agents"]; + clackResponses.locations = ["agents"]; clackResponses.summary = false; const result = await runWizard({ cwd, globalTrustEnvironment }); @@ -545,7 +546,7 @@ describe("runWizard with an unreadable manifest", () => { const CORRUPT = '{"version": 6,\n<<<<<<< HEAD\n}\n'; it("refuses inside the frame and leaves the file alone", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const manifest = join(cwd, ".taskless", "taskless.json"); @@ -579,7 +580,7 @@ describe("runWizard with an unreadable manifest", () => { */ describe("the wizard's restart-your-agents banner", () => { it("prints on a second run whose recorded version moved", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -614,7 +615,7 @@ describe("the wizard's restart-your-agents banner", () => { }); it("stays quiet on a first run", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -633,7 +634,7 @@ describe("the wizard's restart-your-agents banner", () => { }); it("stays quiet when the recorded version did not move", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -686,7 +687,7 @@ describe("the wizard's offer to commit the install", () => { vi.stubEnv("GIT_CONFIG_COUNT", "1"); vi.stubEnv("GIT_CONFIG_KEY_0", "commit.gpgsign"); vi.stubEnv("GIT_CONFIG_VALUE_0", "false"); - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; }); @@ -787,7 +788,7 @@ describe("the wizard's offer to commit the install", () => { describe("the wizard outside a git repository", () => { it("does not offer to commit", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); @@ -805,7 +806,7 @@ describe("the wizard outside a git repository", () => { */ describe("the wizard's stale-pin notice", () => { it("names a package.json pin older than the running CLI", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const packageJson = JSON.stringify({ devDependencies: { "@taskless/cli": "0.0.1" }, @@ -834,7 +835,7 @@ describe("the wizard's stale-pin notice", () => { }); it("stays quiet without a stale pin", async () => { - clackResponses.locations = [".claude"]; + clackResponses.locations = ["Claude Code"]; clackResponses.summary = true; const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -849,4 +850,30 @@ describe("the wizard's stale-pin notice", () => { logSpy.mockRestore(); } }); + + it("checking Codex alone installs into .agents and writes its rules file", async () => { + // Codex on this machine only: the project has no `.codex/`. + await mkdir(join(globalTrustEnvironment.home, ".codex")); + clackResponses.locations = ["Codex"]; + clackResponses.summary = true; + + const { runWizard } = await import("../src/wizard"); + const result = await runWizard({ cwd, globalTrustEnvironment }); + + expect(result.status).toBe("completed"); + expect(result.locations).toEqual([".agents"]); + expect( + await exists(join(cwd, ".agents", "skills", "taskless", "SKILL.md")) + ).toBe(true); + expect(await exists(join(cwd, ".codex", "rules", "taskless.rules"))).toBe( + true + ); + // Nothing is written beside the home-directory signal it was found by. + expect(await exists(join(globalTrustEnvironment.home, ".agents"))).toBe( + false + ); + expect( + await exists(join(globalTrustEnvironment.home, ".codex", "rules")) + ).toBe(false); + }); }); diff --git a/packages/cli/test/wizard-steps.test.ts b/packages/cli/test/wizard-steps.test.ts index 38b34ac7..15851127 100644 --- a/packages/cli/test/wizard-steps.test.ts +++ b/packages/cli/test/wizard-steps.test.ts @@ -34,148 +34,183 @@ describe("ask wrapper", () => { }); }); +/** The locations step, and catalog harnesses by name. */ +async function load() { + const steps = await import("../src/wizard/steps/locations"); + const { TOOLS } = await import("../src/install/install"); + const tools = (...names: string[]) => + TOOLS.filter((tool) => names.includes(tool.name)); + return { ...steps, tools }; +} + 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", ".cursor", ".opencode", ".agents"]); - expect(values).not.toContain(".taskless"); + it("offers a row for every harness with nothing detected", async () => { + const { locationChoices, OTHER_SECTION } = await load(); + const { groups } = locationChoices([]); + expect(Object.keys(groups)).toEqual([OTHER_SECTION]); + expect(groups[OTHER_SECTION]!.map((row) => row.label)).toEqual([ + "Claude Code (.claude/)", + "OpenCode (.opencode/)", + "Cursor (.cursor/)", + "Codex (.agents/)", + "Gemini CLI (.agents/)", + "Crush (.agents/)", + "Devin (.agents/)", + "Other Agent Skills harnesses (.agents/)", + ]); }); - it("names the detected .agents harnesses on the .agents/ row", async () => { - const { locationChoices } = await import("../src/wizard/steps/locations"); - 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("pre-checks project-detected harnesses in their own section", async () => { + const { locationChoices, tools, PROJECT_SECTION } = await load(); + const { groups, initialValues } = locationChoices( + tools("Claude Code", "Codex") ); + expect(groups[PROJECT_SECTION]!.map((row) => row.value)).toEqual([ + "Claude Code", + "Codex", + ]); + expect(initialValues).toEqual(["Claude Code", "Codex"]); }); - // Naming every harness that reads `.agents/skills` grows with the catalog, - // so an undetected harness is never named. - it("names no harness on the .agents/ row when none is detected", async () => { - const { locationChoices } = await import("../src/wizard/steps/locations"); - const label = locationChoices([]).options.find( - (o) => o.value === ".agents" - )?.label; - expect(label).toBe("Agent Skills (.agents/)"); + it("lists machine-detected harnesses unchecked", async () => { + const { locationChoices, tools, MACHINE_SECTION } = await load(); + const { groups, initialValues } = locationChoices( + tools("Claude Code"), + tools("Gemini CLI") + ); + expect(groups[MACHINE_SECTION]!.map((row) => row.value)).toEqual([ + "Gemini CLI", + ]); + expect(initialValues).toEqual(["Claude Code"]); }); - // 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("lists a harness found in both places once, in the project section", async () => { + const { locationChoices, tools, PROJECT_SECTION } = await load(); + const { groups } = locationChoices( + tools("Claude Code"), + tools("Claude Code") + ); + const rows = Object.values(groups) + .flat() + .filter((row) => row.value === "Claude Code"); + expect(rows).toHaveLength(1); + expect(groups[PROJECT_SECTION]).toEqual(rows); }); - 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("orders the sections project, machine, other", async () => { + const { + locationChoices, + tools, + PROJECT_SECTION, + MACHINE_SECTION, + OTHER_SECTION, + } = await load(); + // Claude Code, first in the catalog, fills the machine section before + // Codex fills the project one; the sections still come out in order. + const { groups } = locationChoices(tools("Codex"), tools("Claude Code")); + expect(Object.keys(groups)).toEqual([ + PROJECT_SECTION, + MACHINE_SECTION, + OTHER_SECTION, + ]); }); - it("pre-checks .agents/ when no tools are detected", async () => { - const { locationChoices } = await import("../src/wizard/steps/locations"); - expect(locationChoices([]).initialValues).toEqual([".agents"]); + it("pre-checks only the generic row with nothing detected or installed", async () => { + const { locationChoices, tools, GENERIC_ROW } = await load(); + expect(locationChoices([], tools("Codex")).initialValues).toEqual([ + GENERIC_ROW, + ]); }); - it("pre-checks a detected tool and hints it as 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, initialValues } = locationChoices([claude]); - expect(initialValues).toEqual([".claude"]); - expect(options.find((o) => o.value === ".claude")?.hint).toBe("detected"); - expect(options.find((o) => o.value === ".cursor")?.hint).toBe( - "not detected" + it("pre-checks the generic row for a recorded .agents/ no detected harness covers", async () => { + const { locationChoices, tools, GENERIC_ROW } = await load(); + const { groups, initialValues } = locationChoices( + tools("Claude Code"), + [], + [".agents"] ); + expect(initialValues).toEqual(["Claude Code", GENERIC_ROW]); + const generic = Object.values(groups) + .flat() + .find((row) => row.value === GENERIC_ROW); + expect(generic?.hint).toBe("installed"); }); - it("pre-checks every detected tool's directory", 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 codex = TOOLS.find((t) => t.name === "Codex")!; - - const { initialValues } = locationChoices([claude, codex]); - expect(initialValues).toContain(".claude"); - expect(initialValues).toContain(".agents"); + it("does not add the generic row when a detected harness covers .agents/", async () => { + const { locationChoices, tools } = await load(); + const { groups, initialValues } = locationChoices( + tools("Codex"), + [], + [".agents"] + ); + expect(initialValues).toEqual(["Codex"]); + const codex = Object.values(groups) + .flat() + .find((row) => row.value === "Codex"); + expect(codex?.hint).toBe("installed"); }); - 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("hints only installed rows", async () => { + const { locationChoices, tools } = await load(); + const { groups } = locationChoices(tools("Claude Code"), tools("Codex")); + expect( + Object.values(groups) + .flat() + .filter((row) => row.hint !== undefined) + ).toEqual([]); }); - 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("ignores a recorded .taskless/", async () => { + const { locationChoices, GENERIC_ROW } = await load(); + const { groups, initialValues } = locationChoices([], [], [".taskless"]); + expect(initialValues).toEqual([GENERIC_ROW]); + expect( + Object.values(groups) + .flat() + .some((row) => row.label.includes(".taskless")) + ).toBe(false); }); - 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")!; + it("labels Devin with .devin/ when the project has it", async () => { + const { locationChoices, tools, PROJECT_SECTION } = await load(); + const { groups } = locationChoices(tools("Devin")); + expect(groups[PROJECT_SECTION]!.map((row) => row.label)).toEqual([ + "Devin (.devin/)", + ]); + }); +}); - const { options } = locationChoices([claude], [".claude"]); - expect(options.find((o) => o.value === ".claude")?.hint).toBe("installed"); +describe("resolveSelection", () => { + it("installs two rows sharing .agents/ once", async () => { + const { resolveSelection } = await load(); + expect(resolveSelection(["Codex", "Gemini CLI"], []).directories).toEqual([ + ".agents", + ]); }); - 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( - "not detected" + it("returns directories in plan order and harnesses without the generic row", async () => { + const { resolveSelection, GENERIC_ROW } = await load(); + expect(resolveSelection([GENERIC_ROW, "Codex", "Claude Code"], [])).toEqual( + { + directories: [".claude", ".agents"], + harnesses: ["Claude Code", "Codex"], + } ); }); - it("pre-checks .agents/ exactly once when Codex is detected", async () => { - const { locationChoices } = await import("../src/wizard/steps/locations"); - const { TOOLS } = await import("../src/install/install"); - const codex = TOOLS.find((t) => t.name === "Codex")!; - - const { initialValues } = locationChoices([codex]); - expect(initialValues).toEqual([".agents"]); + it("installs a machine-only Claude Code into .claude/", async () => { + const { resolveSelection } = await load(); + expect(resolveSelection(["Claude Code"], []).directories).toEqual([ + ".claude", + ]); }); - 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"]); + it("installs Devin into .devin/ only when the project has it", async () => { + const { resolveSelection, tools } = await load(); + expect(resolveSelection(["Devin"], []).directories).toEqual([".agents"]); + expect(resolveSelection(["Devin"], tools("Devin")).directories).toEqual([ + ".devin", + ]); }); });