From 3497108c5c18a96c07784ed2fb9aec2963ac69dd Mon Sep 17 00:00:00 2001 From: devrimcavusoglu Date: Tue, 5 May 2026 17:43:19 +0300 Subject: [PATCH] Add 5 new platforms via declarative spec registry (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-platform Go files (claude.go, codex.go, opencode.go) with a single declarative `Spec` table in internal/platform/spec.go and one generic `Adapter` struct that implements the Platform interface from any spec row. Adding a new platform is now a one-line append to Specs plus one Type constant — no new Go file. New adapters: cursor, gemini-cli, github-copilot, windsurf, continue. Path conventions follow vercel-labs/skills. Detection is per-platform (each adapter checks its own user-level config dir like ~/.cursor, ~/.gemini, ~/.copilot) so platforms that share .agents/skills/ as a project dir are still distinguished. CLI flag help and "unknown platform" error text now enumerate the registry dynamically. Tests are table-driven over the spec set: install/uninstall/detect round-trip and shared-project-dir semantics are covered for every registered platform. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 25 +- CHANGELOG.md | 30 +++ README.md | 4 +- docs/concepts/platform-adapters.md | 47 +++- docs/platforms/codex-cli.md | 2 +- docs/platforms/index.md | 28 +- docs/reference/commands.md | 4 +- internal/cli/capacity.go | 13 + internal/cli/platform_test.go | 12 +- internal/cli/skill_diff.go | 2 +- internal/cli/skill_install.go | 4 +- internal/cli/skill_uninstall.go | 4 +- internal/platform/adapter.go | 80 ++++++ internal/platform/claude.go | 67 ----- internal/platform/codex.go | 72 ------ internal/platform/detector.go | 30 ++- internal/platform/opencode.go | 67 ----- internal/platform/platform.go | 14 +- internal/platform/platform_test.go | 399 +++++++++++++---------------- internal/platform/spec.go | 97 +++++++ 20 files changed, 510 insertions(+), 491 deletions(-) create mode 100644 internal/platform/adapter.go delete mode 100644 internal/platform/claude.go delete mode 100644 internal/platform/codex.go delete mode 100644 internal/platform/opencode.go create mode 100644 internal/platform/spec.go diff --git a/AGENTS.md b/AGENTS.md index 5fa7174..55b89b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Overview -Skern is a minimal, agent-first CLI tool for managing Agent Skills across agentic development platforms (Claude Code, Codex CLI, OpenCode). It follows the Agent Skills open standard (agentskills.io) and uses `SKILL.md` files with YAML frontmatter as the canonical format. +Skern is a minimal, agent-first CLI tool for managing Agent Skills across agentic development platforms (Claude Code, Codex CLI, OpenCode, Cursor, Gemini CLI, GitHub Copilot, Windsurf, Continue, and more). It follows the Agent Skills open standard (agentskills.io) and uses `SKILL.md` files with YAML frontmatter as the canonical format. The project is written in **Go 1.25+** and the current release is **v0.2.0**. @@ -15,7 +15,7 @@ internal/ skill/ # Domain logic: Skill struct, manifest parsing, validation, scaffolding overlap/ # Fuzzy name matching and description similarity scoring registry/ # Filesystem CRUD over ~/.skern/skills/ and .skern/skills/ - platform/ # Platform adapters (Claude Code, Codex CLI, OpenCode) + platform/ # Platform adapters — declarative spec table + generic Adapter output/ # JSON/text structured output formatting scripts/ install.sh # Installer script @@ -95,7 +95,7 @@ Each milestone gets its own feature branch. All commits for that milestone go on - **`cli/`** — Only command wiring, flag parsing, and output. No business logic. - **`skill/`** — Domain types and operations. The `Skill` struct, `Author`, `ModifiedByEntry` types, manifest parsing/serialization, validation rules, and scaffolding templates. - **`registry/`** — Filesystem operations for skill storage. CRUD and discovery across user/project scopes. -- **`platform/`** — Each adapter implements the `Platform` interface: `Name()`, `Detect()`, `UserSkillsDir()`, `ProjectSkillsDir()`, `Install()`, `Uninstall()`, `InstalledSkills()`. +- **`platform/`** — Declarative `Spec` table (`spec.go`) plus a single generic `Adapter` (`adapter.go`) that implements the `Platform` interface (`Name()`, `Detect()`, `UserSkillsDir()`, `ProjectSkillsDir()`, `Install()`, `Uninstall()`, `InstalledSkills()`) from any spec row. Adding a platform = one row in `Specs` plus one `Type` constant; no per-platform Go file. - **`overlap/`** — Similarity scoring (Levenshtein distance, keyword overlap). Returns a float64 score in [0, 1]. - **`output/`** — Handles `--json` and `--quiet` flags. All commands go through this package for consistent formatting. @@ -134,7 +134,7 @@ Each milestone gets its own feature branch. All commits for that milestone go on 3. **Platform adapters are copiers** — Installing a skill to a platform means copying the skill directory to the platform's expected location. Each adapter knows its platform's directory convention. -4. **Platform auto-detection** — Skern detects which platforms are installed by checking for their config directories/binaries (`~/.claude/`, `~/.codex/` or `~/.agents/`, `~/.config/opencode/`). Each `install`/`uninstall` invocation targets exactly one platform (per #52 D6); `--platform all` is not accepted. Agents specify the platform they are running on, and `skill install`/`skill uninstall` accept multiple skill names per call for batch operations. +4. **Platform auto-detection** — Skern detects which platforms are installed by checking each adapter's home-relative `DetectHome` paths (e.g. `~/.claude/`, `~/.cursor/`, `~/.gemini/`). Detection is per-platform even when several adapters share `.agents/skills/` as their project directory. Each `install`/`uninstall` invocation targets exactly one platform (per #52 D6); `--platform all` is not accepted. Agents specify the platform they are running on, and `skill install`/`skill uninstall` accept multiple skill names per call for batch operations. 5. **JSON output as first-class** — Every command supports `--json` for machine-readable output. Default is human-friendly text. Exit codes are semantic: 0=success, 1=error, 2=validation failure. @@ -237,11 +237,18 @@ Names must match `^[a-z0-9]+(-[a-z0-9]+)*$` and be 1-64 characters. ### Platform Paths -| Platform | User-level | Project-level | -|-------------|-------------------------------------|--------------------------| -| Claude Code | `~/.claude/skills//` | `.claude/skills//` | -| Codex CLI | `~/.agents/skills//` | `.agents/skills//` | -| OpenCode | `~/.config/opencode/skills//`| `.opencode/skills//`| +| Adapter name | User-level | Project-level | +|------------------|--------------------------------------|----------------------------| +| `claude-code` | `~/.claude/skills//` | `.claude/skills//` | +| `codex-cli` | `~/.agents/skills//` | `.agents/skills//` | +| `opencode` | `~/.config/opencode/skills//` | `.opencode/skills//` | +| `cursor` | `~/.cursor/skills//` | `.agents/skills//` | +| `gemini-cli` | `~/.gemini/skills//` | `.agents/skills//` | +| `github-copilot` | `~/.copilot/skills//` | `.agents/skills//` | +| `windsurf` | `~/.codeium/windsurf/skills//`| `.windsurf/skills//` | +| `continue` | `~/.continue/skills//` | `.continue/skills//` | + +The full list is generated from `internal/platform/spec.go` — append a row there to add a platform. ### Overlap Detection Thresholds diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2be14..c77f839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to skern are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Five new platform adapters: `cursor`, `gemini-cli`, `github-copilot`, + `windsurf`, `continue`.** All five accept the same `--platform` flag, route + installs to the platform's expected skill directory, and participate in + `skern platform list`/`status` matrices. Path conventions follow + [vercel-labs/skills](https://github.com/vercel-labs/skills#supported-agents). + ([#80]) +- **Declarative platform registry.** Adapters are now defined as one row in + `internal/platform/spec.go` — a `Spec` carrying name, user dir, project dir, + and home-relative detection paths. A single generic `Adapter` struct + implements the `Platform` interface from any spec row, replacing the + per-platform Go files (`claude.go`, `codex.go`, `opencode.go`). Adding a + platform is a one-line PR. ([#80]) + +### Changed + +- **Platform detection is per-platform, not per-directory.** Several adapters + share `.agents/skills/` as their project dir; detection now keys on each + adapter's distinct user-level config dir (`~/.cursor`, `~/.gemini`, + `~/.copilot`, `~/.codex`, etc.) so `platform list` doesn't false-positive + for platforms whose CLI isn't installed. +- **CLI flag help and error messages enumerate the registered platforms + dynamically** — adding a platform updates `--platform` help and the + "unknown platform" error text without touching the CLI. + +[#80]: https://github.com/devrimcavusoglu/skern/issues/80 + ## [v0.2.1] — 2026-05-03 Cross-platform install. No code changes; release pipeline + install UX only. diff --git a/README.md b/README.md index 4821651..cf0818b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ --- -Skern is a minimal, agent-first CLI for managing [Agent Skills](https://agentskills.io) across **Claude Code**, **Codex CLI**, and **OpenCode**. One `SKILL.md` per skill — portable, validated, and instantly installable to any supported platform. +Skern is a minimal, agent-first CLI for managing [Agent Skills](https://agentskills.io) across **Claude Code**, **Codex CLI**, **OpenCode**, **Cursor**, **Gemini CLI**, **GitHub Copilot**, **Windsurf**, **Continue**, and more. One `SKILL.md` per skill — portable, validated, and instantly installable to any supported platform. ## Quick Example @@ -31,7 +31,7 @@ skern skill install code-review --platform claude-code - **Unified skill lifecycle** — create, validate, search, install, and remove across platforms - **Agent Skills spec** — reads and writes `SKILL.md` directly, no proprietary format -- **Cross-platform** — install to Claude Code, Codex CLI, or OpenCode in one command +- **Cross-platform** — install to Claude Code, Codex CLI, OpenCode, Cursor, Gemini CLI, GitHub Copilot, Windsurf, Continue, and more - **Tool-forming loop** — agents scaffold and reuse skills automatically - **Overlap detection** — fuzzy matching prevents duplication - **JSON output** — every command supports `--json` for agent-operable workflows diff --git a/docs/concepts/platform-adapters.md b/docs/concepts/platform-adapters.md index d88e745..6a154fc 100644 --- a/docs/concepts/platform-adapters.md +++ b/docs/concepts/platform-adapters.md @@ -13,30 +13,42 @@ When you run `skern skill install`, the adapter: ## Supported Platforms +Adapter names match `vercel-labs/skills` agent identifiers wherever possible. + | Platform | Adapter name | Detection | -|----------|-------------|-----------| -| Claude Code | `claude-code` | Looks for `.claude/` or `~/.claude/` | -| Codex CLI | `codex-cli` | Looks for `.agents/` or `~/.agents/` | -| OpenCode | `opencode` | Looks for `.opencode/` or `~/.config/opencode/` | +|----------|--------------|-----------| +| Claude Code | `claude-code` | `~/.claude/` | +| Codex CLI | `codex-cli` | `~/.codex/` or `~/.agents/` | +| OpenCode | `opencode` | `~/.config/opencode/` | +| Cursor | `cursor` | `~/.cursor/` | +| Gemini CLI | `gemini-cli` | `~/.gemini/` | +| GitHub Copilot | `github-copilot` | `~/.copilot/` | +| Windsurf | `windsurf` | `~/.codeium/windsurf/` or `~/.windsurf/` | +| Continue | `continue` | `~/.continue/` | + +See [Supported Platforms](/platforms/) for the full path reference. ## Installation Paths -Each platform uses different directories for user-level and project-level skills: - -| Platform | User-level | Project-level | -|----------|-----------|---------------| -| Claude Code | `~/.claude/skills//` | `.claude/skills//` | -| Codex CLI | `~/.agents/skills//` | `.agents/skills//` | -| OpenCode | `~/.config/opencode/skills//` | `.opencode/skills//` | +Each platform uses different directories for user-level and project-level skills. Several platforms share `.agents/skills/` as the project directory — see [Shared project directory](#shared-project-directory) below. ## Auto-Detection -Skern auto-detects which platforms are installed on your system. Use `skern platform list` to see detected platforms: +Skern auto-detects which platforms are installed on your system by checking each platform's user-level config directory. Use `skern platform list` to see detected platforms: ```sh skern platform list ``` +## Shared project directory + +`codex-cli`, `cursor`, `gemini-cli`, and `github-copilot` all use `.agents/skills/` as their project-level skills directory. A skill installed there is visible to every agent that reads from it — that is intentional and matches the conventions in `vercel-labs/skills`. + +Two consequences: + +- **Detection is per-platform**, not per-directory. The presence of `.agents/skills/` does not by itself indicate which agents are installed; skern looks at each platform's distinct user-level config dir (`~/.cursor`, `~/.gemini`, `~/.copilot`, `~/.codex`) to disambiguate. +- **Capacity is per-directory.** When two platforms share a directory, both adapters see the same installed-skills count. Capacity thresholds protect the directory, not the logical agent — installing 50 skills via `cursor` will register as full capacity for `gemini-cli` too, because the agent will load all of them. + ## One Platform per Invocation Each `skern skill install` call targets exactly one platform. Agents are expected to specify the platform they are running on — there is no `all` value, and skern does not broadcast skills across platforms automatically. @@ -46,7 +58,7 @@ This design supports the [dynamic skill loading](./registry) model: each agent m To deploy a skill across several platforms, loop the call: ```sh -for p in claude-code codex-cli opencode; do +for p in claude-code codex-cli opencode cursor gemini-cli; do skern skill install code-review --platform "$p" done ``` @@ -71,3 +83,12 @@ skern platform status ``` This shows a matrix of skills and their installation status across all detected platforms. + +## Adding a Platform + +Adapters are declarative: every supported platform is one row in `Specs` in [`internal/platform/spec.go`](https://github.com/devrimcavusoglu/skern/blob/main/internal/platform/spec.go). Adding a new platform takes: + +1. A new `Type` constant in `internal/platform/platform.go`. +2. A new `Spec` row giving its name, user-level skills dir, project-level skills dir, and one or more home-relative detection paths. + +A single generic `Adapter` struct implements every platform's `Platform` interface from the spec, so no platform-specific Go code is required. The table-driven tests in `internal/platform/platform_test.go` cover every registered spec automatically. diff --git a/docs/platforms/codex-cli.md b/docs/platforms/codex-cli.md index d152d5d..212a4a6 100644 --- a/docs/platforms/codex-cli.md +++ b/docs/platforms/codex-cli.md @@ -27,7 +27,7 @@ skern skill uninstall code-review --platform codex-cli ## Detection -Skern detects Codex CLI by checking for the presence of `.agents/` in the current project or `~/.agents/` at the user level. +Skern detects Codex CLI by checking for `~/.codex/` (preferred) or `~/.agents/` at the user level. The `.agents/skills/` project directory is shared with several other agents (cursor, gemini-cli, github-copilot) — see [Platform Adapters › Shared project directory](/concepts/platform-adapters#shared-project-directory). ## How Skills Work in Codex CLI diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 9541f30..d14eb91 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -1,15 +1,27 @@ # Supported Platforms -Skern supports three agentic development platforms. Each platform has a dedicated adapter that handles skill installation and uninstallation. +Skern ships adapters for the most common agentic development platforms. Each adapter knows where its platform stores user-level and project-level skills, copies skills into place, and reports which platforms are installed on the host. -## Platform Comparison +The set is driven by a declarative registry in `internal/platform/spec.go` — adding a new platform is a one-line append (see [Platform Adapters](/concepts/platform-adapters#adding-a-platform)). -| Feature | Claude Code | Codex CLI | OpenCode | -|---------|-------------|-----------|----------| -| User-level skills | `~/.claude/skills/` | `~/.agents/skills/` | `~/.config/opencode/skills/` | -| Project-level skills | `.claude/skills/` | `.agents/skills/` | `.opencode/skills/` | -| Auto-detection | Yes | Yes | Yes | -| Batch install (multiple skills, one call) | Yes | Yes | Yes | +## Path Reference + +| Adapter name | User-level skills | Project-level skills | +|--------------|--------------------|----------------------| +| `claude-code` | `~/.claude/skills/` | `.claude/skills/` | +| `codex-cli` | `~/.agents/skills/` | `.agents/skills/` | +| `opencode` | `~/.config/opencode/skills/` | `.opencode/skills/` | +| `cursor` | `~/.cursor/skills/` | `.agents/skills/` | +| `gemini-cli` | `~/.gemini/skills/` | `.agents/skills/` | +| `github-copilot` | `~/.copilot/skills/` | `.agents/skills/` | +| `windsurf` | `~/.codeium/windsurf/skills/` | `.windsurf/skills/` | +| `continue` | `~/.continue/skills/` | `.continue/skills/` | + +Several platforms share `.agents/skills/` as their project directory — a skill installed there is visible to every agent that reads from it. See [Platform Adapters](/concepts/platform-adapters#shared-project-directory) for the implications on capacity reporting and detection. + +## Auto-detection + +`skern platform list` reports which adapters appear installed on the current host. Detection is per-platform: each adapter checks its own user-level config dir (e.g. `~/.cursor`, `~/.gemini`, `~/.copilot`) so platforms that share a project directory are still distinguished. ## Feature Comparison diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 96306bb..2f55612 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -171,7 +171,7 @@ The response includes a `capacity` block reporting the platform's installed-skil | Flag | Description | |------|-------------| -| `--platform` | `claude-code`, `codex-cli`, or `opencode` (required) | +| `--platform` | One of: `claude-code`, `codex-cli`, `opencode`, `cursor`, `gemini-cli`, `github-copilot`, `windsurf`, `continue` (required) | | `--scope` | `user` or `project` | | `--force` | Overwrite existing installation | | `--enforce-budget` | Refuse the operation if it would push the platform's installed-skill count past the per-scope threshold | @@ -190,7 +190,7 @@ Mirrors `install` semantics: one platform per call, multiple skills allowed, par | Flag | Description | |------|-------------| -| `--platform` | `claude-code`, `codex-cli`, or `opencode` (required) | +| `--platform` | One of: `claude-code`, `codex-cli`, `opencode`, `cursor`, `gemini-cli`, `github-copilot`, `windsurf`, `continue` (required) | | `--scope` | `user` or `project` | ## `skern platform list` diff --git a/internal/cli/capacity.go b/internal/cli/capacity.go index 1feda34..44e3070 100644 --- a/internal/cli/capacity.go +++ b/internal/cli/capacity.go @@ -2,12 +2,25 @@ package cli import ( "fmt" + "strings" "github.com/devrimcavusoglu/skern/internal/output" "github.com/devrimcavusoglu/skern/internal/platform" "github.com/devrimcavusoglu/skern/internal/skill" ) +// platformNamesList returns a comma-separated list of supported platform names +// for use in flag help text and error messages. Pulled from the platform spec +// registry so adding a platform takes one line. +func platformNamesList() string { + names := platform.SupportedNames() + parts := make([]string, len(names)) + for i, n := range names { + parts[i] = string(n) + } + return strings.Join(parts, ", ") +} + // buildCapacityReport queries a platform for its currently-installed skills // at the given scope and returns a CapacityReport ready for inclusion in // command output. Returns nil if the query fails (capacity is best-effort diff --git a/internal/cli/platform_test.go b/internal/cli/platform_test.go index ded7736..06bb0cb 100644 --- a/internal/cli/platform_test.go +++ b/internal/cli/platform_test.go @@ -25,9 +25,9 @@ func withTestDetector(t *testing.T, cc *CommandContext, home, project string) { cc.NewDetector = func() (*platform.Detector, error) { return platform.NewDetectorWithPlatforms([]platform.Platform{ - platform.NewClaudeCode(home, project), - platform.NewCodexCLI(home, project), - platform.NewOpenCode(home, project), + platform.New(platform.TypeClaudeCode, home, project), + platform.New(platform.TypeCodexCLI, home, project), + platform.New(platform.TypeOpenCode, home, project), }), nil } } @@ -427,9 +427,9 @@ func TestPlatformList_PartialDetection(t *testing.T) { NewRegistry: defaultNewRegistry, NewDetector: func() (*platform.Detector, error) { return platform.NewDetectorWithPlatforms([]platform.Platform{ - platform.NewClaudeCode(home, project), - platform.NewCodexCLI(home, project), - platform.NewOpenCode(home, project), + platform.New(platform.TypeClaudeCode, home, project), + platform.New(platform.TypeCodexCLI, home, project), + platform.New(platform.TypeOpenCode, home, project), }), nil }, } diff --git a/internal/cli/skill_diff.go b/internal/cli/skill_diff.go index 254f79b..481dfc8 100644 --- a/internal/cli/skill_diff.go +++ b/internal/cli/skill_diff.go @@ -46,7 +46,7 @@ With two arguments, compares two registry skills by name } cmd.Flags().StringVar(&scope, "scope", "", "skill scope (user or project)") - cmd.Flags().StringVar(&platformFlag, "platform", "", "platform to compare against (claude-code, codex-cli, opencode)") + cmd.Flags().StringVar(&platformFlag, "platform", "", "platform to compare against (one of: "+platformNamesList()+")") return cmd } diff --git a/internal/cli/skill_install.go b/internal/cli/skill_install.go index 8635758..606770a 100644 --- a/internal/cli/skill_install.go +++ b/internal/cli/skill_install.go @@ -68,7 +68,7 @@ installed-skill count would meet or exceed the per-platform threshold (see p := det.Get(platformType) if p == nil { - return &ValidationError{Message: fmt.Sprintf("platform %q not recognized; valid platforms: claude-code, codex-cli, opencode", platformFlag)} + return &ValidationError{Message: fmt.Sprintf("platform %q not recognized; valid platforms: %s", platformFlag, platformNamesList())} } // Capacity pre-check: if --enforce-budget is set, refuse the entire @@ -131,7 +131,7 @@ installed-skill count would meet or exceed the per-platform threshold (see }, } - cmd.Flags().StringVar(&platformFlag, "platform", "", "target platform (claude-code, codex-cli, or opencode)") + cmd.Flags().StringVar(&platformFlag, "platform", "", "target platform (one of: "+platformNamesList()+")") cmd.Flags().StringVar(&scope, "scope", "user", "skill scope (user or project)") cmd.Flags().BoolVar(&force, "force", false, "overwrite existing installation") cmd.Flags().BoolVar(&enforceBudget, "enforce-budget", false, "refuse to install when at or over capacity") diff --git a/internal/cli/skill_uninstall.go b/internal/cli/skill_uninstall.go index 285487e..5f64419 100644 --- a/internal/cli/skill_uninstall.go +++ b/internal/cli/skill_uninstall.go @@ -55,7 +55,7 @@ of stale skills at once.`, p := det.Get(platformType) if p == nil { - return &ValidationError{Message: fmt.Sprintf("platform %q not recognized; valid platforms: claude-code, codex-cli, opencode", platformFlag)} + return &ValidationError{Message: fmt.Sprintf("platform %q not recognized; valid platforms: %s", platformFlag, platformNamesList())} } var entries []output.SkillActionEntry @@ -90,7 +90,7 @@ of stale skills at once.`, }, } - cmd.Flags().StringVar(&platformFlag, "platform", "", "target platform (claude-code, codex-cli, or opencode)") + cmd.Flags().StringVar(&platformFlag, "platform", "", "target platform (one of: "+platformNamesList()+")") cmd.Flags().StringVar(&scope, "scope", "user", "skill scope (user or project)") _ = cmd.MarkFlagRequired("platform") diff --git a/internal/platform/adapter.go b/internal/platform/adapter.go new file mode 100644 index 0000000..872db85 --- /dev/null +++ b/internal/platform/adapter.go @@ -0,0 +1,80 @@ +package platform + +import ( + "os" + "path/filepath" + + "github.com/devrimcavusoglu/skern/internal/skill" +) + +// Adapter is a generic platform adapter parameterized by a Spec. Every +// supported platform is represented by an Adapter — adding a new one means +// appending to Specs, not writing more Go. +type Adapter struct { + spec Spec + homeDir string + projectRoot string +} + +// New returns a Platform adapter for the registered spec name, or nil if the +// name is not in Specs. Empty homeDir/projectRoot resolve to the OS home and +// the current directory respectively. +func New(name Type, homeDir, projectRoot string) Platform { + spec := SpecFor(name) + if spec == nil { + return nil + } + if homeDir == "" { + homeDir, _ = os.UserHomeDir() + } + if projectRoot == "" { + projectRoot = "." + } + return &Adapter{spec: *spec, homeDir: homeDir, projectRoot: projectRoot} +} + +// Name implements Platform. +func (a *Adapter) Name() Type { return a.spec.Name } + +// Detect implements Platform — true if any of the spec's home-relative paths +// exist on disk. +func (a *Adapter) Detect() bool { + for _, p := range a.spec.DetectHome { + if _, err := os.Stat(filepath.Join(a.homeDir, p)); err == nil { + return true + } + } + return false +} + +// UserSkillsDir implements Platform. +func (a *Adapter) UserSkillsDir() string { + return filepath.Join(a.homeDir, a.spec.UserDir) +} + +// ProjectSkillsDir implements Platform. +func (a *Adapter) ProjectSkillsDir() string { + return filepath.Join(a.projectRoot, a.spec.ProjectDir) +} + +// Install implements Platform. +func (a *Adapter) Install(skillDir, skillName string, scope skill.Scope) error { + return installSkill(skillDir, skillName, a.skillsDir(scope)) +} + +// Uninstall implements Platform. +func (a *Adapter) Uninstall(skillName string, scope skill.Scope) error { + return uninstallSkill(skillName, a.skillsDir(scope)) +} + +// InstalledSkills implements Platform. +func (a *Adapter) InstalledSkills(scope skill.Scope) ([]string, error) { + return listInstalledSkills(a.skillsDir(scope)) +} + +func (a *Adapter) skillsDir(scope skill.Scope) string { + if scope == skill.ScopeProject { + return a.ProjectSkillsDir() + } + return a.UserSkillsDir() +} diff --git a/internal/platform/claude.go b/internal/platform/claude.go deleted file mode 100644 index 99330a1..0000000 --- a/internal/platform/claude.go +++ /dev/null @@ -1,67 +0,0 @@ -package platform - -import ( - "os" - "path/filepath" - - "github.com/devrimcavusoglu/skern/internal/skill" -) - -// ClaudeCode is the platform adapter for Claude Code. -type ClaudeCode struct { - homeDir string - projectRoot string -} - -// NewClaudeCode creates a Claude Code adapter. -// Empty strings use default paths (home directory, current directory). -func NewClaudeCode(homeDir, projectRoot string) *ClaudeCode { - if homeDir == "" { - homeDir, _ = os.UserHomeDir() - } - if projectRoot == "" { - projectRoot = "." - } - return &ClaudeCode{homeDir: homeDir, projectRoot: projectRoot} -} - -// Name implements Platform. -func (c *ClaudeCode) Name() Type { return TypeClaudeCode } - -// Detect implements Platform. -func (c *ClaudeCode) Detect() bool { - _, err := os.Stat(filepath.Join(c.homeDir, ".claude")) - return err == nil -} - -// UserSkillsDir implements Platform. -func (c *ClaudeCode) UserSkillsDir() string { - return filepath.Join(c.homeDir, ".claude", "skills") -} - -// ProjectSkillsDir implements Platform. -func (c *ClaudeCode) ProjectSkillsDir() string { - return filepath.Join(c.projectRoot, ".claude", "skills") -} - -// Install implements Platform. -func (c *ClaudeCode) Install(skillDir string, skillName string, scope skill.Scope) error { - return installSkill(skillDir, skillName, c.skillsDir(scope)) -} - -// Uninstall implements Platform. -func (c *ClaudeCode) Uninstall(skillName string, scope skill.Scope) error { - return uninstallSkill(skillName, c.skillsDir(scope)) -} - -// InstalledSkills implements Platform. -func (c *ClaudeCode) InstalledSkills(scope skill.Scope) ([]string, error) { - return listInstalledSkills(c.skillsDir(scope)) -} - -func (c *ClaudeCode) skillsDir(scope skill.Scope) string { - if scope == skill.ScopeProject { - return c.ProjectSkillsDir() - } - return c.UserSkillsDir() -} diff --git a/internal/platform/codex.go b/internal/platform/codex.go deleted file mode 100644 index f3325e1..0000000 --- a/internal/platform/codex.go +++ /dev/null @@ -1,72 +0,0 @@ -package platform - -import ( - "os" - "path/filepath" - - "github.com/devrimcavusoglu/skern/internal/skill" -) - -// CodexCLI is the platform adapter for Codex CLI. -type CodexCLI struct { - homeDir string - projectRoot string -} - -// NewCodexCLI creates a Codex CLI adapter. -// Empty strings use default paths (home directory, current directory). -func NewCodexCLI(homeDir, projectRoot string) *CodexCLI { - if homeDir == "" { - homeDir, _ = os.UserHomeDir() - } - if projectRoot == "" { - projectRoot = "." - } - return &CodexCLI{homeDir: homeDir, projectRoot: projectRoot} -} - -// Name implements Platform. -func (c *CodexCLI) Name() Type { return TypeCodexCLI } - -// Detect implements Platform. -func (c *CodexCLI) Detect() bool { - // Primary: ~/.agents/ - if _, err := os.Stat(filepath.Join(c.homeDir, ".agents")); err == nil { - return true - } - // Fallback: ~/.codex/ - _, err := os.Stat(filepath.Join(c.homeDir, ".codex")) - return err == nil -} - -// UserSkillsDir implements Platform. -func (c *CodexCLI) UserSkillsDir() string { - return filepath.Join(c.homeDir, ".agents", "skills") -} - -// ProjectSkillsDir implements Platform. -func (c *CodexCLI) ProjectSkillsDir() string { - return filepath.Join(c.projectRoot, ".agents", "skills") -} - -// Install implements Platform. -func (c *CodexCLI) Install(skillDir string, skillName string, scope skill.Scope) error { - return installSkill(skillDir, skillName, c.skillsDir(scope)) -} - -// Uninstall implements Platform. -func (c *CodexCLI) Uninstall(skillName string, scope skill.Scope) error { - return uninstallSkill(skillName, c.skillsDir(scope)) -} - -// InstalledSkills implements Platform. -func (c *CodexCLI) InstalledSkills(scope skill.Scope) ([]string, error) { - return listInstalledSkills(c.skillsDir(scope)) -} - -func (c *CodexCLI) skillsDir(scope skill.Scope) string { - if scope == skill.ScopeProject { - return c.ProjectSkillsDir() - } - return c.UserSkillsDir() -} diff --git a/internal/platform/detector.go b/internal/platform/detector.go index 60bd227..f0fe40b 100644 --- a/internal/platform/detector.go +++ b/internal/platform/detector.go @@ -11,17 +11,17 @@ type Detector struct { platforms []Platform } -// NewDetector creates a Detector initialized with all known platform adapters using real paths. +// NewDetector creates a Detector initialized with one Adapter per registered +// Spec. Adding a platform never requires touching this function. func NewDetector() (*Detector, error) { home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("determining home directory: %w", err) } - platforms := []Platform{ - NewClaudeCode(home, "."), - NewCodexCLI(home, "."), - NewOpenCode(home, "."), + platforms := make([]Platform, 0, len(Specs)) + for _, s := range Specs { + platforms = append(platforms, New(s.Name, home, ".")) } return &Detector{platforms: platforms}, nil @@ -64,12 +64,22 @@ func (d *Detector) All() []Platform { // running skern is expected to specify its own platform explicitly. func ParsePlatformType(s string) (Type, error) { normalized := strings.ToLower(strings.TrimSpace(s)) - switch Type(normalized) { - case TypeClaudeCode, TypeCodexCLI, TypeOpenCode: + if normalized == "all" { + return "", fmt.Errorf("platform %q is no longer supported; specify a single platform (one of: %s) — agents should target the platform they are running on", s, supportedNamesList()) + } + if SpecFor(Type(normalized)) != nil { return Type(normalized), nil } - if normalized == "all" { - return "", fmt.Errorf("platform %q is no longer supported; specify a single platform (claude-code, codex-cli, or opencode) — agents should target the platform they are running on", s) + return "", fmt.Errorf("unknown platform %q: must be one of %s", s, supportedNamesList()) +} + +// supportedNamesList returns a comma-separated list of registered platform +// names for use in user-facing error messages. +func supportedNamesList() string { + names := SupportedNames() + parts := make([]string, len(names)) + for i, n := range names { + parts[i] = string(n) } - return "", fmt.Errorf("unknown platform %q: must be one of claude-code, codex-cli, opencode", s) + return strings.Join(parts, ", ") } diff --git a/internal/platform/opencode.go b/internal/platform/opencode.go deleted file mode 100644 index cf073a3..0000000 --- a/internal/platform/opencode.go +++ /dev/null @@ -1,67 +0,0 @@ -package platform - -import ( - "os" - "path/filepath" - - "github.com/devrimcavusoglu/skern/internal/skill" -) - -// OpenCode is the platform adapter for OpenCode. -type OpenCode struct { - homeDir string - projectRoot string -} - -// NewOpenCode creates an OpenCode adapter. -// Empty strings use default paths (home directory, current directory). -func NewOpenCode(homeDir, projectRoot string) *OpenCode { - if homeDir == "" { - homeDir, _ = os.UserHomeDir() - } - if projectRoot == "" { - projectRoot = "." - } - return &OpenCode{homeDir: homeDir, projectRoot: projectRoot} -} - -// Name implements Platform. -func (o *OpenCode) Name() Type { return TypeOpenCode } - -// Detect implements Platform. -func (o *OpenCode) Detect() bool { - _, err := os.Stat(filepath.Join(o.homeDir, ".config", "opencode")) - return err == nil -} - -// UserSkillsDir implements Platform. -func (o *OpenCode) UserSkillsDir() string { - return filepath.Join(o.homeDir, ".config", "opencode", "skills") -} - -// ProjectSkillsDir implements Platform. -func (o *OpenCode) ProjectSkillsDir() string { - return filepath.Join(o.projectRoot, ".opencode", "skills") -} - -// Install implements Platform. -func (o *OpenCode) Install(skillDir string, skillName string, scope skill.Scope) error { - return installSkill(skillDir, skillName, o.skillsDir(scope)) -} - -// Uninstall implements Platform. -func (o *OpenCode) Uninstall(skillName string, scope skill.Scope) error { - return uninstallSkill(skillName, o.skillsDir(scope)) -} - -// InstalledSkills implements Platform. -func (o *OpenCode) InstalledSkills(scope skill.Scope) ([]string, error) { - return listInstalledSkills(o.skillsDir(scope)) -} - -func (o *OpenCode) skillsDir(scope skill.Scope) string { - if scope == skill.ScopeProject { - return o.ProjectSkillsDir() - } - return o.UserSkillsDir() -} diff --git a/internal/platform/platform.go b/internal/platform/platform.go index bd7eafd..2e48eaf 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -8,11 +8,17 @@ import ( // Type identifies a supported platform. type Type string -// Platform type constants. +// Platform type constants. Adding a new platform also requires an entry in +// Specs (see spec.go). const ( - TypeClaudeCode Type = "claude-code" - TypeCodexCLI Type = "codex-cli" - TypeOpenCode Type = "opencode" + TypeClaudeCode Type = "claude-code" + TypeCodexCLI Type = "codex-cli" + TypeOpenCode Type = "opencode" + TypeCursor Type = "cursor" + TypeGeminiCLI Type = "gemini-cli" + TypeGitHubCopilot Type = "github-copilot" + TypeWindsurf Type = "windsurf" + TypeContinue Type = "continue" ) // Platform defines the interface that each platform adapter must implement. diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index 649aeab..a12b7a2 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -20,259 +20,198 @@ func createSkillDir(t *testing.T, baseDir, name string) string { return dir } -// --- ClaudeCode adapter --- - -func TestClaudeCode_Name(t *testing.T) { - c := NewClaudeCode("/home/test", "/project") - assert.Equal(t, TypeClaudeCode, c.Name()) -} - -func TestClaudeCode_Detect_Positive(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) - - c := NewClaudeCode(home, t.TempDir()) - assert.True(t, c.Detect()) +// --- Spec registry --- + +func TestSpecsAreUnique(t *testing.T) { + seen := map[Type]bool{} + for _, s := range Specs { + assert.False(t, seen[s.Name], "duplicate spec for %q", s.Name) + seen[s.Name] = true + assert.NotEmpty(t, s.UserDir, "%q must have UserDir", s.Name) + assert.NotEmpty(t, s.ProjectDir, "%q must have ProjectDir", s.Name) + assert.NotEmpty(t, s.DetectHome, "%q must have at least one DetectHome path", s.Name) + } } -func TestClaudeCode_Detect_Negative(t *testing.T) { - home := t.TempDir() - c := NewClaudeCode(home, t.TempDir()) - assert.False(t, c.Detect()) +func TestSpecsCoverExpectedPlatforms(t *testing.T) { + // Acceptance criterion (#80): at least 5 new platforms beyond the + // original three. Lock the minimum here so future edits can't silently + // drop one. + expected := []Type{ + TypeClaudeCode, TypeCodexCLI, TypeOpenCode, + TypeCursor, TypeGeminiCLI, TypeGitHubCopilot, TypeWindsurf, TypeContinue, + } + for _, e := range expected { + assert.NotNil(t, SpecFor(e), "expected spec for %q to be registered", e) + } + assert.GreaterOrEqual(t, len(Specs), 8) } -func TestClaudeCode_Paths(t *testing.T) { - c := NewClaudeCode("/home/test", "/project") - assert.Equal(t, filepath.Join("/home/test", ".claude", "skills"), c.UserSkillsDir()) - assert.Equal(t, filepath.Join("/project", ".claude", "skills"), c.ProjectSkillsDir()) +func TestSpecFor_Unknown(t *testing.T) { + assert.Nil(t, SpecFor(Type("does-not-exist"))) } -func TestClaudeCode_Install(t *testing.T) { - home := t.TempDir() - project := t.TempDir() - registry := t.TempDir() - - skillDir := createSkillDir(t, registry, "my-skill") - - c := NewClaudeCode(home, project) - require.NoError(t, c.Install(skillDir, "my-skill", skill.ScopeUser)) - - // Verify installed - installed := filepath.Join(home, ".claude", "skills", "my-skill", "SKILL.md") - _, err := os.Stat(installed) - require.NoError(t, err) -} - -func TestClaudeCode_Install_Duplicate(t *testing.T) { - home := t.TempDir() - registry := t.TempDir() - - skillDir := createSkillDir(t, registry, "my-skill") - - c := NewClaudeCode(home, t.TempDir()) - require.NoError(t, c.Install(skillDir, "my-skill", skill.ScopeUser)) - - err := c.Install(skillDir, "my-skill", skill.ScopeUser) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already installed") -} - -func TestClaudeCode_Uninstall(t *testing.T) { - home := t.TempDir() - registry := t.TempDir() - - skillDir := createSkillDir(t, registry, "my-skill") - - c := NewClaudeCode(home, t.TempDir()) - require.NoError(t, c.Install(skillDir, "my-skill", skill.ScopeUser)) - require.NoError(t, c.Uninstall("my-skill", skill.ScopeUser)) - - // Verify removed - installed := filepath.Join(home, ".claude", "skills", "my-skill") - _, err := os.Stat(installed) - assert.True(t, os.IsNotExist(err)) +func TestSupportedNames(t *testing.T) { + names := SupportedNames() + assert.Equal(t, len(Specs), len(names)) + for i, s := range Specs { + assert.Equal(t, s.Name, names[i], "SupportedNames must preserve declaration order") + } } -func TestClaudeCode_Uninstall_NotFound(t *testing.T) { - home := t.TempDir() - c := NewClaudeCode(home, t.TempDir()) +// --- Generic adapter (one row per spec) --- - err := c.Uninstall("nonexistent", skill.ScopeUser) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not installed") +func TestAdapter_NamesAndPaths(t *testing.T) { + for _, s := range Specs { + t.Run(string(s.Name), func(t *testing.T) { + a := New(s.Name, "/home/test", "/project") + require.NotNil(t, a) + assert.Equal(t, s.Name, a.Name()) + assert.Equal(t, filepath.Join("/home/test", s.UserDir), a.UserSkillsDir()) + assert.Equal(t, filepath.Join("/project", s.ProjectDir), a.ProjectSkillsDir()) + }) + } } -func TestClaudeCode_InstalledSkills(t *testing.T) { - home := t.TempDir() - registry := t.TempDir() - - skillDir1 := createSkillDir(t, registry, "skill-a") - skillDir2 := createSkillDir(t, registry, "skill-b") - - c := NewClaudeCode(home, t.TempDir()) - require.NoError(t, c.Install(skillDir1, "skill-a", skill.ScopeUser)) - require.NoError(t, c.Install(skillDir2, "skill-b", skill.ScopeUser)) - - installed, err := c.InstalledSkills(skill.ScopeUser) - require.NoError(t, err) - assert.Len(t, installed, 2) - assert.Contains(t, installed, "skill-a") - assert.Contains(t, installed, "skill-b") +func TestAdapter_New_UnknownReturnsNil(t *testing.T) { + assert.Nil(t, New(Type("nope"), "/h", "/p")) } -func TestClaudeCode_InstalledSkills_Empty(t *testing.T) { - home := t.TempDir() - c := NewClaudeCode(home, t.TempDir()) - - installed, err := c.InstalledSkills(skill.ScopeUser) - require.NoError(t, err) - assert.Empty(t, installed) +func TestAdapter_New_DefaultsResolveHomeAndCwd(t *testing.T) { + a := New(TypeClaudeCode, "", "") + require.NotNil(t, a) + // Must produce a non-empty path; exact value depends on the test runner's + // home directory. + assert.NotEmpty(t, a.UserSkillsDir()) + assert.NotEmpty(t, a.ProjectSkillsDir()) } -// --- CodexCLI adapter --- - -func TestCodexCLI_Name(t *testing.T) { - c := NewCodexCLI("/home/test", "/project") - assert.Equal(t, TypeCodexCLI, c.Name()) +func TestAdapter_Detect(t *testing.T) { + for _, s := range Specs { + t.Run(string(s.Name)+"_positive", func(t *testing.T) { + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, s.DetectHome[0]), 0o755)) + a := New(s.Name, home, t.TempDir()) + assert.True(t, a.Detect()) + }) + t.Run(string(s.Name)+"_negative", func(t *testing.T) { + home := t.TempDir() + a := New(s.Name, home, t.TempDir()) + assert.False(t, a.Detect()) + }) + } } -func TestCodexCLI_Detect_Agents(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(home, ".agents"), 0o755)) - - c := NewCodexCLI(home, t.TempDir()) - assert.True(t, c.Detect()) +// TestAdapter_Detect_FallbackPaths ensures every entry in DetectHome +// independently triggers detection — important for codex (~/.codex or +// ~/.agents) and windsurf (~/.codeium/windsurf or ~/.windsurf). +func TestAdapter_Detect_FallbackPaths(t *testing.T) { + for _, s := range Specs { + if len(s.DetectHome) <= 1 { + continue + } + for i, p := range s.DetectHome { + t.Run(string(s.Name)+"_path"+string(rune('0'+i)), func(t *testing.T) { + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, p), 0o755)) + a := New(s.Name, home, t.TempDir()) + assert.True(t, a.Detect(), "path %q should trigger detection", p) + }) + } + } } -func TestCodexCLI_Detect_CodexFallback(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(home, ".codex"), 0o755)) +// TestAdapter_RoundTrip verifies install/list/uninstall lifecycle for every +// platform via the spec table — replaces the per-platform install/uninstall +// tests that used to live in claude.go/codex.go/opencode.go. +func TestAdapter_RoundTrip(t *testing.T) { + for _, s := range Specs { + t.Run(string(s.Name), func(t *testing.T) { + home := t.TempDir() + project := t.TempDir() + registry := t.TempDir() - c := NewCodexCLI(home, t.TempDir()) - assert.True(t, c.Detect()) -} + skillDir := createSkillDir(t, registry, "round-trip") -func TestCodexCLI_Detect_Negative(t *testing.T) { - home := t.TempDir() - c := NewCodexCLI(home, t.TempDir()) - assert.False(t, c.Detect()) -} + a := New(s.Name, home, project) -func TestCodexCLI_Paths(t *testing.T) { - c := NewCodexCLI("/home/test", "/project") - assert.Equal(t, filepath.Join("/home/test", ".agents", "skills"), c.UserSkillsDir()) - assert.Equal(t, filepath.Join("/project", ".agents", "skills"), c.ProjectSkillsDir()) -} + // User scope + require.NoError(t, a.Install(skillDir, "round-trip", skill.ScopeUser)) + userInstalled := filepath.Join(home, s.UserDir, "round-trip", "SKILL.md") + _, err := os.Stat(userInstalled) + require.NoError(t, err, "expected SKILL.md at %s", userInstalled) -func TestCodexCLI_Install(t *testing.T) { - home := t.TempDir() - registry := t.TempDir() + names, err := a.InstalledSkills(skill.ScopeUser) + require.NoError(t, err) + assert.Contains(t, names, "round-trip") - skillDir := createSkillDir(t, registry, "my-skill") + require.NoError(t, a.Uninstall("round-trip", skill.ScopeUser)) + _, err = os.Stat(filepath.Join(home, s.UserDir, "round-trip")) + assert.True(t, os.IsNotExist(err)) - c := NewCodexCLI(home, t.TempDir()) - require.NoError(t, c.Install(skillDir, "my-skill", skill.ScopeUser)) + // Project scope + require.NoError(t, a.Install(skillDir, "round-trip", skill.ScopeProject)) + projectInstalled := filepath.Join(project, s.ProjectDir, "round-trip", "SKILL.md") + _, err = os.Stat(projectInstalled) + require.NoError(t, err, "expected SKILL.md at %s", projectInstalled) - installed := filepath.Join(home, ".agents", "skills", "my-skill", "SKILL.md") - _, err := os.Stat(installed) - require.NoError(t, err) + require.NoError(t, a.Uninstall("round-trip", skill.ScopeProject)) + }) + } } -func TestCodexCLI_Uninstall(t *testing.T) { +func TestAdapter_Install_Duplicate(t *testing.T) { home := t.TempDir() registry := t.TempDir() + skillDir := createSkillDir(t, registry, "dup") - skillDir := createSkillDir(t, registry, "my-skill") - - c := NewCodexCLI(home, t.TempDir()) - require.NoError(t, c.Install(skillDir, "my-skill", skill.ScopeUser)) - require.NoError(t, c.Uninstall("my-skill", skill.ScopeUser)) - - _, err := os.Stat(filepath.Join(home, ".agents", "skills", "my-skill")) - assert.True(t, os.IsNotExist(err)) -} - -// --- OpenCode adapter --- - -func TestOpenCode_Name(t *testing.T) { - o := NewOpenCode("/home/test", "/project") - assert.Equal(t, TypeOpenCode, o.Name()) -} - -func TestOpenCode_Detect_Positive(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "opencode"), 0o755)) + a := New(TypeClaudeCode, home, t.TempDir()) + require.NoError(t, a.Install(skillDir, "dup", skill.ScopeUser)) - o := NewOpenCode(home, t.TempDir()) - assert.True(t, o.Detect()) -} - -func TestOpenCode_Detect_Negative(t *testing.T) { - home := t.TempDir() - o := NewOpenCode(home, t.TempDir()) - assert.False(t, o.Detect()) + err := a.Install(skillDir, "dup", skill.ScopeUser) + require.Error(t, err) + assert.Contains(t, err.Error(), "already installed") } -func TestOpenCode_Paths(t *testing.T) { - o := NewOpenCode("/home/test", "/project") - assert.Equal(t, filepath.Join("/home/test", ".config", "opencode", "skills"), o.UserSkillsDir()) - assert.Equal(t, filepath.Join("/project", ".opencode", "skills"), o.ProjectSkillsDir()) +func TestAdapter_Uninstall_NotFound(t *testing.T) { + a := New(TypeClaudeCode, t.TempDir(), t.TempDir()) + err := a.Uninstall("ghost", skill.ScopeUser) + require.Error(t, err) + assert.Contains(t, err.Error(), "not installed") } -func TestOpenCode_Install(t *testing.T) { - home := t.TempDir() - registry := t.TempDir() - - skillDir := createSkillDir(t, registry, "my-skill") - - o := NewOpenCode(home, t.TempDir()) - require.NoError(t, o.Install(skillDir, "my-skill", skill.ScopeUser)) - - installed := filepath.Join(home, ".config", "opencode", "skills", "my-skill", "SKILL.md") - _, err := os.Stat(installed) +func TestAdapter_InstalledSkills_Empty(t *testing.T) { + a := New(TypeClaudeCode, t.TempDir(), t.TempDir()) + names, err := a.InstalledSkills(skill.ScopeUser) require.NoError(t, err) + assert.Empty(t, names) } -func TestOpenCode_Uninstall(t *testing.T) { - home := t.TempDir() - registry := t.TempDir() - - skillDir := createSkillDir(t, registry, "my-skill") - - o := NewOpenCode(home, t.TempDir()) - require.NoError(t, o.Install(skillDir, "my-skill", skill.ScopeUser)) - require.NoError(t, o.Uninstall("my-skill", skill.ScopeUser)) - - _, err := os.Stat(filepath.Join(home, ".config", "opencode", "skills", "my-skill")) - assert.True(t, os.IsNotExist(err)) -} - -// --- Project scope --- - -func TestClaudeCode_ProjectScope(t *testing.T) { +// TestAdapter_SharedProjectDir captures the design decision from #80: when +// several platforms share .agents/skills/ as their project dir, a skill +// installed via one of them is visible to the others. Capacity reporting is +// per-directory by design. +func TestAdapter_SharedProjectDir(t *testing.T) { home := t.TempDir() project := t.TempDir() registry := t.TempDir() + skillDir := createSkillDir(t, registry, "shared") - skillDir := createSkillDir(t, registry, "proj-skill") + cursor := New(TypeCursor, home, project) + gemini := New(TypeGeminiCLI, home, project) + require.Equal(t, cursor.ProjectSkillsDir(), gemini.ProjectSkillsDir(), + "sanity: cursor and gemini-cli should share .agents/skills/") - c := NewClaudeCode(home, project) - require.NoError(t, c.Install(skillDir, "proj-skill", skill.ScopeProject)) + require.NoError(t, cursor.Install(skillDir, "shared", skill.ScopeProject)) - installed := filepath.Join(project, ".claude", "skills", "proj-skill", "SKILL.md") - _, err := os.Stat(installed) + // Both adapters report the skill because they read the same directory. + cursorList, err := cursor.InstalledSkills(skill.ScopeProject) require.NoError(t, err) + assert.Contains(t, cursorList, "shared") - // User scope should be empty - userInstalled, err := c.InstalledSkills(skill.ScopeUser) + geminiList, err := gemini.InstalledSkills(skill.ScopeProject) require.NoError(t, err) - assert.Empty(t, userInstalled) - - // Project scope should have the skill - projectInstalled, err := c.InstalledSkills(skill.ScopeProject) - require.NoError(t, err) - assert.Len(t, projectInstalled, 1) - assert.Equal(t, "proj-skill", projectInstalled[0]) + assert.Contains(t, geminiList, "shared") } // --- Detector --- @@ -284,20 +223,20 @@ func TestDetector_DetectAll(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) det := NewDetectorWithPlatforms([]Platform{ - NewClaudeCode(home, t.TempDir()), - NewCodexCLI(home, t.TempDir()), - NewOpenCode(home, t.TempDir()), + New(TypeClaudeCode, home, t.TempDir()), + New(TypeCodexCLI, home, t.TempDir()), + New(TypeOpenCode, home, t.TempDir()), }) detected := det.DetectAll() - assert.Len(t, detected, 1) + require.Len(t, detected, 1) assert.Equal(t, TypeClaudeCode, detected[0].Name()) } func TestDetector_Get(t *testing.T) { det := NewDetectorWithPlatforms([]Platform{ - NewClaudeCode(t.TempDir(), t.TempDir()), - NewCodexCLI(t.TempDir(), t.TempDir()), + New(TypeClaudeCode, t.TempDir(), t.TempDir()), + New(TypeCodexCLI, t.TempDir(), t.TempDir()), }) p := det.Get(TypeCodexCLI) @@ -307,22 +246,17 @@ func TestDetector_Get(t *testing.T) { func TestDetector_Get_NotFound(t *testing.T) { det := NewDetectorWithPlatforms([]Platform{ - NewClaudeCode(t.TempDir(), t.TempDir()), + New(TypeClaudeCode, t.TempDir(), t.TempDir()), }) p := det.Get(TypeOpenCode) assert.Nil(t, p) } -func TestDetector_All(t *testing.T) { - det := NewDetectorWithPlatforms([]Platform{ - NewClaudeCode(t.TempDir(), t.TempDir()), - NewCodexCLI(t.TempDir(), t.TempDir()), - NewOpenCode(t.TempDir(), t.TempDir()), - }) - - all := det.All() - assert.Len(t, all, 3) +func TestDetector_All_DefaultIncludesEverySpec(t *testing.T) { + det, err := NewDetector() + require.NoError(t, err) + assert.Len(t, det.All(), len(Specs)) } // --- ParsePlatformType --- @@ -337,19 +271,24 @@ func TestParsePlatformType(t *testing.T) { {"codex-cli", TypeCodexCLI, false}, {"opencode", TypeOpenCode, false}, {"Claude-Code", TypeClaudeCode, false}, + // New platforms — must round-trip through the parser. + {"cursor", TypeCursor, false}, + {"gemini-cli", TypeGeminiCLI, false}, + {"github-copilot", TypeGitHubCopilot, false}, + {"windsurf", TypeWindsurf, false}, + {"continue", TypeContinue, false}, // "all" is rejected per #52 D6 — agents must specify their own platform. {"all", "", true}, {"ALL", "", true}, {"", "", true}, {"unknown", "", true}, - {"github-copilot", "", true}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got, err := ParsePlatformType(tt.input) if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tt.want, got) @@ -358,6 +297,16 @@ func TestParsePlatformType(t *testing.T) { } } +func TestParsePlatformType_ErrorListsRegisteredPlatforms(t *testing.T) { + _, err := ParsePlatformType("nope") + require.Error(t, err) + // Error message must enumerate every supported platform so users have a + // recovery path without needing to consult the docs. + for _, n := range SupportedNames() { + assert.Contains(t, err.Error(), string(n)) + } +} + // --- Integration: full lifecycle --- func TestFullLifecycle(t *testing.T) { @@ -369,8 +318,8 @@ func TestFullLifecycle(t *testing.T) { skillDir := createSkillDir(t, registry, "lifecycle-skill") // Create adapters - claude := NewClaudeCode(home, project) - codex := NewCodexCLI(home, project) + claude := New(TypeClaudeCode, home, project) + codex := New(TypeCodexCLI, home, project) // Install to Claude Code require.NoError(t, claude.Install(skillDir, "lifecycle-skill", skill.ScopeUser)) diff --git a/internal/platform/spec.go b/internal/platform/spec.go new file mode 100644 index 0000000..b6dc880 --- /dev/null +++ b/internal/platform/spec.go @@ -0,0 +1,97 @@ +package platform + +// Spec is the declarative configuration for one platform adapter. Adding a +// new supported platform is a one-line PR — append to Specs below. +// +// Path conventions follow vercel-labs/skills (https://github.com/vercel-labs/skills#supported-agents), +// the closest thing to a community standard for Agent Skills directory layout. +// +// UserDir is rooted at the user's home directory; ProjectDir at the project +// root. DetectHome lists candidate paths under home — if any of them exists, +// the platform is considered installed. Per-platform detection paths matter +// because several platforms share .agents/skills/ as their project dir but +// each carries a distinct user-level config dir (e.g. ~/.cursor, ~/.gemini, +// ~/.copilot) that disambiguates them. +type Spec struct { + Name Type + UserDir string + ProjectDir string + DetectHome []string +} + +// Specs is the source of truth for supported platforms. Each entry is mapped +// to a generic Adapter at runtime; no per-platform Go file is required. +// +// The first three entries (claude-code, codex-cli, opencode) keep the paths +// skern shipped originally — the codex-cli UserDir intentionally stays at +// ~/.agents/skills/ rather than vercel's ~/.codex/skills/ so existing skern +// users see no disk-layout change. +var Specs = []Spec{ + { + Name: TypeClaudeCode, + UserDir: ".claude/skills", + ProjectDir: ".claude/skills", + DetectHome: []string{".claude"}, + }, + { + Name: TypeCodexCLI, + UserDir: ".agents/skills", + ProjectDir: ".agents/skills", + DetectHome: []string{".codex", ".agents"}, + }, + { + Name: TypeOpenCode, + UserDir: ".config/opencode/skills", + ProjectDir: ".opencode/skills", + DetectHome: []string{".config/opencode"}, + }, + { + Name: TypeCursor, + UserDir: ".cursor/skills", + ProjectDir: ".agents/skills", + DetectHome: []string{".cursor"}, + }, + { + Name: TypeGeminiCLI, + UserDir: ".gemini/skills", + ProjectDir: ".agents/skills", + DetectHome: []string{".gemini"}, + }, + { + Name: TypeGitHubCopilot, + UserDir: ".copilot/skills", + ProjectDir: ".agents/skills", + DetectHome: []string{".copilot"}, + }, + { + Name: TypeWindsurf, + UserDir: ".codeium/windsurf/skills", + ProjectDir: ".windsurf/skills", + DetectHome: []string{".codeium/windsurf", ".windsurf"}, + }, + { + Name: TypeContinue, + UserDir: ".continue/skills", + ProjectDir: ".continue/skills", + DetectHome: []string{".continue"}, + }, +} + +// SpecFor returns the Spec registered under name, or nil if unknown. +func SpecFor(name Type) *Spec { + for i := range Specs { + if Specs[i].Name == name { + return &Specs[i] + } + } + return nil +} + +// SupportedNames returns the registered platform names in declaration order. +func SupportedNames() []Type { + out := make([]Type, len(Specs)) + for i, s := range Specs { + out[i] = s.Name + } + return out +}