Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
141 changes: 141 additions & 0 deletions cmd/skill.go
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 65 additions & 0 deletions cmd/skill_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
14 changes: 14 additions & 0 deletions docs/llm/skill.md
Original file line number Diff line number Diff line change
@@ -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 <target> 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).
Loading