Skip to content
Open
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ clime plugin install opencli --npm @jackwener/opencli
clime opencli --help

clime plugin list
clime plugin update all
clime plugin update all # update installed plugins only
clime plugin uninstall opencli
```

Expand Down Expand Up @@ -85,8 +85,11 @@ Bootstrap CLI plugins from a shared manifest:
```sh
clime init # saved source or built-in defaults
clime init https://example.com/tools.yaml # use and remember your team's manifest
clime init ./tools.yaml # remember an absolute local path
```

The URL or absolute path is saved as `init_url` in `~/.clime/plugins.yaml` and reused by `clime init` when no source is provided.

Share a `skills.yaml` separately and apply it with `clime skills sync --manifest ./skills.yaml`.

## More commands
Expand Down
20 changes: 13 additions & 7 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"net/url"
"path/filepath"
"strings"

uicli "github.com/alperdrsnn/clime"
Expand All @@ -29,9 +30,10 @@ var initCmd = &cobra.Command{

If a URL is provided, the plugin list is fetched from that remote YAML file and
remembered for future runs.
If a local file path is provided, the plugin list is loaded from that YAML file.
Otherwise, the remembered URL is used, falling back to the built-in default
plugin list when no URL has been recorded.`,
If a local file path is provided, the plugin list is loaded from that YAML file
and its absolute path is remembered for future runs.
Otherwise, the remembered URL or path is used, falling back to the built-in
default plugin list when no source has been recorded.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
manifest, err := plugin.LoadManifest()
Expand All @@ -40,21 +42,25 @@ plugin list when no URL has been recorded.`,
}

source := manifest.InitURL
shouldRecord := false
if len(args) > 0 {
source = args[0]
shouldRecord = isURL(source)
if !isURL(source) {
source, err = filepath.Abs(source)
if err != nil {
return fmt.Errorf("failed to resolve init path: %w", err)
}
}
}

plugins, err := resolvePlugins(source)
if err != nil {
return err
}

if shouldRecord && source != manifest.InitURL {
if len(args) > 0 && source != manifest.InitURL {
manifest.InitURL = source
if err := manifest.Save(); err != nil {
return fmt.Errorf("failed to save init URL: %w", err)
return fmt.Errorf("failed to save init source: %w", err)
}
}

Expand Down
37 changes: 37 additions & 0 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,50 @@
package cmd

import (
"os"
"path/filepath"
"testing"

"github.com/git-hulk/clime/internal/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInitRemembersLocalSource(t *testing.T) {
for _, absolute := range []bool{false, true} {
name := "relative path"
if absolute {
name = "absolute path"
}
t.Run(name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Chdir(t.TempDir())
sourcePath, err := filepath.Abs("plugins.yaml")
require.NoError(t, err)
require.NoError(t, os.WriteFile(sourcePath, []byte("plugins:\n - name: remembered\n"), 0644))

manifest := &plugin.Manifest{InitURL: "https://example.com/previous.yaml"}
manifest.Add("remembered", "1.0.0", plugin.SourceTypeGitHub, "example/clime-remembered", "")
require.NoError(t, manifest.Save())

source := "plugins.yaml"
if absolute {
source = sourcePath
}
require.NoError(t, initCmd.RunE(initCmd, []string{source}))
manifest, err = plugin.LoadManifest()
require.NoError(t, err)
require.Equal(t, sourcePath, manifest.InitURL)

t.Chdir(t.TempDir())
output := captureStdout(t, func() {
require.NoError(t, initCmd.RunE(initCmd, nil))
})
require.Contains(t, output, "Skipping 1 already installed plugin(s): remembered")
})
}
}

func TestFilterPluginsByTags(t *testing.T) {
plugins := []plugin.Plugin{
{Name: "core"}, // untagged
Expand Down
7 changes: 5 additions & 2 deletions cmd/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ var pluginUninstallCmd = &cobra.Command{
var pluginUpdateCmd = &cobra.Command{
Use: "update <name|all>",
Short: "Update an installed plugin",
Long: "Updates a plugin from its configured source. GitHub-based plugins update to the latest release. Script-based plugins rerun their install script. Repo is resolved from --repo, manifest, or the default git-hulk/clime-<name> convention. Use '*' or 'all' to update all managed plugins.",
Long: "Updates a plugin from its configured source. GitHub-based plugins update to the latest release. Script-based plugins rerun their install script. Repo is resolved from --repo, manifest, or the default git-hulk/clime-<name> convention. Use '*' or 'all' to update all installed managed plugins.",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
Expand Down Expand Up @@ -521,13 +521,16 @@ func runPluginUpdateAll() error {
if name == "" || seen[name] {
continue
}
if _, installed := plugin.Find(name); !installed {
continue
}
seen[name] = true
names = append(names, name)
}
sort.Strings(names)

if len(names) == 0 {
terminal.Warning("No managed plugins found.")
terminal.Warning("No installed managed plugins found.")
terminal.Info("Install a plugin with: clime plugin install <name>")
return nil
}
Expand Down
65 changes: 65 additions & 0 deletions cmd/plugin_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package cmd

import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/git-hulk/clime/internal/plugin"
"github.com/stretchr/testify/require"
)

func TestPluginUpdateAllSkipsMissingPlugins(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a shell script to simulate npm")
}
t.Setenv("HOME", t.TempDir())
binDir := t.TempDir()
t.Setenv("PATH", binDir)
updateLog := filepath.Join(t.TempDir(), "updates")
t.Setenv("CLIME_TEST_NPM_LOG", updateLog)
require.NoError(t, os.WriteFile(filepath.Join(binDir, "npm"), []byte(`#!/bin/sh
case "$1" in
update) printf '%s\n' "$3" >> "$CLIME_TEST_NPM_LOG" ;;
list) printf '{"dependencies":{"%s":{"version":"2.0.0"}}}\n' "$3" ;;
*) exit 1 ;;
esac
`), 0755))

managedDir, err := plugin.PluginBinDir()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(managedDir, 0755))
managedPath := filepath.Join(managedDir, "clime-managed")
pathPlugin := filepath.Join(binDir, "clime-path-only")
for _, path := range []string{managedPath, pathPlugin, filepath.Join(binDir, "clime-unmanaged")} {
require.NoError(t, os.WriteFile(path, nil, 0755))
}

manifest := &plugin.Manifest{}
for _, name := range []string{"managed", "missing", "path-only"} {
manifest.Add(name, "1.0.0", plugin.SourceTypeNpm, name, "")
}
require.NoError(t, manifest.Save())

require.NoError(t, pluginUpdateCmd.RunE(pluginUpdateCmd, []string{"all"}))
updates, err := os.ReadFile(updateLog)
require.NoError(t, err)
require.Equal(t, "managed\npath-only\n", string(updates))
manifest, err = plugin.LoadManifest()
require.NoError(t, err)
require.Len(t, manifest.Plugins, 3)
for _, entry := range manifest.Plugins {
version := "2.0.0"
if entry.Name == "missing" {
version = "1.0.0"
}
require.Equal(t, version, entry.Version)
}

require.NoError(t, os.Remove(managedPath))
require.NoError(t, os.Remove(pathPlugin))
require.NoError(t, os.Remove(updateLog))
require.NoError(t, pluginUpdateCmd.RunE(pluginUpdateCmd, []string{"all"}))
require.NoFileExists(t, updateLog)
}
1 change: 1 addition & 0 deletions internal/plugin/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type ManifestEntry struct {

// Manifest holds the list of managed plugins.
type Manifest struct {
// InitURL stores the init manifest URL or absolute local path.
InitURL string `yaml:"init_url,omitempty"`
Plugins []ManifestEntry `yaml:"plugins"`
}
Expand Down
Loading