From 8138011b908efe570990ae46d23db292066962de Mon Sep 17 00:00:00 2001 From: git-hulk Date: Mon, 26 Oct 2026 17:58:40 +0800 Subject: [PATCH] fix(plugin): remember init paths and update only installed plugins ## Problem `clime init` remembers remote URLs but discards local manifest paths. Later runs without an argument therefore use an older URL or the built-in defaults. Bulk plugin updates also run installers for stale manifest entries whose executable no longer exists. ## Reproduction Run `clime init ./tools.yaml`, change directories, then run `clime init` without an argument. The local source is not reused. Removing a managed plugin's executable while retaining its manifest entry still causes `clime plugin update all` to invoke its updater. ## Fix Save local init sources as absolute paths in the existing `init_url` field, retaining URL support and reusing either source when omitted. Filter bulk updates to managed plugins whose executable exists in `~/.clime/plugins/` or on `PATH`, leaving missing entries unchanged. ## Test - `go test ./... -v`, `go vet ./...`, and `go build` pass. - Regression tests cover path reuse from another directory and bulk updates with both installed and missing plugins. - CLI smoke checks cover URL reuse, invalid source preservation, and skipping missing executables, non-executable files, and broken links. With no installed managed plugins, bulk update now reports: ```text No installed managed plugins found. ``` Assistant-By GPT-6 --- README.md | 5 ++- cmd/init.go | 20 ++++++++---- cmd/init_test.go | 37 +++++++++++++++++++++ cmd/plugin.go | 7 ++-- cmd/plugin_update_test.go | 65 +++++++++++++++++++++++++++++++++++++ internal/plugin/manifest.go | 1 + 6 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 cmd/plugin_update_test.go diff --git a/README.md b/README.md index 6b08213..80083ef 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 diff --git a/cmd/init.go b/cmd/init.go index 1115756..f6c2af7 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "net/url" + "path/filepath" "strings" uicli "github.com/alperdrsnn/clime" @@ -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() @@ -40,10 +42,14 @@ 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) @@ -51,10 +57,10 @@ plugin list when no URL has been recorded.`, 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) } } diff --git a/cmd/init_test.go b/cmd/init_test.go index e967840..382d289 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "path/filepath" "testing" "github.com/git-hulk/clime/internal/plugin" @@ -8,6 +10,41 @@ import ( "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 diff --git a/cmd/plugin.go b/cmd/plugin.go index 8a2a625..a265e94 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -434,7 +434,7 @@ var pluginUninstallCmd = &cobra.Command{ var pluginUpdateCmd = &cobra.Command{ Use: "update ", 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- 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- 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 { @@ -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 ") return nil } diff --git a/cmd/plugin_update_test.go b/cmd/plugin_update_test.go new file mode 100644 index 0000000..a223035 --- /dev/null +++ b/cmd/plugin_update_test.go @@ -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) +} diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go index a7f6255..7c7b0b8 100644 --- a/internal/plugin/manifest.go +++ b/internal/plugin/manifest.go @@ -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"` }