diff --git a/AGENTS.md b/AGENTS.md index 45bf3e5..d14748b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Releases: push a `v*` tag; `.github/workflows/release.yml` runs goreleaser. User | [docs/llm/build.md](docs/llm/build.md) | `build` (Godot, Unity, Bevy exports) | | [docs/llm/test.md](docs/llm/test.md) | `test`: local server, FPS overlay, profiler | | [docs/llm/store.md](docs/llm/store.md) | `store check/push`, `store/page.json`, media rules | +| [docs/llm/skill.md](docs/llm/skill.md) | `skill install/update` (Claude Code skill) | | [docs/llm/publish.md](docs/llm/publish.md) | `publish`, zip checks, experimental chunks | | [docs/llm/llm-txt.md](docs/llm/llm-txt.md) | `vapour llm` fetch/cache/fallback design | | [docs/llm/release.md](docs/llm/release.md) | goreleaser, `install.sh`, CI, branch rules | diff --git a/README.md b/README.md index 62e7de7..3b2971f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,14 @@ vapour perf # optional: engine-specific web performance vapour publish --version 0.1.0 --notes "First release" ``` +### Claude Code skill + +```sh +vapour skill install # clones vapour-computer/vapour-skill into ~/.claude/skills/vapour +vapour skill install --project # or into ./.claude/skills/vapour +vapour skill update # git pull --ff-only +``` + ### Update `vapour-cli update` installs the latest release over the running binary (checksum verified). `--check` only reports, `--version v0.2.0` picks a release. Not available on Windows. diff --git a/cmd/root.go b/cmd/root.go index e1b14aa..2a252e1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -55,7 +55,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().StringVar(&apiFlag, "api", "", "API base URL (default: saved login, or "+config.DefaultAPI+")") root.PersistentFlags().BoolVar(&allowCustomAPI, "allow-custom-api", false, "allow non-default API hosts (unsafe: tokens are sent to that host)") root.PersistentFlags().BoolVar(&allowInsecureHTTP, "allow-insecure-http", false, "allow plain HTTP only for localhost") - root.AddCommand(newLogin(), newLogout(), newWhoami(), newCreate(), newTemplate(), newPublish(), newStore(), newBuild(), newTest(), newPerf(), newLLM(), newController(), newUpdate(), newScan(), newVerify()) + root.AddCommand(newLogin(), newLogout(), newWhoami(), newCreate(), newTemplate(), newPublish(), newStore(), newBuild(), newTest(), newPerf(), newLLM(), newController(), newUpdate(), newScan(), newVerify(), newSkill()) return root } diff --git a/cmd/skill.go b/cmd/skill.go new file mode 100644 index 0000000..ba49577 --- /dev/null +++ b/cmd/skill.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +const ( + defaultSkillRepo = "https://github.com/vapour-computer/vapour-skill.git" + skillDirName = "vapour" +) + +func newSkill() *cobra.Command { + cmd := &cobra.Command{ + Use: "skill", + Short: "Install the Vapour skill for Claude Code", + Long: "Install or update the Vapour skill, which teaches Claude Code how to build, test and publish games with this CLI.", + } + cmd.AddCommand(newSkillInstall(), newSkillUpdate()) + return cmd +} + +func newSkillInstall() *cobra.Command { + var project, force, allowUntrusted bool + var dest, repo string + cmd := &cobra.Command{ + Use: "install", + Short: "Clone the Vapour skill into your Claude skills folder", + Long: "Clone " + defaultSkillRepo + " into ~/.claude/skills/vapour (or $CLAUDE_CONFIG_DIR/skills/vapour).\n" + + "Use --project to install into ./.claude/skills/vapour for the current project only.\n" + + "The clone keeps its .git folder so `vapour skill update` can pull new versions.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + target, err := skillTarget(dest, project) + if err != nil { + return err + } + if err := installSkill(repo, target, force, allowUntrusted); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Installed the Vapour skill at %s\nRestart Claude Code (or start a new session) to load it.\n", target) + return nil + }, + } + cmd.Flags().BoolVar(&project, "project", false, "install into ./.claude/skills/vapour instead of your user folder") + cmd.Flags().StringVar(&dest, "dest", "", "install into this exact folder") + cmd.Flags().StringVar(&repo, "repo", defaultSkillRepo, "git URL of the skill repo") + cmd.Flags().BoolVar(&force, "force", false, "replace an existing install") + cmd.Flags().BoolVar(&allowUntrusted, "allow-untrusted-template", false, "allow cloning from outside the vapour-computer org") + return cmd +} + +func newSkillUpdate() *cobra.Command { + var project bool + var dest string + cmd := &cobra.Command{ + Use: "update", + Short: "Pull the latest Vapour skill", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + target, err := skillTarget(dest, project) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(target, ".git")); err != nil { + return fmt.Errorf("no git-based skill install at %s: run `vapour skill install`", target) + } + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required to update the skill") + } + c := exec.Command("git", "-C", target, "pull", "--ff-only") + c.Stdout = cmd.OutOrStdout() + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("git pull failed: %w", err) + } + return nil + }, + } + cmd.Flags().BoolVar(&project, "project", false, "update ./.claude/skills/vapour") + cmd.Flags().StringVar(&dest, "dest", "", "update the skill in this exact folder") + return cmd +} + +// skillTarget resolves where the skill lives: --dest, then ./.claude (--project), then the user's Claude config dir. +func skillTarget(dest string, project bool) (string, error) { + if dest != "" { + return filepath.Abs(dest) + } + if project { + wd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Join(wd, ".claude", "skills", skillDirName), nil + } + base := os.Getenv("CLAUDE_CONFIG_DIR") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".claude") + } + return filepath.Join(base, "skills", skillDirName), nil +} + +// installSkill shallow-clones repo into target and keeps .git so the install can be updated. +func installSkill(repo, target string, force, allowUntrusted bool) error { + if strings.HasPrefix(repo, "-") { + return fmt.Errorf("invalid git URL %q", repo) + } + if !allowUntrusted && !trustedTemplateURL(repo) { + return fmt.Errorf("untrusted skill source %q (pass --allow-untrusted-template to override)", repo) + } + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required to install the skill") + } + if _, err := os.Lstat(target); err == nil { + if !force { + return fmt.Errorf("%s already exists: use `vapour skill update`, or pass --force to replace it", target) + } + if err := os.RemoveAll(target); err != nil { + return err + } + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + c := exec.Command("git", "clone", "--depth", "1", "--", repo, target) + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + return nil +} diff --git a/cmd/skill_test.go b/cmd/skill_test.go new file mode 100644 index 0000000..3b36647 --- /dev/null +++ b/cmd/skill_test.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestSkillTarget(t *testing.T) { + t.Setenv("CLAUDE_CONFIG_DIR", "/cfg") + got, err := skillTarget("", false) + if err != nil || got != filepath.Join("/cfg", "skills", "vapour") { + t.Fatalf("user target = %q, %v", got, err) + } + dir := t.TempDir() + t.Chdir(dir) + got, err = skillTarget("", true) + if err != nil { + t.Fatal(err) + } + if want, _ := filepath.EvalSymlinks(filepath.Join(dir, ".claude", "skills", "vapour")); got != want && got != filepath.Join(dir, ".claude", "skills", "vapour") { + t.Fatalf("project target = %q", got) + } +} + +func TestInstallSkillClonesAndKeepsGit(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + src := filepath.Join(t.TempDir(), "src") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, src, "init") + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("---\nname: vapour\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, src, "add", ".") + runGit(t, src, "commit", "-m", "init") + + dst := filepath.Join(t.TempDir(), "skills", "vapour") + if err := installSkill(src, dst, false, false); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dst, ".git")); err != nil { + t.Fatalf("expected .git to be kept for updates: %v", err) + } + if err := installSkill(src, dst, false, false); err == nil { + t.Fatal("expected error when already installed") + } + if err := installSkill(src, dst, true, false); err != nil { + t.Fatalf("--force should replace: %v", err) + } +} + +func TestInstallSkillRejectsUntrustedSource(t *testing.T) { + err := installSkill("https://example.com/evil/skill.git", filepath.Join(t.TempDir(), "vapour"), false, false) + if err == nil { + t.Fatal("expected untrusted source error") + } +} diff --git a/docs/llm/skill.md b/docs/llm/skill.md new file mode 100644 index 0000000..05a6fab --- /dev/null +++ b/docs/llm/skill.md @@ -0,0 +1,14 @@ +# `vapour skill install|update` (`cmd/skill.go`) + +Installs the Vapour skill for Claude Code from `https://github.com/vapour-computer/vapour-skill.git`. It needs `git`; it does not call the Vapour API and needs no login. + +- `install`: shallow-clones (`--depth 1`) into the target and **keeps `.git`** so it can be updated. Fails if the target exists unless `--force` (which removes it first). +- `update`: `git -C pull --ff-only`. Errors if the target has no `.git` (run `install` first). + +Target resolution (both commands): `--dest DIR` > `--project` (`./.claude/skills/vapour`) > `$CLAUDE_CONFIG_DIR/skills/vapour` > `~/.claude/skills/vapour`. + +Flags: `--project`, `--dest`, and for `install` also `--repo` (default above), `--force`, `--allow-untrusted-template`. + +Source safety reuses `trustedTemplateURL` (same rule as `create --from`): only `vapour-computer/*` GitHub repos (or local paths) unless `--allow-untrusted-template`. URLs starting with `-` are rejected. + +Tests: `cmd/skill_test.go` (clone keeps `.git`, refuses overwrite, `--force`, untrusted source).