From 083e4f1e8d5d4e3b13e7dfda75c1b2f79f61db8a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 17:30:21 -0700 Subject: [PATCH 01/12] docs(cli): Add cli-canonical-install change proposal Add the OpenSpec change for moving skill/command installation to a single canonical .taskless/ store with thin reference stubs in each tool directory, replacing per-tool full copies. Also allow the WebSearch tool in .claude/settings.json, used while researching cross-tool skill discovery behavior for this change. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.json | 3 +- .../cli-canonical-install/.openspec.yaml | 2 + .../changes/cli-canonical-install/design.md | 83 ++++++ .../changes/cli-canonical-install/proposal.md | 34 +++ .../specs/cli-init/spec.md | 266 ++++++++++++++++++ .../changes/cli-canonical-install/tasks.md | 55 ++++ 6 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/cli-canonical-install/.openspec.yaml create mode 100644 openspec/changes/cli-canonical-install/design.md create mode 100644 openspec/changes/cli-canonical-install/proposal.md create mode 100644 openspec/changes/cli-canonical-install/specs/cli-init/spec.md create mode 100644 openspec/changes/cli-canonical-install/tasks.md 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/openspec/changes/cli-canonical-install/.openspec.yaml b/openspec/changes/cli-canonical-install/.openspec.yaml new file mode 100644 index 0000000..66da1ae --- /dev/null +++ b/openspec/changes/cli-canonical-install/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/cli-canonical-install/design.md b/openspec/changes/cli-canonical-install/design.md new file mode 100644 index 0000000..84ebf29 --- /dev/null +++ b/openspec/changes/cli-canonical-install/design.md @@ -0,0 +1,83 @@ +## 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: Every tool location gets a reference stub; `.agents/` included + +Each tool location receives a 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` — stub for Claude Code. +- `.agents/skills//SKILL.md` — stub serving OpenCode, Cursor, and Codex, which read `.agents/skills/` natively. One stub covers all three; `.cursor/skills/` and `.opencode/skills/` are not written. +- `.claude/commands/tskl/.md` and `.cursor/commands/tskl/.md` — command stubs. + +`.agents/` holding a _stub_ rather than the real skill is mildly unidiomatic (the standard expects real content there), but the stub is itself a conformant, working skill. The upside: if/when `.agents/` is trusted enough to be canonical, "promotion" is just regenerating which file is full vs. stub — a non-event, no data migration. + +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: Cleanup is manifest-driven; existing installs converge via 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`. A new `.taskless/` migration (`filesystem/migrations/`) sweeps obsolete `.cursor/skills/`/`.opencode/skills/` full copies, replaces any symlinked tool entry with a real stub, seeds the canonical `.taskless/` store, and rewrites `taskless.json` with per-target `mode`. The bootstrap system already runs migrations on the next `update`. + +## Risks / Trade-offs + +- **One extra stub vs. an `.agents/`-canonical model** → The `.taskless/` model writes a stub in `.agents/` where an `.agents/`-canonical model would write the real file. One extra featherweight file; content is still single-sourced, so drift is unaffected. Worth it for the structural bug elimination. +- **Stub `description` drift from canonical** → If the canonical `description` changes, stubs go stale. Mitigation: `update` regenerates a stub _as a stub_ when frontmatter drifts — refreshing `name`/`description` only, never writing full body content. +- **Double discovery** → A tool reading both `.agents/` and `.claude/` (OpenCode reads both) sees two stubs for the same skill. Both resolve to the same canonical file, so behavior is identical; worst case is a duplicate listing. Pre-existing in any multi-target model; acceptable. +- **A consumer manually symlinked things** (the customer's current state) → The migration detects a symlinked tool entry and replaces it with a real stub file rather than writing through the link. +- **`.agents/` standard regressing for a tool** → If a tool stops reading `.agents/`, it can be given its own stub via the same `mode: reference` mechanism — the model already generalizes to one stub per tool location. + +## Migration Plan + +1. Ship the new install model as the default `init`/`update` behavior (no flag). +2. Add a `.taskless/` migration that: writes the canonical `.taskless/skills/` and `.taskless/commands/` store; removes obsolete `.cursor/skills/`/`.opencode/skills/` full copies recorded in the prior manifest; replaces any symlinked tool entry with a real reference stub; and rewrites `taskless.json` install state with per-target `mode`. +3. On a user's next `taskless update`, the bootstrap migration runner applies step 2; the install summary reports removed obsolete copies. +4. Rollback: the manifest change is additive (legacy entries read as `canonical`). 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 + +- Resolved — empty `.cursor/skills/`/`.opencode/skills/` directories are left as-is. Git does not track empty directories, so once the obsolete copies are swept they disappear from commits and clones automatically; an empty dir only lingers in a local working tree. Adding code to delete it would be cosmetic-only, so the migration sweeps files and stops there. +- 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/cli-canonical-install/proposal.md b/openspec/changes/cli-canonical-install/proposal.md new file mode 100644 index 0000000..05e142a --- /dev/null +++ b/openspec/changes/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**, in Taskless's owned namespace — no tool target ever cleans it up. +- Every tool location receives a 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). +- `.agents/skills//SKILL.md` is a stub — it serves OpenCode, Cursor, and Codex, which read `.agents/skills/` natively. `.claude/skills//SKILL.md` is a stub for Claude Code. Command stubs go to `.claude/commands/tskl/` and `.cursor/commands/tskl/`. +- **BREAKING**: Drop the separate `.cursor/skills/` and `.opencode/skills/` skill-install targets — Cursor and OpenCode read `.agents/skills/` natively, so those copies are removed, not written. +- The install manifest (`.taskless/taskless.json`) gains a per-target **mode**: `canonical` (`.taskless/`) vs `reference` (every tool location). `update` rewrites canonical content only, creates stubs only when missing, and **never** overwrites a stub with full content. +- Cleanup becomes strictly manifest-driven — no `rm -rf` of a path another target sources from. +- A `.taskless/` migration converges existing installs (removes obsolete full copies, replaces any symlinked tool entries with real stubs, writes the canonical store). + +## 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 every tool location. Canonical content location, the stub model, the manifest schema (per-target `mode`), update behavior (rewrite canonical only, preserve stubs), removal of the `.cursor`/`.opencode` skill copy targets, and extension of the model to commands are all requirement-level changes. + +## Impact + +- **Code**: `packages/cli/src/install/install.ts` (canonical store + stub writes, `installForTool`/`applyInstallPlan`, removal of `rm -rf` glob cleanup), `install/catalog.ts` / `TOOLS[]` (drop `.cursor`/`.opencode` skill targets), `install/state.ts` (manifest `mode` field), `install/frontmatter.ts` (stub generation). +- **Filesystem**: new `.taskless/skills/` and `.taskless/commands/` canonical directories; `.taskless/README.md` "Files" section updated. +- **Migration**: a new `.taskless/` migration removes obsolete `.cursor/skills/`/`.opencode/skills/` copies, replaces symlinked tool entries with real stubs, and seeds per-target `mode` (`filesystem/migrations/`). +- **Manifest**: `.taskless/taskless.json` install-state schema gains per-target `mode`. +- **Tests**: install/update unit tests covering canonical write, stub generation, mode preservation across `update`, symlink-to-stub conversion, and obsolete-copy cleanup. +- **Tools affected**: Claude Code (skill + command stubs), Cursor (command stub; skills via `.agents/` stub), OpenCode / Codex (skills via `.agents/` stub, no separate files). diff --git a/openspec/changes/cli-canonical-install/specs/cli-init/spec.md b/openspec/changes/cli-canonical-install/specs/cli-init/spec.md new file mode 100644 index 0000000..53ace41 --- /dev/null +++ b/openspec/changes/cli-canonical-install/specs/cli-init/spec.md @@ -0,0 +1,266 @@ +## 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 how many tools are detected. + +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 detected tools + +- **WHEN** `taskless init` runs with any combination of tools detected, 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: Tool locations receive reference stubs that delegate to the canonical store + +For every tool location that needs the skill or command, the CLI SHALL write a **reference stub** rather than a full copy. 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; 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 stub locations are: + +- `.claude/skills//SKILL.md` — skill stub for Claude Code. +- `.agents/skills//SKILL.md` — skill stub serving OpenCode, Cursor, and Codex, which read `.agents/skills/` natively. +- `.claude/commands/tskl/.md` and `.cursor/commands/tskl/.md` — command stubs. + +#### Scenario: Skill stub has valid frontmatter and a delegating body + +- **WHEN** the CLI writes a skill stub for a tool +- **THEN** the stub SHALL be a regular file with frontmatter `name` and `description` matching the canonical skill +- **AND** its body SHALL delegate to `.taskless/skills//SKILL.md` without inlining the canonical instructions + +#### Scenario: One .agents stub serves the .agents-native tools + +- **WHEN** any of OpenCode, Cursor, or Codex is detected and `taskless init` runs +- **THEN** the CLI SHALL write a single skill stub at `.agents/skills//SKILL.md` +- **AND** SHALL NOT write a skill file under `.opencode/skills/` or `.cursor/skills/` + +#### 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 location 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 tool location entry (e.g. `.claude`, `.agents`) 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` 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: Obsolete per-tool copies and symlinks are converged on update + +When a prior install recorded full skill copies under tool-specific skill directories that the new model no longer writes (`.cursor/skills/`, `.opencode/skills/`), or recorded a tool entry that exists on disk as a symlink, `taskless update` SHALL converge the repository onto the canonical-plus-stub layout: obsolete full copies SHALL be removed, and any symlinked tool entry SHALL be replaced with a real reference stub file. Removal SHALL be driven by recorded manifest state, not by glob-deletion of arbitrary paths, and SHALL be reported in the install summary. + +#### Scenario: Upgrading a multi-copy install converges on canonical + +- **WHEN** a user whose prior install wrote `.cursor/skills/taskless/SKILL.md` and `.opencode/skills/taskless/SKILL.md` runs `taskless update` +- **THEN** those obsolete skill copies SHALL be removed +- **AND** the canonical `.taskless/skills/taskless/SKILL.md` SHALL be present +- **AND** the install summary SHALL report the removed obsolete copies + +#### 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 tool location that needs the skill SHALL receive a 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: Detected tool receives a stub, not a full copy + +- **WHEN** the CLI installs the `taskless` skill and any tool is detected +- **THEN** the tool'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-location targets record the stubs written for that tool. + +#### Scenario: Manifest records the canonical store and reference stubs with modes + +- **WHEN** init writes the canonical store and stubs for Claude Code and the `.agents/` location +- **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 + +OpenCode reads `.agents/skills//SKILL.md` natively, so detecting OpenCode SHALL ensure a skill stub exists at `.agents/skills/`; the CLI SHALL NOT write a skill file under `.opencode/skills/`. 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: Detecting OpenCode writes only the .agents stub + +- **WHEN** OpenCode is detected and `taskless init` runs +- **THEN** a skill stub SHALL exist at `.agents/skills/taskless/SKILL.md` +- **AND** no skill file SHALL be written under `.opencode/skills/` + +### Requirement: Cursor detection signals + +Cursor SHALL be detected when any of the following exist in the project root: + +- `.cursor/` directory +- `.cursorrules` file + +Cursor reads `.agents/skills//SKILL.md` natively, so detecting Cursor SHALL ensure a skill stub exists at `.agents/skills/`; the CLI SHALL NOT write a skill file under `.cursor/skills/`. Cursor SHALL receive a command stub at `.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: Detecting Cursor writes the .agents skill stub and a Cursor command stub + +- **WHEN** Cursor is detected and `taskless init` runs +- **THEN** a skill stub SHALL exist at `.agents/skills/taskless/SKILL.md` +- **AND** no skill file SHALL be written under `.cursor/skills/` +- **AND** a 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 Code is detected, a reference skill stub SHALL be installed to `.claude/skills//SKILL.md` and a reference command stub SHALL be installed 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 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 skill stub SHALL be installed to `.claude/skills/` + +### Requirement: Agents fallback install + +`.agents/skills//SKILL.md` SHALL hold a reference skill stub whenever any of OpenCode, Cursor, or Codex is detected, or when no tools are detected at all (the fallback case). The `.agents/` location SHALL always hold a stub — never full canonical content — and SHALL NOT receive commands. The `.agents/` target SHALL NOT be part of tool detection. + +#### Scenario: .agents stub written when no tools detected + +- **WHEN** a user runs `taskless init` +- **AND** no tools are detected in the project root +- **THEN** a skill stub SHALL be installed to `.agents/skills/` + +#### Scenario: .agents location never holds full content + +- **WHEN** the `.agents/skills/` stub is written +- **THEN** it SHALL be a reference stub delegating to `.taskless/skills/` +- **AND** SHALL NOT contain full canonical skill content + +#### Scenario: .agents location 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 that does not support commands +- **THEN** no command file SHALL be written for that tool + +### 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 detected and the install plan is applied +- **THEN** a skill stub SHALL serve Cursor via `.agents/skills/` +- **AND** a command stub SHALL be written to `.cursor/commands/tskl/` diff --git a/openspec/changes/cli-canonical-install/tasks.md b/openspec/changes/cli-canonical-install/tasks.md new file mode 100644 index 0000000..ce2683c --- /dev/null +++ b/openspec/changes/cli-canonical-install/tasks.md @@ -0,0 +1,55 @@ +## 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 + +- [ ] 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` +- [ ] 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 +- [ ] 2.3 Add a helper to detect frontmatter drift between an existing stub and the canonical content +- [ ] 2.4 Unit test: canonical content matches embedded source verbatim +- [ ] 2.5 Unit test: generated stub has valid frontmatter, a delegating body, no inlined canonical content, and is a regular file +- [ ] 2.6 Unit test: drift detection flags a changed `description` and ignores an unchanged stub + +## 3. Tool registry and install plan + +- [ ] 3.1 Update `TOOLS[]` / plan construction so OpenCode and Cursor no longer contribute a tool-specific skills target +- [ ] 3.2 Make the `.taskless/` canonical store an unconditional `canonical`-mode target whenever the plan contains a skill or command +- [ ] 3.3 Ensure each detected tool contributes `reference`-mode stub targets: `.claude/skills/` + `.claude/commands/tskl/` for Claude Code, `.agents/skills/` for OpenCode/Cursor/Codex, `.cursor/commands/tskl/` for Cursor +- [ ] 3.4 Ensure `.agents/skills/` receives a stub when no tools are detected (fallback) +- [ ] 3.5 Update the install summary to report the canonical `.taskless/` write and the stub locations / tools served + +## 4. Apply install plan: mode-aware writes + +- [ ] 4.1 In `applyInstallPlan`, write full content only to the `canonical` `.taskless/` target +- [ ] 4.2 For `reference` targets, write a stub only when absent or when frontmatter has drifted; never overwrite a stub with full content +- [ ] 4.3 Remove the destructive `rm -rf` glob in `removeOwnedSkills`/`removeOwnedCommands`; rely solely on manifest-diff-driven removal +- [ ] 4.4 Guarantee no target's cleanup deletes the canonical `.taskless/skills/` or `.taskless/commands/` store +- [ ] 4.5 Ensure no code path creates a symlink for skills or commands +- [ ] 4.6 Persist per-target `mode` into `taskless.json` on write + +## 5. Migration: converge existing installs + +- [ ] 5.1 Add a new `.taskless/` migration in `filesystem/migrations/` that seeds the canonical `.taskless/skills/`/`.taskless/commands/` store +- [ ] 5.2 In the migration, remove obsolete `.cursor/skills/`/`.opencode/skills/` skill copies recorded in the prior manifest +- [ ] 5.3 In the migration, replace any symlinked tool entry (e.g. `.claude/skills/`) with a real reference stub (do not write through the symlink) +- [ ] 5.4 Rewrite `taskless.json` install state with per-target `mode` during the migration +- [ ] 5.5 Unit test: a recorded multi-copy install converges to canonical + stubs; a symlinked tool entry becomes a real stub + +## 6. Update behavior + +- [ ] 6.1 Verify `taskless update` rewrites canonical `.taskless/` content and leaves reference stubs intact +- [ ] 6.2 Verify update reports removed obsolete copies and symlink conversions in the install summary +- [ ] 6.3 Unit test: update against a stub install does not clobber the stub +- [ ] 6.4 Unit test: update never deletes the canonical store while cleaning another target + +## 7. Verification and docs + +- [ ] 7.1 Run `pnpm typecheck` and `pnpm lint`; fix any issues +- [ ] 7.2 Run the CLI test suite; update existing install/update tests for the canonical-store-plus-stub model +- [ ] 7.3 Add a changeset describing the new install model and the BREAKING removal of `.cursor`/`.opencode` skill copies +- [ ] 7.4 Update CLI README / `help` text for `init`/`update` to describe the canonical `.taskless/` layout +- [ ] 7.5 Update the `.taskless/README.md` "Files" section to document `skills/` and `commands/` From c0c253a36a0c3a3a95b70eb6ad1d6cc447ce65f5 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 17:30:32 -0700 Subject: [PATCH 02/12] feat(cli): Add per-target install mode to the manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an InstallMode (canonical | reference) recorded per target in .taskless/taskless.json install state. A canonical target stores full skill/command content; a reference target stores thin stubs. readInstallState normalizes a missing mode to canonical, so manifests written before this field round-trip unchanged. computeInstallDiff entries now carry the effective mode so removal logic can respect it without a second state lookup. No write or cleanup logic acts on the mode yet — this lands the schema ahead of the install-engine changes that depend on it. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/filesystem/migrate.ts | 6 ++++ packages/cli/src/install/state.ts | 31 +++++++++++++++++++ packages/cli/test/install-state.test.ts | 41 +++++++++++++++++++++++++ 3 files changed, 78 insertions(+) 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/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/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( From a9938a7a311658144204da6ee3d970e6864c7041 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 17:42:02 -0700 Subject: [PATCH 03/12] feat(cli): Add canonical store and reference stub helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add install/canonical.ts: writeCanonicalSkill/writeCanonicalCommand write full content verbatim into the .taskless/ store; buildSkillStub/ buildCommandStub produce thin reference files that carry name and description frontmatter and delegate to the canonical path. Command stubs preserve the canonical argument-hint and pass $ARGUMENTS through. stubFrontmatterDrifted lets update leave a still-matching stub untouched instead of regenerating it. These helpers are standalone — the install plan wires them in next. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../changes/cli-canonical-install/tasks.md | 12 +- packages/cli/src/install/canonical.ts | 132 +++++++++++++++ packages/cli/test/canonical-store.test.ts | 154 ++++++++++++++++++ 3 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/install/canonical.ts create mode 100644 packages/cli/test/canonical-store.test.ts diff --git a/openspec/changes/cli-canonical-install/tasks.md b/openspec/changes/cli-canonical-install/tasks.md index ce2683c..8a9e2e3 100644 --- a/openspec/changes/cli-canonical-install/tasks.md +++ b/openspec/changes/cli-canonical-install/tasks.md @@ -7,12 +7,12 @@ ## 2. Canonical store and stub generation -- [ ] 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` -- [ ] 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 -- [ ] 2.3 Add a helper to detect frontmatter drift between an existing stub and the canonical content -- [ ] 2.4 Unit test: canonical content matches embedded source verbatim -- [ ] 2.5 Unit test: generated stub has valid frontmatter, a delegating body, no inlined canonical content, and is a regular file -- [ ] 2.6 Unit test: drift detection flags a changed `description` and ignores an unchanged stub +- [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. Tool registry and install plan diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts new file mode 100644 index 0000000..52d32be --- /dev/null +++ b/packages/cli/src/install/canonical.ts @@ -0,0 +1,132 @@ +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; +} + +/** + * 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; +} + +/** Serialize ordered string fields into a `---`-delimited frontmatter 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 }) + + "\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; + 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); + return data.name !== meta.name || data.description !== meta.description; +} diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts new file mode 100644 index 0000000..fdf0b97 --- /dev/null +++ b/packages/cli/test/canonical-store.test.ts @@ -0,0 +1,154 @@ +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, + 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(content).toContain(".taskless/skills/taskless/SKILL.md"); + expect(content.toLowerCase()).toContain("read"); + expect(stub).not.toContain(SENTINEL); + }); + + 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("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 + ); + }); +}); From e210e88b1b23859789ef7ad5eac7a1cec3e99675 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 18:34:06 -0700 Subject: [PATCH 04/12] docs(cli): Revise cli-canonical-install to the uniform stub model Replace the routed .agents/ design with a uniform model: every selected tool directory (.claude/.cursor/.opencode/.agents) is a peer target and receives its own reference stub. No directory is special-cased or routed. The wizard location step is reframed as a fixed tool-selection multiselect. The migration converts existing full per-tool copies into stubs rather than removing them. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../changes/cli-canonical-install/design.md | 30 +++-- .../changes/cli-canonical-install/proposal.md | 23 ++-- .../specs/cli-init/spec.md | 123 ++++++++++-------- .../changes/cli-canonical-install/tasks.md | 19 +-- 4 files changed, 107 insertions(+), 88 deletions(-) diff --git a/openspec/changes/cli-canonical-install/design.md b/openspec/changes/cli-canonical-install/design.md index 84ebf29..68948b8 100644 --- a/openspec/changes/cli-canonical-install/design.md +++ b/openspec/changes/cli-canonical-install/design.md @@ -35,15 +35,16 @@ Two alternatives were considered and rejected: 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: Every tool location gets a reference stub; `.agents/` included +### Decision: Uniform per-tool stubs — every selected directory is a peer target -Each tool location receives a 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. +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` — stub for Claude Code. -- `.agents/skills//SKILL.md` — stub serving OpenCode, Cursor, and Codex, which read `.agents/skills/` natively. One stub covers all three; `.cursor/skills/` and `.opencode/skills/` are not written. -- `.claude/commands/tskl/.md` and `.cursor/commands/tskl/.md` — command stubs. +- `.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). -`.agents/` holding a _stub_ rather than the real skill is mildly unidiomatic (the standard expects real content there), but the stub is itself a conformant, working skill. The upside: if/when `.agents/` is trusted enough to be canonical, "promotion" is just regenerating which file is full vs. stub — a non-event, no data migration. +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. @@ -57,27 +58,30 @@ _Alternative considered:_ hardlinks — rejected because `git clone` materialize 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; existing installs converge via 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`. A new `.taskless/` migration (`filesystem/migrations/`) sweeps obsolete `.cursor/skills/`/`.opencode/skills/` full copies, replaces any symlinked tool entry with a real stub, seeds the canonical `.taskless/` store, and rewrites `taskless.json` with per-target `mode`. The bootstrap system already runs migrations on the next `update`. +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`. A new `.taskless/` migration (`filesystem/migrations/`) seeds the canonical `.taskless/` store, converts existing full per-tool copies into stubs, replaces any symlinked tool entry with a real stub, and rewrites `taskless.json` with per-target `mode`. Converting a full copy to a stub is explicit migration work — a leftover full `SKILL.md` whose `name`/`description` still match canonical would otherwise read as drift-free and never be regenerated. The bootstrap system already runs migrations on the next `update`. ## Risks / Trade-offs -- **One extra stub vs. an `.agents/`-canonical model** → The `.taskless/` model writes a stub in `.agents/` where an `.agents/`-canonical model would write the real file. One extra featherweight file; content is still single-sourced, so drift is unaffected. Worth it for the structural bug elimination. +- **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 `description` drift from canonical** → If the canonical `description` changes, stubs go stale. Mitigation: `update` regenerates a stub _as a stub_ when frontmatter drifts — refreshing `name`/`description` only, never writing full body content. -- **Double discovery** → A tool reading both `.agents/` and `.claude/` (OpenCode reads both) sees two stubs for the same skill. Both resolve to the same canonical file, so behavior is identical; worst case is a duplicate listing. Pre-existing in any multi-target model; acceptable. +- **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) → The migration detects a symlinked tool entry and replaces it with a real stub file rather than writing through the link. -- **`.agents/` standard regressing for a tool** → If a tool stops reading `.agents/`, it can be given its own stub via the same `mode: reference` mechanism — the model already generalizes to one stub per tool location. +- **A leftover full copy reads as drift-free** → An old per-tool full `SKILL.md` has `name`/`description` matching canonical, so the drift check alone would never regenerate it. Mitigation: the migration converts full copies to stubs explicitly, rather than relying on `update`'s drift check. ## Migration Plan 1. Ship the new install model as the default `init`/`update` behavior (no flag). -2. Add a `.taskless/` migration that: writes the canonical `.taskless/skills/` and `.taskless/commands/` store; removes obsolete `.cursor/skills/`/`.opencode/skills/` full copies recorded in the prior manifest; replaces any symlinked tool entry with a real reference stub; and rewrites `taskless.json` install state with per-target `mode`. -3. On a user's next `taskless update`, the bootstrap migration runner applies step 2; the install summary reports removed obsolete copies. +2. Add a `.taskless/` migration that: writes the canonical `.taskless/skills/` and `.taskless/commands/` store; converts existing full per-tool copies recorded in the prior manifest into stubs; replaces any symlinked tool entry with a real reference stub; and rewrites `taskless.json` install state with per-target `mode`. +3. On a user's next `taskless update`, the bootstrap migration runner applies step 2; the install summary reports the converged layout. 4. Rollback: the manifest change is additive (legacy entries read as `canonical`). 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 -- Resolved — empty `.cursor/skills/`/`.opencode/skills/` directories are left as-is. Git does not track empty directories, so once the obsolete copies are swept they disappear from commits and clones automatically; an empty dir only lingers in a local working tree. Adding code to delete it would be cosmetic-only, so the migration sweeps files and stops there. - 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/cli-canonical-install/proposal.md b/openspec/changes/cli-canonical-install/proposal.md index 05e142a..7d46a9a 100644 --- a/openspec/changes/cli-canonical-install/proposal.md +++ b/openspec/changes/cli-canonical-install/proposal.md @@ -6,13 +6,14 @@ The fix is to give canonical content its own home that no tool ever installs int ## What Changes -- Canonical skill content moves to `.taskless/skills//SKILL.md`; canonical command content to `.taskless/commands/tskl/.md`. Written **once**, in Taskless's owned namespace — no tool target ever cleans it up. -- Every tool location receives a 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). -- `.agents/skills//SKILL.md` is a stub — it serves OpenCode, Cursor, and Codex, which read `.agents/skills/` natively. `.claude/skills//SKILL.md` is a stub for Claude Code. Command stubs go to `.claude/commands/tskl/` and `.cursor/commands/tskl/`. -- **BREAKING**: Drop the separate `.cursor/skills/` and `.opencode/skills/` skill-install targets — Cursor and OpenCode read `.agents/skills/` natively, so those copies are removed, not written. -- The install manifest (`.taskless/taskless.json`) gains a per-target **mode**: `canonical` (`.taskless/`) vs `reference` (every tool location). `update` rewrites canonical content only, creates stubs only when missing, and **never** overwrites a stub with full content. +- 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. -- A `.taskless/` migration converges existing installs (removes obsolete full copies, replaces any symlinked tool entries with real stubs, writes the canonical store). +- A `.taskless/` migration converges existing installs: seeds the canonical store, converts existing full per-tool copies into stubs, replaces any symlinked tool entry with a real stub, and stamps per-target modes. ## Capabilities @@ -22,13 +23,13 @@ The fix is to give canonical content its own home that no tool ever installs int ### 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 every tool location. Canonical content location, the stub model, the manifest schema (per-target `mode`), update behavior (rewrite canonical only, preserve stubs), removal of the `.cursor`/`.opencode` skill copy targets, and extension of the model to commands are all requirement-level changes. +- `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, `installForTool`/`applyInstallPlan`, removal of `rm -rf` glob cleanup), `install/catalog.ts` / `TOOLS[]` (drop `.cursor`/`.opencode` skill targets), `install/state.ts` (manifest `mode` field), `install/frontmatter.ts` (stub generation). +- **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. -- **Migration**: a new `.taskless/` migration removes obsolete `.cursor/skills/`/`.opencode/skills/` copies, replaces symlinked tool entries with real stubs, and seeds per-target `mode` (`filesystem/migrations/`). +- **Migration**: a new `.taskless/` migration seeds the canonical store, converts full per-tool copies into stubs, replaces symlinked tool entries with real stubs, and stamps per-target `mode` (`filesystem/migrations/`). - **Manifest**: `.taskless/taskless.json` install-state schema gains per-target `mode`. -- **Tests**: install/update unit tests covering canonical write, stub generation, mode preservation across `update`, symlink-to-stub conversion, and obsolete-copy cleanup. -- **Tools affected**: Claude Code (skill + command stubs), Cursor (command stub; skills via `.agents/` stub), OpenCode / Codex (skills via `.agents/` stub, no separate files). +- **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/cli-canonical-install/specs/cli-init/spec.md b/openspec/changes/cli-canonical-install/specs/cli-init/spec.md index 53ace41..b56a193 100644 --- a/openspec/changes/cli-canonical-install/specs/cli-init/spec.md +++ b/openspec/changes/cli-canonical-install/specs/cli-init/spec.md @@ -2,7 +2,7 @@ ### 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 how many tools are detected. +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. @@ -12,9 +12,9 @@ The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's d - **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 detected tools +#### Scenario: Canonical write happens regardless of selected tools -- **WHEN** `taskless init` runs with any combination of tools detected, including none +- **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 @@ -22,29 +22,30 @@ The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's d - **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: Tool locations receive reference stubs that delegate to the canonical store +### Requirement: Selected tool directories receive reference stubs -For every tool location that needs the skill or command, the CLI SHALL write a **reference stub** rather than a full copy. 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; 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. +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; 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 stub locations are: +The per-directory stub layout is: -- `.claude/skills//SKILL.md` — skill stub for Claude Code. -- `.agents/skills//SKILL.md` — skill stub serving OpenCode, Cursor, and Codex, which read `.agents/skills/` natively. -- `.claude/commands/tskl/.md` and `.cursor/commands/tskl/.md` — command stubs. +- `.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 tool +- **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** its body SHALL delegate to `.taskless/skills//SKILL.md` without inlining the canonical instructions -#### Scenario: One .agents stub serves the .agents-native tools +#### Scenario: Each selected directory gets its own stub -- **WHEN** any of OpenCode, Cursor, or Codex is detected and `taskless init` runs -- **THEN** the CLI SHALL write a single skill stub at `.agents/skills//SKILL.md` -- **AND** SHALL NOT write a skill file under `.opencode/skills/` or `.cursor/skills/` +- **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 @@ -53,13 +54,13 @@ The stub locations are: ### 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 location 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`. +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 tool location entry (e.g. `.claude`, `.agents`) SHALL have `mode: "reference"` +- **AND** each selected tool directory entry SHALL have `mode: "reference"` #### Scenario: Legacy manifest without mode is treated as canonical @@ -89,16 +90,15 @@ Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any di - **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: Obsolete per-tool copies and symlinks are converged on update +### Requirement: Existing installs converge to the canonical-plus-stub layout -When a prior install recorded full skill copies under tool-specific skill directories that the new model no longer writes (`.cursor/skills/`, `.opencode/skills/`), or recorded a tool entry that exists on disk as a symlink, `taskless update` SHALL converge the repository onto the canonical-plus-stub layout: obsolete full copies SHALL be removed, and any symlinked tool entry SHALL be replaced with a real reference stub file. Removal SHALL be driven by recorded manifest state, not by glob-deletion of arbitrary paths, and SHALL be reported in the install summary. +When a prior install recorded full skill or command copies in tool directories, or recorded a tool entry that exists on disk as a symlink, `taskless update` SHALL converge the repository onto the canonical-plus-stub layout: the canonical `.taskless/` store SHALL be seeded; each existing full per-tool copy SHALL be converted into a reference stub; any symlinked tool entry SHALL be replaced with a real stub file; and each target's `mode` SHALL be stamped (`.taskless` → `canonical`, tool directories → `reference`). Conversion SHALL be driven by recorded manifest state and SHALL be reported in the install summary. -#### Scenario: Upgrading a multi-copy install converges on canonical +#### Scenario: A full per-tool copy is converted to a stub -- **WHEN** a user whose prior install wrote `.cursor/skills/taskless/SKILL.md` and `.opencode/skills/taskless/SKILL.md` runs `taskless update` -- **THEN** those obsolete skill copies SHALL be removed +- **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 -- **AND** the install summary SHALL report the removed obsolete copies #### Scenario: A symlinked tool entry is replaced with a real stub @@ -110,7 +110,7 @@ When a prior install recorded full skill copies under tool-specific skill direct ### 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 tool location that needs the skill SHALL receive a 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. +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 @@ -118,19 +118,19 @@ The CLI SHALL install skill content using a canonical-store-plus-stub model rath - **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: Detected tool receives a stub, not a full copy +#### Scenario: Selected tool directory receives a stub, not a full copy -- **WHEN** the CLI installs the `taskless` skill and any tool is detected -- **THEN** the tool's skill location SHALL contain a reference stub +- **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-location targets record the stubs written for that tool. +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 Code and the `.agents/` location +- **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"` @@ -142,7 +142,7 @@ OpenCode SHALL be detected when any of the following exist in the project root: - `opencode.jsonc` file - `opencode.json` file -OpenCode reads `.agents/skills//SKILL.md` natively, so detecting OpenCode SHALL ensure a skill stub exists at `.agents/skills/`; the CLI SHALL NOT write a skill file under `.opencode/skills/`. 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 @@ -159,11 +159,11 @@ OpenCode reads `.agents/skills//SKILL.md` natively, so detecting OpenCode - **WHEN** `opencode.json` exists as a file in the project root - **THEN** OpenCode SHALL be detected -#### Scenario: Detecting OpenCode writes only the .agents stub +#### Scenario: Selecting OpenCode writes a stub to .opencode/skills -- **WHEN** OpenCode is detected and `taskless init` runs -- **THEN** a skill stub SHALL exist at `.agents/skills/taskless/SKILL.md` -- **AND** no skill file SHALL be written under `.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 @@ -172,7 +172,7 @@ Cursor SHALL be detected when any of the following exist in the project root: - `.cursor/` directory - `.cursorrules` file -Cursor reads `.agents/skills//SKILL.md` natively, so detecting Cursor SHALL ensure a skill stub exists at `.agents/skills/`; the CLI SHALL NOT write a skill file under `.cursor/skills/`. Cursor SHALL receive a command stub at `.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 @@ -184,12 +184,11 @@ Cursor reads `.agents/skills//SKILL.md` natively, so detecting Cursor SHAL - **WHEN** `.cursorrules` exists as a file in the project root - **THEN** Cursor SHALL be detected -#### Scenario: Detecting Cursor writes the .agents skill stub and a Cursor command stub +#### Scenario: Selecting Cursor writes a skill stub and a command stub -- **WHEN** Cursor is detected and `taskless init` runs -- **THEN** a skill stub SHALL exist at `.agents/skills/taskless/SKILL.md` -- **AND** no skill file SHALL be written under `.cursor/skills/` -- **AND** a command stub SHALL be written to `.cursor/commands/tskl/` +- **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 @@ -198,38 +197,33 @@ Claude Code SHALL be detected when any of the following exist in the project roo - `.claude/` directory - `CLAUDE.md` file -When Claude Code is detected, a reference skill stub SHALL be installed to `.claude/skills//SKILL.md` and a reference command stub SHALL be installed to `.claude/commands/tskl/.md`. +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 skill stub 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** a skill stub SHALL be installed to `.claude/skills/` +- **AND** a reference skill stub SHALL be installed to `.claude/skills/` ### Requirement: Agents fallback install -`.agents/skills//SKILL.md` SHALL hold a reference skill stub whenever any of OpenCode, Cursor, or Codex is detected, or when no tools are detected at all (the fallback case). The `.agents/` location SHALL always hold a stub — never full canonical content — and SHALL NOT receive commands. The `.agents/` target SHALL NOT be part of tool detection. +`.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** a skill stub SHALL be installed to `.agents/skills/` - -#### Scenario: .agents location never holds full content - -- **WHEN** the `.agents/skills/` stub is written -- **THEN** it SHALL be a reference stub delegating to `.taskless/skills/` -- **AND** SHALL NOT contain full canonical skill content +- **THEN** `.agents/` SHALL be selected by default +- **AND** a reference skill stub SHALL be installed to `.agents/skills/` -#### Scenario: .agents location does not install commands +#### Scenario: .agents target does not install commands - **WHEN** the `.agents/skills/` stub is written - **THEN** no command files SHALL be written to `.agents/` @@ -246,8 +240,8 @@ For Claude Code, the CLI SHALL place a reference command stub at `.claude/comman #### 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 file 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 @@ -261,6 +255,25 @@ For Cursor, the CLI SHALL place a reference command stub at `.cursor/commands/ts #### Scenario: Cursor receives a skill stub and a command stub -- **WHEN** Cursor is detected and the install plan is applied -- **THEN** a skill stub SHALL serve Cursor via `.agents/skills/` +- **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/cli-canonical-install/tasks.md b/openspec/changes/cli-canonical-install/tasks.md index 8a9e2e3..ce483f4 100644 --- a/openspec/changes/cli-canonical-install/tasks.md +++ b/openspec/changes/cli-canonical-install/tasks.md @@ -14,13 +14,14 @@ - [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. Tool registry and install plan +## 3. Install-plan model and tool selection -- [ ] 3.1 Update `TOOLS[]` / plan construction so OpenCode and Cursor no longer contribute a tool-specific skills target -- [ ] 3.2 Make the `.taskless/` canonical store an unconditional `canonical`-mode target whenever the plan contains a skill or command -- [ ] 3.3 Ensure each detected tool contributes `reference`-mode stub targets: `.claude/skills/` + `.claude/commands/tskl/` for Claude Code, `.agents/skills/` for OpenCode/Cursor/Codex, `.cursor/commands/tskl/` for Cursor -- [ ] 3.4 Ensure `.agents/skills/` receives a stub when no tools are detected (fallback) -- [ ] 3.5 Update the install summary to report the canonical `.taskless/` write and the stub locations / tools served +- [ ] 3.1 Define a resolved install-plan target type `{ dir, mode, label, skills, commands }` decoupled from `ToolDescriptor`; keep `ToolDescriptor` for detection only +- [ ] 3.2 Build the plan so the `.taskless/` canonical target (`mode: canonical`) is always present when the plan contains a skill or command +- [ ] 3.3 Build a `reference`-mode stub target for each selected tool directory: skill stub for all; command stub for `.claude/` and `.cursor/` only +- [ ] 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 +- [ ] 3.5 Update non-interactive `init`/`update` plan construction to the same canonical + per-tool-stub model +- [ ] 3.6 Update the install summary to report the canonical `.taskless/` write and each selected stub target ## 4. Apply install plan: mode-aware writes @@ -34,15 +35,15 @@ ## 5. Migration: converge existing installs - [ ] 5.1 Add a new `.taskless/` migration in `filesystem/migrations/` that seeds the canonical `.taskless/skills/`/`.taskless/commands/` store -- [ ] 5.2 In the migration, remove obsolete `.cursor/skills/`/`.opencode/skills/` skill copies recorded in the prior manifest +- [ ] 5.2 In the migration, convert existing full per-tool skill/command copies recorded in the prior manifest into reference stubs - [ ] 5.3 In the migration, replace any symlinked tool entry (e.g. `.claude/skills/`) with a real reference stub (do not write through the symlink) -- [ ] 5.4 Rewrite `taskless.json` install state with per-target `mode` during the migration +- [ ] 5.4 Rewrite `taskless.json` install state with per-target `mode` during the migration (`.taskless` canonical, tool dirs reference) - [ ] 5.5 Unit test: a recorded multi-copy install converges to canonical + stubs; a symlinked tool entry becomes a real stub ## 6. Update behavior - [ ] 6.1 Verify `taskless update` rewrites canonical `.taskless/` content and leaves reference stubs intact -- [ ] 6.2 Verify update reports removed obsolete copies and symlink conversions in the install summary +- [ ] 6.2 Verify update reports the converged layout (full-copy-to-stub and symlink conversions) in the install summary - [ ] 6.3 Unit test: update against a stub install does not clobber the stub - [ ] 6.4 Unit test: update never deletes the canonical store while cleaning another target From 3f611a18e7031ca4790bb94107442871bd2bcb65 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 20:45:23 -0700 Subject: [PATCH 05/12] feat(cli): Install a canonical store plus per-tool reference stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-tool full-copy install model with a resolved install-plan: one canonical .taskless/ target holding full content, plus one reference stub target per selected tool directory. applyInstallPlan branches on each target's mode — canonical targets get verbatim content, reference targets get a drift-checked stub that is never overwritten with full content. Removals are scoped to each diff entry's own directory, so no target's cleanup can reach the canonical store; the destructive rm -rf glob is gone. The wizard's location step is reframed as a fixed tool-selection multiselect. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../changes/cli-canonical-install/tasks.md | 24 +- packages/cli/src/commands/init.ts | 85 ++- packages/cli/src/install/install.ts | 537 +++++++++--------- packages/cli/src/wizard/index.ts | 68 +-- packages/cli/src/wizard/steps/locations.ts | 56 +- packages/cli/test/apply-install-plan.test.ts | 251 ++++---- packages/cli/test/install.test.ts | 227 ++------ 7 files changed, 500 insertions(+), 748 deletions(-) diff --git a/openspec/changes/cli-canonical-install/tasks.md b/openspec/changes/cli-canonical-install/tasks.md index ce483f4..4c6afb6 100644 --- a/openspec/changes/cli-canonical-install/tasks.md +++ b/openspec/changes/cli-canonical-install/tasks.md @@ -16,21 +16,21 @@ ## 3. Install-plan model and tool selection -- [ ] 3.1 Define a resolved install-plan target type `{ dir, mode, label, skills, commands }` decoupled from `ToolDescriptor`; keep `ToolDescriptor` for detection only -- [ ] 3.2 Build the plan so the `.taskless/` canonical target (`mode: canonical`) is always present when the plan contains a skill or command -- [ ] 3.3 Build a `reference`-mode stub target for each selected tool directory: skill stub for all; command stub for `.claude/` and `.cursor/` only -- [ ] 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 -- [ ] 3.5 Update non-interactive `init`/`update` plan construction to the same canonical + per-tool-stub model -- [ ] 3.6 Update the install summary to report the canonical `.taskless/` write and each selected stub target +- [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 -- [ ] 4.1 In `applyInstallPlan`, write full content only to the `canonical` `.taskless/` target -- [ ] 4.2 For `reference` targets, write a stub only when absent or when frontmatter has drifted; never overwrite a stub with full content -- [ ] 4.3 Remove the destructive `rm -rf` glob in `removeOwnedSkills`/`removeOwnedCommands`; rely solely on manifest-diff-driven removal -- [ ] 4.4 Guarantee no target's cleanup deletes the canonical `.taskless/skills/` or `.taskless/commands/` store -- [ ] 4.5 Ensure no code path creates a symlink for skills or commands -- [ ] 4.6 Persist per-target `mode` into `taskless.json` on write +- [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. Migration: converge existing installs 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/install/install.ts b/packages/cli/src/install/install.ts index 33d2b4a..0146585 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -1,18 +1,19 @@ -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, + stubFrontmatterDrifted, + writeCanonicalCommand, + writeCanonicalSkill, +} from "./canonical"; import { parseFrontmatter } from "./frontmatter"; import { computeInstallDiff, readInstallState, writeInstallState, + type InstallMode, type InstallState, } from "./state"; @@ -28,6 +29,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 +42,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 +64,9 @@ export interface EmbeddedSkill { export interface EmbeddedCommand { filename: string; content: string; + name: string; + description: string; + argumentHint?: string; } export interface SkillStatus { @@ -72,7 +81,7 @@ export interface ToolStatus { skills: SkillStatus[]; } -// --- Tool Registry --- +// --- Tool Registry (detection only) --- export const TOOLS: ToolDescriptor[] = [ { @@ -82,12 +91,6 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: "CLAUDE.md" }, ], installDir: ".claude", - skills: { - path: "skills", - }, - commands: { - path: "commands/tskl", - }, }, { name: "OpenCode", @@ -97,9 +100,6 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: "opencode.json" }, ], installDir: ".opencode", - skills: { - path: "skills", - }, }, { name: "Cursor", @@ -108,12 +108,6 @@ export const TOOLS: ToolDescriptor[] = [ { type: "file", path: ".cursorrules" }, ], installDir: ".cursor", - skills: { - path: "skills", - }, - commands: { - path: "commands/tskl", - }, }, { name: "Codex", @@ -122,20 +116,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 +172,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 +208,96 @@ 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; + }; + const filename = basename(path); + return { + filename, + content, + name: data.name ?? filename.replace(/\.md$/, ""), + description: data.description ?? "", + argumentHint: data["argument-hint"], + }; + }); } -// --- 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 +312,122 @@ export interface ApplyInstallResult { removedCommands: Array<{ target: string; command: string }>; } -/** - * 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. - */ -const ALL_KNOWN_TOOLS: readonly ToolDescriptor[] = [...TOOLS, AGENTS_FALLBACK]; - -function findToolByInstallDirectory( - directory: string -): ToolDescriptor | undefined { - return ALL_KNOWN_TOOLS.find((t) => t.installDir === directory); +/** Filesystem path of a skill directory inside any target. */ +function skillDirectory( + cwd: string, + targetDirectory: string, + name: string +): string { + return join(cwd, targetDirectory, "skills", name); } -async function writeSkillFile( +/** Filesystem path of a command file inside any target. */ +function commandFile( cwd: string, - tool: ToolDescriptor, - 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"); + 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. + } } -async function writeCommandFile( +/** + * Write a skill into a target. A `canonical` target receives the full + * embedded content; a `reference` target receives a stub, written only when + * absent or when its frontmatter has drifted. Returns whether a file was + * written. + */ +async function writeSkill( cwd: string, - tool: ToolDescriptor, - command: EmbeddedCommand -): Promise { - if (!tool.commands) return; - const commandDirectory = join(cwd, tool.installDir, tool.commands.path); - await mkdir(commandDirectory, { recursive: true }); + target: PlanTarget, + skill: EmbeddedSkill +): 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 existing = await readFile(path, "utf8").catch(() => {}); + if ( + existing !== undefined && + !stubFrontmatterDrifted(existing, { + name: skill.name, + description: skill.description, + }) + ) { + return false; + } + + await unlinkIfSymlink(path); + await mkdir(dirname(path), { recursive: true }); await writeFile( - join(commandDirectory, command.filename), - command.content, + path, + buildSkillStub({ name: skill.name, description: skill.description }), "utf8" ); + return true; } -async function deleteSkill( +/** + * Write a command into a target. Mirrors {@link writeSkill}: full content for + * a `canonical` target, a drift-checked stub for a `reference` target. + */ +async function writeCommand( cwd: string, - tool: ToolDescriptor, - skillName: string -): Promise { - const skillDirectory = join( - cwd, - tool.installDir, - tool.skills.path, - skillName - ); - await rm(skillDirectory, { recursive: true, force: true }); -} + target: PlanTarget, + command: EmbeddedCommand +): Promise { + if (target.mode === "canonical") { + await writeCanonicalCommand(cwd, command.filename, command.content); + return true; + } -async function deleteCommand( - cwd: string, - tool: ToolDescriptor, - commandFilename: string -): Promise { - if (!tool.commands) return; - const commandPath = join( - cwd, - tool.installDir, - tool.commands.path, - commandFilename + const path = commandFile(cwd, target.dir, command.filename); + const existing = await readFile(path, "utf8").catch(() => {}); + if ( + existing !== undefined && + !stubFrontmatterDrifted(existing, { + name: command.name, + description: command.description, + }) + ) { + return false; + } + + await unlinkIfSymlink(path); + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + buildCommandStub( + { + name: command.name, + description: command.description, + argumentHint: command.argumentHint, + }, + command.filename + ), + "utf8" ); - await rm(commandPath, { force: true }); + 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 +440,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 +494,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 +511,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/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..e1b4a62 100644 --- a/packages/cli/src/wizard/steps/locations.ts +++ b/packages/cli/src/wizard/steps/locations.ts @@ -1,55 +1,45 @@ import { multiselect, log } from "@clack/prompts"; -import { detectTools } from "../../install/install"; +import { + DEFAULT_SHIM_DIR, + SHIM_TARGETS, + detectTools, +} from "../../install/install"; import { ask } from "../ask"; -export interface LocationChoice { - installDir: string; - label: 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", - }, -]; - +/** + * 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 initialValues = + detected.length > 0 ? [...detectedDirectories] : [DEFAULT_SHIM_DIR]; 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 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", })); 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..90e0861 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -12,18 +12,12 @@ 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 { parseFrontmatter } from "../src/install/frontmatter"; +import { readInstallState, writeInstallState } from "../src/install/state"; async function exists(path: string): Promise { try { @@ -55,149 +49,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); + + const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - expect(result.writtenSkills).toHaveLength(1); - expect(result.removedSkills).toHaveLength(0); + // Canonical store holds verbatim content. + const canonical = await readFile( + join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), + "utf8" + ); + expect(canonical).toBe(tasklessSkill().content); - const skillContent = await readFile( + // 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 +204,33 @@ 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("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"); - // 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"]); + 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/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(); } }); }); From dc11318026f61581f57fccede92baaf2f69a899f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 21:04:23 -0700 Subject: [PATCH 06/12] feat(cli): Converge stale installs via self-healing applyInstallPlan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark every reference stub with a metadata.type: shim frontmatter marker. applyInstallPlan now rewrites a reference file unless it is already a current, non-drifted shim stub — converging full copies left by older installs, symlinked entries, and drifted stubs on the next init/update. This replaces the planned .taskless/ convergence migration: a migration would import install.ts (an import cycle through state.ts) and would run on every ensureTasklessDirectory call, including taskless check. The manifest mode field is additive, so no schema migration is needed. Also add a migration version-matrix test that seeds .taskless/ at each prior schema version and asserts a clean forward-migration. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../changes/cli-canonical-install/design.md | 18 +++--- .../changes/cli-canonical-install/proposal.md | 5 +- .../specs/cli-init/spec.md | 5 +- .../changes/cli-canonical-install/tasks.md | 20 +++---- packages/cli/src/install/canonical.ts | 35 ++++++++++-- packages/cli/src/install/install.ts | 56 +++++++++++-------- packages/cli/test/apply-install-plan.test.ts | 44 ++++++++++++++- packages/cli/test/canonical-store.test.ts | 27 +++++++++ packages/cli/test/migrate-install.test.ts | 51 +++++++++++++++++ 9 files changed, 211 insertions(+), 50 deletions(-) diff --git a/openspec/changes/cli-canonical-install/design.md b/openspec/changes/cli-canonical-install/design.md index 68948b8..1860d68 100644 --- a/openspec/changes/cli-canonical-install/design.md +++ b/openspec/changes/cli-canonical-install/design.md @@ -62,24 +62,28 @@ The install state (`install/state.ts`) records a per-target `mode`. The `.taskle 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; existing installs converge via migration +### 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`. A new `.taskless/` migration (`filesystem/migrations/`) seeds the canonical `.taskless/` store, converts existing full per-tool copies into stubs, replaces any symlinked tool entry with a real stub, and rewrites `taskless.json` with per-target `mode`. Converting a full copy to a stub is explicit migration work — a leftover full `SKILL.md` whose `name`/`description` still match canonical would otherwise read as drift-free and never be regenerated. The bootstrap system already runs migrations on the next `update`. +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 `description` drift from canonical** → If the canonical `description` changes, stubs go stale. Mitigation: `update` regenerates a stub _as a stub_ when frontmatter drifts — refreshing `name`/`description` only, never writing full body content. - **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) → The migration detects a symlinked tool entry and replaces it with a real stub file rather than writing through the link. -- **A leftover full copy reads as drift-free** → An old per-tool full `SKILL.md` has `name`/`description` matching canonical, so the drift check alone would never regenerate it. Mitigation: the migration converts full copies to stubs explicitly, rather than relying on `update`'s drift check. +- **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. Add a `.taskless/` migration that: writes the canonical `.taskless/skills/` and `.taskless/commands/` store; converts existing full per-tool copies recorded in the prior manifest into stubs; replaces any symlinked tool entry with a real reference stub; and rewrites `taskless.json` install state with per-target `mode`. -3. On a user's next `taskless update`, the bootstrap migration runner applies step 2; the install summary reports the converged layout. -4. Rollback: the manifest change is additive (legacy entries read as `canonical`). 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. +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 diff --git a/openspec/changes/cli-canonical-install/proposal.md b/openspec/changes/cli-canonical-install/proposal.md index 7d46a9a..910c92c 100644 --- a/openspec/changes/cli-canonical-install/proposal.md +++ b/openspec/changes/cli-canonical-install/proposal.md @@ -13,7 +13,7 @@ The fix is to give canonical content its own home that no tool ever installs int - 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. -- A `.taskless/` migration converges existing installs: seeds the canonical store, converts existing full per-tool copies into stubs, replaces any symlinked tool entry with a real stub, and stamps per-target modes. +- Existing installs converge without a migration. Stubs carry a `metadata.type: shim` frontmatter marker, 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 @@ -29,7 +29,6 @@ The fix is to give canonical content its own home that no tool ever installs int - **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. -- **Migration**: a new `.taskless/` migration seeds the canonical store, converts full per-tool copies into stubs, replaces symlinked tool entries with real stubs, and stamps per-target `mode` (`filesystem/migrations/`). -- **Manifest**: `.taskless/taskless.json` install-state schema gains per-target `mode`. +- **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/cli-canonical-install/specs/cli-init/spec.md b/openspec/changes/cli-canonical-install/specs/cli-init/spec.md index b56a193..b337b3c 100644 --- a/openspec/changes/cli-canonical-install/specs/cli-init/spec.md +++ b/openspec/changes/cli-canonical-install/specs/cli-init/spec.md @@ -24,7 +24,7 @@ The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's d ### 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; 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. +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.type: shim` marker that identifies the file as a reference stub. 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. @@ -39,6 +39,7 @@ The per-directory stub layout is: - **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 @@ -92,7 +93,7 @@ Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any di ### Requirement: Existing installs converge to the canonical-plus-stub layout -When a prior install recorded full skill or command copies in tool directories, or recorded a tool entry that exists on disk as a symlink, `taskless update` SHALL converge the repository onto the canonical-plus-stub layout: the canonical `.taskless/` store SHALL be seeded; each existing full per-tool copy SHALL be converted into a reference stub; any symlinked tool entry SHALL be replaced with a real stub file; and each target's `mode` SHALL be stamped (`.taskless` → `canonical`, tool directories → `reference`). Conversion SHALL be driven by recorded manifest state and SHALL be reported in the install summary. +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`). 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 diff --git a/openspec/changes/cli-canonical-install/tasks.md b/openspec/changes/cli-canonical-install/tasks.md index 4c6afb6..c928e39 100644 --- a/openspec/changes/cli-canonical-install/tasks.md +++ b/openspec/changes/cli-canonical-install/tasks.md @@ -32,20 +32,20 @@ - [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. Migration: converge existing installs +## 5. Convergence: self-healing applyInstallPlan -- [ ] 5.1 Add a new `.taskless/` migration in `filesystem/migrations/` that seeds the canonical `.taskless/skills/`/`.taskless/commands/` store -- [ ] 5.2 In the migration, convert existing full per-tool skill/command copies recorded in the prior manifest into reference stubs -- [ ] 5.3 In the migration, replace any symlinked tool entry (e.g. `.claude/skills/`) with a real reference stub (do not write through the symlink) -- [ ] 5.4 Rewrite `taskless.json` install state with per-target `mode` during the migration (`.taskless` canonical, tool dirs reference) -- [ ] 5.5 Unit test: a recorded multi-copy install converges to canonical + stubs; a symlinked tool entry becomes a real stub +- [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 -- [ ] 6.1 Verify `taskless update` rewrites canonical `.taskless/` content and leaves reference stubs intact -- [ ] 6.2 Verify update reports the converged layout (full-copy-to-stub and symlink conversions) in the install summary -- [ ] 6.3 Unit test: update against a stub install does not clobber the stub -- [ ] 6.4 Unit test: update never deletes the canonical store while cleaning another target +- [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 diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index 52d32be..3ea9bc4 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -70,8 +70,15 @@ export async function writeCanonicalCommand( return path; } -/** Serialize ordered string fields into a `---`-delimited frontmatter block. */ -function frontmatterBlock(fields: Record): string { +/** + * Frontmatter `metadata` block stamped onto every 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}. + */ +const SHIM_METADATA = { type: "shim" } as const; + +/** Serialize ordered frontmatter fields into a `---`-delimited block. */ +function frontmatterBlock(fields: Record): string { const yaml = stringify(fields).trimEnd(); return `---\n${yaml}\n---\n`; } @@ -84,7 +91,11 @@ function frontmatterBlock(fields: Record): string { export function buildSkillStub(meta: StubFrontmatter): string { const canonical = canonicalSkillPath(meta.name); return ( - frontmatterBlock({ name: meta.name, description: meta.description }) + + frontmatterBlock({ + name: meta.name, + description: meta.description, + metadata: SHIM_METADATA, + }) + "\n" + `This is a Taskless reference stub. The canonical skill is defined at ` + `\`${canonical}\`.\n\n` + @@ -102,11 +113,12 @@ export function buildCommandStub( filename: string ): string { const canonical = canonicalCommandPath(filename); - const fields: Record = { + const fields: Record = { name: meta.name, description: meta.description, }; if (meta.argumentHint) fields["argument-hint"] = meta.argumentHint; + fields.metadata = SHIM_METADATA; return ( frontmatterBlock(fields) + "\n" + @@ -130,3 +142,18 @@ export function stubFrontmatterDrifted( const { data } = parseFrontmatter(existingStub); return data.name !== meta.name || data.description !== meta.description; } + +/** + * 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 0146585..6608dc1 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -4,6 +4,7 @@ import { basename, dirname, join } from "node:path"; import { buildCommandStub, buildSkillStub, + isShimStub, stubFrontmatterDrifted, writeCanonicalCommand, writeCanonicalSkill, @@ -342,11 +343,34 @@ async function unlinkIfSymlink(path: string): Promise { } } +/** + * 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. + */ +async function referenceNeedsRewrite( + path: string, + meta: { name: string; description: string } +): 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); +} + /** * Write a skill into a target. A `canonical` target receives the full - * embedded content; a `reference` target receives a stub, written only when - * absent or when its frontmatter has drifted. Returns whether a file was - * written. + * 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, @@ -359,30 +383,18 @@ async function writeSkill( } const path = join(skillDirectory(cwd, target.dir, skill.name), "SKILL.md"); - const existing = await readFile(path, "utf8").catch(() => {}); - if ( - existing !== undefined && - !stubFrontmatterDrifted(existing, { - name: skill.name, - description: skill.description, - }) - ) { - return false; - } + const meta = { name: skill.name, description: skill.description }; + if (!(await referenceNeedsRewrite(path, meta))) return false; await unlinkIfSymlink(path); await mkdir(dirname(path), { recursive: true }); - await writeFile( - path, - buildSkillStub({ name: skill.name, description: skill.description }), - "utf8" - ); + await writeFile(path, buildSkillStub(meta), "utf8"); return true; } /** * Write a command into a target. Mirrors {@link writeSkill}: full content for - * a `canonical` target, a drift-checked stub for a `reference` target. + * a `canonical` target, a self-healing shim stub for a `reference` target. */ async function writeCommand( cwd: string, @@ -395,13 +407,11 @@ async function writeCommand( } const path = commandFile(cwd, target.dir, command.filename); - const existing = await readFile(path, "utf8").catch(() => {}); if ( - existing !== undefined && - !stubFrontmatterDrifted(existing, { + !(await referenceNeedsRewrite(path, { name: command.name, description: command.description, - }) + })) ) { return false; } diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index 90e0861..45ac9bf 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -1,13 +1,15 @@ 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 { @@ -16,6 +18,7 @@ import { getEmbeddedCommands, getEmbeddedSkills, } from "../src/install/install"; +import { isShimStub } from "../src/install/canonical"; import { parseFrontmatter } from "../src/install/frontmatter"; import { readInstallState, writeInstallState } from "../src/install/state"; @@ -221,6 +224,45 @@ describe("applyInstallPlan", () => { } }); + 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); + + 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"), { diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts index fdf0b97..0096665 100644 --- a/packages/cli/test/canonical-store.test.ts +++ b/packages/cli/test/canonical-store.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildCommandStub, buildSkillStub, + isShimStub, stubFrontmatterDrifted, writeCanonicalCommand, writeCanonicalSkill, @@ -71,6 +72,7 @@ describe("buildSkillStub", () => { 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"); @@ -130,6 +132,31 @@ describe("buildCommandStub", () => { }); }); +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." }; 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); + }); + } +}); From 90b2604e2f3d989290299c1e7eb8b9af43e3058e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 21:25:31 -0700 Subject: [PATCH 07/12] docs(cli): Document the canonical-install model and add a changeset Update the init/update help recipes, the CLI README, and the .taskless/README.md Files section to describe the canonical .taskless/ store plus per-tool reference stubs. Add a changeset for the release. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/canonical-install.md | 12 +++++++++++ .taskless/README.md | 2 ++ .../changes/cli-canonical-install/tasks.md | 10 +++++----- packages/cli/README.md | 11 ++++++++-- .../src/filesystem/migrations/0001-init.ts | 2 ++ packages/cli/src/help/init.txt | 20 +++++++++++-------- packages/cli/src/help/update.txt | 10 ++++++---- 7 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 .changeset/canonical-install.md 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/.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/cli-canonical-install/tasks.md b/openspec/changes/cli-canonical-install/tasks.md index c928e39..a4a3a4e 100644 --- a/openspec/changes/cli-canonical-install/tasks.md +++ b/openspec/changes/cli-canonical-install/tasks.md @@ -49,8 +49,8 @@ ## 7. Verification and docs -- [ ] 7.1 Run `pnpm typecheck` and `pnpm lint`; fix any issues -- [ ] 7.2 Run the CLI test suite; update existing install/update tests for the canonical-store-plus-stub model -- [ ] 7.3 Add a changeset describing the new install model and the BREAKING removal of `.cursor`/`.opencode` skill copies -- [ ] 7.4 Update CLI README / `help` text for `init`/`update` to describe the canonical `.taskless/` layout -- [ ] 7.5 Update the `.taskless/README.md` "Files" section to document `skills/` and `commands/` +- [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/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/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 From 3f7c8c6cdf65285f0067764f2c5897616425f420 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 21:41:39 -0700 Subject: [PATCH 08/12] feat(cli): Carry the canonical version in reference stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stub frontmatter now records metadata.version alongside the metadata.type: shim marker, copied from the canonical content. The drift check compares version too, so a canonical version bump regenerates every stub on the next update — keeping the stub's version reference accurate rather than frozen at install time. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../changes/cli-canonical-install/design.md | 2 +- .../changes/cli-canonical-install/proposal.md | 2 +- .../specs/cli-init/spec.md | 6 +-- packages/cli/src/install/canonical.ts | 27 +++++++++--- packages/cli/src/install/install.ts | 41 +++++++++---------- packages/cli/test/canonical-store.test.ts | 24 +++++++++++ 6 files changed, 69 insertions(+), 33 deletions(-) diff --git a/openspec/changes/cli-canonical-install/design.md b/openspec/changes/cli-canonical-install/design.md index 1860d68..7eb14b4 100644 --- a/openspec/changes/cli-canonical-install/design.md +++ b/openspec/changes/cli-canonical-install/design.md @@ -73,7 +73,7 @@ Instead, `applyInstallPlan` is **self-healing**. Every stub carries a frontmatte ## 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 `description` drift from canonical** → If the canonical `description` changes, stubs go stale. Mitigation: `update` regenerates a stub _as a stub_ when frontmatter drifts — refreshing `name`/`description` only, never writing full body content. +- **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. diff --git a/openspec/changes/cli-canonical-install/proposal.md b/openspec/changes/cli-canonical-install/proposal.md index 910c92c..8195e50 100644 --- a/openspec/changes/cli-canonical-install/proposal.md +++ b/openspec/changes/cli-canonical-install/proposal.md @@ -13,7 +13,7 @@ The fix is to give canonical content its own home that no tool ever installs int - 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 a `metadata.type: shim` frontmatter marker, 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`. +- 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 diff --git a/openspec/changes/cli-canonical-install/specs/cli-init/spec.md b/openspec/changes/cli-canonical-install/specs/cli-init/spec.md index b337b3c..18f260d 100644 --- a/openspec/changes/cli-canonical-install/specs/cli-init/spec.md +++ b/openspec/changes/cli-canonical-install/specs/cli-init/spec.md @@ -24,7 +24,7 @@ The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's d ### 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.type: shim` marker that identifies the file as a reference stub. 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. +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. @@ -70,7 +70,7 @@ Each target entry in `.taskless/taskless.json` install state SHALL record a `mod ### 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` has drifted from the canonical content; the stub's delegating body SHALL be preserved. +`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`. @@ -93,7 +93,7 @@ Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any di ### 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`). 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. +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 diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index 3ea9bc4..5df40aa 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -16,6 +16,12 @@ const CANONICAL_DIR = ".taskless"; 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; } /** @@ -71,11 +77,14 @@ export async function writeCanonicalCommand( } /** - * Frontmatter `metadata` block stamped onto every stub. `type: shim` marks the + * 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}. + * inspecting the body (see {@link isShimStub}); `version` records the + * canonical version the stub was generated from. */ -const SHIM_METADATA = { type: "shim" } as const; +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 { @@ -94,7 +103,7 @@ export function buildSkillStub(meta: StubFrontmatter): string { frontmatterBlock({ name: meta.name, description: meta.description, - metadata: SHIM_METADATA, + metadata: shimMetadata(meta.version), }) + "\n" + `This is a Taskless reference stub. The canonical skill is defined at ` + @@ -118,7 +127,7 @@ export function buildCommandStub( description: meta.description, }; if (meta.argumentHint) fields["argument-hint"] = meta.argumentHint; - fields.metadata = SHIM_METADATA; + fields.metadata = shimMetadata(meta.version); return ( frontmatterBlock(fields) + "\n" + @@ -140,7 +149,13 @@ export function stubFrontmatterDrifted( meta: StubFrontmatter ): boolean { const { data } = parseFrontmatter(existingStub); - return data.name !== meta.name || data.description !== meta.description; + 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; } /** diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 6608dc1..0719b72 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -8,6 +8,8 @@ import { stubFrontmatterDrifted, writeCanonicalCommand, writeCanonicalSkill, + type CommandStubFrontmatter, + type StubFrontmatter, } from "./canonical"; import { parseFrontmatter } from "./frontmatter"; import { @@ -68,6 +70,7 @@ export interface EmbeddedCommand { name: string; description: string; argumentHint?: string; + version?: string; } export interface SkillStatus { @@ -215,6 +218,7 @@ export function getEmbeddedCommands(): EmbeddedCommand[] { name?: string; description?: string; "argument-hint"?: string; + metadata?: Record; }; const filename = basename(path); return { @@ -223,6 +227,7 @@ export function getEmbeddedCommands(): EmbeddedCommand[] { name: data.name ?? filename.replace(/\.md$/, ""), description: data.description ?? "", argumentHint: data["argument-hint"], + version: data.metadata?.version, }; }); } @@ -352,7 +357,7 @@ async function unlinkIfSymlink(path: string): Promise { */ async function referenceNeedsRewrite( path: string, - meta: { name: string; description: string } + meta: StubFrontmatter ): Promise { let stats; try { @@ -383,7 +388,11 @@ async function writeSkill( } const path = join(skillDirectory(cwd, target.dir, skill.name), "SKILL.md"); - const meta = { name: skill.name, description: skill.description }; + const meta: StubFrontmatter = { + name: skill.name, + description: skill.description, + version: skill.metadata.version, + }; if (!(await referenceNeedsRewrite(path, meta))) return false; await unlinkIfSymlink(path); @@ -407,29 +416,17 @@ async function writeCommand( } const path = commandFile(cwd, target.dir, command.filename); - if ( - !(await referenceNeedsRewrite(path, { - name: command.name, - description: command.description, - })) - ) { - return false; - } + const meta: CommandStubFrontmatter = { + name: command.name, + description: command.description, + argumentHint: command.argumentHint, + version: command.version, + }; + if (!(await referenceNeedsRewrite(path, meta))) return false; await unlinkIfSymlink(path); await mkdir(dirname(path), { recursive: true }); - await writeFile( - path, - buildCommandStub( - { - name: command.name, - description: command.description, - argumentHint: command.argumentHint, - }, - command.filename - ), - "utf8" - ); + await writeFile(path, buildCommandStub(meta, command.filename), "utf8"); return true; } diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts index 0096665..86a637d 100644 --- a/packages/cli/test/canonical-store.test.ts +++ b/packages/cli/test/canonical-store.test.ts @@ -79,6 +79,20 @@ describe("buildSkillStub", () => { 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 { @@ -178,4 +192,14 @@ describe("stubFrontmatterDrifted", () => { 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 + ); + }); }); From 872fae0dd00ee834b378d6fe71e594de9a7a8429 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 22:13:18 -0700 Subject: [PATCH 09/12] test(cli): Cover the wizard tool-selection detection mapping Extract the pure detection-to-choices logic from promptLocations into a testable locationChoices() function, and unit-test it: every shim target is offered, the canonical .taskless/ store is never selectable, detected tools are pre-checked, and .agents/ is the default when nothing is detected. The interactive multiselect itself still needs a TTY and stays covered only by the mocked wizard-integration tests; this closes the gap on the detection mapping that feeds it. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/wizard/steps/locations.ts | 52 ++++++++++++++++------ packages/cli/test/wizard-steps.test.ts | 38 ++++++++++++++++ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/wizard/steps/locations.ts b/packages/cli/src/wizard/steps/locations.ts index e1b4a62..8e5c02d 100644 --- a/packages/cli/src/wizard/steps/locations.ts +++ b/packages/cli/src/wizard/steps/locations.ts @@ -4,9 +4,47 @@ 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 { + value: string; + label: string; + hint: string; +} + +/** + * 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 @@ -14,21 +52,9 @@ import { ask } from "../ask"; */ export async function promptLocations(cwd: string): Promise { const detected = await detectTools(cwd); - const detectedDirectories = new Set(detected.map((t) => t.installDir)); - const initialValues = - detected.length > 0 ? [...detectedDirectories] : [DEFAULT_SHIM_DIR]; + const { options, initialValues } = locationChoices(detected); while (true) { - 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", - })); - const selected = await ask("locations", () => multiselect({ message: "Which tools do you want to enable Taskless for?", 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"); + }); +}); From f3fc93a323d8e5f06b1a26fcb0539a4a950cad9e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 22:19:39 -0700 Subject: [PATCH 10/12] chore(openspec): Archive cli-canonical-install and sync cli-init spec Sync the cli-canonical-install delta into openspec/specs/cli-init/spec.md (5 added, 9 modified requirements) so the main spec reflects the shipped canonical-store-plus-stub install model, and move the change to openspec/changes/archive/2026-05-17-cli-canonical-install/. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cli-init/spec.md | 0 .../tasks.md | 0 openspec/specs/cli-init/spec.md | 223 ++++++++++++++---- 6 files changed, 176 insertions(+), 47 deletions(-) rename openspec/changes/{cli-canonical-install => archive/2026-05-17-cli-canonical-install}/.openspec.yaml (100%) rename openspec/changes/{cli-canonical-install => archive/2026-05-17-cli-canonical-install}/design.md (100%) rename openspec/changes/{cli-canonical-install => archive/2026-05-17-cli-canonical-install}/proposal.md (100%) rename openspec/changes/{cli-canonical-install => archive/2026-05-17-cli-canonical-install}/specs/cli-init/spec.md (100%) rename openspec/changes/{cli-canonical-install => archive/2026-05-17-cli-canonical-install}/tasks.md (100%) diff --git a/openspec/changes/cli-canonical-install/.openspec.yaml b/openspec/changes/archive/2026-05-17-cli-canonical-install/.openspec.yaml similarity index 100% rename from openspec/changes/cli-canonical-install/.openspec.yaml rename to openspec/changes/archive/2026-05-17-cli-canonical-install/.openspec.yaml diff --git a/openspec/changes/cli-canonical-install/design.md b/openspec/changes/archive/2026-05-17-cli-canonical-install/design.md similarity index 100% rename from openspec/changes/cli-canonical-install/design.md rename to openspec/changes/archive/2026-05-17-cli-canonical-install/design.md diff --git a/openspec/changes/cli-canonical-install/proposal.md b/openspec/changes/archive/2026-05-17-cli-canonical-install/proposal.md similarity index 100% rename from openspec/changes/cli-canonical-install/proposal.md rename to openspec/changes/archive/2026-05-17-cli-canonical-install/proposal.md diff --git a/openspec/changes/cli-canonical-install/specs/cli-init/spec.md b/openspec/changes/archive/2026-05-17-cli-canonical-install/specs/cli-init/spec.md similarity index 100% rename from openspec/changes/cli-canonical-install/specs/cli-init/spec.md rename to openspec/changes/archive/2026-05-17-cli-canonical-install/specs/cli-init/spec.md diff --git a/openspec/changes/cli-canonical-install/tasks.md b/openspec/changes/archive/2026-05-17-cli-canonical-install/tasks.md similarity index 100% rename from openspec/changes/cli-canonical-install/tasks.md rename to openspec/changes/archive/2026-05-17-cli-canonical-install/tasks.md diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 7e4289a..993b00d 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,13 +488,13 @@ 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 @@ -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 From a4a383358f19bb31624e538650d8b2e4f8c0e2b1 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 22:21:31 -0700 Subject: [PATCH 11/12] fix(openspec): Add a normative keyword to the cli-init re-install requirement The "Re-install computes a diff against the previous manifest" requirement lacked a SHALL/MUST keyword, failing openspec spec validation. Reword to make the diff computation and confirmation normative. Pre-existing error, surfaced while syncing OSS-8. Co-Authored-By: Claude Opus 4.7 (1M context) --- openspec/specs/cli-init/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 993b00d..e91c0b9 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -498,7 +498,7 @@ The install manifest in `.taskless/taskless.json` continues to record what was w ### 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 From f01957e9bcaa9eb7a4730ed995489142cccb6f64 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 17 May 2026 22:36:29 -0700 Subject: [PATCH 12/12] test(cli): Update bundle-level summary assertions for the new install output init-no-interactive.test.ts and cli.test.ts exec the built CLI bundle and asserted the old per-tool summary line (": installed"). The canonical-install model prints " (/): wrote N skill ..." instead. These were missed locally because the tests ran against a stale dist/ build; CI's fresh build caught them. Refs OSS-8 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/test/cli.test.ts | 2 +- packages/cli/test/init-no-interactive.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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);