From 439a4d177222efb0da40a56ff6a0eb9bae0c0323 Mon Sep 17 00:00:00 2001 From: git-hulk Date: Thu, 17 Sep 2026 15:52:34 +0800 Subject: [PATCH] feat(skills): select install scope from the manifest path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choose skill destinations from the selected manifest so project skills stay beside their configuration instead of being installed globally. Manifests under ~/.clime retain global targets. Other manifests use .agents/skills beside the manifest and create .claude/skills symlinks, even when the project has no existing .claude directory. Install, update, sync, and uninstall use the same scope. Global Claude detection and the manifest format are unchanged. Update the CLI help and README, and test relative paths, directory boundaries, scope isolation, and fresh-project Claude links. Verification: - go test ./... -v: passed - go vet ./...: passed - go build ./...: passed - Built CLI sync in a fresh project: shared files and Claude links created; global targets untouched. CLI output from the fresh-project sync: ```text ✓ Synced skill "alpha" to agents, claude ✓ Synced 1 skill(s) from 1 source(s). ``` Assistant-By GPT-6 --- README.md | 4 +- cmd/skill.go | 2 +- cmd/skills.go | 10 ++-- cmd/skills_scope_test.go | 103 +++++++++++++++++++++++++++++++++ internal/skill/manager.go | 2 +- internal/skill/manager_test.go | 2 +- internal/skill/target.go | 48 ++++++++++----- internal/skill/target_test.go | 17 +++++- 8 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 cmd/skills_scope_test.go 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) {