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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ clime skills sync # restore skills from the saved manifest
clime skills uninstall my-skill
```

Skills live in `~/.agents/skills/<name>/`, 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/<name>/`, with links in `~/.claude/skills/` when `~/.claude` exists. Other manifests use project scope: `.agents/skills/<name>/` 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`.

Expand Down
2 changes: 1 addition & 1 deletion cmd/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
10 changes: 6 additions & 4 deletions cmd/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
103 changes: 103 additions & 0 deletions cmd/skills_scope_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
2 changes: 1 addition & 1 deletion internal/skill/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/skill/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 35 additions & 13 deletions internal/skill/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
Expand Down
17 changes: 14 additions & 3 deletions internal/skill/target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading