Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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