diff --git a/README.md b/README.md index d069c62..6b08213 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ clime skills sync # restore skills from the saved manifest clime skills uninstall my-skill ``` -Skills live in `~/.agents/skills//`, with links in `~/.claude/skills/` when `~/.claude` exists. Source repositories can store skills under `skills/`, `.agents/skills/`, or `.claude/skills/`, with a `SKILL.md` in each skill directory. Explicit catalog manifests take precedence; otherwise, clime uses the first directory containing skills in that order. +Manifests under `~/.clime/` install skills globally into `~/.agents/skills//`, with links in `~/.claude/skills/` when `~/.claude` exists. Other manifests use project scope: `.agents/skills//` beside the manifest, with links in `.claude/skills/`. Project installs create both directories as needed. Install, update, sync, and uninstall all use this scope. + +Source repositories can store skills under `skills/`, `.agents/skills/`, or `.claude/skills/`, with a `SKILL.md` in each skill directory. Explicit catalog manifests take precedence; otherwise, clime uses the first directory containing skills in that order. To teach agents how to use clime, install its bundled skill with `clime install skill`. diff --git a/cmd/skill.go b/cmd/skill.go index dafb620..e404693 100644 --- a/cmd/skill.go +++ b/cmd/skill.go @@ -31,7 +31,7 @@ var installSkillCmd = &cobra.Command{ Use: "skill", Short: "Install the clime-cli skill into ~/.agents/skills with a Claude symlink", RunE: func(cmd *cobra.Command, args []string) error { - targets, err := skill.Targets() + targets, err := skill.Targets("") if err != nil { return err } diff --git a/cmd/skills.go b/cmd/skills.go index fe9591f..47f1525 100644 --- a/cmd/skills.go +++ b/cmd/skills.go @@ -69,8 +69,10 @@ func init() { var skillsCmd = &cobra.Command{ Use: "skills", Short: "Manage AI agent skills from GitHub repositories or local paths", - Long: "Install skills from GitHub repositories or local paths into ~/.agents/skills. " + - "When ~/.claude exists, Claude Code gets symlinks in ~/.claude/skills.", + Long: "Install skills from GitHub repositories or local paths. Manifests under " + + "~/.clime install globally into ~/.agents/skills; other manifests install into " + + ".agents/skills beside the manifest, with Claude Code symlinks in .claude/skills. " + + "Global installs add Claude Code symlinks only when ~/.claude exists.", RunE: skillsListCmd.RunE, } @@ -302,7 +304,7 @@ func newSkillsManager(manifest *skill.Manifest) (*skill.Manager, error) { if err != nil { return nil, err } - targets, err := skill.DetectTargets() + targets, err := manifest.DetectTargets() if err != nil { return nil, err } @@ -682,7 +684,7 @@ func uninstallByName(manifest *skill.Manifest, name string) error { if _, exists := manifest.GetSkill(name); !exists { return fmt.Errorf("skill %q is not installed", name) } - targets, err := skill.DetectTargets() + targets, err := manifest.DetectTargets() if err != nil { return err } diff --git a/cmd/skills_scope_test.go b/cmd/skills_scope_test.go new file mode 100644 index 0000000..94b4890 --- /dev/null +++ b/cmd/skills_scope_test.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/git-hulk/clime/internal/prompt" + "github.com/git-hulk/clime/internal/skill" + "github.com/stretchr/testify/require" +) + +func TestSkillsCommandsUseManifestScope(t *testing.T) { + for _, scope := range []string{"default", "global", "nested global", "relative global", "project", "relative project", "similar prefix"} { + t.Run(scope, func(t *testing.T) { + home := t.TempDir() + project := filepath.Join(home, "project") + if scope == "similar prefix" { + project = filepath.Join(home, ".clime-project") + } + t.Setenv("HOME", home) + t.Chdir(home) + defer stubSkillPrompts(t)() + t.Cleanup(func() { + rootCmd.SetArgs(nil) + skillsManifestPath = "" + skillsCmd.PersistentFlags().Lookup("manifest").Changed = false + }) + multiSelectPrompt = func(prompt.SelectConfig) ([]int, error) { + return []int{0}, nil + } + + path := "" + baseDir, untouchedDir := home, project + switch scope { + case "global": + path = filepath.Join(home, ".clime", "skills.yaml") + case "nested global": + path = filepath.Join(home, ".clime", "team", "skills.yaml") + case "relative global": + path = filepath.Join(".clime", "skills.yaml") + case "project", "relative project", "similar prefix": + path = filepath.Join(project, "skills.yaml") + baseDir, untouchedDir = project, home + if scope == "relative project" { + path = filepath.Join("project", "skills.yaml") + } + } + if baseDir == home { + require.NoError(t, os.MkdirAll(filepath.Join(baseDir, ".claude"), 0o755)) + } + for _, target := range []string{".agents", ".claude"} { + dir := filepath.Join(untouchedDir, target, "skills", "alpha") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# Untouched"), 0o644)) + } + + sourceDir := t.TempDir() + sourceSkill := filepath.Join(sourceDir, "skills", "alpha", "SKILL.md") + require.NoError(t, os.MkdirAll(filepath.Dir(sourceSkill), 0o755)) + for _, command := range []string{"install", "sync", "update", "uninstall"} { + content := "# " + command + require.NoError(t, os.WriteFile(sourceSkill, []byte(content), 0o644)) + args := []string{"skills", command} + if command == "install" { + args = append(args, sourceDir) + } else if command == "uninstall" { + args = append(args, "alpha") + } + if path != "" { + args = append(args, "--manifest", path) + } + rootCmd.SetArgs(args) + captureStdout(t, func() { + require.NoError(t, rootCmd.Execute()) + }) + + for _, target := range []string{".agents", ".claude"} { + dir := filepath.Join(baseDir, target, "skills", "alpha") + if command == "uninstall" { + _, err := os.Lstat(dir) + require.ErrorIs(t, err, os.ErrNotExist) + } else { + data, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) + require.NoError(t, err) + require.Equal(t, content, string(data)) + if target == ".claude" { + link, err := os.Readlink(dir) + require.NoError(t, err) + require.Equal(t, filepath.Join(baseDir, ".agents", "skills", "alpha"), link) + } + } + data, err := os.ReadFile(filepath.Join(untouchedDir, target, "skills", "alpha", "SKILL.md")) + require.NoError(t, err) + require.Equal(t, "# Untouched", string(data)) + } + } + manifest, err := skill.LoadManifest(path) + require.NoError(t, err) + require.Empty(t, manifest.Skills) + }) + } +} diff --git a/internal/skill/manager.go b/internal/skill/manager.go index 0518482..0e5bf66 100644 --- a/internal/skill/manager.go +++ b/internal/skill/manager.go @@ -81,7 +81,7 @@ func Open(events Events) (*Manager, error) { if err != nil { return nil, err } - targets, err := DetectTargets() + targets, err := manifest.DetectTargets() if err != nil { return nil, err } diff --git a/internal/skill/manager_test.go b/internal/skill/manager_test.go index 4288e64..a460e77 100644 --- a/internal/skill/manager_test.go +++ b/internal/skill/manager_test.go @@ -51,7 +51,7 @@ func newTestManager(t *testing.T, manifest *Manifest) (*Manager, string) { home := t.TempDir() t.Setenv("HOME", home) require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) - targets, err := DetectTargets() + targets, err := manifest.DetectTargets() require.NoError(t, err) return &Manager{ Manifest: manifest, diff --git a/internal/skill/target.go b/internal/skill/target.go index a7218a5..6f16969 100644 --- a/internal/skill/target.go +++ b/internal/skill/target.go @@ -4,9 +4,10 @@ import ( "fmt" "os" "path/filepath" + "strings" ) -// Target is a destination for installed skills: ~/.agents or ~/.claude. +// Target is a global or project destination for installed skills. type Target struct { Name string Dir string @@ -18,30 +19,51 @@ var targetHomes = []struct{ name, dir string }{ {"claude", ".claude"}, } -// Targets returns every known target, whether or not its base directory -// exists. -func Targets() ([]Target, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get home directory: %w", err) +// Targets returns every known target under baseDir, whether or not it exists. +// An empty baseDir defaults to the user's home directory. +func Targets(baseDir string) ([]Target, error) { + if baseDir == "" { + var err error + baseDir, err = os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } } targets := make([]Target, 0, len(targetHomes)) for _, target := range targetHomes { - targets = append(targets, Target{Name: target.name, Dir: filepath.Join(home, target.dir)}) + targets = append(targets, Target{Name: target.name, Dir: filepath.Join(baseDir, target.dir)}) } return targets, nil } -// DetectTargets always includes the shared skills target, followed by agent -// targets whose base directory exists. -func DetectTargets() ([]Target, error) { - all, err := Targets() +// DetectTargets uses global targets for manifests under ~/.clime, including +// the shared target and agent targets whose base directory exists. +// Other manifests include every target beside the manifest. +func (manifest *Manifest) DetectTargets() ([]Target, error) { + baseDir := "" + if manifest.path != "" { + defaultPath, err := manifestPath() + if err != nil { + return nil, fmt.Errorf("failed to determine manifest path: %w", err) + } + path, err := filepath.Abs(manifest.path) + if err != nil { + return nil, fmt.Errorf("failed to resolve manifest path: %w", err) + } + globalDir := filepath.Dir(defaultPath) + manifestDir := filepath.Dir(path) + if manifestDir != globalDir && !strings.HasPrefix(manifestDir, globalDir+string(filepath.Separator)) { + baseDir = manifestDir + } + } + + all, err := Targets(baseDir) if err != nil { return nil, err } var detected []Target for _, target := range all { - if target.Name == "agents" || target.Exists() { + if baseDir != "" || target.Name == "agents" || target.Exists() { detected = append(detected, target) } } diff --git a/internal/skill/target_test.go b/internal/skill/target_test.go index ab6e845..aba0cca 100644 --- a/internal/skill/target_test.go +++ b/internal/skill/target_test.go @@ -11,25 +11,36 @@ import ( func TestDetectTargets(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + manifest := &Manifest{} - targets, err := DetectTargets() + targets, err := manifest.DetectTargets() require.NoError(t, err) require.Len(t, targets, 1) require.Equal(t, "agents", targets[0].Name) require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) - targets, err = DetectTargets() + targets, err = manifest.DetectTargets() require.NoError(t, err) require.Len(t, targets, 2) require.Equal(t, "agents", targets[0].Name) require.Equal(t, "claude", targets[1].Name) require.NoError(t, os.MkdirAll(filepath.Join(home, ".codex"), 0o755)) - targets, err = DetectTargets() + targets, err = manifest.DetectTargets() require.NoError(t, err) require.Len(t, targets, 2) require.Equal(t, "agents", targets[0].Name) require.Equal(t, "claude", targets[1].Name) + + project := t.TempDir() + manifest, err = LoadManifest(filepath.Join(project, "skills.yaml")) + require.NoError(t, err) + targets, err = manifest.DetectTargets() + require.NoError(t, err) + require.Equal(t, []Target{ + {Name: "agents", Dir: filepath.Join(project, ".agents")}, + {Name: "claude", Dir: filepath.Join(project, ".claude")}, + }, targets) } func TestTargetInstallAndRemove(t *testing.T) {