diff --git a/.changeset/canonical-install.md b/.changeset/canonical-install.md new file mode 100644 index 0000000..90d388e --- /dev/null +++ b/.changeset/canonical-install.md @@ -0,0 +1,12 @@ +--- +"@taskless/cli": minor +--- + +Install a single canonical skill/command store with thin per-tool reference stubs. + +- **Canonical store**: `taskless init`/`update` now writes the skill and command content exactly once, to `.taskless/skills//SKILL.md` and `.taskless/commands/tskl/.md`. This Taskless-owned directory is never a tool install target, so no install or cleanup step can ever delete it. +- **Reference stubs**: each enabled tool directory (`.claude/`, `.cursor/`, `.opencode/`, `.agents/`) receives a thin reference stub instead of a full copy — an ordinary file (never a symlink) carrying `name`/`description` frontmatter, a `metadata.type: shim` marker, and a body that delegates to the canonical file. This ends the N-identical-copies drift of the previous per-tool full-copy model. `.claude/` and `.cursor/` also receive a `tskl` command stub; `.opencode/` and `.agents/` receive skills only. +- **Per-target install mode**: `.taskless/taskless.json` records a `mode` (`canonical` | `reference`) per target. The field is additive and backward-compatible — a manifest written before this change reads its entries as `canonical`, so no schema migration is needed. +- **Self-healing convergence**: `applyInstallPlan` rewrites a reference file unless it is already a current, non-drifted shim stub. Full per-tool copies left by older installs, manually-created symlinks, and stubs whose frontmatter has drifted are all converged into stubs on the next `init`/`update`. The destructive `rm -rf` glob cleanup is removed; cleanup is now driven solely by the recorded-manifest diff and scoped to each target's own directory. +- **Wizard tool selection**: the wizard's location step is reframed as "which tools do you want to enable Taskless for?" — a fixed multiselect of `.claude/`, `.cursor/`, `.opencode/`, `.agents/`, with detected entries pre-checked and `.agents/` the default when nothing is detected. The canonical `.taskless/` store is always written and is not a selectable entry. +- **No symlinks**: the CLI never creates symlinks for skills or commands. Symlink-based skill discovery is unreliable across Cursor, OpenCode, and Codex, and breaks on Windows checkout. diff --git a/.claude/settings.json b/.claude/settings.json index a5582f9..62c502c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -29,7 +29,8 @@ "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d['summary'][k] for k in ['high','medium','low','resolved','needs_attention']}\\)\\)\")", "Bash(find skills -name \"SKILL.md\" -exec grep -l \"optional\\\\|required\" {} \\\\;)", "Skill(pr-writer)", - "Skill(pr-writer:*)" + "Skill(pr-writer:*)", + "WebSearch" ], "deny": [ "AskUserQuestion*", diff --git a/.taskless/README.md b/.taskless/README.md index b0bcfc9..890f719 100644 --- a/.taskless/README.md +++ b/.taskless/README.md @@ -18,5 +18,7 @@ npx @taskless/cli@latest check - `taskless.json` - Version manifest / migration state - `.env.local.json` - Local authentication credentials (git-ignored) +- `skills/` - Canonical Taskless skill content; tool directories hold thin stubs that delegate here (managed by Taskless) +- `commands/` - Canonical Taskless command content (managed by Taskless) - `rules/` - Generated ast-grep rules (managed by Taskless) - `rule-tests/` - Rule tests containing pass/fail examples for your rules diff --git a/openspec/changes/archive/2026-05-17-cli-canonical-install/.openspec.yaml b/openspec/changes/archive/2026-05-17-cli-canonical-install/.openspec.yaml new file mode 100644 index 0000000..66da1ae --- /dev/null +++ b/openspec/changes/archive/2026-05-17-cli-canonical-install/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/archive/2026-05-17-cli-canonical-install/design.md b/openspec/changes/archive/2026-05-17-cli-canonical-install/design.md new file mode 100644 index 0000000..7eb14b4 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-cli-canonical-install/design.md @@ -0,0 +1,91 @@ +## Context + +Today the CLI embeds skill/command content at build time (`import.meta.glob`) and, on `init`/`update`, writes a **full copy** of every skill into each detected tool's directory — `.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, and `.agents/skills/` (Codex). Each detected tool is an independent install target in `applyInstallPlan` ([install.ts](packages/cli/src/install/install.ts)), so an N-tool repo gets N identical `SKILL.md` files. They drift, they churn PR diffs, and staleness is checked per-copy. + +A customer trying to standardize on one shared skill surfaced the root cause: the model conflates _where content lives_ with _where tools read it_. Codex's `installDir` is `.agents`, so the Codex target's `removeOwnedSkills` does `rm -rf .agents/skills/taskless` — destroying the directory other targets point into. With symlinks, `update` fails; with wrapper files, `applyInstallPlan` clobbers the wrapper with a full copy because it has no notion of a target being a pointer. + +Research (Dec 2025 Agent Skills spec; per-tool docs and open issue trackers) established: `.agents/skills//SKILL.md` is the cross-tool standard, read **natively** by OpenCode, Cursor, and Codex; symlink discovery is broken or unreliable on all three and Windows-checkout-fragile; Claude Code reads only `.claude/skills/`. + +## Goals / Non-Goals + +**Goals:** + +- A single canonical store for skill and command content, in a directory **no tool target ever installs into or cleans up**. +- Every tool location served by a thin, ordinary-file reference stub — no symlinks anywhere. +- `update` that rewrites canonical content without clobbering stubs or destroying the canonical source. +- A manifest that distinguishes the `canonical` store from `reference` tool locations. +- Existing multi-copy and symlinked installs converge on the canonical layout via the migration system. + +**Non-Goals:** + +- No Claude Code plugin/marketplace distribution work — noted as a future path, out of scope here. +- No change to skill _authoring_ layout (`skills/taskless/SKILL.md` in this repo) or build-time embedding. +- No symlink support — explicitly rejected (see Decisions). + +## Decisions + +### Decision: Canonical content lives in `.taskless/`, not `.agents/` or a tool directory + +Skill content goes to `.taskless/skills//SKILL.md`; command content to `.taskless/commands/tskl/.md`. `.taskless/` is Taskless's owned namespace — already committed, already home to `rules/`, `rule-tests/`, `taskless.json`. + +Two alternatives were considered and rejected: + +- **`.taskless/agents/*`** (OSS-8's original framing) — fine as an owned namespace, but the sub-path is arbitrary; `.taskless/skills/` + `.taskless/commands/` mirrors the kind of content and is clearer. +- **`.agents/skills/` as canonical** — `.agents/skills/` is read natively by three tools, which is attractive, but it makes `.agents/` do double duty: canonical store _and_ a tool read-path. That dual role **is** the customer's bug — Codex's target cleanup lives in `.agents`. It is also a _shared_ namespace other installers write into, making cleanup a prefix-match in someone else's room, and the standard is young. + +Putting the canonical in `.taskless/` separates "where content lives" from "where tools read it." No install target ever points its write/cleanup at `.taskless/skills/`, so the canonical-destruction bug becomes **structurally impossible** rather than something guarded against in code. `.taskless/` is collision-free (no other tool reads or writes it), and the layout is decoupled from the fate of the `.agents/` standard. + +### Decision: Uniform per-tool stubs — every selected directory is a peer target + +Each selected tool directory receives its own stub — an ordinary `SKILL.md` (or command `.md`) with real `name`/`description` frontmatter (so the tool discovers and triggers it) and a body that says "read `.taskless/skills//SKILL.md` and follow it," without inlining canonical instructions. + +- `.claude/skills//SKILL.md` + `.claude/commands/tskl/.md` — Claude Code. +- `.cursor/skills//SKILL.md` + `.cursor/commands/tskl/.md` — Cursor. +- `.opencode/skills//SKILL.md` — OpenCode (no commands). +- `.agents/skills//SKILL.md` — generic Agent Skills location, including Codex (no commands). + +An alternative was considered and rejected: **routing** — since Cursor, OpenCode, and Codex read `.agents/skills/` natively, "enable Cursor" could be routed to a single shared `.agents/` stub and `.cursor/skills/` left unwritten. Rejected for two reasons: (a) it makes `.agents/` a special case in an otherwise uniform model, and the routing logic ("which tools collapse onto `.agents/`") is exactly the kind of cleverness that ages badly; (b) it depends on each tool's native `.agents/` discovery actually working, which the research found uneven. The uniform model treats `.agents/` as an ordinary peer: every selected directory gets exactly one stub, no routing, no special cases. The cost is one tiny stub per tool instead of a shared one — featherweight, and content is still single-sourced so drift is unaffected. + +Each stub points **directly** at the canonical file — never at another stub — so resolution is always a single hop. + +### Decision: No symlinks — stubs are ordinary files + +Symlinks are rejected on three independently sufficient grounds: (a) Cursor, OpenCode, and Codex all have open symlink-discovery bugs; (b) Windows checkout without Developer Mode materializes a symlink as a plain text file containing the link path; (c) symlinks invite exactly the destructive-cleanup failure this change exists to remove. An ordinary stub file works on every OS and VCS and survives archives/ZIP exports. + +_Alternative considered:_ hardlinks — rejected because `git clone` materializes independent copies, so they don't survive distribution. + +### Decision: Targets carry a `mode` (`canonical` | `reference`) + +The install state (`install/state.ts`) records a per-target `mode`. The `.taskless` target is `canonical`; every tool location is `reference`. `applyInstallPlan` branches on it: `canonical` gets full content written/rewritten; `reference` gets a stub generated only when absent or when frontmatter has drifted, and is **never** overwritten with full content. A legacy manifest with no `mode` defaults entries to `canonical`, preserving backward compatibility. + +### Decision: The wizard's location step becomes tool selection + +The wizard's location step is reframed from "where should skills be installed?" to "which tools do you want to enable Taskless for?" — a fixed multiselect of `.claude/`, `.cursor/`, `.opencode/`, `.agents/`, with detected entries pre-checked and `.agents/` the default when nothing is detected. The canonical `.taskless/` store is not a selectable entry: it is always written and always maintained, independent of the selection. Each checked entry produces one `reference` stub target; the unchecked entries produce nothing. + +### Decision: Cleanup is manifest-driven; convergence is self-healing, not a migration + +The destructive `rm -rf` glob in `removeOwnedSkills` is removed. Cleanup operates solely on the recorded-manifest diff (`computeInstallDiff`): only paths a prior manifest recorded are removed, respecting each entry's `mode`. + +Converging an existing install onto the canonical-plus-stub layout is **not** done with a `.taskless/` migration. Two reasons: a migration that needs embedded content and the tool catalog would import `install/install.ts`, forming an import cycle through `state.ts` → `migrate.ts`; and migrations run on _every_ `ensureTasklessDirectory` call, including `taskless check`, so a convergence migration would write skill files into a repo during an unrelated command. + +Instead, `applyInstallPlan` is **self-healing**. Every stub carries a frontmatter marker — `metadata.type: shim` (see `isShimStub`) — so a stub is distinguishable from a full copy without inspecting the body. When writing a `reference` target, `applyInstallPlan` rewrites the file unless it is already a current, non-drifted shim stub. That single rule converges every stale shape: a missing file, a full copy left by an older install, a symlink, or a drifted stub — all on the next `init`/`update`, with no migration. A legacy manifest with no `mode` still reads as `canonical`, so the manifest change needs no migration either. + +## Risks / Trade-offs + +- **A stub per tool rather than a shared one** → The uniform model writes one stub into each selected tool directory instead of routing several tools onto a shared `.agents/` stub. Each stub is featherweight (~6 lines) and content is single-sourced, so drift is unaffected; the trade buys a uniform, routing-free model. +- **Stub frontmatter drift from canonical** → A stub copies the canonical `name`, `description`, and `metadata.version`; if any change, the stub goes stale. Mitigation: `update` regenerates a stub _as a stub_ when any of those drifts — refreshing the frontmatter only, never writing full body content. A version bump therefore refreshes every stub on the next `update`. +- **Double discovery** → A tool that reads more than one of the selected directories (e.g. a tool reading both `.agents/` and its own dir) sees two stubs for the same skill. Both resolve to the same canonical file, so behavior is identical; worst case is a duplicate listing. Acceptable. +- **A consumer manually symlinked things** (the customer's current state) → `applyInstallPlan` `lstat`s each reference path; a symlink is always replaced with a real stub file rather than written through. +- **A leftover full copy reads as drift-free** → An old per-tool full `SKILL.md` has `name`/`description` matching canonical, so a `name`/`description` drift check alone would never regenerate it. Mitigation: the `metadata.type: shim` marker — a full copy lacks it, so `applyInstallPlan` treats any non-shim file as something to convert. + +## Migration Plan + +1. Ship the new install model as the default `init`/`update` behavior (no flag). +2. No `.taskless/` schema migration is added. The manifest's new `mode` field is additive and backward-compatible (absent → `canonical`). +3. On a user's next `taskless init`/`update`, `applyInstallPlan` self-heals: it seeds the canonical store and rewrites every reference file that is not a current shim stub (full copies, symlinks, drifted stubs). +4. Rollback: reverting the CLI leaves a valid `.taskless/` store; an older CLI would re-create per-tool full copies, which is the prior behavior — no corruption. + +## Open Questions + +- Should the canonical `.taskless/skills/` store carry the staleness `metadata.version`, with stubs version-free — making staleness a single-file check? (Leaning: yes.) +- Does the install summary need a per-tool "served by `.taskless/` canonical" line, or one canonical line plus the tool list? (Leaning: one canonical line; keep summary terse.) diff --git a/openspec/changes/archive/2026-05-17-cli-canonical-install/proposal.md b/openspec/changes/archive/2026-05-17-cli-canonical-install/proposal.md new file mode 100644 index 0000000..8195e50 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-cli-canonical-install/proposal.md @@ -0,0 +1,34 @@ +## Why + +`taskless init`/`update` writes a full copy of every skill into each detected tool's directory (`.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, `.agents/skills/`), producing N identical copies that drift and churn PR diffs. A customer standardizing on a single shared skill surfaced the deeper flaw: the install model has no separation between _where content lives_ and _where tools read it_. Because Codex's install directory **is** `.agents`, the Codex target's `rm -rf` cleanup destroys the very directory other targets point into — so symlinks break `update`, and wrapper files get clobbered with full copies. + +The fix is to give canonical content its own home that no tool ever installs into or cleans up: `.taskless/`, Taskless's owned namespace. Every tool location then holds only a thin reference stub. This makes the canonical-destruction bug structurally impossible, keeps content single-sourced, and hedges the still-young `.agents/skills/` standard. + +## What Changes + +- Canonical skill content moves to `.taskless/skills//SKILL.md`; canonical command content to `.taskless/commands/tskl/.md`. Written **once** and always maintained, in Taskless's owned namespace — no tool target ever cleans it up. +- Every selected tool directory receives its own thin **reference stub**: an ordinary file with valid frontmatter and a body that delegates to the canonical file. No symlinks anywhere (symlink discovery is broken/unreliable across Cursor, OpenCode, Codex and fragile on Windows checkout). +- Stubs are uniform: `.claude/`, `.cursor/`, `.opencode/`, and `.agents/` are peer targets. Each selected one gets a skill stub; `.claude/` and `.cursor/` additionally get a command stub. `.agents/` is an ordinary selectable target, not a special shared location. +- Per-tool full skill copies are replaced by per-tool stubs. No target is dropped — the installed file shape changes from a full `SKILL.md` to a delegating stub, which is what kills the N-identical-copies drift. +- The install manifest (`.taskless/taskless.json`) gains a per-target **mode**: `canonical` (`.taskless/`) vs `reference` (each tool directory). `update` rewrites canonical content only, regenerates a stub only when its frontmatter has drifted, and **never** overwrites a stub with full content. +- The interactive wizard reframes its location step as "which tools do you want to enable Taskless for?" — a fixed multiselect of `.claude/.cursor/.opencode/.agents`, detected entries pre-checked. +- Cleanup becomes strictly manifest-driven — no `rm -rf` of a path another target sources from. +- Existing installs converge without a migration. Stubs carry `metadata` with a `type: shim` marker and the canonical `version`, and `applyInstallPlan` self-heals: it rewrites any reference file that is not a current shim stub — a full copy from an older install, a symlink, or a drifted stub — on the next `init`/`update`. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `cli-init`: The install/update model changes from per-tool full copies to a single canonical `.taskless/` store plus mode-aware reference stubs in each selected tool directory. Canonical content location, the uniform stub model, the manifest schema (per-target `mode`), update behavior (rewrite canonical only, preserve stubs), the wizard's reframed tool-selection step, and extension of the model to commands are all requirement-level changes. + +## Impact + +- **Code**: `packages/cli/src/install/install.ts` (canonical store + stub writes, the install-plan model, `applyInstallPlan`, removal of `rm -rf` glob cleanup), `install/canonical.ts` (canonical write + stub helpers), `install/state.ts` (manifest `mode` field), `commands/init.ts` + `wizard/` (plan construction, reframed tool-selection step, summary). +- **Filesystem**: new `.taskless/skills/` and `.taskless/commands/` canonical directories; `.taskless/README.md` "Files" section updated. +- **Manifest**: `.taskless/taskless.json` install-state schema gains per-target `mode` (backward-compatible — a missing `mode` reads as `canonical`). +- **Tests**: install/update unit tests covering canonical write, stub generation, mode preservation across `update`, symlink-to-stub conversion, and full-copy-to-stub conversion. +- **Tools affected**: Claude Code and Cursor (skill + command stubs); OpenCode and Codex/`.agents` (skill stub, no commands). diff --git a/openspec/changes/archive/2026-05-17-cli-canonical-install/specs/cli-init/spec.md b/openspec/changes/archive/2026-05-17-cli-canonical-install/specs/cli-init/spec.md new file mode 100644 index 0000000..18f260d --- /dev/null +++ b/openspec/changes/archive/2026-05-17-cli-canonical-install/specs/cli-init/spec.md @@ -0,0 +1,280 @@ +## ADDED Requirements + +### Requirement: Skill and command content is installed once to the canonical .taskless store + +The CLI SHALL write skill and command content exactly once per install, to a canonical store inside Taskless's owned `.taskless/` namespace: skill content to `.taskless/skills//SKILL.md` and command content to `.taskless/commands/tskl/.md`. The canonical write SHALL occur on every install that contains at least one skill or command, regardless of which tools are selected. + +The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's detection, install destination, or cleanup logic SHALL point at `.taskless/skills/` or `.taskless/commands/`. This guarantees that no install target can ever delete the canonical content. + +#### Scenario: Canonical content is written once + +- **WHEN** `taskless init` runs and the install plan contains the `taskless` skill and `tskl` command +- **THEN** the CLI SHALL write the full skill content to `.taskless/skills/taskless/SKILL.md` +- **AND** SHALL write the full command content to `.taskless/commands/tskl/tskl.md` + +#### Scenario: Canonical write happens regardless of selected tools + +- **WHEN** `taskless init` runs with any combination of tool directories selected, including none +- **THEN** the canonical `.taskless/` store SHALL be written + +#### Scenario: No tool target points at the canonical store + +- **WHEN** the install plan is constructed and applied +- **THEN** no tool target's install or cleanup operation SHALL write to or delete `.taskless/skills/` or `.taskless/commands/` + +### Requirement: Selected tool directories receive reference stubs + +For every selected tool directory, the CLI SHALL write a **reference stub** rather than a full copy. Each selected directory receives its own stub — `.claude/`, `.cursor/`, `.opencode/`, and `.agents/` are peer targets, and no directory is special-cased or routed onto another. A stub SHALL be an ordinary file (never a symlink). A skill stub SHALL contain valid YAML frontmatter with `name` and `description` copied from the canonical skill so the tool can discover and trigger it, plus a `metadata` block carrying `type: shim` (which marks the file as a reference stub) and the canonical `version` (carried for reference and kept in lockstep with the canonical content). Its body SHALL instruct the agent to read the canonical file (`.taskless/skills//SKILL.md` for skills, `.taskless/commands/tskl/.md` for commands) and follow it, and SHALL NOT duplicate the canonical content inline. Every stub SHALL point directly at a canonical file, never at another stub. + +The CLI SHALL NOT create symlinks for any tool, for skills or commands. + +The per-directory stub layout is: + +- `.claude/skills//SKILL.md` and `.claude/commands/tskl/.md` — Claude Code. +- `.cursor/skills//SKILL.md` and `.cursor/commands/tskl/.md` — Cursor. +- `.opencode/skills//SKILL.md` — OpenCode (no command stub). +- `.agents/skills//SKILL.md` — generic Agent Skills location, including Codex (no command stub). + +#### Scenario: Skill stub has valid frontmatter and a delegating body + +- **WHEN** the CLI writes a skill stub for a selected directory +- **THEN** the stub SHALL be a regular file with frontmatter `name` and `description` matching the canonical skill +- **AND** the stub frontmatter SHALL include `metadata.type: shim` +- **AND** its body SHALL delegate to `.taskless/skills//SKILL.md` without inlining the canonical instructions + +#### Scenario: Each selected directory gets its own stub + +- **WHEN** `taskless init` runs with `.cursor/` and `.opencode/` both selected +- **THEN** a skill stub SHALL be written to `.cursor/skills/taskless/SKILL.md` +- **AND** a skill stub SHALL be written to `.opencode/skills/taskless/SKILL.md` + +#### Scenario: No symlinks are created + +- **WHEN** any `taskless init` or `taskless update` run completes +- **THEN** no skill or command file or directory written by the CLI SHALL be a symlink + +### Requirement: Install manifest records a per-target install mode + +Each target entry in `.taskless/taskless.json` install state SHALL record a `mode` field with one of two values: `canonical` (the `.taskless/` store, holding full content) or `reference` (a tool directory holding stubs). The manifest SHALL remain backward-compatible: when reading a prior manifest with no `mode` field, the CLI SHALL treat existing entries as `canonical`. + +#### Scenario: Manifest records canonical and reference modes + +- **WHEN** `taskless init` writes the canonical store plus tool stubs +- **THEN** the `.taskless` target entry SHALL have `mode: "canonical"` +- **AND** each selected tool directory entry SHALL have `mode: "reference"` + +#### Scenario: Legacy manifest without mode is treated as canonical + +- **WHEN** the CLI reads a prior manifest whose target entries omit `mode` +- **THEN** it SHALL treat each such entry as `mode: "canonical"` without error + +### Requirement: Update rewrites canonical content and preserves reference stubs + +`taskless update` SHALL rewrite the canonical `.taskless/skills/` and `.taskless/commands/` content from the embedded bundle. For `reference`-mode targets, update SHALL create a stub only if it is missing, and SHALL NOT overwrite an existing stub with full canonical content. Update SHALL re-generate a stub in place only when its frontmatter `name`, `description`, or `metadata.version` has drifted from the canonical content; the stub's delegating body SHALL be preserved. + +Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any directory that another target sources content from. Removal logic SHALL operate only on entries recorded in the prior manifest and SHALL respect each entry's `mode`. + +#### Scenario: Update refreshes canonical content + +- **WHEN** `taskless update` runs against an install with a newer bundled skill version +- **THEN** `.taskless/skills/taskless/SKILL.md` SHALL be rewritten with the new content + +#### Scenario: Update does not clobber a reference stub + +- **WHEN** `taskless update` runs and `.claude/skills/taskless/SKILL.md` is an existing reference stub +- **THEN** update SHALL NOT replace it with full canonical content +- **AND** the stub SHALL continue to delegate to `.taskless/skills/taskless/SKILL.md` + +#### Scenario: Update never destroys the canonical store + +- **WHEN** `taskless update` processes its targets +- **THEN** it SHALL NOT delete `.taskless/skills/` or `.taskless/commands/` as part of cleaning up any target +- **AND** the canonical content SHALL remain readable throughout the update + +### Requirement: Existing installs converge to the canonical-plus-stub layout + +When a prior install left a full skill or command copy in a tool directory, or left a tool entry that exists on disk as a symlink, `taskless init`/`update` SHALL converge the repository onto the canonical-plus-stub layout with no separate migration step. `applyInstallPlan` SHALL seed the canonical `.taskless/` store and, for each `reference` target, SHALL rewrite the file unless it is already a current, non-drifted shim stub (a file carrying the `metadata.type: shim` marker with matching `name`, `description`, and `metadata.version`). A full copy lacks the marker and a symlink is detected by `lstat`, so each is rewritten as a real stub. Convergence SHALL be reported in the install summary. + +#### Scenario: A full per-tool copy is converted to a stub + +- **WHEN** a user whose prior install wrote a full `.cursor/skills/taskless/SKILL.md` runs `taskless update` +- **THEN** `.cursor/skills/taskless/SKILL.md` SHALL be replaced with a reference stub delegating to the canonical store +- **AND** the canonical `.taskless/skills/taskless/SKILL.md` SHALL be present + +#### Scenario: A symlinked tool entry is replaced with a real stub + +- **WHEN** `taskless update` finds `.claude/skills/taskless` recorded as a target but present on disk as a symlink +- **THEN** update SHALL replace the symlink with a real reference stub file +- **AND** SHALL NOT write through the symlink into another directory + +## MODIFIED Requirements + +### Requirement: Skills are installed as Agent Skills spec SKILL.md files + +The CLI SHALL install skill content using a canonical-store-plus-stub model rather than writing a full copy per detected tool. The full skill content SHALL be written exactly once to the canonical `.taskless/skills//SKILL.md`. Each selected tool directory SHALL receive its own reference stub as defined by the reference-stub requirement. Skill names SHALL be installed verbatim from the embedded source. No additional namespace prefixing SHALL be applied at install time. + +#### Scenario: Canonical skill content matches source + +- **WHEN** a skill is installed +- **THEN** the canonical `.taskless/skills//SKILL.md` content SHALL be identical to the embedded source from `skills/` +- **AND** no frontmatter fields SHALL be modified at install time + +#### Scenario: Selected tool directory receives a stub, not a full copy + +- **WHEN** the CLI installs the `taskless` skill and any tool directory is selected +- **THEN** that directory's skill location SHALL contain a reference stub +- **AND** SHALL NOT contain a full copy of the canonical skill content + +### Requirement: Install manifest records what was installed per target + +The install manifest in `.taskless/taskless.json` continues to record what was written per target. Each target entry SHALL additionally record a `mode` field (`canonical` or `reference`) as defined by the per-target install mode requirement. The `.taskless` target records the canonical store; tool-directory targets record the stubs written for that directory. + +#### Scenario: Manifest records the canonical store and reference stubs with modes + +- **WHEN** init writes the canonical store and stubs for `.claude/` and `.agents/` +- **THEN** the manifest's `install.targets[".taskless"]` SHALL have `mode: "canonical"` +- **AND** `install.targets[".claude"]` and `install.targets[".agents"]` SHALL each have `mode: "reference"` + +### Requirement: OpenCode detection signals + +OpenCode SHALL be detected when any of the following exist in the project root: + +- `.opencode/` directory +- `opencode.jsonc` file +- `opencode.json` file + +When `.opencode/` is selected, a reference skill stub SHALL be installed to `.opencode/skills//SKILL.md`. OpenCode SHALL NOT receive commands. + +#### Scenario: OpenCode detected by .opencode directory + +- **WHEN** `.opencode/` exists as a directory in the project root +- **THEN** OpenCode SHALL be detected + +#### Scenario: OpenCode detected by opencode.jsonc file + +- **WHEN** `opencode.jsonc` exists as a file in the project root +- **THEN** OpenCode SHALL be detected + +#### Scenario: OpenCode detected by opencode.json file + +- **WHEN** `opencode.json` exists as a file in the project root +- **THEN** OpenCode SHALL be detected + +#### Scenario: Selecting OpenCode writes a stub to .opencode/skills + +- **WHEN** `.opencode/` is selected and `taskless init` runs +- **THEN** a reference skill stub SHALL be written to `.opencode/skills/taskless/SKILL.md` +- **AND** no command file SHALL be written under `.opencode/` + +### Requirement: Cursor detection signals + +Cursor SHALL be detected when any of the following exist in the project root: + +- `.cursor/` directory +- `.cursorrules` file + +When `.cursor/` is selected, a reference skill stub SHALL be installed to `.cursor/skills//SKILL.md` and a reference command stub to `.cursor/commands/tskl/.md`. + +#### Scenario: Cursor detected by .cursor directory + +- **WHEN** `.cursor/` exists as a directory in the project root +- **THEN** Cursor SHALL be detected + +#### Scenario: Cursor detected by .cursorrules file + +- **WHEN** `.cursorrules` exists as a file in the project root +- **THEN** Cursor SHALL be detected + +#### Scenario: Selecting Cursor writes a skill stub and a command stub + +- **WHEN** `.cursor/` is selected and `taskless init` runs +- **THEN** a reference skill stub SHALL be written to `.cursor/skills/taskless/SKILL.md` +- **AND** a reference command stub SHALL be written to `.cursor/commands/tskl/` + +### Requirement: Claude Code detection signals + +Claude Code SHALL be detected when any of the following exist in the project root: + +- `.claude/` directory +- `CLAUDE.md` file + +When `.claude/` is selected, a reference skill stub SHALL be installed to `.claude/skills//SKILL.md` and a reference command stub to `.claude/commands/tskl/.md`. + +#### Scenario: Claude Code detected by .claude directory + +- **WHEN** `.claude/` exists as a directory in the project root +- **THEN** Claude Code SHALL be detected +- **AND** a reference skill stub SHALL be installed to `.claude/skills/` + +#### Scenario: Claude Code detected by CLAUDE.md file + +- **WHEN** `CLAUDE.md` exists as a file in the project root +- **AND** `.claude/` directory does not exist +- **THEN** Claude Code SHALL be detected +- **AND** a reference skill stub SHALL be installed to `.claude/skills/` + +### Requirement: Agents fallback install + +`.agents/` is an ordinary selectable tool target, a peer of `.claude/`, `.cursor/`, and `.opencode/`. When `.agents/` is selected, a reference skill stub SHALL be installed to `.agents/skills//SKILL.md`. The `.agents/` target SHALL NOT receive commands. When no tools are detected, `.agents/` SHALL be the default selected target so a `taskless init` with zero detected tools still produces a usable install. + +#### Scenario: .agents stub written when no tools detected + +- **WHEN** a user runs `taskless init` +- **AND** no tools are detected in the project root +- **THEN** `.agents/` SHALL be selected by default +- **AND** a reference skill stub SHALL be installed to `.agents/skills/` + +#### Scenario: .agents target does not install commands + +- **WHEN** the `.agents/skills/` stub is written +- **THEN** no command files SHALL be written to `.agents/` + +### Requirement: Claude Code commands are placed from embedded source + +For Claude Code, the CLI SHALL place a reference command stub at `.claude/commands/tskl/.md`. The stub SHALL delegate to the canonical command at `.taskless/commands/tskl/.md` and SHALL NOT inline the command content. + +#### Scenario: Command stub is placed for Claude Code + +- **WHEN** the CLI installs for Claude Code +- **THEN** it SHALL write a command stub to `.claude/commands/tskl/.md` +- **AND** the stub SHALL delegate to `.taskless/commands/tskl/.md` + +#### Scenario: Command stubs are only placed for tools that support commands + +- **WHEN** the CLI installs for a tool directory that does not support commands (`.opencode/`, `.agents/`) +- **THEN** no command file SHALL be written for that directory + +### Requirement: Cursor commands are placed from embedded source + +For Cursor, the CLI SHALL place a reference command stub at `.cursor/commands/tskl/.md`, mirroring the layout used for Claude Code. The stub SHALL delegate to the canonical command at `.taskless/commands/tskl/.md` and SHALL NOT inline the command content. + +#### Scenario: Command stub is placed for Cursor + +- **WHEN** the CLI installs for Cursor +- **THEN** it SHALL write a command stub to `.cursor/commands/tskl/.md` +- **AND** the stub SHALL delegate to `.taskless/commands/tskl/.md` + +#### Scenario: Cursor receives a skill stub and a command stub + +- **WHEN** `.cursor/` is selected and the install plan is applied +- **THEN** a skill stub SHALL be written to `.cursor/skills/` +- **AND** a command stub SHALL be written to `.cursor/commands/tskl/` + +### Requirement: Wizard prompts the user to choose install locations + +The wizard's location step SHALL be presented as a tool-selection step: "which tools do you want to enable Taskless for?". It SHALL offer a fixed multiselect of `.claude/`, `.cursor/`, `.opencode/`, and `.agents/`, with detected directories pre-checked and `.agents/` pre-checked when no tools are detected. The canonical `.taskless/` store SHALL NOT appear as a selectable entry — it is always written. Each checked entry SHALL produce one `reference` stub target; the resulting install plan always contains the single `taskless` skill (and, for `.claude/` and `.cursor/`, the `tskl` command). + +#### 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 + +#### Scenario: Agents is the default when nothing is detected + +- **WHEN** the wizard reaches the tool-selection step and no tools are detected +- **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 diff --git a/openspec/changes/archive/2026-05-17-cli-canonical-install/tasks.md b/openspec/changes/archive/2026-05-17-cli-canonical-install/tasks.md new file mode 100644 index 0000000..a4a3a4e --- /dev/null +++ b/openspec/changes/archive/2026-05-17-cli-canonical-install/tasks.md @@ -0,0 +1,56 @@ +## 1. Manifest schema: per-target mode + +- [x] 1.1 Add a `mode: "canonical" | "reference"` field to the per-target install-state type in `install/state.ts` +- [x] 1.2 When reading a legacy manifest with no `mode`, default each target entry to `canonical` +- [x] 1.3 Update `computeInstallDiff` so removals carry enough info to respect each entry's `mode` +- [x] 1.4 Unit test: legacy manifest reads as `canonical`; round-trip of a manifest with both modes + +## 2. Canonical store and stub generation + +- [x] 2.1 Add a canonical-write helper that writes embedded skill content to `.taskless/skills//SKILL.md` and command content to `.taskless/commands/tskl/.md` +- [x] 2.2 Add a stub-generation helper that builds a `SKILL.md`/command file with `name`+`description` frontmatter copied from the canonical content and a body delegating to the canonical path +- [x] 2.3 Add a helper to detect frontmatter drift between an existing stub and the canonical content +- [x] 2.4 Unit test: canonical content matches embedded source verbatim +- [x] 2.5 Unit test: generated stub has valid frontmatter, a delegating body, no inlined canonical content, and is a regular file +- [x] 2.6 Unit test: drift detection flags a changed `description` and ignores an unchanged stub + +## 3. Install-plan model and tool selection + +- [x] 3.1 Define a resolved install-plan target type `{ dir, mode, label, skills, commands }` decoupled from `ToolDescriptor`; keep `ToolDescriptor` for detection only +- [x] 3.2 Build the plan so the `.taskless/` canonical target (`mode: canonical`) is always present when the plan contains a skill or command +- [x] 3.3 Build a `reference`-mode stub target for each selected tool directory: skill stub for all; command stub for `.claude/` and `.cursor/` only +- [x] 3.4 Reframe the wizard location step as a fixed tool-selection multiselect (`.claude/.cursor/.opencode/.agents`), detected pre-checked, `.agents/` default when nothing detected +- [x] 3.5 Update non-interactive `init`/`update` plan construction to the same canonical + per-tool-stub model +- [x] 3.6 Update the install summary to report the canonical `.taskless/` write and each selected stub target + +## 4. Apply install plan: mode-aware writes + +- [x] 4.1 In `applyInstallPlan`, write full content only to the `canonical` `.taskless/` target +- [x] 4.2 For `reference` targets, write a stub only when absent or when frontmatter has drifted; never overwrite a stub with full content +- [x] 4.3 Remove the destructive `rm -rf` glob in `removeOwnedSkills`/`removeOwnedCommands`; rely solely on manifest-diff-driven removal +- [x] 4.4 Guarantee no target's cleanup deletes the canonical `.taskless/skills/` or `.taskless/commands/` store +- [x] 4.5 Ensure no code path creates a symlink for skills or commands +- [x] 4.6 Persist per-target `mode` into `taskless.json` on write + +## 5. Convergence: self-healing applyInstallPlan + +- [x] 5.1 Mark reference stubs with frontmatter `metadata.type: shim`; add an `isShimStub` helper +- [x] 5.2 In `applyInstallPlan`, rewrite a reference file unless it is a current, non-drifted shim stub — converging full copies, symlinks, and drifted stubs on the next init/update +- [x] 5.3 Unit test: a full per-tool copy is converted to a shim stub on apply +- [x] 5.4 Unit test: a symlinked tool entry is replaced with a real shim stub +- [x] 5.5 Unit test: migration version matrix — seed `.taskless/` at v0/v1/v2 and assert each forward-migrates to the latest schema + +## 6. Update behavior + +- [x] 6.1 Verify `taskless update` rewrites canonical `.taskless/` content and leaves reference stubs intact +- [x] 6.2 Verify update reports written and converted files per target in the install summary +- [x] 6.3 Unit test: update against a stub install does not clobber the stub +- [x] 6.4 Unit test: update never deletes the canonical store while cleaning another target + +## 7. Verification and docs + +- [x] 7.1 Run `pnpm typecheck` and `pnpm lint`; fix any issues +- [x] 7.2 Run the CLI test suite; update existing install/update tests for the canonical-store-plus-stub model +- [x] 7.3 Add a changeset describing the new install model and the BREAKING removal of `.cursor`/`.opencode` skill copies +- [x] 7.4 Update CLI README / `help` text for `init`/`update` to describe the canonical `.taskless/` layout +- [x] 7.5 Update the `.taskless/README.md` "Files" section to document `skills/` and `commands/` diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 7e4289a..e91c0b9 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -97,20 +97,20 @@ Claude Code SHALL be detected when any of the following exist in the project roo - `.claude/` directory - `CLAUDE.md` file -Skills SHALL be installed to `.claude/skills//SKILL.md`. Commands SHALL be installed to `.claude/commands/tskl/`. +When `.claude/` is selected, a reference skill stub SHALL be installed to `.claude/skills//SKILL.md` and a reference command stub to `.claude/commands/tskl/.md`. #### Scenario: Claude Code detected by .claude directory - **WHEN** `.claude/` exists as a directory in the project root - **THEN** Claude Code SHALL be detected -- **AND** skills SHALL be installed to `.claude/skills/` +- **AND** a reference skill stub SHALL be installed to `.claude/skills/` #### Scenario: Claude Code detected by CLAUDE.md file - **WHEN** `CLAUDE.md` exists as a file in the project root - **AND** `.claude/` directory does not exist - **THEN** Claude Code SHALL be detected -- **AND** skills SHALL be installed to `.claude/skills/` +- **AND** a reference skill stub SHALL be installed to `.claude/skills/` ### Requirement: OpenCode detection signals @@ -120,7 +120,7 @@ OpenCode SHALL be detected when any of the following exist in the project root: - `opencode.jsonc` file - `opencode.json` file -Skills SHALL be installed to `.opencode/skills//SKILL.md`. OpenCode SHALL NOT receive commands. +When `.opencode/` is selected, a reference skill stub SHALL be installed to `.opencode/skills//SKILL.md`. OpenCode SHALL NOT receive commands. #### Scenario: OpenCode detected by .opencode directory @@ -137,6 +137,12 @@ Skills SHALL be installed to `.opencode/skills//SKILL.md`. OpenCode SHALL - **WHEN** `opencode.json` exists as a file in the project root - **THEN** OpenCode SHALL be detected +#### Scenario: Selecting OpenCode writes a stub to .opencode/skills + +- **WHEN** `.opencode/` is selected and `taskless init` runs +- **THEN** a reference skill stub SHALL be written to `.opencode/skills/taskless/SKILL.md` +- **AND** no command file SHALL be written under `.opencode/` + ### Requirement: Cursor detection signals Cursor SHALL be detected when any of the following exist in the project root: @@ -144,7 +150,7 @@ Cursor SHALL be detected when any of the following exist in the project root: - `.cursor/` directory - `.cursorrules` file -Skills SHALL be installed to `.cursor/skills//SKILL.md`. Commands SHALL be installed to `.cursor/commands/tskl/.md`. +When `.cursor/` is selected, a reference skill stub SHALL be installed to `.cursor/skills//SKILL.md` and a reference command stub to `.cursor/commands/tskl/.md`. #### Scenario: Cursor detected by .cursor directory @@ -156,6 +162,12 @@ Skills SHALL be installed to `.cursor/skills//SKILL.md`. Commands SHALL be - **WHEN** `.cursorrules` exists as a file in the project root - **THEN** Cursor SHALL be detected +#### Scenario: Selecting Cursor writes a skill stub and a command stub + +- **WHEN** `.cursor/` is selected and `taskless init` runs +- **THEN** a reference skill stub SHALL be written to `.cursor/skills/taskless/SKILL.md` +- **AND** a reference command stub SHALL be written to `.cursor/commands/tskl/` + ### Requirement: Codex detection signals OpenAI Codex SHALL be detected when any of the following exist in the project root: @@ -215,24 +227,19 @@ When Codex is detected, the install plan SHALL treat `.agents/` as the Codex tar ### Requirement: Agents fallback install -When `taskless init` completes with zero tool installs (no tools were detected), skills SHALL be installed to `.agents/skills//SKILL.md`. The `.agents/` target SHALL NOT receive commands. The `.agents/` target SHALL NOT be part of tool detection — it is used only as a fallback. +`.agents/` is an ordinary selectable tool target, a peer of `.claude/`, `.cursor/`, and `.opencode/`. When `.agents/` is selected, a reference skill stub SHALL be installed to `.agents/skills//SKILL.md`. The `.agents/` target SHALL NOT receive commands. When no tools are detected, `.agents/` SHALL be the default selected target so a `taskless init` with zero detected tools still produces a usable install. -#### Scenario: Fallback installs to .agents when no tools detected +#### Scenario: .agents stub written when no tools detected - **WHEN** a user runs `taskless init` - **AND** no tools are detected in the project root -- **THEN** skills SHALL be installed to `.agents/skills/` +- **THEN** `.agents/` SHALL be selected by default +- **AND** a reference skill stub SHALL be installed to `.agents/skills/` -#### Scenario: Fallback not used when tools are detected +#### Scenario: .agents target does not install commands -- **WHEN** a user runs `taskless init` -- **AND** at least one tool is detected -- **THEN** skills SHALL NOT be installed to `.agents/skills/` - -#### Scenario: Fallback does not install commands - -- **WHEN** the `.agents/` fallback is used -- **THEN** no command files SHALL be written +- **WHEN** the `.agents/skills/` stub is written +- **THEN** no command files SHALL be written to `.agents/` ### Requirement: Detection checks files and directories with stat @@ -257,50 +264,50 @@ Directory detection signals SHALL use `fs.stat()` and verify `isDirectory()`. Fi ### Requirement: Skills are installed as Agent Skills spec SKILL.md files -For each detected tool that supports skills, the CLI SHALL write SKILL.md files into the tool's skill directory. Skill names SHALL be installed verbatim from the embedded source (already prefixed with `taskless-`). No additional namespace prefixing SHALL be applied at install time. - -#### Scenario: Skill installed with verbatim name +The CLI SHALL install skill content using a canonical-store-plus-stub model rather than writing a full copy per detected tool. The full skill content SHALL be written exactly once to the canonical `.taskless/skills//SKILL.md`. Each selected tool directory SHALL receive its own reference stub as defined by the reference-stub requirement. Skill names SHALL be installed verbatim from the embedded source. No additional namespace prefixing SHALL be applied at install time. -- **WHEN** the CLI installs the `taskless-info` skill for Claude Code -- **THEN** it SHALL write to `.claude/skills/taskless-info/SKILL.md` -- **AND** the `name` field in the SKILL.md frontmatter SHALL be `taskless-info` - -#### Scenario: Skill content matches source +#### Scenario: Canonical skill content matches source - **WHEN** a skill is installed -- **THEN** the SKILL.md content SHALL be identical to the embedded source from `skills/` +- **THEN** the canonical `.taskless/skills//SKILL.md` content SHALL be identical to the embedded source from `skills/` - **AND** no frontmatter fields SHALL be modified at install time +#### Scenario: Selected tool directory receives a stub, not a full copy + +- **WHEN** the CLI installs the `taskless` skill and any tool directory is selected +- **THEN** that directory's skill location SHALL contain a reference stub +- **AND** SHALL NOT contain a full copy of the canonical skill content + ### Requirement: Claude Code commands are placed from embedded source -For Claude Code specifically, the CLI SHALL also place command `.md` files from the embedded command source. Commands SHALL be placed in `.claude/commands/tskl/` with filenames matching the embedded source (prefix already stripped). +For Claude Code, the CLI SHALL place a reference command stub at `.claude/commands/tskl/.md`. The stub SHALL delegate to the canonical command at `.taskless/commands/tskl/.md` and SHALL NOT inline the command content. -#### Scenario: Command file is placed from embedded source +#### Scenario: Command stub is placed for Claude Code - **WHEN** the CLI installs for Claude Code -- **THEN** it SHALL write command files to `.claude/commands/tskl/.md` -- **AND** the command content SHALL be identical to the embedded source from `commands/tskl/` +- **THEN** it SHALL write a command stub to `.claude/commands/tskl/.md` +- **AND** the stub SHALL delegate to `.taskless/commands/tskl/.md` -#### Scenario: Command files are only placed for Claude Code +#### Scenario: Command stubs are only placed for tools that support commands -- **WHEN** the CLI installs for a tool that does not support commands -- **THEN** no command files SHALL be written for that tool +- **WHEN** the CLI installs for a tool directory that does not support commands (`.opencode/`, `.agents/`) +- **THEN** no command file SHALL be written for that directory ### Requirement: Cursor commands are placed from embedded source -For Cursor specifically, the CLI SHALL also place command `.md` files from the embedded command source. Commands SHALL be placed in `.cursor/commands/tskl/` with filenames matching the embedded source (prefix already stripped), mirroring the layout used for Claude Code. +For Cursor, the CLI SHALL place a reference command stub at `.cursor/commands/tskl/.md`, mirroring the layout used for Claude Code. The stub SHALL delegate to the canonical command at `.taskless/commands/tskl/.md` and SHALL NOT inline the command content. -#### Scenario: Command file is placed from embedded source +#### Scenario: Command stub is placed for Cursor - **WHEN** the CLI installs for Cursor -- **THEN** it SHALL write command files to `.cursor/commands/tskl/.md` -- **AND** the command content SHALL be identical to the embedded source from `commands/tskl/` +- **THEN** it SHALL write a command stub to `.cursor/commands/tskl/.md` +- **AND** the stub SHALL delegate to `.taskless/commands/tskl/.md` -#### Scenario: Cursor receives both skills and commands +#### Scenario: Cursor receives a skill stub and a command stub -- **WHEN** Cursor is detected and the install plan is applied -- **THEN** skills SHALL be written to `.cursor/skills/` -- **AND** commands SHALL be written to `.cursor/commands/tskl/` +- **WHEN** `.cursor/` is selected and the install plan is applied +- **THEN** a skill stub SHALL be written to `.cursor/skills/` +- **AND** a command stub SHALL be written to `.cursor/commands/tskl/` ### Requirement: Skills are bundled into the CLI at build time @@ -373,7 +380,22 @@ The wizard SHALL begin by rendering an ASCII rendition of the Taskless wordmark ### Requirement: Wizard prompts the user to choose install locations -The wizard's location step is unchanged in shape but the resulting install plan only ever contains the single `taskless` skill (and its corresponding `tskl` command). +The wizard's location step SHALL be presented as a tool-selection step: "which tools do you want to enable Taskless for?". It SHALL offer a fixed multiselect of `.claude/`, `.cursor/`, `.opencode/`, and `.agents/`, with detected directories pre-checked and `.agents/` pre-checked when no tools are detected. The canonical `.taskless/` store SHALL NOT appear as a selectable entry — it is always written. Each checked entry SHALL produce one `reference` stub target; the resulting install plan always contains the single `taskless` skill (and, for `.claude/` and `.cursor/`, the `tskl` command). + +#### 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 + +#### Scenario: Agents is the default when nothing is detected + +- **WHEN** the wizard reaches the tool-selection step and no tools are detected +- **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 ### Requirement: Wizard explains the auth tradeoff and offers to log in @@ -466,17 +488,17 @@ If the user cancels the wizard at any step (Ctrl-C, Esc, or equivalent clack can ### Requirement: Install manifest records what was installed per target -The install manifest in `.taskless/taskless.json` continues to record what was written per target. With one skill in the bundle, each target's `skills` array contains at most `["taskless"]` and each target's `commands` array contains at most `["tskl"]`. The manifest schema is unchanged — only the contents differ. +The install manifest in `.taskless/taskless.json` continues to record what was written per target. Each target entry SHALL additionally record a `mode` field (`canonical` or `reference`) as defined by the per-target install mode requirement. The `.taskless` target records the canonical store; tool-directory targets record the stubs written for that directory. -#### Scenario: Manifest records the consolidated skill +#### Scenario: Manifest records the canonical store and reference stubs with modes -- **WHEN** init writes the consolidated skill to `.claude/` -- **THEN** the manifest's `install.targets[".claude"].skills` SHALL be `["taskless"]` -- **AND** `install.targets[".claude"].commands` SHALL be `["tskl"]` +- **WHEN** init writes the canonical store and stubs for `.claude/` and `.agents/` +- **THEN** the manifest's `install.targets[".taskless"]` SHALL have `mode: "canonical"` +- **AND** `install.targets[".claude"]` and `install.targets[".agents"]` SHALL each have `mode: "reference"` ### Requirement: Re-install computes a diff against the previous manifest -Re-install diff computation is unchanged. With v0.7.0 the diff for a v0.6 user shows 10 skill removals + 6 command removals + 1 skill addition + 1 command addition per detected target. Removals require user confirmation per the existing requirement. +A re-install SHALL compute a diff against the previous manifest. With v0.7.0 the diff for a v0.6 user shows 10 skill removals + 6 command removals + 1 skill addition + 1 command addition per detected target. Removals SHALL require user confirmation per the existing requirement. #### Scenario: Upgrade from v0.6 shows removals in summary @@ -527,3 +549,110 @@ After a successful install (both wizard and `--no-interactive` paths), `taskless - **WHEN** a user re-runs `taskless init` and `install.onboarded` is already `true` - **THEN** the trailer SHALL still be printed - **AND** the trailer wording SHALL still adapt to whether commands were installed by the current run + +### Requirement: Skill and command content is installed once to the canonical .taskless store + +The CLI SHALL write skill and command content exactly once per install, to a canonical store inside Taskless's owned `.taskless/` namespace: skill content to `.taskless/skills//SKILL.md` and command content to `.taskless/commands/tskl/.md`. The canonical write SHALL occur on every install that contains at least one skill or command, regardless of which tools are selected. + +The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's detection, install destination, or cleanup logic SHALL point at `.taskless/skills/` or `.taskless/commands/`. This guarantees that no install target can ever delete the canonical content. + +#### Scenario: Canonical content is written once + +- **WHEN** `taskless init` runs and the install plan contains the `taskless` skill and `tskl` command +- **THEN** the CLI SHALL write the full skill content to `.taskless/skills/taskless/SKILL.md` +- **AND** SHALL write the full command content to `.taskless/commands/tskl/tskl.md` + +#### Scenario: Canonical write happens regardless of selected tools + +- **WHEN** `taskless init` runs with any combination of tool directories selected, including none +- **THEN** the canonical `.taskless/` store SHALL be written + +#### Scenario: No tool target points at the canonical store + +- **WHEN** the install plan is constructed and applied +- **THEN** no tool target's install or cleanup operation SHALL write to or delete `.taskless/skills/` or `.taskless/commands/` + +### Requirement: Selected tool directories receive reference stubs + +For every selected tool directory, the CLI SHALL write a **reference stub** rather than a full copy. Each selected directory receives its own stub — `.claude/`, `.cursor/`, `.opencode/`, and `.agents/` are peer targets, and no directory is special-cased or routed onto another. A stub SHALL be an ordinary file (never a symlink). A skill stub SHALL contain valid YAML frontmatter with `name` and `description` copied from the canonical skill so the tool can discover and trigger it, plus a `metadata` block carrying `type: shim` (which marks the file as a reference stub) and the canonical `version` (carried for reference and kept in lockstep with the canonical content). Its body SHALL instruct the agent to read the canonical file (`.taskless/skills//SKILL.md` for skills, `.taskless/commands/tskl/.md` for commands) and follow it, and SHALL NOT duplicate the canonical content inline. Every stub SHALL point directly at a canonical file, never at another stub. + +The CLI SHALL NOT create symlinks for any tool, for skills or commands. + +The per-directory stub layout is: + +- `.claude/skills//SKILL.md` and `.claude/commands/tskl/.md` — Claude Code. +- `.cursor/skills//SKILL.md` and `.cursor/commands/tskl/.md` — Cursor. +- `.opencode/skills//SKILL.md` — OpenCode (no command stub). +- `.agents/skills//SKILL.md` — generic Agent Skills location, including Codex (no command stub). + +#### Scenario: Skill stub has valid frontmatter and a delegating body + +- **WHEN** the CLI writes a skill stub for a selected directory +- **THEN** the stub SHALL be a regular file with frontmatter `name` and `description` matching the canonical skill +- **AND** the stub frontmatter SHALL include `metadata.type: shim` +- **AND** its body SHALL delegate to `.taskless/skills//SKILL.md` without inlining the canonical instructions + +#### Scenario: Each selected directory gets its own stub + +- **WHEN** `taskless init` runs with `.cursor/` and `.opencode/` both selected +- **THEN** a skill stub SHALL be written to `.cursor/skills/taskless/SKILL.md` +- **AND** a skill stub SHALL be written to `.opencode/skills/taskless/SKILL.md` + +#### Scenario: No symlinks are created + +- **WHEN** any `taskless init` or `taskless update` run completes +- **THEN** no skill or command file or directory written by the CLI SHALL be a symlink + +### Requirement: Install manifest records a per-target install mode + +Each target entry in `.taskless/taskless.json` install state SHALL record a `mode` field with one of two values: `canonical` (the `.taskless/` store, holding full content) or `reference` (a tool directory holding stubs). The manifest SHALL remain backward-compatible: when reading a prior manifest with no `mode` field, the CLI SHALL treat existing entries as `canonical`. + +#### Scenario: Manifest records canonical and reference modes + +- **WHEN** `taskless init` writes the canonical store plus tool stubs +- **THEN** the `.taskless` target entry SHALL have `mode: "canonical"` +- **AND** each selected tool directory entry SHALL have `mode: "reference"` + +#### Scenario: Legacy manifest without mode is treated as canonical + +- **WHEN** the CLI reads a prior manifest whose target entries omit `mode` +- **THEN** it SHALL treat each such entry as `mode: "canonical"` without error + +### Requirement: Update rewrites canonical content and preserves reference stubs + +`taskless update` SHALL rewrite the canonical `.taskless/skills/` and `.taskless/commands/` content from the embedded bundle. For `reference`-mode targets, update SHALL create a stub only if it is missing, and SHALL NOT overwrite an existing stub with full canonical content. Update SHALL re-generate a stub in place only when its frontmatter `name`, `description`, or `metadata.version` has drifted from the canonical content; the stub's delegating body SHALL be preserved. + +Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any directory that another target sources content from. Removal logic SHALL operate only on entries recorded in the prior manifest and SHALL respect each entry's `mode`. + +#### Scenario: Update refreshes canonical content + +- **WHEN** `taskless update` runs against an install with a newer bundled skill version +- **THEN** `.taskless/skills/taskless/SKILL.md` SHALL be rewritten with the new content + +#### Scenario: Update does not clobber a reference stub + +- **WHEN** `taskless update` runs and `.claude/skills/taskless/SKILL.md` is an existing reference stub +- **THEN** update SHALL NOT replace it with full canonical content +- **AND** the stub SHALL continue to delegate to `.taskless/skills/taskless/SKILL.md` + +#### Scenario: Update never destroys the canonical store + +- **WHEN** `taskless update` processes its targets +- **THEN** it SHALL NOT delete `.taskless/skills/` or `.taskless/commands/` as part of cleaning up any target +- **AND** the canonical content SHALL remain readable throughout the update + +### Requirement: Existing installs converge to the canonical-plus-stub layout + +When a prior install left a full skill or command copy in a tool directory, or left a tool entry that exists on disk as a symlink, `taskless init`/`update` SHALL converge the repository onto the canonical-plus-stub layout with no separate migration step. `applyInstallPlan` SHALL seed the canonical `.taskless/` store and, for each `reference` target, SHALL rewrite the file unless it is already a current, non-drifted shim stub (a file carrying the `metadata.type: shim` marker with matching `name`, `description`, and `metadata.version`). A full copy lacks the marker and a symlink is detected by `lstat`, so each is rewritten as a real stub. Convergence SHALL be reported in the install summary. + +#### Scenario: A full per-tool copy is converted to a stub + +- **WHEN** a user whose prior install wrote a full `.cursor/skills/taskless/SKILL.md` runs `taskless update` +- **THEN** `.cursor/skills/taskless/SKILL.md` SHALL be replaced with a reference stub delegating to the canonical store +- **AND** the canonical `.taskless/skills/taskless/SKILL.md` SHALL be present + +#### Scenario: A symlinked tool entry is replaced with a real stub + +- **WHEN** `taskless update` finds `.claude/skills/taskless` recorded as a target but present on disk as a symlink +- **THEN** update SHALL replace the symlink with a real reference stub file +- **AND** SHALL NOT write through the symlink into another directory diff --git a/packages/cli/README.md b/packages/cli/README.md index 7541822..15e05f6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -32,8 +32,8 @@ Outputs CLI version, tool status, and login info as JSON to stdout: ### `taskless init` Launches an interactive wizard that detects supported tool directories in the -current project (`.claude/`, `.opencode/`, `.cursor/`, `.agents/`), lets you -pick which ones to install into, and walks through the auth tradeoff before +current project (`.claude/`, `.opencode/`, `.cursor/`, `.agents/`), asks which +tools to enable Taskless for, and walks through the auth tradeoff before writing anything. Running `taskless` with no subcommand in a TTY also launches this wizard. Without a TTY, bare `taskless` prints a short context preamble followed by the topic index from `taskless help`. @@ -41,6 +41,13 @@ followed by the topic index from `taskless help`. In v0.7+, there is exactly one skill (`taskless`) and one command (`tskl`) — no opt-in selection needed. +The skill and command content is written **once** to a canonical store in +`.taskless/skills/` and `.taskless/commands/`. Each enabled tool directory +receives only a thin reference stub — an ordinary file with a delegating body, +never a symlink — so there is a single source of truth and no drift between +copies. Stale layouts from earlier versions (full per-tool copies, symlinks) +are converged into stubs automatically on the next `init`/`update`. + For CI and scripted installs, pass `--no-interactive` to skip all prompts: ```bash diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a38843e..7f134e3 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,12 +3,13 @@ import { defineCommand } from "citty"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { - AGENTS_FALLBACK, applyInstallPlan, + buildInstallPlan, + DEFAULT_SHIM_DIR, + detectSelectedDirectories, detectTools, - getEmbeddedSkills, getEmbeddedCommands, - type InstallPlanTarget, + getEmbeddedSkills, } from "../install/install"; import { getMandatorySkillNames } from "../install/catalog"; import { getTelemetry } from "../telemetry"; @@ -133,40 +134,20 @@ async function runNonInteractive( const mandatoryNames = new Set(getMandatorySkillNames()); const skills = allSkills.filter((s) => mandatoryNames.has(s.name)); const commands = getEmbeddedCommands(); - const tools = await detectTools(cwd); - - const planTargets: InstallPlanTarget[] = []; - let usingFallback = false; - - if (tools.length > 0) { - for (const tool of tools) { - planTargets.push({ - tool, - skills, - commands: tool.commands ? commands : [], - }); - } - } else { - usingFallback = true; - planTargets.push({ - tool: AGENTS_FALLBACK, - skills, - commands: [], - }); - } - const commandsInstalled = planTargets.some((t) => t.commands.length > 0); - - const result = await applyInstallPlan( - cwd, - { targets: planTargets }, - { cliVersion: getCliVersion() } + const detected = await detectTools(cwd); + const selectedDirectories = await detectSelectedDirectories(cwd); + const plan = buildInstallPlan(selectedDirectories, skills, commands); + const commandsInstalled = plan.targets.some( + (t) => t.mode === "reference" && t.commands.length > 0 ); - if (usingFallback) { - console.log( - `No tools detected. Using fallback: ${AGENTS_FALLBACK.installDir}/` - ); + const result = await applyInstallPlan(cwd, plan, { + cliVersion: getCliVersion(), + }); + + if (detected.length === 0) { + console.log(`No tools detected. Using fallback: ${DEFAULT_SHIM_DIR}/`); } const skillsByTarget = groupValuesByTarget( @@ -194,21 +175,31 @@ async function runNonInteractive( })) ); - for (const { tool } of planTargets) { - const targetSkills = skillsByTarget.get(tool.installDir) ?? []; - const targetCommands = commandsByTarget.get(tool.installDir) ?? []; - const removedSkills = removedSkillsByTarget.get(tool.installDir) ?? []; - const removedCommands = removedCommandsByTarget.get(tool.installDir) ?? []; + for (const target of plan.targets) { + const writtenSkills = skillsByTarget.get(target.dir) ?? []; + const writtenCommands = commandsByTarget.get(target.dir) ?? []; + const removedSkills = removedSkillsByTarget.get(target.dir) ?? []; + const removedCommands = removedCommandsByTarget.get(target.dir) ?? []; + const noun = target.mode === "canonical" ? "canonical file" : "stub"; + + if ( + writtenSkills.length === 0 && + writtenCommands.length === 0 && + removedSkills.length === 0 && + removedCommands.length === 0 + ) { + console.log(`${target.label} (${target.dir}/): up to date`); + continue; + } + console.log( - `${tool.name}: installed ${String(targetSkills.length)} skill(s)` + `${target.label} (${target.dir}/): wrote ${String(writtenSkills.length)} skill ${noun}(s)` ); - for (const name of targetSkills) { + for (const name of writtenSkills) { console.log(` - ${name}`); } - if (targetCommands.length > 0 && tool.commands) { - console.log( - ` + ${String(targetCommands.length)} command(s) in ${tool.installDir}/${tool.commands.path}/` - ); + if (writtenCommands.length > 0) { + console.log(` + ${String(writtenCommands.length)} command ${noun}(s)`); } if (removedSkills.length > 0) { console.log( @@ -244,7 +235,5 @@ function groupValuesByTarget( } async function detectedLocationDirectories(cwd: string): Promise { - const tools = await detectTools(cwd); - if (tools.length === 0) return [AGENTS_FALLBACK.installDir]; - return tools.map((t) => t.installDir); + return detectSelectedDirectories(cwd); } diff --git a/packages/cli/src/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index be8d1a8..ad96f92 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -8,6 +8,12 @@ import installMigration from "./migrations/0002-install"; export interface TasklessInstallTarget { skills?: string[]; commands?: string[]; + /** + * Install mode for this target: `canonical` (full content) or `reference` + * (stubs delegating to the canonical store). Absent in manifests written + * before this field existed; consumers treat a missing value as canonical. + */ + mode?: "canonical" | "reference"; } export interface TasklessInstallManifest { diff --git a/packages/cli/src/filesystem/migrations/0001-init.ts b/packages/cli/src/filesystem/migrations/0001-init.ts index 4b641e4..4d30160 100644 --- a/packages/cli/src/filesystem/migrations/0001-init.ts +++ b/packages/cli/src/filesystem/migrations/0001-init.ts @@ -29,6 +29,8 @@ npx @taskless/cli@latest check - \`taskless.json\` - Version manifest / migration state - \`.env.local.json\` - Local authentication credentials (git-ignored) +- \`skills/\` - Canonical Taskless skill content; tool directories hold thin stubs that delegate here (managed by Taskless) +- \`commands/\` - Canonical Taskless command content (managed by Taskless) - \`rules/\` - Generated ast-grep rules (managed by Taskless) - \`rule-tests/\` - Rule tests containing pass/fail examples for your rules `; diff --git a/packages/cli/src/help/init.txt b/packages/cli/src/help/init.txt index 8f7973e..a755fb0 100644 --- a/packages/cli/src/help/init.txt +++ b/packages/cli/src/help/init.txt @@ -26,18 +26,22 @@ npx @taskless/cli init --no-interactive ``` The wizard will: -1. Detect installed tools (Claude Code, OpenCode, Cursor) and let - the user pick install locations. +1. Detect installed tools (Claude Code, OpenCode, Cursor) and ask + which tools to enable Taskless for. 2. Show the auth tradeoff and offer to log in (skippable). 3. Show a diff against the previous install state before writing. -4. Write the consolidated `taskless` skill (and `tskl` command for - Claude Code) to each selected location. +4. Write the canonical `taskless` skill (and `tskl` command) once to + `.taskless/`, then a thin reference stub into each selected tool + directory (`.claude/`, `.cursor/`, `.opencode/`, `.agents/`). 5. Update `.taskless/taskless.json` with the install manifest. -If the user is on v0.6 or earlier, the wizard removes the obsolete -per-task skills (taskless-check, taskless-create-rule, etc.) and the -old slash commands as part of the install. The summary shows what -was removed. +The skill content lives in exactly one place — `.taskless/skills/` — +and each tool directory holds only a short stub that points at it. +Stale layouts from older installs (full per-tool copies, symlinks) +are converged into stubs automatically. If the user is on v0.6 or +earlier, the obsolete per-task skills (taskless-check, etc.) and old +slash commands are removed as part of the install; the summary shows +what was removed. ## Errors diff --git a/packages/cli/src/help/update.txt b/packages/cli/src/help/update.txt index 71739ba..2188663 100644 --- a/packages/cli/src/help/update.txt +++ b/packages/cli/src/help/update.txt @@ -27,11 +27,13 @@ The CLI: 1. Detects installed tools (Claude Code, OpenCode, Cursor, etc.) 2. Reads the previous install state from `.taskless/taskless.json` 3. Computes the diff (skills/commands to add, remove) -4. Writes the consolidated `taskless` skill (and `tskl` command for - tools that support commands) to each detected location -5. Removes obsolete files from prior versions +4. Rewrites the canonical `taskless` skill and `tskl` command in + `.taskless/`, and refreshes the reference stub in each detected + tool directory +5. Converts any stale full copies or symlinks left by older installs + into stubs, and removes obsolete files from prior versions 6. Updates the install manifest -7. Prints a summary including what was added and removed +7. Prints a summary including what was written and removed ## Errors diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts new file mode 100644 index 0000000..5df40aa --- /dev/null +++ b/packages/cli/src/install/canonical.ts @@ -0,0 +1,174 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { stringify } from "yaml"; + +import { parseFrontmatter } from "./frontmatter"; + +/** + * The Taskless-owned namespace that holds canonical skill and command + * content. No tool target ever installs into or cleans up this directory, + * which is what makes the canonical content safe from destructive cleanup. + */ +const CANONICAL_DIR = ".taskless"; + +/** Frontmatter fields copied verbatim from canonical content into a stub. */ +export interface StubFrontmatter { + name: string; + description: string; + /** + * Version of the canonical content this stub was generated from. Carried + * for reference and kept in lockstep — a version change counts as drift, so + * `update` regenerates the stub. + */ + version?: string; +} + +/** + * Command-stub frontmatter. Beyond `name`/`description`, the canonical + * command's `argument-hint` is preserved so the slash command keeps its + * editor argument hint. + */ +export interface CommandStubFrontmatter extends StubFrontmatter { + argumentHint?: string; +} + +/** Workspace-relative path of the canonical skill file for `name`. */ +export function canonicalSkillPath(name: string): string { + return `${CANONICAL_DIR}/skills/${name}/SKILL.md`; +} + +/** Workspace-relative path of the canonical command file for `filename`. */ +export function canonicalCommandPath(filename: string): string { + return `${CANONICAL_DIR}/commands/tskl/${filename}`; +} + +/** + * Write a skill's full content to the canonical store at + * `.taskless/skills//SKILL.md`. Content is written verbatim — the + * canonical store is the single source of truth. + */ +export async function writeCanonicalSkill( + cwd: string, + name: string, + content: string +): Promise { + const directory = join(cwd, CANONICAL_DIR, "skills", name); + await mkdir(directory, { recursive: true }); + const path = join(directory, "SKILL.md"); + await writeFile(path, content, "utf8"); + return path; +} + +/** + * Write a command's full content to the canonical store at + * `.taskless/commands/tskl/`. Content is written verbatim. + */ +export async function writeCanonicalCommand( + cwd: string, + filename: string, + content: string +): Promise { + const directory = join(cwd, CANONICAL_DIR, "commands", "tskl"); + await mkdir(directory, { recursive: true }); + const path = join(directory, filename); + await writeFile(path, content, "utf8"); + return path; +} + +/** + * Frontmatter `metadata` block stamped onto a stub. `type: shim` marks the + * file as a reference stub so it is distinguishable from a full copy without + * inspecting the body (see {@link isShimStub}); `version` records the + * canonical version the stub was generated from. + */ +function shimMetadata(version: string | undefined): Record { + return version ? { type: "shim", version } : { type: "shim" }; +} + +/** Serialize ordered frontmatter fields into a `---`-delimited block. */ +function frontmatterBlock(fields: Record): string { + const yaml = stringify(fields).trimEnd(); + return `---\n${yaml}\n---\n`; +} + +/** + * Build a reference skill stub: a real `SKILL.md` whose frontmatter carries + * `name`/`description` (so the tool discovers and triggers it) and whose body + * delegates to the canonical file without inlining its instructions. + */ +export function buildSkillStub(meta: StubFrontmatter): string { + const canonical = canonicalSkillPath(meta.name); + return ( + frontmatterBlock({ + name: meta.name, + description: meta.description, + metadata: shimMetadata(meta.version), + }) + + "\n" + + `This is a Taskless reference stub. The canonical skill is defined at ` + + `\`${canonical}\`.\n\n` + + `Read \`${canonical}\` and follow its instructions.\n` + ); +} + +/** + * Build a reference command stub: a real command file whose frontmatter + * carries `name`/`description` and whose body passes `$ARGUMENTS` through and + * delegates to the canonical command file. + */ +export function buildCommandStub( + meta: CommandStubFrontmatter, + filename: string +): string { + const canonical = canonicalCommandPath(filename); + const fields: Record = { + name: meta.name, + description: meta.description, + }; + if (meta.argumentHint) fields["argument-hint"] = meta.argumentHint; + fields.metadata = shimMetadata(meta.version); + return ( + frontmatterBlock(fields) + + "\n" + + `This command was invoked with: $ARGUMENTS\n\n` + + `This is a Taskless reference stub. The canonical command is defined at ` + + `\`${canonical}\`.\n\n` + + `Read \`${canonical}\` and follow its instructions, treating the text ` + + `above as the command arguments.\n` + ); +} + +/** + * Report whether an existing stub's frontmatter has drifted from the + * canonical `name`/`description`. Used by `update` to decide whether a stub + * needs regeneration — a stub that still matches is left untouched. + */ +export function stubFrontmatterDrifted( + existingStub: string, + meta: StubFrontmatter +): boolean { + const { data } = parseFrontmatter(existingStub); + if (data.name !== meta.name || data.description !== meta.description) { + return true; + } + const metadata = data.metadata as { version?: unknown } | undefined; + const stubVersion = + typeof metadata?.version === "string" ? metadata.version : undefined; + return stubVersion !== meta.version; +} + +/** + * Whether `content` is a Taskless reference stub, identified by its + * frontmatter `metadata.type === "shim"`. A full canonical copy lacks this + * marker, so install can tell a stub apart from a copy it must convert. + */ +export function isShimStub(content: string): boolean { + const { data } = parseFrontmatter(content); + const metadata = data.metadata; + return ( + typeof metadata === "object" && + metadata !== null && + (metadata as Record).type === "shim" + ); +} diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 33d2b4a..0719b72 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -1,18 +1,22 @@ -import { - readFile, - readdir, - rm, - stat, - writeFile, - mkdir, -} from "node:fs/promises"; -import { join, basename, dirname } from "node:path"; +import { lstat, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { + buildCommandStub, + buildSkillStub, + isShimStub, + stubFrontmatterDrifted, + writeCanonicalCommand, + writeCanonicalSkill, + type CommandStubFrontmatter, + type StubFrontmatter, +} from "./canonical"; import { parseFrontmatter } from "./frontmatter"; import { computeInstallDiff, readInstallState, writeInstallState, + type InstallMode, type InstallState, } from "./state"; @@ -28,6 +32,12 @@ const commandFiles: Record = import.meta.glob( { query: "?raw", import: "default", eager: true } ); +/** + * Taskless-owned namespace holding the canonical skill/command content. It is + * never a tool target — no detection, install, or cleanup logic points here. + */ +export const CANONICAL_DIR = ".taskless"; + // --- Types --- export interface DetectionSignal { @@ -35,16 +45,15 @@ export interface DetectionSignal { path: string; } +/** + * 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. + */ export interface ToolDescriptor { name: string; detect: DetectionSignal[]; installDir: string; - skills: { - path: string; - }; - commands?: { - path: string; - }; } export interface EmbeddedSkill { @@ -58,6 +67,10 @@ export interface EmbeddedSkill { export interface EmbeddedCommand { filename: string; content: string; + name: string; + description: string; + argumentHint?: string; + version?: string; } export interface SkillStatus { @@ -72,7 +85,7 @@ export interface ToolStatus { skills: SkillStatus[]; } -// --- Tool Registry --- +// --- Tool Registry (detection only) --- export const TOOLS: ToolDescriptor[] = [ { @@ -82,12 +95,6 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: "CLAUDE.md" }, ], installDir: ".claude", - skills: { - path: "skills", - }, - commands: { - path: "commands/tskl", - }, }, { name: "OpenCode", @@ -97,9 +104,6 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: "opencode.json" }, ], installDir: ".opencode", - skills: { - path: "skills", - }, }, { name: "Cursor", @@ -108,12 +112,6 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: ".cursorrules" }, ], installDir: ".cursor", - skills: { - path: "skills", - }, - commands: { - path: "commands/tskl", - }, }, { name: "Codex", @@ -122,20 +120,32 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: ".codex/config.toml" }, ], installDir: ".agents", - skills: { - path: "skills", - }, }, ]; -export const AGENTS_FALLBACK: ToolDescriptor = { - name: "Agent Skills", - detect: [], - installDir: ".agents", - skills: { - path: "skills", - }, -}; +/** + * A selectable stub destination. The wizard offers this fixed catalog as the + * "which tools do you want to enable Taskless for?" multiselect; every entry + * is a peer — no directory is special-cased or routed onto another. + */ +export interface ShimTarget { + /** Directory the stub is written into, relative to the project root. */ + dir: string; + /** Human-readable label for prompts and summaries. */ + label: string; + /** Whether this directory receives the `tskl` command stub. */ + commands: boolean; +} + +export const SHIM_TARGETS: readonly ShimTarget[] = [ + { dir: ".claude", label: "Claude Code", commands: true }, + { dir: ".cursor", label: "Cursor", commands: true }, + { dir: ".opencode", label: "OpenCode", commands: false }, + { dir: ".agents", label: "Agent Skills", commands: false }, +]; + +/** Directory selected by default when no tools are detected. */ +export const DEFAULT_SHIM_DIR = ".agents"; // --- Detection --- @@ -166,6 +176,19 @@ export async function detectTools(cwd: string): Promise { return results.filter((t): t is ToolDescriptor => t !== undefined); } +/** + * The shim directories to pre-select for a project: the install directories + * of every detected tool, or `.agents/` when nothing is detected. + */ +export async function detectSelectedDirectories( + cwd: string +): Promise { + const tools = await detectTools(cwd); + if (tools.length === 0) return [DEFAULT_SHIM_DIR]; + const directories = new Set(tools.map((t) => t.installDir)); + return SHIM_TARGETS.map((s) => s.dir).filter((d) => directories.has(d)); +} + // --- Embedded Skills --- export function getEmbeddedSkills(): EmbeddedSkill[] { @@ -189,137 +212,98 @@ export function getEmbeddedSkills(): EmbeddedSkill[] { // --- Embedded Commands --- export function getEmbeddedCommands(): EmbeddedCommand[] { - return Object.entries(commandFiles).map(([path, content]) => ({ - filename: basename(path), - content, - })); + return Object.entries(commandFiles).map(([path, content]) => { + const parsed = parseFrontmatter(content); + const data = parsed.data as { + name?: string; + description?: string; + "argument-hint"?: string; + metadata?: Record; + }; + const filename = basename(path); + return { + filename, + content, + name: data.name ?? filename.replace(/\.md$/, ""), + description: data.description ?? "", + argumentHint: data["argument-hint"], + version: data.metadata?.version, + }; + }); } -// --- Cleanup --- - -/** Known prefixes for Taskless-owned skills (current and legacy) */ -const SKILL_PREFIXES = ["taskless-", "use-taskless-"]; - -/** Known directory names for Taskless-owned commands (current and legacy) */ -const COMMAND_DIRS = ["tskl", "taskless"]; +// --- Install Plan --- /** - * Remove all Taskless-owned skill directories so a fresh set can be installed. - * Matches any directory starting with known prefixes. + * A resolved install target. Either the single `canonical` `.taskless/` store + * (full content) or a `reference` tool directory (thin stubs). */ -async function removeOwnedSkills( - cwd: string, - tool: ToolDescriptor -): Promise { - const skillsDirectory = join(cwd, tool.installDir, tool.skills.path); - - let entries: string[]; - try { - entries = await readdir(skillsDirectory); - } catch { - return; - } - - const owned = entries.filter((name) => - SKILL_PREFIXES.some((prefix) => name.startsWith(prefix)) - ); +export interface PlanTarget { + dir: string; + label: string; + mode: InstallMode; + skills: EmbeddedSkill[]; + commands: EmbeddedCommand[]; +} - for (const name of owned) { - await rm(join(skillsDirectory, name), { recursive: true, force: true }); - } +export interface InstallPlan { + targets: PlanTarget[]; } /** - * Remove all Taskless-owned command directories so a fresh set can be installed. - * Matches known command directory names. + * Build an install plan: the always-present canonical `.taskless/` target + * plus one `reference` stub target per selected directory. The canonical + * target is included whenever the plan carries any skill or command. */ -async function removeOwnedCommands( - cwd: string, - tool: ToolDescriptor -): Promise { - if (!tool.commands) return; - - const commandsBase = join(cwd, tool.installDir, dirname(tool.commands.path)); - - for (const directoryName of COMMAND_DIRS) { - await rm(join(commandsBase, directoryName), { - recursive: true, - force: true, - }); - } -} - -// --- Installation --- - -export interface InstallResult { - skills: string[]; - commands: string[]; -} - -export async function installForTool( - cwd: string, - tool: ToolDescriptor, +export function buildInstallPlan( + selectedDirectories: readonly string[], skills: EmbeddedSkill[], commands: EmbeddedCommand[] -): Promise { - const installedSkills: string[] = []; - const installedCommands: string[] = []; - - // Remove all Taskless-owned skills and commands before installing fresh - await removeOwnedSkills(cwd, tool); - await removeOwnedCommands(cwd, tool); - - // Install skills verbatim - for (const skill of skills) { - const skillDirectory = join( - cwd, - tool.installDir, - tool.skills.path, - skill.name - ); - await mkdir(skillDirectory, { recursive: true }); - await writeFile(join(skillDirectory, "SKILL.md"), skill.content, "utf8"); - installedSkills.push(skill.name); +): InstallPlan { + const targets: PlanTarget[] = []; + + if (skills.length > 0 || commands.length > 0) { + targets.push({ + dir: CANONICAL_DIR, + label: "Taskless canonical store", + mode: "canonical", + skills, + commands, + }); } - // Place commands for any tool descriptor that defines a commands path - if (tool.commands) { - const commandDirectory = join(cwd, tool.installDir, tool.commands.path); - await mkdir(commandDirectory, { recursive: true }); - for (const command of commands) { - await writeFile( - join(commandDirectory, command.filename), - command.content, - "utf8" - ); - installedCommands.push(command.filename); - } + for (const shim of SHIM_TARGETS) { + if (!selectedDirectories.includes(shim.dir)) continue; + targets.push({ + dir: shim.dir, + label: shim.label, + mode: "reference", + skills, + commands: shim.commands ? commands : [], + }); } - return { skills: installedSkills, commands: installedCommands }; + return { targets }; } -// --- Wizard-facing install plan --- - /** - * Per-target slice of an install plan. Each slice names the tool descriptor - * to install into plus the explicit skill and command lists the caller chose. + * The install-state target map a plan would produce. Shared by + * {@link applyInstallPlan} and callers that need to preview the diff (the + * wizard) so both derive identical state. */ -export interface InstallPlanTarget { - tool: ToolDescriptor; - skills: EmbeddedSkill[]; - commands: EmbeddedCommand[]; +export function planToStateTargets(plan: InstallPlan): InstallState["targets"] { + const targets: InstallState["targets"] = {}; + for (const target of plan.targets) { + targets[target.dir] = { + skills: target.skills.map((s) => s.name), + commands: target.commands.map((c) => c.filename), + mode: target.mode, + }; + } + return targets; } -/** - * Full plan handed to {@link applyInstallPlan}. Targets are independent — - * applying a plan replaces the entire install state, so targets not listed - * here and that were recorded in the previous install state will be treated - * as removals and surgically cleaned up. - */ -export interface InstallPlan { - targets: InstallPlanTarget[]; -} +// --- Installation --- export interface ApplyInstallOptions { cliVersion: string; @@ -334,91 +318,123 @@ export interface ApplyInstallResult { removedCommands: Array<{ target: string; command: string }>; } +/** Filesystem path of a skill directory inside any target. */ +function skillDirectory( + cwd: string, + targetDirectory: string, + name: string +): string { + return join(cwd, targetDirectory, "skills", name); +} + +/** Filesystem path of a command file inside any target. */ +function commandFile( + cwd: string, + targetDirectory: string, + filename: string +): string { + return join(cwd, targetDirectory, "commands", "tskl", filename); +} + +/** Replace `path` with a regular file if it currently exists as a symlink. */ +async function unlinkIfSymlink(path: string): Promise { + try { + const stats = await lstat(path); + if (stats.isSymbolicLink()) { + await rm(path, { force: true }); + } + } catch { + // Missing path — nothing to unlink. + } +} + /** - * Tool registry keyed by installDir so state-based cleanup can find the - * original paths for a target recorded in a previous manifest. The agents - * fallback is included since prior installs may have written to it. - * - * Order matters: TOOLS entries come first so registered tools win over the - * fallback when they share an installDir. Codex is registered with - * installDir `.agents` (Codex's documented read path), and Array.find - * returns the first match — so the lookup resolves to Codex rather than - * AGENTS_FALLBACK whenever both are valid for the same directory. + * Whether a `reference` file at `path` must be (re)written. Self-healing: a + * file is rewritten unless it is already a current, non-drifted shim stub. + * That converges anything stale onto the canonical-plus-stub layout — + * a missing file, a full copy left by an older install, a symlink, or a + * stub whose frontmatter has drifted. */ -const ALL_KNOWN_TOOLS: readonly ToolDescriptor[] = [...TOOLS, AGENTS_FALLBACK]; - -function findToolByInstallDirectory( - directory: string -): ToolDescriptor | undefined { - return ALL_KNOWN_TOOLS.find((t) => t.installDir === directory); +async function referenceNeedsRewrite( + path: string, + meta: StubFrontmatter +): Promise { + let stats; + try { + stats = await lstat(path); + } catch { + return true; // missing + } + if (stats.isSymbolicLink()) return true; // always replace a symlink + const existing = await readFile(path, "utf8").catch(() => {}); + if (existing === undefined) return true; + if (!isShimStub(existing)) return true; // a full copy — convert it + return stubFrontmatterDrifted(existing, meta); } -async function writeSkillFile( +/** + * Write a skill into a target. A `canonical` target receives the full + * embedded content; a `reference` target receives a shim stub, (re)written + * per {@link referenceNeedsRewrite}. Returns whether a file was written. + */ +async function writeSkill( cwd: string, - tool: ToolDescriptor, + target: PlanTarget, skill: EmbeddedSkill -): Promise { - const skillDirectory = join( - cwd, - tool.installDir, - tool.skills.path, - skill.name - ); - await mkdir(skillDirectory, { recursive: true }); - await writeFile(join(skillDirectory, "SKILL.md"), skill.content, "utf8"); +): Promise { + if (target.mode === "canonical") { + await writeCanonicalSkill(cwd, skill.name, skill.content); + return true; + } + + const path = join(skillDirectory(cwd, target.dir, skill.name), "SKILL.md"); + const meta: StubFrontmatter = { + name: skill.name, + description: skill.description, + version: skill.metadata.version, + }; + if (!(await referenceNeedsRewrite(path, meta))) return false; + + await unlinkIfSymlink(path); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, buildSkillStub(meta), "utf8"); + return true; } -async function writeCommandFile( +/** + * Write a command into a target. Mirrors {@link writeSkill}: full content for + * a `canonical` target, a self-healing shim stub for a `reference` target. + */ +async function writeCommand( cwd: string, - tool: ToolDescriptor, + target: PlanTarget, command: EmbeddedCommand -): Promise { - if (!tool.commands) return; - const commandDirectory = join(cwd, tool.installDir, tool.commands.path); - await mkdir(commandDirectory, { recursive: true }); - await writeFile( - join(commandDirectory, command.filename), - command.content, - "utf8" - ); -} +): Promise { + if (target.mode === "canonical") { + await writeCanonicalCommand(cwd, command.filename, command.content); + return true; + } -async function deleteSkill( - cwd: string, - tool: ToolDescriptor, - skillName: string -): Promise { - const skillDirectory = join( - cwd, - tool.installDir, - tool.skills.path, - skillName - ); - await rm(skillDirectory, { recursive: true, force: true }); -} + const path = commandFile(cwd, target.dir, command.filename); + const meta: CommandStubFrontmatter = { + name: command.name, + description: command.description, + argumentHint: command.argumentHint, + version: command.version, + }; + if (!(await referenceNeedsRewrite(path, meta))) return false; -async function deleteCommand( - cwd: string, - tool: ToolDescriptor, - commandFilename: string -): Promise { - if (!tool.commands) return; - const commandPath = join( - cwd, - tool.installDir, - tool.commands.path, - commandFilename - ); - await rm(commandPath, { force: true }); + await unlinkIfSymlink(path); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, buildCommandStub(meta, command.filename), "utf8"); + return true; } /** - * Apply an explicit install plan, using the previously-recorded state in - * `.taskless/taskless.json` to surgically delete files that are no longer - * selected. Unlike {@link installForTool}, this function NEVER glob-deletes - * Taskless-prefixed files — it only touches files recorded in the prior - * state. First-run installs (no prior state) write all plan files with - * zero deletions. + * Apply an install plan. The previous manifest state drives surgical cleanup: + * only skills/commands recorded for a target and no longer present are + * removed, and each removal is scoped to that target's own directory — so no + * target's cleanup can ever reach into the canonical `.taskless/` store. */ export async function applyInstallPlan( cwd: string, @@ -431,55 +447,44 @@ export async function applyInstallPlan( const nextState: InstallState = { installedAt: now().toISOString(), cliVersion: options.cliVersion, - targets: {}, + targets: planToStateTargets(plan), }; - for (const { tool, skills, commands } of plan.targets) { - nextState.targets[tool.installDir] = { - skills: skills.map((s) => s.name), - commands: commands.map((c) => c.filename), - }; - } - const diff = computeInstallDiff(previousState, nextState); const removedSkills: Array<{ target: string; skill: string }> = []; const removedCommands: Array<{ target: string; command: string }> = []; + // Removals are scoped to each diff entry's own directory. Since no tool + // target's directory is ever `.taskless`, this can never delete canonical + // content as a side effect of cleaning up another target. for (const entry of diff.entries) { - if ( - entry.removals.skills.length === 0 && - entry.removals.commands.length === 0 - ) { - continue; - } - const tool = findToolByInstallDirectory(entry.target); - if (!tool) continue; - for (const skillName of entry.removals.skills) { - await deleteSkill(cwd, tool, skillName); + await rm(skillDirectory(cwd, entry.target, skillName), { + recursive: true, + force: true, + }); removedSkills.push({ target: entry.target, skill: skillName }); } - for (const commandFilename of entry.removals.commands) { - await deleteCommand(cwd, tool, commandFilename); - removedCommands.push({ target: entry.target, command: commandFilename }); + for (const filename of entry.removals.commands) { + await rm(commandFile(cwd, entry.target, filename), { force: true }); + removedCommands.push({ target: entry.target, command: filename }); } } const writtenSkills: Array<{ target: string; skill: string }> = []; const writtenCommands: Array<{ target: string; command: string }> = []; - for (const { tool, skills, commands } of plan.targets) { - for (const skill of skills) { - await writeSkillFile(cwd, tool, skill); - writtenSkills.push({ target: tool.installDir, skill: skill.name }); + for (const target of plan.targets) { + for (const skill of target.skills) { + if (await writeSkill(cwd, target, skill)) { + writtenSkills.push({ target: target.dir, skill: skill.name }); + } } - for (const command of commands) { - await writeCommandFile(cwd, tool, command); - writtenCommands.push({ - target: tool.installDir, - command: command.filename, - }); + for (const command of target.commands) { + if (await writeCommand(cwd, target, command)) { + writtenCommands.push({ target: target.dir, command: command.filename }); + } } } @@ -496,11 +501,15 @@ export async function applyInstallPlan( // --- Staleness Check --- -async function readInstalledSkillVersion( - skillPath: string +async function readCanonicalSkillVersion( + cwd: string, + name: string ): Promise { try { - const content = await readFile(skillPath, "utf8"); + const content = await readFile( + join(skillDirectory(cwd, CANONICAL_DIR, name), "SKILL.md"), + "utf8" + ); const parsed = parseFrontmatter(content); const metadata = parsed.data.metadata as Record | undefined; return metadata?.version; @@ -509,39 +518,22 @@ async function readInstalledSkillVersion( } } +/** + * Report skill staleness for every detected tool. Versions are read from the + * canonical `.taskless/` store — stubs are version-free — so every detected + * tool reflects the single canonical install. + */ export async function checkStaleness(cwd: string): Promise { const embedded = getEmbeddedSkills(); const tools = await detectTools(cwd); - // Include .agents/ fallback if the directory exists (from a previous - // fallback install) AND no detected tool already uses that installDir. - // Codex registers installDir `.agents`, so without this guard a Codex - // repo would surface duplicate/contradictory statuses for the same - // directory (once under Codex, once under AGENTS_FALLBACK). - const fallbackAlreadyCovered = tools.some( - (t) => t.installDir === AGENTS_FALLBACK.installDir - ); - const fallbackExists = await stat(join(cwd, AGENTS_FALLBACK.installDir)) - .then((s) => s.isDirectory()) - .catch(() => false); - if (fallbackExists && !fallbackAlreadyCovered) { - tools.push(AGENTS_FALLBACK); - } - const results: ToolStatus[] = []; for (const tool of tools) { const skillStatuses: SkillStatus[] = []; for (const skill of embedded) { - const installedPath = join( - cwd, - tool.installDir, - tool.skills.path, - skill.name, - "SKILL.md" - ); - const installedVersion = await readInstalledSkillVersion(installedPath); + const installedVersion = await readCanonicalSkillVersion(cwd, skill.name); const currentVersion = skill.metadata.version ?? "unknown"; skillStatuses.push({ diff --git a/packages/cli/src/install/state.ts b/packages/cli/src/install/state.ts index ce88d55..9806ea4 100644 --- a/packages/cli/src/install/state.ts +++ b/packages/cli/src/install/state.ts @@ -8,9 +8,22 @@ import { readManifest, writeManifest } from "../filesystem/migrate"; const TASKLESS_DIR = ".taskless"; +/** + * How a target holds its content. A `canonical` target stores the full + * skill/command files; a `reference` target stores thin stubs that delegate + * to the canonical store. + */ +export type InstallMode = "canonical" | "reference"; + export interface InstallTargetRecord { skills: string[]; commands: string[]; + /** + * Install mode for this target. Optional on in-memory records and absent + * in manifests written before this field existed — {@link readInstallState} + * normalizes a missing value to `canonical`. + */ + mode?: InstallMode; } export interface InstallState { @@ -21,6 +34,13 @@ export interface InstallState { export interface InstallDiffEntry { target: string; + /** + * Effective install mode for this target: the next state's mode when the + * target survives, otherwise the previous state's mode, defaulting to + * `canonical`. Lets removal logic respect a target's mode without a second + * state lookup. + */ + mode: InstallMode; additions: { skills: string[]; commands: string[] }; removals: { skills: string[]; commands: string[] }; unchanged: { skills: string[]; commands: string[] }; @@ -41,6 +61,8 @@ function toInstallState( targets[name] = { skills: t.skills ?? [], commands: t.commands ?? [], + // A manifest written before `mode` existed is treated as canonical. + mode: t.mode ?? "canonical", }; } } @@ -57,6 +79,7 @@ function toInstallManifest(state: InstallState): TasklessInstallManifest { const entry: TasklessInstallTarget = {}; if (t.skills.length > 0) entry.skills = [...t.skills]; if (t.commands.length > 0) entry.commands = [...t.commands]; + if (t.mode) entry.mode = t.mode; targets[name] = entry; } const manifest: TasklessInstallManifest = { targets }; @@ -132,6 +155,13 @@ export function computeInstallDiff( const skillDiff = diffArrays(previous_.skills, current.skills); const commandDiff = diffArrays(previous_.commands, current.commands); + // Prefer the next state's mode (target survives), fall back to the + // previous state's mode (target removed), default to canonical. + const mode: InstallMode = + next.targets[target]?.mode ?? + previous.targets[target]?.mode ?? + "canonical"; + if (skillDiff.additions.length > 0 || commandDiff.additions.length > 0) { hasAdditions = true; } @@ -141,6 +171,7 @@ export function computeInstallDiff( entries.push({ target, + mode, additions: { skills: skillDiff.additions, commands: commandDiff.additions, diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index aa79f4d..157b56e 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -4,13 +4,10 @@ import { getOnboardTrailer } from "../commands/onboard"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { applyInstallPlan, - AGENTS_FALLBACK, + buildInstallPlan, getEmbeddedCommands, getEmbeddedSkills, - TOOLS, - type EmbeddedCommand, - type InstallPlanTarget, - type ToolDescriptor, + planToStateTargets, } from "../install/install"; import { computeInstallDiff, readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; @@ -35,20 +32,6 @@ export interface WizardResult { cancelledStep?: string; } -/** - * Lookup map keyed by installDir, derived from the canonical registry. - * - * AGENTS_FALLBACK is seeded first so that any registered tool sharing its - * installDir overwrites it (Object.fromEntries keeps the last value for a - * duplicate key). Codex's installDir is `.agents` — the same as the - * fallback — so this ordering ensures `.agents` resolves to Codex when - * Codex is in TOOLS, while still leaving the fallback descriptor available - * for users who never had Codex registered. - */ -const TOOL_BY_INSTALL_DIR: Record = Object.fromEntries( - [AGENTS_FALLBACK, ...TOOLS].map((tool) => [tool.installDir, tool]) -); - export async function runWizard( options: RunWizardOptions ): Promise { @@ -71,38 +54,19 @@ export async function runWizard( authPromptShown = authResult.prompted; authCompleted = authResult.loggedIn; - const embeddedSkills = getEmbeddedSkills(); - const embeddedCommands = getEmbeddedCommands(); // Catalog has one entry now (`taskless`); install all embedded skills. - const selectedSkills = embeddedSkills; - - const planTargets: InstallPlanTarget[] = locations.map((directory) => { - const tool = TOOL_BY_INSTALL_DIR[directory]; - if (!tool) { - throw new Error(`Unknown install location: ${directory}`); - } - return { - tool, - skills: selectedSkills, - commands: tool.commands ? embeddedCommands : [], - }; - }); + const plan = buildInstallPlan( + locations, + getEmbeddedSkills(), + getEmbeddedCommands() + ); const previousState = await readInstallState(options.cwd); - const nextStateForDiff = { + const diff = computeInstallDiff(previousState, { installedAt: previousState.installedAt, cliVersion: previousState.cliVersion, - targets: Object.fromEntries( - planTargets.map((t) => [ - t.tool.installDir, - { - skills: t.skills.map((s) => s.name), - commands: t.commands.map((c: EmbeddedCommand) => c.filename), - }, - ]) - ), - }; - const diff = computeInstallDiff(previousState, nextStateForDiff); + targets: planToStateTargets(plan), + }); const proceed = await renderSummaryAndConfirm(diff); if (!proceed) { @@ -114,14 +78,14 @@ export async function runWizard( await ensureTasklessDirectory(options.cwd, { onNotice: (message) => log.info(message), }); - await applyInstallPlan( - options.cwd, - { targets: planTargets }, - { cliVersion: getCliVersion() } - ); + await applyInstallPlan(options.cwd, plan, { + cliVersion: getCliVersion(), + }); outro("Taskless is ready to go."); - const commandsInstalled = planTargets.some((t) => t.commands.length > 0); + const commandsInstalled = plan.targets.some( + (t) => t.mode === "reference" && t.commands.length > 0 + ); console.log(getOnboardTrailer({ commandsInstalled })); return finish({ status: "completed" }); } catch (error) { diff --git a/packages/cli/src/wizard/steps/locations.ts b/packages/cli/src/wizard/steps/locations.ts index 92b1159..8e5c02d 100644 --- a/packages/cli/src/wizard/steps/locations.ts +++ b/packages/cli/src/wizard/steps/locations.ts @@ -1,55 +1,71 @@ import { multiselect, log } from "@clack/prompts"; -import { detectTools } from "../../install/install"; +import { + DEFAULT_SHIM_DIR, + SHIM_TARGETS, + detectTools, + type ToolDescriptor, +} from "../../install/install"; import { ask } from "../ask"; +/** A single entry in the tool-selection multiselect. */ export interface LocationChoice { - installDir: string; + value: string; label: string; - hint?: string; + hint: string; } -const ALL_LOCATIONS: LocationChoice[] = [ - { installDir: ".claude", label: ".claude/" }, - { installDir: ".opencode", label: ".opencode/" }, - { installDir: ".cursor", label: ".cursor/" }, - { - installDir: ".agents", - label: ".agents/", - hint: "fallback for unknown tools", - }, -]; +/** + * Build the tool-selection multiselect options and the pre-checked set from + * the detected tools. Pure — no prompt, no TTY — so the detection-to-choices + * mapping is unit-testable. + * + * Every shim target is always offered (a peer list); the canonical + * `.taskless/` store is never an entry. Detected tools are pre-checked, and + * `.agents/` is pre-checked as the default when nothing is detected. + */ +export function locationChoices(detected: ToolDescriptor[]): { + options: LocationChoice[]; + initialValues: string[]; +} { + const detectedDirectories = new Set(detected.map((t) => t.installDir)); + const initialValues = + detected.length > 0 ? [...detectedDirectories] : [DEFAULT_SHIM_DIR]; + + const options = SHIM_TARGETS.map((shim) => ({ + value: shim.dir, + label: `${shim.label} (${shim.dir}/)`, + hint: detectedDirectories.has(shim.dir) + ? "detected" + : shim.dir === DEFAULT_SHIM_DIR + ? "generic agent skills" + : "not detected", + })); + + return { options, initialValues }; +} +/** + * Ask which tools to enable Taskless for. The canonical `.taskless/` store is + * 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 { const detected = await detectTools(cwd); - const detectedDirectories = new Set(detected.map((t) => t.installDir)); - const detectedNamesByDirectory = new Map(); - for (const tool of detected) { - const existing = detectedNamesByDirectory.get(tool.installDir) ?? []; - existing.push(tool.name); - detectedNamesByDirectory.set(tool.installDir, existing); - } + const { options, initialValues } = locationChoices(detected); while (true) { - const options = ALL_LOCATIONS.map((loc) => ({ - value: loc.installDir, - label: loc.label, - hint: detectedDirectories.has(loc.installDir) - ? `detected (${(detectedNamesByDirectory.get(loc.installDir) ?? []).join(", ")})` - : (loc.hint ?? "not detected"), - })); - const selected = await ask("locations", () => multiselect({ - message: "Where should Taskless skills be installed?", + message: "Which tools do you want to enable Taskless for?", options, - initialValues: [...detectedDirectories], + initialValues, required: false, }) ); if (selected.length === 0) { - log.error("Select at least one install location."); + log.error("Select at least one tool."); continue; } diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index 5c7bf8f..45ac9bf 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -1,29 +1,26 @@ import { + lstat, mkdir, mkdtemp, readFile, rm, stat, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { applyInstallPlan, + buildInstallPlan, + getEmbeddedCommands, getEmbeddedSkills, - type ToolDescriptor, } from "../src/install/install"; -import { readInstallState } from "../src/install/state"; - -const TEST_TOOL: ToolDescriptor = { - name: "Test Tool", - detect: [{ type: "directory", path: ".test" }], - installDir: ".claude", - skills: { path: "skills" }, - commands: { path: "commands/tskl" }, -}; +import { isShimStub } from "../src/install/canonical"; +import { parseFrontmatter } from "../src/install/frontmatter"; +import { readInstallState, writeInstallState } from "../src/install/state"; async function exists(path: string): Promise { try { @@ -55,149 +52,153 @@ afterEach(async () => { await rm(cwd, { recursive: true, force: true }); }); +function tasklessSkill() { + const skill = getEmbeddedSkills().find((s) => s.name === "taskless"); + if (!skill) throw new Error("embedded taskless skill missing"); + return skill; +} + describe("applyInstallPlan", () => { - it("writes selected skills to the target and records state", async () => { - const skills = getEmbeddedSkills(); - const taskless = skills.find((s) => s.name === "taskless")!; - - const result = await applyInstallPlan( - cwd, - { - targets: [{ tool: TEST_TOOL, skills: [taskless], commands: [] }], - }, - { cliVersion: "0.5.4" } - ); + it("writes canonical full content and a reference stub, recording modes", async () => { + const skills = [tasklessSkill()]; + const commands = getEmbeddedCommands(); + const plan = buildInstallPlan([".claude"], skills, commands); - expect(result.writtenSkills).toHaveLength(1); - expect(result.removedSkills).toHaveLength(0); + const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - const skillContent = await readFile( + // Canonical store holds verbatim content. + const canonical = await readFile( + join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), + "utf8" + ); + expect(canonical).toBe(tasklessSkill().content); + + // The .claude target holds a delegating stub, not the full content. + const stub = await readFile( join(cwd, ".claude", "skills", "taskless", "SKILL.md"), "utf8" ); - expect(skillContent).toContain("taskless"); + expect(stub).not.toBe(canonical); + expect(parseFrontmatter(stub).content).toContain( + ".taskless/skills/taskless/SKILL.md" + ); + // Manifest records modes. const state = await readInstallState(cwd); - expect(state.cliVersion).toBe("0.5.4"); - expect(state.targets[".claude"]?.skills).toEqual(["taskless"]); + expect(state.targets[".taskless"]?.mode).toBe("canonical"); + expect(state.targets[".claude"]?.mode).toBe("reference"); + expect(result.writtenSkills.length).toBeGreaterThan(0); }); - it("surgically removes obsolete skills recorded in the previous state", async () => { - const skills = getEmbeddedSkills(); - const taskless = skills.find((s) => s.name === "taskless")!; - - // Seed manifest with a stale skill name (e.g. left over from a prior - // CLI version) AND a real one. We don't need the file on disk — the - // diff drives removals from the manifest, not the filesystem. - const { writeInstallState } = await import("../src/install/state"); - await writeInstallState(cwd, { - installedAt: "2026-04-01T00:00:00.000Z", - cliVersion: "0.5.4", - targets: { - ".claude": { - skills: ["taskless", "taskless-removed-fixture"], - commands: [], - }, - }, - }); - - const second = await applyInstallPlan( - cwd, - { - targets: [{ tool: TEST_TOOL, skills: [taskless], commands: [] }], - }, - { cliVersion: "0.5.4" } + it("writes a command stub only for command-capable targets", async () => { + const plan = buildInstallPlan( + [".claude", ".opencode"], + [tasklessSkill()], + getEmbeddedCommands() ); + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - expect(second.removedSkills).toEqual([ - { target: ".claude", skill: "taskless-removed-fixture" }, - ]); expect( - await exists(join(cwd, ".claude", "skills", "taskless", "SKILL.md")) + await exists(join(cwd, ".claude", "commands", "tskl", "tskl.md")) + ).toBe(true); + expect(await exists(join(cwd, ".opencode", "commands"))).toBe(false); + // .opencode still gets its skill stub. + expect( + await exists(join(cwd, ".opencode", "skills", "taskless", "SKILL.md")) ).toBe(true); }); - it("does not touch unknown files in the skills directory", async () => { - const skills = getEmbeddedSkills(); - const taskless = skills.find((s) => s.name === "taskless")!; + it("rewrites the canonical store but skips an unchanged reference stub", async () => { + const plan = buildInstallPlan( + [".claude"], + [tasklessSkill()], + getEmbeddedCommands() + ); + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); + const second = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - // User-owned file that the CLI must never delete - const userOwned = join(cwd, ".claude", "skills", "user-tool", "SKILL.md"); - await mkdir(join(cwd, ".claude", "skills", "user-tool"), { - recursive: true, + // Canonical is always rewritten; the unchanged .claude stub is skipped. + expect(second.writtenSkills).toContainEqual({ + target: ".taskless", + skill: "taskless", }); - await writeFile(userOwned, "# user skill", "utf8"); + expect(second.writtenSkills).not.toContainEqual({ + target: ".claude", + skill: "taskless", + }); + expect(second.removedSkills).toHaveLength(0); + }); - await applyInstallPlan( - cwd, - { - targets: [{ tool: TEST_TOOL, skills: [taskless], commands: [] }], - }, - { cliVersion: "0.5.4" } - ); + it("does not clobber an existing reference stub on re-run", async () => { + const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - expect(await exists(userOwned)).toBe(true); + const stubPath = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + const before = await readFile(stubPath, "utf8"); + + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); + const after = await readFile(stubPath, "utf8"); + + expect(after).toBe(before); + expect(parseFrontmatter(after).content).toContain( + ".taskless/skills/taskless/SKILL.md" + ); }); - it("zero-diff re-run produces no removals", async () => { - const skills = getEmbeddedSkills(); - const taskless = skills.find((s) => s.name === "taskless")!; + it("never deletes the canonical store while cleaning another target", async () => { + // Prior install recorded an obsolete skill under .claude. + await writeInstallState(cwd, { + installedAt: "2026-04-01T00:00:00.000Z", + cliVersion: "0.6.0", + targets: { + ".claude": { + skills: ["taskless", "taskless-obsolete"], + commands: [], + mode: "reference", + }, + }, + }); - const plan = { - targets: [{ tool: TEST_TOOL, skills: [taskless], commands: [] }], - }; - await applyInstallPlan(cwd, plan, { cliVersion: "0.5.4" }); - const second = await applyInstallPlan(cwd, plan, { cliVersion: "0.5.4" }); + const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - expect(second.removedSkills).toHaveLength(0); - expect(second.writtenSkills).toHaveLength(1); + // The obsolete .claude skill is gone; the canonical store is intact. + expect( + await exists(join(cwd, ".claude", "skills", "taskless-obsolete")) + ).toBe(false); + expect( + await exists(join(cwd, ".taskless", "skills", "taskless", "SKILL.md")) + ).toBe(true); }); - it("v0.6 → v0.7 migration removes 10 old skills + 6 old commands and writes the consolidated skill", async () => { - const skills = getEmbeddedSkills(); - const taskless = skills.find((s) => s.name === "taskless")!; - - // Seed manifest exactly as v0.6 would have left it. + it("surgically removes obsolete skills and commands recorded for a target", async () => { const v6Skills = [ "taskless-check", "taskless-ci", "taskless-create-rule", - "taskless-create-rule-anonymous", "taskless-delete-rule", "taskless-improve-rule", - "taskless-improve-rule-anonymous", "taskless-info", "taskless-login", "taskless-logout", ]; - const v6Commands = [ - "check.md", - "improve.md", - "info.md", - "login.md", - "logout.md", - "rule.md", - ]; + const v6Commands = ["check.md", "improve.md", "info.md"]; - // Create the actual files on disk so we can assert they're deleted. const claudeSkills = join(cwd, ".claude", "skills"); for (const name of v6Skills) { await mkdir(join(claudeSkills, name), { recursive: true }); await writeFile( join(claudeSkills, name, "SKILL.md"), - "# stale v0.6 skill", + "# stale skill", "utf8" ); } const claudeCommands = join(cwd, ".claude", "commands", "tskl"); await mkdir(claudeCommands, { recursive: true }); for (const name of v6Commands) { - await writeFile(join(claudeCommands, name), "stale v0.6 command", "utf8"); + await writeFile(join(claudeCommands, name), "stale command", "utf8"); } - // Record those installs in the manifest so the diff sees them as - // existing. - const { writeInstallState } = await import("../src/install/state"); await writeInstallState(cwd, { installedAt: "2026-04-17T00:00:00.000Z", cliVersion: "0.6.0", @@ -206,44 +207,72 @@ describe("applyInstallPlan", () => { }, }); - // Now run the v0.7 install plan: one skill (taskless), one command - // (tskl.md). The install should delete the 10 + 6 obsolete files and - // write the new ones. - const result = await applyInstallPlan( - cwd, - { - targets: [ - { - tool: TEST_TOOL, - skills: [taskless], - commands: [{ filename: "tskl.md", content: "# new command" }], - }, - ], - }, - { cliVersion: "0.7.0" } + const plan = buildInstallPlan( + [".claude"], + [tasklessSkill()], + getEmbeddedCommands() ); + const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - expect(result.removedSkills).toHaveLength(10); - expect(result.removedCommands).toHaveLength(6); - expect(result.writtenSkills).toHaveLength(1); - expect(result.writtenCommands).toHaveLength(1); - - // Old files gone + expect(result.removedSkills).toHaveLength(v6Skills.length); + expect(result.removedCommands).toHaveLength(v6Commands.length); for (const name of v6Skills) { expect(await exists(join(claudeSkills, name))).toBe(false); } for (const name of v6Commands) { expect(await exists(join(claudeCommands, name))).toBe(false); } + }); - // New files present - expect(await exists(join(claudeSkills, "taskless", "SKILL.md"))).toBe(true); - expect(await exists(join(claudeCommands, "tskl.md"))).toBe(true); + it("converts a full per-tool copy into a shim stub", async () => { + const skill = tasklessSkill(); + const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + // An older install left a full copy here — no shim marker. + await mkdir(dirname(claudeSkill), { recursive: true }); + await writeFile(claudeSkill, skill.content, "utf8"); + expect(isShimStub(await readFile(claudeSkill, "utf8"))).toBe(false); - // Manifest reflects the new layout - const state = await readInstallState(cwd); - expect(state.cliVersion).toBe("0.7.0"); - expect(state.targets[".claude"]?.skills).toEqual(["taskless"]); - expect(state.targets[".claude"]?.commands).toEqual(["tskl.md"]); + await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill], []), { + cliVersion: "0.7.0", + }); + + const after = await readFile(claudeSkill, "utf8"); + expect(isShimStub(after)).toBe(true); + expect(parseFrontmatter(after).content).toContain( + ".taskless/skills/taskless/SKILL.md" + ); + }); + + it("replaces a symlinked tool entry with a real shim stub", async () => { + const skill = tasklessSkill(); + const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); + const linkTarget = join(cwd, "external-skill.md"); + await writeFile(linkTarget, skill.content, "utf8"); + await mkdir(dirname(claudeSkill), { recursive: true }); + await symlink(linkTarget, claudeSkill); + + await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill], []), { + cliVersion: "0.7.0", + }); + + const stats = await lstat(claudeSkill); + expect(stats.isSymbolicLink()).toBe(false); + expect(stats.isFile()).toBe(true); + expect(isShimStub(await readFile(claudeSkill, "utf8"))).toBe(true); + // The symlink target file itself is left untouched. + expect(await readFile(linkTarget, "utf8")).toBe(skill.content); + }); + + it("does not touch unknown files in a skills directory", async () => { + const userOwned = join(cwd, ".claude", "skills", "user-tool", "SKILL.md"); + await mkdir(join(cwd, ".claude", "skills", "user-tool"), { + recursive: true, + }); + await writeFile(userOwned, "# user skill", "utf8"); + + const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); + + expect(await exists(userOwned)).toBe(true); }); }); diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts new file mode 100644 index 0000000..86a637d --- /dev/null +++ b/packages/cli/test/canonical-store.test.ts @@ -0,0 +1,205 @@ +import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + buildCommandStub, + buildSkillStub, + isShimStub, + stubFrontmatterDrifted, + writeCanonicalCommand, + writeCanonicalSkill, +} from "../src/install/canonical"; +import { parseFrontmatter } from "../src/install/frontmatter"; + +const SENTINEL = "INLINED-CANONICAL-BODY-MARKER"; + +const skillSource = `--- +name: taskless +description: Use for any Taskless task. +--- + +# Taskless + +${SENTINEL} — full canonical skill instructions live here. +`; + +describe("writeCanonicalSkill / writeCanonicalCommand", () => { + let temporaryDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "taskless-canonical-")); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + it("writes skill content to .taskless/skills verbatim", async () => { + const path = await writeCanonicalSkill( + temporaryDirectory, + "taskless", + skillSource + ); + expect(path).toBe( + join(temporaryDirectory, ".taskless", "skills", "taskless", "SKILL.md") + ); + expect(await readFile(path, "utf8")).toBe(skillSource); + }); + + it("writes command content to .taskless/commands/tskl verbatim", async () => { + const commandSource = "---\nname: Taskless\n---\n\nbody\n"; + const path = await writeCanonicalCommand( + temporaryDirectory, + "tskl.md", + commandSource + ); + expect(path).toBe( + join(temporaryDirectory, ".taskless", "commands", "tskl", "tskl.md") + ); + expect(await readFile(path, "utf8")).toBe(commandSource); + }); +}); + +describe("buildSkillStub", () => { + it("produces valid frontmatter, a delegating body, and no inlined content", () => { + const stub = buildSkillStub({ + name: "taskless", + description: "Use for any Taskless task.", + }); + + const { data, content } = parseFrontmatter(stub); + expect(data.name).toBe("taskless"); + expect(data.description).toBe("Use for any Taskless task."); + expect((data.metadata as { type?: string }).type).toBe("shim"); + + expect(content).toContain(".taskless/skills/taskless/SKILL.md"); + expect(content.toLowerCase()).toContain("read"); + expect(stub).not.toContain(SENTINEL); + }); + + it("carries metadata.type shim and the canonical version", () => { + const stub = buildSkillStub({ + name: "taskless", + description: "d", + version: "0.7.0", + }); + const metadata = parseFrontmatter(stub).data.metadata as { + type?: string; + version?: string; + }; + expect(metadata.type).toBe("shim"); + expect(metadata.version).toBe("0.7.0"); + }); + + it("writes to disk as a regular file, not a symlink", async () => { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "taskless-stub-")); + try { + const stubPath = join(temporaryDirectory, "SKILL.md"); + await writeFile( + stubPath, + buildSkillStub({ name: "taskless", description: "desc" }), + "utf8" + ); + const stats = await lstat(stubPath); + expect(stats.isFile()).toBe(true); + expect(stats.isSymbolicLink()).toBe(false); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + }); +}); + +describe("buildCommandStub", () => { + it("passes $ARGUMENTS through and delegates to the canonical command", () => { + const stub = buildCommandStub( + { name: "Taskless", description: "Run any Taskless action." }, + "tskl.md" + ); + const { data, content } = parseFrontmatter(stub); + expect(data.name).toBe("Taskless"); + expect(content).toContain("$ARGUMENTS"); + expect(content).toContain(".taskless/commands/tskl/tskl.md"); + }); + + it("preserves the canonical argument-hint when present", () => { + const stub = buildCommandStub( + { + name: "Taskless", + description: "Run any Taskless action.", + argumentHint: "", + }, + "tskl.md" + ); + const { data } = parseFrontmatter(stub); + expect(data["argument-hint"]).toBe(""); + }); + + it("omits argument-hint when the canonical command has none", () => { + const stub = buildCommandStub( + { name: "Taskless", description: "Run any Taskless action." }, + "tskl.md" + ); + const { data } = parseFrontmatter(stub); + expect(data["argument-hint"]).toBeUndefined(); + }); +}); + +describe("isShimStub", () => { + it("returns true for a generated skill stub", () => { + expect( + isShimStub(buildSkillStub({ name: "taskless", description: "d" })) + ).toBe(true); + }); + + it("returns true for a generated command stub", () => { + expect( + isShimStub( + buildCommandStub({ name: "Taskless", description: "d" }, "tskl.md") + ) + ).toBe(true); + }); + + it("returns false for a full canonical skill copy", () => { + // skillSource has no metadata.type — it is a full copy, not a stub. + expect(isShimStub(skillSource)).toBe(false); + }); + + it("returns false for content without frontmatter", () => { + expect(isShimStub("# just a heading\n")).toBe(false); + }); +}); + +describe("stubFrontmatterDrifted", () => { + const meta = { name: "taskless", description: "Use for any Taskless task." }; + + it("returns false for a stub that still matches the canonical", () => { + const stub = buildSkillStub(meta); + expect(stubFrontmatterDrifted(stub, meta)).toBe(false); + }); + + it("returns true when the description has drifted", () => { + const stub = buildSkillStub(meta); + expect( + stubFrontmatterDrifted(stub, { ...meta, description: "changed" }) + ).toBe(true); + }); + + it("returns true when the name has drifted", () => { + const stub = buildSkillStub(meta); + expect(stubFrontmatterDrifted(stub, { ...meta, name: "renamed" })).toBe( + true + ); + }); + + it("returns true when the version has drifted", () => { + const stub = buildSkillStub({ ...meta, version: "0.7.0" }); + expect(stubFrontmatterDrifted(stub, { ...meta, version: "0.7.0" })).toBe( + false + ); + expect(stubFrontmatterDrifted(stub, { ...meta, version: "0.8.0" })).toBe( + true + ); + }); +}); diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 0a252ba..9291589 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -62,7 +62,7 @@ describe("cli", () => { temporaryDirectory, ]); expect(stdout).toContain("No tools detected. Using fallback: .agents/"); - expect(stdout).toContain("Agent Skills: installed"); + expect(stdout).toContain("Agent Skills (.agents/)"); }); it("installs skills when .claude/ directory exists", async () => { diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index b6d9c7f..87fdb36 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -39,7 +39,7 @@ describe("taskless init --no-interactive", () => { cwd, ]); - expect(stdout).toContain("Claude Code: installed"); + expect(stdout).toContain("Claude Code (.claude/)"); expect( await exists(join(cwd, ".claude", "skills", "taskless", "SKILL.md")) @@ -91,7 +91,7 @@ describe("taskless init --no-interactive", () => { ]); expect(stderr).toContain("Detected non-interactive context"); - expect(stdout).toContain("Claude Code: installed"); + expect(stdout).toContain("Claude Code (.claude/)"); }); it("`taskless update` runs the same non-interactive install path", async () => { @@ -104,7 +104,7 @@ describe("taskless init --no-interactive", () => { cwd, ]); - expect(stdout).toContain("Claude Code: installed"); + expect(stdout).toContain("Claude Code (.claude/)"); expect( await exists(join(cwd, ".claude", "skills", "taskless", "SKILL.md")) ).toBe(true); diff --git a/packages/cli/test/install-state.test.ts b/packages/cli/test/install-state.test.ts index c6c7da1..c801d50 100644 --- a/packages/cli/test/install-state.test.ts +++ b/packages/cli/test/install-state.test.ts @@ -137,6 +137,7 @@ describe("readInstallState / writeInstallState", () => { ".claude": { skills: ["taskless-check", "taskless-ci"], commands: ["check"], + mode: "canonical", }, }, }; @@ -146,6 +147,46 @@ describe("readInstallState / writeInstallState", () => { expect(roundtrip).toEqual(state); }); + it("treats a legacy manifest target without mode as canonical", async () => { + const tasklessDirectory = join(temporaryDirectory, ".taskless"); + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ + version: 2, + install: { + targets: { ".claude": { skills: ["taskless"], commands: ["tskl"] } }, + }, + }), + "utf8" + ); + + const state = await readInstallState(temporaryDirectory); + expect(state.targets[".claude"]?.mode).toBe("canonical"); + }); + + it("round-trips canonical and reference modes", async () => { + const state: InstallState = { + targets: { + ".taskless": { + skills: ["taskless"], + commands: ["tskl"], + mode: "canonical", + }, + ".claude": { + skills: ["taskless"], + commands: ["tskl"], + mode: "reference", + }, + }, + }; + await writeInstallState(temporaryDirectory, state); + + const roundtrip = await readInstallState(temporaryDirectory); + expect(roundtrip.targets[".taskless"]?.mode).toBe("canonical"); + expect(roundtrip.targets[".claude"]?.mode).toBe("reference"); + expect(roundtrip).toEqual(state); + }); + it("preserves other top-level manifest fields", async () => { const tasklessDirectory = join(temporaryDirectory, ".taskless"); await writeFile( diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts index 9b80df4..932144c 100644 --- a/packages/cli/test/install.test.ts +++ b/packages/cli/test/install.test.ts @@ -1,23 +1,16 @@ -import { - mkdir, - mkdtemp, - readdir, - rm, - readFile, - writeFile, -} from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - AGENTS_FALLBACK, + applyInstallPlan, + buildInstallPlan, + checkStaleness, + detectSelectedDirectories, detectTools, getEmbeddedCommands, getEmbeddedSkills, - installForTool, - checkStaleness, - TOOLS, } from "../src/install/install"; let cwd: string; @@ -98,32 +91,11 @@ describe("detectTools", () => { expect(tools[0]!.installDir).toBe(".agents"); }); - it("detects Codex via .codex/config.toml file", async () => { - await mkdir(join(cwd, ".codex"), { recursive: true }); - await writeFile(join(cwd, ".codex", "config.toml"), "", "utf8"); - // Both detection signals are present (directory + file); this asserts - // that having config.toml in place still yields a single Codex detection - // (no duplicates from multiple matching signals). - const tools = await detectTools(cwd); - expect(tools).toHaveLength(1); - expect(tools[0]!.name).toBe("Codex"); - }); - it("returns Codex once when multiple Codex signals match", async () => { await mkdir(join(cwd, ".codex"), { recursive: true }); await writeFile(join(cwd, ".codex", "config.toml"), "", "utf8"); const tools = await detectTools(cwd); - const codexEntries = tools.filter((t) => t.name === "Codex"); - expect(codexEntries).toHaveLength(1); - }); - - it("detects Codex alongside Claude Code", async () => { - await mkdir(join(cwd, ".codex"), { recursive: true }); - await mkdir(join(cwd, ".claude"), { recursive: true }); - const tools = await detectTools(cwd); - const names = tools.map((t) => t.name); - expect(names).toContain("Codex"); - expect(names).toContain("Claude Code"); + expect(tools.filter((t) => t.name === "Codex")).toHaveLength(1); }); it("returns empty when no signals match", async () => { @@ -132,192 +104,57 @@ describe("detectTools", () => { }); }); -describe("installForTool", () => { - it("writes skills to installDir-based path", async () => { - const skills = getEmbeddedSkills(); - const tool = { - name: "Test Tool", - detect: [{ type: "directory" as const, path: ".test" }], - installDir: ".test", - skills: { path: "skills" }, - }; - - const result = await installForTool(cwd, tool, skills, []); - expect(result.skills.length).toBeGreaterThan(0); - - const firstSkill = result.skills[0]!; - const content = await readFile( - join(cwd, ".test", "skills", firstSkill, "SKILL.md"), - "utf8" - ); - expect(content).toBeTruthy(); - }); - - it("creates directories if installDir does not exist", async () => { - const tool = { - name: "Test Tool", - detect: [{ type: "file" as const, path: "TEST.md" }], - installDir: ".nonexistent", - skills: { path: "skills" }, - }; - const skills = getEmbeddedSkills(); - - const result = await installForTool(cwd, tool, skills, []); - expect(result.skills.length).toBeGreaterThan(0); - - const firstSkill = result.skills[0]!; - const content = await readFile( - join(cwd, ".nonexistent", "skills", firstSkill, "SKILL.md"), - "utf8" - ); - expect(content).toBeTruthy(); +describe("detectSelectedDirectories", () => { + it("defaults to .agents when no tools are detected", async () => { + expect(await detectSelectedDirectories(cwd)).toEqual([".agents"]); }); -}); - -describe("Codex install", () => { - it("writes skills to .agents/skills/ and no commands", async () => { - await mkdir(join(cwd, ".codex"), { recursive: true }); - const tools = await detectTools(cwd); - const codex = tools.find((t) => t.name === "Codex"); - expect(codex).toBeDefined(); - const skills = getEmbeddedSkills(); - const commands = getEmbeddedCommands(); - const result = await installForTool(cwd, codex!, skills, commands); - - expect(result.skills.length).toBeGreaterThan(0); - expect(result.commands).toHaveLength(0); - - const firstSkill = result.skills[0]!; - const skillContent = await readFile( - join(cwd, ".agents", "skills", firstSkill, "SKILL.md"), - "utf8" - ); - const embedded = skills.find((s) => s.name === firstSkill); - expect(skillContent).toBe(embedded!.content); - - const commandsDirectoryEntries = await readdir( - join(cwd, ".agents", "commands", "tskl") - ).catch(() => null); - expect(commandsDirectoryEntries).toBeNull(); + it("returns the install dir of a detected tool", async () => { + await mkdir(join(cwd, ".claude"), { recursive: true }); + expect(await detectSelectedDirectories(cwd)).toEqual([".claude"]); }); -}); -describe("Cursor install", () => { - it("writes both skills and commands", async () => { + it("returns every detected tool's directory in catalog order", async () => { await mkdir(join(cwd, ".cursor"), { recursive: true }); - const tools = await detectTools(cwd); - const cursor = tools.find((t) => t.name === "Cursor"); - expect(cursor).toBeDefined(); - expect(cursor!.commands?.path).toBe("commands/tskl"); - - const skills = getEmbeddedSkills(); - const commands = getEmbeddedCommands(); - const result = await installForTool(cwd, cursor!, skills, commands); - - expect(result.skills.length).toBeGreaterThan(0); - expect(result.commands.length).toBeGreaterThan(0); - - const firstSkill = result.skills[0]!; - const firstCommand = result.commands[0]!; - const skillContent = await readFile( - join(cwd, ".cursor", "skills", firstSkill, "SKILL.md"), - "utf8" - ); - expect(skillContent).toBeTruthy(); - - const commandContent = await readFile( - join(cwd, ".cursor", "commands", "tskl", firstCommand), - "utf8" - ); - const embeddedCommand = commands.find((c) => c.filename === firstCommand); - expect(commandContent).toBe(embeddedCommand!.content); - }); -}); - -describe(".agents/ lookup ordering", () => { - it("registered Codex resolves before AGENTS_FALLBACK for installDir '.agents'", () => { - const candidates = [...TOOLS, AGENTS_FALLBACK]; - const resolved = candidates.find((t) => t.installDir === ".agents"); - expect(resolved).toBeDefined(); - expect(resolved!.name).toBe("Codex"); - }); -}); - -describe("AGENTS_FALLBACK", () => { - it("installs skills to .agents/skills/ when no tools detected", async () => { - const tools = await detectTools(cwd); - expect(tools).toHaveLength(0); - - const skills = getEmbeddedSkills(); - const result = await installForTool(cwd, AGENTS_FALLBACK, skills, []); - expect(result.skills.length).toBeGreaterThan(0); - - const firstSkill = result.skills[0]!; - const content = await readFile( - join(cwd, ".agents", "skills", firstSkill, "SKILL.md"), - "utf8" - ); - expect(content).toBeTruthy(); - }); - - it("is not used when at least one tool is detected", async () => { await mkdir(join(cwd, ".claude"), { recursive: true }); - const tools = await detectTools(cwd); - expect(tools.length).toBeGreaterThan(0); - // Fallback should not be in the detected tools - expect(tools.every((t) => t.name !== AGENTS_FALLBACK.name)).toBe(true); + expect(await detectSelectedDirectories(cwd)).toEqual([ + ".claude", + ".cursor", + ]); }); - it("does not install commands", async () => { - const skills = getEmbeddedSkills(); - const result = await installForTool(cwd, AGENTS_FALLBACK, skills, []); - expect(result.commands).toHaveLength(0); + it("maps Codex detection to .agents", async () => { + await mkdir(join(cwd, ".codex"), { recursive: true }); + expect(await detectSelectedDirectories(cwd)).toEqual([".agents"]); }); }); describe("checkStaleness", () => { - it("reports status using installDir-based paths", async () => { + it("reports a detected tool as up to date after install", async () => { await mkdir(join(cwd, ".claude"), { recursive: true }); - - const skills = getEmbeddedSkills(); - const tools = await detectTools(cwd); - expect(tools).toHaveLength(1); - - await installForTool(cwd, tools[0]!, skills, []); + const plan = buildInstallPlan( + [".claude"], + getEmbeddedSkills(), + getEmbeddedCommands() + ); + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); const statuses = await checkStaleness(cwd); expect(statuses).toHaveLength(1); expect(statuses[0]!.name).toBe("Claude Code"); expect(statuses[0]!.skills.length).toBeGreaterThan(0); - for (const skill of statuses[0]!.skills) { expect(skill.current).toBe(true); } }); - it("reports status for multiple detected tools", async () => { + it("reports a skill as stale when the canonical store is missing", async () => { await mkdir(join(cwd, ".claude"), { recursive: true }); - await mkdir(join(cwd, ".cursor"), { recursive: true }); - - const skills = getEmbeddedSkills(); - const tools = await detectTools(cwd); - expect(tools).toHaveLength(2); - - for (const tool of tools) { - await installForTool(cwd, tool, skills, []); - } - const statuses = await checkStaleness(cwd); - expect(statuses).toHaveLength(2); - const names = statuses.map((s) => s.name); - expect(names).toContain("Claude Code"); - expect(names).toContain("Cursor"); - - for (const status of statuses) { - for (const skill of status.skills) { - expect(skill.current).toBe(true); - } + expect(statuses).toHaveLength(1); + for (const skill of statuses[0]!.skills) { + expect(skill.current).toBe(false); + expect(skill.installedVersion).toBeUndefined(); } }); }); diff --git a/packages/cli/test/migrate-install.test.ts b/packages/cli/test/migrate-install.test.ts index 9222696..ff5ef23 100644 --- a/packages/cli/test/migrate-install.test.ts +++ b/packages/cli/test/migrate-install.test.ts @@ -154,3 +154,54 @@ describe("migration 2 — install state", () => { }); }); }); + +/** The latest schema version, derived from a fresh bootstrap. */ +async function latestSchemaVersion(): Promise { + const fresh = await mkdtemp(join(tmpdir(), "taskless-migrate-latest-")); + try { + await ensureTasklessDirectory(fresh); + const manifest = JSON.parse( + await readFile(join(fresh, ".taskless", "taskless.json"), "utf8") + ) as { version: number }; + return manifest.version; + } finally { + await rm(fresh, { recursive: true, force: true }); + } +} + +describe("migration version matrix", () => { + let temporaryDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp( + join(tmpdir(), "taskless-migrate-matrix-") + ); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + // Seed .taskless/ at every prior schema version and confirm each + // forward-migrates cleanly to the latest. Catches a future migration that + // forgets to handle an older starting point. + for (const startVersion of [0, 1, 2]) { + it(`forward-migrates a v${String(startVersion)} project to the latest schema`, async () => { + const latest = await latestSchemaVersion(); + const tasklessDirectory = join(temporaryDirectory, ".taskless"); + await mkdir(tasklessDirectory, { recursive: true }); + await writeFile( + join(tasklessDirectory, "taskless.json"), + JSON.stringify({ version: startVersion }), + "utf8" + ); + + await ensureTasklessDirectory(temporaryDirectory); + + const manifest = JSON.parse( + await readFile(join(tasklessDirectory, "taskless.json"), "utf8") + ) as { version: number }; + expect(manifest.version).toBe(latest); + }); + } +}); diff --git a/packages/cli/test/wizard-steps.test.ts b/packages/cli/test/wizard-steps.test.ts index 9b31893..18f8901 100644 --- a/packages/cli/test/wizard-steps.test.ts +++ b/packages/cli/test/wizard-steps.test.ts @@ -31,3 +31,41 @@ describe("ask wrapper", () => { } }); }); + +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("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 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 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"); + }); +});