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
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,29 @@ This writes the skill file to `~/.claude/skills/` and `~/.codex/skills/`.

### Skills from repositories

Install, list, and uninstall skills from GitHub repositories or local paths:
Install, update, sync, list, and uninstall skills from GitHub repositories or local paths:

```sh
clime skills install owner/repo # browse and install skills from a repo
clime skills install /local/path # install from a local directory
clime skills install # interactive mode — pick a source and skills
clime skills list # list installed skills
clime skills uninstall <name> # remove a skill
clime skills install owner/repo # browse and install skills (same as @latest)
clime skills install owner/repo@latest # highest stable semver tag, like go get
clime skills install owner/repo@v1 # highest v1.x.y tag
clime skills install owner/repo@v1.2.3 # pin to a tag, branch, or commit SHA
clime skills install /local/path # install from a local directory
clime skills install # interactive mode — pick a source and skills
clime skills update # update every source to its latest version
clime skills update owner/repo # update one source to latest
clime skills update owner/repo@v1.2.3 # move one source to a specific version
clime skills sync # reinstall skills at the versions locked in the manifest
clime skills list # list installed skills
clime skills uninstall <name> # remove a skill
```

The manifest at `~/.clime/skills.yaml` records each source's locked version. `sync`
applies those versions as-is (from the local cache when available, so it works
offline), while `update` resolves a newer version and rewrites the lock. An update
is refused when the new version no longer provides an installed skill, so skills
are never removed implicitly.

## Shell Completions

Generate shell completion scripts for bash, zsh, fish, or PowerShell:
Expand Down
121 changes: 54 additions & 67 deletions cmd/plugin_skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"os/exec"
"strings"
"time"

uicli "github.com/alperdrsnn/clime"
"github.com/git-hulk/clime/internal/plugin"
Expand All @@ -18,6 +17,22 @@ const pluginSkillsOption = "Plugin Skills"
// auto-install any skills the plugin provides. It's a variable for testing.
var pluginSkillInstaller = tryInstallPluginSkills

// pluginSkillEvents reports only successful installs; every failure stays
// silent so plugin installation is never blocked.
type pluginSkillEvents struct {
skill.NopEvents
}

// pluginSkillSource pairs a plugin name with its skill source path/repo.
type pluginSkillSource struct {
pluginName string
source string
}

func (pluginSkillEvents) SkillInstalled(_ skill.Verb, name string, targets []string) {
terminal.Successf("Installed plugin skill %q to %s", name, strings.Join(targets, ", "))
}

// tryInstallPluginSkills invokes `clime-<name> skills` to discover a skill
// source from the plugin. If the plugin provides skills, they are automatically
// installed. Errors are silently ignored so plugin installation is never blocked.
Expand All @@ -26,41 +41,28 @@ func tryInstallPluginSkills(name string) {
if source == "" {
return
}

manifest, err := skill.LoadManifest()
src, err := skill.ParseSource(source)
if err != nil {
return
}

repoManifest, err := skill.FetchRepoManifest(source)
mgr, err := skill.Open(pluginSkillEvents{})
if err != nil {
return
}

dir, cleanup, err := skill.PrepareRepoDir(source)
snap, catalog, err := mgr.Fetch(src)
if err != nil {
return
}
defer cleanup()

for _, entry := range repoManifest.Skills {
if _, installed := manifest.GetSkill(entry.Name); installed {
continue
}
targets, err := skill.InstallFromDir(entry.Name, dir, entry.Path)
if err != nil || len(targets) == 0 {
continue
var entries []skill.Entry
for _, entry := range catalog.Skills {
if _, installed := mgr.Manifest.GetSkill(entry.Name); !installed {
entries = append(entries, entry)
}
manifest.AddSkill(skill.InstalledSkill{
Name: entry.Name,
Description: entry.Description,
Source: source,
Path: entry.Path,
InstalledAt: time.Now(),
})
manifest.Save()
terminal.Successf("Installed plugin skill %q to %s", entry.Name, strings.Join(targets, ", "))
}
_, _ = mgr.Install(snap, entries)
}

// getPluginSkillSource runs `clime-<name> skills` and returns the trimmed
Expand All @@ -78,12 +80,6 @@ func getPluginSkillSource(name string) string {
return strings.TrimSpace(string(out))
}

// pluginSkillSource pairs a plugin name with its skill source path/repo.
type pluginSkillSource struct {
pluginName string
source string
}

// discoverPluginSkillSources iterates all discovered plugins and returns
// those that support the `skills` subcommand with a valid source.
func discoverPluginSkillSources() []pluginSkillSource {
Expand All @@ -105,11 +101,7 @@ func discoverPluginSkillSources() []pluginSkillSource {
// It scans all plugins for skill sources, presents available skills, and
// installs the user's selections.
func installFromPluginSkills(manifest *skill.Manifest) error {
spinner := uicli.NewSpinner().
WithStyle(uicli.SpinnerDots).
WithColor(uicli.CyanColor).
WithMessage("Scanning plugins for skills...").
Start()
spinner := startSpinner("Scanning plugins for skills...")

sources := discoverPluginSkillSources()
if len(sources) == 0 {
Expand All @@ -118,24 +110,30 @@ func installFromPluginSkills(manifest *skill.Manifest) error {
return nil
}

mgr, err := newSkillsManager(manifest)
if err != nil {
spinner.Error("Failed to prepare skill manager")
return err
}

// Collect skills from all plugin sources.
type skillCandidate struct {
entry skill.SkillEntry
source string
label string
entry skill.Entry
src skill.Source
label string
}
var candidates []skillCandidate
var dirs []struct {
dir string
cleanup func()
}

for _, ps := range sources {
repoManifest, err := skill.FetchRepoManifest(ps.source)
src, err := skill.ParseSource(ps.source)
if err != nil {
continue
}
_, catalog, err := mgr.Fetch(src)
if err != nil {
continue
}
for _, entry := range repoManifest.Skills {
for _, entry := range catalog.Skills {
if _, installed := manifest.GetSkill(entry.Name); installed {
continue
}
Expand All @@ -144,9 +142,9 @@ func installFromPluginSkills(manifest *skill.Manifest) error {
label = fmt.Sprintf("%s — %s (%s)", entry.Name, uicli.TruncateString(entry.Description, 50), ps.pluginName)
}
candidates = append(candidates, skillCandidate{
entry: entry,
source: ps.source,
label: label,
entry: entry,
src: src,
label: label,
})
}
}
Expand Down Expand Up @@ -178,46 +176,35 @@ func installFromPluginSkills(manifest *skill.Manifest) error {
return nil
}

// Group selected skills by source to prepare repos efficiently.
// Group selected skills by source so each repository resolves once.
type sourceSkills struct {
source string
entries []skill.SkillEntry
src skill.Source
entries []skill.Entry
}
sourceMap := make(map[string]*sourceSkills)
for _, idx := range selectedIdxs {
c := candidates[idx]
ss, ok := sourceMap[c.source]
ss, ok := sourceMap[c.src.Repo]
if !ok {
ss = &sourceSkills{source: c.source}
sourceMap[c.source] = ss
ss = &sourceSkills{src: c.src}
sourceMap[c.src.Repo] = ss
}
ss.entries = append(ss.entries, c.entry)
}

fmt.Println()
for _, ss := range sourceMap {
dir, cleanup, err := skill.PrepareRepoDir(ss.source)
snap, err := mgr.Store.Snapshot(ss.src)
if err != nil {
terminal.Errorf("Failed to prepare %q: %v", ss.source, err)
terminal.Errorf("Failed to prepare %q: %v", ss.src, err)
continue
}
dirs = append(dirs, struct {
dir string
cleanup func()
}{dir, cleanup})

manifest.AddSource(ss.source)
manifest.AddSource(ss.src)
manifest.Save()

for _, entry := range ss.entries {
if err := installSkillEntry(manifest, &entry, ss.source, dir); err != nil {
terminal.Errorf("Failed to install %q: %v", entry.Name, err)
}
}
}

for _, d := range dirs {
d.cleanup()
// Per-skill failures are already reported through the progress events.
_, _ = mgr.Install(snap, ss.entries)
}

return nil
Expand Down
42 changes: 13 additions & 29 deletions cmd/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/git-hulk/clime/internal/skill"
"github.com/spf13/cobra"
)

Expand All @@ -31,44 +31,28 @@ var installSkillCmd = &cobra.Command{
Use: "skill",
Short: "Install the clime-cli skill into ~/.claude/skills and ~/.codex/skills",
RunE: func(cmd *cobra.Command, args []string) error {
home, err := os.UserHomeDir()
targets, err := skill.Targets()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
return err
}

installed := 0
targets := []string{".claude", ".codex"}
for _, dir := range targets {
base := filepath.Join(home, dir)
info, err := os.Stat(base)
if err != nil || !info.IsDir() {
terminal.Warningf("Skipping %s (directory not found)", base)
for _, t := range targets {
if !t.Exists() {
terminal.Warningf("Skipping %s (directory not found)", t.Dir)
continue
}

skillDir := filepath.Join(base, "skills", skillDirName)
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("failed to create %s: %w", skillDir, err)
files := map[string][]byte{skillFileName: []byte(SkillContent)}
// Codex discovers skills through agents/openai.yaml.
if t.Name == "codex" {
files[filepath.Join("agents", "openai.yaml")] = []byte(AgentYAML)
}

dest := filepath.Join(skillDir, skillFileName)
if err := os.WriteFile(dest, []byte(SkillContent), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", dest, err)
}

// Write agents/openai.yaml for Codex skill discovery.
if dir == ".codex" {
agentsDir := filepath.Join(skillDir, "agents")
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
return fmt.Errorf("failed to create %s: %w", agentsDir, err)
}
yamlDest := filepath.Join(agentsDir, "openai.yaml")
if err := os.WriteFile(yamlDest, []byte(AgentYAML), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", yamlDest, err)
}
if err := t.Install(skillDirName, files); err != nil {
return fmt.Errorf("failed to install skill to %s: %w", t.Dir, err)
}

terminal.Successf("Installed skill to %s", dest)
terminal.Successf("Installed skill to %s", filepath.Join(t.Dir, "skills", skillDirName, skillFileName))
installed++
}

Expand Down
Loading
Loading