Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/thv/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -118,6 +119,7 @@ func IsInformationalCommand(args []string) bool {
"mcp": true,
"secret": true,
"skill": true,
"plugin": true,
"vmcp": true,
"llm": true,
}
Expand Down
14 changes: 14 additions & 0 deletions cmd/thv/app/plugin.go
Original file line number Diff line number Diff line change
@@ -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.`,
}
54 changes: 54 additions & 0 deletions cmd/thv/app/plugin_build.go
Original file line number Diff line number Diff line change
@@ -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
}
82 changes: 82 additions & 0 deletions cmd/thv/app/plugin_builds.go
Original file line number Diff line number Diff line change
@@ -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()
}
31 changes: 31 additions & 0 deletions cmd/thv/app/plugin_builds_remove.go
Original file line number Diff line number Diff line change
@@ -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 <tag>",
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
}
56 changes: 56 additions & 0 deletions cmd/thv/app/plugin_helpers.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading