Skip to content
Merged
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
139 changes: 139 additions & 0 deletions examples/plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# PingCLI Plugin Development Guide

Welcome to the developer guide for creating `pingcli` plugins! This document provides all the information you need to build, test, and distribute your own custom commands to extend the functionality of the `pingcli` tool.

## Table of Contents

- [Introduction](#introduction)
- [Prerequisites](#prerequisites)
- [How Plugins Work](#how-plugins-work)
- [Building a Plugin](#building-a-plugin)
- [Registering and Managing Plugins](#registering-and-managing-plugins)
- [Adding a Plugin](#adding-a-plugin)
- [Listing Plugins](#listing-plugins)
- [Removing a Plugin](#removing-a-plugin)
- [Plugin Command Interface](#plugin-command-interface)
- [Configuration](#configuration-pingclicommandconfiguration-error)
- [Run](#runargs-string-logger-grpclogger-error)
- [Logging from Plugins](#logging-from-plugins)
- [Troubleshooting](#troubleshooting)
- [Further Reading](#further-reading)

## Introduction

The `pingcli` plugin system allows developers to create new commands that integrate seamlessly into the main application. Each plugin is a standalone executable that communicates with the `pingcli` host process over gRPC. This architecture ensures that plugins are isolated and secure. Currently, the plugin framework only supports plugins written in Go.

## Prerequisites

- **Go 1.24+** (for building Go plugins)
- [HashiCorp go-plugin](https://github.com/hashicorp/go-plugin) (used by both host and plugin)
- **PingCLI v0.7.0+** installed and configured

## How Plugins Work

1. **Discovery**: When `pingcli` starts, it loads a list of registered plugin executables from its configuration profile. This list is managed by the `pingcli plugin` command. Plugins must be on your system `PATH`
2. **Handshake**: For each registered plugin, `pingcli` launches the executable as a child process. A secure handshake is performed to verify that the child process is a valid plugin and is compatible with the host.
3. **Communication**: Once the handshake is complete, the host and plugin communicate over gRPC. The host can call functions defined in the plugin (like `Run`), and the plugin can send log messages back to the host.
4. **Execution**: When a user runs a command provided by a plugin, `pingcli` invokes the corresponding gRPC method in the plugin process, passing along any arguments and flags.
5. **Compatibility**: The Handshake process includes a ProtocolVersion check. This ensures that the plugin and the pingcli host are compatible, preventing issues if the underlying gRPC interface changes in future versions of pingcli.

## Building a Plugin

1. **Clone or create your plugin source code.**
See [`plugin.go`](plugin.go) for a complete example.

2. **Build the plugin binary:**
```sh
go build -o my-plugin
```

3. **Place the binary in a directory on your `PATH`:**
```sh
mv my-plugin ~/go/bin/
# or any directory in your $PATH
```

## Registering and Managing Plugins

`pingcli` provides the `plugin` command to manage the lifecycle of your plugins.

### Adding a Plugin

To add a new plugin, use the add subcommand. Crucially, the plugin executable must first be placed in a directory that is part of your system's PATH environment variable. pingcli relies on the system's PATH to find the executable to run.

```bash
pingcli plugin add <executable-name>
```

### Listing Plugins

To see a list of all currently registered plugins, use the `list` subcommand.

```bash
pingcli plugin list
```

### Removing a Plugin

To unregister a plugin from `pingcli`, use the `remove` subcommand.

```bash
pingcli plugin remove <executable-name>
```

## Plugin Command Interface

To create a valid plugin, you must implement the `grpc.PingCliCommand` interface. This interface has two methods:

#### `Configuration() (*grpc.PingCliCommandConfiguration, error)`

This method is called by the `pingcli` host to get metadata about your command. This allows `pingcli` to display your command in the help text (`pingcli --help`).

The `PingCliCommandConfiguration` struct has the following fields, which correspond directly to properties of a [Cobra](https://github.com/spf13/cobra) command:

- `Use`: The one-line usage message for the command (e.g., `my-command [flags]`).
- `Short`: A short description of the command.
- `Long`: A longer, more detailed description of the command.
- `Example`: One or more examples of how to use the command.

By providing this metadata, pingcli can present your plugin in a manner that is consistent and feels native to the main application.

#### `Run(args []string, logger grpc.Logger) error`

This is the main entry point for your command's logic. It is executed when a user runs your command.

- `args []string`: A slice of strings containing all the command-line arguments and flags that were passed to your command. For example, if a user runs `pingcli my-command first-arg --verbose`, the `args` slice will be `["first-arg", "--verbose"]`.
- `logger grpc.Logger`: A gRPC client that allows your plugin to send log messages back to the `pingcli` host. **This is the only way your plugin should produce output.**

## Logging from Plugins

Plugins must not write directly to `stdout` or `stderr`. Instead, they must use the provided `logger` object in the `Run` method. This ensures that all output is managed by the host and presented to the user in a consistent format.

The `logger` interface provides several methods for different log levels:

- `logger.Message(message string, fields map[string]string)`
- `logger.Warn(message string, fields map[string]string)`
- `logger.PluginError(message string, fields map[string]string)`
- `logger.Success(message string, fields map[string]string)`
- `logger.UserError(message string, fields map[string]string)`
- `logger.UserFatal(message string, fields map[string]string)`

## Troubleshooting

- **Plugin not found:**
Ensure the binary is on your `PATH` and registered with `pingcli plugin add`.

- **Handshake failed:**
Check that both host and plugin use compatible protocol versions.

- **gRPC errors:**
Ensure your plugin implements the correct interface and uses the expected gRPC protocol.

- **No output:**
All output is expected to go through the provided `logger`.

## Further Reading

- [HashiCorp go-plugin documentation](https://github.com/hashicorp/go-plugin)
- [`pingcli` main documentation](../../README.md)
- [Cobra CLI framework](https://github.com/spf13/cobra)
115 changes: 115 additions & 0 deletions examples/plugin/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright © 2025 Ping Identity Corporation

// Package 'plugin' provides an example implementation of a PingCLI command plugin.
//
// It demonstrates the required structure and interfaces for building a new
// command that can be dynamically loaded and executed by the main pingcli
// application. This includes implementing the PingCliCommand interface and
// serving it over gRPC using Hashicorp's `go-plugin“ library.
package plugin

import (
"github.com/hashicorp/go-plugin"
"github.com/pingidentity/pingcli/shared/grpc"
)

// These variables define the command's metadata, which is sent to the pingcli
// host process. This information is used by the host's command-line framework
// (Cobra) to display help text, usage, and examples, making the plugin feel
// like a native command.
var (
// Example provides one or more usage examples for the command.
Example = "pingcli plugin-command --flag value"

// Long provides a detailed description of the command. It's shown in the
// help text when a user runs `pingcli help plugin-command`.
Long = `This command is an example of a plugin command that can be used with pingcli.
It demonstrates how to implement a custom command that can be executed by the pingcli host process`

// Short provides a brief, one-line description of the command.
Short = "An example plugin command for pingcli"

// Use defines the command's name and its arguments/flags syntax.
Use = "plugin-command [flags]"
)

// PingCliCommand is the implementation of the grpc.PingCliCommand interface.
// It encapsulates the logic for the custom command provided by this plugin.
type PingCliCommand struct{}

// A compile-time check to ensure PingCliCommand correctly implements the
// grpc.PingCliCommand interface.
var _ grpc.PingCliCommand = (*PingCliCommand)(nil)

// Configuration is called by the pingcli host to retrieve the command's
// metadata, such as its name, description, and usage examples. This allows
// the host to integrate the plugin's command into its own help and usage
// displays without executing the plugin's main logic.
func (c *PingCliCommand) Configuration() (*grpc.PingCliCommandConfiguration, error) {
cmdConfig := &grpc.PingCliCommandConfiguration{
Example: Example,
Long: Long,
Short: Short,
Use: Use,
}

return cmdConfig, nil
}

// Run is the execution entry point for the plugin command. The pingcli host
// calls this method when a user invokes the plugin command.
//
// The `args` parameter contains all command-line arguments and flags passed
// after the command's name (as defined in the `Use` variable). For example,
// if a user runs `pingcli plugin-command add --flag value`, the `args` slice
// will be `["add", "--flag", "value"]`.
//
// The `logger` parameter is a gRPC client that allows the plugin to send log
// messages back to the host process, ensuring that all output is displayed
// consistently through the main pingcli interface.
func (c *PingCliCommand) Run(args []string, logger grpc.Logger) error {
err := logger.Message("Message from plugin", nil)
if err != nil {
return err
}

err = logger.Warn("Warning from plugin", nil)
if err != nil {
return err
}

err = logger.PluginError("Error from plugin", map[string]string{
"key": "value",
"debug": "info",
})
if err != nil {
return err
}

return nil
}

// main is the entry point for the plugin's executable. When the pingcli host
// launches this plugin, this function starts a gRPC server that serves the
// PingCliCommand implementation. The go-plugin library handles the handshake
// and communication between the host and the plugin process.
func main() { //nolint:unused
plugin.Serve(&plugin.ServeConfig{
// HandshakeConfig is a shared configuration used to verify that the host
// and plugin are compatible.
HandshakeConfig: grpc.HandshakeConfig,

// Plugins defines the set of services this plugin serves. The key is a
// unique name for the plugin service, and the value is an implementation
// of the plugin.Plugin interface.
Plugins: map[string]plugin.Plugin{
grpc.ENUM_PINGCLI_COMMAND_GRPC: &grpc.PingCliCommandGrpcPlugin{
Impl: &PingCliCommand{},
},
},

// GRPCServer specifies the gRPC server implementation to use.
// plugin.DefaultGRPCServer is a sane default provided by the library.
GRPCServer: plugin.DefaultGRPCServer,
})
}
17 changes: 9 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ require (
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.6.3
github.com/hashicorp/go-uuid v1.0.3
github.com/knadh/koanf/parsers/yaml v1.0.0
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/confmap v1.0.0
github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/v2 v2.2.1
github.com/manifoldco/promptui v0.9.0
github.com/patrickcping/pingone-go-sdk-v2 v0.12.17
github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.0
github.com/patrickcping/pingone-go-sdk-v2/management v0.57.0
github.com/patrickcping/pingone-go-sdk-v2/mfa v0.23.0
github.com/patrickcping/pingone-go-sdk-v2/risk v0.19.0
github.com/patrickcping/pingone-go-sdk-v2 v0.13.0
github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.1
github.com/patrickcping/pingone-go-sdk-v2/management v0.59.0
github.com/patrickcping/pingone-go-sdk-v2/mfa v0.23.1
github.com/patrickcping/pingone-go-sdk-v2/risk v0.19.1
github.com/pingidentity/pingfederate-go-client/v1220 v1220.0.0
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.9.1
Expand Down Expand Up @@ -160,8 +160,8 @@ require (
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
github.com/oklog/run v1.0.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/patrickcping/pingone-go-sdk-v2/credentials v0.11.0 // indirect
github.com/patrickcping/pingone-go-sdk-v2/verify v0.9.0 // indirect
github.com/patrickcping/pingone-go-sdk-v2/credentials v0.11.1 // indirect
github.com/patrickcping/pingone-go-sdk-v2/verify v0.9.1 // indirect
github.com/pavius/impi v0.0.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
Expand Down Expand Up @@ -222,6 +222,7 @@ require (
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.24.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
Expand Down
34 changes: 18 additions & 16 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,8 @@ github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/tt
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.0.0 h1:PXyeHCRhAMKyfLJaoTWsqUTxIFeDMmdAKz3XVEslZV4=
github.com/knadh/koanf/parsers/yaml v1.0.0/go.mod h1:Q63VAOh/s6XaQs6a0TB2w9GFUuuPGvfYrCSWb9eWAQU=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
Expand Down Expand Up @@ -483,20 +483,20 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/patrickcping/pingone-go-sdk-v2 v0.12.17 h1:XN4IpfaqGVTcmNYx9Qh42Yo9TJLCZGl+sQT5H7sUnBA=
github.com/patrickcping/pingone-go-sdk-v2 v0.12.17/go.mod h1:6Q+d1SXedSku9+64HlgZofHGTfahQmoGKRwMsWBn7qM=
github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.0 h1:gEPzZToJlBcJh2Ft12dP1GCSGzsNFQFEHS7Bql86RQk=
github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.0/go.mod h1:2PDrgC1ufXk2IDIk4JQHx6r34r2xpkbnzKIpXFv8gYs=
github.com/patrickcping/pingone-go-sdk-v2/credentials v0.11.0 h1:pLiiBkROks/40vhFWJEcr/tiIEqqYdP4FWsHtfCLdIs=
github.com/patrickcping/pingone-go-sdk-v2/credentials v0.11.0/go.mod h1:yRGf7+tsB3/AQYsNjIIs4ScJhR885mvDYMgwHiQeMl0=
github.com/patrickcping/pingone-go-sdk-v2/management v0.57.0 h1:no242yX2sr9BIyWoyuBGzVscZJgDaE0RseUJL0AmqPk=
github.com/patrickcping/pingone-go-sdk-v2/management v0.57.0/go.mod h1:oLB/jjAkn4oEA60nC5/0KAobvcNJbflOWnVaS6lKxv8=
github.com/patrickcping/pingone-go-sdk-v2/mfa v0.23.0 h1:k133OY6PNO3tgNK3LBoEI+Uf9bRNKsvAkMMVUf99/Q0=
github.com/patrickcping/pingone-go-sdk-v2/mfa v0.23.0/go.mod h1:Q+Ym6kktv5Y6VnVhDt//lWoOhmIKfyjo6ejRx5mLttY=
github.com/patrickcping/pingone-go-sdk-v2/risk v0.19.0 h1:qGdwnfjsexHhTUAyBaUzheyeKWhR3Q8groqVpprzzOw=
github.com/patrickcping/pingone-go-sdk-v2/risk v0.19.0/go.mod h1:ppwkDT/w2/2y2aFH+hFQgziLMsWvz2MEZvwYexREqRk=
github.com/patrickcping/pingone-go-sdk-v2/verify v0.9.0 h1:Gnxvi7yx4NSBNOqBBydUPoR9Flp/dnnXj3129+ub9WY=
github.com/patrickcping/pingone-go-sdk-v2/verify v0.9.0/go.mod h1:bCq5fHv9mSdNsm/XiT5jb3YgYnQb8F824EYfq9eAJl4=
github.com/patrickcping/pingone-go-sdk-v2 v0.13.0 h1:0GUXULyb6VdYv6pLXplPcA3UloamGqooz8niZCYCwis=
github.com/patrickcping/pingone-go-sdk-v2 v0.13.0/go.mod h1:VU2guylo0C4Bad/4iRBAx7nR39+uTaki4YbkRCugs5Y=
github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.1 h1:Q4lJI8jmK1/HYy82cb4FHCh6cxNebkYiioRgsf8SX8I=
github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.1/go.mod h1:2PDrgC1ufXk2IDIk4JQHx6r34r2xpkbnzKIpXFv8gYs=
github.com/patrickcping/pingone-go-sdk-v2/credentials v0.11.1 h1:p7QsW+80U5LM43gkJYetYFPNeRmW5k8FteWMvEyPsJk=
github.com/patrickcping/pingone-go-sdk-v2/credentials v0.11.1/go.mod h1:yRGf7+tsB3/AQYsNjIIs4ScJhR885mvDYMgwHiQeMl0=
github.com/patrickcping/pingone-go-sdk-v2/management v0.59.0 h1:VWvxsNdYvOvfcbsA7Wbdyd4x9Zs+FlVs39PQ0F9yQ2M=
github.com/patrickcping/pingone-go-sdk-v2/management v0.59.0/go.mod h1:oLB/jjAkn4oEA60nC5/0KAobvcNJbflOWnVaS6lKxv8=
github.com/patrickcping/pingone-go-sdk-v2/mfa v0.23.1 h1:M97mqgeECxnF27syT+2dTYCSMFvGAZ+JFYXRsuKMlwY=
github.com/patrickcping/pingone-go-sdk-v2/mfa v0.23.1/go.mod h1:Q+Ym6kktv5Y6VnVhDt//lWoOhmIKfyjo6ejRx5mLttY=
github.com/patrickcping/pingone-go-sdk-v2/risk v0.19.1 h1:sthTrmwt46h9ey1TWI8B8LKctefuohvCXNdRXeh+ra0=
github.com/patrickcping/pingone-go-sdk-v2/risk v0.19.1/go.mod h1:ppwkDT/w2/2y2aFH+hFQgziLMsWvz2MEZvwYexREqRk=
github.com/patrickcping/pingone-go-sdk-v2/verify v0.9.1 h1:Os9QKUELVNTi+sFsflANjWZplc01K/wxetEsiAacQBc=
github.com/patrickcping/pingone-go-sdk-v2/verify v0.9.1/go.mod h1:bCq5fHv9mSdNsm/XiT5jb3YgYnQb8F824EYfq9eAJl4=
github.com/pavius/impi v0.0.3 h1:DND6MzU+BLABhOZXbELR3FU8b+zDgcq4dOCNLhiTYuI=
github.com/pavius/impi v0.0.3/go.mod h1:x/hU0bfdWIhuOT1SKwiJg++yvkk6EuOtJk8WtDZqgr8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
Expand Down Expand Up @@ -703,6 +703,8 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
Expand Down
Loading