Skip to content

Commit d3445c6

Browse files
committed
Fix CI issue
1 parent 4bb95ac commit d3445c6

4 files changed

Lines changed: 243 additions & 3 deletions

File tree

cmd/plugin.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ func executePluginInstall(manifest *plugin.Manifest, name string, p plugin.Plugi
338338
}
339339

340340
spinner.Success(fmt.Sprintf("Installed plugin %q (%s)", name, version))
341+
342+
pluginSkillInstaller(name)
341343
return nil
342344
}
343345

cmd/plugin_skills.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"strings"
7+
"time"
8+
9+
uicli "github.com/alperdrsnn/clime"
10+
"github.com/git-hulk/clime/internal/plugin"
11+
"github.com/git-hulk/clime/internal/prompt"
12+
"github.com/git-hulk/clime/internal/skill"
13+
)
14+
15+
const pluginSkillsOption = "Plugin Skills"
16+
17+
// pluginSkillInstaller is the function called after plugin installation to
18+
// auto-install any skills the plugin provides. It's a variable for testing.
19+
var pluginSkillInstaller = tryInstallPluginSkills
20+
21+
// tryInstallPluginSkills invokes `clime-<name> skills` to discover a skill
22+
// source from the plugin. If the plugin provides skills, they are automatically
23+
// installed. Errors are silently ignored so plugin installation is never blocked.
24+
func tryInstallPluginSkills(name string) {
25+
source := getPluginSkillSource(name)
26+
if source == "" {
27+
return
28+
}
29+
30+
manifest, err := skill.LoadManifest()
31+
if err != nil {
32+
return
33+
}
34+
35+
repoManifest, err := skill.FetchRepoManifest(source)
36+
if err != nil {
37+
return
38+
}
39+
40+
dir, cleanup, err := skill.PrepareRepoDir(source)
41+
if err != nil {
42+
return
43+
}
44+
defer cleanup()
45+
46+
for _, entry := range repoManifest.Skills {
47+
if _, installed := manifest.GetSkill(entry.Name); installed {
48+
continue
49+
}
50+
targets, err := skill.InstallFromDir(entry.Name, dir, entry.Path)
51+
if err != nil || len(targets) == 0 {
52+
continue
53+
}
54+
manifest.AddSkill(skill.InstalledSkill{
55+
Name: entry.Name,
56+
Description: entry.Description,
57+
Source: source,
58+
Path: entry.Path,
59+
InstalledAt: time.Now(),
60+
})
61+
manifest.Save()
62+
terminal.Successf("Installed plugin skill %q to %s", entry.Name, strings.Join(targets, ", "))
63+
}
64+
}
65+
66+
// getPluginSkillSource runs `clime-<name> skills` and returns the trimmed
67+
// output. Returns an empty string if the plugin is not found, the subcommand
68+
// fails, or the output is empty.
69+
func getPluginSkillSource(name string) string {
70+
binPath, found := plugin.Find(name)
71+
if !found {
72+
return ""
73+
}
74+
out, err := exec.Command(binPath, "skills").Output()
75+
if err != nil {
76+
return ""
77+
}
78+
return strings.TrimSpace(string(out))
79+
}
80+
81+
// pluginSkillSource pairs a plugin name with its skill source path/repo.
82+
type pluginSkillSource struct {
83+
pluginName string
84+
source string
85+
}
86+
87+
// discoverPluginSkillSources iterates all discovered plugins and returns
88+
// those that support the `skills` subcommand with a valid source.
89+
func discoverPluginSkillSources() []pluginSkillSource {
90+
plugins := plugin.Discover()
91+
var sources []pluginSkillSource
92+
for _, p := range plugins {
93+
source := getPluginSkillSource(p.Name)
94+
if source != "" {
95+
sources = append(sources, pluginSkillSource{
96+
pluginName: p.Name,
97+
source: source,
98+
})
99+
}
100+
}
101+
return sources
102+
}
103+
104+
// installFromPluginSkills handles the "Plugin Skills" interactive flow.
105+
// It scans all plugins for skill sources, presents available skills, and
106+
// installs the user's selections.
107+
func installFromPluginSkills(manifest *skill.Manifest) error {
108+
spinner := uicli.NewSpinner().
109+
WithStyle(uicli.SpinnerDots).
110+
WithColor(uicli.CyanColor).
111+
WithMessage("Scanning plugins for skills...").
112+
Start()
113+
114+
sources := discoverPluginSkillSources()
115+
if len(sources) == 0 {
116+
spinner.Error("No plugins with skills found")
117+
terminal.Info("None of the installed plugins support the \"skills\" subcommand.")
118+
return nil
119+
}
120+
121+
// Collect skills from all plugin sources.
122+
type skillCandidate struct {
123+
entry skill.SkillEntry
124+
source string
125+
label string
126+
}
127+
var candidates []skillCandidate
128+
var dirs []struct {
129+
dir string
130+
cleanup func()
131+
}
132+
133+
for _, ps := range sources {
134+
repoManifest, err := skill.FetchRepoManifest(ps.source)
135+
if err != nil {
136+
continue
137+
}
138+
for _, entry := range repoManifest.Skills {
139+
if _, installed := manifest.GetSkill(entry.Name); installed {
140+
continue
141+
}
142+
label := fmt.Sprintf("%s — %s", entry.Name, ps.pluginName)
143+
if entry.Description != "" {
144+
label = fmt.Sprintf("%s — %s (%s)", entry.Name, uicli.TruncateString(entry.Description, 50), ps.pluginName)
145+
}
146+
candidates = append(candidates, skillCandidate{
147+
entry: entry,
148+
source: ps.source,
149+
label: label,
150+
})
151+
}
152+
}
153+
154+
if len(candidates) == 0 {
155+
spinner.Success(fmt.Sprintf("Scanned %d plugin(s)", len(sources)))
156+
terminal.Info("All skills from plugins are already installed.")
157+
return nil
158+
}
159+
160+
spinner.Success(fmt.Sprintf("Found %d skill(s) from %d plugin(s)", len(candidates), len(sources)))
161+
162+
options := make([]string, len(candidates))
163+
for i, c := range candidates {
164+
options[i] = c.label
165+
}
166+
167+
fmt.Println()
168+
selectedIdxs, err := multiSelectPrompt(prompt.SelectConfig{
169+
Label: "Select skills to install (space to toggle, enter to confirm)",
170+
Options: options,
171+
})
172+
if err != nil {
173+
return err
174+
}
175+
176+
if len(selectedIdxs) == 0 {
177+
terminal.Info("No skills selected.")
178+
return nil
179+
}
180+
181+
// Group selected skills by source to prepare repos efficiently.
182+
type sourceSkills struct {
183+
source string
184+
entries []skill.SkillEntry
185+
}
186+
sourceMap := make(map[string]*sourceSkills)
187+
for _, idx := range selectedIdxs {
188+
c := candidates[idx]
189+
ss, ok := sourceMap[c.source]
190+
if !ok {
191+
ss = &sourceSkills{source: c.source}
192+
sourceMap[c.source] = ss
193+
}
194+
ss.entries = append(ss.entries, c.entry)
195+
}
196+
197+
fmt.Println()
198+
for _, ss := range sourceMap {
199+
dir, cleanup, err := skill.PrepareRepoDir(ss.source)
200+
if err != nil {
201+
terminal.Errorf("Failed to prepare %q: %v", ss.source, err)
202+
continue
203+
}
204+
dirs = append(dirs, struct {
205+
dir string
206+
cleanup func()
207+
}{dir, cleanup})
208+
209+
manifest.AddSource(ss.source)
210+
manifest.Save()
211+
212+
for _, entry := range ss.entries {
213+
if err := installSkillEntry(manifest, &entry, ss.source, dir); err != nil {
214+
terminal.Errorf("Failed to install %q: %v", entry.Name, err)
215+
}
216+
}
217+
}
218+
219+
for _, d := range dirs {
220+
d.cleanup()
221+
}
222+
223+
return nil
224+
}

cmd/skills.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ func runInteractiveSkillsInstall(manifest *skill.Manifest) error {
155155
return skillsActionRunner(manifest, repo, actionBrowseInstall)
156156
}
157157

158-
options := append(sources, newRepoOption)
158+
options := append(sources, pluginSkillsOption, newRepoOption)
159159
showSourceSpacer := true
160160
for {
161161
if showSourceSpacer {
@@ -175,6 +175,15 @@ func runInteractiveSkillsInstall(manifest *skill.Manifest) error {
175175
return err
176176
}
177177

178+
if options[idx] == pluginSkillsOption {
179+
err := installFromPluginSkills(manifest)
180+
if errors.Is(err, prompt.ErrBack) {
181+
showSourceSpacer = false
182+
continue
183+
}
184+
return err
185+
}
186+
178187
if options[idx] == newRepoOption {
179188
repo, err := inputPrompt("Enter repository (owner/repo)")
180189
if err != nil {

cmd/skills_interactive_test.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ func TestRunInteractiveSkillsInstallEscFromActionReturnsToSourceMenu(t *testing.
2626
selectCalls++
2727
switch selectCalls {
2828
case 1:
29+
// Select existing source "owner/repo".
2930
return 0, nil
3031
case 2:
32+
// Esc from the action menu to go back.
3133
return 0, prompt.ErrBack
3234
case 3:
33-
return 1, nil
35+
// Select "Enter a new repository..." (after Plugin Skills option).
36+
return 2, nil
3437
default:
3538
t.Fatalf("unexpected select call %d", selectCalls)
3639
return 0, nil
@@ -77,9 +80,11 @@ func TestRunInteractiveSkillsInstallEscAtTopLevelKeepsUIOpen(t *testing.T) {
7780
selectCalls++
7881
switch selectCalls {
7982
case 1:
83+
// Esc at source menu.
8084
return 0, prompt.ErrBack
8185
case 2:
82-
return 1, nil
86+
// Select "Enter a new repository..." (after Plugin Skills option).
87+
return 2, nil
8388
default:
8489
t.Fatalf("unexpected select call %d", selectCalls)
8590
return 0, nil

0 commit comments

Comments
 (0)