diff --git a/.please/docs/knowledge/product.md b/.please/docs/knowledge/product.md index 895fa22..500a915 100644 --- a/.please/docs/knowledge/product.md +++ b/.please/docs/knowledge/product.md @@ -19,6 +19,8 @@ One command (`ask install`) generates `AGENTS.md` with lazy documentation refere The lazy commands `ask src ` and `ask docs ` give coding agents on-demand documentation access: they print absolute paths to a cached source tree (and any documentation directories), fetching on cache miss. Both commands work via shell substitution, e.g. `rg "pattern" $(ask src react)`. +`ask skills ` is a sibling namespace that surfaces **producer-side skills** shipped by libraries (a `skills/` directory convention). `ask skills install` vendors those skills into `.ask/skills/` and symlinks them into every detected coding agent directory (Claude Code, Cursor, OpenCode, Codex) so the agent can consume library-authored instruction bundles directly. + ## Product Components | Component | Purpose | diff --git a/.please/docs/tracks.jsonl b/.please/docs/tracks.jsonl index 08358b6..76fa114 100644 --- a/.please/docs/tracks.jsonl +++ b/.please/docs/tracks.jsonl @@ -22,3 +22,4 @@ {"id":"interactive-add-20260412","type":"feature","status":"in_progress","phase":"implement","issue":"#71","created":"2026-04-12","section":"active"} {"id":"monorepo-tag-pattern-20260413","type":"feature","status":"review","phase":"finalize","issue":"#77","pr":"#79","created":"2026-04-13","section":"completed"} {"id":"telemetry-driven-registry-20260413","type":"feature","status":"in_progress","phase":"implement","issue":"#78","created":"2026-04-13","section":"active"} +{"id":"ask-skills-command-20260414","type":"feature","status":"review","phase":"finalize","issue":"#86","pr":"#87","created":"2026-04-14","section":"completed"} diff --git a/.please/docs/tracks/completed/ask-skills-command-20260414/metadata.json b/.please/docs/tracks/completed/ask-skills-command-20260414/metadata.json new file mode 100644 index 0000000..d6dd50c --- /dev/null +++ b/.please/docs/tracks/completed/ask-skills-command-20260414/metadata.json @@ -0,0 +1,11 @@ +{ + "track_id": "ask-skills-command-20260414", + "type": "feature", + "status": "review", + "created_at": "2026-04-14T00:00:00Z", + "updated_at": "2026-04-15T17:30:00Z", + "issue": "#86", + "pr": "#87", + "project": "", + "project_item_id": "" +} diff --git a/.please/docs/tracks/completed/ask-skills-command-20260414/plan.md b/.please/docs/tracks/completed/ask-skills-command-20260414/plan.md new file mode 100644 index 0000000..af6af52 --- /dev/null +++ b/.please/docs/tracks/completed/ask-skills-command-20260414/plan.md @@ -0,0 +1,163 @@ +# Plan: `ask skills` Command + +> Track: ask-skills-command-20260414 +> Spec: [spec.md](./spec.md) + +## Overview + +- **Source**: /please:plan +- **Track**: ask-skills-command-20260414 +- **Issue**: (assigned in metadata after Issue creation) +- **Created**: 2026-04-14 +- **Approach**: New `skills` citty namespace with `list|install|remove` subcommands, backed by a `.ask/skills/` vendored store, `.ask/skills-lock.json` lock file, and relative POSIX symlinks into each selected agent's `skills/` directory. Reuses `ensureCheckout` and the existing `findDocLikePaths` walker pattern. + +## Purpose + +Give users a single command family to surface and consume library-provided (producer-side) skill directories, mirroring `ask docs` for documentation. `install` makes skills actually usable by any supported coding agent (Claude Code, Cursor, OpenCode, Codex) without duplicating bytes across agent dirs. + +## Context + +- `ask docs` and `ask src` already use a shared `ensureCheckout` helper (`packages/cli/src/commands/ensure-checkout.ts`) and a generic walker (`packages/cli/src/commands/find-doc-paths.ts`). `ask skills` reuses the first verbatim and parallels the second with a `/skill/i` regex. +- Vendoring under `.ask/` is an established pattern: `.ask/docs/` + `.ask/resolved.json` are already managed by `packages/cli/src/ignore-files.ts` via a `# ask:start ... # ask:end` marker block. The same mechanism extends to `.ask/skills/` + `.ask/skills-lock.json` with a single patch payload edit. +- The existing `generateSkill` in `packages/cli/src/skill.ts` emits consumer-side `.claude/skills/-docs/SKILL.md` files (docs pointers). Producer-side skills are orthogonal and must never collide with that path — we install under `/skills//` (no `-docs` suffix). + +## Architecture Decision + +**Why a namespace command instead of flags on `ask install`**: `list` and `install` have incompatible semantics (read-only vs mutating) and `ask install` is already overloaded. A dedicated `skills` namespace keeps the mental model clean and keeps future subcommands (e.g. `ask skills update`) discoverable. + +**Why a lock file instead of scanning**: `remove` must undo exactly what `install` did. Scanning `/skills/` at remove time would either over-delete (user-authored skills) or under-delete (agents removed from the detection list). The lock is the only safe source of truth. + +**Why symlinks from agent dirs to `.ask/skills/` instead of copies**: copies triple/quadruple bytes across agent dirs and drift if the vendored copy is refreshed. A relative symlink from `.claude/skills/foo` → `../../.ask/skills//foo` stays in sync automatically and is safe to commit-ignore at the agent level (via the vendored-skills marker block). + +**Why `.ask/skills///` not `.ask/skills//`**: multiple libraries can ship skills with the same name. Namespacing by spec avoids collisions and makes `remove` a single `rm -rf` of the spec subdir. + +**`` encoding**: replace `/`, `@`, `:` with `__` — reversible, grep-friendly, filesystem-safe on every platform. Examples: `npm__next__14.2.3`, `github__vercel__ai__v5.0.0`. + +## Architecture Diagram + +``` + ensureCheckout (existing) + | + v + +----------------------------------+ + | ~/.ask/github/...// | + +----------------------------------+ + | + findSkillLikePaths (new) + | + +---list---+ +----install----+ + | stdout | | | + +----------+ v v + vendor(copy) agent-detect + select + | | + v v + .ask/skills// symlinks in .claude/skills/ etc + \ / + \ / + v v + .ask/skills-lock.json + (updated atomically) +``` + +## Tasks + +- [x] T001 [P] Add `findSkillLikePaths` walker in `packages/cli/src/commands/find-skill-paths.ts` plus tests in `packages/cli/test/commands/find-skill-paths.test.ts` (file: packages/cli/src/commands/find-skill-paths.ts). Mirrors `findDocLikePaths` with regex `/skill/i`; same MAX_DEPTH=4 and SKIP_DIRS. Acceptance: returns root + all `/skill/i` subdirs; empty array on missing root. +- [x] T002 [P] Add spec-key encoder `encodeSpecKey` in `packages/cli/src/skills/spec-key.ts` + tests (file: packages/cli/src/skills/spec-key.ts). Converts resolved `(ecosystem, name, version)` to `{ecosystem}__{name}__{version}` (with `/` → `__`). Acceptance: round-trips via `decodeSpecKey` for at least npm, github, scoped-npm, monorepo-tag cases. +- [x] T003 [P] Add `.ask/skills-lock.json` schema + IO helpers in `packages/cli/src/skills/lock.ts` + tests (file: packages/cli/src/skills/lock.ts). Functions: `readLock`, `upsertEntry`, `removeEntry`, `writeLockAtomic` (tmp-file + rename). Lock shape: `{ version: 1, entries: { [specKey]: { spec, specKey, skills: [{ name, agents: ["claude", ...] }], installedAt } } }`. +- [x] T004 [P] Add agent detector in `packages/cli/src/skills/agent-detect.ts` + tests (file: packages/cli/src/skills/agent-detect.ts). Function `detectAgents(projectDir)` returns `{ name, label, skillsDir }[]` for every agent marker present: `.claude/` → `.claude/skills`, `.cursor/` → `.cursor/skills`, `.opencode/` → `.opencode/skills`, `.codex/` → `.codex/skills`. `AGENTS.md` alone does not enable any target. +- [x] T005 [P] Implement `ask skills list` in `packages/cli/src/commands/skills/list.ts` (file: packages/cli/src/commands/skills/list.ts) (depends on T001). Mirrors `runDocs`: calls `ensureCheckout`, walks `node_modules//` if set, walks the checkout, prints paths via `log()`. Honors `--no-fetch`. Includes unit tests with mocked `ensureCheckout`. +- [x] T006 Implement vendor step `vendorSkills(projectDir, specKey, sourcePaths)` in `packages/cli/src/skills/vendor.ts` + tests (file: packages/cli/src/skills/vendor.ts) (depends on T002). Atomic: copy to `.ask/skills///` via staging dir + rename on success. Refresh-safe: replaces any prior version of the same vendored key. +- [x] T007 Implement symlink utilities `linkSkill`, `verifyLink`, `unlinkIfOwned` in `packages/cli/src/skills/symlinks.ts` + tests (file: packages/cli/src/skills/symlinks.ts). Uses relative POSIX symlinks. `unlinkIfOwned` only unlinks when `fs.readlinkSync` resolves to the expected vendored target; never deletes real directories. +- [x] T008 Extend `ignore-files.ts` to also vendor `.ask/skills/` and `.ask/skills-lock.json` (file: packages/cli/src/ignore-files.ts). Update `ROOT_PATCHES` payloads for `.gitignore`, `.prettierignore`, `sonar-project.properties`, `.markdownlintignore`. Add a test that `manageIgnoreFiles('install')` produces the new marker block content. +- [x] T009 Implement `ask skills install` in `packages/cli/src/commands/skills/install.ts` (file: packages/cli/src/commands/skills/install.ts) (depends on T002, T003, T004, T005, T006, T007, T008). Orchestrates: resolve via `ensureCheckout` → walk → vendor → detect → multiselect (if >1) via `consola.prompt` → symlink → update lock → `manageIgnoreFiles('install')`. Flags: `--force`, `--no-fetch`, `--agent [,...]` (opt-in explicit override of detection). Emits `consola.info` summary of what was installed. +- [x] T010 Implement `ask skills remove` in `packages/cli/src/commands/skills/remove.ts` (file: packages/cli/src/commands/skills/remove.ts) (depends on T003, T007). Reads lock, iterates recorded symlinks, `unlinkIfOwned` each, deletes `.ask/skills//`, purges lock entry. Errors if lock entry missing (unless `--ignore-missing`). +- [x] T011 Wire `skillsCmd` into `packages/cli/src/index.ts` (file: packages/cli/src/index.ts) (depends on T005, T009, T010). Default run (no subcommand) dispatches to `list`. Update `cli/commands.test.ts` / `src-docs-registration.test.ts` style test to assert `skills`, `skills list`, `skills install`, `skills remove` are all registered. +- [x] T012 End-to-end integration test in `packages/cli/test/commands/skills.integration.test.ts` (file: packages/cli/test/commands/skills.integration.test.ts) (depends on T009, T010, T011). Happy path: install into a fixture with `.claude/` + `.cursor/`, verify vendored files, verify symlinks, verify lock; re-install is no-op; remove deletes everything. +- [x] T013 Documentation update in root `README.md` and `packages/cli/README.md` (file: README.md) (depends on T011). Add `ask skills` section under CLI usage; mention v1 platform limits (Linux/macOS only). + +## Dependencies + +``` +T001 ---\ +T002 ---+--> T005 ---\ +T003 ---| +--> T009 --> T011 --> T012 --> T013 +T004 ---| | ^ +T006 ---| | | +T007 ---+------------+ | +T008 ---+-------------------------+ +T010 ---(depends on T003, T007)---+ +``` + +T001–T004 and T008 are [P] (parallel). T005 depends only on T001. T006–T007 are leaf utilities runnable after T002/T003 land. T009 is the integration point; it blocks T010–T013. + +## Key Files + +- **Reuse**: `packages/cli/src/commands/ensure-checkout.ts`, `packages/cli/src/commands/find-doc-paths.ts` (pattern reference) +- **Reuse**: `packages/cli/src/ignore-files.ts` (extend `ROOT_PATCHES` + `NESTED_CONFIGS`) +- **Reuse**: `packages/cli/src/markers.ts` (wrap/inject/remove) +- **Reference (unchanged)**: `packages/cli/src/skill.ts` (consumer-side `*-docs` SKILL.md — must NOT be touched) +- **New**: `packages/cli/src/skills/` (vendor, symlinks, lock, agent-detect, spec-key) +- **New**: `packages/cli/src/commands/skills/` (list, install, remove) + `packages/cli/src/commands/find-skill-paths.ts` +- **Modified**: `packages/cli/src/index.ts` (register `skillsCmd`) +- **Modified**: `README.md`, `packages/cli/README.md` + +## Verification + +- `bun run --cwd packages/cli lint` — zero errors. +- `bun run --cwd packages/cli test` — all new unit + integration tests pass. +- Manual smoke: + 1. `cd /tmp/ask-smoke && mkdir -p .claude .cursor && npm init -y && node /dist/index.js skills install github:pleaseai/some-repo@v1` + 2. Confirm `.ask/skills/github__pleaseai__some-repo__v1/` populated, symlinks present in `.claude/skills/` and `.cursor/skills/`, `.gitignore` patched. + 3. Repeat same command — no error, no duplication. + 4. `skills remove ...` — all symlinks and vendored dir gone; lock entry purged. +- AC-1 through AC-7 from spec each covered by at least one automated test. + +## Progress + +- [x] Phase 1 (T001–T004, T008): foundation utilities +- [x] Phase 2 (T005–T007): list command + vendor/symlink primitives +- [x] Phase 3 (T009–T011): install/remove orchestration + wiring +- [x] Phase 4 (T012–T013): integration test + docs + +## Decision Log + +- 2026-04-14: chose symlink-based model over copy-per-agent after user guidance (중복 설치 방지). +- 2026-04-14: `` encoding uses `__` separator for grep-friendliness; alternative (URL encoding) rejected as harder to eyeball. +- 2026-04-14: Windows junction fallback deferred to a follow-up track; v1 assumes POSIX symlink support. + +## Surprises & Discoveries + +- `findSkillLikePaths` needed a tight basename match (`/^skills?$/i`) when used as the "skills parent" selector in install. A loose substring match was vendoring the checkout root whenever its tempdir name happened to contain "skill" (e.g. `ask-skills-e2e-ck-XXXX`) — harmless in production, but it pulled `skills/` into itself and left the lock entry with `name: "skills"` instead of `name: "alpha"`. The walker still uses loose `/skill/i` for `list` output; the stricter filter lives in `collectSkillDirs`. +- `manageIgnoreFiles` validates `ask.json` against the real `AskJsonSchema` (`{ libraries: [] }`), not a fabricated `{ entries: [] }` shape. Test fixtures had to match — a reminder that the schema package is authoritative and cannot be faked. +- The integration test needed schema package built first (`bun run --cwd packages/schema build`) because `packages/cli/src/schemas.ts` imports `@pleaseai/ask-schema` from the workspace, and an unbuilt workspace dep surfaces as a runtime "Cannot find module" error rather than a TS build error. + +- [x] (2026-04-15 17:30 KST) Review fixes applied (SHA: `44de2d6`) + +## Outcomes & Retrospective + +### What Was Shipped + +- `ask skills` CLI namespace with `list`, `install`, `remove` subcommands (+ `ask skills ` shorthand for `list`). +- `.ask/skills///` vendored layout backed by `.ask/skills-lock.json` for deterministic reverse. +- Relative POSIX symlinks into auto-detected agent dirs (`.claude/`, `.cursor/`, `.opencode/`, `.codex/`) with multiselect prompt when >1 agent is present and `--agent` CSV override. +- 5 new foundation modules under `packages/cli/src/skills/` (spec-key, lock, agent-detect, vendor, symlinks). +- Ignore-file management extended to mark skills paths vendored, now gated by a multi-signal opt-in check so skills-only users are covered. + +### What Went Well + +- The existing `ensureCheckout` + `findDocLikePaths` + `ignore-files.ts` patterns dropped in almost verbatim. Minimal new primitives. +- Symlink + lock architecture means `remove` can never touch user-authored content — `unlinkIfOwned` compares the symlink target against the vendored path first. +- TDD cycle (67 unit + integration tests) caught two design bugs before merge: the walker's substring-match surfacing the tempdir root, and `ask.json`-only opt-in gate missing skills-only users. +- Review-fix loop surfaced the FR-11/AC-6 gap that the original integration tests masked by always seeding `ask.json`. + +### What Could Improve + +- Initial spec-key encode/decode design encoded `@` which broke scoped-npm round-trip. Should have exercised round-trip tests against all four representative specs before landing the encoder. Now fixed (only `/`/`:` encoded, `@` preserved). +- `collectSkillDirs` needed a tighter `^skills?$` regex separate from the walker's looser `/skill/i` — worth documenting the dual regex convention in future producer-side skill features. +- `manageIgnoreFiles` implicit opt-in (ask.json-only) was easy to overlook. The multi-signal `hasAskOptIn` helper should probably become the canonical predicate anywhere new ASK entry points appear. + +### Tech Debt Created + +- Windows native junction fallback is deferred (noted in spec Out of Scope + README). Any Windows user will hit a symlink permission issue today — follow-up track required. +- Convention-based auto-discovery of skill packages (via `package.json` keywords / intent adapter) deferred. +- No declarative `ask.json` entry shape for skills yet — users must invoke `ask skills install` imperatively per library. diff --git a/.please/docs/tracks/completed/ask-skills-command-20260414/spec.md b/.please/docs/tracks/completed/ask-skills-command-20260414/spec.md new file mode 100644 index 0000000..858c820 --- /dev/null +++ b/.please/docs/tracks/completed/ask-skills-command-20260414/spec.md @@ -0,0 +1,77 @@ +--- +product_spec_domain: cli/skills +--- + +# `ask skills` Command + +> Track: ask-skills-command-20260414 + +## Overview + +Add an `ask skills` CLI namespace that surfaces and installs **producer-side skills** (agent-facing instruction bundles) shipped by libraries. Parallel to `ask docs`/`ask src` (which surface documentation), `ask skills` targets `skills/` directories embedded in packages so AI coding agents can consume library-provided skill files directly. + +This is distinct from the consumer-side `.claude/skills/-docs/SKILL.md` that `ask install` auto-generates — those are references TO docs; producer-side skills ARE skills authored by the library maintainer. + +**Install model**: skills are vendored once into `.ask/skills///` (gitignored), then **symlinked** into each selected agent directory (`.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, `.codex/skills/`). A lock file tracks installed entries so double-install is a no-op and `remove` is deterministic. + +## Requirements + +### Functional Requirements + +- [ ] FR-1 `ask skills ` (no subcommand) = implicit `list`: prints one absolute path per line for every candidate skills directory found. Same spec format as `ask docs`. +- [ ] FR-2 Skill source locations walked (in order): + - `node_modules//skills/` when the spec is an npm-ecosystem entry AND the package is installed locally. + - The cached checkout root from `ensureCheckout` and every nested dir whose basename matches `/skill/i`, up to depth 4. +- [ ] FR-3 Output format mirrors `ask docs`: one absolute path per line, root of each source always included, subdirectories whose basename matches `/skill/i` appended. +- [ ] FR-4 `ask skills list ` = explicit alias of FR-1. +- [ ] FR-5 `ask skills install `: + 1. Resolves source skills via the same walker used by `list`. + 2. **Vendor step**: copies every discovered skill directory into `.ask/skills///` where `` is a filesystem-safe form of the resolved spec (e.g. `npm__next__14.2.3`, `github__vercel__ai__v5.0.0`). This is the canonical on-disk copy. + 3. **Agent detection**: scans project root for `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, `AGENTS.md`. + 4. **Agent selection**: if >1 agent detected, prompts via `consola.prompt({ type: 'multiselect' })`; if exactly 1, auto-selects it; if 0, errors with a helpful message. + 5. **Symlink step**: creates a relative symlink from `/skills/` → `.ask/skills///`. If a symlink already exists and points to the same target, no-op. If a symlink exists with a different target OR a real directory exists, errors unless `--force` is passed. + 6. **Lock step**: updates `.ask/skills-lock.json` with an entry `{ spec, specKey, skills: [{ name, agents: [...] }], installedAt }`. Lock is the single source of truth for `remove`. +- [ ] FR-6 `ask skills remove ` uses `.ask/skills-lock.json` to enumerate installed symlinks and the vendored copy, then: + 1. Removes every agent symlink recorded in the lock (safe: only unlinks when the link target matches the vendored path — never touches user-authored skills). + 2. Removes `.ask/skills//` directory. + 3. Removes the spec's entry from `.ask/skills-lock.json`. +- [ ] FR-7 `--no-fetch` flag on `list` / default: cache-hit-only, exits 1 on cache miss (same as `ask docs`). +- [ ] FR-8 Exit codes: 0 on success, 1 on cache miss with `--no-fetch`, 1 on resolver/ecosystem failures, 1 on symlink conflicts without `--force`. +- [ ] FR-9 **Idempotence**: `ask skills install` twice produces the same final state — vendor copy is refreshed, symlinks are re-verified, lock entry is updated-in-place (not appended). +- [ ] FR-10 Reuses the shared `ensureCheckout` helper; no new resolver logic. +- [ ] FR-11 `ask skills install|remove` also manages ignore markers so `.ask/skills/` and `.ask/skills-lock.json` are marked vendored (extends the existing `# ask:start ... # ask:end` block in `.gitignore`). +- [ ] FR-12 Symlink flavor: relative POSIX symlinks (`ln -s ../../.ask/skills/...`) so the project tree stays portable across clones. Windows junction fallback deferred. + +### Non-functional Requirements + +- [ ] NFR-1 Zero overlap with the existing `ask install` generated `.claude/skills/-docs/` block — those are **files** authored by ask; producer skills live under a different path (the skill's own name, not `-docs`) and are symlinks, not generated files. +- [ ] NFR-2 Walk behavior mirrors `findDocLikePaths` (skip `node_modules`, `.git`, `dist`, `build`, `coverage`, dotdirs; MAX_DEPTH=4). +- [ ] NFR-3 All user-facing output via `consola`; path listings via `stdout` so shell substitution works. +- [ ] NFR-4 Lock file format is stable, documented in the plan, and safe to hand-edit (no embedded hashes or opaque blobs in v1). + +## Acceptance Criteria + +- [ ] AC-1 `ask skills vercel/ai` prints `node_modules//skills/` paths (if installed) then the cached checkout and nested skill dirs. +- [ ] AC-2 `ask skills install npm:some-pkg@1.0.0` in a project with both `.claude/` and `.cursor/` prompts the user, vendors skills into `.ask/skills/`, and symlinks into the selected agent dirs. +- [ ] AC-3 Running the same `install` a second time is a no-op (same lock entry, same symlinks, no errors). +- [ ] AC-4 `ask skills remove npm:some-pkg@1.0.0` deletes every symlink listed in the lock, removes `.ask/skills//`, and purges the lock entry. +- [ ] AC-5 If a user has a pre-existing `.claude/skills/` real directory, `install` fails with a clear message and suggests `--force`. +- [ ] AC-6 `.gitignore` picks up `.ask/skills/` and `.ask/skills-lock.json` within the `# ask:start ... # ask:end` marker block after the first `install`. +- [ ] AC-7 No regression in `ask install` output. + +## Out of Scope + +- Declarative `ask.json` entries for skills (future track). +- Publishing user-authored skills to the ASK Registry. +- Agent-specific format translation (Cursor-rules vs Claude-SKILL.md). v1 copies skill dirs as-is. +- Global install target (`~/.claude/skills/`). v1 is project-local only. +- Windows native junction support (Linux/macOS symlink only in v1). +- Auto-discovery of skills via `package.json` keywords / intent adapter (may be added when wiring convention-based discovery later). + +## Assumptions + +- A library ships its producer-side skills under a top-level `skills/` directory (convention inherited from tanstack-intent). The walker also surfaces any `/skill/i`-named subdirectory for forgiving discovery. +- `.ask/skills/` is safe to vendor alongside `.ask/docs/` — both are cache-like and gitignored. +- Symlinks are viable on every developer platform this project targets today (macOS + Linux; CI is Linux). Windows users can unblock manually until FR-12's junction fallback lands. +- `ensureCheckout` already guarantees the checkout tree is read-only from the consumer's perspective — vendoring into `.ask/skills/` avoids future cache cleanup invalidating active symlinks. +- `` encoding scheme is decided in the plan phase (proposed: replace `/`, `@`, `:` with `__` for a flat, grep-friendly structure). diff --git a/README.md b/README.md index d3aba94..ee0dda2 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,38 @@ matches `/doc/i` up to depth 4, skipping `node_modules`, `.git`, `.next`, always emitted as the first line so the agent can fall back to it when no `docs/` directory exists. +### `ask skills` — producer-side skill bundles + +`ask skills` is a sibling namespace to `ask docs` that surfaces and +installs **producer-side skills** shipped by libraries under a top-level +`skills/` directory (convention inherited from tanstack-intent). + +```bash +# List all candidate skill paths for a library (read-only, one per line) +ask skills # shorthand for `skills list` +ask skills list + +# Vendor skills into .ask/skills/ and symlink into each selected agent dir +ask skills install +ask skills install --agent claude,cursor +ask skills install --force # overwrite conflicting entries + +# Reverse a prior install using the lock file +ask skills remove +ask skills remove --ignore-missing +``` + +Skills are vendored once into `.ask/skills///` +(gitignored) and **symlinked** into each selected agent directory +(`.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, +`.codex/skills/`). `install` auto-detects which agents the project uses; +if more than one is present it prompts you to pick. A lock file at +`.ask/skills-lock.json` tracks what was installed so `remove` can reverse +the operation deterministically without touching user-authored skills. + +v1 platform support is POSIX only (macOS/Linux) — Windows junction +fallback is on the roadmap. + ## Registry The ASK Registry (`apps/registry/`) is a community-maintained catalog of library documentation configs. Each entry is a Markdown file with YAML frontmatter: diff --git a/packages/cli/src/commands/find-skill-paths.ts b/packages/cli/src/commands/find-skill-paths.ts new file mode 100644 index 0000000..90dc781 --- /dev/null +++ b/packages/cli/src/commands/find-skill-paths.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs' +import path from 'node:path' + +/** Module-scope regex — reused across every directory visited by the walker. */ +const SKILL_DIR_RE = /skill/i + +/** Skip set — mirrors `findDocLikePaths`. */ +const SKIP_DIRS = new Set([ + 'node_modules', + '.git', + '.next', + '.nuxt', + 'dist', + 'build', + 'coverage', +]) + +/** Maximum walk depth. Root counts as depth 0. */ +const MAX_DEPTH = 4 + +/** + * Walk `root` and return the root plus every nested directory whose basename + * matches `/skill/i`, up to depth 4. Mirrors `findDocLikePaths` with a + * different regex — used by `ask skills` to surface producer-side skill + * directories shipped by libraries (e.g. `skills//SKILL.md`). + * + * Returns an empty array when `root` does not exist (no throw). + */ +export function findSkillLikePaths(root: string): string[] { + if (!fs.existsSync(root)) { + return [] + } + const results: string[] = [root] + walk(root, 0, results) + return results +} + +function walk(currentDir: string, depth: number, out: string[]): void { + if (depth >= MAX_DEPTH) { + return + } + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }) + } + catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) { + continue + } + const full = path.join(currentDir, entry.name) + if (SKILL_DIR_RE.test(entry.name)) { + out.push(full) + } + walk(full, depth + 1, out) + } +} diff --git a/packages/cli/src/commands/skills/index.ts b/packages/cli/src/commands/skills/index.ts new file mode 100644 index 0000000..54fecae --- /dev/null +++ b/packages/cli/src/commands/skills/index.ts @@ -0,0 +1,40 @@ +import process from 'node:process' +import { defineCommand } from 'citty' +import { skillsInstallCmd } from './install.js' +import { runSkillsList, skillsListCmd } from './list.js' +import { skillsRemoveCmd } from './remove.js' + +/** + * Citty parent command for `ask skills`. Subcommands are `list`, `install`, + * `remove`. When invoked without a subcommand but with a positional spec, + * falls through to `list` so `ask skills ` works as a shorthand. + */ +export const skillsCmd = defineCommand({ + meta: { + name: 'skills', + description: 'Surface and install producer-side skills shipped by libraries', + }, + subCommands: { + list: skillsListCmd, + install: skillsInstallCmd, + remove: skillsRemoveCmd, + }, + args: { + 'spec': { type: 'positional', description: 'Library spec (shorthand for `skills list `)', required: false }, + 'no-fetch': { type: 'boolean', description: 'Return cache hit only — exit 1 on cache miss' }, + }, + async run({ args }) { + if (!args.spec) { + return + } + await runSkillsList({ + spec: args.spec, + projectDir: process.cwd(), + noFetch: Boolean(args['no-fetch']), + }) + }, +}) + +export { runSkillsInstall } from './install.js' +export { runSkillsList } from './list.js' +export { runSkillsRemove } from './remove.js' diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts new file mode 100644 index 0000000..be75901 --- /dev/null +++ b/packages/cli/src/commands/skills/install.ts @@ -0,0 +1,221 @@ +import type { AgentTarget } from '../../skills/agent-detect.js' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { defineCommand } from 'citty' +import { consola } from 'consola' +import { manageIgnoreFiles } from '../../ignore-files.js' +import { detectAgents, resolveAgentNames } from '../../skills/agent-detect.js' +import { readLock, upsertEntry, writeLockAtomic } from '../../skills/lock.js' +import { encodeSpecKey } from '../../skills/spec-key.js' +import { linkSkill } from '../../skills/symlinks.js' +import { vendorSkills } from '../../skills/vendor.js' +import { ensureCheckout as defaultEnsureCheckout, NoCacheError } from '../ensure-checkout.js' +import { findSkillLikePaths } from '../find-skill-paths.js' + +export interface RunSkillsInstallOptions { + spec: string + projectDir: string + noFetch?: boolean + force?: boolean + /** Explicit agent list (CSV from CLI). Overrides detection + prompt. */ + agents?: string[] +} + +export interface RunSkillsInstallDeps { + ensureCheckout?: typeof defaultEnsureCheckout + /** Picks agents when more than one is detected and `agents` option is unset. */ + pickAgents?: (candidates: AgentTarget[]) => Promise + log?: (msg: string) => void + error?: (msg: string) => void + exit?: (code: number) => void +} + +const SKILLS_PARENT_RE = /^skills?$/i + +async function defaultPickAgents(candidates: AgentTarget[]): Promise { + const picked = await consola.prompt('Install into which agents?', { + type: 'multiselect', + options: candidates.map(c => ({ value: c.name, label: c.label })), + required: true, + }) as unknown as string[] + const byName = new Map(candidates.map(c => [c.name, c])) + return picked.map(n => byName.get(n)!).filter(Boolean) +} + +/** + * `ask skills install ` — resolve, vendor, pick agents, symlink, + * update lock, and patch ignore files. + */ +export async function runSkillsInstall( + options: RunSkillsInstallOptions, + deps: RunSkillsInstallDeps = {}, +): Promise { + const ensureCheckout = deps.ensureCheckout ?? defaultEnsureCheckout + const pickAgents = deps.pickAgents ?? defaultPickAgents + const log = deps.log ?? ((msg: string) => consola.info(msg)) + const error = deps.error ?? ((msg: string) => consola.error(msg)) + const exit = deps.exit ?? ((code: number) => process.exit(code)) + + let result + try { + result = await ensureCheckout({ + spec: options.spec, + projectDir: options.projectDir, + noFetch: options.noFetch, + }) + } + catch (err) { + if (err instanceof NoCacheError) { + error(err.message) + exit(1) + return + } + error(err instanceof Error ? err.message : String(err)) + exit(1) + return + } + + // Discover skill source directories. A source path is only treated as a + // "skill dir" when its basename itself matches /skill/i — the walker returns + // roots too, but roots rarely ARE the skill dir. This keeps the vendored + // layout tidy. + const sources = collectSkillDirs(options.projectDir, result) + if (sources.length === 0) { + error(`no skills/ directories found for ${options.spec}`) + exit(1) + return + } + + // Agent selection: explicit flag > detect + prompt > detect-1 auto > error. + let agents: AgentTarget[] + if (options.agents && options.agents.length > 0) { + agents = resolveAgentNames(options.projectDir, options.agents) + } + else { + const detected = detectAgents(options.projectDir) + if (detected.length === 0) { + error('no supported coding agent detected in this project (.claude/, .cursor/, .opencode/, .codex/). Pass --agent to force.') + exit(1) + return + } + agents = detected.length === 1 ? detected : await pickAgents(detected) + if (agents.length === 0) { + error('no agents selected') + exit(1) + return + } + } + + // Encode the spec-key from the resolver result. + const ecosystem = result.npmPackageName ? 'npm' : 'github' + const name = result.npmPackageName ?? `${result.owner}/${result.repo}` + const specKey = encodeSpecKey({ ecosystem, name, version: result.resolvedVersion }) + + // Vendor + symlink. + const vendor = vendorSkills(options.projectDir, specKey, sources) + const agentNames = agents.map(a => a.name) + for (const skill of vendor.skillNames) { + const targetPath = path.join(vendor.vendorDir, skill) + for (const agent of agents) { + const linkPath = path.join(agent.skillsDir, skill) + linkSkill({ linkPath, targetPath, force: options.force }) + } + } + + // Persist the lock. + const lock = readLock(options.projectDir) + const updated = upsertEntry(lock, { + spec: options.spec, + specKey, + skills: vendor.skillNames.map(n => ({ name: n, agents: agentNames })), + installedAt: new Date().toISOString(), + }) + writeLockAtomic(options.projectDir, updated) + + // Make sure .ask/skills/ and skills-lock.json are marked vendored. + manageIgnoreFiles(options.projectDir, 'install') + + log(`installed ${vendor.skillNames.length} skill(s) for ${options.spec} into ${agentNames.join(', ')}`) +} + +/** + * Gather the candidate individual skill directories. The walker returns the + * `skills/` parent directories shipped by producers; each of their direct + * subdirectories is a single skill bundle that should be vendored + * independently so `.ask/skills///` lines up. + */ +function collectSkillDirs( + projectDir: string, + result: { checkoutDir: string, npmPackageName?: string }, +): string[] { + const parents: string[] = [] + + if (result.npmPackageName) { + const nmPath = path.join(projectDir, 'node_modules', result.npmPackageName) + if (fs.existsSync(nmPath)) { + parents.push(...findSkillLikePaths(nmPath)) + } + } + parents.push(...findSkillLikePaths(result.checkoutDir)) + + const skillDirs: string[] = [] + const seen = new Set() + for (const parent of parents) { + // Tight match: only exact `skill` or `skills` (case-insensitive) qualify + // as the producer-side "skills parent". Substring matches like `my-skills` + // or unrelated tokens (e.g. temp-dir prefixes that happen to contain + // "skill") are ignored to avoid vendoring unintended directories. + if (!SKILLS_PARENT_RE.test(path.basename(parent))) { + continue + } + for (const entry of safeReaddir(parent)) { + if (!entry.isDirectory()) + continue + const child = path.join(parent, entry.name) + if (seen.has(child)) + continue + seen.add(child) + skillDirs.push(child) + } + } + return skillDirs +} + +function safeReaddir(dir: string): fs.Dirent[] { + try { + return fs.readdirSync(dir, { withFileTypes: true }) + } + catch { + return [] + } +} + +export const skillsInstallCmd = defineCommand({ + meta: { + name: 'install', + description: 'Vendor producer-side skills into .ask/skills/ and symlink into detected agent directories', + }, + args: { + 'spec': { + type: 'positional', + description: 'Library spec (e.g. react, npm:react@18.2.0, github:facebook/react@v18.2.0)', + required: true, + }, + 'no-fetch': { type: 'boolean', description: 'Return cache hit only — exit 1 on cache miss' }, + 'force': { type: 'boolean', description: 'Overwrite conflicting entries in agent skills dirs' }, + 'agent': { type: 'string', description: 'Explicit agent targets (CSV): claude,cursor,opencode,codex' }, + }, + async run({ args }) { + const agents = typeof args.agent === 'string' && args.agent.length > 0 + ? args.agent.split(',').map(s => s.trim()).filter(Boolean) + : undefined + await runSkillsInstall({ + spec: args.spec, + projectDir: process.cwd(), + noFetch: Boolean(args['no-fetch']), + force: Boolean(args.force), + agents, + }) + }, +}) diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts new file mode 100644 index 0000000..86d98f5 --- /dev/null +++ b/packages/cli/src/commands/skills/list.ts @@ -0,0 +1,96 @@ +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { defineCommand } from 'citty' +import { ensureCheckout as defaultEnsureCheckout, NoCacheError } from '../ensure-checkout.js' +import { findSkillLikePaths } from '../find-skill-paths.js' + +export interface RunSkillsListOptions { + spec: string + projectDir: string + noFetch?: boolean +} + +export interface RunSkillsListDeps { + ensureCheckout?: typeof defaultEnsureCheckout + log?: (msg: string) => void + error?: (msg: string) => void + exit?: (code: number) => void +} + +/** + * Implementation of `ask skills [list] `. Resolves the spec via the + * shared `ensureCheckout` helper, then prints every candidate skills + * directory found under `node_modules//` (for npm specs that are + * locally installed) and under the cached checkout tree. + * + * Output format mirrors `ask docs`: one absolute path per line. The agent + * decides which path is the "real" producer-side skills directory. + */ +export async function runSkillsList( + options: RunSkillsListOptions, + deps: RunSkillsListDeps = {}, +): Promise { + const ensureCheckout = deps.ensureCheckout ?? defaultEnsureCheckout + const log = deps.log ?? ((msg: string) => process.stdout.write(`${msg}\n`)) + const error = deps.error ?? ((msg: string) => process.stderr.write(`${msg}\n`)) + const exit = deps.exit ?? ((code: number) => process.exit(code)) + + let result + try { + result = await ensureCheckout({ + spec: options.spec, + projectDir: options.projectDir, + noFetch: options.noFetch, + }) + } + catch (err) { + if (err instanceof NoCacheError) { + error(err.message) + exit(1) + return + } + const message = err instanceof Error ? err.message : String(err) + error(message) + exit(1) + return + } + + if (result.npmPackageName) { + const nmPath = path.join(options.projectDir, 'node_modules', result.npmPackageName) + if (fs.existsSync(nmPath)) { + for (const p of findSkillLikePaths(nmPath)) { + log(p) + } + } + } + + for (const p of findSkillLikePaths(result.checkoutDir)) { + log(p) + } +} + +export const skillsListCmd = defineCommand({ + meta: { + name: 'list', + description: 'Print all candidate producer-side skill paths from node_modules and the cached source tree', + }, + args: { + 'spec': { + type: 'positional', + description: 'Library spec (e.g. react, npm:react@18.2.0, github:facebook/react@v18.2.0)', + required: true, + }, + 'no-fetch': { + type: 'boolean', + description: 'Return cache hit only — exit 1 on cache miss', + }, + }, + async run({ args }) { + await runSkillsList({ + spec: args.spec, + projectDir: process.cwd(), + noFetch: Boolean(args['no-fetch']), + }) + }, +}) diff --git a/packages/cli/src/commands/skills/remove.ts b/packages/cli/src/commands/skills/remove.ts new file mode 100644 index 0000000..2ba5591 --- /dev/null +++ b/packages/cli/src/commands/skills/remove.ts @@ -0,0 +1,86 @@ +import path from 'node:path' +import process from 'node:process' +import { defineCommand } from 'citty' +import { consola } from 'consola' +import { resolveAgentNames } from '../../skills/agent-detect.js' +import { readLock, removeEntry, writeLockAtomic } from '../../skills/lock.js' +import { unlinkIfOwned } from '../../skills/symlinks.js' +import { removeVendorDir, VENDOR_ROOT } from '../../skills/vendor.js' + +export interface RunSkillsRemoveOptions { + /** Full user spec OR the spec-key directly. Resolution falls back to matching `spec` field. */ + spec: string + projectDir: string + ignoreMissing?: boolean +} + +export interface RunSkillsRemoveDeps { + log?: (msg: string) => void + error?: (msg: string) => void + exit?: (code: number) => void +} + +/** + * `ask skills remove ` — reverse a prior `install` using the lock as + * the source of truth. Never touches real directories or symlinks that + * point somewhere else. + */ +export async function runSkillsRemove( + options: RunSkillsRemoveOptions, + deps: RunSkillsRemoveDeps = {}, +): Promise { + const log = deps.log ?? ((msg: string) => consola.info(msg)) + const error = deps.error ?? ((msg: string) => consola.error(msg)) + const exit = deps.exit ?? ((code: number) => process.exit(code)) + + const lock = readLock(options.projectDir) + const entry = Object.values(lock.entries).find( + e => e.specKey === options.spec || e.spec === options.spec, + ) + + if (!entry) { + if (options.ignoreMissing) { + log(`no lock entry for ${options.spec} — nothing to do`) + return + } + error(`no lock entry for ${options.spec}. Pass --ignore-missing to silence.`) + exit(1) + return + } + + const vendorDir = path.join(options.projectDir, VENDOR_ROOT, entry.specKey) + let unlinked = 0 + for (const skill of entry.skills) { + const agents = resolveAgentNames(options.projectDir, skill.agents) + for (const agent of agents) { + const linkPath = path.join(agent.skillsDir, skill.name) + const targetPath = path.join(vendorDir, skill.name) + if (unlinkIfOwned(linkPath, targetPath)) { + unlinked++ + } + } + } + + removeVendorDir(options.projectDir, entry.specKey) + writeLockAtomic(options.projectDir, removeEntry(lock, entry.specKey)) + + log(`removed ${unlinked} symlink(s) and vendored copy for ${options.spec}`) +} + +export const skillsRemoveCmd = defineCommand({ + meta: { + name: 'remove', + description: 'Remove a previously-installed skill set by spec', + }, + args: { + 'spec': { type: 'positional', description: 'Spec (same as used with install) or spec-key', required: true }, + 'ignore-missing': { type: 'boolean', description: 'Silently succeed if the spec has no lock entry' }, + }, + async run({ args }) { + await runSkillsRemove({ + spec: args.spec, + projectDir: process.cwd(), + ignoreMissing: Boolean(args['ignore-missing']), + }) + }, +}) diff --git a/packages/cli/src/ignore-files.ts b/packages/cli/src/ignore-files.ts index 7a89c2c..8217773 100644 --- a/packages/cli/src/ignore-files.ts +++ b/packages/cli/src/ignore-files.ts @@ -22,6 +22,19 @@ import { readAskJson } from './io.js' import { inject, remove as removeMarker, wrap } from './markers.js' import { getDocsDir } from './storage.js' +function hasAskOptIn(projectDir: string): boolean { + if (readAskJson(projectDir)) { + return true + } + if (fs.existsSync(path.join(projectDir, '.ask', 'skills-lock.json'))) { + return true + } + if (fs.existsSync(path.join(projectDir, '.ask', 'skills'))) { + return true + } + return false +} + /** * Local configuration files written inside `.ask/docs/` so that * lint/format/review tools with nested-config support automatically skip @@ -84,23 +97,23 @@ const ROOT_PATCHES: RootPatch[] = [ { file: '.prettierignore', syntax: 'hash', - payload: '# Vendored docs — managed by ASK\n.ask/docs/\n.ask/resolved.json', + payload: '# Vendored by ASK\n.ask/docs/\n.ask/skills/\n.ask/resolved.json\n.ask/skills-lock.json', }, { file: 'sonar-project.properties', syntax: 'hash', - payload: '# Vendored docs — managed by ASK\nsonar.exclusions=.ask/docs/**', + payload: '# Vendored by ASK\nsonar.exclusions=.ask/docs/**,.ask/skills/**', }, { file: '.markdownlintignore', syntax: 'hash', - payload: '# Vendored docs — managed by ASK\n.ask/docs/', + payload: '# Vendored by ASK\n.ask/docs/\n.ask/skills/', warn: 'Legacy .markdownlintignore detected. Consider migrating to markdownlint-cli2, which supports nested config inside .ask/docs/ automatically.', }, { file: '.gitignore', syntax: 'hash', - payload: '# Vendored docs — managed by ASK\n.ask/docs/\n.ask/resolved.json', + payload: '# Vendored by ASK\n.ask/docs/\n.ask/skills/\n.ask/resolved.json\n.ask/skills-lock.json', }, ] @@ -213,11 +226,12 @@ export function manageIgnoreFiles( mode: 'install' | 'remove', ): void { // The pre-refactor `manageIgnores: false` opt-out lived in - // .ask/config.json, which no longer exists. The new contract: if - // ask.json is absent, do nothing (the user hasn't opted in to ASK - // at all). - const askJson = readAskJson(projectDir) - if (!askJson) { + // .ask/config.json, which no longer exists. The new contract: patch + // ignore files when the user has opted into ASK through ANY of its + // entry points — `ask.json` (docs), `.ask/skills-lock.json` (skills), + // or an existing `.ask/` vendor tree. Without any of those, a fresh + // project should stay untouched. + if (!hasAskOptIn(projectDir)) { return } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4c111d3..63e756d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ import { defineCommand } from 'citty' import { consola } from 'consola' import { docsCmd } from './commands/docs.js' import { splitExplicitVersion } from './commands/ensure-checkout.js' +import { skillsCmd } from './commands/skills/index.js' import { srcCmd } from './commands/src.js' import { manageIgnoreFiles } from './ignore-files.js' import { runInstall } from './install.js' @@ -326,6 +327,7 @@ export const main = defineCommand({ list: listCmd, src: srcCmd, docs: docsCmd, + skills: skillsCmd, cache: cacheCmd, }, }) diff --git a/packages/cli/src/skills/agent-detect.ts b/packages/cli/src/skills/agent-detect.ts new file mode 100644 index 0000000..3b8c120 --- /dev/null +++ b/packages/cli/src/skills/agent-detect.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs' +import path from 'node:path' + +export interface AgentTarget { + /** Stable identifier used in the lock file, e.g. `claude`. */ + name: string + /** Human label shown in prompts, e.g. `Claude Code`. */ + label: string + /** Marker directory whose presence enables this agent (absolute). */ + markerDir: string + /** Absolute path where `skills/` symlinks are created. */ + skillsDir: string +} + +const AGENTS: Array & { marker: string, skillsRel: string }> = [ + { name: 'claude', label: 'Claude Code', marker: '.claude', skillsRel: '.claude/skills' }, + { name: 'cursor', label: 'Cursor', marker: '.cursor', skillsRel: '.cursor/skills' }, + { name: 'opencode', label: 'OpenCode', marker: '.opencode', skillsRel: '.opencode/skills' }, + { name: 'codex', label: 'Codex', marker: '.codex', skillsRel: '.codex/skills' }, +] + +/** + * Scan `projectDir` and return every supported coding agent whose marker + * directory is present. The returned `skillsDir` is where + * `/skills/` symlinks will be created; it is NOT required + * to exist yet — the install step creates it on demand. + * + * `AGENTS.md` by itself is not treated as an agent (it is a cross-agent + * convention file, not an install target). + */ +export function detectAgents(projectDir: string): AgentTarget[] { + const found: AgentTarget[] = [] + for (const a of AGENTS) { + const markerDir = path.join(projectDir, a.marker) + if (fs.existsSync(markerDir)) { + found.push({ + name: a.name, + label: a.label, + markerDir, + skillsDir: path.join(projectDir, a.skillsRel), + }) + } + } + return found +} + +/** + * Resolve a user-supplied `--agent` CSV into {@link AgentTarget}s without + * requiring the marker dir to exist. Unknown names throw — we prefer loud + * failure over silently installing into an unintended location. + */ +export function resolveAgentNames(projectDir: string, names: string[]): AgentTarget[] { + const byName = new Map(AGENTS.map(a => [a.name, a])) + return names.map((name) => { + const hit = byName.get(name) + if (!hit) { + throw new Error(`unknown agent '${name}'. Supported: ${AGENTS.map(a => a.name).join(', ')}`) + } + return { + name: hit.name, + label: hit.label, + markerDir: path.join(projectDir, hit.marker), + skillsDir: path.join(projectDir, hit.skillsRel), + } + }) +} + +export const SUPPORTED_AGENTS = AGENTS.map(a => a.name) diff --git a/packages/cli/src/skills/lock.ts b/packages/cli/src/skills/lock.ts new file mode 100644 index 0000000..3395bd9 --- /dev/null +++ b/packages/cli/src/skills/lock.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs' +import path from 'node:path' + +export const LOCK_FILENAME = '.ask/skills-lock.json' + +export interface LockSkill { + /** Skill name — the basename of the source skill directory. */ + name: string + /** Agents whose `/skills/` symlinks were installed. */ + agents: string[] +} + +export interface LockEntry { + /** Original user-facing spec, e.g. `npm:next@14.2.3`. */ + spec: string + /** Filesystem-safe encoding (see `encodeSpecKey`). */ + specKey: string + /** Skills installed for this entry. */ + skills: LockSkill[] + /** ISO timestamp of the last install. */ + installedAt: string +} + +export interface LockFile { + version: 1 + entries: Record +} + +const EMPTY_LOCK: LockFile = { version: 1, entries: {} } + +export function lockPath(projectDir: string): string { + return path.join(projectDir, LOCK_FILENAME) +} + +export function readLock(projectDir: string): LockFile { + const p = lockPath(projectDir) + if (!fs.existsSync(p)) { + return { version: 1, entries: {} } + } + const raw = fs.readFileSync(p, 'utf-8') + const parsed = JSON.parse(raw) as unknown + if (!isLockFile(parsed)) { + throw new Error(`${LOCK_FILENAME}: schema mismatch`) + } + return parsed +} + +export function upsertEntry(lock: LockFile, entry: LockEntry): LockFile { + return { + version: 1, + entries: { ...lock.entries, [entry.specKey]: entry }, + } +} + +export function removeEntry(lock: LockFile, specKey: string): LockFile { + if (!(specKey in lock.entries)) { + return lock + } + const next = { ...lock.entries } + delete next[specKey] + return { version: 1, entries: next } +} + +/** + * Atomic write — serialise to a `.tmp` neighbour first, then rename. Avoids + * half-written files if the process dies mid-write. The parent directory + * (`.ask/`) is created on demand so callers never have to pre-mkdir. + */ +export function writeLockAtomic(projectDir: string, lock: LockFile): void { + const target = lockPath(projectDir) + fs.mkdirSync(path.dirname(target), { recursive: true }) + const tmp = `${target}.tmp` + fs.writeFileSync(tmp, `${JSON.stringify(lock, null, 2)}\n`, 'utf-8') + fs.renameSync(tmp, target) +} + +function isLockFile(value: unknown): value is LockFile { + if (typeof value !== 'object' || value === null) + return false + const v = value as Partial + return v.version === 1 && typeof v.entries === 'object' && v.entries !== null +} + +export { EMPTY_LOCK } diff --git a/packages/cli/src/skills/spec-key.ts b/packages/cli/src/skills/spec-key.ts new file mode 100644 index 0000000..98dd77c --- /dev/null +++ b/packages/cli/src/skills/spec-key.ts @@ -0,0 +1,54 @@ +/** + * Filesystem-safe encoding of a resolved library spec used as the top-level + * directory name under `.ask/skills//`. Only `/` and `:` — the + * structural separators that collide with path syntax — are rewritten to + * `__`. `@` is kept as-is so scoped npm packages stay human-readable. + * + * Examples: + * { npm, next, 14.2.3 } → npm__next__14.2.3 + * { npm, @vercel/ai, 5.0.0 } → npm__@vercel__ai__5.0.0 + * { github, vercel/ai, v5.0.0 } → github__vercel__ai__v5.0.0 + */ + +export interface SpecKeyInput { + /** Ecosystem prefix: `npm`, `github`, `pypi`, etc. */ + ecosystem: string + /** Package name or `owner/repo` for github. */ + name: string + /** Resolved version or git ref. */ + version: string +} + +const FORBIDDEN_RE = /[/:]/g + +export function encodeSpecKey(input: SpecKeyInput): string { + return [input.ecosystem, input.name, input.version].map(encodePart).join('__') +} + +function encodePart(value: string): string { + if (value === '') { + throw new Error('spec-key part must be non-empty') + } + return value.replace(FORBIDDEN_RE, '__') +} + +/** + * Reverse of {@link encodeSpecKey}. The input is split on `__` and the + * canonical layout has at least three segments: `[ecosystem, …name parts, version]`. + * Name parts are re-joined with `/` since that is the only separator we encode + * inside `name` (the `:` replacement is rare and reserved for ecosystem-specific + * metadata we do not currently emit). + */ +export function decodeSpecKey(key: string): SpecKeyInput { + const segments = key.split('__') + if (segments.length < 3) { + throw new Error(`malformed spec-key (needs at least 3 segments): ${key}`) + } + const ecosystem = segments[0] + const version = segments.at(-1) + const name = segments.slice(1, -1).join('/') + if (!ecosystem || !name || !version) { + throw new Error(`malformed spec-key (empty segment): ${key}`) + } + return { ecosystem, name, version } +} diff --git a/packages/cli/src/skills/symlinks.ts b/packages/cli/src/skills/symlinks.ts new file mode 100644 index 0000000..daec359 --- /dev/null +++ b/packages/cli/src/skills/symlinks.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs' +import path from 'node:path' + +/** + * Create a relative symlink at `linkPath` pointing to `targetPath`. The parent + * directory of `linkPath` is created on demand. + * + * If a symlink already exists and resolves to the exact same target, this is a + * no-op. If the link exists with a different target, or if a real file/dir + * sits at the path, we throw unless `force` is set — in which case the + * existing entry is removed and the new link created. + */ +export interface LinkSkillOptions { + linkPath: string + targetPath: string + force?: boolean +} + +export function linkSkill(opts: LinkSkillOptions): void { + const { linkPath, targetPath, force } = opts + fs.mkdirSync(path.dirname(linkPath), { recursive: true }) + + const relTarget = path.relative(path.dirname(linkPath), targetPath) + + const lstat = safeLstat(linkPath) + if (lstat) { + if (lstat.isSymbolicLink()) { + const current = fs.readlinkSync(linkPath) + if (current === relTarget) { + return // identical — no-op + } + if (!force) { + throw new SymlinkConflictError(linkPath, `symlink points to '${current}', expected '${relTarget}'`) + } + fs.unlinkSync(linkPath) + } + else { + if (!force) { + throw new SymlinkConflictError(linkPath, 'a non-symlink entry already exists') + } + fs.rmSync(linkPath, { recursive: true, force: true }) + } + } + + fs.symlinkSync(relTarget, linkPath, 'dir') +} + +/** + * Remove `linkPath` iff it is a symlink whose target matches `expectedTarget`. + * Protects user-authored skills that happen to sit under the same name. + */ +export function unlinkIfOwned(linkPath: string, expectedTarget: string): boolean { + const lstat = safeLstat(linkPath) + if (!lstat || !lstat.isSymbolicLink()) { + return false + } + const relExpected = path.relative(path.dirname(linkPath), expectedTarget) + const current = fs.readlinkSync(linkPath) + if (current !== relExpected) { + return false + } + fs.unlinkSync(linkPath) + return true +} + +export class SymlinkConflictError extends Error { + constructor(public linkPath: string, reason: string) { + super(`${linkPath}: ${reason}. Re-run with --force to overwrite.`) + this.name = 'SymlinkConflictError' + } +} + +function safeLstat(p: string): fs.Stats | null { + try { + return fs.lstatSync(p) + } + catch { + return null + } +} diff --git a/packages/cli/src/skills/vendor.ts b/packages/cli/src/skills/vendor.ts new file mode 100644 index 0000000..8e5c3f4 --- /dev/null +++ b/packages/cli/src/skills/vendor.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs' +import path from 'node:path' + +export const VENDOR_ROOT = '.ask/skills' + +export interface VendorResult { + /** Absolute path to `.ask/skills//`. */ + vendorDir: string + /** Skill basenames that were copied in. */ + skillNames: string[] +} + +/** + * Copy each source skill directory into `.ask/skills///`. + * + * Refresh-safe: if `.ask/skills//` already exists it is wiped and + * replaced, so a re-install leaves no stale files. The new contents are + * staged under a sibling `..tmp` directory first and renamed into + * place on success, so a mid-copy crash cannot leave a half-populated + * vendor directory. + * + * Skill basenames collide only when the caller passes two source paths + * with the same final segment. In that case the later copy wins — the + * caller is responsible for deduping if needed. + */ +export function vendorSkills(projectDir: string, specKey: string, sources: string[]): VendorResult { + const root = path.join(projectDir, VENDOR_ROOT) + const vendorDir = path.join(root, specKey) + fs.mkdirSync(root, { recursive: true }) + + const staging = path.join(root, `.${specKey}.tmp`) + if (fs.existsSync(staging)) { + fs.rmSync(staging, { recursive: true, force: true }) + } + fs.mkdirSync(staging, { recursive: true }) + + const skillNames: string[] = [] + for (const source of sources) { + if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) { + continue + } + const name = path.basename(source) + const target = path.join(staging, name) + fs.cpSync(source, target, { recursive: true }) + skillNames.push(name) + } + + if (fs.existsSync(vendorDir)) { + fs.rmSync(vendorDir, { recursive: true, force: true }) + } + fs.renameSync(staging, vendorDir) + + return { vendorDir, skillNames } +} + +export function removeVendorDir(projectDir: string, specKey: string): void { + const vendorDir = path.join(projectDir, VENDOR_ROOT, specKey) + if (fs.existsSync(vendorDir)) { + fs.rmSync(vendorDir, { recursive: true, force: true }) + } +} diff --git a/packages/cli/test/cli/skills-registration.test.ts b/packages/cli/test/cli/skills-registration.test.ts new file mode 100644 index 0000000..79c3483 --- /dev/null +++ b/packages/cli/test/cli/skills-registration.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'bun:test' +import { main } from '../../src/index.js' + +describe('ask CLI registration — skills namespace', () => { + it('main.subCommands exposes skills', async () => { + const subs = await resolveSubCommands(main) + expect(subs!.skills).toBeDefined() + }) + + it('skills has list, install, remove subcommands', async () => { + const subs = await resolveSubCommands(main) + const skillsCmd = await resolveCommand(subs!.skills) + const skillsSubs = await resolveSubCommands(skillsCmd) + expect(skillsSubs!.list).toBeDefined() + expect(skillsSubs!.install).toBeDefined() + expect(skillsSubs!.remove).toBeDefined() + }) + + it('skills list exposes --no-fetch', async () => { + const subs = await resolveSubCommands(main) + const skillsCmd = await resolveCommand(subs!.skills) + const skillsSubs = await resolveSubCommands(skillsCmd) + const list = await resolveCommand(skillsSubs!.list) + expect(list.args?.['no-fetch']).toBeDefined() + }) + + it('skills install exposes --force and --agent', async () => { + const subs = await resolveSubCommands(main) + const skillsCmd = await resolveCommand(subs!.skills) + const skillsSubs = await resolveSubCommands(skillsCmd) + const install = await resolveCommand(skillsSubs!.install) + expect(install.args?.force).toBeDefined() + expect(install.args?.agent).toBeDefined() + }) + + it('skills remove exposes --ignore-missing', async () => { + const subs = await resolveSubCommands(main) + const skillsCmd = await resolveCommand(subs!.skills) + const skillsSubs = await resolveSubCommands(skillsCmd) + const remove = await resolveCommand(skillsSubs!.remove) + expect(remove.args?.['ignore-missing']).toBeDefined() + }) +}) + +async function resolveSubCommands(cmd: { subCommands?: unknown }): Promise | null> { + const sc = cmd.subCommands + if (!sc) + return null + if (typeof sc === 'function') { + return await (sc as () => Promise>)() + } + return sc as Record +} + +async function resolveCommand(maybeCmd: unknown): Promise<{ meta?: { name?: string }, args?: Record, subCommands?: unknown }> { + if (typeof maybeCmd === 'function') { + return await (maybeCmd as () => Promise<{ meta?: { name?: string }, args?: Record }>)() + } + return maybeCmd as { meta?: { name?: string }, args?: Record } +} diff --git a/packages/cli/test/commands/ensure-checkout.integration.test.ts b/packages/cli/test/commands/ensure-checkout.integration.test.ts index b20c1f5..82be442 100644 --- a/packages/cli/test/commands/ensure-checkout.integration.test.ts +++ b/packages/cli/test/commands/ensure-checkout.integration.test.ts @@ -3,8 +3,8 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { ensureCheckout } from '../../src/commands/ensure-checkout.js' import { runDocs } from '../../src/commands/docs.js' +import { ensureCheckout } from '../../src/commands/ensure-checkout.js' import { githubStorePath } from '../../src/store/index.js' /** diff --git a/packages/cli/test/commands/find-skill-paths.test.ts b/packages/cli/test/commands/find-skill-paths.test.ts new file mode 100644 index 0000000..7238283 --- /dev/null +++ b/packages/cli/test/commands/find-skill-paths.test.ts @@ -0,0 +1,118 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { findSkillLikePaths } from '../../src/commands/find-skill-paths.js' + +let root: string + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-skill-walker-')) +}) + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) +}) + +function mkdir(...segments: string[]) { + const p = path.join(root, ...segments) + fs.mkdirSync(p, { recursive: true }) + return p +} + +describe('findSkillLikePaths', () => { + it('returns empty array for a non-existent root (no throw)', () => { + const missing = path.join(root, 'does-not-exist') + expect(findSkillLikePaths(missing)).toEqual([]) + }) + + it('always returns the root as the first element', () => { + const result = findSkillLikePaths(root) + expect(result[0]).toBe(root) + }) + + it('matches a top-level "skills" directory', () => { + const skills = mkdir('skills') + const result = findSkillLikePaths(root) + expect(result).toContain(skills) + }) + + it('matches singular "skill" directory', () => { + const skill = mkdir('skill') + const result = findSkillLikePaths(root) + expect(result).toContain(skill) + }) + + it('matches case-insensitive — "Skills" qualifies', () => { + const upper = mkdir('Skills') + const result = findSkillLikePaths(root) + expect(result).toContain(upper) + }) + + it('matches substring — "agent-skills" qualifies', () => { + const agentSkills = mkdir('agent-skills') + const result = findSkillLikePaths(root) + expect(result).toContain(agentSkills) + }) + + it('does NOT match "docs" (the doc walker\'s domain)', () => { + mkdir('docs') + const result = findSkillLikePaths(root) + expect(result).toEqual([root]) + }) + + it('skips node_modules entirely', () => { + mkdir('node_modules', 'react', 'skills') + const result = findSkillLikePaths(root) + expect(result.some(p => p.includes('node_modules'))).toBe(false) + }) + + it('skips .git, .next, .nuxt, dist, build, coverage', () => { + mkdir('.git', 'skills') + mkdir('.next', 'skills') + mkdir('.nuxt', 'skills') + mkdir('dist', 'skills') + mkdir('build', 'skills') + mkdir('coverage', 'skills') + const result = findSkillLikePaths(root) + for (const skip of ['.git', '.next', '.nuxt', 'dist', 'build', 'coverage']) { + expect(result.some(p => p.includes(`${path.sep}${skip}${path.sep}`))).toBe(false) + } + }) + + it('skips all dotdirs', () => { + mkdir('.vscode', 'skills') + mkdir('.cache', 'skills') + const result = findSkillLikePaths(root) + expect(result.some(p => p.includes('.vscode'))).toBe(false) + expect(result.some(p => p.includes('.cache'))).toBe(false) + }) + + it('walks nested directories within depth limit', () => { + const nested = mkdir('packages', 'core', 'skills') + const result = findSkillLikePaths(root) + expect(result).toContain(nested) + }) + + it('respects depth limit of 4', () => { + const included = mkdir('a', 'b', 'c', 'skills') + const excluded = mkdir('a', 'b', 'c', 'd', 'skills') + const result = findSkillLikePaths(root) + expect(result).toContain(included) + expect(result).not.toContain(excluded) + }) + + it('does not match files, only directories', () => { + fs.writeFileSync(path.join(root, 'skills.md'), '# not a dir') + const result = findSkillLikePaths(root) + expect(result).toEqual([root]) + }) + + it('returns multiple matches for monorepos', () => { + const a = mkdir('packages', 'pkg-a', 'skills') + const b = mkdir('packages', 'pkg-b', 'skills') + const result = findSkillLikePaths(root) + expect(result).toContain(a) + expect(result).toContain(b) + }) +}) diff --git a/packages/cli/test/commands/skills-integration.test.ts b/packages/cli/test/commands/skills-integration.test.ts new file mode 100644 index 0000000..1b77b67 --- /dev/null +++ b/packages/cli/test/commands/skills-integration.test.ts @@ -0,0 +1,139 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { runSkillsInstall } from '../../src/commands/skills/install.js' +import { runSkillsRemove } from '../../src/commands/skills/remove.js' +import { readLock } from '../../src/skills/lock.js' + +/** + * End-to-end coverage of ask skills install/remove using a synthetic + * checkout tree. ensureCheckout is mocked so the test never touches the + * real GitHub store or network. + */ + +let projectDir: string +let checkoutDir: string + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-skills-e2e-proj-')) + checkoutDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-skills-e2e-ck-')) + // Synthetic producer skills under the checkout. + const skillsRoot = path.join(checkoutDir, 'skills') + fs.mkdirSync(path.join(skillsRoot, 'alpha'), { recursive: true }) + fs.writeFileSync(path.join(skillsRoot, 'alpha', 'SKILL.md'), 'alpha v1') +}) + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }) + fs.rmSync(checkoutDir, { recursive: true, force: true }) +}) + +function mockEnsureCheckout() { + return mock(async () => ({ + parsed: { kind: 'github', owner: 'acme', repo: 'skills-lib', name: 'skills-lib' } as any, + owner: 'acme', + repo: 'skills-lib', + ref: 'v1.0.0', + resolvedVersion: 'v1.0.0', + checkoutDir, + })) +} + +function quiet() { + return { + log: () => {}, + error: () => {}, + exit: (code: number) => { throw new Error(`exit ${code}`) }, + } +} + +function createClaudeMarker() { + fs.mkdirSync(path.join(projectDir, '.claude'), { recursive: true }) +} + +describe('skills install/remove (integration)', () => { + it('installs into .claude/skills/ when only Claude is detected', async () => { + createClaudeMarker() + const ensureCheckout = mockEnsureCheckout() + // Need a fake ask.json for manageIgnoreFiles to do anything. + fs.writeFileSync(path.join(projectDir, 'ask.json'), JSON.stringify({ libraries: [] })) + + await runSkillsInstall( + { spec: 'github:acme/skills-lib@v1.0.0', projectDir }, + { ensureCheckout, ...quiet() }, + ) + + // Vendored copy. + const vendorDir = path.join(projectDir, '.ask/skills/github__acme__skills-lib__v1.0.0/alpha') + expect(fs.existsSync(path.join(vendorDir, 'SKILL.md'))).toBe(true) + + // Symlink in agent dir. + const link = path.join(projectDir, '.claude/skills/alpha') + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true) + expect(fs.readFileSync(path.join(link, 'SKILL.md'), 'utf-8')).toBe('alpha v1') + + // Lock entry present. + const lock = readLock(projectDir) + const entry = Object.values(lock.entries)[0] + expect(entry.skills[0].name).toBe('alpha') + expect(entry.skills[0].agents).toEqual(['claude']) + }) + + it('re-install is idempotent', async () => { + createClaudeMarker() + fs.writeFileSync(path.join(projectDir, 'ask.json'), JSON.stringify({ libraries: [] })) + const ensureCheckout = mockEnsureCheckout() + + await runSkillsInstall({ spec: 'github:acme/skills-lib@v1.0.0', projectDir }, { ensureCheckout, ...quiet() }) + // Second run should not throw. + await runSkillsInstall({ spec: 'github:acme/skills-lib@v1.0.0', projectDir }, { ensureCheckout, ...quiet() }) + + const lock = readLock(projectDir) + expect(Object.keys(lock.entries)).toHaveLength(1) + }) + + it('remove undoes install cleanly', async () => { + createClaudeMarker() + fs.writeFileSync(path.join(projectDir, 'ask.json'), JSON.stringify({ libraries: [] })) + const ensureCheckout = mockEnsureCheckout() + await runSkillsInstall({ spec: 'github:acme/skills-lib@v1.0.0', projectDir }, { ensureCheckout, ...quiet() }) + + await runSkillsRemove({ spec: 'github:acme/skills-lib@v1.0.0', projectDir }, quiet()) + + expect(fs.existsSync(path.join(projectDir, '.claude/skills/alpha'))).toBe(false) + expect(fs.existsSync(path.join(projectDir, '.ask/skills/github__acme__skills-lib__v1.0.0'))).toBe(false) + expect(readLock(projectDir).entries).toEqual({}) + }) + + it('remove refuses missing entry without --ignore-missing', async () => { + await expect( + runSkillsRemove({ spec: 'npm:nope@1.0.0', projectDir }, quiet()), + ).rejects.toThrow(/exit 1/) + }) + + it('install without detectable agent exits 1', async () => { + const ensureCheckout = mockEnsureCheckout() + await expect( + runSkillsInstall({ spec: 'github:acme/skills-lib@v1.0.0', projectDir }, { ensureCheckout, ...quiet() }), + ).rejects.toThrow(/exit 1/) + }) + + it('skills-only project (no ask.json) still gets .gitignore patched — FR-11/AC-6', async () => { + createClaudeMarker() + // Seed an empty .gitignore so patchRootIgnores has a file to patch. + fs.writeFileSync(path.join(projectDir, '.gitignore'), '') + // Intentionally NO ask.json — this regressed before the hasAskOptIn fix + // landed: skills-only users ended up committing .ask/skills-lock.json. + const ensureCheckout = mockEnsureCheckout() + + await runSkillsInstall( + { spec: 'github:acme/skills-lib@v1.0.0', projectDir }, + { ensureCheckout, ...quiet() }, + ) + + const gitignore = fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf-8') + expect(gitignore).toContain('.ask/skills/') + expect(gitignore).toContain('.ask/skills-lock.json') + }) +}) diff --git a/packages/cli/test/commands/skills-list.test.ts b/packages/cli/test/commands/skills-list.test.ts new file mode 100644 index 0000000..9126544 --- /dev/null +++ b/packages/cli/test/commands/skills-list.test.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { NoCacheError } from '../../src/commands/ensure-checkout.js' +import { runSkillsList } from '../../src/commands/skills/list.js' + +interface CapturedIO { + stdout: string[] + stderr: string[] + exitCode: number | null +} + +function makeIo() { + const io: CapturedIO = { stdout: [], stderr: [], exitCode: null } + return { + io, + deps: { + log: (msg: string) => io.stdout.push(msg), + error: (msg: string) => io.stderr.push(msg), + exit: (code: number) => { io.exitCode = code }, + }, + } +} + +let projectDir: string +let checkoutDir: string + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-slist-proj-')) + checkoutDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-slist-ck-')) +}) + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }) + fs.rmSync(checkoutDir, { recursive: true, force: true }) +}) + +describe('runSkillsList', () => { + it('prints checkout root and any /skill/i subdirs', async () => { + fs.mkdirSync(path.join(checkoutDir, 'skills'), { recursive: true }) + const { io, deps } = makeIo() + const ensureCheckout = mock(async () => ({ + parsed: { kind: 'npm', pkg: 'react', name: 'react' } as any, + owner: 'facebook', + repo: 'react', + ref: 'v18.2.0', + resolvedVersion: '18.2.0', + checkoutDir, + npmPackageName: 'react', + })) + + await runSkillsList({ spec: 'react@18.2.0', projectDir }, { ensureCheckout, ...deps }) + + expect(io.stdout).toContain(checkoutDir) + expect(io.stdout).toContain(path.join(checkoutDir, 'skills')) + }) + + it('walks node_modules// when the package is installed locally', async () => { + const pkgDir = path.join(projectDir, 'node_modules', 'react', 'skills') + fs.mkdirSync(pkgDir, { recursive: true }) + const { io, deps } = makeIo() + const ensureCheckout = mock(async () => ({ + parsed: { kind: 'npm', pkg: 'react', name: 'react' } as any, + owner: 'facebook', + repo: 'react', + ref: 'v18.2.0', + resolvedVersion: '18.2.0', + checkoutDir, + npmPackageName: 'react', + })) + + await runSkillsList({ spec: 'react', projectDir }, { ensureCheckout, ...deps }) + + expect(io.stdout).toContain(pkgDir) + }) + + it('exits 1 with NoCacheError message when ensureCheckout throws it', async () => { + const { io, deps } = makeIo() + const ensureCheckout = mock(async () => { + throw new NoCacheError('/somewhere', 'react') + }) + + await runSkillsList( + { spec: 'react', projectDir, noFetch: true }, + { ensureCheckout, ...deps }, + ) + + expect(io.exitCode).toBe(1) + expect(io.stderr.join('\n')).toContain('no cached checkout') + }) + + it('exits 1 on generic resolver failure', async () => { + const { io, deps } = makeIo() + const ensureCheckout = mock(async () => { + throw new Error('unsupported ecosystem') + }) + await runSkillsList({ spec: 'xxx:foo', projectDir }, { ensureCheckout, ...deps }) + expect(io.exitCode).toBe(1) + expect(io.stderr.join('\n')).toContain('unsupported') + }) +}) diff --git a/packages/cli/test/skills/agent-detect.test.ts b/packages/cli/test/skills/agent-detect.test.ts new file mode 100644 index 0000000..afa4beb --- /dev/null +++ b/packages/cli/test/skills/agent-detect.test.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { detectAgents, resolveAgentNames, SUPPORTED_AGENTS } from '../../src/skills/agent-detect.js' + +let projectDir: string + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-agents-')) +}) + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }) +}) + +describe('detectAgents', () => { + it('returns [] for an empty project', () => { + expect(detectAgents(projectDir)).toEqual([]) + }) + + it('detects Claude Code via .claude/', () => { + fs.mkdirSync(path.join(projectDir, '.claude')) + const found = detectAgents(projectDir) + expect(found.map(a => a.name)).toEqual(['claude']) + expect(found[0].skillsDir).toBe(path.join(projectDir, '.claude', 'skills')) + }) + + it('detects multiple agents in stable order', () => { + for (const d of ['.claude', '.cursor', '.opencode', '.codex']) { + fs.mkdirSync(path.join(projectDir, d)) + } + expect(detectAgents(projectDir).map(a => a.name)).toEqual(['claude', 'cursor', 'opencode', 'codex']) + }) + + it('ignores a lone AGENTS.md (not an install target)', () => { + fs.writeFileSync(path.join(projectDir, 'AGENTS.md'), '# agents') + expect(detectAgents(projectDir)).toEqual([]) + }) +}) + +describe('resolveAgentNames', () => { + it('resolves known names without requiring the marker dir to exist', () => { + const [claude] = resolveAgentNames(projectDir, ['claude']) + expect(claude.name).toBe('claude') + expect(claude.skillsDir).toBe(path.join(projectDir, '.claude', 'skills')) + }) + + it('throws on unknown names', () => { + expect(() => resolveAgentNames(projectDir, ['nope'])).toThrow(/unknown agent/) + }) + + it('SUPPORTED_AGENTS lists all four', () => { + expect(SUPPORTED_AGENTS.sort()).toEqual(['claude', 'codex', 'cursor', 'opencode']) + }) +}) diff --git a/packages/cli/test/skills/ignore-files-skills.test.ts b/packages/cli/test/skills/ignore-files-skills.test.ts new file mode 100644 index 0000000..688ddc1 --- /dev/null +++ b/packages/cli/test/skills/ignore-files-skills.test.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { patchRootIgnores } from '../../src/ignore-files.js' + +let projectDir: string + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-ign-skills-')) +}) + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }) +}) + +describe('patchRootIgnores (skills-aware)', () => { + it('.gitignore marker block covers .ask/skills/ and skills-lock.json', () => { + fs.writeFileSync(path.join(projectDir, '.gitignore'), '') + patchRootIgnores(projectDir) + const content = fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf-8') + expect(content).toContain('.ask/docs/') + expect(content).toContain('.ask/skills/') + expect(content).toContain('.ask/skills-lock.json') + }) + + it('sonar.exclusions lists both .ask/docs/** and .ask/skills/**', () => { + fs.writeFileSync(path.join(projectDir, 'sonar-project.properties'), '') + patchRootIgnores(projectDir) + const content = fs.readFileSync(path.join(projectDir, 'sonar-project.properties'), 'utf-8') + expect(content).toContain('.ask/docs/**') + expect(content).toContain('.ask/skills/**') + }) + + it('.prettierignore lists skills paths', () => { + fs.writeFileSync(path.join(projectDir, '.prettierignore'), '') + patchRootIgnores(projectDir) + const content = fs.readFileSync(path.join(projectDir, '.prettierignore'), 'utf-8') + expect(content).toContain('.ask/skills/') + expect(content).toContain('.ask/skills-lock.json') + }) +}) diff --git a/packages/cli/test/skills/lock.test.ts b/packages/cli/test/skills/lock.test.ts new file mode 100644 index 0000000..1be059b --- /dev/null +++ b/packages/cli/test/skills/lock.test.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { LOCK_FILENAME, readLock, removeEntry, upsertEntry, writeLockAtomic } from '../../src/skills/lock.js' + +let projectDir: string + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-lock-')) +}) + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }) +}) + +const ENTRY = { + spec: 'npm:next@14.2.3', + specKey: 'npm__next__14.2.3', + skills: [{ name: 'ssr', agents: ['claude'] }], + installedAt: '2026-04-14T00:00:00Z', +} + +describe('lock IO', () => { + it('readLock returns an empty lock when the file is missing', () => { + expect(readLock(projectDir)).toEqual({ version: 1, entries: {} }) + }) + + it('writeLockAtomic then readLock round-trips', () => { + const lock = upsertEntry({ version: 1, entries: {} }, ENTRY) + writeLockAtomic(projectDir, lock) + expect(readLock(projectDir)).toEqual(lock) + }) + + it('writes to .ask/skills-lock.json', () => { + writeLockAtomic(projectDir, upsertEntry({ version: 1, entries: {} }, ENTRY)) + expect(fs.existsSync(path.join(projectDir, LOCK_FILENAME))).toBe(true) + }) + + it('upsertEntry is pure (does not mutate input)', () => { + const initial = { version: 1 as const, entries: {} } + const next = upsertEntry(initial, ENTRY) + expect(initial.entries).toEqual({}) + expect(next.entries[ENTRY.specKey]).toEqual(ENTRY) + }) + + it('upsertEntry replaces an existing entry by specKey', () => { + const first = upsertEntry({ version: 1, entries: {} }, ENTRY) + const updated = { ...ENTRY, installedAt: '2026-04-15T00:00:00Z' } + const second = upsertEntry(first, updated) + expect(Object.keys(second.entries)).toHaveLength(1) + expect(second.entries[ENTRY.specKey]?.installedAt).toBe(updated.installedAt) + }) + + it('removeEntry deletes the specKey and is a no-op for missing keys', () => { + const lock = upsertEntry({ version: 1, entries: {} }, ENTRY) + const afterRemove = removeEntry(lock, ENTRY.specKey) + expect(afterRemove.entries).toEqual({}) + expect(removeEntry(afterRemove, 'not-there')).toEqual(afterRemove) + }) + + it('readLock throws on malformed JSON', () => { + fs.mkdirSync(path.join(projectDir, '.ask'), { recursive: true }) + fs.writeFileSync(path.join(projectDir, LOCK_FILENAME), '{"version":2}') + expect(() => readLock(projectDir)).toThrow(/schema mismatch/) + }) +}) diff --git a/packages/cli/test/skills/spec-key.test.ts b/packages/cli/test/skills/spec-key.test.ts new file mode 100644 index 0000000..e384968 --- /dev/null +++ b/packages/cli/test/skills/spec-key.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'bun:test' +import { decodeSpecKey, encodeSpecKey } from '../../src/skills/spec-key.js' + +describe('encodeSpecKey', () => { + it('encodes a simple npm spec', () => { + expect(encodeSpecKey({ ecosystem: 'npm', name: 'next', version: '14.2.3' })) + .toBe('npm__next__14.2.3') + }) + + it('encodes a scoped npm package by replacing slash with __', () => { + expect(encodeSpecKey({ ecosystem: 'npm', name: '@vercel/ai', version: '5.0.0' })) + .toBe('npm__@vercel__ai__5.0.0') + }) + + it('encodes a github spec with owner/repo', () => { + expect(encodeSpecKey({ ecosystem: 'github', name: 'vercel/ai', version: 'v5.0.0' })) + .toBe('github__vercel__ai__v5.0.0') + }) + + it('encodes a monorepo tag (contains @)', () => { + expect(encodeSpecKey({ ecosystem: 'github', name: 'tanstack/router', version: '@tanstack/router-core@1.0.0' })) + .toBe('github__tanstack__router__@tanstack__router-core@1.0.0') + }) + + it('rejects empty parts', () => { + expect(() => encodeSpecKey({ ecosystem: '', name: 'x', version: '1' })) + .toThrow() + }) +}) + +describe('decodeSpecKey', () => { + it('round-trips a simple npm spec', () => { + const input = { ecosystem: 'npm', name: 'next', version: '14.2.3' } + expect(decodeSpecKey(encodeSpecKey(input))).toEqual(input) + }) + + it('round-trips a scoped npm spec', () => { + const input = { ecosystem: 'npm', name: '@vercel/ai', version: '5.0.0' } + expect(decodeSpecKey(encodeSpecKey(input))).toEqual(input) + }) + + it('round-trips a github spec', () => { + const input = { ecosystem: 'github', name: 'vercel/ai', version: 'v5.0.0' } + expect(decodeSpecKey(encodeSpecKey(input))).toEqual(input) + }) + + it('throws on malformed key without __ separator', () => { + expect(() => decodeSpecKey('onlyone')).toThrow() + }) + + it('throws on malformed key missing version segment', () => { + expect(() => decodeSpecKey('npm__next')).toThrow() + }) +}) diff --git a/packages/cli/test/skills/symlinks.test.ts b/packages/cli/test/skills/symlinks.test.ts new file mode 100644 index 0000000..73c2998 --- /dev/null +++ b/packages/cli/test/skills/symlinks.test.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { linkSkill, SymlinkConflictError, unlinkIfOwned } from '../../src/skills/symlinks.js' + +let root: string + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-symlink-')) +}) + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) +}) + +describe('linkSkill', () => { + it('creates a relative symlink and auto-mkdirs the parent', () => { + const targetPath = path.join(root, '.ask/skills/npm__x__1/alpha') + fs.mkdirSync(targetPath, { recursive: true }) + const linkPath = path.join(root, '.claude/skills/alpha') + + linkSkill({ linkPath, targetPath }) + + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true) + expect(fs.readlinkSync(linkPath)).toBe('../../.ask/skills/npm__x__1/alpha') + }) + + it('is a no-op when the same symlink already exists', () => { + const targetPath = path.join(root, '.ask/skills/npm__x__1/alpha') + fs.mkdirSync(targetPath, { recursive: true }) + const linkPath = path.join(root, '.claude/skills/alpha') + linkSkill({ linkPath, targetPath }) + expect(() => linkSkill({ linkPath, targetPath })).not.toThrow() + }) + + it('throws SymlinkConflictError when a real dir exists and force=false', () => { + const targetPath = path.join(root, '.ask/skills/npm__x__1/alpha') + fs.mkdirSync(targetPath, { recursive: true }) + const linkPath = path.join(root, '.claude/skills/alpha') + fs.mkdirSync(linkPath, { recursive: true }) + + expect(() => linkSkill({ linkPath, targetPath })).toThrow(SymlinkConflictError) + }) + + it('replaces a conflicting entry when force=true', () => { + const targetPath = path.join(root, '.ask/skills/npm__x__1/alpha') + fs.mkdirSync(targetPath, { recursive: true }) + const linkPath = path.join(root, '.claude/skills/alpha') + fs.mkdirSync(linkPath, { recursive: true }) + + linkSkill({ linkPath, targetPath, force: true }) + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) +}) + +describe('unlinkIfOwned', () => { + it('removes a symlink whose target matches and returns true', () => { + const targetPath = path.join(root, '.ask/skills/npm__x__1/alpha') + fs.mkdirSync(targetPath, { recursive: true }) + const linkPath = path.join(root, '.claude/skills/alpha') + linkSkill({ linkPath, targetPath }) + + expect(unlinkIfOwned(linkPath, targetPath)).toBe(true) + expect(fs.existsSync(linkPath)).toBe(false) + }) + + it('leaves mismatched symlinks in place and returns false', () => { + const target1 = path.join(root, '.ask/skills/npm__x__1/alpha') + const target2 = path.join(root, '.ask/skills/npm__other__2/alpha') + fs.mkdirSync(target1, { recursive: true }) + fs.mkdirSync(target2, { recursive: true }) + const linkPath = path.join(root, '.claude/skills/alpha') + linkSkill({ linkPath, targetPath: target1 }) + + expect(unlinkIfOwned(linkPath, target2)).toBe(false) + expect(fs.existsSync(linkPath)).toBe(true) + }) + + it('never deletes a real directory', () => { + const linkPath = path.join(root, '.claude/skills/alpha') + fs.mkdirSync(linkPath, { recursive: true }) + expect(unlinkIfOwned(linkPath, path.join(root, 'anything'))).toBe(false) + expect(fs.existsSync(linkPath)).toBe(true) + }) +}) diff --git a/packages/cli/test/skills/vendor.test.ts b/packages/cli/test/skills/vendor.test.ts new file mode 100644 index 0000000..eb15e9c --- /dev/null +++ b/packages/cli/test/skills/vendor.test.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { removeVendorDir, VENDOR_ROOT, vendorSkills } from '../../src/skills/vendor.js' + +let projectDir: string +let srcRoot: string + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-vendor-proj-')) + srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-test-vendor-src-')) +}) + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }) + fs.rmSync(srcRoot, { recursive: true, force: true }) +}) + +function writeSkill(name: string, files: Record): string { + const dir = path.join(srcRoot, name) + fs.mkdirSync(dir, { recursive: true }) + for (const [f, c] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, f), c) + } + return dir +} + +describe('vendorSkills', () => { + it('copies each source dir into .ask/skills///', () => { + const a = writeSkill('alpha', { 'SKILL.md': 'a' }) + const b = writeSkill('beta', { 'SKILL.md': 'b' }) + const result = vendorSkills(projectDir, 'npm__next__14.2.3', [a, b]) + + expect(result.skillNames.sort()).toEqual(['alpha', 'beta']) + expect(result.vendorDir).toBe(path.join(projectDir, VENDOR_ROOT, 'npm__next__14.2.3')) + expect(fs.readFileSync(path.join(result.vendorDir, 'alpha', 'SKILL.md'), 'utf-8')).toBe('a') + expect(fs.readFileSync(path.join(result.vendorDir, 'beta', 'SKILL.md'), 'utf-8')).toBe('b') + }) + + it('refreshes: a second call replaces the prior vendor dir', () => { + const a = writeSkill('alpha', { 'SKILL.md': 'v1' }) + vendorSkills(projectDir, 'npm__x__1', [a]) + fs.writeFileSync(path.join(srcRoot, 'alpha', 'SKILL.md'), 'v2') + vendorSkills(projectDir, 'npm__x__1', [a]) + expect(fs.readFileSync(path.join(projectDir, VENDOR_ROOT, 'npm__x__1', 'alpha', 'SKILL.md'), 'utf-8')).toBe('v2') + }) + + it('skips missing/non-directory sources', () => { + const a = writeSkill('alpha', {}) + const missing = path.join(srcRoot, 'does-not-exist') + const result = vendorSkills(projectDir, 'npm__x__1', [a, missing]) + expect(result.skillNames).toEqual(['alpha']) + }) + + it('removeVendorDir wipes the vendored tree', () => { + const a = writeSkill('alpha', {}) + vendorSkills(projectDir, 'npm__x__1', [a]) + removeVendorDir(projectDir, 'npm__x__1') + expect(fs.existsSync(path.join(projectDir, VENDOR_ROOT, 'npm__x__1'))).toBe(false) + }) +})