diff --git a/.changeset/multi-tool-detection.md b/.changeset/multi-tool-detection.md new file mode 100644 index 0000000..3e04d19 --- /dev/null +++ b/.changeset/multi-tool-detection.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Add multi-tool detection for `taskless init`. The CLI now detects and installs skills for OpenCode (`.opencode/`, `opencode.jsonc`, `opencode.json`), Cursor (`.cursor/`, `.cursorrules`), and Claude Code (now also via `CLAUDE.md`). When no tools are detected, skills are installed to `.agents/skills/` as a fallback. diff --git a/.conventions/STYLEGUIDE-CODE.md b/.conventions/STYLEGUIDE-CODE.md index 1a947a3..bb68564 100644 --- a/.conventions/STYLEGUIDE-CODE.md +++ b/.conventions/STYLEGUIDE-CODE.md @@ -122,6 +122,36 @@ interface GitHubComment { - You need a subset/transformation of the original type for your domain model - You're defining types for your own APIs (not external ones) +### Export Types Referenced by Public API Signatures + +**DO NOT** remove `export` from types that are transitively referenced by exported functions, values, or other exported types — even if tools like knip report them as "unused exports." With `declaration: true` in `tsconfig`, TypeScript requires all types in exported signatures to be exported themselves. + +Before removing an `export` from a type, check whether any exported function or value references it in its signature (parameters, return types, or fields of other exported types). + +```typescript +// ✅ Good - Type is exported because it's used in an exported function's return type +export interface VerifyResult { + success: boolean; + schema: LayerResult; +} + +export interface LayerResult { + valid: boolean; + errors: string[]; +} + +export async function verifyRule(path: string): Promise { ... } + +// ❌ Bad - Knip says LayerResult is "unused" so you remove the export +interface LayerResult { ... } // breaks declaration emit for VerifyResult +``` + +**Rationale:** + +- Knip tracks direct import usage, not transitive type reachability through exported signatures +- Removing these exports causes `declaration: true` to fail with "exported function has or is using private name" errors +- The fix is tedious — each type must be re-exported individually, often across multiple review cycles + ## Cross-Worker Durable Object Access ### Use DurableObjectRPC for Cross-Worker DO Calls diff --git a/openspec/changes/cli-posthog-analytics/.openspec.yaml b/openspec/changes/archive/2026-04-07-cli-posthog-analytics/.openspec.yaml similarity index 100% rename from openspec/changes/cli-posthog-analytics/.openspec.yaml rename to openspec/changes/archive/2026-04-07-cli-posthog-analytics/.openspec.yaml diff --git a/openspec/changes/cli-posthog-analytics/design.md b/openspec/changes/archive/2026-04-07-cli-posthog-analytics/design.md similarity index 100% rename from openspec/changes/cli-posthog-analytics/design.md rename to openspec/changes/archive/2026-04-07-cli-posthog-analytics/design.md diff --git a/openspec/changes/cli-posthog-analytics/proposal.md b/openspec/changes/archive/2026-04-07-cli-posthog-analytics/proposal.md similarity index 100% rename from openspec/changes/cli-posthog-analytics/proposal.md rename to openspec/changes/archive/2026-04-07-cli-posthog-analytics/proposal.md diff --git a/openspec/changes/cli-posthog-analytics/specs/analytics/spec.md b/openspec/changes/archive/2026-04-07-cli-posthog-analytics/specs/analytics/spec.md similarity index 100% rename from openspec/changes/cli-posthog-analytics/specs/analytics/spec.md rename to openspec/changes/archive/2026-04-07-cli-posthog-analytics/specs/analytics/spec.md diff --git a/openspec/changes/cli-posthog-analytics/tasks.md b/openspec/changes/archive/2026-04-07-cli-posthog-analytics/tasks.md similarity index 100% rename from openspec/changes/cli-posthog-analytics/tasks.md rename to openspec/changes/archive/2026-04-07-cli-posthog-analytics/tasks.md diff --git a/openspec/changes/archive/2026-04-07-multi-tool-detection/.openspec.yaml b/openspec/changes/archive/2026-04-07-multi-tool-detection/.openspec.yaml new file mode 100644 index 0000000..23ef75a --- /dev/null +++ b/openspec/changes/archive/2026-04-07-multi-tool-detection/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-08 diff --git a/openspec/changes/archive/2026-04-07-multi-tool-detection/design.md b/openspec/changes/archive/2026-04-07-multi-tool-detection/design.md new file mode 100644 index 0000000..6cd5310 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-multi-tool-detection/design.md @@ -0,0 +1,61 @@ +## Context + +The CLI's `detectTools()` function checks for known directories to decide where to install skills. Currently, `ToolDescriptor` uses a single `dir` field for both detection and install path. Only Claude Code (`.claude/` directory) is supported. + +We need to support four tool targets with detection signals that include both files and directories, and add a fallback install path when no tools are detected. + +## Goals / Non-Goals + +**Goals:** + +- Detect Claude Code, OpenCode, Cursor, and install skills into each detected tool's directory +- Support file-based detection signals (e.g., `CLAUDE.md`, `.cursorrules`, `opencode.json`) +- Provide `.agents/skills/` as a fallback when no tools are detected +- Keep the install logic unchanged — only detection and the tool registry expand + +**Non-Goals:** + +- Adapting skill content per tool (all tools get identical `SKILL.md` files) +- Supporting tool-specific config file generation (e.g., creating `.cursorrules`) +- Auto-detecting tools that require network or process inspection +- Adding commands support for any tool other than Claude Code + +## Decisions + +### Split detection signals from install root + +**Decision:** Replace the single `dir` field in `ToolDescriptor` with separate `detect` and `installDir` fields. + +**Rationale:** Detection of `CLAUDE.md` (a file) should trigger installation into `.claude/` (a directory). The current design conflates these — `dir` is both the detection check and the install root. Splitting them keeps each concern clean. + +**`detect`** is an array of signal objects: + +```typescript +type DetectionSignal = + | { type: "directory"; path: string } + | { type: "file"; path: string }; +``` + +**`installDir`** is the directory root for skill (and optionally command) installation. This replaces `dir`. + +**Alternative considered:** Keep `dir` and add an optional `additionalSignals` array. Rejected because it makes `dir` semantically overloaded — it would mean "primary detection signal AND install root," which is confusing when the primary signal for a tool might be a file. + +### Fallback is handled in init, not in detection + +**Decision:** The `.agents/` fallback is not a detected tool. Instead, `init.ts` checks whether any installs were made after the tool loop and falls back to a hardcoded `AGENTS_FALLBACK` descriptor. + +**Rationale:** `.agents/` has no detection signal — it exists precisely because nothing was detected. Modeling it as a detected tool would require a sentinel like `detect: []` meaning "always match," which is misleading. Keeping it as explicit fallback logic in `init.ts` makes the intent clear. + +### Detection uses `stat` for both directories and files + +**Decision:** Directory signals use `stat().isDirectory()` (existing pattern). File signals use `stat().isFile()` so detection only succeeds for actual files, not directories that happen to exist at the same path. + +**Rationale:** Using `stat` consistently for both signal types keeps detection semantics explicit and avoids false positives for file-based signals. For directories we need `isDirectory()`, and for files we need `isFile()` to distinguish them from directories of the same name. + +## Risks / Trade-offs + +**[Multiple installs create duplication]** → A repo with both `.claude/` and `.cursor/` gets skills in two places. This is intentional — each tool reads from its own directory. Users who find this noisy can remove the tool directory they don't use. + +**[File detection may create directories]** → Detecting `CLAUDE.md` without a `.claude/` directory means `mkdir -p .claude/skills/` runs during install, creating a new directory. This is acceptable — if the user has `CLAUDE.md`, they're using Claude Code and will benefit from having skills installed. Same applies to `.cursorrules` creating `.cursor/skills/`. + +**[`.cursorrules` false positives]** → `.cursorrules` is widely used and still actively supported by Cursor. Detection of it is a valid signal that the project uses Cursor. diff --git a/openspec/changes/archive/2026-04-07-multi-tool-detection/proposal.md b/openspec/changes/archive/2026-04-07-multi-tool-detection/proposal.md new file mode 100644 index 0000000..ccce665 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-multi-tool-detection/proposal.md @@ -0,0 +1,28 @@ +## Why + +The CLI currently only detects Claude Code (via `.claude/` directory). As AI coding tools proliferate — OpenCode, Cursor, and generic agent frameworks — users expect `taskless init` to install skills wherever their tools are configured. Expanding detection also provides a fallback (`.agents/skills/`) so that projects without a recognized tool still get skills installed. + +## What Changes + +- **Expand Claude Code detection** to also trigger on a `CLAUDE.md` file in the project root (not just the `.claude/` directory). +- **Add OpenCode detection** via `.opencode/` directory, `opencode.jsonc`, or `opencode.json` in the project root. Skills install to `.opencode/skills//SKILL.md`. +- **Add Cursor detection** via `.cursor/` directory or `.cursorrules` file in the project root. Skills install to `.cursor/skills//SKILL.md`. +- **Add `.agents/` fallback** — if no recognized tools were detected (zero installs made), install skills to `.agents/skills//SKILL.md`. +- **Split detection signals from install paths** in `ToolDescriptor` — detection can now be triggered by files or directories, while the install root is a separate field. +- Commands remain Claude Code-only; all other tools receive skills only. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-init`: The init command's detection and install flow changes to support multiple tools, file-based detection signals, and the `.agents/` fallback. + +## Impact + +- **Code**: `packages/cli/src/install/install.ts` — `ToolDescriptor` type, `TOOLS` registry, `detectTools()` function. `packages/cli/src/commands/init.ts` — fallback logic after install loop. +- **Tests**: Existing detection tests need updating; new tests for each tool's detection signals and the fallback behavior. +- **User-facing**: `taskless init` output changes to list multiple tools when detected; fallback message changes from "no tools found" to installing into `.agents/`. diff --git a/openspec/changes/archive/2026-04-07-multi-tool-detection/specs/cli-init/spec.md b/openspec/changes/archive/2026-04-07-multi-tool-detection/specs/cli-init/spec.md new file mode 100644 index 0000000..556588a --- /dev/null +++ b/openspec/changes/archive/2026-04-07-multi-tool-detection/specs/cli-init/spec.md @@ -0,0 +1,188 @@ +## ADDED Requirements + +### Requirement: Tool descriptor separates detection from install path + +Each tool in the registry SHALL have a `detect` array of signals and a separate `installDir` string. Detection signals SHALL be objects with a `type` field (`"directory"` or `"file"`) and a `path` field relative to the project root. + +#### Scenario: Tool detected by directory signal + +- **WHEN** a tool descriptor has `{ type: "directory", path: ".claude" }` in its `detect` array +- **AND** `.claude/` exists as a directory in the project root +- **THEN** the tool SHALL be detected + +#### Scenario: Tool detected by file signal + +- **WHEN** a tool descriptor has `{ type: "file", path: "CLAUDE.md" }` in its `detect` array +- **AND** `CLAUDE.md` exists as a file in the project root +- **THEN** the tool SHALL be detected + +#### Scenario: Tool detected by any matching signal + +- **WHEN** a tool descriptor has multiple signals in its `detect` array +- **AND** at least one signal matches a file or directory in the project root +- **THEN** the tool SHALL be detected + +#### Scenario: Tool not detected when no signals match + +- **WHEN** no signals in a tool's `detect` array match files or directories in the project root +- **THEN** the tool SHALL NOT be detected + +### 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 + +Skills SHALL be installed to `.claude/skills//SKILL.md`. Commands SHALL be installed to `.claude/commands/tskl/`. + +#### 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/` + +#### 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/` + +### 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 + +Skills SHALL be installed to `.opencode/skills//SKILL.md`. OpenCode SHALL NOT receive commands. + +#### Scenario: OpenCode detected by .opencode directory + +- **WHEN** `.opencode/` exists as a directory in the project root +- **THEN** OpenCode SHALL be detected + +#### Scenario: OpenCode detected by opencode.jsonc file + +- **WHEN** `opencode.jsonc` exists as a file in the project root +- **THEN** OpenCode SHALL be detected + +#### Scenario: OpenCode detected by opencode.json file + +- **WHEN** `opencode.json` exists as a file in the project root +- **THEN** OpenCode SHALL be detected + +### Requirement: Cursor detection signals + +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`. Cursor SHALL NOT receive commands. + +#### 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 + +### 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. + +#### Scenario: Fallback installs to .agents 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/` + +#### Scenario: Fallback not used when tools are detected + +- **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 + +### Requirement: Detection checks files and directories with stat + +Directory detection signals SHALL use `fs.stat()` and verify `isDirectory()`. File detection signals SHALL use `fs.stat()` and verify `isFile()` to avoid false positives on directories. All detection checks SHALL run in parallel. + +#### Scenario: Directory signal uses stat + +- **WHEN** a directory detection signal is evaluated +- **THEN** `fs.stat()` SHALL be called on the path +- **AND** `isDirectory()` SHALL return true for detection to succeed + +#### Scenario: File signal uses stat + +- **WHEN** a file detection signal is evaluated +- **THEN** `fs.stat()` SHALL be called on the path +- **AND** `isFile()` SHALL return true for detection to succeed + +#### Scenario: Detection runs in parallel + +- **WHEN** `detectTools()` is called +- **THEN** all tool detection checks SHALL run concurrently via `Promise.all` + +## MODIFIED Requirements + +### Requirement: Tool detection via filesystem inspection + +The CLI SHALL detect installed AI tools by checking for known detection signals (files and directories) via parallel filesystem checks. Detection SHALL NOT rely on a config file. The tool registry SHALL be a typed array of tool descriptors maintained in `packages/cli/src/install/install.ts`. Each tool descriptor SHALL have a `detect` array of signals and a separate `installDir` for the install root. + +#### Scenario: Claude Code is detected + +- **WHEN** a `.claude/` directory or `CLAUDE.md` file exists in the working directory +- **THEN** the CLI SHALL detect Claude Code as an installed tool + +#### Scenario: OpenCode is detected + +- **WHEN** a `.opencode/` directory, `opencode.jsonc` file, or `opencode.json` file exists in the working directory +- **THEN** the CLI SHALL detect OpenCode as an installed tool + +#### Scenario: Cursor is detected + +- **WHEN** a `.cursor/` directory or `.cursorrules` file exists in the working directory +- **THEN** the CLI SHALL detect Cursor as an installed tool + +#### Scenario: Multiple tools are detected + +- **WHEN** multiple known tool signals exist (e.g., `.claude/` and `.cursor/`) +- **THEN** the CLI SHALL detect all of them and install skills for each + +#### Scenario: No tools detected triggers fallback + +- **WHEN** no known tool signals exist in the working directory +- **THEN** the CLI SHALL install skills to `.agents/skills/` as a fallback + +### Requirement: Init subcommand installs skills into a repository + +The CLI SHALL support a `taskless init` subcommand that installs Taskless skills into the current working directory. The subcommand SHALL also be available as `taskless update` (alias with identical behavior). When no tool directories are detected, the CLI SHALL install skills to `.agents/skills//SKILL.md` as a fallback. + +#### Scenario: Running taskless init installs skills + +- **WHEN** a user runs `taskless init` in a repository with at least one detected AI tool +- **THEN** the CLI SHALL write skill files into each detected tool's directory +- **AND** report which tools were updated and how many skills were installed + +#### Scenario: Running taskless update behaves identically to init + +- **WHEN** a user runs `taskless update` +- **THEN** the behavior SHALL be identical to `taskless init` + +#### Scenario: Running init with no detected tools uses fallback + +- **WHEN** a user runs `taskless init` in a repository with no detected AI tool signals +- **THEN** the CLI SHALL install skills to `.agents/skills/` +- **AND** report that the fallback location was used diff --git a/openspec/changes/archive/2026-04-07-multi-tool-detection/tasks.md b/openspec/changes/archive/2026-04-07-multi-tool-detection/tasks.md new file mode 100644 index 0000000..3aa2c68 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-multi-tool-detection/tasks.md @@ -0,0 +1,48 @@ +## 1. Refactor ToolDescriptor to split detection from install path + +Purely structural refactor — no new tools, no behavior change. Claude Code remains the only tool, still detected by `.claude/` directory only. + +- [x] 1.1 Add `DetectionSignal` type (`{ type: "directory" | "file"; path: string }`) to `packages/cli/src/install/install.ts` +- [x] 1.2 Replace `dir` field in `ToolDescriptor` with `detect: DetectionSignal[]` and `installDir: string` +- [x] 1.3 Update Claude Code entry in `TOOLS` to use `detect: [{ type: "directory", path: ".claude" }]` and `installDir: ".claude"` +- [x] 1.4 Update `detectTools()` to iterate each tool's `detect` array, using `stat().isDirectory()` for directory signals and `stat().isFile()` for file signals +- [x] 1.5 Update `installForTool`, `removeOwnedSkills`, `removeOwnedCommands`, and `checkStaleness` to use `installDir` instead of `dir` +- [x] 1.6 Create `test/install.test.ts` with baseline detection tests: Claude Code detected via `.claude/` directory, not detected when absent +- [x] 1.7 Test `installForTool` writes skills to `installDir`-based path and creates directories if missing +- [x] 1.8 Test `checkStaleness` reports status using `installDir`-based paths +- [x] 1.9 Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build` + +## 2. Expand Claude Code detection and add OpenCode and Cursor tools + +Add new detection signals and tool entries. Each detected tool gets skills installed. + +- [x] 2.1 Add `CLAUDE.md` file signal to Claude Code's `detect` array +- [x] 2.2 Add OpenCode entry: detect `[".opencode/" dir, "opencode.jsonc" file, "opencode.json" file]`, installDir `.opencode`, no commands +- [x] 2.3 Add Cursor entry: detect `[".cursor/" dir, ".cursorrules" file]`, installDir `.cursor`, no commands +- [x] 2.4 Test `detectTools()` detects Claude Code via `.claude/` directory +- [x] 2.5 Test `detectTools()` detects Claude Code via `CLAUDE.md` file +- [x] 2.6 Test `detectTools()` detects OpenCode via `.opencode/` directory, `opencode.jsonc`, and `opencode.json` +- [x] 2.7 Test `detectTools()` detects Cursor via `.cursor/` directory and `.cursorrules` +- [x] 2.8 Test `detectTools()` returns multiple tools when multiple signals exist +- [x] 2.9 Test `detectTools()` returns empty when no signals match +- [x] 2.10 Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build` + +## 3. Add .agents fallback when no tools detected + +Replace the "no tools found" message with a fallback install to `.agents/skills/`. + +- [x] 3.1 Define `AGENTS_FALLBACK` descriptor: no detect array, installDir `.agents`, no commands +- [x] 3.2 In `init.ts`, track whether any installs were made after the tool loop +- [x] 3.3 If zero installs, call `installForTool(cwd, AGENTS_FALLBACK, skills, [])` and report fallback usage +- [x] 3.4 Remove the old "no supported tool directories detected" message +- [x] 3.5 Test fallback installs to `.agents/skills/` when no tools detected +- [x] 3.6 Test fallback is NOT used when at least one tool is detected +- [x] 3.7 Test fallback does not install commands +- [x] 3.8 Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build` + +## 4. Update info command and help text + +- [x] 4.1 Verify `info.ts` works with updated `checkStaleness()` (uses `installDir`) +- [x] 4.2 Update `src/help/init.txt` if it references detection behavior +- [x] 4.3 Test `checkStaleness` returns correct status for each newly supported tool (OpenCode, Cursor, `.agents/`) +- [x] 4.4 Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build` diff --git a/openspec/specs/analytics/spec.md b/openspec/specs/analytics/spec.md new file mode 100644 index 0000000..f53fea8 --- /dev/null +++ b/openspec/specs/analytics/spec.md @@ -0,0 +1,190 @@ +# Analytics + +## Purpose + +Defines the PostHog telemetry module for the `@taskless/cli` package — anonymous identity, authenticated identity upgrade, opt-out, event capture, and client lifecycle. + +## Requirements + +### Requirement: Telemetry module exports getTelemetry + +The CLI SHALL provide a `getTelemetry(cwd?: string)` function in `src/telemetry.ts` that returns a telemetry object with `capture(event: string, properties?: Record)` and `shutdown()` methods. The function SHALL resolve identity, create the PostHog client, and call `identify()` internally before returning. The `identify()` call is not exposed on the public interface. + +#### Scenario: Telemetry object is created + +- **WHEN** `getTelemetry()` is called +- **THEN** it SHALL return an object with `capture` and `shutdown` methods + +#### Scenario: Telemetry resolves identity on creation + +- **WHEN** `getTelemetry(cwd)` is called with a working directory that has a valid JWT +- **THEN** it SHALL resolve the authenticated identity (JWT subject as `distinctId`, org group) before returning + +### Requirement: Anonymous identity persists in XDG config + +The CLI SHALL generate a UUID v4 on first run and persist it to `$XDG_CONFIG_HOME/taskless/anonymous_id` (or `~/.config/taskless/anonymous_id`). Subsequent invocations SHALL read the existing UUID. The file SHALL contain only the raw UUID string (no JSON, no newline). + +#### Scenario: First run generates anonymous ID + +- **WHEN** `getTelemetry()` is called and `anonymous_id` does not exist +- **THEN** the CLI SHALL generate a UUID v4, write it to the XDG config directory, and use it as the `distinctId` + +#### Scenario: Subsequent run reads existing anonymous ID + +- **WHEN** `getTelemetry()` is called and `anonymous_id` already exists +- **THEN** the CLI SHALL read the existing UUID and use it + +#### Scenario: Anonymous ID file is deleted + +- **WHEN** the `anonymous_id` file is manually deleted between invocations +- **THEN** the CLI SHALL generate a new UUID on the next run + +#### Scenario: XDG config directory does not exist + +- **WHEN** the XDG config directory (`~/.config/taskless/`) does not exist +- **THEN** the CLI SHALL create it before writing the `anonymous_id` file + +### Requirement: Authenticated identity upgrade + +When a valid JWT is available (via `getToken()`), the CLI SHALL use the JWT subject (`sub` claim) as the `distinctId` instead of the anonymous UUID. It SHALL call `posthog.identify()` with the `cli` property set to the anonymous UUID, linking the device to the authenticated user. It SHALL call `posthog.groupIdentify()` with the `organization` group type and the JWT `orgId` claim as the group key. + +#### Scenario: JWT available upgrades identity + +- **WHEN** `getTelemetry(cwd)` is called and a valid JWT exists for the working directory +- **THEN** `distinctId` SHALL be the JWT `sub` claim +- **AND** `identify()` SHALL be called with `{ cli: anonymousUuid }` +- **AND** `groupIdentify()` SHALL be called with `{ groupType: 'organization', groupKey: String(orgId) }` + +#### Scenario: No JWT falls back to anonymous + +- **WHEN** `getTelemetry(cwd)` is called and no JWT is available +- **THEN** `distinctId` SHALL be the anonymous UUID +- **AND** `identify()` SHALL be called with `{ cli: anonymousUuid }` +- **AND** `groupIdentify()` SHALL NOT be called + +### Requirement: Telemetry is disabled by environment variable + +Setting `TASKLESS_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1` SHALL cause `getTelemetry()` to return an inert stub with no-op implementations of `capture` and `shutdown`. No PostHog client SHALL be created. No network requests SHALL be made. No anonymous ID file SHALL be read or written. + +#### Scenario: TASKLESS_TELEMETRY_DISABLED disables telemetry + +- **WHEN** `TASKLESS_TELEMETRY_DISABLED` is set to `"1"` +- **THEN** `getTelemetry()` SHALL return a no-op stub +- **AND** no PostHog client SHALL be instantiated + +#### Scenario: DO_NOT_TRACK disables telemetry + +- **WHEN** `DO_NOT_TRACK` is set to `"1"` +- **THEN** `getTelemetry()` SHALL return a no-op stub + +#### Scenario: Telemetry enabled by default + +- **WHEN** neither `TASKLESS_TELEMETRY_DISABLED` nor `DO_NOT_TRACK` is set +- **THEN** `getTelemetry()` SHALL create a real PostHog client + +### Requirement: PostHog client uses hardcoded constants + +The PostHog client SHALL be created with project token `phc_stymptTiUskp4zM3m9StNSGheHwjskaYagpxV7rDjZyc` and host `https://z.taskless.io`. These SHALL be hardcoded constants in the telemetry module, not read from environment variables or config files. + +#### Scenario: Client uses correct project token and host + +- **WHEN** a PostHog client is created +- **THEN** it SHALL use the hardcoded project token and host URL + +### Requirement: PostHog client uses immediate flush + +The PostHog client SHALL be created with `flushAt: 1` and `flushInterval: 0` because the CLI is a short-lived process. `shutdown()` SHALL be called before the process exits to ensure buffered events are delivered. + +#### Scenario: Events flush immediately + +- **WHEN** `capture()` is called +- **THEN** the event SHALL be flushed immediately (not batched) + +#### Scenario: Shutdown flushes remaining events + +- **WHEN** `shutdown()` is called +- **THEN** all buffered events SHALL be flushed before the promise resolves + +### Requirement: All capture calls include standard properties + +Every `capture()` call SHALL include the `cli` property (anonymous UUID). When authenticated, the `groups` parameter SHALL include `{ organization: String(orgId) }`. + +#### Scenario: Anonymous capture includes standard properties + +- **WHEN** `capture("cli_check")` is called without authentication +- **THEN** the event SHALL include `{ cli: anonymousUuid }` +- **AND** the event SHALL NOT include a `groups` parameter + +#### Scenario: Authenticated capture includes standard properties and group + +- **WHEN** `capture("cli_rule_create")` is called with authentication +- **THEN** the event SHALL include `{ cli: anonymousUuid }` +- **AND** the `groups` parameter SHALL include `{ organization: String(orgId) }` + +### Requirement: CLI events use cli\_ prefix + +All CLI events SHALL use the `cli_` prefix and `snake_case` naming. The following events SHALL be emitted: + +| Event | Command | +| -------------------------- | ------------------------- | +| `cli_help` | `help` (top-level) | +| `cli_help_auth` | `help auth [subcommand]` | +| `cli_help_check` | `help check` | +| `cli_help_info` | `help info` | +| `cli_help_init` | `help init` | +| `cli_help_rule` | `help rules [subcommand]` | +| `cli_auth_login` | `auth login` (initiated) | +| `cli_auth_login_completed` | `auth login` (succeeded) | +| `cli_auth_logout` | `auth logout` | +| `cli_check` | `check` | +| `cli_init` | `init` | +| `cli_info` | `info` | +| `cli_rule_create` | `rules create` | +| `cli_rule_improve` | `rules improve` | +| `cli_rule_delete` | `rules delete` | +| `cli_rule_verify` | `rules verify` | +| `cli_rule_meta` | `rules meta` | + +#### Scenario: Each command emits its event + +- **WHEN** a user runs `taskless check` +- **THEN** the CLI SHALL emit a `cli_check` event before the command logic executes + +#### Scenario: Help for specific topic emits scoped event + +- **WHEN** a user runs `taskless help rules` +- **THEN** the CLI SHALL emit a `cli_help_rule` event + +### Requirement: Telemetry failures are silent + +All telemetry operations (client creation, `capture`, `identify`, `groupIdentify`, `shutdown`) SHALL catch and suppress errors. A telemetry failure SHALL NOT cause a command to fail or alter its exit code. + +#### Scenario: Network failure during capture + +- **WHEN** the PostHog API is unreachable during `capture()` +- **THEN** the error SHALL be silently suppressed +- **AND** the command SHALL continue normally + +#### Scenario: Malformed anonymous ID file + +- **WHEN** the `anonymous_id` file exists but contains invalid content +- **THEN** the CLI SHALL generate a new UUID and overwrite the file + +### Requirement: Telemetry lifecycle uses lazy init with centralized shutdown + +Each command handler SHALL call `getTelemetry(cwd)` to lazily initialize the singleton with the correct working directory for identity resolution. The main entry point (`src/index.ts`) SHALL call `shutdownTelemetry()` in a `finally` block after the subcommand completes. If no command initialized telemetry, shutdown SHALL be a no-op (no PostHog client created). + +#### Scenario: Telemetry is initialized lazily by command handler + +- **WHEN** a subcommand handler runs +- **THEN** it SHALL call `getTelemetry(cwd)` to initialize telemetry with the resolved working directory + +#### Scenario: Telemetry is shut down after subcommand completes + +- **WHEN** a subcommand handler returns +- **THEN** `shutdownTelemetry()` SHALL be called in the entry point `finally` block before the process exits + +#### Scenario: No telemetry init when no command runs + +- **WHEN** the CLI exits without running a command (e.g. showing top-level help) +- **THEN** `shutdownTelemetry()` SHALL be a no-op and no PostHog client SHALL be created diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 65ae115..9d7bc69 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -8,11 +8,11 @@ TBD — Defines the `taskless init` subcommand that installs Taskless skills int ### Requirement: Init subcommand installs skills into a repository -The CLI SHALL support a `taskless init` subcommand that installs Taskless skills into the current working directory. The subcommand SHALL also be available as `taskless update` (alias with identical behavior). When no tool directories are detected, the CLI SHALL inform the user and suggest using the Claude Code Plugin Marketplace or Vercel skills CLI as alternatives. +The CLI SHALL support a `taskless init` subcommand that installs Taskless skills into the current working directory. The subcommand SHALL also be available as `taskless update` (alias with identical behavior). When no tool directories are detected, the CLI SHALL install skills to `.agents/skills//SKILL.md` as a fallback. #### Scenario: Running taskless init installs skills -- **WHEN** a user runs `taskless init` in a repository with at least one detected AI tool directory +- **WHEN** a user runs `taskless init` in a repository with at least one detected AI tool - **THEN** the CLI SHALL write skill files into each detected tool's directory - **AND** report which tools were updated and how many skills were installed @@ -21,30 +21,175 @@ The CLI SHALL support a `taskless init` subcommand that installs Taskless skills - **WHEN** a user runs `taskless update` - **THEN** the behavior SHALL be identical to `taskless init` -#### Scenario: Running init with no detected tools shows alternatives +#### Scenario: Running init with no detected tools uses fallback -- **WHEN** a user runs `taskless init` in a repository with no detected AI tool directories -- **THEN** the CLI SHALL inform the user that no supported tool directories were found -- **AND** suggest using the Claude Code Plugin Marketplace or Vercel skills CLI for installation +- **WHEN** a user runs `taskless init` in a repository with no detected AI tool signals +- **THEN** the CLI SHALL install skills to `.agents/skills/` +- **AND** report that the fallback location was used ### Requirement: Tool detection via filesystem inspection -The CLI SHALL detect installed AI tools by checking for known directories via parallel `fs.stat` calls. Detection SHALL NOT rely on a config file. The tool registry SHALL be a typed array of tool descriptors maintained in `cli/src/actions/install.ts`. +The CLI SHALL detect installed AI tools by checking for known detection signals (files and directories) via parallel filesystem checks. Detection SHALL NOT rely on parsing configuration file contents or other tool-specific metadata; config-style files MAY be used only as file-presence signals. The tool registry SHALL be a typed array of tool descriptors maintained in `packages/cli/src/install/install.ts`. Each tool descriptor SHALL have a `detect` array of signals and a separate `installDir` for the install root. #### Scenario: Claude Code is detected -- **WHEN** a `.claude/` directory exists in the working directory +- **WHEN** a `.claude/` directory or `CLAUDE.md` file exists in the working directory - **THEN** the CLI SHALL detect Claude Code as an installed tool +#### Scenario: OpenCode is detected + +- **WHEN** a `.opencode/` directory, `opencode.jsonc` file, or `opencode.json` file exists in the working directory +- **THEN** the CLI SHALL detect OpenCode as an installed tool + +#### Scenario: Cursor is detected + +- **WHEN** a `.cursor/` directory or `.cursorrules` file exists in the working directory +- **THEN** the CLI SHALL detect Cursor as an installed tool + #### Scenario: Multiple tools are detected -- **WHEN** multiple known tool directories exist (e.g., `.claude/` and `.cursor/`) +- **WHEN** multiple known tool signals exist (e.g., `.claude/` and `.cursor/`) - **THEN** the CLI SHALL detect all of them and install skills for each -#### Scenario: No tools are detected +#### Scenario: No tools detected triggers fallback + +- **WHEN** no known tool signals exist in the working directory +- **THEN** the CLI SHALL install skills to `.agents/skills/` as a fallback + +### Requirement: Tool descriptor separates detection from install path + +Each tool in the registry SHALL have a `detect` array of signals and a separate `installDir` string. Detection signals SHALL be objects with a `type` field (`"directory"` or `"file"`) and a `path` field relative to the project root. + +#### Scenario: Tool detected by directory signal + +- **WHEN** a tool descriptor has `{ type: "directory", path: ".claude" }` in its `detect` array +- **AND** `.claude/` exists as a directory in the project root +- **THEN** the tool SHALL be detected + +#### Scenario: Tool detected by file signal + +- **WHEN** a tool descriptor has `{ type: "file", path: "CLAUDE.md" }` in its `detect` array +- **AND** `CLAUDE.md` exists as a file in the project root +- **THEN** the tool SHALL be detected + +#### Scenario: Tool detected by any matching signal + +- **WHEN** a tool descriptor has multiple signals in its `detect` array +- **AND** at least one signal matches a file or directory in the project root +- **THEN** the tool SHALL be detected + +#### Scenario: Tool not detected when no signals match + +- **WHEN** no signals in a tool's `detect` array match files or directories in the project root +- **THEN** the tool SHALL NOT be detected + +### 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 + +Skills SHALL be installed to `.claude/skills//SKILL.md`. Commands SHALL be installed to `.claude/commands/tskl/`. + +#### 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/` + +#### 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/` + +### 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 + +Skills SHALL be installed to `.opencode/skills//SKILL.md`. OpenCode SHALL NOT receive commands. + +#### Scenario: OpenCode detected by .opencode directory + +- **WHEN** `.opencode/` exists as a directory in the project root +- **THEN** OpenCode SHALL be detected + +#### Scenario: OpenCode detected by opencode.jsonc file + +- **WHEN** `opencode.jsonc` exists as a file in the project root +- **THEN** OpenCode SHALL be detected + +#### Scenario: OpenCode detected by opencode.json file + +- **WHEN** `opencode.json` exists as a file in the project root +- **THEN** OpenCode SHALL be detected + +### Requirement: Cursor detection signals + +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`. Cursor SHALL NOT receive commands. + +#### 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 + +### 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. + +#### Scenario: Fallback installs to .agents 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/` + +#### Scenario: Fallback not used when tools are detected + +- **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 + +### Requirement: Detection checks files and directories with stat + +Directory detection signals SHALL use `fs.stat()` and verify `isDirectory()`. File detection signals SHALL use `fs.stat()` and verify `isFile()` to avoid false positives on directories. All detection checks SHALL run in parallel. + +#### Scenario: Directory signal uses stat + +- **WHEN** a directory detection signal is evaluated +- **THEN** `fs.stat()` SHALL be called on the path +- **AND** `isDirectory()` SHALL return true for detection to succeed + +#### Scenario: File signal uses stat + +- **WHEN** a file detection signal is evaluated +- **THEN** `fs.stat()` SHALL be called on the path +- **AND** `isFile()` SHALL return true for detection to succeed + +#### Scenario: Detection runs in parallel -- **WHEN** no known tool directories exist in the working directory -- **THEN** the CLI SHALL inform the user and suggest using the Claude Code Plugin Marketplace or Vercel skills CLI +- **WHEN** `detectTools()` is called +- **THEN** all tool detection checks SHALL run concurrently via `Promise.all` ### Requirement: Skills are installed as Agent Skills spec SKILL.md files diff --git a/packages/cli/src/capabilities.ts b/packages/cli/src/capabilities.ts deleted file mode 100644 index 4c96eff..0000000 --- a/packages/cli/src/capabilities.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Validate that a string is a YYYY-MM-DD or YYYY-MM-DD.patch spec version */ -export function isValidSpecVersion(value: string): boolean { - const match = /^(\d{4})-(\d{2})-(\d{2})(\.\d+)?$/.exec(value); - if (!match) return false; - const month = Number(match[2]); - const day = Number(match[3]); - return month >= 1 && month <= 12 && day >= 1 && day <= 31; -} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b556cd3..7e1c8ed 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -2,6 +2,7 @@ import { resolve } from "node:path"; import { defineCommand } from "citty"; import { + AGENTS_FALLBACK, detectTools, getEmbeddedSkills, getEmbeddedCommands, @@ -30,15 +31,11 @@ export const initCommand = defineCommand({ const commands = getEmbeddedCommands(); const tools = await detectTools(cwd); - if (tools.length === 0) { - console.log( - `No supported tool directories detected.\n\nAlternative installation methods:\n - Claude Code Plugin Marketplace: /plugin marketplace add taskless/skills\n - Vercel Skills CLI: npx skills add taskless/skills` - ); - return; - } + let installCount = 0; for (const tool of tools) { const result = await installForTool(cwd, tool, skills, commands); + installCount++; console.log( `${tool.name}: installed ${String(result.skills.length)} skill(s)` ); @@ -47,9 +44,22 @@ export const initCommand = defineCommand({ } if (result.commands.length > 0) { console.log( - ` + ${String(result.commands.length)} command(s) in ${tool.dir}/${tool.commands!.path}/` + ` + ${String(result.commands.length)} command(s) in ${tool.installDir}/${tool.commands!.path}/` ); } } + + if (installCount === 0) { + const result = await installForTool(cwd, AGENTS_FALLBACK, skills, []); + console.log( + `No tools detected. Using fallback: ${AGENTS_FALLBACK.installDir}/` + ); + console.log( + `${AGENTS_FALLBACK.name}: installed ${String(result.skills.length)} skill(s)` + ); + for (const name of result.skills) { + console.log(` - ${name}`); + } + } }, }); diff --git a/packages/cli/src/help/init.txt b/packages/cli/src/help/init.txt index 42ab055..4b85b99 100644 --- a/packages/cli/src/help/init.txt +++ b/packages/cli/src/help/init.txt @@ -1,7 +1,15 @@ Install Taskless skills Detects supported coding agent tools in your project and installs -Taskless skills and commands for each one. Currently supports Claude Code. +Taskless skills and commands for each one. + +Supported tools: + Claude Code .claude/ directory or CLAUDE.md file + OpenCode .opencode/ directory, opencode.jsonc, or opencode.json + Cursor .cursor/ directory or .cursorrules file + +If no tools are detected, skills are installed to .agents/skills/ as a +fallback location. Usage: taskless init [options] @@ -10,14 +18,10 @@ Options: -d, --dir Set working directory (default: current directory) Behavior: - - Detects tool directories (e.g., .claude/) in the project root - - Installs skill files to the tool's skills directory - - Installs command files to the tool's commands directory - - If no supported tools are detected, suggests alternative install methods - -Alternative Installation: - Claude Code Plugin Marketplace: /plugin marketplace add taskless/skills - Vercel Skills CLI: npx skills add taskless/skills + - Detects tool signals (files and directories) in the project root + - Installs skill files to each detected tool's skills directory + - Installs command files for Claude Code only + - Falls back to .agents/skills/ if no tools are detected Examples: taskless init diff --git a/packages/cli/src/install/frontmatter.ts b/packages/cli/src/install/frontmatter.ts index 6c403ed..15edb63 100644 --- a/packages/cli/src/install/frontmatter.ts +++ b/packages/cli/src/install/frontmatter.ts @@ -1,6 +1,6 @@ -import { parse, stringify } from "yaml"; +import { parse } from "yaml"; -interface Frontmatter { +export interface Frontmatter { data: Record; content: string; } @@ -16,11 +16,3 @@ export function parseFrontmatter(source: string): Frontmatter { content: match[2] ?? "", }; } - -/** Serialize data as YAML frontmatter prepended to a body string. */ -export function stringifyFrontmatter( - body: string, - data: Record -): string { - return `---\n${stringify(data, { lineWidth: 0 })}---\n${body}`; -} diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 4d4fac5..21a6e0c 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -23,9 +23,15 @@ const commandFiles: Record = import.meta.glob( // --- Types --- -interface ToolDescriptor { +export interface DetectionSignal { + type: "directory" | "file"; + path: string; +} + +export interface ToolDescriptor { name: string; - dir: string; + detect: DetectionSignal[]; + installDir: string; skills: { path: string; }; @@ -34,7 +40,7 @@ interface ToolDescriptor { }; } -interface EmbeddedSkill { +export interface EmbeddedSkill { name: string; description: string; content: string; @@ -42,19 +48,19 @@ interface EmbeddedSkill { metadata: Record; } -interface EmbeddedCommand { +export interface EmbeddedCommand { filename: string; content: string; } -interface SkillStatus { +export interface SkillStatus { name: string; installedVersion: string | undefined; currentVersion: string; current: boolean; } -interface ToolStatus { +export interface ToolStatus { name: string; skills: SkillStatus[]; } @@ -64,7 +70,11 @@ interface ToolStatus { const TOOLS: ToolDescriptor[] = [ { name: "Claude Code", - dir: ".claude", + detect: [ + { type: "directory", path: ".claude" }, + { type: "file", path: "CLAUDE.md" }, + ], + installDir: ".claude", skills: { path: "skills", }, @@ -72,17 +82,64 @@ const TOOLS: ToolDescriptor[] = [ path: "commands/tskl", }, }, + { + name: "OpenCode", + detect: [ + { type: "directory", path: ".opencode" }, + { type: "file", path: "opencode.jsonc" }, + { type: "file", path: "opencode.json" }, + ], + installDir: ".opencode", + skills: { + path: "skills", + }, + }, + { + name: "Cursor", + detect: [ + { type: "directory", path: ".cursor" }, + { type: "file", path: ".cursorrules" }, + ], + installDir: ".cursor", + skills: { + path: "skills", + }, + }, ]; +export const AGENTS_FALLBACK: ToolDescriptor = { + name: "Agent Skills", + detect: [], + installDir: ".agents", + skills: { + path: "skills", + }, +}; + // --- Detection --- +async function checkSignal( + cwd: string, + signal: DetectionSignal +): Promise { + const fullPath = join(cwd, signal.path); + if (signal.type === "directory") { + return stat(fullPath) + .then((s) => s.isDirectory()) + .catch(() => false); + } + return stat(fullPath) + .then((s) => s.isFile()) + .catch(() => false); +} + export async function detectTools(cwd: string): Promise { const results = await Promise.all( TOOLS.map(async (tool) => { - const exists = await stat(join(cwd, tool.dir)) - .then((s) => s.isDirectory()) - .catch(() => false); - return exists ? tool : undefined; + const signals = await Promise.all( + tool.detect.map((signal) => checkSignal(cwd, signal)) + ); + return signals.some(Boolean) ? tool : undefined; }) ); return results.filter((t): t is ToolDescriptor => t !== undefined); @@ -133,7 +190,7 @@ async function removeOwnedSkills( cwd: string, tool: ToolDescriptor ): Promise { - const skillsDirectory = join(cwd, tool.dir, tool.skills.path); + const skillsDirectory = join(cwd, tool.installDir, tool.skills.path); let entries: string[]; try { @@ -161,7 +218,7 @@ async function removeOwnedCommands( ): Promise { if (!tool.commands) return; - const commandsBase = join(cwd, tool.dir, dirname(tool.commands.path)); + const commandsBase = join(cwd, tool.installDir, dirname(tool.commands.path)); for (const directoryName of COMMAND_DIRS) { await rm(join(commandsBase, directoryName), { @@ -193,7 +250,12 @@ export async function installForTool( // Install skills verbatim for (const skill of skills) { - const skillDirectory = join(cwd, tool.dir, tool.skills.path, skill.name); + 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); @@ -201,7 +263,7 @@ export async function installForTool( // Place commands (Claude Code only) if (tool.commands) { - const commandDirectory = join(cwd, tool.dir, tool.commands.path); + const commandDirectory = join(cwd, tool.installDir, tool.commands.path); await mkdir(commandDirectory, { recursive: true }); for (const command of commands) { await writeFile( @@ -234,6 +296,15 @@ async function readInstalledSkillVersion( 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) + const fallbackExists = await stat(join(cwd, AGENTS_FALLBACK.installDir)) + .then((s) => s.isDirectory()) + .catch(() => false); + if (fallbackExists) { + tools.push(AGENTS_FALLBACK); + } + const results: ToolStatus[] = []; for (const tool of tools) { @@ -242,7 +313,7 @@ export async function checkStaleness(cwd: string): Promise { for (const skill of embedded) { const installedPath = join( cwd, - tool.dir, + tool.installDir, tool.skills.path, skill.name, "SKILL.md" diff --git a/packages/cli/src/rules/verify.ts b/packages/cli/src/rules/verify.ts index c8572b0..6ece170 100644 --- a/packages/cli/src/rules/verify.ts +++ b/packages/cli/src/rules/verify.ts @@ -24,16 +24,16 @@ function escapeRegExp(s: string): string { // --- Types --- -interface LayerResult { +export interface LayerResult { valid: boolean; errors: string[]; } -interface RequirementsResult extends LayerResult { +export interface RequirementsResult extends LayerResult { hasTestFile: boolean; } -interface TestLayerResult extends LayerResult { +export interface TestLayerResult extends LayerResult { passed: number; failed: number; } diff --git a/packages/cli/src/schemas/check.ts b/packages/cli/src/schemas/check.ts index 739be4e..c66b890 100644 --- a/packages/cli/src/schemas/check.ts +++ b/packages/cli/src/schemas/check.ts @@ -1,7 +1,7 @@ import { z } from "zod"; /** Schema for a single check result */ -export const checkResultSchema = z.object({ +const checkResultSchema = z.object({ source: z.string().describe("Scanner that produced this result"), ruleId: z.string().describe("Rule identifier"), severity: z diff --git a/packages/cli/src/util/format.ts b/packages/cli/src/util/format.ts index 53a3591..ce17c4f 100644 --- a/packages/cli/src/util/format.ts +++ b/packages/cli/src/util/format.ts @@ -42,16 +42,3 @@ export function formatText(results: CheckResult[]): string { return lines.join("\n"); } - -/** Format results as a JSON object with success state */ -export function formatJson( - results: CheckResult[], - options?: { success: boolean; error?: string } -): string { - const hasErrors = results.some((r) => r.severity === "error"); - return JSON.stringify({ - success: options?.success ?? !hasErrors, - ...(options?.error ? { error: options.error } : {}), - results, - }); -} diff --git a/packages/cli/test/capabilities.test.ts b/packages/cli/test/capabilities.test.ts deleted file mode 100644 index 27866aa..0000000 --- a/packages/cli/test/capabilities.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isValidSpecVersion } from "../src/capabilities"; - -describe("isValidSpecVersion", () => { - it("accepts valid YYYY-MM-DD dates", () => { - expect(isValidSpecVersion("2026-03-01")).toBe(true); - expect(isValidSpecVersion("2025-12-15")).toBe(true); - expect(isValidSpecVersion("2026-01-01")).toBe(true); - }); - - it("accepts patch versions (YYYY-MM-DD.N)", () => { - expect(isValidSpecVersion("2026-03-01.1")).toBe(true); - expect(isValidSpecVersion("2026-03-01.42")).toBe(true); - }); - - it("rejects invalid formats", () => { - expect(isValidSpecVersion("not-a-date")).toBe(false); - expect(isValidSpecVersion("2026-13-01")).toBe(false); - expect(isValidSpecVersion("2026-00-01")).toBe(false); - expect(isValidSpecVersion("2026-01-32")).toBe(false); - expect(isValidSpecVersion("")).toBe(false); - expect(isValidSpecVersion("2026")).toBe(false); - expect(isValidSpecVersion("2026-3-1")).toBe(false); - }); -}); diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index aa6530c..cd1383c 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -54,15 +54,15 @@ describe("cli", () => { await rm(temporaryDirectory, { recursive: true, force: true }); }); - it("shows alternative install methods when no tool directories exist", async () => { + it("uses .agents fallback when no tool directories exist", async () => { const { stdout } = await execFileAsync("node", [ binPath, "init", "-d", temporaryDirectory, ]); - expect(stdout).toContain("No supported tool directories detected"); - expect(stdout).toContain("Alternative installation methods"); + expect(stdout).toContain("No tools detected. Using fallback: .agents/"); + expect(stdout).toContain("Agent Skills: installed"); }); it("installs skills when .claude/ directory exists", async () => { diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts new file mode 100644 index 0000000..56f2d7f --- /dev/null +++ b/packages/cli/test/install.test.ts @@ -0,0 +1,208 @@ +import { mkdir, mkdtemp, rm, readFile, 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, + detectTools, + getEmbeddedSkills, + installForTool, + checkStaleness, +} from "../src/install/install"; + +let cwd: string; + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-install-test-")); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); +}); + +describe("detectTools", () => { + it("detects Claude Code via .claude/ directory", async () => { + await mkdir(join(cwd, ".claude"), { recursive: true }); + const tools = await detectTools(cwd); + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe("Claude Code"); + }); + + it("detects Claude Code via CLAUDE.md file", async () => { + await writeFile(join(cwd, "CLAUDE.md"), "# Claude", "utf8"); + const tools = await detectTools(cwd); + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe("Claude Code"); + }); + + it("detects OpenCode via .opencode/ directory", async () => { + await mkdir(join(cwd, ".opencode"), { recursive: true }); + const tools = await detectTools(cwd); + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe("OpenCode"); + }); + + it("detects OpenCode via opencode.jsonc file", async () => { + await writeFile(join(cwd, "opencode.jsonc"), "{}", "utf8"); + const tools = await detectTools(cwd); + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe("OpenCode"); + }); + + it("detects OpenCode via opencode.json file", async () => { + await writeFile(join(cwd, "opencode.json"), "{}", "utf8"); + const tools = await detectTools(cwd); + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe("OpenCode"); + }); + + it("detects Cursor via .cursor/ directory", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + const tools = await detectTools(cwd); + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe("Cursor"); + }); + + it("detects Cursor via .cursorrules file", async () => { + await writeFile(join(cwd, ".cursorrules"), "", "utf8"); + const tools = await detectTools(cwd); + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe("Cursor"); + }); + + it("returns multiple tools when multiple signals exist", async () => { + await mkdir(join(cwd, ".claude"), { recursive: true }); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + const tools = await detectTools(cwd); + expect(tools).toHaveLength(2); + const names = tools.map((t) => t.name); + expect(names).toContain("Claude Code"); + expect(names).toContain("Cursor"); + }); + + it("returns empty when no signals match", async () => { + const tools = await detectTools(cwd); + expect(tools).toHaveLength(0); + }); +}); + +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("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); + }); + + it("does not install commands", async () => { + const skills = getEmbeddedSkills(); + const result = await installForTool(cwd, AGENTS_FALLBACK, skills, []); + expect(result.commands).toHaveLength(0); + }); +}); + +describe("checkStaleness", () => { + it("reports status using installDir-based paths", 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 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 () => { + 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); + } + } + }); +});