Foundation library for building TypeScript Stripe CLI plugins. This package provides:
~/.config/stripe/config.toml)This package is published to GitHub Packages. Configure your package manager to use it:
# For npm
npm config set @stripe:registry https://npm.pkg.github.com
echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN" >> ~/.npmrc
# For pnpm
pnpm config set @stripe:registry https://npm.pkg.github.com
echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN" >> ~/.npmrc
Your token needs the read:packages scope.
Create a new npm project and add bootstrap as a dependency:
mkdir my-stripe-plugin
cd my-stripe-plugin
pnpm init
pnpm add @stripe/stripe-cli-plugin-bootstrap
Run the init script to set up your plugin. This will prompt for your plugin name and generate a unique magic cookie (UUID) for handshake verification:
pnpm exec stripe-cli-init-plugin
This creates a .plugin file with your plugin name and magic cookie:
myplugin XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
Create a src/main.ts that imports servePlugin and starts your plugin. The plugin uses versioned plugins to support multiple protocol versions:
// src/main.ts
import { servePlugin } from '@stripe/stripe-cli-plugin-bootstrap'
import { MyPlugin } from './plugin'
async function main() {
await servePlugin({
versionedPlugins: {
3: new MyPlugin(),
},
address: process.env.PLUGIN_ADDRESS || '127.0.0.1:0',
networkType: 'tcp',
})
}
main().catch(err => {
console.error('Plugin startup failed:', err)
process.exit(1)
})
The plugin will automatically negotiate the protocol version:
PLUGIN_PROTOCOL_VERSIONS env var (e.g., "2,3"), the plugin selects the highest common versionThis pattern matches HashiCorp’s go-plugin VersionedPlugins option and allows graceful evolution of your plugin API.
Create a src/plugin.ts that implements PluginCommand. Use getPluginYargs() to get a pre-configured yargs instance with global flags:
// src/plugin.ts
import {
PluginCommand,
getPluginYargs,
GlobalFlags,
CoreCLIHelper,
} from '@stripe/stripe-cli-plugin-bootstrap'
import * as yargs from 'yargs'
import * as commands from './commands'
export class MyPlugin implements PluginCommand {
private pluginYargs: yargs.Argv<GlobalFlags>
constructor() {
// 'myplugin' becomes the script name: `stripe myplugin <command>`
this.pluginYargs = getPluginYargs('myplugin')
// Register your commands
for (const cmd of Object.values(commands)) {
this.pluginYargs = cmd.connect(this.pluginYargs)
}
}
async runCommand(args: string[], coreCLIHelper?: CoreCLIHelper): Promise<void> {
// use coreCLIHelper to call back into the host Stripe CLI process, like emitting analytics and managing API keys
await this.pluginYargs.parseAsync(args)
}
}
Create commands in src/commands/. Each command exports an object with a connect(yargs) method:
// src/commands/hello.ts
import * as yargs from 'yargs'
import { GlobalFlags } from '@stripe/stripe-cli-plugin-bootstrap'
export const helloCommand = {
connect(yargsInstance: yargs.Argv<GlobalFlags>): yargs.Argv<GlobalFlags> {
return yargsInstance.command(
'hello <name>',
'Say hello to someone',
y =>
y.positional('name', {
description: 'Name to greet',
type: 'string',
}),
async args => {
console.log(`Hello, ${args.name}!`)
},
)
},
}
// src/commands/index.ts
export { helloCommand } from './hello'
export { anotherCommand } from './another'
Build your plugin and install it locally:
pnpm build
pnpm exec stripe-cli-install-plugin
Then test it:
stripe myplugin hello world
# Build the JavaScript bundle
pnpm build
The bootstrap package provides stripe-cli-build-binaries to create standalone executables:
# Build binary for current platform
pnpm exec stripe-cli-build-binaries ./bin
# Build for a specific target (canonical target name)
pnpm exec stripe-cli-build-binaries ./bin macos-arm64
# Legacy node18-* aliases are still accepted for backwards-compat
pnpm exec stripe-cli-build-binaries ./bin node18-macos-arm64
Supported targets: macos-arm64, macos-x64, linux-x64, linux-arm64, win-x64
Legacy aliases accepted for backwards-compat: node18-macos-arm64, node18-macos-x64, node18-linux-x64, node18-linux-arm64, node18-win-x64
stripe-cli-build-binaries uses Bun’s bun build --compile
to produce standalone plugin binaries. Bun must be installed on PATH to run
stripe-cli-build-binaries. The required minimum Bun version is pinned in
.tool-versions at the repo root (currently 1.3.13) and enforced by
stripe-cli-build-binaries, which exits with an actionable error if bun is
missing or older than the required version.
Install Bun with any of:
# Official installer:
curl -fsSL https://bun.sh/install | bash
# Homebrew:
brew install oven-sh/bun/bun
# mise / asdf (reads .tool-versions):
mise install bun
# or: asdf install bun
Bun is only needed for the binary-compile path. pnpm build, pnpm test,
and pnpm lint continue to run on Node without Bun.
If your plugin needs data files at runtime (OpenAPI specs, templates, etc.),
declare them in your plugin’s package.json under bun.assets:
{
"bun": {
"assets": ["openapi/**/*", "sdk-bases/**/*", "templates/**/*"]
}
}
stripe-cli-build-binaries expands those globs, embeds every matched file
directly into the compiled binary using Bun’s import ... with { type: "file" }
attribute, and produces a single self-contained executable.
At runtime, resolve asset paths through
@stripe/stripe-cli-plugin-bootstrap/runtime:
import * as fs from 'node:fs/promises'
import * as yaml from 'yaml'
import { resolveAsset } from '@stripe/stripe-cli-plugin-bootstrap/runtime'
const specPath = resolveAsset('openapi/spec3.sdk.yaml')
const spec = yaml.parse(await fs.readFile(specPath, 'utf8'))
In a compiled binary, getAssetDir() extracts embedded files to a temp
directory on first call and returns that path. The temp directory is cleaned up
on process exit. In dev mode (Node/tsx/vitest), it returns the repo root where
assets exist on disk.
Bundled consumption only. These helpers rely on
__dirnameresolving to
the consumer plugin’sdist/directory. This works when esbuild bundles
the import into the plugin’sdist/bundle.js. It does not work when
imported unbundled via plainnodeortsx— in that case__dirname
points into bootstrap’s owndist/runtime/.
Any publish, archive, or install flow must preserve that sibling layout inside
~/.config/stripe/plugins/<shortname>/<version>/(or any equivalent release
directory), or runtime file reads will fail.
To install a plugin for local development with the Stripe CLI:
# Build and install to ~/.config/stripe/plugins/<plugin-name>/local.build.dev/
pnpm exec stripe-cli-install-plugin
This script:
.pluginbun.assets sibling files to ~/.config/stripe/plugins/<plugin-name>/local.build.dev/~/.config/stripe/plugins.toml with the plugin entryAfter installation, you can run your plugin via the Stripe CLI:
stripe myplugin hello world
To build a local binary without installing to the Stripe CLI plugins directory:
pnpm exec stripe-cli-build-local
This creates the binary at
./bin/<plugin-name>/local.build.dev/stripe-cli-<plugin-name> and copies any
declared bun.assets siblings into the same local build directory.
# Run tests with vitest
pnpm test
TBD - Publishing workflow for distributing plugins.
Access Stripe CLI configuration (profiles, API keys, etc.):
import { initializeConfig, getStripeCLIConfig } from '@stripe/stripe-cli-plugin-bootstrap'
// Initialize config (reads ~/.config/stripe/config.toml)
const config = initializeConfig('default')
// Get API key for current profile
const profile = config.getProfile()
const apiKey = await profile.getAPIKey(false) // false = test mode
Wrap your command handler with telemetry (when fully implemented):
import { withTelemetry, PluginInfo } from '@stripe/stripe-cli-plugin-bootstrap'
const pluginInfo: PluginInfo = {
name: 'my-plugin',
version: '1.0.0',
}
const execute = withTelemetry(async (args: string[]) => {
// Your command implementation
}, pluginInfo)
servePlugin(options)Boots the plugin gRPC server and prints the handshake line to stdout.
type NetworkType = 'tcp' | 'unix'
interface PluginCommand {
runCommand(args: string[]): Promise<void>
}
interface ServeOptions {
appProtocolVersion: number // host-defined application version
address: string // e.g. "127.0.0.1:0" for ephemeral port
networkType?: NetworkType // default "tcp"
plugin: PluginCommand // your command handler
}
Behavior:
GRPCStdio and GRPCController services are registered automaticallyproto.Main service is registered to invoke your plugin’s runCommand methodtcp, the server binds to an ephemeral port and prints the final host:port in the handshakegetPluginYargs(pluginName)Returns a yargs instance pre-configured with Stripe CLI global flags:
const yargs = getPluginYargs('myplugin')
// Includes: --api-key, --color, --config, --device-name, --log-level, --project-name
formatHandshake(core, app, network, addr, protocol)Returns a string in the format required by go-plugin:
const line = formatHandshake(1, 2, 'tcp', '127.0.0.1:34567', 'grpc')
// => "1|2|tcp|127.0.0.1:34567|grpc"
addTypedService(server, service, impl)Type-safe helper for registering gRPC services on a server:
import { addTypedService } from '@stripe/stripe-cli-plugin-bootstrap'
import { MyService, MyServer } from './proto/my_service'
addTypedService(server, MyService, myImplementation)
This library automatically registers the internal services that the go-plugin host expects:
plugin.GRPCStdio - Streams stdout/stderr to the host processplugin.GRPCController - Handles shutdown requestsgrpc.health.v1.Health - Health check service (reports SERVING for “plugin”)proto.Main - Command dispatch service (invokes your PluginCommand.runCommand)When the plugin starts, it prints a handshake line to stdout:
CORE-PROTOCOL-VERSION|APP-PROTOCOL-VERSION|NETWORK-TYPE|NETWORK-ADDR|grpc
Example: 1|2|tcp|127.0.0.1:12345|grpc
The host process parses this line to connect to the plugin’s gRPC server.
The gRPC Health service reports SERVING for the service name "plugin". This allows the host to probe plugin readiness.
The TerminalInfo class provides information about whether the host’s stdout/stderr are terminals:
import { TerminalInfo } from '@stripe/stripe-cli-plugin-bootstrap'
if (TerminalInfo.hostStdoutIsTerminal) {
// Use colors, interactive output, etc.
}
For more details on the go-plugin protocol, see:
docs/guide-plugin-write-non-go.md in the upstream repo