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..5d239732f0 --- /dev/null +++ b/cmd/thv/app/plugin_info.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "encoding/json" + "fmt" + "maps" + "os" + "slices" + "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 by ToolHive):\t%s\n", formatComponentInventory(declared)) + } + } + } + + if len(info.UnmaterializedComponents) > 0 { + _, _ = fmt.Fprintln(w, "\nUnmaterialized Components:") + for _, clientType := range slices.Sorted(maps.Keys(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 := 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])) + } + 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 +} diff --git a/cmd/thv/app/plugin_install.go b/cmd/thv/app/plugin_install.go new file mode 100644 index 0000000000..f053c6edcb --- /dev/null +++ b/cmd/thv/app/plugin_install.go @@ -0,0 +1,63 @@ +// 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 ( + 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: parseSkillInstallClients(pluginInstallClientsRaw), + Force: pluginInstallForce, + ProjectRoot: pluginInstallProjectRoot, + Group: pluginInstallGroup, + }) + if err != nil { + return formatPluginError("install plugin", err) + } + + return nil +} 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/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) 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"` +}