From b7d389096b131f2d9038ea770f45821e50ca7661 Mon Sep 17 00:00:00 2001 From: David vonThenen <12752197+dvonthenen@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:26:23 -0700 Subject: [PATCH] Example of Consuming an External CLI Plugin --- .gitignore | 3 + README.md | 40 +++++++++ cmd/listen/listen.go | 11 --- cmd/plugins/plugins.go | 53 +++++++++++ cmd/root.go | 42 +++++++++ cmd/speak/speak.go | 11 --- main.go | 1 + pkg/plugins/actions.go | 132 ++++++++++++++++++++++++++++ pkg/plugins/interfaces/constants.go | 16 ++++ pkg/plugins/plugins.go | 102 +++++++++++++++++++++ pkg/plugins/types.go | 44 ++++++++++ 11 files changed, 433 insertions(+), 22 deletions(-) create mode 100644 cmd/plugins/plugins.go create mode 100644 pkg/plugins/actions.go create mode 100644 pkg/plugins/interfaces/constants.go create mode 100644 pkg/plugins/plugins.go create mode 100644 pkg/plugins/types.go diff --git a/.gitignore b/.gitignore index 87b3921..1a0cd9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# project specific +my-plugins/ + # Binaries for programs and plugins .DS_Store *.exe diff --git a/README.md b/README.md index 3a42af0..0b1ecb4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,46 @@ 🔑 To access the Deepgram API you will need a [free Deepgram API Key](https://console.deepgram.com/signup?jump=keys). +## Building the CLI + +When you build the Deepgram CLI for the current platform/architecture of your laptop (for example, macOS arm64), you simply need to be at the root of the repo and run: + +```bash +go build . +``` + +> **IMPORTANT:** In order to support multiple platforms, you need to have build mechanisms (ie a Makefile for example) to orchestrate making the binaries for all your target platforms (macOS x86, macOS arm64, Linux amd64, etc, etc). + +## External Plugins + +### Consuming an External Plugin + +You should be able to use the `deepgram-cli` to manage the plugins or download new plugins to your system. You can do this with the `deepgram-cli plugins` command. + +```bash +TODO +``` + +### Manually Installing an External Plugin + +For testing or 3rd party purposes, you can also load plugins manually. To do this, copy your Deepgram CLI plugin (this will be a `.so` file extension) into a subfolder called `plugins`. + +You will need an accompanying plugin description file, this description file should be the same filename with a `.plugin` extension instead of `.so`. + +The contents of the `.plugin` file will look like: + +```json +{ + "name": "", + "description": "An example plugin description", + "version": "0.0.1" +} +``` + +There is an optional field in this JSON that could be used when your root [Cobra CLI Command](https://github.com/spf13/cobra) and initiation function is named something other than `MainCmd` and `InitMain`, respectively. You can specify an optional property named `entrypoint`. + +Next time you run the Deepgram CLI, you should see your plugin command available on the command line. + ## Development and Contributing Interested in contributing? We ❤️ pull requests! diff --git a/cmd/listen/listen.go b/cmd/listen/listen.go index 4f0e6a5..377680c 100644 --- a/cmd/listen/listen.go +++ b/cmd/listen/listen.go @@ -5,8 +5,6 @@ package listen import ( - "fmt" - "github.com/spf13/cobra" "github.com/deepgram-devs/deepgram-cli/cmd" @@ -23,15 +21,6 @@ and usage of using your command. For example: Cobra is a CLI library for Go that empowers applications. This application is a tool to generate the needed files to quickly create a Cobra application.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("listen (Speech-to-Text) called") - fmt.Println("Available subcommands:") - if len(args) == 0 { - for _, subCmd := range cmd.Commands() { - fmt.Printf("- %s: %s\n", subCmd.Use, subCmd.Short) - } - } - }, } func init() { diff --git a/cmd/plugins/plugins.go b/cmd/plugins/plugins.go new file mode 100644 index 0000000..e1ab9c5 --- /dev/null +++ b/cmd/plugins/plugins.go @@ -0,0 +1,53 @@ +// Copyright 2024 Deepgram CLI contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package plugins + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/deepgram-devs/deepgram-cli/cmd" + "github.com/deepgram-devs/deepgram-cli/pkg/plugins" +) + +// pluginCmd represents the manage command +var pluginCmd = &cobra.Command{ + Use: "plugins", + Short: "This manages plugins in this installation.", + Long: `This manages plugins current instaled. For example: + +list - lists all plugins +install -name - installs a plugin +uninstall -name - uninstalls a plugin`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("plugins called") + if len(args) == 0 { + pluginsInstalled := plugins.ListInstalledPlugins() + for name := range pluginsInstalled { + fmt.Printf("Plugin: %s\n", name) + } + } + }, +} + +func init() { + // // load all plugins + // pluginsInstalled := plugins.ListInstalledPlugins() + // for name, plugin := range pluginsInstalled { + // fmt.Printf("-----------------> Adding plugin: %s\n", name) + // cmd.RootCmd.AddCommand(plugin.Cmd) + // } + + // add the plugin command + cmd.RootCmd.AddCommand(pluginCmd) + + // Cobra supports Persistent Flags which will work for this command and all subcommands, e.g.: + pluginCmd.PersistentFlags().String("name", "", "Plugin name") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // manageCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} diff --git a/cmd/root.go b/cmd/root.go index 4ef5002..492cb47 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,8 +5,11 @@ package cmd import ( + "log" "os" + "strings" + "github.com/deepgram-devs/deepgram-cli/pkg/plugins" "github.com/spf13/cobra" ) @@ -35,6 +38,45 @@ func Execute() { } func init() { + // Get the list of files in the "plugins" subdirectory + files, err := os.ReadDir("plugins") + if err != nil { + log.Fatal(err) + } + + // Iterate over the files and create a new plugin for each one + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".plugin") { + // remove the ".plugin" suffix + pluginName := strings.TrimSuffix(file.Name(), ".plugin") + + desc, err := plugins.DiscoverPlugin(pluginName) + if err != nil { + log.Printf("Failed to install plugin %s: %v", pluginName, err) + continue + } + + pluginCmd, err := plugins.LoadPlugin(pluginName, desc.EntryPoint) + if err != nil { + log.Printf("Failed to install plugin %s: %v", pluginName, err) + continue + } + + // add command + RootCmd.AddCommand(pluginCmd) + } + } + + // load all plugins + pluginsInstalled := plugins.ListInstalledPlugins() + for _, plugin := range pluginsInstalled { + // fmt.Printf("-----------------> Adding plugin: %s\n", name) + RootCmd.AddCommand(plugin.Cmd) + } + + // TODO: disable completion command? + RootCmd.Root().CompletionOptions.DisableDefaultCmd = true + // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. diff --git a/cmd/speak/speak.go b/cmd/speak/speak.go index 0d63bea..671e193 100644 --- a/cmd/speak/speak.go +++ b/cmd/speak/speak.go @@ -5,8 +5,6 @@ package speak import ( - "fmt" - "github.com/spf13/cobra" "github.com/deepgram-devs/deepgram-cli/cmd" @@ -23,15 +21,6 @@ and usage of using your command. For example: Cobra is a CLI library for Go that empowers applications. This application is a tool to generate the needed files to quickly create a Cobra application.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("speak (Text-to-Speech) called") - fmt.Println("Available subcommands:") - if len(args) == 0 { - for _, subCmd := range cmd.Commands() { - fmt.Printf("- %s: %s\n", subCmd.Use, subCmd.Short) - } - } - }, } func init() { diff --git a/main.go b/main.go index 3757b9d..d526ae6 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( _ "github.com/deepgram-devs/deepgram-cli/cmd/analyze" _ "github.com/deepgram-devs/deepgram-cli/cmd/listen" _ "github.com/deepgram-devs/deepgram-cli/cmd/manage" + _ "github.com/deepgram-devs/deepgram-cli/cmd/plugins" _ "github.com/deepgram-devs/deepgram-cli/cmd/selfhosted" _ "github.com/deepgram-devs/deepgram-cli/cmd/speak" ) diff --git a/pkg/plugins/actions.go b/pkg/plugins/actions.go new file mode 100644 index 0000000..3f26834 --- /dev/null +++ b/pkg/plugins/actions.go @@ -0,0 +1,132 @@ +// Copyright 2024 Deepgram CLI contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package plugins + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "plugin" + + "github.com/spf13/cobra" +) + +// plugins that are currently installed +var pluginsInstalled = make(map[string]*Plugin) +var pluginDescriptors = make(map[string]*PluginDescriptor) + +// ListInstalledPlugins returns a list of installed plugins +func ListInstalledPlugins() map[string]*Plugin { + return pluginsInstalled +} + +func GetPlugin(pluginName string) *Plugin { + return pluginsInstalled[pluginName] +} + +func DiscoverPlugin(pluginName string) (*PluginDescriptor, error) { + // Get the plugin binary file + binaryFilePath := filepath.Join("plugins", pluginName+".so") + _, err := os.Stat(binaryFilePath) + if err != nil { + fmt.Printf("Failed to find plugin binary %s: %v\n", binaryFilePath, err) + return nil, err + } + + // Get the plugin descriptor file + jsonFilePath := filepath.Join("plugins", pluginName+".plugin") + _, err = os.Stat(jsonFilePath) + if err != nil { + fmt.Printf("Failed to find plugin descriptor %s: %v\n", jsonFilePath, err) + return nil, err + } + + // Open the ".plugin" file + fileContent, err := os.ReadFile(jsonFilePath) + if err != nil { + log.Printf("Failed to read file %s: %v", jsonFilePath, err) + return nil, err + } + + // Load the JSON according to the Plugin Descriptor + var pluginDescriptor PluginDescriptor + err = json.Unmarshal(fileContent, &pluginDescriptor) + if err != nil { + log.Printf("Failed to unmarshal plugin descriptor from file %s: %v", jsonFilePath, err) + return nil, err + } + + // load and save the plugin + newPlugin, err := NewPlugin(&pluginDescriptor) + if err != nil { + log.Printf("Failed to load plugin from file %s: %v", jsonFilePath, err) + return nil, err + } + + pluginsInstalled[pluginName] = newPlugin + pluginDescriptors[pluginName] = &pluginDescriptor + return &pluginDescriptor, nil +} + +func LoadPlugin(pluginName, commandName string) (*cobra.Command, error) { + // Get the plugin binary file + binaryFilePath := filepath.Join("plugins", pluginName+".so") + // fmt.Printf("---------> binaryFilePath: %s\n", binaryFilePath) + _, err := os.Stat(binaryFilePath) + if err != nil { + fmt.Printf("Failed to find plugin binary %s: %v\n", binaryFilePath, err) + return nil, err + } + + // get plugin command + p, err := plugin.Open(binaryFilePath) + if err != nil { + fmt.Printf("Failed to Open plugin %s: %v\n", binaryFilePath, err) + return nil, err + } + b, err := p.Lookup(commandName + "Cmd") + if err != nil { + fmt.Printf("-----> Failed to Lookup plugin %s: %v\n", binaryFilePath, err) + return nil, err + } + f, err := p.Lookup("Init" + commandName) + if err == nil { + f.(func())() + } + + fmt.Printf("Plugin %s installed\n", pluginName) + return *b.(**cobra.Command), nil +} + +func UninstallPlugin(pluginName string) error { + delete(pluginsInstalled, pluginName) + delete(pluginDescriptors, pluginName) + + // Remove the plugin file + jsonFilePath := filepath.Join("plugins", pluginName+".plugin") + err := os.Remove(jsonFilePath) + if err != nil { + log.Printf("Failed to remove plugin file %s: %v", jsonFilePath, err) + return err + } + + // Remove the plugin binary + binaryFilePath := filepath.Join("plugins", pluginName) + err = os.Remove(binaryFilePath) + if err != nil { + log.Printf("Failed to remove plugin %s: %v", binaryFilePath, err) + return err + } + + log.Printf("Plugin %s uninstalled", pluginName) + return nil +} + +func DownloadPlugin(pluginName string) error { + // TODO: Implement this... + return nil +} diff --git a/pkg/plugins/interfaces/constants.go b/pkg/plugins/interfaces/constants.go new file mode 100644 index 0000000..91f8126 --- /dev/null +++ b/pkg/plugins/interfaces/constants.go @@ -0,0 +1,16 @@ +// Copyright 2024 Deepgram CLI contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package interfaces + +// CmdGroup is a group of CLI commands. +type CmdGroup string + +const ( + // StartersCmdGroup are commands associated with starter apps. + StartersCmdGroup CmdGroup = "Starters" + + // TestCmdGroup is the test command group. + TestCmdGroup CmdGroup = "Test" +) diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go new file mode 100644 index 0000000..a81222f --- /dev/null +++ b/pkg/plugins/plugins.go @@ -0,0 +1,102 @@ +// Copyright 2024 Deepgram CLI contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package plugins + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + "github.com/deepgram-devs/deepgram-cli/pkg/plugins/interfaces" +) + +// NewPlugin creates an instance of Plugin. +func NewPlugin(descriptor *PluginDescriptor) (*Plugin, error) { + p := &Plugin{ + Cmd: &cobra.Command{ + Use: descriptor.Name, + Short: descriptor.Description, + Aliases: descriptor.Aliases, + }, + } + + p.Cmd.AddCommand( + newDescribeCmd(descriptor.Description), + newVersionCmd(descriptor.Version), + newInfoCmd(descriptor), + ) + + return p, nil +} + +// NewTestFor creates a plugin descriptor for a test plugin. +func NewTestFor(pluginName string) *PluginDescriptor { + return &PluginDescriptor{ + Name: fmt.Sprintf("%s-test", pluginName), + Description: fmt.Sprintf("test for %s", pluginName), + Version: "v0.0.1", + // BuildSHA: SHA, + Group: interfaces.TestCmdGroup, + Aliases: []string{fmt.Sprintf("%s-alias", pluginName)}, + } +} + +func newDescribeCmd(description string) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describes the plugin", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println(description) + return nil + }, + } + + return cmd +} + +func newVersionCmd(version string) *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Version the plugin", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println(version) + return nil + }, + } + + return cmd +} + +func newInfoCmd(desc *PluginDescriptor) *cobra.Command { + cmd := &cobra.Command{ + Use: "info", + Short: "Plugin info", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + b, err := json.Marshal(desc) + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + }, + } + + return cmd +} + +// AddCommands adds commands to the plugin. +func (p Plugin) AddCommands(commands ...*cobra.Command) error { + p.Cmd.AddCommand(commands...) + return nil +} + +// Execute executes the plugin. +func (p Plugin) Execute() error { + return p.Cmd.Execute() +} diff --git a/pkg/plugins/types.go b/pkg/plugins/types.go new file mode 100644 index 0000000..6a7dcdf --- /dev/null +++ b/pkg/plugins/types.go @@ -0,0 +1,44 @@ +// Copyright 2024 Deepgram CLI contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +package plugins + +import ( + "github.com/spf13/cobra" + + "github.com/deepgram-devs/deepgram-cli/pkg/plugins/interfaces" +) + +// Plugin is a Tanzu CLI plugin. +type Plugin struct { + Cmd *cobra.Command +} + +// PluginDescriptor describes a plugin binary. +type PluginDescriptor struct { + // Name is the name of the plugin. + Name string `json:"name" yaml:"name"` + + // Description is the plugin's description. + Description string `json:"description" yaml:"description"` + + // Version of the plugin. Must be a valid semantic version https://semver.org/ + Version string `json:"version" yaml:"version"` + + // EntryPoint for the Cobra CMD and Init function. + EntryPoint string `json:"entrypoint" yaml:"entrypoint"` + + // BuildSHA is the git commit hash the plugin was built with. + // TODO(stmcginnis): Update Makefile to set build info with LDFLAG. + BuildSHA string `json:"buildSHA,omitempty" yaml:"buildSHA,omitempty"` + + // Command group for the plugin. + Group interfaces.CmdGroup `json:"group,omitempty" yaml:"group,omitempty"` + + // DocURL for the plugin. + DocURL string `json:"docURL,omitempty" yaml:"docURL,omitempty"` + + // Aliases are other text strings used to call this command + Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"` +}