Skip to content

Commit 4e023d2

Browse files
committed
Allow using the interactive UI to install plugins
1 parent 1a44a55 commit 4e023d2

4 files changed

Lines changed: 453 additions & 30 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
# Repository Guidelines
22

33
## Project Structure & Module Organization
4-
- `main.go`: CLI entrypoint; injects build metadata into `internal/version`.
5-
- `cmd/`: Cobra commands (`root`, `version`, `plugin`, `init`, `update`).
4+
- `main.go`: CLI entrypoint; embeds `SKILL.md` and `agents/openai.yaml`, injects build metadata into `internal/version`.
5+
- `cmd/`: Cobra commands (`root`, `version`, `plugin`, `init`, `update`, `completion`, `install skill`, `skills`).
66
- `internal/`: non-exported packages:
7-
- `plugin/` for plugin discovery/install/exec + manifest handling.
7+
- `plugin/` for plugin discovery/exec + manifest handling (`~/.clime/plugins.yaml`).
8+
- `installer/` for plugin install/update/uninstall across sources (GitHub, npm, Homebrew, script).
9+
- `skill/` for AI agent skill install/uninstall + manifest handling (`~/.clime/skills.yaml`).
810
- `selfupdate/` for CLI self-update flow.
911
- `githubrelease/` shared GitHub release fetch/extract helpers.
12+
- `prompt/` for interactive terminal prompts (select, input, multiselect).
1013
- `version/` build/version string formatting.
1114
- `scripts/install.sh`: installer used for release-based bootstrap.
1215
- `.github/workflows/`: CI (`go vet`, `go test`, `go build`) and tagged release via GoReleaser.
@@ -20,7 +23,7 @@
2023
- Quick local run: `go run . version` or `go run . update --help`.
2124

2225
## Coding Style & Naming Conventions
23-
- Language: Go (`go 1.26.x` per `go.mod`).
26+
- Language: Go (`go 1.25` per `go.mod`).
2427
- Formatting: always run `gofmt -w` on changed Go files.
2528
- Keep packages small and single-purpose under `internal/`.
2629
- File and package names should be lowercase; command files in `cmd/` should map to subcommands (for example, `update.go` -> `clime update`).

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ By default it installs `clime` to `~/.local/bin` and updates your shell profile
3232
Install a plugin, then use it as a subcommand — clime forwards all arguments to the underlying binary:
3333

3434
```sh
35+
# Install a plugin from GitHub Releases (default — looks for git-hulk/clime-<name>)
36+
clime plugin install mytools
37+
clime plugin install mytools --repo owner/repo # custom GitHub repo
38+
3539
# Install a plugin via a custom install script
3640
clime plugin install account --script https://example.com/install.sh --binary-path ~/.local/bin/clime-account
3741

@@ -41,6 +45,9 @@ clime plugin install opencli --npm @jackwener/opencli
4145
# Install a plugin from Homebrew
4246
clime plugin install golangci-lint --brew golangci-lint
4347

48+
# Interactive mode — wizard prompts for name, source, and details
49+
clime plugin install
50+
4451
# Now use it — clime dispatches to the clime-<name> binary
4552
clime account login --user hulk
4653
clime account list
@@ -64,14 +71,40 @@ clime init # built-in defaults
6471
clime init https://example.com/tools.yaml # your team's plugin list
6572
```
6673

67-
## AI Agent Skill
74+
## AI Agent Skills
75+
76+
clime can manage AI agent skills for Claude Code and Codex.
77+
78+
### Built-in skill
6879

69-
clime ships with a built-in skill that AI agents (Claude Code, Codex, etc.) can use to discover and manage plugins on your behalf. Install it with:
80+
Install the bundled clime-cli skill so agents can discover and manage plugins on your behalf:
7081

7182
```sh
7283
clime install skill
7384
```
7485

75-
This writes the skill file to `~/.claude/skills/` and `~/.codex/skills/` so agents can automatically pick it up.
86+
This writes the skill file to `~/.claude/skills/` and `~/.codex/skills/`.
87+
88+
### Skills from repositories
89+
90+
Install, list, and uninstall skills from GitHub repositories or local paths:
91+
92+
```sh
93+
clime skills install owner/repo # browse and install skills from a repo
94+
clime skills install /local/path # install from a local directory
95+
clime skills install # interactive mode — pick a source and skills
96+
clime skills list # list installed skills
97+
clime skills uninstall <name> # remove a skill
98+
```
99+
100+
## Shell Completions
101+
102+
Generate shell completion scripts for bash, zsh, fish, or PowerShell:
103+
104+
```sh
105+
clime completion install # auto-detect shell and install completions
106+
clime completion bash # output bash completion script
107+
clime completion zsh # output zsh completion script
108+
```
76109

77110
Run **clime help** or **clime <command> --help** for full usage details.

cmd/plugin.go

Lines changed: 147 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"errors"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -10,13 +11,15 @@ import (
1011
uicli "github.com/alperdrsnn/clime"
1112
"github.com/git-hulk/clime/internal/installer"
1213
"github.com/git-hulk/clime/internal/plugin"
14+
"github.com/git-hulk/clime/internal/prompt"
1315
"github.com/spf13/cobra"
1416
)
1517

1618
var (
17-
pluginInstall plugin.Plugin
18-
pluginUpdateRepo string
19-
pluginUpdateForce bool
19+
pluginInstall plugin.Plugin
20+
pluginUpdateRepo string
21+
pluginUpdateForce bool
22+
pluginInstallRunner = executePluginInstall
2023
)
2124

2225
func init() {
@@ -149,17 +152,25 @@ func pluginListColumns(p plugin.DiscoveredPlugin, manifest *plugin.Manifest, hom
149152
}
150153

151154
var pluginInstallCmd = &cobra.Command{
152-
Use: "install <name>",
155+
Use: "install [name]",
153156
Short: "Install a plugin from GitHub Releases, npm, Homebrew, or an install script",
154-
Long: "Downloads and installs a plugin. By default, looks for git-hulk/clime-<name> on GitHub. Use --npm to install from an npm package, --brew to install from a Homebrew formula, or --script to run a remote install script.",
155-
Args: cobra.ExactArgs(1),
157+
Long: "Downloads and installs a plugin. Run without arguments for an interactive wizard. With a name argument, looks for git-hulk/clime-<name> on GitHub by default. Use --npm, --brew, --repo, or --script to specify an alternative source.",
158+
Args: cobra.MaximumNArgs(1),
156159
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
157160
if len(args) != 0 {
158161
return nil, cobra.ShellCompDirectiveNoFileComp
159162
}
160163
return nil, cobra.ShellCompDirectiveNoFileComp
161164
},
162165
RunE: func(cmd *cobra.Command, args []string) error {
166+
if len(args) == 0 {
167+
if pluginInstall.Npm != "" || pluginInstall.Brew != "" ||
168+
pluginInstall.Repo != "" || pluginInstall.Script != "" {
169+
return fmt.Errorf("plugin name argument is required when using --npm, --brew, --repo, or --script flags")
170+
}
171+
return runInteractivePluginInstall()
172+
}
173+
163174
name := args[0]
164175

165176
sources := 0
@@ -187,34 +198,147 @@ var pluginInstallCmd = &cobra.Command{
187198
return err
188199
}
189200

190-
inst, err := installer.FromPlugin(pluginInstall)
201+
pluginInstall.Name = name
202+
return pluginInstallRunner(manifest, name, pluginInstall)
203+
},
204+
}
205+
206+
func runInteractivePluginInstall() error {
207+
// Step 1: Enter plugin name.
208+
fmt.Println()
209+
name, err := inputPrompt("Enter plugin name")
210+
if err != nil {
211+
return err
212+
}
213+
name = strings.TrimSpace(name)
214+
if name == "" {
215+
return fmt.Errorf("plugin name cannot be empty")
216+
}
217+
218+
manifest, err := plugin.LoadManifest()
219+
if err != nil {
220+
manifest = &plugin.Manifest{}
221+
}
222+
if err := ensureInstallNameAvailable(manifest, name); err != nil {
223+
return err
224+
}
225+
226+
// Step 2: Select install type.
227+
installTypes := []string{
228+
"Script (curl | sh)",
229+
"npm (global package)",
230+
"Homebrew (formula)",
231+
"GitHub Release",
232+
}
233+
234+
fmt.Println()
235+
typeIdx, err := selectPrompt(prompt.SelectConfig{
236+
Label: "Select install type",
237+
Options: installTypes,
238+
})
239+
if err != nil {
240+
if errors.Is(err, prompt.ErrBack) {
241+
terminal.Info("Installation cancelled.")
242+
return nil
243+
}
244+
return err
245+
}
246+
247+
// Step 3: Collect source details based on install type.
248+
var p plugin.Plugin
249+
p.Name = name
250+
251+
fmt.Println()
252+
switch typeIdx {
253+
case 0: // Script
254+
url, err := inputPrompt("Enter install script URL")
191255
if err != nil {
192256
return err
193257
}
258+
url = strings.TrimSpace(url)
259+
if url == "" {
260+
return fmt.Errorf("script URL cannot be empty")
261+
}
262+
p.Script = url
194263

195-
spinner := uicli.NewSpinner().
196-
WithStyle(uicli.SpinnerDots).
197-
WithColor(uicli.CyanColor).
198-
WithMessage(fmt.Sprintf("Installing plugin %q...", name)).
199-
Start()
264+
binPath, err := inputPrompt("Enter binary path after install (leave empty to auto-detect)")
265+
if err != nil {
266+
return err
267+
}
268+
p.BinaryPath = strings.TrimSpace(binPath)
200269

201-
version, err := inst.Install(name)
270+
case 1: // npm
271+
pkg, err := inputPrompt("Enter npm package name")
202272
if err != nil {
203-
spinner.Error(fmt.Sprintf("Failed to install plugin %q", name))
204-
return fmt.Errorf("failed to install plugin %q: %w", name, err)
273+
return err
205274
}
275+
pkg = strings.TrimSpace(pkg)
276+
if pkg == "" {
277+
return fmt.Errorf("npm package name cannot be empty")
278+
}
279+
p.Npm = pkg
206280

207-
manifest.Add(name, version, inst.PluginType(), inst.Source(), "")
208-
if pluginInstall.Description != "" {
209-
manifest.SetDescription(name, pluginInstall.Description)
281+
case 2: // Homebrew
282+
formula, err := inputPrompt("Enter Homebrew formula")
283+
if err != nil {
284+
return err
210285
}
211-
if err := manifest.Save(); err != nil {
212-
return fmt.Errorf("plugin installed but failed to update manifest: %w", err)
286+
formula = strings.TrimSpace(formula)
287+
if formula == "" {
288+
return fmt.Errorf("Homebrew formula cannot be empty")
213289
}
290+
p.Brew = formula
214291

215-
spinner.Success(fmt.Sprintf("Installed plugin %q (%s)", name, version))
216-
return nil
217-
},
292+
case 3: // GitHub Release
293+
repo, err := inputPrompt("Enter GitHub repository (owner/repo)")
294+
if err != nil {
295+
return err
296+
}
297+
repo = strings.TrimSpace(repo)
298+
if repo == "" {
299+
return fmt.Errorf("GitHub repository cannot be empty")
300+
}
301+
p.Repo = repo
302+
}
303+
304+
// Step 4: Optional description.
305+
desc, err := inputPrompt("Enter description (leave empty to skip)")
306+
if err != nil {
307+
return err
308+
}
309+
p.Description = strings.TrimSpace(desc)
310+
311+
return pluginInstallRunner(manifest, name, p)
312+
}
313+
314+
func executePluginInstall(manifest *plugin.Manifest, name string, p plugin.Plugin) error {
315+
inst, err := installer.FromPlugin(p)
316+
if err != nil {
317+
return err
318+
}
319+
320+
spinner := uicli.NewSpinner().
321+
WithStyle(uicli.SpinnerDots).
322+
WithColor(uicli.CyanColor).
323+
WithMessage(fmt.Sprintf("Installing plugin %q...", name)).
324+
Start()
325+
326+
version, err := inst.Install(name)
327+
if err != nil {
328+
spinner.Error(fmt.Sprintf("Failed to install plugin %q", name))
329+
return fmt.Errorf("failed to install plugin %q: %w", name, err)
330+
}
331+
332+
manifest.Add(name, version, inst.PluginType(), inst.Source(), p.BinaryPath)
333+
if p.Description != "" {
334+
manifest.SetDescription(name, p.Description)
335+
}
336+
if err := manifest.Save(); err != nil {
337+
return fmt.Errorf("plugin installed but failed to update manifest: %w", err)
338+
}
339+
340+
spinner.Success(fmt.Sprintf("Installed plugin %q (%s)", name, version))
341+
return nil
218342
}
219343

220344
var pluginUninstallCmd = &cobra.Command{

0 commit comments

Comments
 (0)