diff --git a/CHANGELOG.md b/CHANGELOG.md index ab34c12..0d61ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Each released version is tagged in git (`v0.0.1`, `v0.1.0`, etc.) and includes t ## [Unreleased] +### Changed + +- **`draftwise skills install` auto-detects which AI harnesses are on the machine instead of installing to all three by default.** Previously the default behavior was to write SKILL.md into every known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`) regardless of whether the user actually had Claude Code, Cursor, or Gemini CLI installed. Now `detectInstalledProviders` (in `src/utils/skill-providers.js`) checks which of `~/.claude` / `~/.cursor` / `~/.gemini` exist at the chosen scope root and installs only to those. The detected set is logged so the user sees *why* a particular harness was picked. `--provider=all` is the explicit opt-in for the old behavior; `--provider=` still targets one harness regardless of detection. When detection finds nothing the command errors with a hint pointing at both override flags. `skills help` now also prints "Detected harnesses (user scope): …" and the project-scope equivalent so the auto-detect set is visible without having to run install. Why: this is the same behavior impeccable's install uses (via the `vercel-labs/skills` package — auto-detect, with `--all` as the explicit override) and it's friendlier than littering provider dirs with files for harnesses the user doesn't have. CLAUDE.md's "Standalone skill" section and README's slash-command callout updated to match. `skills uninstall` keeps its existing "iterate every known dir and skip ones with nothing to remove" behavior — different goal (clean up stale Draftwise installs whether or not the harness is still on disk), so detection-on-uninstall would miss the cleanup case. — Ankur + ## [0.2.1] — 2026-04-29 — Ankur The polish + cleanup release. Half ergonomic improvements (init now auto-detects new project vs existing codebase from the filesystem instead of asking; plain-language UX throughout init), half code-quality work (five small dedup + tidy PRs that shrink the public API surface, fix two user-visible typos, and consolidate the drafting-command boilerplate behind shared helpers). One genuinely new surface: `draftwise skills ` drops standalone slash-command skills into Claude Code, Cursor, and Gemini CLI's user-level skill dirs — same SKILL.md, per-provider frontmatter trim — independent of the Claude Code marketplace plugin. diff --git a/CLAUDE.md b/CLAUDE.md index 3c80adb..b973bd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ src/core/scanner.js → codebase scanning (frameworks, routes, components, src/ai/provider.js → routes complete() calls to the right provider adapter src/ai/providers/ → claude.js wired; openai.js + gemini.js stubbed src/ai/prompts/ → one prompt module per command. Each exports brownfield + greenfield SYSTEM constants, a selectSystem(projectState) helper, a buildPrompt() that branches on projectState, and an agent-mode instruction -src/utils/ → config.js (yaml loader; returns projectState/stack/scanMaxFiles), specs.js (list .draftwise/specs/), slug.js, overview.js (read .draftwise/overview.md for greenfield context), scan-cache.js (fingerprinted scan cache, drop-in for scan()), flow-filter.js (narrow scan to flow-relevant items), scan-warnings.js (truncation + missing-framework messages), fs.js (shared pathExists), scan-projection.js (shared compactScan that trims a raw scan into a prompt-sized projection), tty.js (isInteractive helper), agent-handoff.js (shared orienting prefix logged before every agent-mode handoff), project-state.js (filesystem auto-detect for `init` — bail-fast walk for source files using scanner's IGNORE_DIRS + CODE_EXTENSIONS), skill-providers.js (provider dir mapping + Claude-only frontmatter trim shared across `skills install` / `uninstall` / `help`) +src/utils/ → config.js (yaml loader; returns projectState/stack/scanMaxFiles), specs.js (list .draftwise/specs/), slug.js, overview.js (read .draftwise/overview.md for greenfield context), scan-cache.js (fingerprinted scan cache, drop-in for scan()), flow-filter.js (narrow scan to flow-relevant items), scan-warnings.js (truncation + missing-framework messages), fs.js (shared pathExists), scan-projection.js (shared compactScan that trims a raw scan into a prompt-sized projection), tty.js (isInteractive helper), agent-handoff.js (shared orienting prefix logged before every agent-mode handoff), project-state.js (filesystem auto-detect for `init` — bail-fast walk for source files using scanner's IGNORE_DIRS + CODE_EXTENSIONS), skill-providers.js (provider dir mapping + Claude-only frontmatter trim + `detectInstalledProviders` filesystem check shared across `skills install` / `uninstall` / `help`) test/ → vitest, mirrors src structure .claude-plugin/ → plugin marketplace declaration (see "Claude Code plugin" below) plugin/ → plugin source tree shipped via the marketplace @@ -51,7 +51,7 @@ plugin/ → plugin source tree shipped via the marketplace **Claude Code skill — two install paths, three harnesses on the standalone path.** `.claude-plugin/marketplace.json` at repo root declares a single `draftwise` plugin with `source: ./plugin`. Inside `plugin/` is `.claude-plugin/plugin.json` (the install manifest) and `skills/draftwise/SKILL.md` plus `skills/draftwise/reference/.md` per CLI verb. The same SKILL.md ships through two install paths with different slash-command shapes: - **Marketplace plugin** (`/plugin marketplace add 4nkur/draftwise` then `/plugin install draftwise`): Claude Code namespaces all plugin skills as `:`, so the chat form is `/draftwise:draftwise `. The namespace prefix is mandatory for plugin-installed skills regardless of `/plugin install` scope (user / project / project-this-user) — see [anthropics/claude-code#15882](https://github.com/anthropics/claude-code/issues/15882) (closed: not planned). Claude Code only. -- **Standalone skill** (`draftwise skills install`): writes the same SKILL.md into each known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`). No plugin manifest sits alongside it, so each harness reads it as a regular user skill — bare `/draftwise `, matching the CLI binary. Same pattern impeccable uses for its bare verbs across providers. `--provider=` narrows; `--scope=project` writes under `` instead of `~`. Per-provider frontmatter trim in `src/utils/skill-providers.js` strips Claude-only fields (`user-invocable`, `argument-hint`, `allowed-tools`) for non-Claude harnesses; body is identical. +- **Standalone skill** (`draftwise skills install`): writes the same SKILL.md into each known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`). No plugin manifest sits alongside it, so each harness reads it as a regular user skill — bare `/draftwise `, matching the CLI binary. Same pattern impeccable uses for its bare verbs across providers. **Default is auto-detect:** with no `--provider` flag, `detectInstalledProviders` checks which of `~/.claude` / `~/.cursor` / `~/.gemini` exist at the chosen scope root and installs only to those — same shape as impeccable's install behavior (which delegates to the `vercel-labs/skills` package). `--provider=all` forces install everywhere regardless of detection (the old default); `--provider=` targets one harness regardless of detection; `--scope=project` writes under `` instead of `~`. When auto-detect finds nothing, the command errors with a hint pointing at `--provider=all` / `--provider=`. `skills uninstall` keeps its "remove from every known dir, skip empty" behavior — different goal (clean up stale Draftwise installs whether or not the harness is still present), so detection-on-uninstall would miss the cleanup case. Per-provider frontmatter trim in `src/utils/skill-providers.js` strips Claude-only fields (`user-invocable`, `argument-hint`, `allowed-tools`) for non-Claude harnesses; body is identical. Pattern follows impeccable's distribution model: one skill routes to per-verb references that drive the conversation in chat and shell out to the npm-installed `draftwise` CLI. The two install paths are independent and may coexist (you'll see both `/draftwise:draftwise ` and `/draftwise ` listed in Claude Code). `package.json` `files` ships `plugin/skills/` (so the standalone install can copy from `node_modules/draftwise/plugin/skills/draftwise/`) but excludes `plugin/.claude-plugin/`. References include pre-flight checks (e.g. `new` warns if `overview.md` is stale, `tech` nudges to skim the product spec first) and tone shaping for how to ask the user about ambiguous flag values; they explicitly inherit `src/ai/prompts/principles.js`'s collaboration standards so the chat-driven conversation matches what the CLI's api-mode synthesis enforces. The `skills` subcommand group lives at `src/commands/skills/{install,uninstall,help}.js`; routing in `src/index.js` follows the `git remote ` / `gh pr ` pattern via `SUBCOMMAND_GROUPS`. diff --git a/README.md b/README.md index 81704e4..2a61cdc 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ draftwise init ``` draftwise skills install ``` - Drops a standalone skill into each known harness's skill dir at the user level: `~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`. Slash form is `/draftwise init`, `/draftwise new ""`, etc. Narrow with `--provider=claude|cursor|gemini` or write at project scope with `--scope=project`. `draftwise skills help` shows what's installed where; `draftwise skills uninstall` removes it. + Auto-detects which harnesses you have on this machine (by checking for `~/.claude/`, `~/.cursor/`, `~/.gemini/`) and installs the standalone skill only to those — slash form `/draftwise init`, `/draftwise new ""`, etc. Pass `--provider=all` to install everywhere regardless of detection, `--provider=claude|cursor|gemini` to target one explicitly, or `--scope=project` to write at `/./skills/draftwise/` instead of the user-level dir. `draftwise skills help` shows what's installed where (and which harnesses auto-detect would target); `draftwise skills uninstall` removes it. - **`/draftwise:draftwise ` via the Claude Code plugin marketplace.** If you prefer Claude Code's `/plugin` workflow: ``` /plugin marketplace add 4nkur/draftwise diff --git a/src/commands/skills/help.js b/src/commands/skills/help.js index 59c1f61..674436c 100644 --- a/src/commands/skills/help.js +++ b/src/commands/skills/help.js @@ -3,6 +3,7 @@ import { pathExists } from '../../utils/fs.js'; import { PROVIDER_NAMES, PROVIDERS, + detectInstalledProviders, resolveProviderTarget, } from '../../utils/skill-providers.js'; @@ -13,7 +14,8 @@ Usage: Walks ~/./skills/draftwise/ and /./skills/draftwise/ for each known harness (Claude Code, Cursor, Gemini CLI), reports what's -installed, and points at the matching install / uninstall command. +installed, and prints the auto-detected harness set that +\`draftwise skills install\` (with no --provider flag) would target. `; function pad(s, n) { @@ -39,6 +41,29 @@ export default async function skillsHelp(_args = [], deps = {}) { } } log(''); + + const detectedUser = await detectInstalledProviders({ + scope: 'user', + cwd, + home, + }); + const detectedProject = await detectInstalledProviders({ + scope: 'project', + cwd, + home, + }); + const labelList = (names) => + names.length === 0 ? 'none' : names.map((n) => PROVIDERS[n].label).join(', '); + log( + `Detected harnesses (user scope, --scope=user): ${labelList(detectedUser)}`, + ); + log( + `Detected harnesses (project scope, --scope=project): ${labelList(detectedProject)}`, + ); + log( + '`draftwise skills install` (no flag) targets the detected set; pass --provider=all to override.', + ); + log(''); log('Install: draftwise skills install [--provider=] [--scope=] [--force]'); log('Uninstall: draftwise skills uninstall [--provider=] [--scope=]'); log(''); diff --git a/src/commands/skills/install.js b/src/commands/skills/install.js index f16d370..f0a10f1 100644 --- a/src/commands/skills/install.js +++ b/src/commands/skills/install.js @@ -7,6 +7,7 @@ import { pathExists } from '../../utils/fs.js'; import { PROVIDER_NAMES, PROVIDERS, + detectInstalledProviders, resolveProviderTarget, transformSkillForProvider, } from '../../utils/skill-providers.js'; @@ -14,13 +15,16 @@ import { export const HELP = `draftwise skills install — install Draftwise as a standalone slash-command skill Usage: - draftwise skills install # install for all known harnesses (Claude, Cursor, Gemini) - draftwise skills install --provider=claude # install for one harness + draftwise skills install # auto-detect harnesses on disk (~/.claude, ~/.cursor, ~/.gemini) and install to those + draftwise skills install --provider=all # install to every known harness regardless of detection + draftwise skills install --provider=claude # install to one harness regardless of detection draftwise skills install --scope=project # install at /./skills/draftwise/ draftwise skills install --force # overwrite existing installs Flags: - --provider Which harness(es) to target. Default: all. + --provider Which harness(es) to target. Default: auto-detect + (install only to harnesses whose provider dir + exists at the chosen scope). --scope user → ~/./skills/draftwise/ project → /./skills/draftwise/ Default: user. @@ -79,14 +83,25 @@ async function copyTreeWithTransform(src, dest, provider) { } } -function resolveProviderList(flag) { - if (!flag || flag === 'all') return PROVIDER_NAMES; - if (!PROVIDER_NAMES.includes(flag)) { +async function resolveProviderList(flag, { scope, cwd, home }) { + if (flag === 'all') return PROVIDER_NAMES; + if (flag) { + if (!PROVIDER_NAMES.includes(flag)) { + throw new Error( + `Invalid --provider value "${flag}". Use one of: ${PROVIDER_NAMES.join(', ')}, all.`, + ); + } + return [flag]; + } + const detected = await detectInstalledProviders({ scope, cwd, home }); + if (detected.length === 0) { + const root = scope === 'project' ? cwd : home; + const dirs = PROVIDER_NAMES.map((p) => PROVIDERS[p].providerDir).join(', '); throw new Error( - `Invalid --provider value "${flag}". Use one of: ${PROVIDER_NAMES.join(', ')}, all.`, + `No AI harnesses detected at ${root} (looked for: ${dirs}). Pass --provider=all to install for every known harness, or --provider= to target one explicitly.`, ); } - return [flag]; + return detected; } export default async function skillsInstall(args = [], deps = {}) { @@ -117,7 +132,11 @@ export default async function skillsInstall(args = [], deps = {}) { ); } const force = Boolean(parsed.values.force); - const providers = resolveProviderList(parsed.values.provider); + const providers = await resolveProviderList(parsed.values.provider, { + scope, + cwd, + home, + }); if (!(await pathExists(sourceDir))) { throw new Error( @@ -125,6 +144,14 @@ export default async function skillsInstall(args = [], deps = {}) { ); } + // Tell the user *why* this provider list was chosen when they didn't pass + // a flag. Auto-detect picking one harness when three exist on the machine + // is otherwise silent and confusing. + if (!parsed.values.provider) { + const labels = providers.map((p) => PROVIDERS[p].label).join(', '); + log(`Detected harness(es): ${labels}. (Use --provider=all to install everywhere, or --provider= to target one.)`); + } + // Fail fast on conflicts before we write anything: if any target dir exists // and --force wasn't passed, error with a single message that lists every // conflict — friendlier than half-installing then erroring. diff --git a/src/utils/skill-providers.js b/src/utils/skill-providers.js index b706041..a42fc27 100644 --- a/src/utils/skill-providers.js +++ b/src/utils/skill-providers.js @@ -1,6 +1,7 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { parse as yamlParse, stringify as yamlStringify } from 'yaml'; +import { pathExists } from './fs.js'; // One row per AI harness Draftwise's standalone skill targets. Each provider // reads SKILL.md from its own `//skills/draftwise/` @@ -33,6 +34,22 @@ export function resolveProviderTarget({ provider, scope, cwd, home = homedir() } return join(root, meta.providerDir, 'skills', 'draftwise'); } +// Returns the subset of PROVIDER_NAMES whose provider dir exists at the given +// scope root — `~/.claude`, `~/.cursor`, `~/.gemini` for user scope, or the +// `/.` equivalents for project scope. The presence of the +// dir is treated as a proxy for "this harness is installed on this machine." +// Used by `skills install` to install only where the user actually has the +// harness, instead of writing to all three by default. +export async function detectInstalledProviders({ scope, cwd, home = homedir() }) { + const root = scope === 'project' ? cwd : home; + const found = []; + for (const provider of PROVIDER_NAMES) { + const dir = join(root, PROVIDERS[provider].providerDir); + if (await pathExists(dir)) found.push(provider); + } + return found; +} + // Splits a SKILL.md (or any markdown with YAML frontmatter) into its // frontmatter object and body string. Returns null frontmatter if the file // has no frontmatter block — caller decides what to do. diff --git a/test/commands/skills/help.test.js b/test/commands/skills/help.test.js index d900a14..09c7411 100644 --- a/test/commands/skills/help.test.js +++ b/test/commands/skills/help.test.js @@ -48,4 +48,21 @@ describe('draftwise skills help (state report)', () => { expect(out).toMatch(/Claude Code\s+user\s+not installed/); expect(out).toMatch(/Gemini CLI\s+project\s+not installed/); }); + + it('shows the detected-harness set that auto-detect install would target', async () => { + await mkdir(join(home, '.claude'), { recursive: true }); + await mkdir(join(cwd, '.gemini'), { recursive: true }); + + await skillsHelp([], deps()); + const out = logs.join('\n'); + expect(out).toMatch(/Detected harnesses \(user scope[^)]*\):\s+Claude Code/); + expect(out).toMatch(/Detected harnesses \(project scope[^)]*\):\s+Gemini CLI/); + }); + + it('reports "none" when no harness dirs exist at a scope', async () => { + await skillsHelp([], deps()); + const out = logs.join('\n'); + expect(out).toMatch(/Detected harnesses \(user scope[^)]*\):\s+none/); + expect(out).toMatch(/Detected harnesses \(project scope[^)]*\):\s+none/); + }); }); diff --git a/test/commands/skills/install.test.js b/test/commands/skills/install.test.js index 2cbae69..86904cc 100644 --- a/test/commands/skills/install.test.js +++ b/test/commands/skills/install.test.js @@ -57,9 +57,48 @@ describe('draftwise skills install', () => { return { cwd, home, sourceDir, log: (m) => logs.push(m), ...extra }; } - it('installs to all known harnesses by default', async () => { + // Auto-detect needs the provider dir to exist at the scope root. Most tests + // pre-seed all three so behavior matches the old "install everywhere" + // baseline; the no-detection / single-detection cases are tested separately + // below. + async function seedAllProviderDirs() { + for (const provider of ['.claude', '.cursor', '.gemini']) { + await mkdir(join(home, provider), { recursive: true }); + } + } + + it('auto-detects: installs only to harnesses whose provider dir exists', async () => { + // Only Claude is "installed" on this machine. + await mkdir(join(home, '.claude'), { recursive: true }); + await skillsInstall([], deps()); + const claudeSkill = await readFile( + join(home, '.claude', 'skills', 'draftwise', 'SKILL.md'), + 'utf8', + ); + expect(claudeSkill).toContain('name: draftwise'); + await expect( + readFile(join(home, '.cursor', 'skills', 'draftwise', 'SKILL.md'), 'utf8'), + ).rejects.toThrow(); + await expect( + readFile(join(home, '.gemini', 'skills', 'draftwise', 'SKILL.md'), 'utf8'), + ).rejects.toThrow(); + + // The detection log line tells the user *why* only Claude was picked. + expect(logs.join('\n')).toContain('Detected harness(es): Claude Code'); + }); + + it('errors when no provider dir is detected, with a hint to use --provider', async () => { + await expect(skillsInstall([], deps())).rejects.toThrow( + /No AI harnesses detected[\s\S]*--provider=all[\s\S]*--provider=/, + ); + }); + + it('--provider=all installs to every known harness regardless of detection', async () => { + // No provider dirs exist — auto-detect would error here, but --provider=all overrides. + await skillsInstall(['--provider=all'], deps()); + for (const provider of ['.claude', '.cursor', '.gemini']) { const target = join(home, provider, 'skills', 'draftwise'); const skill = await readFile(join(target, 'SKILL.md'), 'utf8'); @@ -70,6 +109,7 @@ describe('draftwise skills install', () => { }); it('keeps Claude-only frontmatter on Claude, strips it for other providers', async () => { + await seedAllProviderDirs(); await skillsInstall([], deps()); const claude = await readFile( @@ -130,6 +170,7 @@ describe('draftwise skills install', () => { }); it('refuses to overwrite existing installs without --force, lists every conflict', async () => { + await seedAllProviderDirs(); await skillsInstall([], deps()); await expect(skillsInstall([], deps())).rejects.toThrow( /Target dirs already exist[\s\S]*\.claude[\s\S]*\.cursor[\s\S]*\.gemini/, @@ -137,6 +178,7 @@ describe('draftwise skills install', () => { }); it('--force replaces all conflicting installs cleanly', async () => { + await seedAllProviderDirs(); await skillsInstall([], deps()); // Plant a stale file in one of the targets to confirm it's wiped. const stale = join( @@ -159,6 +201,7 @@ describe('draftwise skills install', () => { }); it('errors clearly when the skill source is missing', async () => { + await seedAllProviderDirs(); await rm(sourceDir, { recursive: true, force: true }); await expect(skillsInstall([], deps())).rejects.toThrow( /Skill source not found/, diff --git a/test/utils/skill-providers.test.js b/test/utils/skill-providers.test.js index d142694..95617f4 100644 --- a/test/utils/skill-providers.test.js +++ b/test/utils/skill-providers.test.js @@ -1,7 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { PROVIDERS, PROVIDER_NAMES, + detectInstalledProviders, resolveProviderTarget, splitFrontmatter, joinFrontmatter, @@ -52,6 +56,43 @@ describe('skill-providers', () => { }); }); + describe('detectInstalledProviders', () => { + let workspace; + let home; + let cwd; + + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'draftwise-detect-')); + home = join(workspace, 'home'); + cwd = join(workspace, 'project'); + await mkdir(home, { recursive: true }); + await mkdir(cwd, { recursive: true }); + }); + + afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it('returns an empty list when no provider dirs exist', async () => { + const found = await detectInstalledProviders({ scope: 'user', cwd, home }); + expect(found).toEqual([]); + }); + + it('returns the providers whose dirs exist at user scope', async () => { + await mkdir(join(home, '.claude'), { recursive: true }); + await mkdir(join(home, '.gemini'), { recursive: true }); + const found = await detectInstalledProviders({ scope: 'user', cwd, home }); + expect(found.sort()).toEqual(['claude', 'gemini']); + }); + + it('uses for project scope, not home', async () => { + await mkdir(join(home, '.claude'), { recursive: true }); // user-scope decoy + await mkdir(join(cwd, '.cursor'), { recursive: true }); + const found = await detectInstalledProviders({ scope: 'project', cwd, home }); + expect(found).toEqual(['cursor']); + }); + }); + describe('splitFrontmatter / joinFrontmatter', () => { it('round-trips a SKILL.md with frontmatter', () => { const src = `---\nname: x\nversion: 1.0\n---\nbody here\n`;