diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de48627..2fd5ae4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -250,6 +250,15 @@ jobs: test do assert_match version.to_s, shell_output("#{bin}/hs --version") end + + def caveats + <<~EOS + To install the Claude Code skill that drives this CLI: + hs skill install + Cross-agent (Cursor/Codex/Copilot/OpenCode), via skills.sh: + npx skills add say8425/hs-cli + EOS + end end RB diff --git a/.oxlintrc.json b/.oxlintrc.json index cb8c8fb..5cd52d4 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -59,5 +59,13 @@ "promise/no-return-wrap": "error", "promise/param-names": "error" }, - "ignorePatterns": ["dist/**", "node_modules/**", "*.config.js", "*.config.ts"] + "ignorePatterns": ["dist/**", "node_modules/**", "*.config.js", "*.config.ts"], + "overrides": [ + { + "files": ["src/commands/skill.ts"], + "rules": { + "no-await-in-loop": "off" + } + } + ] } diff --git a/CLAUDE.md b/CLAUDE.md index 4569562..3395d62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ hs card [-l koKR] # card lookup hs card --search --class CLASS [-l koKR] # filtered search hs card --class CLASS [--cost N] [-l koKR] # browse (blank/no --search = wildcard) hs meta sets|classes|types|rarities [-l koKR] +hs skill install [--agent claude,cursor,codex,copilot,opencode] [--project] [--use-npx] # install the hearthstone-deck skill into agent skills dirs ``` Add `-f json` to any command for raw JSON. Default `table` format is agent-friendly (compressed). @@ -60,16 +61,22 @@ Install channels for end users (CLI): Claude Code plugin install (all channels): `/plugin marketplace add say8425/hs-cli` + `/plugin install hs-cli@say8425`. Validate with `claude plugin validate .` (marketplace) or `claude plugin validate ./plugins/hs-cli` (plugin). +Install the bundled `hearthstone-deck` skill into any agent (works across all CLI channels — the skill is embedded in the binary, no network needed): `hs skill install` (interactive multiselect) or `hs skill install --agent claude`. Cross-agent via [skills.sh](https://www.skills.sh/): `npx skills add say8425/hs-cli` (discovered through `.claude-plugin/marketplace.json`, no repo restructure needed). + **Do not duplicate SKILL.md at the repo root.** The single source of truth lives inside the plugin. Root-level docs should link to it, not copy it. ## Architecture - `src/index.ts` — citty `runMain`, registers subcommands -- `src/commands/` — one file per command (deck/card/meta), each exports `defineCommand` instance +- `src/commands/` — one file per command (deck/card/meta/skill), each exports `defineCommand` instance. `skill.ts` nests `install` as a subcommand (`hs skill install`). - `src/services/card-db.ts` — HearthstoneJSON CDN fetch + local cache at `~/.hs-cli/` - `src/services/deck-decoder.ts` — wraps `deckstrings` npm, joins with card DB - `src/services/formatter.ts` — table/json output. **Add new formatters here, not in commands.** - `src/services/locale.ts` — locale detection + normalization (`ko` / `ko-KR` / `ko_KR` / `koKR` → `koKR`) +- `src/services/agent-dirs.ts` — agent→skills-dir map (claude/cursor/codex/copilot/opencode) + path resolver (injectable home/cwd for tests) +- `src/services/skill-bundle.ts` — `hearthstone-deck` SKILL.md + recipes embedded into the binary via Bun text imports. Single source of truth stays at `plugins/hs-cli/skills/hearthstone-deck/`; a test asserts byte-equality with disk. +- `src/services/skill-installer.ts` — pure FS writer for the embedded skill (`writeBundle`, `skillExists`) +- `src/services/skill-select.ts` — pure agent-selection resolver (`--agent` validation + non-TTY guard); clack multiselect lives in the command, not here - `src/types/index.ts` — `Card`, `Deck`, `DeckCard` types + Korean class name map - `tests/` — bun:test files, one per service. Imports use `.ts` extension (Bun resolves native TS). - `tsconfig.json` — typecheck + IDE config. `noEmit: true`, `allowImportingTsExtensions: true`. No separate build config. diff --git a/README.es.md b/README.es.md index 67a894e..d6287be 100644 --- a/README.es.md +++ b/README.es.md @@ -67,6 +67,21 @@ hs --version hs deck "AAECAQcAA0VjgAEAAA==" ``` +### Instalar la skill de Claude Code + +El CLI `hs` incluye la skill `hearthstone-deck`. Tras instalar el CLI: + +```bash +hs skill install # interactivo: elige agentes +hs skill install --agent claude # no interactivo +``` + +Instalación multi-agente vía [skills.sh](https://www.skills.sh/) (Cursor, Codex, Copilot, OpenCode): + +```bash +npx skills add say8425/hs-cli +``` + ## Uso ### Decodificar un mazo diff --git a/README.ja.md b/README.ja.md index 19d831d..8170786 100644 --- a/README.ja.md +++ b/README.ja.md @@ -67,6 +67,21 @@ hs --version hs deck "AAECAQcAA0VjgAEAAA==" ``` +### Claude Code スキルのインストール + +`hs` CLI には `hearthstone-deck` スキルが同梱されています。CLI インストール後: + +```bash +hs skill install # 対話式: エージェントを選択 +hs skill install --agent claude # 非対話式 +``` + +[skills.sh](https://www.skills.sh/) 経由のマルチエージェントインストール (Cursor、Codex、Copilot、OpenCode): + +```bash +npx skills add say8425/hs-cli +``` + ## 使い方 ### デッキをデコード diff --git a/README.ko.md b/README.ko.md index 0fe40d9..37807c1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -67,6 +67,21 @@ hs --version hs deck "AAECAQcAA0VjgAEAAA==" ``` +### Claude Code 스킬 설치 + +`hs` CLI에는 `hearthstone-deck` 스킬이 포함되어 있습니다. CLI 설치 후: + +```bash +hs skill install # 대화형: 에이전트 선택 +hs skill install --agent claude # 비대화형 +``` + +[skills.sh](https://www.skills.sh/)를 통한 멀티 에이전트 설치 (Cursor, Codex, Copilot, OpenCode): + +```bash +npx skills add say8425/hs-cli +``` + ## 사용법 ### 덱 디코딩 diff --git a/README.md b/README.md index f0f4110..05fe5b2 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,21 @@ hs --version hs deck "AAECAQcAA0VjgAEAAA==" ``` +### Install the Claude Code skill + +The `hs` CLI ships the `hearthstone-deck` skill. After installing the CLI: + +```bash +hs skill install # interactive: pick agents +hs skill install --agent claude # non-interactive +``` + +Cross-agent install via [skills.sh](https://www.skills.sh/) (Cursor, Codex, Copilot, OpenCode): + +```bash +npx skills add say8425/hs-cli +``` + ## Usage ### Decode a deck diff --git a/README.zh.md b/README.zh.md index e013778..4a017bd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -67,6 +67,21 @@ hs --version hs deck "AAECAQcAA0VjgAEAAA==" ``` +### 安装 Claude Code 技能 + +`hs` CLI 内置 `hearthstone-deck` 技能。安装 CLI 后: + +```bash +hs skill install # 交互式:选择代理 +hs skill install --agent claude # 非交互式 +``` + +通过 [skills.sh](https://www.skills.sh/) 跨代理安装(Cursor、Codex、Copilot、OpenCode): + +```bash +npx skills add say8425/hs-cli +``` + ## 使用方法 ### 解码套牌 diff --git a/bun.lock b/bun.lock index c5812b2..8ef5b55 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@penguin/hs-cli", "dependencies": { + "@clack/prompts": "^1.4.0", "citty": "^0.1.6", "deckstrings": "^3.1.2", }, @@ -17,6 +18,10 @@ }, }, "packages": { + "@clack/core": ["@clack/core@1.3.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA=="], + + "@clack/prompts": ["@clack/prompts@1.4.0", "", { "dependencies": { "@clack/core": "1.3.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ=="], @@ -105,10 +110,18 @@ "deckstrings": ["deckstrings@3.1.2", "", {}, "sha512-u495/2wXLJJwusL27Tp8d5srTMg7H5ZZxhnvLG7ZYeVxQOpKaCTHx/JTLElG6j/jZX5QUFh6/nOmqoGnQtSFKw=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="], "oxlint": ["oxlint@1.67.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.67.0", "@oxlint/binding-android-arm64": "1.67.0", "@oxlint/binding-darwin-arm64": "1.67.0", "@oxlint/binding-darwin-x64": "1.67.0", "@oxlint/binding-freebsd-x64": "1.67.0", "@oxlint/binding-linux-arm-gnueabihf": "1.67.0", "@oxlint/binding-linux-arm-musleabihf": "1.67.0", "@oxlint/binding-linux-arm64-gnu": "1.67.0", "@oxlint/binding-linux-arm64-musl": "1.67.0", "@oxlint/binding-linux-ppc64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-musl": "1.67.0", "@oxlint/binding-linux-s390x-gnu": "1.67.0", "@oxlint/binding-linux-x64-gnu": "1.67.0", "@oxlint/binding-linux-x64-musl": "1.67.0", "@oxlint/binding-openharmony-arm64": "1.67.0", "@oxlint/binding-win32-arm64-msvc": "1.67.0", "@oxlint/binding-win32-ia32-msvc": "1.67.0", "@oxlint/binding-win32-x64-msvc": "1.67.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], diff --git a/package.json b/package.json index 9a65d00..8958fdc 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "bun": ">=1.3" }, "dependencies": { + "@clack/prompts": "^1.4.0", "citty": "^0.1.6", "deckstrings": "^3.1.2" }, diff --git a/plugins/hs-cli/README.md b/plugins/hs-cli/README.md index 9144aea..7a6b983 100644 --- a/plugins/hs-cli/README.md +++ b/plugins/hs-cli/README.md @@ -34,6 +34,21 @@ hs --version hs deck "AAECAQcAA0VjgAEAAA==" ``` +### Install the Claude Code skill + +The `hs` CLI ships the `hearthstone-deck` skill. After installing the CLI: + +```bash +hs skill install # interactive: pick agents +hs skill install --agent claude # non-interactive +``` + +Cross-agent install via [skills.sh](https://www.skills.sh/) (Cursor, Codex, Copilot, OpenCode): + +```bash +npx skills add say8425/hs-cli +``` + ## Install the plugin From the marketplace: diff --git a/src/commands/skill.ts b/src/commands/skill.ts new file mode 100644 index 0000000..e00a8d2 --- /dev/null +++ b/src/commands/skill.ts @@ -0,0 +1,157 @@ +import { spawnSync } from "node:child_process"; +import { defineCommand } from "citty"; +import { isCancel, multiselect, confirm } from "@clack/prompts"; +import { AGENTS, resolveAgentDir, type AgentId } from "../services/agent-dirs.ts"; +import { resolveSelection } from "../services/skill-select.ts"; +import { skillExists, writeBundle, targetSkillDir } from "../services/skill-installer.ts"; +import { SKILL_NAME } from "../services/skill-bundle.ts"; +import { formatSkillOutcomes } from "../services/formatter.ts"; +import type { OutputFormat, SkillOutcome } from "../types/index.ts"; + +const fail = (message: string): never => { + process.stderr.write(`${message}\n`); + process.exit(1); +}; + +const promptAgents = async (): Promise => { + const picked = await multiselect({ + message: "Install the hearthstone-deck skill for which agents?", + options: AGENTS.map((a) => ({ value: a.id, label: a.label })), + required: true, + output: process.stderr, + }); + if (isCancel(picked)) process.exit(0); + return picked as readonly AgentId[]; +}; + +const delegateToNpx = (global: boolean): never => { + const args = ["skills", "add", "say8425/hs-cli", "--skill", SKILL_NAME]; + if (global) args.push("-g"); + process.stderr.write(`Delegating to: npx ${args.join(" ")}\n`); + const res = spawnSync("npx", args, { stdio: "inherit" }); + process.exit(res.status ?? 1); +}; + +const installCommand = defineCommand({ + meta: { + name: "install", + description: "Install the hearthstone-deck skill into agent skills dirs", + }, + args: { + agent: { + type: "string", + description: "Comma-separated agent ids: claude,cursor,codex,copilot,opencode", + }, + project: { + type: "boolean", + default: false, + description: "Install into the current project instead of the user home (global)", + }, + "use-npx": { + type: "boolean", + default: false, + description: + "Delegate to `npx skills add` when npx is available (installs for all agents skills detects; ignores --agent)", + }, + force: { + type: "boolean", + default: false, + description: "Overwrite an existing skill without prompting", + }, + format: { + type: "string", + alias: "f", + default: "table", + description: "Output format: table or json", + }, + }, + run: async ({ args }) => { + const scope = args.project ? "project" : "global"; + + if (args["use-npx"]) { + const hasNpx = spawnSync("npx", ["--version"], { stdio: "ignore" }).status === 0; + if (hasNpx) delegateToNpx(scope === "global"); + process.stderr.write("npx not found; falling back to embedded install.\n"); + } + + const agentFlags = (args.agent ?? "") + .split(",") + .map((s: string) => s.trim()) + .filter((s: string) => s.length > 0); + + const selection = resolveSelection({ + agents: agentFlags, + isTTY: process.stdout.isTTY === true, + }); + if (selection.kind === "error") fail(selection.message); + + const agentIds: readonly AgentId[] = + selection.kind === "explicit" ? selection.agents : await promptAgents(); + + const outcomes: SkillOutcome[] = []; + + // Several agents can resolve to the same dir (e.g. project scope: cursor/codex/ + // copilot/opencode all map to .agents/skills). Dedupe by resolved target dir so we + // writeBundle once per physical dir and report a single accurate outcome for it. + const byDir = new Map(); + const order: string[] = []; + for (const id of agentIds) { + const def = AGENTS.find((a) => a.id === id); + if (!def) { + outcomes.push({ agent: id, path: id, status: "failed", error: "unknown agent" }); + continue; + } + const baseDir = resolveAgentDir(def, { scope }); + const existing = byDir.get(baseDir); + if (existing) { + existing.push(id); + } else { + byDir.set(baseDir, [id]); + order.push(baseDir); + } + } + + // Sequential on purpose: the overwrite confirm() prompt must be shown one dir at a time. + for (const baseDir of order) { + const ids = byDir.get(baseDir) ?? []; + const agentLabel = ids.join(","); + try { + const exists = await skillExists(baseDir); + if (exists && !args.force && process.stdout.isTTY === true) { + const ok = await confirm({ + message: `${agentLabel}: skill exists at ${baseDir}. Overwrite?`, + output: process.stderr, + }); + if (isCancel(ok)) process.exit(0); + if (ok === false) { + outcomes.push({ agent: agentLabel, path: baseDir, status: "skipped" }); + continue; + } + } + await writeBundle(baseDir); + outcomes.push({ + agent: agentLabel, + path: targetSkillDir(baseDir), + status: exists ? "overwritten" : "installed", + }); + } catch (err) { + outcomes.push({ + agent: agentLabel, + path: baseDir, + status: "failed", + error: err instanceof Error ? err.message : String(err), + }); + } + } + + process.stdout.write(`${formatSkillOutcomes(outcomes, args.format as OutputFormat)}\n`); + + const anySuccess = outcomes.some((o) => o.status === "installed" || o.status === "overwritten"); + process.exit(anySuccess ? 0 : 1); + }, +}); + +export const skillCommand = defineCommand({ + meta: { name: "skill", description: "Manage the hearthstone-deck Claude Code skill" }, + subCommands: { install: installCommand }, +}); diff --git a/src/index.ts b/src/index.ts index d8cf742..3d9c208 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import pkg from "../package.json" with { type: "json" }; import { cardCommand } from "./commands/card.ts"; import { deckCommand } from "./commands/deck.ts"; import { metaCommand } from "./commands/meta.ts"; +import { skillCommand } from "./commands/skill.ts"; const main = defineCommand({ meta: { @@ -15,6 +16,7 @@ const main = defineCommand({ deck: deckCommand, card: cardCommand, meta: metaCommand, + skill: skillCommand, }, }); diff --git a/src/services/agent-dirs.ts b/src/services/agent-dirs.ts new file mode 100644 index 0000000..79b3d3b --- /dev/null +++ b/src/services/agent-dirs.ts @@ -0,0 +1,42 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +export type AgentId = "claude" | "cursor" | "codex" | "copilot" | "opencode"; + +export interface AgentDef { + readonly id: AgentId; + readonly label: string; + readonly globalDir: string; + readonly projectDir: string; +} + +export const AGENTS: readonly AgentDef[] = [ + { id: "claude", label: "Claude Code", globalDir: ".claude/skills", projectDir: ".claude/skills" }, + { id: "cursor", label: "Cursor", globalDir: ".cursor/skills", projectDir: ".agents/skills" }, + { id: "codex", label: "Codex", globalDir: ".codex/skills", projectDir: ".agents/skills" }, + { + id: "copilot", + label: "GitHub Copilot", + globalDir: ".copilot/skills", + projectDir: ".agents/skills", + }, + { + id: "opencode", + label: "OpenCode", + globalDir: ".config/opencode/skills", + projectDir: ".agents/skills", + }, +]; + +export const isAgentId = (value: string): value is AgentId => AGENTS.some((a) => a.id === value); + +export interface ResolveOptions { + readonly scope: "global" | "project"; + readonly home?: string; + readonly cwd?: string; +} + +export const resolveAgentDir = (agent: AgentDef, opts: ResolveOptions): string => + opts.scope === "global" + ? join(opts.home ?? homedir(), agent.globalDir) + : join(opts.cwd ?? process.cwd(), agent.projectDir); diff --git a/src/services/formatter.ts b/src/services/formatter.ts index 5c7180f..28b36c3 100644 --- a/src/services/formatter.ts +++ b/src/services/formatter.ts @@ -1,4 +1,4 @@ -import type { Card, Deck, DeckCard, OutputFormat } from "../types/index.js"; +import type { Card, Deck, DeckCard, OutputFormat, SkillOutcome } from "../types/index.js"; import { getFormatKo, getHeroClassKo } from "./deck-decoder.js"; const buildManaCurve = (cards: readonly DeckCard[]): Record => { @@ -104,3 +104,16 @@ export const formatMeta = ( if (format === "json") return JSON.stringify({ type, values }, undefined, 2); return `${type} (${values.length}):\n${values.map((v) => ` ${v}`).join("\n")}`; }; + +export const formatSkillOutcomes = ( + outcomes: readonly SkillOutcome[], + format: OutputFormat, +): string => { + if (format === "json") return JSON.stringify(outcomes, undefined, 2); + return outcomes + .map( + (o) => + `${o.status.padEnd(11)} ${o.agent.padEnd(9)} ${o.path}${o.error ? ` (${o.error})` : ""}`, + ) + .join("\n"); +}; diff --git a/src/services/skill-bundle.ts b/src/services/skill-bundle.ts new file mode 100644 index 0000000..ff8734b --- /dev/null +++ b/src/services/skill-bundle.ts @@ -0,0 +1,18 @@ +import skillMd from "../../plugins/hs-cli/skills/hearthstone-deck/SKILL.md" with { type: "text" }; +import cardRecipe from "../../plugins/hs-cli/skills/hearthstone-deck/recipes/card.md" with { type: "text" }; +import deckRecipe from "../../plugins/hs-cli/skills/hearthstone-deck/recipes/deck.md" with { type: "text" }; +import metaRecipe from "../../plugins/hs-cli/skills/hearthstone-deck/recipes/meta.md" with { type: "text" }; + +export const SKILL_NAME = "hearthstone-deck"; + +export interface BundledFile { + readonly relativePath: string; + readonly contents: string; +} + +export const SKILL_BUNDLE: readonly BundledFile[] = [ + { relativePath: "SKILL.md", contents: skillMd }, + { relativePath: "recipes/card.md", contents: cardRecipe }, + { relativePath: "recipes/deck.md", contents: deckRecipe }, + { relativePath: "recipes/meta.md", contents: metaRecipe }, +]; diff --git a/src/services/skill-installer.ts b/src/services/skill-installer.ts new file mode 100644 index 0000000..eafd4ff --- /dev/null +++ b/src/services/skill-installer.ts @@ -0,0 +1,26 @@ +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { SKILL_BUNDLE, SKILL_NAME } from "./skill-bundle.ts"; + +export const targetSkillDir = (baseDir: string): string => join(baseDir, SKILL_NAME); + +export const skillExists = async (baseDir: string): Promise => { + try { + await stat(targetSkillDir(baseDir)); + return true; + } catch { + return false; + } +}; + +export const writeBundle = async (baseDir: string): Promise => { + const root = targetSkillDir(baseDir); + await Promise.all( + SKILL_BUNDLE.map(async (f) => { + const dest = join(root, f.relativePath); + await mkdir(dirname(dest), { recursive: true }); + await writeFile(dest, f.contents); + }), + ); + return SKILL_BUNDLE.map((f) => join(root, f.relativePath)); +}; diff --git a/src/services/skill-select.ts b/src/services/skill-select.ts new file mode 100644 index 0000000..cceb9cf --- /dev/null +++ b/src/services/skill-select.ts @@ -0,0 +1,31 @@ +import { AGENTS, isAgentId, type AgentId } from "./agent-dirs.ts"; + +export type Selection = + | { readonly kind: "explicit"; readonly agents: readonly AgentId[] } + | { readonly kind: "prompt" } + | { readonly kind: "error"; readonly message: string }; + +export interface SelectionInput { + readonly agents: readonly string[]; + readonly isTTY: boolean; +} + +const validIds = (): string => AGENTS.map((a) => a.id).join(", "); + +export const resolveSelection = (input: SelectionInput): Selection => { + if (input.agents.length > 0) { + const invalid = input.agents.filter((a) => !isAgentId(a)); + if (invalid.length > 0) { + return { + kind: "error", + message: `Unknown agent(s): ${invalid.join(", ")}. Valid: ${validIds()}`, + }; + } + return { kind: "explicit", agents: input.agents as readonly AgentId[] }; + } + if (input.isTTY) return { kind: "prompt" }; + return { + kind: "error", + message: `Not interactive: pass --agent (comma-separated). Valid: ${validIds()}. e.g. hs skill install --agent claude`, + }; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 95cd8aa..f26dcfe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,6 +37,13 @@ export type DeckFormat = "standard" | "wild" | "classic" | "twist" | "unknown"; export type OutputFormat = "table" | "json"; +export interface SkillOutcome { + readonly agent: string; + readonly path: string; + readonly status: "installed" | "overwritten" | "skipped" | "failed"; + readonly error?: string; +} + export const FORMAT_MAP: Record = { 1: "wild", 2: "standard", diff --git a/src/types/text-modules.d.ts b/src/types/text-modules.d.ts new file mode 100644 index 0000000..c94d67b --- /dev/null +++ b/src/types/text-modules.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +} diff --git a/tests/skill.test.ts b/tests/skill.test.ts new file mode 100644 index 0000000..891f71a --- /dev/null +++ b/tests/skill.test.ts @@ -0,0 +1,165 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "bun:test"; +import { AGENTS, isAgentId, resolveAgentDir, type AgentDef } from "../src/services/agent-dirs.ts"; +import { SKILL_BUNDLE, SKILL_NAME } from "../src/services/skill-bundle.ts"; +import { skillExists, targetSkillDir, writeBundle } from "../src/services/skill-installer.ts"; + +const byId = (id: string): AgentDef => { + const a = AGENTS.find((x) => x.id === id); + if (!a) throw new Error(`missing agent ${id}`); + return a; +}; + +describe("agent-dirs", () => { + it("exposes the five supported agents", () => { + expect(AGENTS.map((a) => a.id).toSorted()).toEqual([ + "claude", + "codex", + "copilot", + "cursor", + "opencode", + ]); + }); + + it("validates agent ids", () => { + expect(isAgentId("claude")).toBe(true); + expect(isAgentId("nope")).toBe(false); + }); + + it("resolves global dirs against an injected home", () => { + expect(resolveAgentDir(byId("claude"), { scope: "global", home: "/h" })).toBe( + "/h/.claude/skills", + ); + expect(resolveAgentDir(byId("opencode"), { scope: "global", home: "/h" })).toBe( + "/h/.config/opencode/skills", + ); + }); + + it("resolves project dirs against an injected cwd", () => { + expect(resolveAgentDir(byId("claude"), { scope: "project", cwd: "/p" })).toBe( + "/p/.claude/skills", + ); + expect(resolveAgentDir(byId("cursor"), { scope: "project", cwd: "/p" })).toBe( + "/p/.agents/skills", + ); + }); +}); + +const SKILL_SRC = "plugins/hs-cli/skills/hearthstone-deck"; + +describe("skill-bundle", () => { + it("uses the canonical skill name", () => { + expect(SKILL_NAME).toBe("hearthstone-deck"); + }); + + it("bundles SKILL.md plus all three recipes", () => { + expect(SKILL_BUNDLE.map((f) => f.relativePath).toSorted()).toEqual([ + "SKILL.md", + "recipes/card.md", + "recipes/deck.md", + "recipes/meta.md", + ]); + }); + + it("bundle contents match the on-disk source files", async () => { + const pairs = await Promise.all( + SKILL_BUNDLE.map(async (f) => ({ + f, + disk: await readFile(join(SKILL_SRC, f.relativePath), "utf8"), + })), + ); + for (const { f, disk } of pairs) { + expect(f.contents).toBe(disk); + } + }); +}); + +import { resolveSelection } from "../src/services/skill-select.ts"; + +describe("skill-select", () => { + it("uses explicit --agent ids when valid", () => { + const r = resolveSelection({ agents: ["claude", "cursor"], isTTY: false }); + expect(r).toEqual({ kind: "explicit", agents: ["claude", "cursor"] }); + }); + + it("errors on unknown --agent ids", () => { + const r = resolveSelection({ agents: ["claude", "bogus"], isTTY: true }); + expect(r.kind).toBe("error"); + }); + + it("asks to prompt when interactive and no flags", () => { + expect(resolveSelection({ agents: [], isTTY: true })).toEqual({ kind: "prompt" }); + }); + + it("errors when non-interactive and no flags", () => { + const r = resolveSelection({ agents: [], isTTY: false }); + expect(r.kind).toBe("error"); + }); +}); + +import { formatSkillOutcomes } from "../src/services/formatter.ts"; +import type { SkillOutcome } from "../src/types/index.ts"; + +describe("formatSkillOutcomes", () => { + const sample: readonly SkillOutcome[] = [ + { agent: "claude", path: "/h/.claude/skills/hearthstone-deck", status: "installed" }, + { agent: "cursor,codex", path: "/p/.agents/skills", status: "failed", error: "boom" }, + ]; + + it("json format round-trips to the outcomes array", () => { + const out = formatSkillOutcomes(sample, "json"); + expect(JSON.parse(out)).toEqual(sample); + }); + + it("table format renders status, agent, path, and (error) suffix", () => { + const out = formatSkillOutcomes(sample, "table"); + const lines = out.split("\n"); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("installed"); + expect(lines[0]).toContain("claude"); + expect(lines[0]).toContain("/h/.claude/skills/hearthstone-deck"); + expect(lines[0]).not.toContain("("); + expect(lines[1]).toContain("failed"); + expect(lines[1]).toContain("cursor,codex"); + expect(lines[1]).toContain("/p/.agents/skills"); + expect(lines[1]).toContain("(boom)"); + }); +}); + +describe("skill-installer", () => { + it("targets /hearthstone-deck", () => { + expect(targetSkillDir("/base")).toBe("/base/hearthstone-deck"); + }); + + it("reports non-existence then existence after writing", async () => { + const base = await mkdtemp(join(tmpdir(), "hs-skill-")); + try { + expect(await skillExists(base)).toBe(false); + await writeBundle(base); + expect(await skillExists(base)).toBe(true); + } finally { + await rm(base, { recursive: true, force: true }); + } + }); + + it("writes SKILL.md and all recipes with correct content, idempotently", async () => { + const base = await mkdtemp(join(tmpdir(), "hs-skill-")); + try { + await writeBundle(base); + await writeBundle(base); // overwrite must not throw + const skillPath = join(base, "hearthstone-deck", "SKILL.md"); + const recipePath = join(base, "hearthstone-deck", "recipes", "deck.md"); + await stat(skillPath); // throws if missing + const disk = await readFile( + join("plugins/hs-cli/skills/hearthstone-deck", "SKILL.md"), + "utf8", + ); + expect(await readFile(skillPath, "utf8")).toBe(disk); + await stat(recipePath); + } finally { + await rm(base, { recursive: true, force: true }); + } + }); +});