Metashell is a wrapper around the shell (bash only for now).
It is able to transparantly capture keystrokes, as well as command exit codes.
Metashell depends on plugins to do anything useful; plugins receive the events captured by metashell and can act on them and even provide their own output and shell injections.
Simply build metashell:
go build -o metashell ./cmd/metashellThen, add it to your $PATH, and then set it as your shell in your terminal emulator; metashell will automatically install the necessary hooks to your shell on first run.
Metashell is a single binary that runs under 3 main modes, each serving a specific role in the architecture:
The main process that wraps the shell using a pseudoterminal (PTY) approach:
- Creates a pseudoterminal pair (master/slave) using the
ptypackage - Starts the shell process with the PTY slave as its controlling terminal
- Sets the user's terminal to raw mode for byte-by-byte keystroke capture
- Intercepts all I/O between the user and shell as the PTY master
- Performs smart filtering: ESC triggers "meta-mode", other keys pass through transparently
- Injects bash hooks on startup via
. <(metashell install) - Buffers keystrokes and sends command text to daemon when Enter is pressed
- Maintains command execution state (running/idle) based on daemon feedback
A long-running process that provides semantic command delineation:
- Receives command strings from metashell instances
- Receives command lifecycle events (start/end) from shellclient hooks
- Correlates commands with their execution lifecycle using unique UUIDs to match command text with execution results
- Manages plugin lifecycle using HashiCorp's go-plugin framework
- Forwards complete command events (with metadata and exit codes) to plugins
- Handles bidirectional communication via Unix domain sockets
- Coordinates between multiple metashell sessions
A lightweight client triggered by injected bash hooks that provides precise command boundaries:
- DEBUG trap (
__preRun): Called before each command execution- Requests UUID from daemon for command correlation
- Captures exact command text and execution start time
- PROMPT_COMMAND (
__postRun): Called after each command completion- Reports command exit code and completion to daemon
- Enables daemon to close the command lifecycle loop
- Works with daemon to transform raw keystroke data into semantically meaningful command events with complete metadata (command text, timing, exit status, TTY context)
sequenceDiagram
participant U as User
participant MS as Metashell
participant B as Bash
participant SC as Shellclient
participant D as Daemon
participant P as Plugin
U ->> MS : keystrokes
MS ->> B : PTY I/O
B ->> SC : DEBUG trap (__preRun)
SC ->> D : start (cmd UUID)
MS ->> D : command text
D ->> P : command event
P -->> MS: optional injections
B ->> MS : PTY I/O
MS -->> U : command output
B ->> SC : PROMPT_COMMAND (__postRun)
SC ->> D : end (exit code)
D ->> P : completion event
Metashell's plugin system is built on HashiCorp's go-plugin framework, using gRPC for communication. Plugins are standalone executables that communicate with the daemon process.
Plugins in Metashell implement the DaemonPlugin interface and provide two main capabilities:
- Command Reporting: Receive notifications about executed commands with metadata (command text, TTY, timestamp, exit code)
- Meta-commands: Respond to special commands triggered from meta-mode (ESC key) that can provide interactive lists, shell injections, or formatted output
- Go 1.19+
- Protocol Buffers compiler (
protoc) - Access to the Metashell source code for importing plugin packages
A minimal plugin implements the DaemonPlugin interface with four required methods:
type DaemonPlugin interface {
Init(context.Context, *proto.PluginConfig) error
Info(context.Context) (*proto.PluginInfo, error)
ReportCommand(context.Context, *proto.ReportCommandRequest) error
Metacommand(context.Context, *proto.MetacommandRequest) (*proto.MetacommandResponse, error)
}Here's a complete example of a command logging plugin:
package main
import (
"context"
"encoding/json"
"errors"
"github.com/hashicorp/go-plugin"
"github.com/raphaelreyna/metashell/pkg/plugin/log"
"github.com/raphaelreyna/metashell/pkg/plugin/proto/proto"
"github.com/raphaelreyna/metashell/pkg/plugin/proto/shared"
)
type MyPlugin struct {
history []string
}
func (p *MyPlugin) Init(ctx context.Context, config *proto.PluginConfig) error {
// Initialize plugin logging
log.Init(config)
log.Info("Plugin initialized")
return nil
}
func (p *MyPlugin) Info(ctx context.Context) (*proto.PluginInfo, error) {
return &proto.PluginInfo{
Name: "my-plugin",
Version: "v1.0.0",
AcceptsCommandReports: true,
Metacommands: []*proto.MetacommandInfo{
{
Name: "history",
Format: proto.MetacommandResponseFormat_SHELL_INJECTION_LIST,
},
},
}, nil
}
func (p *MyPlugin) ReportCommand(ctx context.Context, req *proto.ReportCommandRequest) error {
// Handle command reports
log.Info("Command executed", "command", req.Command, "exit_code", req.ExitCode)
p.history = append(p.history, req.Command)
return nil
}
func (p *MyPlugin) Metacommand(ctx context.Context, req *proto.MetacommandRequest) (*proto.MetacommandResponse, error) {
var resp proto.MetacommandResponse
switch req.MetaCommand {
case "history":
items := make([]map[string]string, len(p.history))
for i, cmd := range p.history {
items[i] = map[string]string{
"title": cmd,
"description": "Previously executed command",
"filter_value": cmd,
"value": cmd,
}
}
data, err := json.Marshal(items)
if err != nil {
return nil, err
}
resp.Data = data
default:
resp.Error = "unknown command"
return &resp, errors.New("unknown command")
}
return &resp, nil
}
func main() {
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: shared.Handshake,
Plugins: map[string]plugin.Plugin{
"daemonPlugin": &shared.DaemonPluginImplementation{
Impl: &MyPlugin{},
},
},
GRPCServer: plugin.DefaultGRPCServer,
Logger: log.GetLogger(),
})
}Init(context.Context, *proto.PluginConfig) errorCalled once when the plugin is loaded. Use this to:
- Initialize plugin logging with
log.Init(config) - Set up any required state or connections
- Parse plugin-specific configuration from
config.Data
Info(context.Context) (*proto.PluginInfo, error)Returns metadata about your plugin:
Name: Unique plugin identifierVersion: Plugin version stringAcceptsCommandReports: Set totrueto receive command reportsMetacommands: List of meta-commands your plugin supports
ReportCommand(context.Context, *proto.ReportCommandRequest) errorCalled for each executed command when AcceptsCommandReports is true. The request contains:
Command: The executed command textTty: The TTY where the command was executedTimestamp: Unix timestamp when the command was executedExitCode: The command's exit code
Metacommand(context.Context, *proto.MetacommandRequest) (*proto.MetacommandResponse, error)Handles meta-commands triggered from meta-mode. The request contains:
MetaCommand: The meta-command nameArgs: Command argumentsFormatArgs: Format-specific argumentsTty: The TTY where the command was triggered
Your meta-commands can return different response formats:
Simple text output:
resp.Data = []byte("Hello, world!")Command to inject into the shell:
resp.Data = []byte("ls -la")Interactive list for selection:
items := []map[string]string{
{
"title": "List Files",
"description": "Show directory contents",
"filter_value": "ls",
},
}
data, _ := json.Marshal(items)
resp.Data = dataInteractive list where each item injects a command:
items := []map[string]string{
{
"title": "List Files",
"description": "Show directory contents",
"filter_value": "ls",
"value": "ls -la",
},
}
data, _ := json.Marshal(items)
resp.Data = data- Build your plugin:
go build -o my-plugin main.go-
Install the plugin: Place the compiled binary in your plugins directory (typically
~/.metashell/plugins/) -
Restart the daemon:
metashell daemon stop
metashell daemon start- Use the provided logging package:
github.com/raphaelreyna/metashell/pkg/plugin/log - Handle errors gracefully: Return appropriate errors from your methods
- Test with the example plugin: Start with the
examples/plugins/command_loggingplugin - Use meta-commands for user interaction: Provide useful interactive commands via meta-mode
- Consider performance: Command reports are called frequently, keep processing lightweight
Plugins can receive configuration data through the Init method:
func (p *MyPlugin) Init(ctx context.Context, config *proto.PluginConfig) error {
// config.Data contains plugin-specific configuration
// Parse your configuration here
return nil
}Plugins can run additional services like HTTP servers for dashboards or APIs:
func (p *MyPlugin) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Handle HTTP requests
}
func main() {
plugin := &MyPlugin{}
go http.ListenAndServe(":8080", plugin)
// ... rest of plugin.Serve() call
}- Command Analytics: Track command usage patterns and provide insights
- Smart Completions: Offer context-aware command suggestions
- Integration Tools: Connect with external services (Slack, JIRA, etc.)
- Development Helpers: Git shortcuts, deployment commands, environment management
- Security Monitoring: Alert on suspicious commands or patterns
For more examples, see the examples/plugins/ directory in the source code.
