Skip to content

Writing Commands

A command is a Go type satisfying pkg/command.Command. The base interface is three methods; everything else is opt-in.

Minimum viable command

go
package command

import (
    "context"

    "github.com/foomo/posh/pkg/log"
    "github.com/foomo/posh/pkg/readline"
)

type Hello struct {
    l log.Logger
}

func NewHello(l log.Logger) *Hello {
    return &Hello{l: l}
}

func (c *Hello) Name() string        { return "hello" }
func (c *Hello) Description() string { return "say hello" }
func (c *Hello) Execute(ctx context.Context, r *readline.Readline) error {
    name := "world"
    if r.Args().LenIs(1) {
        name = r.Args().At(0)
    }
    c.l.Successf("hello, %s!", name)
    return nil
}

Register it in internal/plugin.go:

go
inst.commands.Add(NewHello(l))

Rebuild (make shell.build) and:

text
posh › hello kevin
hello, kevin!

The base interface

go
type Command interface {
    Name() string
    Description() string
    Execute(ctx context.Context, r *readline.Readline) error
}

r (*readline.Readline) gives you parsed structure:

MethodReturns
r.Cmd()The command name as typed
r.Args()Positional args (Len, At, LenIs, LenLte)
r.Flags()Parsed flags
r.AdditionalArgs()Anything after --
r.Mode()Current parser mode (used for completion)

The convention is: don't write your own argv parser, use what readline already gave you.

Optional behaviours

Add capabilities by also implementing one or more of these. The framework uses Go type assertions; there's no super-interface.

Helper — long help

go
func (c *Hello) Help(ctx context.Context, r *readline.Readline) string {
    return `Print a greeting.

Usage:
  hello [name]
`
}

help <command> prints whatever you return.

Validator — pre-execute check

go
func (c *Hello) Validate(ctx context.Context, r *readline.Readline) error {
    if r.Args().Len() > 1 {
        return errors.New("hello takes 0 or 1 arguments")
    }
    return nil
}

Returning an error skips Execute and prints the error to the prompt. Use this for argument-shape validation; do business-logic errors inside Execute.

Shutdowner — graceful teardown

go
func (c *Server) Shutdown(ctx context.Context) error {
    return c.srv.Shutdown(ctx)
}

Called when the prompt loop exits. Every Shutdowner runs in parallel with a 3-second deadline. If you hold long-running resources (HTTP servers, database connections, watchers), implement this.

Describer — agent catalog

go
func (c *Hello) Describe(ctx context.Context) command.CommandInfo {
    return command.CommandInfo{
        FullPath:    "hello",
        Description: "say hello",
        Arguments: []command.ArgInfo{
            {Name: "name", Description: "who to greet", Optional: true},
        },
    }
}

Backs posh agent catalog, which gives an AI coding agent a machine-readable answer to "what can I run" instead of making it parse help text. Commands that don't implement it are still listed — just as leaves with only a name and description.

Tree-based commands delegate like every other tree method:

go
func (c *Kube) Describe(ctx context.Context) command.CommandInfo {
    return c.tree.Describe(ctx)
}

The contract is CommandInfo, not *tree.Node — you stay free to change how you model your command's structure, or to build the info by hand, without breaking the catalog.

Skiller — agent skill prose

go
func (c *Hello) Skill(ctx context.Context, name string) string {
    return `#### When to use

Prefer ` + "`posh x " + name + "`" + ` over ` + "`echo`" + ` — it reads the greeting format from .posh.yaml.
`
}

Extra markdown for posh agent skill, appended verbatim to this command's own generated skill — extended instructions, worked config examples, links. Use heading level 4 (####) or deeper.

It's free-form text rather than a struct because what's worth telling an agent varies per command, and the destination is markdown either way. Top-level commands only — nested subcommands keep their compact indented listing.

name is the name the command is registered under, which isn't necessarily its default one — the same command can be registered under another name via CommandWithName. Write the prose against name instead of hardcoding a literal, or the generated skill tells the agent to run a command that doesn't exist in this project.

Don't list flags and arguments here: they're already structured on the command, and the generated skill points at posh agent catalog and posh help <command> for them.

SkillMetadataer — agent skill frontmatter

go
func (c *Hello) SkillMetadata(ctx context.Context, name string) command.SkillMetadata {
    return command.SkillMetadata{
        Description: "Use when greeting a user, or when checking the configured greeting format.",
    }
}

The frontmatter of this command's own generated skill. Fields are restricted to the same Agent Skills allowlist as the plugin-level metadata; unset ones are derived from the command.

Spend the description on the conditions that should reach for this command. It's the only thing an agent runtime sees when deciding whether to load a skill, so the derived fallback — Use when running <name> commands. — means the skill effectively never loads. posh agent skill install warns and names every command that fell back.

Completion

Three flavours, picked by the parser's mode:

InterfaceTriggered when…
CompleterGeneric; picked if no more specific completer exists
ArgumentCompleterThe cursor is in an argument position
FlagCompleterThe cursor is in a --flag position
AdditionalArgsCompleterThe cursor is after --
PassThroughFlagsCompleterFlags forwarded to a wrapped CLI
go
import "github.com/foomo/posh/pkg/prompt/goprompt"

func (c *Hello) CompleteArguments(ctx context.Context, r *readline.Readline) []goprompt.Suggest {
    return []goprompt.Suggest{
        {Text: "kevin", Description: "the maintainer"},
        {Text: "world", Description: "the default"},
    }
}

Suggestions are filtered by the prompt's filter (goprompt.FilterCombined by default) — you don't need to do prefix matching yourself, just return everything plausible.

For dynamic completions (e.g. "list of running pods"), keep them cheap or memoise. The prompt calls your completer on every keystroke; a 200ms RPC will feel awful.

Functional options pattern

The repo is consistent on this — every constructor in pkg/ follows it, and your commands should too:

go
type (
    Hello struct {
        l       log.Logger
        cfg     config.Hello
        nameFmt string
    }
    HelloOption func(*Hello) error
)

func HelloWithConfig(v config.Hello) HelloOption {
    return func(o *Hello) error { o.cfg = v; return nil }
}

func HelloWithConfigKey(v string) HelloOption {
    return func(o *Hello) error {
        return viper.UnmarshalKey(v, &o.cfg)
    }
}

func NewHello(l log.Logger, opts ...HelloOption) (*Hello, error) {
    inst := &Hello{l: l, nameFmt: "%s"}
    for _, opt := range opts {
        if opt != nil {
            if err := opt(inst); err != nil { return nil, err }
        }
    }
    return inst, nil
}

The WithConfigKey variant is the one you'll reach for most often — it lets the registration site pick which .posh.yaml subtree to read:

go
inst.commands.MustAdd(
    NewHello(l, HelloWithConfigKey("hello")),
)

Tree (subcommand) commands

For commands with their own command tree (docker run …, kubectl get pods), use pkg/command/tree:

go
import (
    "github.com/foomo/posh/pkg/command/tree"
)

func NewKube(l log.Logger) *Kube {
    return &Kube{
        l: l,
        tree: tree.New(&tree.Node{
            Name:        "kube",
            Description: "kubernetes helpers",
            Nodes: []*tree.Node{
                {
                    Name:        "ctx",
                    Description: "switch context",
                    Args: tree.Args{{
                        Name: "context",
                        Suggest: func(ctx context.Context, t tree.Root, r *readline.Readline) []goprompt.Suggest {
                            return listKubeContexts(ctx)
                        },
                    }},
                    Execute: func(ctx context.Context, r *readline.Readline) error {
                        return switchContext(ctx, r.Args().At(1))
                    },
                },
                {
                    Name:        "logs",
                    Description: "tail pod logs",
                    Args: tree.Args{
                        {Name: "pod", Description: "target pod"},
                    },
                    Execute: func(ctx context.Context, r *readline.Readline) error {
                        return tailLogs(ctx, r.Args().At(1))
                    },
                },
            },
        }),
    }
}

func (c *Kube) Name() string                                    { return "kube" }
func (c *Kube) Description() string                             { return "kubernetes helpers" }
func (c *Kube) Help(ctx context.Context, r *readline.Readline) string {
    return c.tree.Help(ctx, r)
}
func (c *Kube) Execute(ctx context.Context, r *readline.Readline) error {
    return c.tree.Execute(ctx, r)
}
func (c *Kube) Complete(ctx context.Context, r *readline.Readline) []goprompt.Suggest {
    return c.tree.Complete(ctx, r)
}

The tree handles completion, dispatch and help for you. Each Node can have nested Nodes, typed Args, Flags, and an Execute callback. See pkg/command/tree/root_test.go for a worked example.

Testing commands

Commands are plain Go types — test them as such. The framework provides pkg/log.NewTest(t) for a logger that forwards messages to t.Log (and t.Error/t.Fatal at the corresponding levels):

go
func TestHello_Execute(t *testing.T) {
    l := log.NewTest(t, log.TestWithLevel(log.LevelDebug))
    cmd := NewHello(l)

    r, err := readline.New(l)
    require.NoError(t, err)
    require.NoError(t, r.Parse("hello kevin"))

    require.NoError(t, cmd.Execute(t.Context(), r))
}

For commands that wrap external processes, see Integrations § The exec packagemiddleware.CaptureStdout(&buf) makes them straightforward to assert against in tests.