From 916b69a90cbdc6a3b4b48b7754376a2e5122ea5f Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 13 Jul 2026 13:23:35 +0300 Subject: [PATCH 1/4] Add plugin REST API, thv plugin CLI, and HTTP client (Phase 4) Expose the frozen PluginService interface over REST at /api/v1beta/plugins, add the thv plugin cobra command tree mirroring thv skill, and implement an HTTP client in pkg/plugins/client. Wire the plugin service into the API server with Claude Code and Codex materialization adapters, SQLite plugin store, and OCI registry client. The plugin info text output surfaces the executable surface inventory (hooks, commands, agents, skills) separately from declared-but-unmanaged components (mcpServers, lspServers) per the issue exit gate. Closes #5528 --- cmd/thv/app/commands.go | 2 + cmd/thv/app/plugin.go | 14 + cmd/thv/app/plugin_build.go | 54 ++ cmd/thv/app/plugin_builds.go | 82 ++ cmd/thv/app/plugin_builds_remove.go | 31 + cmd/thv/app/plugin_helpers.go | 56 ++ cmd/thv/app/plugin_info.go | 155 ++++ cmd/thv/app/plugin_install.go | 85 ++ cmd/thv/app/plugin_list.go | 104 +++ cmd/thv/app/plugin_push.go | 35 + cmd/thv/app/plugin_uninstall.go | 54 ++ cmd/thv/app/plugin_validate.go | 68 ++ docs/cli/thv.md | 1 + docs/cli/thv_plugin.md | 43 + docs/cli/thv_plugin_build.md | 42 + docs/cli/thv_plugin_builds.md | 41 + docs/cli/thv_plugin_builds_remove.md | 39 + docs/cli/thv_plugin_info.md | 42 + docs/cli/thv_plugin_install.md | 45 ++ docs/cli/thv_plugin_list.md | 44 + docs/cli/thv_plugin_push.md | 39 + docs/cli/thv_plugin_uninstall.md | 41 + docs/cli/thv_plugin_validate.md | 40 + docs/server/docs.go | 1110 ++++++++++++++++++++++++++ docs/server/swagger.json | 1110 ++++++++++++++++++++++++++ docs/server/swagger.yaml | 746 +++++++++++++++++ pkg/api/server.go | 254 ++++-- pkg/api/v1/plugins.go | 392 +++++++++ pkg/api/v1/plugins_test.go | 679 ++++++++++++++++ pkg/api/v1/plugins_types.go | 78 ++ pkg/plugins/client/client.go | 341 ++++++++ pkg/plugins/client/client_test.go | 861 ++++++++++++++++++++ pkg/plugins/client/dto.go | 43 + 33 files changed, 6707 insertions(+), 64 deletions(-) create mode 100644 cmd/thv/app/plugin.go create mode 100644 cmd/thv/app/plugin_build.go create mode 100644 cmd/thv/app/plugin_builds.go create mode 100644 cmd/thv/app/plugin_builds_remove.go create mode 100644 cmd/thv/app/plugin_helpers.go create mode 100644 cmd/thv/app/plugin_info.go create mode 100644 cmd/thv/app/plugin_install.go create mode 100644 cmd/thv/app/plugin_list.go create mode 100644 cmd/thv/app/plugin_push.go create mode 100644 cmd/thv/app/plugin_uninstall.go create mode 100644 cmd/thv/app/plugin_validate.go create mode 100644 docs/cli/thv_plugin.md create mode 100644 docs/cli/thv_plugin_build.md create mode 100644 docs/cli/thv_plugin_builds.md create mode 100644 docs/cli/thv_plugin_builds_remove.md create mode 100644 docs/cli/thv_plugin_info.md create mode 100644 docs/cli/thv_plugin_install.md create mode 100644 docs/cli/thv_plugin_list.md create mode 100644 docs/cli/thv_plugin_push.md create mode 100644 docs/cli/thv_plugin_uninstall.md create mode 100644 docs/cli/thv_plugin_validate.md create mode 100644 pkg/api/v1/plugins.go create mode 100644 pkg/api/v1/plugins_test.go create mode 100644 pkg/api/v1/plugins_types.go create mode 100644 pkg/plugins/client/client.go create mode 100644 pkg/plugins/client/client_test.go create mode 100644 pkg/plugins/client/dto.go diff --git a/cmd/thv/app/commands.go b/cmd/thv/app/commands.go index b9b61fb8af..426122b3cb 100644 --- a/cmd/thv/app/commands.go +++ b/cmd/thv/app/commands.go @@ -74,6 +74,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command { rootCmd.AddCommand(newLLMCommand()) rootCmd.AddCommand(groupCmd) rootCmd.AddCommand(skillCmd) + rootCmd.AddCommand(pluginCmd) rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(tuiCmd) rootCmd.AddCommand(upgradeCmd) @@ -118,6 +119,7 @@ func IsInformationalCommand(args []string) bool { "mcp": true, "secret": true, "skill": true, + "plugin": true, "vmcp": true, "llm": true, } diff --git a/cmd/thv/app/plugin.go b/cmd/thv/app/plugin.go new file mode 100644 index 0000000000..d18f1d1031 --- /dev/null +++ b/cmd/thv/app/plugin.go @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "github.com/spf13/cobra" +) + +var pluginCmd = &cobra.Command{ + Use: "plugin", + Short: "Manage plugins", + Long: `The plugin command provides subcommands to manage plugins.`, +} diff --git a/cmd/thv/app/plugin_build.go b/cmd/thv/app/plugin_build.go new file mode 100644 index 0000000000..2cbd1cdef9 --- /dev/null +++ b/cmd/thv/app/plugin_build.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +var pluginBuildTag string + +var pluginBuildCmd = &cobra.Command{ + Use: "build [path]", + Short: "Build a plugin", + Long: `Build a plugin from a local directory into an OCI artifact that can be pushed to a registry. + +On success, prints the OCI reference of the built artifact to stdout.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterDirs + }, + RunE: pluginBuildCmdFunc, +} + +func init() { + pluginCmd.AddCommand(pluginBuildCmd) + + pluginBuildCmd.Flags().StringVarP(&pluginBuildTag, "tag", "t", "", "OCI tag for the built artifact") +} + +func pluginBuildCmdFunc(cmd *cobra.Command, args []string) error { + absPath, err := filepath.Abs(args[0]) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + c := newPluginClient(cmd.Context()) + + result, err := c.Build(cmd.Context(), plugins.BuildOptions{ + Path: absPath, + Tag: pluginBuildTag, + }) + if err != nil { + return formatPluginError("build plugin", err) + } + + fmt.Println(result.Reference) + return nil +} diff --git a/cmd/thv/app/plugin_builds.go b/cmd/thv/app/plugin_builds.go new file mode 100644 index 0000000000..573d4beaf7 --- /dev/null +++ b/cmd/thv/app/plugin_builds.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +var pluginBuildsFormat string + +var pluginBuildsCmd = &cobra.Command{ + Use: "builds", + Short: "List locally-built plugin artifacts", + Long: `List all locally-built OCI plugin artifacts stored in the local OCI store.`, + PreRunE: chainPreRunE( + ValidateFormat(&pluginBuildsFormat), + ), + RunE: pluginBuildsCmdFunc, +} + +func init() { + pluginCmd.AddCommand(pluginBuildsCmd) + + AddFormatFlag(pluginBuildsCmd, &pluginBuildsFormat) +} + +func pluginBuildsCmdFunc(cmd *cobra.Command, _ []string) error { + c := newPluginClient(cmd.Context()) + + builds, err := c.ListBuilds(cmd.Context()) + if err != nil { + return formatPluginError("list builds", err) + } + + switch pluginBuildsFormat { + case FormatJSON: + if builds == nil { + builds = []plugins.LocalBuild{} + } + data, err := json.MarshalIndent(builds, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(data)) + default: + if len(builds) == 0 { + fmt.Println("No locally-built plugin artifacts found") + return nil + } + printPluginBuildsText(builds) + } + + return nil +} + +func printPluginBuildsText(builds []plugins.LocalBuild) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + _, _ = fmt.Fprintln(w, "TAG\tDIGEST\tNAME\tVERSION") + + for _, b := range builds { + digest := b.Digest + if len(digest) > 19 { + digest = digest[:19] + "..." + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + b.Tag, + digest, + b.Name, + b.Version, + ) + } + + _ = w.Flush() +} diff --git a/cmd/thv/app/plugin_builds_remove.go b/cmd/thv/app/plugin_builds_remove.go new file mode 100644 index 0000000000..f66228e907 --- /dev/null +++ b/cmd/thv/app/plugin_builds_remove.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var pluginBuildsRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a locally-built plugin artifact", + Long: `Remove a locally-built OCI plugin artifact and its blobs from the local OCI store.`, + Args: cobra.ExactArgs(1), + RunE: pluginBuildsRemoveCmdFunc, +} + +func init() { + pluginBuildsCmd.AddCommand(pluginBuildsRemoveCmd) +} + +func pluginBuildsRemoveCmdFunc(cmd *cobra.Command, args []string) error { + c := newPluginClient(cmd.Context()) + if err := c.DeleteBuild(cmd.Context(), args[0]); err != nil { + return formatPluginError("remove build", err) + } + fmt.Printf("Removed build %q\n", args[0]) + return nil +} diff --git a/cmd/thv/app/plugin_helpers.go b/cmd/thv/app/plugin_helpers.go new file mode 100644 index 0000000000..1fd0a0a9be --- /dev/null +++ b/cmd/thv/app/plugin_helpers.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" + pluginclient "github.com/stacklok/toolhive/pkg/plugins/client" +) + +// newPluginClient creates a new Plugins API HTTP client using default settings. +// The context is used for server discovery; it is not stored. +func newPluginClient(ctx context.Context) *pluginclient.Client { + return pluginclient.NewDefaultClient(ctx) +} + +// completePluginNames provides shell completion for installed plugin names. +func completePluginNames(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + c := newPluginClient(cmd.Context()) + installed, err := c.List(cmd.Context(), plugins.ListOptions{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + names := make([]string, 0, len(installed)) + for _, p := range installed { + names = append(names, p.Metadata.Name) + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +// formatPluginError wraps an error with contextual information. If the +// underlying cause is ErrServerUnreachable it appends a helpful hint. +func formatPluginError(action string, err error) error { + if errors.Is(err, pluginclient.ErrServerUnreachable) { + return fmt.Errorf("failed to %s: %w\nHint: ensure 'thv serve' is running", action, err) + } + return fmt.Errorf("failed to %s: %w", action, err) +} + +// validatePluginScope returns a PreRunE that validates the --scope flag. +func validatePluginScope(scopeVar *string) func(*cobra.Command, []string) error { + return func(_ *cobra.Command, _ []string) error { + return plugins.ValidateScope(plugins.Scope(*scopeVar)) + } +} diff --git a/cmd/thv/app/plugin_info.go b/cmd/thv/app/plugin_info.go new file mode 100644 index 0000000000..f75d2ad841 --- /dev/null +++ b/cmd/thv/app/plugin_info.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +var ( + pluginInfoScope string + pluginInfoFormat string + pluginInfoProjectRoot string +) + +var pluginInfoCmd = &cobra.Command{ + Use: "info [plugin-name]", + Short: "Show plugin details", + Long: `Display detailed information about a plugin, including metadata, version, and installation status.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completePluginNames, + PreRunE: chainPreRunE( + validatePluginScope(&pluginInfoScope), + ValidateFormat(&pluginInfoFormat), + ), + RunE: pluginInfoCmdFunc, +} + +func init() { + pluginCmd.AddCommand(pluginInfoCmd) + + pluginInfoCmd.Flags().StringVar(&pluginInfoScope, "scope", "", "Filter by scope (user, project)") + AddFormatFlag(pluginInfoCmd, &pluginInfoFormat) + pluginInfoCmd.Flags().StringVar(&pluginInfoProjectRoot, "project-root", "", "Project root path for project-scoped plugins") +} + +func pluginInfoCmdFunc(cmd *cobra.Command, args []string) error { + c := newPluginClient(cmd.Context()) + + info, err := c.Info(cmd.Context(), plugins.InfoOptions{ + Name: args[0], + Scope: plugins.Scope(pluginInfoScope), + ProjectRoot: pluginInfoProjectRoot, + }) + if err != nil { + return formatPluginError("get plugin info", err) + } + + switch pluginInfoFormat { + case FormatJSON: + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(data)) + default: + printPluginInfoText(info) + } + + return nil +} + +func printPluginInfoText(info *plugins.PluginInfo) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + _, _ = fmt.Fprintf(w, "Name:\t%s\n", info.Metadata.Name) + _, _ = fmt.Fprintf(w, "Version:\t%s\n", info.Metadata.Version) + _, _ = fmt.Fprintf(w, "Description:\t%s\n", info.Metadata.Description) + + if s := info.InstalledPlugin; s != nil { + _, _ = fmt.Fprintf(w, "Scope:\t%s\n", s.Scope) + _, _ = fmt.Fprintf(w, "Status:\t%s\n", s.Status) + _, _ = fmt.Fprintf(w, "Reference:\t%s\n", s.Reference) + _, _ = fmt.Fprintf(w, "Installed At:\t%s\n", s.InstalledAt.Format("2006-01-02 15:04:05")) + if len(s.Clients) > 0 { + _, _ = fmt.Fprintf(w, "Clients:\t%s\n", strings.Join(s.Clients, ", ")) + } + if len(s.Components) > 0 { + managed, declared := splitComponentInventory(s.Components) + if len(managed) > 0 { + _, _ = fmt.Fprintf(w, "Components:\t%s\n", formatComponentInventory(managed)) + } + if len(declared) > 0 { + _, _ = fmt.Fprintf(w, "Declared (NOT managed):\t%s\n", formatComponentInventory(declared)) + } + } + } + + if len(info.UnmaterializedComponents) > 0 { + _, _ = fmt.Fprintln(w, "\nUnmaterialized Components:") + for _, clientType := range sortedKeys(info.UnmaterializedComponents) { + types := info.UnmaterializedComponents[clientType] + labels := make([]string, 0, len(types)) + for _, t := range types { + labels = append(labels, string(t)) + } + _, _ = fmt.Fprintf(w, " %s:\t%s\n", clientType, strings.Join(labels, ", ")) + } + } + if len(info.ProjectScopeDegradedClients) > 0 { + degraded := append([]string(nil), info.ProjectScopeDegradedClients...) + sort.Strings(degraded) + _, _ = fmt.Fprintf(w, "\nProject-scope Degraded Clients:\t%s\n", strings.Join(degraded, ", ")) + } + + _ = w.Flush() +} + +// formatComponentInventory renders a ComponentInventory (map[string]int) as a +// sorted, space-separated "key=count" sequence for deterministic output. +func formatComponentInventory(inv plugins.ComponentInventory) string { + keys := sortedKeys(inv) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%d", k, inv[k])) + } + return strings.Join(parts, " ") +} + +// splitComponentInventory separates managed components (commands, agents, +// skills, hooks) from declared-but-unmanaged components (mcpServers, +// lspServers). MCP/LSP servers are declared in the plugin manifest but are +// not materialized or lifecycle-managed by ToolHive. +func splitComponentInventory(inv plugins.ComponentInventory) (managed, declared plugins.ComponentInventory) { + managed = plugins.ComponentInventory{} + declared = plugins.ComponentInventory{} + for k, v := range inv { + switch k { + case string(plugins.ComponentMCP), string(plugins.ComponentLSP): + declared[k] = v + default: + managed[k] = v + } + } + return managed, declared +} + +// sortedKeys returns the keys of m sorted lexicographically. +func sortedKeys[T any](m map[string]T) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/thv/app/plugin_install.go b/cmd/thv/app/plugin_install.go new file mode 100644 index 0000000000..09db6878f4 --- /dev/null +++ b/cmd/thv/app/plugin_install.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +var ( + pluginInstallScope string + pluginInstallClientsRaw string + pluginInstallForce bool + pluginInstallProjectRoot string + pluginInstallGroup string +) + +var pluginInstallCmd = &cobra.Command{ + Use: "install [plugin-name]", + Short: "Install a plugin", + Long: `Install a plugin by name or OCI reference. +The plugin will be fetched from a remote registry and installed locally.`, + Args: cobra.ExactArgs(1), + PreRunE: chainPreRunE( + validatePluginScope(&pluginInstallScope), + validateProjectRootForScope(&pluginInstallScope, &pluginInstallProjectRoot), + validateGroupFlag(), + ), + RunE: pluginInstallCmdFunc, +} + +func init() { + pluginCmd.AddCommand(pluginInstallCmd) + + pluginInstallCmd.Flags().StringVar(&pluginInstallClientsRaw, "clients", "", + `Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client`) + pluginInstallCmd.Flags().StringVar(&pluginInstallScope, "scope", string(plugins.ScopeUser), "Installation scope (user, project)") + pluginInstallCmd.Flags().BoolVar(&pluginInstallForce, "force", false, "Overwrite existing plugin directory") + pluginInstallCmd.Flags().StringVar( + &pluginInstallProjectRoot, "project-root", "", "Project root path for project-scoped installs", + ) + pluginInstallCmd.Flags().StringVar(&pluginInstallGroup, "group", "", "Group to add the plugin to after installation") +} + +func pluginInstallCmdFunc(cmd *cobra.Command, args []string) error { + c := newPluginClient(cmd.Context()) + + _, err := c.Install(cmd.Context(), plugins.InstallOptions{ + Name: args[0], + Scope: plugins.Scope(pluginInstallScope), + Clients: parsePluginInstallClients(pluginInstallClientsRaw), + Force: pluginInstallForce, + ProjectRoot: pluginInstallProjectRoot, + Group: pluginInstallGroup, + }) + if err != nil { + return formatPluginError("install plugin", err) + } + + return nil +} + +// parsePluginInstallClients splits a comma-separated --clients flag value. +// Empty input yields nil so the server applies its default client. +func parsePluginInstallClients(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/cmd/thv/app/plugin_list.go b/cmd/thv/app/plugin_list.go new file mode 100644 index 0000000000..9e82179610 --- /dev/null +++ b/cmd/thv/app/plugin_list.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +var ( + pluginListScope string + pluginListClient string + pluginListFormat string + pluginListProjectRoot string + pluginListGroup string +) + +var pluginListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List installed plugins", + Long: `List all currently installed plugins and their status.`, + PreRunE: chainPreRunE( + validatePluginScope(&pluginListScope), + ValidateFormat(&pluginListFormat), + validateGroupFlag(), + ), + RunE: pluginListCmdFunc, +} + +func init() { + pluginCmd.AddCommand(pluginListCmd) + + pluginListCmd.Flags().StringVar(&pluginListScope, "scope", "", "Filter by scope (user, project)") + pluginListCmd.Flags().StringVar(&pluginListClient, "client", "", "Filter by client application") + AddFormatFlag(pluginListCmd, &pluginListFormat) + AddGroupFlag(pluginListCmd, &pluginListGroup, false) + pluginListCmd.Flags().StringVar(&pluginListProjectRoot, "project-root", "", "Project root path for project-scoped plugins") +} + +func pluginListCmdFunc(cmd *cobra.Command, _ []string) error { + c := newPluginClient(cmd.Context()) + + installed, err := c.List(cmd.Context(), plugins.ListOptions{ + Scope: plugins.Scope(pluginListScope), + ClientApp: pluginListClient, + ProjectRoot: pluginListProjectRoot, + Group: pluginListGroup, + }) + if err != nil { + return formatPluginError("list plugins", err) + } + + switch pluginListFormat { + case FormatJSON: + if installed == nil { + installed = []plugins.InstalledPlugin{} + } + data, err := json.MarshalIndent(installed, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(data)) + default: + if len(installed) == 0 { + if pluginListScope != "" || pluginListClient != "" { + fmt.Println("No plugins found matching filters") + } else { + fmt.Println("No plugins installed") + } + return nil + } + printPluginListText(installed) + } + + return nil +} + +func printPluginListText(installed []plugins.InstalledPlugin) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + _, _ = fmt.Fprintln(w, "NAME\tVERSION\tSCOPE\tSTATUS\tCLIENTS\tREFERENCE") + + for _, p := range installed { + clients := strings.Join(p.Clients, ", ") + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + p.Metadata.Name, + p.Metadata.Version, + p.Scope, + p.Status, + clients, + p.Reference, + ) + } + + _ = w.Flush() +} diff --git a/cmd/thv/app/plugin_push.go b/cmd/thv/app/plugin_push.go new file mode 100644 index 0000000000..9ffb14ce5e --- /dev/null +++ b/cmd/thv/app/plugin_push.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +var pluginPushCmd = &cobra.Command{ + Use: "push [reference]", + Short: "Push a built plugin", + Long: `Push a previously built plugin artifact to a remote OCI registry.`, + Args: cobra.ExactArgs(1), + RunE: pluginPushCmdFunc, +} + +func init() { + pluginCmd.AddCommand(pluginPushCmd) +} + +func pluginPushCmdFunc(cmd *cobra.Command, args []string) error { + c := newPluginClient(cmd.Context()) + + err := c.Push(cmd.Context(), plugins.PushOptions{ + Reference: args[0], + }) + if err != nil { + return formatPluginError("push plugin", err) + } + + return nil +} diff --git a/cmd/thv/app/plugin_uninstall.go b/cmd/thv/app/plugin_uninstall.go new file mode 100644 index 0000000000..ccad722166 --- /dev/null +++ b/cmd/thv/app/plugin_uninstall.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "github.com/spf13/cobra" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +var ( + pluginUninstallScope string + pluginUninstallProjectRoot string +) + +var pluginUninstallCmd = &cobra.Command{ + Use: "uninstall [plugin-name]", + Short: "Uninstall a plugin", + Long: `Remove a previously installed plugin by name.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completePluginNames, + PreRunE: chainPreRunE( + validatePluginScope(&pluginUninstallScope), + validateProjectRootForScope(&pluginUninstallScope, &pluginUninstallProjectRoot), + ), + RunE: pluginUninstallCmdFunc, +} + +func init() { + pluginCmd.AddCommand(pluginUninstallCmd) + + pluginUninstallCmd.Flags().StringVar( + &pluginUninstallScope, "scope", string(plugins.ScopeUser), "Scope to uninstall from (user, project)", + ) + pluginUninstallCmd.Flags().StringVar( + &pluginUninstallProjectRoot, "project-root", "", "Project root path for project-scoped plugins", + ) +} + +func pluginUninstallCmdFunc(cmd *cobra.Command, args []string) error { + c := newPluginClient(cmd.Context()) + + err := c.Uninstall(cmd.Context(), plugins.UninstallOptions{ + Name: args[0], + Scope: plugins.Scope(pluginUninstallScope), + ProjectRoot: pluginUninstallProjectRoot, + }) + if err != nil { + return formatPluginError("uninstall plugin", err) + } + + return nil +} diff --git a/cmd/thv/app/plugin_validate.go b/cmd/thv/app/plugin_validate.go new file mode 100644 index 0000000000..2290e37d29 --- /dev/null +++ b/cmd/thv/app/plugin_validate.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "encoding/json" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" +) + +var pluginValidateFormat string + +var pluginValidateCmd = &cobra.Command{ + Use: "validate [path]", + Short: "Validate a plugin definition", + Long: `Check that a plugin definition in the given directory is valid and well-formed.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterDirs + }, + PreRunE: ValidateFormat(&pluginValidateFormat), + RunE: pluginValidateCmdFunc, +} + +func init() { + pluginCmd.AddCommand(pluginValidateCmd) + + AddFormatFlag(pluginValidateCmd, &pluginValidateFormat) +} + +func pluginValidateCmdFunc(cmd *cobra.Command, args []string) error { + absPath, err := filepath.Abs(args[0]) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + c := newPluginClient(cmd.Context()) + + result, err := c.Validate(cmd.Context(), absPath) + if err != nil { + return formatPluginError("validate plugin", err) + } + + switch pluginValidateFormat { + case FormatJSON: + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(data)) + default: + for _, e := range result.Errors { + fmt.Printf("Error: %s\n", e) + } + for _, w := range result.Warnings { + fmt.Printf("Warning: %s\n", w) + } + } + + if !result.Valid { + return fmt.Errorf("plugin validation failed") + } + + return nil +} diff --git a/docs/cli/thv.md b/docs/cli/thv.md index 74824dfad8..ac723df9ef 100644 --- a/docs/cli/thv.md +++ b/docs/cli/thv.md @@ -45,6 +45,7 @@ thv [flags] * [thv llm](thv_llm.md) - Manage LLM gateway authentication * [thv logs](thv_logs.md) - Output the logs of an MCP server or manage log files * [thv mcp](thv_mcp.md) - Interact with MCP servers for debugging +* [thv plugin](thv_plugin.md) - Manage plugins * [thv proxy](thv_proxy.md) - Create a transparent proxy for an MCP server with authentication support * [thv registry](thv_registry.md) - Manage MCP server registry * [thv rm](thv_rm.md) - Remove one or more MCP servers diff --git a/docs/cli/thv_plugin.md b/docs/cli/thv_plugin.md new file mode 100644 index 0000000000..5c57292a3d --- /dev/null +++ b/docs/cli/thv_plugin.md @@ -0,0 +1,43 @@ +--- +title: thv plugin +hide_title: true +description: Reference for ToolHive CLI command `thv plugin` +last_update: + author: autogenerated +slug: thv_plugin +mdx: + format: md +--- + +## thv plugin + +Manage plugins + +### Synopsis + +The plugin command provides subcommands to manage plugins. + +### Options + +``` + -h, --help help for plugin +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv plugin build](thv_plugin_build.md) - Build a plugin +* [thv plugin builds](thv_plugin_builds.md) - List locally-built plugin artifacts +* [thv plugin info](thv_plugin_info.md) - Show plugin details +* [thv plugin install](thv_plugin_install.md) - Install a plugin +* [thv plugin list](thv_plugin_list.md) - List installed plugins +* [thv plugin push](thv_plugin_push.md) - Push a built plugin +* [thv plugin uninstall](thv_plugin_uninstall.md) - Uninstall a plugin +* [thv plugin validate](thv_plugin_validate.md) - Validate a plugin definition + diff --git a/docs/cli/thv_plugin_build.md b/docs/cli/thv_plugin_build.md new file mode 100644 index 0000000000..2ad32f0bb4 --- /dev/null +++ b/docs/cli/thv_plugin_build.md @@ -0,0 +1,42 @@ +--- +title: thv plugin build +hide_title: true +description: Reference for ToolHive CLI command `thv plugin build` +last_update: + author: autogenerated +slug: thv_plugin_build +mdx: + format: md +--- + +## thv plugin build + +Build a plugin + +### Synopsis + +Build a plugin from a local directory into an OCI artifact that can be pushed to a registry. + +On success, prints the OCI reference of the built artifact to stdout. + +``` +thv plugin build [path] [flags] +``` + +### Options + +``` + -h, --help help for build + -t, --tag string OCI tag for the built artifact +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin](thv_plugin.md) - Manage plugins + diff --git a/docs/cli/thv_plugin_builds.md b/docs/cli/thv_plugin_builds.md new file mode 100644 index 0000000000..d1d3ae1329 --- /dev/null +++ b/docs/cli/thv_plugin_builds.md @@ -0,0 +1,41 @@ +--- +title: thv plugin builds +hide_title: true +description: Reference for ToolHive CLI command `thv plugin builds` +last_update: + author: autogenerated +slug: thv_plugin_builds +mdx: + format: md +--- + +## thv plugin builds + +List locally-built plugin artifacts + +### Synopsis + +List all locally-built OCI plugin artifacts stored in the local OCI store. + +``` +thv plugin builds [flags] +``` + +### Options + +``` + --format string Output format (json, text) (default "text") + -h, --help help for builds +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin](thv_plugin.md) - Manage plugins +* [thv plugin builds remove](thv_plugin_builds_remove.md) - Remove a locally-built plugin artifact + diff --git a/docs/cli/thv_plugin_builds_remove.md b/docs/cli/thv_plugin_builds_remove.md new file mode 100644 index 0000000000..657595d540 --- /dev/null +++ b/docs/cli/thv_plugin_builds_remove.md @@ -0,0 +1,39 @@ +--- +title: thv plugin builds remove +hide_title: true +description: Reference for ToolHive CLI command `thv plugin builds remove` +last_update: + author: autogenerated +slug: thv_plugin_builds_remove +mdx: + format: md +--- + +## thv plugin builds remove + +Remove a locally-built plugin artifact + +### Synopsis + +Remove a locally-built OCI plugin artifact and its blobs from the local OCI store. + +``` +thv plugin builds remove [flags] +``` + +### Options + +``` + -h, --help help for remove +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin builds](thv_plugin_builds.md) - List locally-built plugin artifacts + diff --git a/docs/cli/thv_plugin_info.md b/docs/cli/thv_plugin_info.md new file mode 100644 index 0000000000..6814adfb90 --- /dev/null +++ b/docs/cli/thv_plugin_info.md @@ -0,0 +1,42 @@ +--- +title: thv plugin info +hide_title: true +description: Reference for ToolHive CLI command `thv plugin info` +last_update: + author: autogenerated +slug: thv_plugin_info +mdx: + format: md +--- + +## thv plugin info + +Show plugin details + +### Synopsis + +Display detailed information about a plugin, including metadata, version, and installation status. + +``` +thv plugin info [plugin-name] [flags] +``` + +### Options + +``` + --format string Output format (json, text) (default "text") + -h, --help help for info + --project-root string Project root path for project-scoped plugins + --scope string Filter by scope (user, project) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin](thv_plugin.md) - Manage plugins + diff --git a/docs/cli/thv_plugin_install.md b/docs/cli/thv_plugin_install.md new file mode 100644 index 0000000000..7a3ef4e776 --- /dev/null +++ b/docs/cli/thv_plugin_install.md @@ -0,0 +1,45 @@ +--- +title: thv plugin install +hide_title: true +description: Reference for ToolHive CLI command `thv plugin install` +last_update: + author: autogenerated +slug: thv_plugin_install +mdx: + format: md +--- + +## thv plugin install + +Install a plugin + +### Synopsis + +Install a plugin by name or OCI reference. +The plugin will be fetched from a remote registry and installed locally. + +``` +thv plugin install [plugin-name] [flags] +``` + +### Options + +``` + --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client + --force Overwrite existing plugin directory + --group string Group to add the plugin to after installation + -h, --help help for install + --project-root string Project root path for project-scoped installs + --scope string Installation scope (user, project) (default "user") +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin](thv_plugin.md) - Manage plugins + diff --git a/docs/cli/thv_plugin_list.md b/docs/cli/thv_plugin_list.md new file mode 100644 index 0000000000..0f58af066d --- /dev/null +++ b/docs/cli/thv_plugin_list.md @@ -0,0 +1,44 @@ +--- +title: thv plugin list +hide_title: true +description: Reference for ToolHive CLI command `thv plugin list` +last_update: + author: autogenerated +slug: thv_plugin_list +mdx: + format: md +--- + +## thv plugin list + +List installed plugins + +### Synopsis + +List all currently installed plugins and their status. + +``` +thv plugin list [flags] +``` + +### Options + +``` + --client string Filter by client application + --format string Output format (json, text) (default "text") + --group string Filter by group + -h, --help help for list + --project-root string Project root path for project-scoped plugins + --scope string Filter by scope (user, project) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin](thv_plugin.md) - Manage plugins + diff --git a/docs/cli/thv_plugin_push.md b/docs/cli/thv_plugin_push.md new file mode 100644 index 0000000000..f7a0018eb8 --- /dev/null +++ b/docs/cli/thv_plugin_push.md @@ -0,0 +1,39 @@ +--- +title: thv plugin push +hide_title: true +description: Reference for ToolHive CLI command `thv plugin push` +last_update: + author: autogenerated +slug: thv_plugin_push +mdx: + format: md +--- + +## thv plugin push + +Push a built plugin + +### Synopsis + +Push a previously built plugin artifact to a remote OCI registry. + +``` +thv plugin push [reference] [flags] +``` + +### Options + +``` + -h, --help help for push +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin](thv_plugin.md) - Manage plugins + diff --git a/docs/cli/thv_plugin_uninstall.md b/docs/cli/thv_plugin_uninstall.md new file mode 100644 index 0000000000..44841542ad --- /dev/null +++ b/docs/cli/thv_plugin_uninstall.md @@ -0,0 +1,41 @@ +--- +title: thv plugin uninstall +hide_title: true +description: Reference for ToolHive CLI command `thv plugin uninstall` +last_update: + author: autogenerated +slug: thv_plugin_uninstall +mdx: + format: md +--- + +## thv plugin uninstall + +Uninstall a plugin + +### Synopsis + +Remove a previously installed plugin by name. + +``` +thv plugin uninstall [plugin-name] [flags] +``` + +### Options + +``` + -h, --help help for uninstall + --project-root string Project root path for project-scoped plugins + --scope string Scope to uninstall from (user, project) (default "user") +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin](thv_plugin.md) - Manage plugins + diff --git a/docs/cli/thv_plugin_validate.md b/docs/cli/thv_plugin_validate.md new file mode 100644 index 0000000000..add41951d9 --- /dev/null +++ b/docs/cli/thv_plugin_validate.md @@ -0,0 +1,40 @@ +--- +title: thv plugin validate +hide_title: true +description: Reference for ToolHive CLI command `thv plugin validate` +last_update: + author: autogenerated +slug: thv_plugin_validate +mdx: + format: md +--- + +## thv plugin validate + +Validate a plugin definition + +### Synopsis + +Check that a plugin definition in the given directory is valid and well-formed. + +``` +thv plugin validate [path] [flags] +``` + +### Options + +``` + --format string Output format (json, text) (default "text") + -h, --help help for validate +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv plugin](thv_plugin.md) - Manage plugins + diff --git a/docs/server/docs.go b/docs/server/docs.go index a0c023d1b9..2b22174eca 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1188,6 +1188,281 @@ const docTemplate = `{ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_plugins.BuildResult": { + "properties": { + "reference": { + "description": "Reference is the OCI reference of the built skill artifact.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.ComponentInventory": { + "additionalProperties": { + "type": "integer" + }, + "description": "Components is the inventory of component types declared by the plugin\n(e.g. {\"commands\": 3, \"skills\": 2}). Extracted from the OCI artifact.", + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.ComponentType": { + "enum": [ + "commands", + "agents", + "skills", + "hooks", + "mcpServers", + "lspServers" + ], + "type": "string", + "x-enum-varnames": [ + "ComponentCommands", + "ComponentAgents", + "ComponentSkills", + "ComponentHooks", + "ComponentMCP", + "ComponentLSP" + ] + }, + "github_com_stacklok_toolhive_pkg_plugins.Dependency": { + "properties": { + "digest": { + "description": "Digest is the OCI digest for upgrade detection.", + "type": "string" + }, + "name": { + "description": "Name is the dependency name.", + "type": "string" + }, + "reference": { + "description": "Reference is the OCI reference for the dependency.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.InstallStatus": { + "description": "Status is the current installation status.", + "enum": [ + "installed", + "pending", + "failed", + "installed", + "pending", + "failed" + ], + "type": "string", + "x-enum-varnames": [ + "InstallStatusInstalled", + "InstallStatusPending", + "InstallStatusFailed" + ] + }, + "github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin": { + "description": "InstalledPlugin contains the full installation record.", + "properties": { + "clients": { + "description": "Clients is the list of client identifiers the plugin is installed for.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "components": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ComponentInventory" + }, + "dependencies": { + "description": "Dependencies is the list of external plugin dependencies.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Dependency" + }, + "type": "array", + "uniqueItems": false + }, + "digest": { + "description": "Digest is the OCI digest (sha256:...) for upgrade detection.", + "type": "string" + }, + "installed_at": { + "description": "InstalledAt is the timestamp when the plugin was installed.", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginMetadata" + }, + "project_root": { + "description": "ProjectRoot is the project root path for project-scoped plugins. Empty for user-scoped.", + "type": "string" + }, + "reference": { + "description": "Reference is the full OCI reference (e.g. ghcr.io/org/plugin:v1).", + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Scope" + }, + "signature": { + "description": "Signature is the optional signing signature for the plugin artifact.", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstallStatus" + }, + "tag": { + "description": "Tag is the OCI tag (e.g. v1.0.0).", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.PluginContent": { + "properties": { + "description": { + "description": "Description is the plugin description from the OCI config labels.", + "type": "string" + }, + "files": { + "description": "Files is the list of all files in the artifact with their sizes.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginFileEntry" + }, + "type": "array", + "uniqueItems": false + }, + "license": { + "description": "License is the SPDX license identifier from the OCI config labels.", + "type": "string" + }, + "manifest": { + "description": "Manifest is the raw .claude-plugin/plugin.json body.", + "type": "string" + }, + "name": { + "description": "Name is the plugin name from the OCI config labels.", + "type": "string" + }, + "version": { + "description": "Version is the plugin version from the OCI config labels.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.PluginFileEntry": { + "properties": { + "path": { + "description": "Path is the file path within the artifact.", + "type": "string" + }, + "size": { + "description": "Size is the uncompressed file size in bytes.", + "type": "integer" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.PluginInfo": { + "properties": { + "installed_plugin": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin" + }, + "metadata": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginMetadata" + }, + "project_scope_degraded_clients": { + "description": "ProjectScopeDegradedClients lists the client types for which a\nproject-scoped install degraded (the adapter could only materialize at\nuser scope — e.g. Codex always writes to the user-scoped config.toml).\nPopulated by Info; empty for user-scoped installs. Recomputed at read\ntime from the stored scope + each adapter's capability, mirroring the\nUnmaterializedComponents pattern (no persistence needed — the degradation\nis deterministic from scope + client type).", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "unmaterialized_components": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ComponentType" + }, + "type": "array" + }, + "description": "UnmaterializedComponents lists, per client type, the component types the\nplugin declares that the installed client adapter does NOT load. Populated\nby Info by diffing InstalledPlugin.Components against each installed\nclient adapter's SupportedComponents.", + "type": "object" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.PluginMetadata": { + "description": "Metadata contains the plugin's metadata.", + "properties": { + "author": { + "description": "Author is the plugin author or maintainer.", + "type": "string" + }, + "description": { + "description": "Description is a human-readable description of the plugin.", + "type": "string" + }, + "keywords": { + "description": "Keywords is a list of keywords for categorization/search.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "license": { + "description": "License is the SPDX license identifier for the plugin.", + "type": "string" + }, + "name": { + "description": "Name is the unique name of the plugin (kebab-case).", + "type": "string" + }, + "version": { + "description": "Version is the semantic version of the plugin.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.Scope": { + "description": "Scope for the installation", + "enum": [ + "user", + "project", + "user", + "project" + ], + "type": "string", + "x-enum-varnames": [ + "ScopeUser", + "ScopeProject" + ] + }, + "github_com_stacklok_toolhive_pkg_plugins.ValidationResult": { + "properties": { + "errors": { + "description": "Errors is a list of validation errors, if any.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "valid": { + "description": "Valid indicates whether the skill definition is valid.", + "type": "boolean" + }, + "warnings": { + "description": "Warnings is a list of non-blocking validation warnings, if any.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket": { "description": "PerUser token bucket configuration for this tool.\n+optional", "properties": { @@ -1618,6 +1893,9 @@ const docTemplate = `{ "github_com_stacklok_toolhive_pkg_skills.InstallStatus": { "description": "Status is the current installation status.", "enum": [ + "installed", + "pending", + "failed", "installed", "pending", "failed" @@ -1708,6 +1986,8 @@ const docTemplate = `{ "github_com_stacklok_toolhive_pkg_skills.Scope": { "description": "Scope for the installation", "enum": [ + "user", + "project", "user", "project" ], @@ -2611,6 +2891,20 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.buildPluginRequest": { + "description": "Request to build a plugin from a local directory", + "properties": { + "path": { + "description": "Path to the plugin definition directory", + "type": "string" + }, + "tag": { + "description": "OCI tag for the built artifact", + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.buildSkillRequest": { "description": "Request to build a skill from a local directory", "properties": { @@ -3006,6 +3300,52 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.installPluginRequest": { + "description": "Request to install a plugin", + "properties": { + "clients": { + "description": "Clients lists target client identifiers (e.g., \"claude-code\"),\nor [\"all\"] to target every plugin-supporting client.\nOmitting this field installs to all available clients.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "force": { + "description": "Force allows overwriting unmanaged plugin directories", + "type": "boolean" + }, + "group": { + "description": "Group is the group name to add the plugin to after installation", + "type": "string" + }, + "name": { + "description": "Name or OCI reference of the plugin to install", + "type": "string" + }, + "project_root": { + "description": "ProjectRoot is the project root path for project-scoped installs", + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Scope" + }, + "version": { + "description": "Version to install (empty means latest)", + "type": "string" + } + }, + "type": "object" + }, + "pkg_api_v1.installPluginResponse": { + "description": "Response after successfully installing a plugin", + "properties": { + "plugin": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin" + } + }, + "type": "object" + }, "pkg_api_v1.installSkillRequest": { "description": "Request to install a skill", "properties": { @@ -3144,6 +3484,20 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.pluginListResponse": { + "description": "Response containing a list of installed plugins", + "properties": { + "plugins": { + "description": "List of installed plugins", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "pkg_api_v1.providerCapabilitiesResponse": { "description": "Capabilities of the secrets provider", "properties": { @@ -3170,6 +3524,16 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.pushPluginRequest": { + "description": "Request to push a built plugin artifact", + "properties": { + "reference": { + "description": "OCI reference to push", + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.pushSkillRequest": { "description": "Request to push a built skill artifact", "properties": { @@ -3597,6 +3961,16 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.validatePluginRequest": { + "description": "Request to validate a plugin definition", + "properties": { + "path": { + "description": "Path to the plugin definition directory", + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.validateSkillRequest": { "description": "Request to validate a skill definition", "properties": { @@ -4807,6 +5181,742 @@ const docTemplate = `{ ] } }, + "/api/v1beta/plugins": { + "get": { + "description": "Get a list of all installed plugins", + "parameters": [ + { + "description": "Filter by scope (user or project)", + "in": "query", + "name": "scope", + "schema": { + "enum": [ + "user", + "project" + ], + "type": "string" + } + }, + { + "description": "Filter by client app", + "in": "query", + "name": "client", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by project root path", + "in": "query", + "name": "project_root", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by group name", + "in": "query", + "name": "group", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.pluginListResponse" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "List all installed plugins", + "tags": [ + "plugins" + ] + }, + "post": { + "description": "Install a plugin from a remote source", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.installPluginRequest", + "summary": "request", + "description": "Install request" + } + ] + } + } + }, + "description": "Install request", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.installPluginResponse" + } + } + }, + "description": "Created", + "headers": { + "Location": { + "description": "URI of the installed plugin resource", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unauthorized (registry refused credentials)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (artifact not present in registry)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Too Many Requests (registry rate limit)" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway (upstream registry failure)" + }, + "504": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Gateway Timeout (upstream pull timed out)" + } + }, + "summary": "Install a plugin", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/build": { + "post": { + "description": "Build a plugin from a local directory", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.buildPluginRequest", + "summary": "request", + "description": "Build request" + } + ] + } + } + }, + "description": "Build request", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.BuildResult" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Build a plugin", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/builds": { + "get": { + "description": "Get a list of all locally-built OCI plugin artifacts in the local store", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.buildListResponse" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "List locally-built plugin artifacts", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/builds/{tag}": { + "delete": { + "description": "Remove a locally-built OCI plugin artifact and its blobs from the local store", + "parameters": [ + { + "description": "Artifact tag", + "in": "path", + "name": "tag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Delete a locally-built plugin artifact", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/content": { + "get": { + "description": "Retrieve the plugin.json body and file listing from an artifact\nwithout installing it. Accepts OCI refs, git refs, or local tags.", + "parameters": [ + { + "description": "OCI reference or local build tag", + "in": "query", + "name": "ref", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginContent" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unauthorized (registry refused credentials)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (artifact not present in registry)" + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Too Many Requests (registry rate limit)" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway (upstream registry or git resolver failure)" + }, + "504": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Gateway Timeout (upstream pull timed out)" + } + }, + "summary": "Get plugin content", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/push": { + "post": { + "description": "Push a built plugin artifact to a remote registry", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.pushPluginRequest", + "summary": "request", + "description": "Push request" + } + ] + } + } + }, + "description": "Push request", + "required": true + }, + "responses": { + "204": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "No Content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Push a plugin", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/validate": { + "post": { + "description": "Validate a plugin definition", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.validatePluginRequest", + "summary": "request", + "description": "Validate request" + } + ] + } + } + }, + "description": "Validate request", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ValidationResult" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Validate a plugin", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/{name}": { + "delete": { + "description": "Remove an installed plugin", + "parameters": [ + { + "description": "Plugin name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Scope to uninstall from (user or project)", + "in": "query", + "name": "scope", + "schema": { + "enum": [ + "user", + "project" + ], + "type": "string" + } + }, + { + "description": "Project root path for project-scoped plugins", + "in": "query", + "name": "project_root", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "No Content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Uninstall a plugin", + "tags": [ + "plugins" + ] + }, + "get": { + "description": "Get detailed information about a specific plugin", + "parameters": [ + { + "description": "Plugin name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Filter by scope (user or project)", + "in": "query", + "name": "scope", + "schema": { + "enum": [ + "user", + "project" + ], + "type": "string" + } + }, + { + "description": "Project root path for project-scoped plugins", + "in": "query", + "name": "project_root", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginInfo" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Get plugin details", + "tags": [ + "plugins" + ] + } + }, "/api/v1beta/registry": { "get": { "description": "Get a list of the current registries", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 54cf8b6e4c..4877fd8014 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1181,6 +1181,281 @@ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_plugins.BuildResult": { + "properties": { + "reference": { + "description": "Reference is the OCI reference of the built skill artifact.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.ComponentInventory": { + "additionalProperties": { + "type": "integer" + }, + "description": "Components is the inventory of component types declared by the plugin\n(e.g. {\"commands\": 3, \"skills\": 2}). Extracted from the OCI artifact.", + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.ComponentType": { + "enum": [ + "commands", + "agents", + "skills", + "hooks", + "mcpServers", + "lspServers" + ], + "type": "string", + "x-enum-varnames": [ + "ComponentCommands", + "ComponentAgents", + "ComponentSkills", + "ComponentHooks", + "ComponentMCP", + "ComponentLSP" + ] + }, + "github_com_stacklok_toolhive_pkg_plugins.Dependency": { + "properties": { + "digest": { + "description": "Digest is the OCI digest for upgrade detection.", + "type": "string" + }, + "name": { + "description": "Name is the dependency name.", + "type": "string" + }, + "reference": { + "description": "Reference is the OCI reference for the dependency.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.InstallStatus": { + "description": "Status is the current installation status.", + "enum": [ + "installed", + "pending", + "failed", + "installed", + "pending", + "failed" + ], + "type": "string", + "x-enum-varnames": [ + "InstallStatusInstalled", + "InstallStatusPending", + "InstallStatusFailed" + ] + }, + "github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin": { + "description": "InstalledPlugin contains the full installation record.", + "properties": { + "clients": { + "description": "Clients is the list of client identifiers the plugin is installed for.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "components": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ComponentInventory" + }, + "dependencies": { + "description": "Dependencies is the list of external plugin dependencies.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Dependency" + }, + "type": "array", + "uniqueItems": false + }, + "digest": { + "description": "Digest is the OCI digest (sha256:...) for upgrade detection.", + "type": "string" + }, + "installed_at": { + "description": "InstalledAt is the timestamp when the plugin was installed.", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginMetadata" + }, + "project_root": { + "description": "ProjectRoot is the project root path for project-scoped plugins. Empty for user-scoped.", + "type": "string" + }, + "reference": { + "description": "Reference is the full OCI reference (e.g. ghcr.io/org/plugin:v1).", + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Scope" + }, + "signature": { + "description": "Signature is the optional signing signature for the plugin artifact.", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstallStatus" + }, + "tag": { + "description": "Tag is the OCI tag (e.g. v1.0.0).", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.PluginContent": { + "properties": { + "description": { + "description": "Description is the plugin description from the OCI config labels.", + "type": "string" + }, + "files": { + "description": "Files is the list of all files in the artifact with their sizes.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginFileEntry" + }, + "type": "array", + "uniqueItems": false + }, + "license": { + "description": "License is the SPDX license identifier from the OCI config labels.", + "type": "string" + }, + "manifest": { + "description": "Manifest is the raw .claude-plugin/plugin.json body.", + "type": "string" + }, + "name": { + "description": "Name is the plugin name from the OCI config labels.", + "type": "string" + }, + "version": { + "description": "Version is the plugin version from the OCI config labels.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.PluginFileEntry": { + "properties": { + "path": { + "description": "Path is the file path within the artifact.", + "type": "string" + }, + "size": { + "description": "Size is the uncompressed file size in bytes.", + "type": "integer" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.PluginInfo": { + "properties": { + "installed_plugin": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin" + }, + "metadata": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginMetadata" + }, + "project_scope_degraded_clients": { + "description": "ProjectScopeDegradedClients lists the client types for which a\nproject-scoped install degraded (the adapter could only materialize at\nuser scope — e.g. Codex always writes to the user-scoped config.toml).\nPopulated by Info; empty for user-scoped installs. Recomputed at read\ntime from the stored scope + each adapter's capability, mirroring the\nUnmaterializedComponents pattern (no persistence needed — the degradation\nis deterministic from scope + client type).", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "unmaterialized_components": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ComponentType" + }, + "type": "array" + }, + "description": "UnmaterializedComponents lists, per client type, the component types the\nplugin declares that the installed client adapter does NOT load. Populated\nby Info by diffing InstalledPlugin.Components against each installed\nclient adapter's SupportedComponents.", + "type": "object" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.PluginMetadata": { + "description": "Metadata contains the plugin's metadata.", + "properties": { + "author": { + "description": "Author is the plugin author or maintainer.", + "type": "string" + }, + "description": { + "description": "Description is a human-readable description of the plugin.", + "type": "string" + }, + "keywords": { + "description": "Keywords is a list of keywords for categorization/search.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "license": { + "description": "License is the SPDX license identifier for the plugin.", + "type": "string" + }, + "name": { + "description": "Name is the unique name of the plugin (kebab-case).", + "type": "string" + }, + "version": { + "description": "Version is the semantic version of the plugin.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_plugins.Scope": { + "description": "Scope for the installation", + "enum": [ + "user", + "project", + "user", + "project" + ], + "type": "string", + "x-enum-varnames": [ + "ScopeUser", + "ScopeProject" + ] + }, + "github_com_stacklok_toolhive_pkg_plugins.ValidationResult": { + "properties": { + "errors": { + "description": "Errors is a list of validation errors, if any.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "valid": { + "description": "Valid indicates whether the skill definition is valid.", + "type": "boolean" + }, + "warnings": { + "description": "Warnings is a list of non-blocking validation warnings, if any.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket": { "description": "PerUser token bucket configuration for this tool.\n+optional", "properties": { @@ -1611,6 +1886,9 @@ "github_com_stacklok_toolhive_pkg_skills.InstallStatus": { "description": "Status is the current installation status.", "enum": [ + "installed", + "pending", + "failed", "installed", "pending", "failed" @@ -1701,6 +1979,8 @@ "github_com_stacklok_toolhive_pkg_skills.Scope": { "description": "Scope for the installation", "enum": [ + "user", + "project", "user", "project" ], @@ -2604,6 +2884,20 @@ }, "type": "object" }, + "pkg_api_v1.buildPluginRequest": { + "description": "Request to build a plugin from a local directory", + "properties": { + "path": { + "description": "Path to the plugin definition directory", + "type": "string" + }, + "tag": { + "description": "OCI tag for the built artifact", + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.buildSkillRequest": { "description": "Request to build a skill from a local directory", "properties": { @@ -2999,6 +3293,52 @@ }, "type": "object" }, + "pkg_api_v1.installPluginRequest": { + "description": "Request to install a plugin", + "properties": { + "clients": { + "description": "Clients lists target client identifiers (e.g., \"claude-code\"),\nor [\"all\"] to target every plugin-supporting client.\nOmitting this field installs to all available clients.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "force": { + "description": "Force allows overwriting unmanaged plugin directories", + "type": "boolean" + }, + "group": { + "description": "Group is the group name to add the plugin to after installation", + "type": "string" + }, + "name": { + "description": "Name or OCI reference of the plugin to install", + "type": "string" + }, + "project_root": { + "description": "ProjectRoot is the project root path for project-scoped installs", + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Scope" + }, + "version": { + "description": "Version to install (empty means latest)", + "type": "string" + } + }, + "type": "object" + }, + "pkg_api_v1.installPluginResponse": { + "description": "Response after successfully installing a plugin", + "properties": { + "plugin": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin" + } + }, + "type": "object" + }, "pkg_api_v1.installSkillRequest": { "description": "Request to install a skill", "properties": { @@ -3137,6 +3477,20 @@ }, "type": "object" }, + "pkg_api_v1.pluginListResponse": { + "description": "Response containing a list of installed plugins", + "properties": { + "plugins": { + "description": "List of installed plugins", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "pkg_api_v1.providerCapabilitiesResponse": { "description": "Capabilities of the secrets provider", "properties": { @@ -3163,6 +3517,16 @@ }, "type": "object" }, + "pkg_api_v1.pushPluginRequest": { + "description": "Request to push a built plugin artifact", + "properties": { + "reference": { + "description": "OCI reference to push", + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.pushSkillRequest": { "description": "Request to push a built skill artifact", "properties": { @@ -3590,6 +3954,16 @@ }, "type": "object" }, + "pkg_api_v1.validatePluginRequest": { + "description": "Request to validate a plugin definition", + "properties": { + "path": { + "description": "Path to the plugin definition directory", + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.validateSkillRequest": { "description": "Request to validate a skill definition", "properties": { @@ -4800,6 +5174,742 @@ ] } }, + "/api/v1beta/plugins": { + "get": { + "description": "Get a list of all installed plugins", + "parameters": [ + { + "description": "Filter by scope (user or project)", + "in": "query", + "name": "scope", + "schema": { + "enum": [ + "user", + "project" + ], + "type": "string" + } + }, + { + "description": "Filter by client app", + "in": "query", + "name": "client", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by project root path", + "in": "query", + "name": "project_root", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by group name", + "in": "query", + "name": "group", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.pluginListResponse" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "List all installed plugins", + "tags": [ + "plugins" + ] + }, + "post": { + "description": "Install a plugin from a remote source", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.installPluginRequest", + "summary": "request", + "description": "Install request" + } + ] + } + } + }, + "description": "Install request", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.installPluginResponse" + } + } + }, + "description": "Created", + "headers": { + "Location": { + "description": "URI of the installed plugin resource", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unauthorized (registry refused credentials)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (artifact not present in registry)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Too Many Requests (registry rate limit)" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway (upstream registry failure)" + }, + "504": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Gateway Timeout (upstream pull timed out)" + } + }, + "summary": "Install a plugin", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/build": { + "post": { + "description": "Build a plugin from a local directory", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.buildPluginRequest", + "summary": "request", + "description": "Build request" + } + ] + } + } + }, + "description": "Build request", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.BuildResult" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Build a plugin", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/builds": { + "get": { + "description": "Get a list of all locally-built OCI plugin artifacts in the local store", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.buildListResponse" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "List locally-built plugin artifacts", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/builds/{tag}": { + "delete": { + "description": "Remove a locally-built OCI plugin artifact and its blobs from the local store", + "parameters": [ + { + "description": "Artifact tag", + "in": "path", + "name": "tag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Delete a locally-built plugin artifact", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/content": { + "get": { + "description": "Retrieve the plugin.json body and file listing from an artifact\nwithout installing it. Accepts OCI refs, git refs, or local tags.", + "parameters": [ + { + "description": "OCI reference or local build tag", + "in": "query", + "name": "ref", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginContent" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Unauthorized (registry refused credentials)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found (artifact not present in registry)" + }, + "429": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Too Many Requests (registry rate limit)" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway (upstream registry or git resolver failure)" + }, + "504": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Gateway Timeout (upstream pull timed out)" + } + }, + "summary": "Get plugin content", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/push": { + "post": { + "description": "Push a built plugin artifact to a remote registry", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.pushPluginRequest", + "summary": "request", + "description": "Push request" + } + ] + } + } + }, + "description": "Push request", + "required": true + }, + "responses": { + "204": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "No Content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Push a plugin", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/validate": { + "post": { + "description": "Validate a plugin definition", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/pkg_api_v1.validatePluginRequest", + "summary": "request", + "description": "Validate request" + } + ] + } + } + }, + "description": "Validate request", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ValidationResult" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Validate a plugin", + "tags": [ + "plugins" + ] + } + }, + "/api/v1beta/plugins/{name}": { + "delete": { + "description": "Remove an installed plugin", + "parameters": [ + { + "description": "Plugin name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Scope to uninstall from (user or project)", + "in": "query", + "name": "scope", + "schema": { + "enum": [ + "user", + "project" + ], + "type": "string" + } + }, + { + "description": "Project root path for project-scoped plugins", + "in": "query", + "name": "project_root", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "No Content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Uninstall a plugin", + "tags": [ + "plugins" + ] + }, + "get": { + "description": "Get detailed information about a specific plugin", + "parameters": [ + { + "description": "Plugin name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Filter by scope (user or project)", + "in": "query", + "name": "scope", + "schema": { + "enum": [ + "user", + "project" + ], + "type": "string" + } + }, + { + "description": "Project root path for project-scoped plugins", + "in": "query", + "name": "project_root", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginInfo" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Get plugin details", + "tags": [ + "plugins" + ] + } + }, "/api/v1beta/registry": { "get": { "description": "Get a list of the current registries", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index b70a631aa5..4d44b73bb4 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1219,6 +1219,226 @@ components: description: TokenURL is the OAuth 2.0 token endpoint URL type: string type: object + github_com_stacklok_toolhive_pkg_plugins.BuildResult: + properties: + reference: + description: Reference is the OCI reference of the built skill artifact. + type: string + type: object + github_com_stacklok_toolhive_pkg_plugins.ComponentInventory: + additionalProperties: + type: integer + description: |- + Components is the inventory of component types declared by the plugin + (e.g. {"commands": 3, "skills": 2}). Extracted from the OCI artifact. + type: object + github_com_stacklok_toolhive_pkg_plugins.ComponentType: + enum: + - commands + - agents + - skills + - hooks + - mcpServers + - lspServers + type: string + x-enum-varnames: + - ComponentCommands + - ComponentAgents + - ComponentSkills + - ComponentHooks + - ComponentMCP + - ComponentLSP + github_com_stacklok_toolhive_pkg_plugins.Dependency: + properties: + digest: + description: Digest is the OCI digest for upgrade detection. + type: string + name: + description: Name is the dependency name. + type: string + reference: + description: Reference is the OCI reference for the dependency. + type: string + type: object + github_com_stacklok_toolhive_pkg_plugins.InstallStatus: + description: Status is the current installation status. + enum: + - installed + - pending + - failed + - installed + - pending + - failed + type: string + x-enum-varnames: + - InstallStatusInstalled + - InstallStatusPending + - InstallStatusFailed + github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin: + description: InstalledPlugin contains the full installation record. + properties: + clients: + description: Clients is the list of client identifiers the plugin is installed + for. + items: + type: string + type: array + uniqueItems: false + components: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ComponentInventory' + dependencies: + description: Dependencies is the list of external plugin dependencies. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Dependency' + type: array + uniqueItems: false + digest: + description: Digest is the OCI digest (sha256:...) for upgrade detection. + type: string + installed_at: + description: InstalledAt is the timestamp when the plugin was installed. + type: string + metadata: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginMetadata' + project_root: + description: ProjectRoot is the project root path for project-scoped plugins. + Empty for user-scoped. + type: string + reference: + description: Reference is the full OCI reference (e.g. ghcr.io/org/plugin:v1). + type: string + scope: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Scope' + signature: + description: Signature is the optional signing signature for the plugin + artifact. + type: string + status: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstallStatus' + tag: + description: Tag is the OCI tag (e.g. v1.0.0). + type: string + type: object + github_com_stacklok_toolhive_pkg_plugins.PluginContent: + properties: + description: + description: Description is the plugin description from the OCI config labels. + type: string + files: + description: Files is the list of all files in the artifact with their sizes. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginFileEntry' + type: array + uniqueItems: false + license: + description: License is the SPDX license identifier from the OCI config + labels. + type: string + manifest: + description: Manifest is the raw .claude-plugin/plugin.json body. + type: string + name: + description: Name is the plugin name from the OCI config labels. + type: string + version: + description: Version is the plugin version from the OCI config labels. + type: string + type: object + github_com_stacklok_toolhive_pkg_plugins.PluginFileEntry: + properties: + path: + description: Path is the file path within the artifact. + type: string + size: + description: Size is the uncompressed file size in bytes. + type: integer + type: object + github_com_stacklok_toolhive_pkg_plugins.PluginInfo: + properties: + installed_plugin: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin' + metadata: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginMetadata' + project_scope_degraded_clients: + description: |- + ProjectScopeDegradedClients lists the client types for which a + project-scoped install degraded (the adapter could only materialize at + user scope — e.g. Codex always writes to the user-scoped config.toml). + Populated by Info; empty for user-scoped installs. Recomputed at read + time from the stored scope + each adapter's capability, mirroring the + UnmaterializedComponents pattern (no persistence needed — the degradation + is deterministic from scope + client type). + items: + type: string + type: array + uniqueItems: false + unmaterialized_components: + additionalProperties: + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ComponentType' + type: array + description: |- + UnmaterializedComponents lists, per client type, the component types the + plugin declares that the installed client adapter does NOT load. Populated + by Info by diffing InstalledPlugin.Components against each installed + client adapter's SupportedComponents. + type: object + type: object + github_com_stacklok_toolhive_pkg_plugins.PluginMetadata: + description: Metadata contains the plugin's metadata. + properties: + author: + description: Author is the plugin author or maintainer. + type: string + description: + description: Description is a human-readable description of the plugin. + type: string + keywords: + description: Keywords is a list of keywords for categorization/search. + items: + type: string + type: array + uniqueItems: false + license: + description: License is the SPDX license identifier for the plugin. + type: string + name: + description: Name is the unique name of the plugin (kebab-case). + type: string + version: + description: Version is the semantic version of the plugin. + type: string + type: object + github_com_stacklok_toolhive_pkg_plugins.Scope: + description: Scope for the installation + enum: + - user + - project + - user + - project + type: string + x-enum-varnames: + - ScopeUser + - ScopeProject + github_com_stacklok_toolhive_pkg_plugins.ValidationResult: + properties: + errors: + description: Errors is a list of validation errors, if any. + items: + type: string + type: array + uniqueItems: false + valid: + description: Valid indicates whether the skill definition is valid. + type: boolean + warnings: + description: Warnings is a list of non-blocking validation warnings, if + any. + items: + type: string + type: array + uniqueItems: false + type: object github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket: description: |- PerUser token bucket configuration for this tool. @@ -1642,6 +1862,9 @@ components: - installed - pending - failed + - installed + - pending + - failed type: string x-enum-varnames: - InstallStatusInstalled @@ -1713,6 +1936,8 @@ components: enum: - user - project + - user + - project type: string x-enum-varnames: - ScopeUser @@ -2476,6 +2701,16 @@ components: type: array uniqueItems: false type: object + pkg_api_v1.buildPluginRequest: + description: Request to build a plugin from a local directory + properties: + path: + description: Path to the plugin definition directory + type: string + tag: + description: OCI tag for the built artifact + type: string + type: object pkg_api_v1.buildSkillRequest: description: Request to build a skill from a local directory properties: @@ -2780,6 +3015,42 @@ components: Use AddHeadersFromSecret for sensitive data like API keys. type: object type: object + pkg_api_v1.installPluginRequest: + description: Request to install a plugin + properties: + clients: + description: |- + Clients lists target client identifiers (e.g., "claude-code"), + or ["all"] to target every plugin-supporting client. + Omitting this field installs to all available clients. + items: + type: string + type: array + uniqueItems: false + force: + description: Force allows overwriting unmanaged plugin directories + type: boolean + group: + description: Group is the group name to add the plugin to after installation + type: string + name: + description: Name or OCI reference of the plugin to install + type: string + project_root: + description: ProjectRoot is the project root path for project-scoped installs + type: string + scope: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.Scope' + version: + description: Version to install (empty means latest) + type: string + type: object + pkg_api_v1.installPluginResponse: + description: Response after successfully installing a plugin + properties: + plugin: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin' + type: object pkg_api_v1.installSkillRequest: description: Request to install a skill properties: @@ -2883,6 +3154,16 @@ components: description: Total is the total number of items matching the query type: integer type: object + pkg_api_v1.pluginListResponse: + description: Response containing a list of installed plugins + properties: + plugins: + description: List of installed plugins + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.InstalledPlugin' + type: array + uniqueItems: false + type: object pkg_api_v1.providerCapabilitiesResponse: description: Capabilities of the secrets provider properties: @@ -2902,6 +3183,13 @@ components: description: Whether the provider can write secrets type: boolean type: object + pkg_api_v1.pushPluginRequest: + description: Request to push a built plugin artifact + properties: + reference: + description: OCI reference to push + type: string + type: object pkg_api_v1.pushSkillRequest: description: Request to push a built skill artifact properties: @@ -3237,6 +3525,13 @@ components: type: array uniqueItems: false type: object + pkg_api_v1.validatePluginRequest: + description: Request to validate a plugin definition + properties: + path: + description: Path to the plugin definition directory + type: string + type: object pkg_api_v1.validateSkillRequest: description: Request to validate a skill definition properties: @@ -4174,6 +4469,457 @@ paths: summary: Get group details tags: - groups + /api/v1beta/plugins: + get: + description: Get a list of all installed plugins + parameters: + - description: Filter by scope (user or project) + in: query + name: scope + schema: + enum: + - user + - project + type: string + - description: Filter by client app + in: query + name: client + schema: + type: string + - description: Filter by project root path + in: query + name: project_root + schema: + type: string + - description: Filter by group name + in: query + name: group + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.pluginListResponse' + description: OK + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: List all installed plugins + tags: + - plugins + post: + description: Install a plugin from a remote source + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/pkg_api_v1.installPluginRequest' + description: Install request + summary: request + description: Install request + required: true + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.installPluginResponse' + description: Created + headers: + Location: + description: URI of the installed plugin resource + schema: + type: string + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "401": + content: + application/json: + schema: + type: string + description: Unauthorized (registry refused credentials) + "404": + content: + application/json: + schema: + type: string + description: Not Found (artifact not present in registry) + "409": + content: + application/json: + schema: + type: string + description: Conflict + "429": + content: + application/json: + schema: + type: string + description: Too Many Requests (registry rate limit) + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + "502": + content: + application/json: + schema: + type: string + description: Bad Gateway (upstream registry failure) + "504": + content: + application/json: + schema: + type: string + description: Gateway Timeout (upstream pull timed out) + summary: Install a plugin + tags: + - plugins + /api/v1beta/plugins/{name}: + delete: + description: Remove an installed plugin + parameters: + - description: Plugin name + in: path + name: name + required: true + schema: + type: string + - description: Scope to uninstall from (user or project) + in: query + name: scope + schema: + enum: + - user + - project + type: string + - description: Project root path for project-scoped plugins + in: query + name: project_root + schema: + type: string + responses: + "204": + content: + application/json: + schema: + type: string + description: No Content + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "404": + content: + application/json: + schema: + type: string + description: Not Found + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: Uninstall a plugin + tags: + - plugins + get: + description: Get detailed information about a specific plugin + parameters: + - description: Plugin name + in: path + name: name + required: true + schema: + type: string + - description: Filter by scope (user or project) + in: query + name: scope + schema: + enum: + - user + - project + type: string + - description: Project root path for project-scoped plugins + in: query + name: project_root + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginInfo' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "404": + content: + application/json: + schema: + type: string + description: Not Found + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: Get plugin details + tags: + - plugins + /api/v1beta/plugins/build: + post: + description: Build a plugin from a local directory + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/pkg_api_v1.buildPluginRequest' + description: Build request + summary: request + description: Build request + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.BuildResult' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: Build a plugin + tags: + - plugins + /api/v1beta/plugins/builds: + get: + description: Get a list of all locally-built OCI plugin artifacts in the local + store + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.buildListResponse' + description: OK + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: List locally-built plugin artifacts + tags: + - plugins + /api/v1beta/plugins/builds/{tag}: + delete: + description: Remove a locally-built OCI plugin artifact and its blobs from the + local store + parameters: + - description: Artifact tag + in: path + name: tag + required: true + schema: + type: string + responses: + "204": + content: + application/json: + schema: + type: string + description: No Content + "404": + content: + application/json: + schema: + type: string + description: Not Found + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: Delete a locally-built plugin artifact + tags: + - plugins + /api/v1beta/plugins/content: + get: + description: |- + Retrieve the plugin.json body and file listing from an artifact + without installing it. Accepts OCI refs, git refs, or local tags. + parameters: + - description: OCI reference or local build tag + in: query + name: ref + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginContent' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "401": + content: + application/json: + schema: + type: string + description: Unauthorized (registry refused credentials) + "404": + content: + application/json: + schema: + type: string + description: Not Found (artifact not present in registry) + "429": + content: + application/json: + schema: + type: string + description: Too Many Requests (registry rate limit) + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + "502": + content: + application/json: + schema: + type: string + description: Bad Gateway (upstream registry or git resolver failure) + "504": + content: + application/json: + schema: + type: string + description: Gateway Timeout (upstream pull timed out) + summary: Get plugin content + tags: + - plugins + /api/v1beta/plugins/push: + post: + description: Push a built plugin artifact to a remote registry + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/pkg_api_v1.pushPluginRequest' + description: Push request + summary: request + description: Push request + required: true + responses: + "204": + content: + application/json: + schema: + type: string + description: No Content + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "404": + content: + application/json: + schema: + type: string + description: Not Found + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: Push a plugin + tags: + - plugins + /api/v1beta/plugins/validate: + post: + description: Validate a plugin definition + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/pkg_api_v1.validatePluginRequest' + description: Validate request + summary: request + description: Validate request + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.ValidationResult' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + summary: Validate a plugin + tags: + - plugins /api/v1beta/registry: get: description: Get a list of the current registries diff --git a/pkg/api/server.go b/pkg/api/server.go index dcb5028fda..a3f68c08fa 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -37,6 +37,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + ociplugins "github.com/stacklok/toolhive-core/oci/plugins" ociskills "github.com/stacklok/toolhive-core/oci/skills" regtypes "github.com/stacklok/toolhive-core/registry/types" v1 "github.com/stacklok/toolhive/pkg/api/v1" @@ -49,6 +50,9 @@ import ( "github.com/stacklok/toolhive/pkg/fileutils" "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/plugins/adapters" + "github.com/stacklok/toolhive/pkg/plugins/pluginsvc" "github.com/stacklok/toolhive/pkg/recovery" "github.com/stacklok/toolhive/pkg/registry" "github.com/stacklok/toolhive/pkg/server/discovery" @@ -78,21 +82,23 @@ const ( // ServerBuilder provides a fluent interface for building and configuring the API server type ServerBuilder struct { - address string - isUnixSocket bool - debugMode bool - enableDocs bool - nonce string - oidcConfig *auth.TokenValidatorConfig - otelEnabled bool - middlewares []func(http.Handler) http.Handler - customRoutes map[string]http.Handler - containerRuntime runtime.Runtime - clientManager client.Manager - workloadManager workloads.Manager - groupManager groups.Manager - skillManager skills.SkillService - skillStoreCloser io.Closer + address string + isUnixSocket bool + debugMode bool + enableDocs bool + nonce string + oidcConfig *auth.TokenValidatorConfig + otelEnabled bool + middlewares []func(http.Handler) http.Handler + customRoutes map[string]http.Handler + containerRuntime runtime.Runtime + clientManager client.Manager + workloadManager workloads.Manager + groupManager groups.Manager + skillManager skills.SkillService + skillStoreCloser io.Closer + pluginManager plugins.PluginService + pluginStoreCloser io.Closer } // NewServerBuilder creates a new ServerBuilder with default configuration @@ -283,46 +289,111 @@ func (b *ServerBuilder) createDefaultManagers(ctx context.Context) error { } if b.skillManager == nil { - store, storeErr := sqlite.NewDefaultSkillStore() - if storeErr != nil { - return fmt.Errorf("failed to create skill store: %w", storeErr) - } - b.skillStoreCloser = store - cm, cmErr := client.NewClientManager() - if cmErr != nil { - _ = store.Close() - return fmt.Errorf("failed to create client manager for skills: %w", cmErr) + if err := b.createDefaultSkillManager(); err != nil { + return err } + } - ociStore, ociErr := ociskills.NewStore(ociskills.DefaultStoreRoot()) - if ociErr != nil { - _ = store.Close() - return fmt.Errorf("failed to create OCI skill store: %w", ociErr) - } - ociRegistry, regErr := newOCIRegistryClient() - if regErr != nil { - _ = store.Close() - // ociStore is directory-backed with no open handles; no cleanup needed. - return fmt.Errorf("failed to create OCI registry client: %w", regErr) - } - packager := ociskills.NewPackager(ociStore) - - skillOpts := []skillsvc.Option{ - skillsvc.WithPathResolver(&clientPathAdapter{cm: cm}), - skillsvc.WithOCIStore(ociStore), - skillsvc.WithPackager(packager), - skillsvc.WithRegistryClient(ociRegistry), - skillsvc.WithGroupManager(b.groupManager), + if b.pluginManager == nil { + if err := b.createDefaultPluginManager(); err != nil { + return err } + } + + return nil +} + +// createDefaultSkillManager builds the default skill service (skillsvc.New) +// wired with a SQLite skill store, the OCI skill store/packager/registry, +// a path resolver backed by the client manager, the group manager, the +// lazy registry lookup, and the git resolver. +func (b *ServerBuilder) createDefaultSkillManager() error { + store, storeErr := sqlite.NewDefaultSkillStore() + if storeErr != nil { + return fmt.Errorf("failed to create skill store: %w", storeErr) + } + b.skillStoreCloser = store + cm, cmErr := client.NewClientManager() + if cmErr != nil { + _ = store.Close() + return fmt.Errorf("failed to create client manager for skills: %w", cmErr) + } + + ociStore, ociErr := ociskills.NewStore(ociskills.DefaultStoreRoot()) + if ociErr != nil { + _ = store.Close() + return fmt.Errorf("failed to create OCI skill store: %w", ociErr) + } + ociRegistry, regErr := newOCIRegistryClient() + if regErr != nil { + _ = store.Close() + // ociStore is directory-backed with no open handles; no cleanup needed. + return fmt.Errorf("failed to create OCI registry client: %w", regErr) + } + packager := ociskills.NewPackager(ociStore) + + skillOpts := []skillsvc.Option{ + skillsvc.WithPathResolver(&clientPathAdapter{cm: cm}), + skillsvc.WithOCIStore(ociStore), + skillsvc.WithPackager(packager), + skillsvc.WithRegistryClient(ociRegistry), + skillsvc.WithGroupManager(b.groupManager), + } + + skillOpts = append(skillOpts, + skillsvc.WithSkillLookup(lazySkillLookup{}), + skillsvc.WithGitResolver(gitresolver.NewResolver()), + ) + + b.skillManager = skillsvc.New(store, skillOpts...) + return nil +} - skillOpts = append(skillOpts, - skillsvc.WithSkillLookup(lazySkillLookup{}), - skillsvc.WithGitResolver(gitresolver.NewResolver()), - ) +// createDefaultPluginManager builds the default plugin service (pluginsvc.New) +// wired with a SQLite plugin store, the OCI plugin store/packager/registry, +// per-client materialization adapters, and the group manager. Mirrors the +// skill-manager block in createDefaultManagers. +func (b *ServerBuilder) createDefaultPluginManager() error { + store, storeErr := sqlite.NewDefaultPluginStore() + if storeErr != nil { + return fmt.Errorf("failed to create plugin store: %w", storeErr) + } + b.pluginStoreCloser = store + cm, cmErr := client.NewClientManager() + if cmErr != nil { + _ = store.Close() + return fmt.Errorf("failed to create client manager for plugins: %w", cmErr) + } + + ociStore, ociErr := ociplugins.NewStore(ociplugins.DefaultStoreRoot()) + if ociErr != nil { + _ = store.Close() + return fmt.Errorf("failed to create OCI plugin store: %w", ociErr) + } + ociRegistry, regErr := newPluginOCIRegistryClient() + if regErr != nil { + _ = store.Close() + // ociStore is directory-backed with no open handles; no cleanup needed. + return fmt.Errorf("failed to create plugin OCI registry client: %w", regErr) + } + packager := ociplugins.NewPackager(ociStore) + + materializers := map[string]plugins.MaterializationAdapter{ + string(client.ClaudeCode): adapters.NewClaudeCodeAdapter(cm), + string(client.Codex): adapters.NewCodexAdapter(cm), + } - b.skillManager = skillsvc.New(store, skillOpts...) + pluginOpts := []pluginsvc.Option{ + pluginsvc.WithStore(store), + pluginsvc.WithOCIStore(ociStore), + pluginsvc.WithPackager(packager), + pluginsvc.WithRegistryClient(ociRegistry), + pluginsvc.WithGroupManager(b.groupManager), + pluginsvc.WithMaterializers(materializers), + pluginsvc.WithClientManager(cm), } + b.pluginManager = pluginsvc.New(pluginOpts...) return nil } @@ -348,6 +419,7 @@ func (b *ServerBuilder) setupDefaultRoutes(r *chi.Mux) { "/api/v1beta/secrets": v1.SecretsRouter(), "/api/v1beta/groups": v1.GroupsRouter(b.groupManager, b.workloadManager, b.clientManager), "/api/v1beta/skills": v1.SkillsRouter(b.skillManager), + "/api/v1beta/plugins": v1.PluginsRouter(b.pluginManager), "/registry": v1.RegistryV01Router(), } for prefix, router := range standardRouters { @@ -437,13 +509,14 @@ func getComponentAndVersionFromRequest(r *http.Request) (string, string, bool) { // Server represents a configured HTTP server type Server struct { - httpServer *http.Server - listener net.Listener - address string - isUnixSocket bool - addrType string - nonce string - storeCloser io.Closer + httpServer *http.Server + listener net.Listener + address string + isUnixSocket bool + addrType string + nonce string + skillStoreCloser io.Closer + pluginStoreCloser io.Closer } // NewServer creates a new Server instance from a pre-configured builder @@ -475,13 +548,14 @@ func NewServer(ctx context.Context, builder *ServerBuilder) (*Server, error) { } return &Server{ - httpServer: httpServer, - listener: listener, - address: builder.address, - isUnixSocket: builder.isUnixSocket, - addrType: addrType, - nonce: builder.nonce, - storeCloser: builder.skillStoreCloser, + httpServer: httpServer, + listener: listener, + address: builder.address, + isUnixSocket: builder.isUnixSocket, + addrType: addrType, + nonce: builder.nonce, + skillStoreCloser: builder.skillStoreCloser, + pluginStoreCloser: builder.pluginStoreCloser, }, nil } @@ -602,11 +676,16 @@ func (s *Server) cleanup() { slog.Warn("failed to remove discovery file", "error", err) } } - if s.storeCloser != nil { - if err := s.storeCloser.Close(); err != nil { + if s.skillStoreCloser != nil { + if err := s.skillStoreCloser.Close(); err != nil { slog.Warn("failed to close skill store", "error", err) } } + if s.pluginStoreCloser != nil { + if err := s.pluginStoreCloser.Close(); err != nil { + slog.Warn("failed to close plugin store", "error", err) + } + } if s.isUnixSocket { cleanupUnixSocket(s.address) } @@ -702,6 +781,53 @@ func ociRefHost(ref string) string { return ref } +// newPluginOCIRegistryClient creates an OCI registry client for plugin +// artifacts. In dev mode (TOOLHIVE_DEV=true), plain HTTP is used for loopback +// registries only via devModePluginRegistryClient. Mirrors +// newOCIRegistryClient (which is the skills analogue) but typed for the +// toolhive-core oci/plugins package. +func newPluginOCIRegistryClient() (ociplugins.RegistryClient, error) { + secure, err := ociplugins.NewRegistry() + if err != nil { + return nil, err + } + if os.Getenv("TOOLHIVE_DEV") != "true" { + return secure, nil + } + plain, err := ociplugins.NewRegistry(ociplugins.WithPlainHTTP(true)) + if err != nil { + return nil, err + } + return &devModePluginRegistryClient{secure: secure, plain: plain}, nil +} + +// devModePluginRegistryClient dispatches each Pull/Push to a plain-HTTP client +// when the target reference's host is loopback (a local test registry), and to +// a normal TLS client otherwise. Mirrors devModeOCIRegistryClient. +type devModePluginRegistryClient struct { + secure ociplugins.RegistryClient + plain ociplugins.RegistryClient +} + +func (c *devModePluginRegistryClient) Pull( + ctx context.Context, store *ociplugins.Store, ref string, +) (digest.Digest, error) { + return c.clientFor(ref).Pull(ctx, store, ref) +} + +func (c *devModePluginRegistryClient) Push( + ctx context.Context, store *ociplugins.Store, manifestDigest digest.Digest, ref string, +) error { + return c.clientFor(ref).Push(ctx, store, manifestDigest, ref) +} + +func (c *devModePluginRegistryClient) clientFor(ref string) ociplugins.RegistryClient { + if networking.IsLocalhost(ociRefHost(ref)) { + return c.plain + } + return c.secure +} + // lazySkillLookup implements skillsvc.SkillLookup by resolving the registry // provider on each call. This ensures that registry config changes (via // thv config set-registry or the API) are picked up without restarting diff --git a/pkg/api/v1/plugins.go b/pkg/api/v1/plugins.go new file mode 100644 index 0000000000..2ec0f3f261 --- /dev/null +++ b/pkg/api/v1/plugins.go @@ -0,0 +1,392 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/stacklok/toolhive-core/httperr" + apierrors "github.com/stacklok/toolhive/pkg/api/errors" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// PluginsRoutes defines the routes for plugin management. +type PluginsRoutes struct { + pluginService plugins.PluginService +} + +// PluginsRouter creates a new router for plugin management endpoints. +func PluginsRouter(pluginService plugins.PluginService) http.Handler { + routes := PluginsRoutes{ + pluginService: pluginService, + } + + r := chi.NewRouter() + r.Get("/", apierrors.ErrorHandler(routes.listPlugins)) + r.Post("/", apierrors.ErrorHandler(routes.installPlugin)) + r.Delete("/{name}", apierrors.ErrorHandler(routes.uninstallPlugin)) + r.Get("/{name}", apierrors.ErrorHandler(routes.getPluginInfo)) + r.Post("/validate", apierrors.ErrorHandler(routes.validatePlugin)) + r.Post("/build", apierrors.ErrorHandler(routes.buildPlugin)) + r.Post("/push", apierrors.ErrorHandler(routes.pushPlugin)) + r.Get("/builds", apierrors.ErrorHandler(routes.listBuilds)) + r.Delete("/builds/{tag}", apierrors.ErrorHandler(routes.deleteBuild)) + r.Get("/content", apierrors.ErrorHandler(routes.getPluginContent)) + + return r +} + +// listPlugins returns a list of installed plugins. +// +// @Summary List all installed plugins +// @Description Get a list of all installed plugins +// @Tags plugins +// @Produce json +// @Param scope query string false "Filter by scope (user or project)" Enums(user, project) +// @Param client query string false "Filter by client app" +// @Param project_root query string false "Filter by project root path" +// @Param group query string false "Filter by group name" +// @Success 200 {object} pluginListResponse +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/plugins [get] +func (s *PluginsRoutes) listPlugins(w http.ResponseWriter, r *http.Request) error { + scope := plugins.Scope(r.URL.Query().Get("scope")) + projectRoot := r.URL.Query().Get("project_root") + client := r.URL.Query().Get("client") + group := r.URL.Query().Get("group") + + result, err := s.pluginService.List(r.Context(), plugins.ListOptions{ + Scope: scope, + ClientApp: client, + ProjectRoot: projectRoot, + Group: group, + }) + if err != nil { + return err + } + + if result == nil { + result = []plugins.InstalledPlugin{} + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(pluginListResponse{Plugins: result}) +} + +// installPlugin installs a plugin from a remote source. +// +// @Summary Install a plugin +// @Description Install a plugin from a remote source +// @Tags plugins +// @Accept json +// @Produce json +// @Param request body installPluginRequest true "Install request" +// @Success 201 {object} installPluginResponse +// @Header 201 {string} Location "URI of the installed plugin resource" +// @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized (registry refused credentials)" +// @Failure 404 {string} string "Not Found (artifact not present in registry)" +// @Failure 409 {string} string "Conflict" +// @Failure 429 {string} string "Too Many Requests (registry rate limit)" +// @Failure 500 {string} string "Internal Server Error" +// @Failure 502 {string} string "Bad Gateway (upstream registry failure)" +// @Failure 504 {string} string "Gateway Timeout (upstream pull timed out)" +// @Router /api/v1beta/plugins [post] +func (s *PluginsRoutes) installPlugin(w http.ResponseWriter, r *http.Request) error { + var req installPluginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid request body: %w", err), + http.StatusBadRequest, + ) + } + + result, err := s.pluginService.Install(r.Context(), plugins.InstallOptions{ + Name: req.Name, + Version: req.Version, + Scope: req.Scope, + ProjectRoot: req.ProjectRoot, + Clients: req.Clients, + Force: req.Force, + Group: req.Group, + }) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", fmt.Sprintf("/api/v1beta/plugins/%s", result.Plugin.Metadata.Name)) + w.WriteHeader(http.StatusCreated) + return json.NewEncoder(w).Encode(installPluginResponse{Plugin: result.Plugin}) +} + +// uninstallPlugin removes an installed plugin. +// +// @Summary Uninstall a plugin +// @Description Remove an installed plugin +// @Tags plugins +// @Param name path string true "Plugin name" +// @Param scope query string false "Scope to uninstall from (user or project)" Enums(user, project) +// @Param project_root query string false "Project root path for project-scoped plugins" +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/plugins/{name} [delete] +func (s *PluginsRoutes) uninstallPlugin(w http.ResponseWriter, r *http.Request) error { + name := chi.URLParam(r, "name") + + if err := plugins.ValidatePluginName(name); err != nil { + return httperr.WithCode(err, http.StatusBadRequest) + } + + scope := plugins.Scope(r.URL.Query().Get("scope")) + projectRoot := r.URL.Query().Get("project_root") + + if err := s.pluginService.Uninstall(r.Context(), plugins.UninstallOptions{ + Name: name, + Scope: scope, + ProjectRoot: projectRoot, + }); err != nil { + return err + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// getPluginInfo returns detailed information about a plugin. +// +// @Summary Get plugin details +// @Description Get detailed information about a specific plugin +// @Tags plugins +// @Produce json +// @Param name path string true "Plugin name" +// @Param scope query string false "Filter by scope (user or project)" Enums(user, project) +// @Param project_root query string false "Project root path for project-scoped plugins" +// @Success 200 {object} plugins.PluginInfo +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/plugins/{name} [get] +func (s *PluginsRoutes) getPluginInfo(w http.ResponseWriter, r *http.Request) error { + name := chi.URLParam(r, "name") + + if err := plugins.ValidatePluginName(name); err != nil { + return httperr.WithCode(err, http.StatusBadRequest) + } + + scope := plugins.Scope(r.URL.Query().Get("scope")) + projectRoot := r.URL.Query().Get("project_root") + + info, err := s.pluginService.Info(r.Context(), plugins.InfoOptions{ + Name: name, + Scope: scope, + ProjectRoot: projectRoot, + }) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(info) +} + +// validatePlugin checks whether a plugin definition is valid. +// +// @Summary Validate a plugin +// @Description Validate a plugin definition +// @Tags plugins +// @Accept json +// @Produce json +// @Param request body validatePluginRequest true "Validate request" +// @Success 200 {object} plugins.ValidationResult +// @Failure 400 {string} string "Bad Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/plugins/validate [post] +func (s *PluginsRoutes) validatePlugin(w http.ResponseWriter, r *http.Request) error { + var req validatePluginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid request body: %w", err), + http.StatusBadRequest, + ) + } + + if req.Path == "" { + return httperr.WithCode( + fmt.Errorf("path is required"), + http.StatusBadRequest, + ) + } + + result, err := s.pluginService.Validate(r.Context(), req.Path) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(result) +} + +// buildPlugin builds a plugin from a local directory into an OCI artifact. +// +// @Summary Build a plugin +// @Description Build a plugin from a local directory +// @Tags plugins +// @Accept json +// @Produce json +// @Param request body buildPluginRequest true "Build request" +// @Success 200 {object} plugins.BuildResult +// @Failure 400 {string} string "Bad Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/plugins/build [post] +func (s *PluginsRoutes) buildPlugin(w http.ResponseWriter, r *http.Request) error { + var req buildPluginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid request body: %w", err), + http.StatusBadRequest, + ) + } + + if req.Path == "" { + return httperr.WithCode( + fmt.Errorf("path is required"), + http.StatusBadRequest, + ) + } + + result, err := s.pluginService.Build(r.Context(), plugins.BuildOptions{ + Path: req.Path, + Tag: req.Tag, + }) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(result) +} + +// pushPlugin pushes a built plugin artifact to a remote registry. +// +// @Summary Push a plugin +// @Description Push a built plugin artifact to a remote registry +// @Tags plugins +// @Accept json +// @Param request body pushPluginRequest true "Push request" +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/plugins/push [post] +func (s *PluginsRoutes) pushPlugin(w http.ResponseWriter, r *http.Request) error { + var req pushPluginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid request body: %w", err), + http.StatusBadRequest, + ) + } + + if req.Reference == "" { + return httperr.WithCode( + fmt.Errorf("reference is required"), + http.StatusBadRequest, + ) + } + + if err := s.pluginService.Push(r.Context(), plugins.PushOptions{ + Reference: req.Reference, + }); err != nil { + return err + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// listBuilds returns a list of locally-built OCI plugin artifacts. +// +// @Summary List locally-built plugin artifacts +// @Description Get a list of all locally-built OCI plugin artifacts in the local store +// @Tags plugins +// @Produce json +// @Success 200 {object} buildListResponse +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/plugins/builds [get] +func (s *PluginsRoutes) listBuilds(w http.ResponseWriter, r *http.Request) error { + builds, err := s.pluginService.ListBuilds(r.Context()) + if err != nil { + return err + } + + if builds == nil { + builds = []plugins.LocalBuild{} + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(pluginBuildListResponse{Builds: builds}) +} + +// deleteBuild removes a locally-built OCI plugin artifact from the local store. +// +// @Summary Delete a locally-built plugin artifact +// @Description Remove a locally-built OCI plugin artifact and its blobs from the local store +// @Tags plugins +// @Param tag path string true "Artifact tag" +// @Success 204 {string} string "No Content" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/plugins/builds/{tag} [delete] +func (s *PluginsRoutes) deleteBuild(w http.ResponseWriter, r *http.Request) error { + tag := chi.URLParam(r, "tag") + if err := s.pluginService.DeleteBuild(r.Context(), tag); err != nil { + return err + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +// getPluginContent retrieves the plugin.json body and file listing from an OCI artifact. +// +// @Summary Get plugin content +// @Description Retrieve the plugin.json body and file listing from an artifact +// @Description without installing it. Accepts OCI refs, git refs, or local tags. +// @Tags plugins +// @Produce json +// @Param ref query string true "OCI reference or local build tag" +// @Success 200 {object} plugins.PluginContent +// @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized (registry refused credentials)" +// @Failure 404 {string} string "Not Found (artifact not present in registry)" +// @Failure 429 {string} string "Too Many Requests (registry rate limit)" +// @Failure 500 {string} string "Internal Server Error" +// @Failure 502 {string} string "Bad Gateway (upstream registry or git resolver failure)" +// @Failure 504 {string} string "Gateway Timeout (upstream pull timed out)" +// @Router /api/v1beta/plugins/content [get] +func (s *PluginsRoutes) getPluginContent(w http.ResponseWriter, r *http.Request) error { + ref := r.URL.Query().Get("ref") + if ref == "" { + return httperr.WithCode( + fmt.Errorf("ref query parameter is required"), + http.StatusBadRequest, + ) + } + + content, err := s.pluginService.GetContent(r.Context(), plugins.ContentOptions{ + Reference: ref, + }) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(content) +} diff --git a/pkg/api/v1/plugins_test.go b/pkg/api/v1/plugins_test.go new file mode 100644 index 0000000000..d621e994b0 --- /dev/null +++ b/pkg/api/v1/plugins_test.go @@ -0,0 +1,679 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/storage" +) + +func TestPluginsRouter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + path string + body string + setupMock func(*plugmocks.MockPluginService, string) + expectedStatus int + expectedBody string + }{ + // listPlugins + { + name: "list plugins success empty", + method: "GET", + path: "/", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().List(gomock.Any(), plugins.ListOptions{}). + Return([]plugins.InstalledPlugin{}, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `{"plugins":[]}`, + }, + { + name: "list plugins success with results", + method: "GET", + path: "/", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().List(gomock.Any(), plugins.ListOptions{}). + Return([]plugins.InstalledPlugin{ + { + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeUser, + Status: plugins.InstallStatusInstalled, + InstalledAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `"my-plugin"`, + }, + { + name: "list plugins project scope missing project root", + method: "GET", + path: "/?scope=project", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().List(gomock.Any(), plugins.ListOptions{ + Scope: plugins.ScopeProject, + }).Return(nil, httperr.WithCode( + fmt.Errorf("project_root is required for project scope"), + http.StatusBadRequest, + )) + }, + expectedStatus: http.StatusBadRequest, + expectedBody: "project_root is required", + }, + { + name: "list plugins with project root filter", + method: "GET", + path: "/?scope=project&project_root={{project_root}}", + setupMock: func(svc *plugmocks.MockPluginService, projectRoot string) { + svc.EXPECT().List(gomock.Any(), plugins.ListOptions{ + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + }).Return([]plugins.InstalledPlugin{}, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `{"plugins":[]}`, + }, + { + name: "list plugins with client filter", + method: "GET", + path: "/?client=claude-code", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().List(gomock.Any(), plugins.ListOptions{ClientApp: "claude-code"}). + Return([]plugins.InstalledPlugin{}, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `{"plugins":[]}`, + }, + { + name: "list plugins with group filter", + method: "GET", + path: "/?group=my-group", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().List(gomock.Any(), plugins.ListOptions{Group: "my-group"}). + Return([]plugins.InstalledPlugin{}, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `{"plugins":[]}`, + }, + { + name: "list plugins error", + method: "GET", + path: "/", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().List(gomock.Any(), gomock.Any()). + Return(nil, fmt.Errorf("database error")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Internal Server Error", + }, + // installPlugin + { + name: "install plugin success", + method: "POST", + path: "/", + body: `{"name":"my-plugin"}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Install(gomock.Any(), plugins.InstallOptions{Name: "my-plugin"}). + Return(&plugins.InstallResult{ + Plugin: plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeUser, + Status: plugins.InstallStatusPending, + InstalledAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, nil) + }, + expectedStatus: http.StatusCreated, + expectedBody: `"my-plugin"`, + }, + { + name: "install plugin empty name", + method: "POST", + path: "/", + body: `{"name":""}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Install(gomock.Any(), plugins.InstallOptions{Name: ""}). + Return(nil, httperr.WithCode(fmt.Errorf("invalid plugin name: must not be empty"), http.StatusBadRequest)) + }, + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid plugin name", + }, + { + name: "install plugin missing name field", + method: "POST", + path: "/", + body: `{}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Install(gomock.Any(), plugins.InstallOptions{Name: ""}). + Return(nil, httperr.WithCode(fmt.Errorf("invalid plugin name: must not be empty"), http.StatusBadRequest)) + }, + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid plugin name", + }, + { + name: "install plugin malformed json", + method: "POST", + path: "/", + body: `{invalid`, + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid request body", + }, + { + name: "install plugin already exists", + method: "POST", + path: "/", + body: `{"name":"my-plugin"}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Install(gomock.Any(), gomock.Any()). + Return(nil, storage.ErrAlreadyExists) + }, + expectedStatus: http.StatusConflict, + expectedBody: "resource already exists", + }, + { + name: "install plugin with clients", + method: "POST", + path: "/", + body: `{"name":"my-plugin","clients":["claude-code","codex"]}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Install(gomock.Any(), plugins.InstallOptions{ + Name: "my-plugin", + Clients: []string{"claude-code", "codex"}, + }).Return(&plugins.InstallResult{ + Plugin: plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Status: plugins.InstallStatusInstalled, + Clients: []string{"claude-code", "codex"}, + }, + }, nil) + }, + expectedStatus: http.StatusCreated, + expectedBody: `"my-plugin"`, + }, + { + name: "install plugin with version and scope", + method: "POST", + path: "/", + body: `{"name":"my-plugin","version":"1.2.0","scope":"project","project_root":"{{project_root}}"}`, + setupMock: func(svc *plugmocks.MockPluginService, projectRoot string) { + svc.EXPECT().Install(gomock.Any(), plugins.InstallOptions{ + Name: "my-plugin", + Version: "1.2.0", + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + }).Return(&plugins.InstallResult{ + Plugin: plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin", Version: "1.2.0"}, + Scope: plugins.ScopeProject, + Status: plugins.InstallStatusPending, + }, + }, nil) + }, + expectedStatus: http.StatusCreated, + expectedBody: `"my-plugin"`, + }, + // uninstallPlugin + { + name: "uninstall plugin success", + method: "DELETE", + path: "/my-plugin", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Uninstall(gomock.Any(), plugins.UninstallOptions{Name: "my-plugin"}). + Return(nil) + }, + expectedStatus: http.StatusNoContent, + }, + { + name: "uninstall plugin invalid name", + method: "DELETE", + path: "/A", + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid", + }, + { + name: "uninstall plugin not found", + method: "DELETE", + path: "/my-plugin", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Uninstall(gomock.Any(), gomock.Any()). + Return(storage.ErrNotFound) + }, + expectedStatus: http.StatusNotFound, + expectedBody: "resource not found", + }, + { + name: "uninstall plugin with scope", + method: "DELETE", + path: "/my-plugin?scope=project&project_root={{project_root}}", + setupMock: func(svc *plugmocks.MockPluginService, projectRoot string) { + svc.EXPECT().Uninstall(gomock.Any(), plugins.UninstallOptions{ + Name: "my-plugin", + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + }).Return(nil) + }, + expectedStatus: http.StatusNoContent, + }, + // getPluginInfo + { + name: "get plugin info found", + method: "GET", + path: "/my-plugin", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Info(gomock.Any(), plugins.InfoOptions{Name: "my-plugin"}). + Return(&plugins.PluginInfo{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + InstalledPlugin: &plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeUser, + Status: plugins.InstallStatusInstalled, + }, + }, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `"installed_plugin"`, + }, + { + name: "get plugin info not found", + method: "GET", + path: "/my-plugin", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Info(gomock.Any(), plugins.InfoOptions{Name: "my-plugin"}). + Return(nil, storage.ErrNotFound) + }, + expectedStatus: http.StatusNotFound, + expectedBody: "resource not found", + }, + { + name: "get plugin info invalid name", + method: "GET", + path: "/A", + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid", + }, + { + name: "get plugin info service error", + method: "GET", + path: "/my-plugin", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Info(gomock.Any(), plugins.InfoOptions{Name: "my-plugin"}). + Return(nil, fmt.Errorf("database error")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Internal Server Error", + }, + { + name: "get plugin info with unmaterialized components and degraded clients", + method: "GET", + path: "/my-plugin", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Info(gomock.Any(), plugins.InfoOptions{Name: "my-plugin"}). + Return(&plugins.PluginInfo{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + InstalledPlugin: &plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeProject, + Status: plugins.InstallStatusInstalled, + }, + UnmaterializedComponents: map[string][]plugins.ComponentType{ + "codex": {plugins.ComponentCommands, plugins.ComponentAgents}, + }, + ProjectScopeDegradedClients: []string{"codex"}, + }, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `"unmaterialized_components"`, + }, + // validatePlugin + { + name: "validate plugin success", + method: "POST", + path: "/validate", + body: `{"path":"/tmp/plugin"}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Validate(gomock.Any(), "/tmp/plugin"). + Return(&plugins.ValidationResult{Valid: true}, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `"valid":true`, + }, + { + name: "validate plugin bad request", + method: "POST", + path: "/validate", + body: `{invalid`, + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid request body", + }, + { + name: "validate plugin empty path", + method: "POST", + path: "/validate", + body: `{"path":""}`, + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "path is required", + }, + { + name: "validate plugin service error", + method: "POST", + path: "/validate", + body: `{"path":"/tmp/plugin"}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Validate(gomock.Any(), "/tmp/plugin"). + Return(nil, fmt.Errorf("validation failed")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Internal Server Error", + }, + // buildPlugin + { + name: "build plugin success", + method: "POST", + path: "/build", + body: `{"path":"/tmp/plugin","tag":"v1.0.0"}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Build(gomock.Any(), plugins.BuildOptions{Path: "/tmp/plugin", Tag: "v1.0.0"}). + Return(&plugins.BuildResult{Reference: "v1.0.0"}, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `"reference":"v1.0.0"`, + }, + { + name: "build plugin bad request", + method: "POST", + path: "/build", + body: `{invalid`, + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid request body", + }, + { + name: "build plugin empty path", + method: "POST", + path: "/build", + body: `{"path":"","tag":"v1"}`, + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "path is required", + }, + // pushPlugin + { + name: "push plugin success", + method: "POST", + path: "/push", + body: `{"reference":"ghcr.io/test/plugin:v1"}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Push(gomock.Any(), plugins.PushOptions{Reference: "ghcr.io/test/plugin:v1"}). + Return(nil) + }, + expectedStatus: http.StatusNoContent, + }, + { + name: "push plugin bad request", + method: "POST", + path: "/push", + body: `{invalid`, + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid request body", + }, + { + name: "push plugin empty reference", + method: "POST", + path: "/push", + body: `{"reference":""}`, + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "reference is required", + }, + { + name: "push plugin service error", + method: "POST", + path: "/push", + body: `{"reference":"ghcr.io/test/plugin:v1"}`, + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().Push(gomock.Any(), plugins.PushOptions{Reference: "ghcr.io/test/plugin:v1"}). + Return(fmt.Errorf("push failed")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Internal Server Error", + }, + // listBuilds + { + name: "list builds success empty", + method: "GET", + path: "/builds", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().ListBuilds(gomock.Any()). + Return([]plugins.LocalBuild{}, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `{"builds":[]}`, + }, + { + name: "list builds success with results", + method: "GET", + path: "/builds", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().ListBuilds(gomock.Any()). + Return([]plugins.LocalBuild{ + {Tag: "my-plugin", Digest: "sha256:abc123", Name: "my-plugin", Version: "1.0.0"}, + }, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `"tag":"my-plugin"`, + }, + { + name: "list builds service error", + method: "GET", + path: "/builds", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().ListBuilds(gomock.Any()). + Return(nil, httperr.WithCode(fmt.Errorf("oci store not configured"), http.StatusInternalServerError)) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "Internal Server Error", + }, + // deleteBuild + { + name: "delete build success", + method: "DELETE", + path: "/builds/my-plugin", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().DeleteBuild(gomock.Any(), "my-plugin").Return(nil) + }, + expectedStatus: http.StatusNoContent, + }, + { + name: "delete build not found", + method: "DELETE", + path: "/builds/missing", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().DeleteBuild(gomock.Any(), "missing"). + Return(httperr.WithCode(fmt.Errorf("tag not found"), http.StatusNotFound)) + }, + expectedStatus: http.StatusNotFound, + }, + // getPluginContent + { + name: "get plugin content success", + method: "GET", + path: "/content?ref=my-plugin", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().GetContent(gomock.Any(), plugins.ContentOptions{Reference: "my-plugin"}). + Return(&plugins.PluginContent{ + Name: "my-plugin", + Manifest: `{"name":"my-plugin"}`, + Files: []plugins.PluginFileEntry{{Path: "plugin.json", Size: 42}}, + }, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: `"my-plugin"`, + }, + { + name: "get plugin content missing ref", + method: "GET", + path: "/content", + setupMock: func(_ *plugmocks.MockPluginService, _ string) {}, + expectedStatus: http.StatusBadRequest, + expectedBody: "ref query parameter is required", + }, + { + name: "get plugin content service error", + method: "GET", + path: "/content?ref=missing", + setupMock: func(svc *plugmocks.MockPluginService, _ string) { + svc.EXPECT().GetContent(gomock.Any(), plugins.ContentOptions{Reference: "missing"}). + Return(nil, storage.ErrNotFound) + }, + expectedStatus: http.StatusNotFound, + expectedBody: "resource not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + path := tt.path + body := tt.body + projectRoot := "" + if strings.Contains(path, "{{project_root}}") || strings.Contains(body, "{{project_root}}") { + projectRoot = makeProjectRoot(t) + path = strings.ReplaceAll(path, "{{project_root}}", url.QueryEscape(projectRoot)) + body = strings.ReplaceAll(body, "{{project_root}}", projectRoot) + } + + ctrl := gomock.NewController(t) + mockSvc := plugmocks.NewMockPluginService(ctrl) + tt.setupMock(mockSvc, projectRoot) + + router := chi.NewRouter() + router.Mount("/", PluginsRouter(mockSvc)) + + req := httptest.NewRequest(tt.method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, tt.expectedStatus, rec.Code) + if tt.expectedBody != "" { + assert.Contains(t, rec.Body.String(), tt.expectedBody) + } + }) + } +} + +func TestPluginsRouterGetInfoJSONKeys(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockSvc := plugmocks.NewMockPluginService(ctrl) + + mockSvc.EXPECT().Info(gomock.Any(), plugins.InfoOptions{Name: "my-plugin"}). + Return(&plugins.PluginInfo{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + InstalledPlugin: &plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeProject, + Status: plugins.InstallStatusInstalled, + }, + UnmaterializedComponents: map[string][]plugins.ComponentType{ + "codex": {plugins.ComponentCommands, plugins.ComponentAgents}, + }, + ProjectScopeDegradedClients: []string{"codex"}, + }, nil) + + router := chi.NewRouter() + router.Mount("/", PluginsRouter(mockSvc)) + + req := httptest.NewRequest(http.MethodGet, "/my-plugin", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `"unmaterialized_components"`) + assert.Contains(t, body, `"project_scope_degraded_clients"`) +} + +func TestPluginsListResponseFormat(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockSvc := plugmocks.NewMockPluginService(ctrl) + + mockSvc.EXPECT().List(gomock.Any(), gomock.Any()). + Return([]plugins.InstalledPlugin{ + { + Metadata: plugins.PluginMetadata{Name: "plugin-one", Version: "1.0.0"}, + Scope: plugins.ScopeUser, + Status: plugins.InstallStatusInstalled, + InstalledAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, nil) + + router := chi.NewRouter() + router.Mount("/", PluginsRouter(mockSvc)) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"plugins"`) + assert.Contains(t, rec.Body.String(), `"plugin-one"`) +} + +func TestPluginsInstallLocationHeader(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockSvc := plugmocks.NewMockPluginService(ctrl) + + mockSvc.EXPECT().Install(gomock.Any(), plugins.InstallOptions{Name: "my-plugin"}). + Return(&plugins.InstallResult{ + Plugin: plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeUser, + Status: plugins.InstallStatusInstalled, + }, + }, nil) + + router := chi.NewRouter() + router.Mount("/", PluginsRouter(mockSvc)) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"my-plugin"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Equal(t, "/api/v1beta/plugins/my-plugin", rec.Header().Get("Location")) +} diff --git a/pkg/api/v1/plugins_types.go b/pkg/api/v1/plugins_types.go new file mode 100644 index 0000000000..e32aac0b8d --- /dev/null +++ b/pkg/api/v1/plugins_types.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import "github.com/stacklok/toolhive/pkg/plugins" + +// pluginListResponse represents the response for listing plugins. +// +// @Description Response containing a list of installed plugins +type pluginListResponse struct { + // List of installed plugins + Plugins []plugins.InstalledPlugin `json:"plugins"` +} + +// installPluginRequest represents the request to install a plugin. +// +// @Description Request to install a plugin +type installPluginRequest struct { + // Name or OCI reference of the plugin to install + Name string `json:"name"` + // Version to install (empty means latest) + Version string `json:"version,omitempty"` + // Scope for the installation + Scope plugins.Scope `json:"scope,omitempty"` + // ProjectRoot is the project root path for project-scoped installs + ProjectRoot string `json:"project_root,omitempty"` + // Clients lists target client identifiers (e.g., "claude-code"), + // or ["all"] to target every plugin-supporting client. + // Omitting this field installs to all available clients. + Clients []string `json:"clients,omitempty"` + // Force allows overwriting unmanaged plugin directories + Force bool `json:"force,omitempty"` + // Group is the group name to add the plugin to after installation + Group string `json:"group,omitempty"` +} + +// installPluginResponse represents the response after installing a plugin. +// +// @Description Response after successfully installing a plugin +type installPluginResponse struct { + // The installed plugin + Plugin plugins.InstalledPlugin `json:"plugin"` +} + +// validatePluginRequest represents the request to validate a plugin. +// +// @Description Request to validate a plugin definition +type validatePluginRequest struct { + // Path to the plugin definition directory + Path string `json:"path"` +} + +// buildPluginRequest represents the request to build a plugin. +// +// @Description Request to build a plugin from a local directory +type buildPluginRequest struct { + // Path to the plugin definition directory + Path string `json:"path"` + // OCI tag for the built artifact + Tag string `json:"tag,omitempty"` +} + +// pushPluginRequest represents the request to push a plugin. +// +// @Description Request to push a built plugin artifact +type pushPluginRequest struct { + // OCI reference to push + Reference string `json:"reference"` +} + +// pluginBuildListResponse represents the response for listing locally-built OCI plugin artifacts. +// +// @Description Response containing a list of locally-built OCI plugin artifacts +type pluginBuildListResponse struct { + // List of locally-built OCI plugin artifacts + Builds []plugins.LocalBuild `json:"builds"` +} diff --git a/pkg/plugins/client/client.go b/pkg/plugins/client/client.go new file mode 100644 index 0000000000..11af6f4d1a --- /dev/null +++ b/pkg/plugins/client/client.go @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package client provides an HTTP client for the ToolHive Plugins API. +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/stacklok/toolhive-core/env" + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/server/discovery" +) + +const ( + pluginsBasePath = "/api/v1beta/plugins" + defaultBaseURL = "http://127.0.0.1:8080" + defaultTimeout = 30 * time.Second + envAPIURL = "TOOLHIVE_API_URL" + maxResponseSize = 1 << 20 // 1 MiB — matches server-side maxRequestBodySize + maxErrorBodySize = 1 << 16 // 64 KiB — matches auth/token and DCR limits +) + +// ErrServerUnreachable is returned when the client cannot connect to the +// ToolHive API server. The most common cause is that "thv serve" is not +// running. +var ErrServerUnreachable = errors.New("could not reach ToolHive API server — is 'thv serve' running?") + +// Compile-time interface check. +var _ plugins.PluginService = (*Client)(nil) + +// Client is an HTTP client for the ToolHive Plugins API. +type Client struct { + baseURL string + httpClient *http.Client +} + +// Option configures a Client. +type Option func(*Client) + +// WithTimeout sets the HTTP client timeout. +func WithTimeout(d time.Duration) Option { + return func(c *Client) { + c.httpClient.Timeout = d + } +} + +// WithHTTPClient replaces the underlying *http.Client entirely. +// This overrides any previously applied options such as WithTimeout. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { + c.httpClient = hc + } +} + +// NewClient creates a new Plugins API client with the given base URL. +func NewClient(baseURL string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{Timeout: defaultTimeout}, + } + for _, o := range opts { + o(c) + } + return c +} + +// NewDefaultClient creates a Plugins API client by trying, in order: +// 1. The TOOLHIVE_API_URL environment variable (explicit override) +// 2. The server discovery file (auto-detected running server) +// 3. The default URL http://127.0.0.1:8080 +// +// The context is used for the server discovery health check; it is not stored. +func NewDefaultClient(ctx context.Context, opts ...Option) *Client { + return newDefaultClientWithEnv(ctx, &env.OSReader{}, resolveViaDiscovery, opts...) +} + +// discoverFunc resolves a running server's base URL and any transport options +// (e.g. a Unix socket client). It returns an empty base URL when no running +// server is found. resolveViaDiscovery is the production implementation; tests +// inject a stub so the discovery step does not read real local state. +type discoverFunc func(ctx context.Context) (string, []Option) + +// newDefaultClientWithEnv is the testable core of NewDefaultClient. The +// envReader and discover dependencies are injected so each resolution step +// can be exercised in isolation. +func newDefaultClientWithEnv(ctx context.Context, envReader env.Reader, discover discoverFunc, opts ...Option) *Client { + // 1. Explicit env var override always wins. + if base := envReader.Getenv(envAPIURL); base != "" { + return NewClient(base, opts...) + } + + // 2. Try server discovery. + if base, httpOpts := discover(ctx); base != "" { + // Discovery opts go first so caller-supplied opts can override them + // (e.g. a caller-provided WithTimeout replaces the discovery default). + merged := make([]Option, 0, len(httpOpts)+len(opts)) + merged = append(merged, httpOpts...) + merged = append(merged, opts...) + return NewClient(base, merged...) + } + + // 3. Fall back to the default URL. + return NewClient(defaultBaseURL, opts...) +} + +// resolveViaDiscovery attempts to find a running server via the discovery file. +// It returns the base URL and any additional options (e.g. a Unix socket transport). +// On failure it returns empty values and the caller falls back to the default. +func resolveViaDiscovery(ctx context.Context) (string, []Option) { + result, err := discovery.Discover(ctx) + if err != nil { + slog.Debug("server discovery failed", "error", err) + return "", nil + } + if result.State != discovery.StateRunning { + return "", nil + } + + client, baseURL, err := discovery.HTTPClientForURL(result.Info.URL) + if err != nil { + slog.Debug("invalid URL in discovery file", "url", result.Info.URL, "error", err) + return "", nil + } + client.Timeout = defaultTimeout + + return baseURL, []Option{WithHTTPClient(client)} +} + +// --- PluginService implementation --- + +// List returns all installed plugins matching the given options. +func (c *Client) List(ctx context.Context, opts plugins.ListOptions) ([]plugins.InstalledPlugin, error) { + q := url.Values{} + if opts.Scope != "" { + q.Set("scope", string(opts.Scope)) + } + if opts.ClientApp != "" { + q.Set("client", opts.ClientApp) + } + if opts.ProjectRoot != "" { + q.Set("project_root", opts.ProjectRoot) + } + if opts.Group != "" { + q.Set("group", opts.Group) + } + + var resp listResponse + if err := c.doJSONRequest(ctx, http.MethodGet, "", q, nil, &resp); err != nil { + return nil, err + } + return resp.Plugins, nil +} + +// Install installs a plugin from a remote source. +func (c *Client) Install(ctx context.Context, opts plugins.InstallOptions) (*plugins.InstallResult, error) { + body := installRequest{ + Name: opts.Name, + Version: opts.Version, + Scope: opts.Scope, + ProjectRoot: opts.ProjectRoot, + Clients: opts.Clients, + Force: opts.Force, + Group: opts.Group, + } + + var resp installResponse + if err := c.doJSONRequest(ctx, http.MethodPost, "", nil, body, &resp); err != nil { + return nil, err + } + return &plugins.InstallResult{Plugin: resp.Plugin}, nil +} + +// Uninstall removes an installed plugin. +func (c *Client) Uninstall(ctx context.Context, opts plugins.UninstallOptions) error { + q := url.Values{} + if opts.Scope != "" { + q.Set("scope", string(opts.Scope)) + } + if opts.ProjectRoot != "" { + q.Set("project_root", opts.ProjectRoot) + } + + path := "/" + url.PathEscape(opts.Name) + return c.doJSONRequest(ctx, http.MethodDelete, path, q, nil, nil) +} + +// Info returns detailed information about a plugin. +func (c *Client) Info(ctx context.Context, opts plugins.InfoOptions) (*plugins.PluginInfo, error) { + q := url.Values{} + if opts.Scope != "" { + q.Set("scope", string(opts.Scope)) + } + if opts.ProjectRoot != "" { + q.Set("project_root", opts.ProjectRoot) + } + + path := "/" + url.PathEscape(opts.Name) + var info plugins.PluginInfo + if err := c.doJSONRequest(ctx, http.MethodGet, path, q, nil, &info); err != nil { + return nil, err + } + return &info, nil +} + +// Validate checks whether a plugin definition is valid. +func (c *Client) Validate(ctx context.Context, path string) (*plugins.ValidationResult, error) { + body := validateRequest{Path: path} + + var result plugins.ValidationResult + if err := c.doJSONRequest(ctx, http.MethodPost, "/validate", nil, body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Build builds a plugin from a local directory into an OCI artifact. +func (c *Client) Build(ctx context.Context, opts plugins.BuildOptions) (*plugins.BuildResult, error) { + body := buildRequest{ + Path: opts.Path, + Tag: opts.Tag, + } + + var result plugins.BuildResult + if err := c.doJSONRequest(ctx, http.MethodPost, "/build", nil, body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Push pushes a built plugin artifact to a remote registry. +func (c *Client) Push(ctx context.Context, opts plugins.PushOptions) error { + body := pushRequest{Reference: opts.Reference} + return c.doJSONRequest(ctx, http.MethodPost, "/push", nil, body, nil) +} + +// ListBuilds returns all locally-built OCI plugin artifacts in the local store. +func (c *Client) ListBuilds(ctx context.Context) ([]plugins.LocalBuild, error) { + var resp listBuildsResponse + if err := c.doJSONRequest(ctx, http.MethodGet, "/builds", nil, nil, &resp); err != nil { + return nil, err + } + return resp.Builds, nil +} + +// DeleteBuild removes a locally-built OCI plugin artifact from the local store. +func (c *Client) DeleteBuild(ctx context.Context, tag string) error { + return c.doJSONRequest(ctx, http.MethodDelete, "/builds/"+url.PathEscape(tag), nil, nil, nil) +} + +// GetContent retrieves the plugin.json body and file listing from an OCI artifact without installing it. +func (c *Client) GetContent(ctx context.Context, opts plugins.ContentOptions) (*plugins.PluginContent, error) { + q := url.Values{} + q.Set("ref", opts.Reference) + var content plugins.PluginContent + if err := c.doJSONRequest(ctx, http.MethodGet, "/content", q, nil, &content); err != nil { + return nil, err + } + return &content, nil +} + +// --- internal helpers --- + +func (c *Client) buildURL(path string, query url.Values) string { + u := c.baseURL + pluginsBasePath + path + if len(query) > 0 { + u += "?" + query.Encode() + } + return u +} + +// doJSONRequest performs the full HTTP request lifecycle: marshal body, build +// URL, create request with context, set headers, execute, check status, and +// decode response or return *APIError. +func (c *Client) doJSONRequest( + ctx context.Context, + method, path string, + query url.Values, + reqBody any, + result any, +) error { + var bodyReader io.Reader + if reqBody != nil { + data, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshaling request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + reqURL := c.buildURL(path, query) + + req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) // #nosec G704 -- baseURL is a trusted local API server URL + if err != nil { + return fmt.Errorf("%w: %w", ErrServerUnreachable, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= http.StatusBadRequest { + return handleErrorResponse(resp) + } + + if result != nil { + limited := io.LimitReader(resp.Body, maxResponseSize) + if err := json.NewDecoder(limited).Decode(result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + } + + return nil +} + +// handleErrorResponse reads the response body and returns an *httperr.CodedError. +func handleErrorResponse(resp *http.Response) error { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize)) + if err != nil { + return httperr.New("failed to read error response body", resp.StatusCode) + } + return httperr.New(strings.TrimSpace(string(body)), resp.StatusCode) +} diff --git a/pkg/plugins/client/client_test.go b/pkg/plugins/client/client_test.go new file mode 100644 index 0000000000..afd6dfce72 --- /dev/null +++ b/pkg/plugins/client/client_test.go @@ -0,0 +1,861 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + envmocks "github.com/stacklok/toolhive-core/env/mocks" + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// newTestClient returns a *Client pointed at the given test server. +func newTestClient(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + return NewClient(srv.URL) +} + +func TestList(t *testing.T) { + t.Parallel() + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + opts plugins.ListOptions + wantQuery map[string]string + response listResponse + statusCode int + wantErr bool + }{ + { + name: "no filters", + opts: plugins.ListOptions{}, + response: listResponse{Plugins: []plugins.InstalledPlugin{ + { + Metadata: plugins.PluginMetadata{Name: "my-plugin", Version: "1.0.0"}, + Scope: plugins.ScopeUser, + Status: plugins.InstallStatusInstalled, + InstalledAt: now, + }, + }}, + statusCode: http.StatusOK, + }, + { + name: "with all filters", + opts: plugins.ListOptions{ + Scope: plugins.ScopeProject, + ClientApp: "claude-code", + ProjectRoot: "/home/user/proj", + Group: "my-group", + }, + wantQuery: map[string]string{ + "scope": "project", + "client": "claude-code", + "project_root": "/home/user/proj", + "group": "my-group", + }, + response: listResponse{Plugins: []plugins.InstalledPlugin{}}, + statusCode: http.StatusOK, + }, + { + name: "server error", + opts: plugins.ListOptions{}, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, pluginsBasePath, r.URL.Path) + + for k, v := range tt.wantQuery { + assert.Equal(t, v, r.URL.Query().Get(k), "query param %s", k) + } + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "something went wrong", tt.statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.List(t.Context(), tt.opts) + + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response.Plugins, got) + }) + } +} + +func TestInstall(t *testing.T) { + t.Parallel() + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + opts plugins.InstallOptions + wantBody installRequest + response installResponse + statusCode int + wantErr bool + wantCode int + }{ + { + name: "success", + opts: plugins.InstallOptions{ + Name: "my-plugin", + Version: "1.0.0", + Scope: plugins.ScopeUser, + Clients: []string{"claude-code"}, + Force: true, + }, + wantBody: installRequest{ + Name: "my-plugin", + Version: "1.0.0", + Scope: plugins.ScopeUser, + Clients: []string{"claude-code"}, + Force: true, + }, + response: installResponse{Plugin: plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin", Version: "1.0.0"}, + Scope: plugins.ScopeUser, + Status: plugins.InstallStatusInstalled, + InstalledAt: now, + }}, + statusCode: http.StatusCreated, + }, + { + name: "bad request", + opts: plugins.InstallOptions{Name: ""}, + statusCode: http.StatusBadRequest, + wantErr: true, + wantCode: http.StatusBadRequest, + }, + { + name: "conflict", + opts: plugins.InstallOptions{Name: "existing-plugin"}, + statusCode: http.StatusConflict, + wantErr: true, + wantCode: http.StatusConflict, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, pluginsBasePath, r.URL.Path) + + if tt.wantBody.Name != "" { + var got installRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + assert.Equal(t, tt.wantBody, got) + } + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "error", tt.statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.Install(t.Context(), tt.opts) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response.Plugin, got.Plugin) + }) + } +} + +func TestUninstall(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts plugins.UninstallOptions + wantPath string + wantQuery map[string]string + statusCode int + wantErr bool + wantCode int + }{ + { + name: "success", + opts: plugins.UninstallOptions{Name: "my-plugin"}, + wantPath: pluginsBasePath + "/my-plugin", + statusCode: http.StatusNoContent, + }, + { + name: "with scope and project root", + opts: plugins.UninstallOptions{ + Name: "my-plugin", + Scope: plugins.ScopeProject, + ProjectRoot: "/home/user/proj", + }, + wantPath: pluginsBasePath + "/my-plugin", + wantQuery: map[string]string{ + "scope": "project", + "project_root": "/home/user/proj", + }, + statusCode: http.StatusNoContent, + }, + { + name: "not found", + opts: plugins.UninstallOptions{Name: "missing"}, + wantPath: pluginsBasePath + "/missing", + statusCode: http.StatusNotFound, + wantErr: true, + wantCode: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, tt.wantPath, r.URL.Path) + + for k, v := range tt.wantQuery { + assert.Equal(t, v, r.URL.Query().Get(k), "query param %s", k) + } + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "not found", tt.statusCode) + return + } + w.WriteHeader(tt.statusCode) + })) + defer srv.Close() + + c := newTestClient(t, srv) + err := c.Uninstall(t.Context(), tt.opts) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + }) + } +} + +func TestInfo(t *testing.T) { + t.Parallel() + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + opts plugins.InfoOptions + wantPath string + response plugins.PluginInfo + statusCode int + wantErr bool + wantCode int + }{ + { + name: "success", + opts: plugins.InfoOptions{Name: "my-plugin"}, + wantPath: pluginsBasePath + "/my-plugin", + response: plugins.PluginInfo{ + Metadata: plugins.PluginMetadata{Name: "my-plugin", Version: "1.0.0"}, + InstalledPlugin: &plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin", Version: "1.0.0"}, + Scope: plugins.ScopeUser, + Status: plugins.InstallStatusInstalled, + InstalledAt: now, + }, + }, + statusCode: http.StatusOK, + }, + { + name: "not found", + opts: plugins.InfoOptions{Name: "missing"}, + wantPath: pluginsBasePath + "/missing", + statusCode: http.StatusNotFound, + wantErr: true, + wantCode: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, tt.wantPath, r.URL.Path) + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "not found", tt.statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.Info(t.Context(), tt.opts) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response, *got) + }) + } +} + +func TestValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantBody validateRequest + response plugins.ValidationResult + statusCode int + wantErr bool + }{ + { + name: "valid plugin", + path: "/home/user/my-plugin", + wantBody: validateRequest{Path: "/home/user/my-plugin"}, + response: plugins.ValidationResult{Valid: true}, + statusCode: http.StatusOK, + }, + { + name: "invalid plugin", + path: "/home/user/bad-plugin", + wantBody: validateRequest{Path: "/home/user/bad-plugin"}, + response: plugins.ValidationResult{ + Valid: false, + Errors: []string{"missing name field"}, + }, + statusCode: http.StatusOK, + }, + { + name: "bad request", + path: "", + statusCode: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, pluginsBasePath+"/validate", r.URL.Path) + + if tt.wantBody.Path != "" { + var got validateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + assert.Equal(t, tt.wantBody, got) + } + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "bad request", tt.statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.Validate(t.Context(), tt.path) + + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response, *got) + }) + } +} + +func TestBuild(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts plugins.BuildOptions + wantBody buildRequest + response plugins.BuildResult + statusCode int + wantErr bool + }{ + { + name: "success", + opts: plugins.BuildOptions{Path: "/home/user/my-plugin", Tag: "v1.0.0"}, + wantBody: buildRequest{Path: "/home/user/my-plugin", Tag: "v1.0.0"}, + response: plugins.BuildResult{Reference: "ghcr.io/org/my-plugin:v1.0.0"}, + statusCode: http.StatusOK, + }, + { + name: "bad request", + opts: plugins.BuildOptions{}, + statusCode: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, pluginsBasePath+"/build", r.URL.Path) + + if tt.wantBody.Path != "" { + var got buildRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + assert.Equal(t, tt.wantBody, got) + } + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "bad request", tt.statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.Build(t.Context(), tt.opts) + + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response, *got) + }) + } +} + +func TestPush(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts plugins.PushOptions + wantBody pushRequest + statusCode int + wantErr bool + wantCode int + }{ + { + name: "success", + opts: plugins.PushOptions{Reference: "ghcr.io/org/my-plugin:v1.0.0"}, + wantBody: pushRequest{Reference: "ghcr.io/org/my-plugin:v1.0.0"}, + statusCode: http.StatusNoContent, + }, + { + name: "not found", + opts: plugins.PushOptions{Reference: "ghcr.io/org/missing:v1"}, + statusCode: http.StatusNotFound, + wantErr: true, + wantCode: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, pluginsBasePath+"/push", r.URL.Path) + + if tt.wantBody.Reference != "" { + var got pushRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + assert.Equal(t, tt.wantBody, got) + } + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "not found", tt.statusCode) + return + } + w.WriteHeader(tt.statusCode) + })) + defer srv.Close() + + c := newTestClient(t, srv) + err := c.Push(t.Context(), tt.opts) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + }) + } +} + +func TestListBuilds(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + response listBuildsResponse + statusCode int + wantErr bool + wantCode int + }{ + { + name: "success", + response: listBuildsResponse{Builds: []plugins.LocalBuild{ + {Tag: "my-plugin", Digest: "sha256:abc", Name: "my-plugin", Version: "1.0.0"}, + }}, + statusCode: http.StatusOK, + }, + { + name: "empty", + response: listBuildsResponse{Builds: []plugins.LocalBuild{}}, + statusCode: http.StatusOK, + }, + { + name: "server error", + statusCode: http.StatusInternalServerError, + wantErr: true, + wantCode: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, pluginsBasePath+"/builds", r.URL.Path) + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "error", tt.statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.ListBuilds(t.Context()) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response.Builds, got) + }) + } +} + +func TestDeleteBuild(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tag string + wantPath string + statusCode int + wantErr bool + wantCode int + }{ + { + name: "success", + tag: "my-plugin", + wantPath: pluginsBasePath + "/builds/my-plugin", + statusCode: http.StatusNoContent, + }, + { + name: "not found", + tag: "missing", + wantPath: pluginsBasePath + "/builds/missing", + statusCode: http.StatusNotFound, + wantErr: true, + wantCode: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, tt.wantPath, r.URL.Path) + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "error", tt.statusCode) + return + } + w.WriteHeader(tt.statusCode) + })) + defer srv.Close() + + c := newTestClient(t, srv) + err := c.DeleteBuild(t.Context(), tt.tag) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + }) + } +} + +func TestGetContent(t *testing.T) { + t.Parallel() + + response := plugins.PluginContent{ + Name: "my-plugin", + Description: "A test plugin", + Version: "1.0.0", + License: "Apache-2.0", + Manifest: `{"name":"my-plugin"}`, + Files: []plugins.PluginFileEntry{{Path: "plugin.json", Size: 42}}, + } + + tests := []struct { + name string + opts plugins.ContentOptions + wantQuery string + response plugins.PluginContent + statusCode int + wantErr bool + wantCode int + }{ + { + name: "success with local tag", + opts: plugins.ContentOptions{Reference: "my-plugin"}, + wantQuery: "my-plugin", + response: response, + statusCode: http.StatusOK, + }, + { + name: "success with OCI reference", + opts: plugins.ContentOptions{Reference: "ghcr.io/org/my-plugin:v1"}, + wantQuery: "ghcr.io/org/my-plugin:v1", + response: response, + statusCode: http.StatusOK, + }, + { + name: "server error propagates", + opts: plugins.ContentOptions{Reference: "missing"}, + statusCode: http.StatusBadRequest, + wantErr: true, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, pluginsBasePath+"/content", r.URL.Path) + if tt.wantQuery != "" { + assert.Equal(t, tt.wantQuery, r.URL.Query().Get("ref")) + } + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "bad request", tt.statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.GetContent(t.Context(), tt.opts) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response, *got) + }) + } +} + +func TestConnectionError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + srv.Close() + + c := NewClient(srv.URL) + _, err := c.List(t.Context(), plugins.ListOptions{}) + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrServerUnreachable), "expected ErrServerUnreachable, got: %v", err) +} + +func TestNewDefaultClient(t *testing.T) { + t.Parallel() + + // noDiscovery stubs server discovery to report no running server, isolating + // the test from any real local server (e.g. a running Desktop app). + noDiscovery := func(context.Context) (string, []Option) { return "", nil } + + // failDiscovery fails the test if discovery is consulted, asserting that an + // earlier resolution step short-circuited. + failDiscovery := func(t *testing.T) discoverFunc { + t.Helper() + return func(context.Context) (string, []Option) { + t.Error("discovery should not be called when TOOLHIVE_API_URL is set") + return "", nil + } + } + + t.Run("falls back to default URL when env is empty", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(envAPIURL).Return("") + + c := newDefaultClientWithEnv(t.Context(), mockEnv, noDiscovery) + assert.Equal(t, defaultBaseURL, c.baseURL) + }) + + t.Run("uses TOOLHIVE_API_URL from env", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(envAPIURL).Return("http://localhost:9999") + + c := newDefaultClientWithEnv(t.Context(), mockEnv, failDiscovery(t)) + assert.Equal(t, "http://localhost:9999", c.baseURL) + }) + + t.Run("uses discovered server when env is empty", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(envAPIURL).Return("") + + discover := func(context.Context) (string, []Option) { + return "http://127.0.0.1:54321", nil + } + c := newDefaultClientWithEnv(t.Context(), mockEnv, discover) + assert.Equal(t, "http://127.0.0.1:54321", c.baseURL) + }) + + t.Run("applies options", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(envAPIURL).Return("") + + c := newDefaultClientWithEnv(t.Context(), mockEnv, noDiscovery, WithTimeout(5*time.Second)) + assert.Equal(t, 5*time.Second, c.httpClient.Timeout) + }) +} + +func TestWithHTTPClient(t *testing.T) { + t.Parallel() + + custom := &http.Client{Timeout: 99 * time.Second} + c := NewClient("http://example.com", WithHTTPClient(custom)) + assert.Equal(t, custom, c.httpClient) +} + +func TestURLEncodesPluginNames(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, pluginsBasePath+"/my%20plugin%2Fv2", r.URL.RawPath) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(plugins.PluginInfo{ + Metadata: plugins.PluginMetadata{Name: "my plugin/v2"}, + })) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.Info(t.Context(), plugins.InfoOptions{Name: "my plugin/v2"}) + require.NoError(t, err) + assert.Equal(t, "my plugin/v2", got.Metadata.Name) +} + +func TestHandleErrorResponseReadFailure(t *testing.T) { + t.Parallel() + + resp := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(&failReader{}), + } + err := handleErrorResponse(resp) + + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + assert.Contains(t, err.Error(), "failed to read error response body") +} + +type failReader struct{} + +func (*failReader) Read([]byte) (int, error) { + return 0, errors.New("simulated read error") +} diff --git a/pkg/plugins/client/dto.go b/pkg/plugins/client/dto.go new file mode 100644 index 0000000000..00f0f29e67 --- /dev/null +++ b/pkg/plugins/client/dto.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import "github.com/stacklok/toolhive/pkg/plugins" + +// --- request/response dto (mirror pkg/api/v1/plugins_types.go) --- + +type installRequest struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Scope plugins.Scope `json:"scope,omitempty"` + ProjectRoot string `json:"project_root,omitempty"` + Clients []string `json:"clients,omitempty"` + Force bool `json:"force,omitempty"` + Group string `json:"group,omitempty"` +} + +type validateRequest struct { + Path string `json:"path"` +} + +type buildRequest struct { + Path string `json:"path"` + Tag string `json:"tag,omitempty"` +} + +type pushRequest struct { + Reference string `json:"reference"` +} + +type listResponse struct { + Plugins []plugins.InstalledPlugin `json:"plugins"` +} + +type installResponse struct { + Plugin plugins.InstalledPlugin `json:"plugin"` +} + +type listBuildsResponse struct { + Builds []plugins.LocalBuild `json:"builds"` +} From 3bbb0d8e6facd07d87283433c1ceeceafbd1a8ba Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 13 Jul 2026 15:28:56 +0300 Subject: [PATCH 2/4] Inject mock plugin manager in server test to avoid SQLite lock The plugin store opens the same SQLite DB file as the skill store. TestNewServer_ReadTimeoutConfigured already injected a mock skill manager to avoid SQLITE_BUSY; it now also injects a mock plugin manager for the same reason. --- pkg/api/server_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 57f8bc8fa6..5c9809a78a 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + pluginsmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks" ) @@ -155,11 +156,13 @@ func TestServerBuilderExtensionPoints(t *testing.T) { func TestNewServer_ReadTimeoutConfigured(t *testing.T) { t.Parallel() - // Inject a skill manager so Build() skips creating the default SQLite skill - // store, which is shared on disk and races under parallel tests (SQLITE_BUSY). + // Inject mock skill and plugin managers so Build() skips creating the default + // SQLite stores, which share a DB file on disk and race under parallel tests + // (SQLITE_BUSY). ctrl := gomock.NewController(t) b := NewServerBuilder().WithAddress("127.0.0.1:0") b.skillManager = skillsmocks.NewMockSkillService(ctrl) + b.pluginManager = pluginsmocks.NewMockPluginService(ctrl) s, err := NewServer(context.Background(), b) require.NoError(t, err) From 4d5ed1be1be7a916905eb33b16e18f500ed49f68 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 13 Jul 2026 16:49:42 +0300 Subject: [PATCH 3/4] Fix panel review findings: wording, dedup, stdlib reuse - Fix 'Declared (NOT managed):' to 'Declared (NOT managed by ToolHive):' per issue #5528 exit gate requirement - Delete parsePluginInstallClients (byte-identical to parseSkillInstallClients), call the existing parseSkillInstallClients instead - Replace custom sortedKeys generic with slices.Sorted(maps.Keys(m)) from Go stdlib --- cmd/thv/app/plugin_info.go | 18 +++++------------- cmd/thv/app/plugin_install.go | 24 +----------------------- 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/cmd/thv/app/plugin_info.go b/cmd/thv/app/plugin_info.go index f75d2ad841..5d239732f0 100644 --- a/cmd/thv/app/plugin_info.go +++ b/cmd/thv/app/plugin_info.go @@ -6,7 +6,9 @@ package app import ( "encoding/json" "fmt" + "maps" "os" + "slices" "sort" "strings" "text/tabwriter" @@ -90,14 +92,14 @@ func printPluginInfoText(info *plugins.PluginInfo) { _, _ = fmt.Fprintf(w, "Components:\t%s\n", formatComponentInventory(managed)) } if len(declared) > 0 { - _, _ = fmt.Fprintf(w, "Declared (NOT managed):\t%s\n", formatComponentInventory(declared)) + _, _ = fmt.Fprintf(w, "Declared (NOT managed by ToolHive):\t%s\n", formatComponentInventory(declared)) } } } if len(info.UnmaterializedComponents) > 0 { _, _ = fmt.Fprintln(w, "\nUnmaterialized Components:") - for _, clientType := range sortedKeys(info.UnmaterializedComponents) { + for _, clientType := range slices.Sorted(maps.Keys(info.UnmaterializedComponents)) { types := info.UnmaterializedComponents[clientType] labels := make([]string, 0, len(types)) for _, t := range types { @@ -118,7 +120,7 @@ func printPluginInfoText(info *plugins.PluginInfo) { // formatComponentInventory renders a ComponentInventory (map[string]int) as a // sorted, space-separated "key=count" sequence for deterministic output. func formatComponentInventory(inv plugins.ComponentInventory) string { - keys := sortedKeys(inv) + keys := slices.Sorted(maps.Keys(inv)) parts := make([]string, 0, len(keys)) for _, k := range keys { parts = append(parts, fmt.Sprintf("%s=%d", k, inv[k])) @@ -143,13 +145,3 @@ func splitComponentInventory(inv plugins.ComponentInventory) (managed, declared } return managed, declared } - -// sortedKeys returns the keys of m sorted lexicographically. -func sortedKeys[T any](m map[string]T) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/cmd/thv/app/plugin_install.go b/cmd/thv/app/plugin_install.go index 09db6878f4..f053c6edcb 100644 --- a/cmd/thv/app/plugin_install.go +++ b/cmd/thv/app/plugin_install.go @@ -4,8 +4,6 @@ package app import ( - "strings" - "github.com/spf13/cobra" "github.com/stacklok/toolhive/pkg/plugins" @@ -52,7 +50,7 @@ func pluginInstallCmdFunc(cmd *cobra.Command, args []string) error { _, err := c.Install(cmd.Context(), plugins.InstallOptions{ Name: args[0], Scope: plugins.Scope(pluginInstallScope), - Clients: parsePluginInstallClients(pluginInstallClientsRaw), + Clients: parseSkillInstallClients(pluginInstallClientsRaw), Force: pluginInstallForce, ProjectRoot: pluginInstallProjectRoot, Group: pluginInstallGroup, @@ -63,23 +61,3 @@ func pluginInstallCmdFunc(cmd *cobra.Command, args []string) error { return nil } - -// parsePluginInstallClients splits a comma-separated --clients flag value. -// Empty input yields nil so the server applies its default client. -func parsePluginInstallClients(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { - out = append(out, t) - } - } - if len(out) == 0 { - return nil - } - return out -} From a45b64de36dc9afbf27d0097a5fd28a03cd8765d Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 13 Jul 2026 17:12:57 +0300 Subject: [PATCH 4/4] Trigger CodeQL re-analysis after dismissing false positive alerts