agentexec

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 5, 2026 License: MIT Imports: 11 Imported by: 0

README

agentexec

What os/exec is to processes, agentexec is to agent CLIs: run the Claude Code, Codex and Gemini CLIs from Go — build the argv, parse what they stream back, and get one canonical event and result shape out the other end.

The CLIs are the most capable agent runtimes most people already have installed — and each one takes different flags, streams a different JSON dialect, and reports usage and failure differently. This library is the part every app that shells out to them ends up writing, extracted once:

  • Command building — modes, models, system prompts, resume/continue, sandbox posture, MCP config, plugin directories, per-provider flag names
  • Stream parsing — stream-json / JSONL frames normalized to one Event type across providers
  • Usage and result accounting — tokens, cost, and an honest failure verdict
  • A PTY runner — some CLIs behave differently without a terminal
  • Hook helpers — install hook commands into ~/.claude / ~/.codex, then parse the payloads they send you

No app config, no transport, no business logic, no opinion about where output goes. Everything app-specific is injected by the caller.

go get github.com/liliang-cn/agentexec

Requires Go 1.25. Only dependency: github.com/creack/pty.

Usage

One turn, end to end:

sess := agentexec.NewClaude().NewSession()

spec, err := sess.BuildCommand(ctx, agentexec.Request{
	Prompt:         "summarise README.md in one line",
	WorkspacePath:  "/path/to/repo",
	PermissionMode: agentexec.PermissionBypass,
	NoMCP:          true,
})
if err != nil {
	return err
}

var answer strings.Builder
res, err := pty.Run(ctx, pty.Command{
	Argv: spec.Argv, Env: spec.Env, WorkDir: spec.WorkDir, Stdin: spec.Stdin,
}, func(chunk []byte) {
	events, _ := sess.ParseChunk(chunk)
	for _, e := range events {
		// Lifecycle frames land on agent.message too — role separates them.
		if e.Type == agentexec.EventAgentMessage && e.Payload["role"] == "assistant" {
			answer.WriteString(e.Payload["text"].(string))
		}
	}
})
if err != nil {
	return err
}

out, _, err := sess.Finalize(ctx, res.Output, res.ExitCode)
// out.Failed, out.Usage.InputTokens, out.Usage.EstimatedCostUSD, out.Summary

Finalize also parses non-streamed output, so a caller that never wired ParseChunk still gets a filled-in Result.

Providers register into a Registry when an app supports more than one:

reg := agentexec.NewRegistry()
reg.Register(agentexec.NewClaude())
reg.Register(agentexec.NewCodex(agentexec.WithBinary("/opt/homebrew/bin/codex")))
reg.Register(agentexec.NewGemini())

p, err := reg.Get("codex")          // reg.Names() is sorted
caps := p.Capabilities()            // Streaming, Resume, Plugins, MCP, SupportsPTY, …

Options: WithBinary, WithName, WithBaseEnv, WithModelEnv, WithMCPConfig, WithAllowedModes. Request.Mode is free-form and app-defined; BuildCommand returns ErrUnsupportedMode when the provider was configured with an allowlist that excludes it.

Four behaviours worth knowing before you rely on them

Each of these is a bug someone has to hit once. They are decided here so that does not have to keep happening.

Result.Failed is not the exit code. A claude whose OAuth token has been revoked writes "Failed to authenticate" as an assistant message, sets is_error on its result frame, and exits zero. Read only the message and the exit code and you write an authentication failure into a file as if it were the model's answer. Only Claude reports this verdict; Codex's error item also carries warnings, so mapping it would mark healthy turns failed, and Gemini has no signal at all — for those two Failed stays false rather than being invented.

Filter agent.message by role. Provider lifecycle frames — Claude's system init, its hook events, the result summary — map onto EventAgentMessage as well, distinguished by Payload["role"]. One "say OK" call produced eleven of them, ten being hook lifecycle. Filtering on text being non-empty happens to work today; that is a coincidence of the current mapping, not a contract.

NoMCP is not the same as no MCP servers. An empty ExtraMCPServers map means no --mcp-config flag, which means the CLI loads everything the developer has configured — right for an interactive session, wrong for using the CLI as an inference backend, where booting every server took longer than the model spent thinking and the call could reach the operator's own servers. NoMCP: true passes an explicitly empty config instead. It yields to ExtraMCPServers and Plugins when those supply servers.

Sandbox is false by default, and that means headless. The zero value emits the skip-sandbox / trust / skip-git-check flags — the posture you want when an app is driving the CLI unattended. Set it to true to run inside the CLI's own sandbox and approval flow.

Hooks

hooks handles the other direction: the CLI calling your app.

hooks.InstallClaude(hooks.InstallOptions{Command: "myapp hook"}) // merges into ~/.claude/settings.json
hooks.InstallCodex(hooks.InstallOptions{Command: "myapp hook"})  // ~/.codex/config.toml

ev, err := hooks.ParsePayload(os.Stdin)  // in your hook command
hooks.Summarize(ev)                      // one-line human description
hooks.SlashCommand(ev)                   // "/compact" etc., "" when not one
hooks.LastAssistantText(ev.TranscriptPath)
hooks.TurnText(ev.TranscriptPath)

Installation is a merge, not a write: other settings survive, and re-running with the same command does not create duplicate hook groups. Default events are PreToolUse, PostToolUse, Notification, Stop, UserPromptSubmit.

Testing

go test ./...

Provider argv is covered by golden tests — the argv a provider builds is the contract this library sells, so it changes visibly or not at all.

License

MIT

Documentation

Overview

Package agentexec provides an app-agnostic core for building, invoking, and parsing the Claude Code and Codex (and Gemini) CLIs. It contains command construction, stream-json / JSONL parsing, usage accounting, a line buffer, and plugin MCP config helpers — with no business logic, app config, or transport baked in. App-specific concerns are injected by the caller.

Index

Constants

View Source
const (
	EventAgentMessage   = "agent.message"
	EventToolCall       = "agent.tool_call"
	EventToolResult     = "agent.tool_result"
	EventTerminalOutput = "terminal.output"
	EventRateLimit      = "provider.rate_limit"
)

Canonical event types.

EventAgentMessage carries more than the agent's prose. Provider lifecycle frames — Claude's `system` init and its hook events, the `result` summary — map here too, distinguished by Payload["role"]: "assistant" is what the model said, "system" and "result" are the CLI talking about the session. One "say OK" call produced eleven agent.message events, ten of them hook lifecycle, so a caller collecting the answer wants:

if e.Type == EventAgentMessage && e.Payload["role"] == "assistant" { ... }

Filtering on Payload["text"] being non-empty happens to work today, because lifecycle frames carry "raw" instead — but that is a coincidence of the current mapping, not a contract.

Variables

View Source
var ErrUnsupportedMode = errors.New("agentexec: unsupported mode")

ErrUnsupportedMode is returned by BuildCommand when Request.Mode is not allowed.

Functions

func WritePluginMCPConfig

func WritePluginMCPConfig(plugins []PluginRef, env map[string]string, outDir, filename string) (string, error)

WritePluginMCPConfig loads + resolves plugin MCP servers and writes a merged config into outDir/filename, returning its path (empty if nothing to write).

Types

type Capabilities

type Capabilities struct {
	Streaming         bool
	Resume            bool
	Plugins           bool
	MCP               bool
	SupportsPTY       bool
	RequiresWorkspace bool
}

Capabilities describes which app-agnostic features a provider supports.

type CommandSpec

type CommandSpec struct {
	Argv    []string
	Env     []string
	WorkDir string
	Stdin   []byte
}

CommandSpec is the fully resolved command to execute, produced by BuildCommand.

type Event

type Event struct {
	Type    string
	Payload map[string]any
}

Event is a single canonical, provider-normalized output event.

type LineBuffer

type LineBuffer struct {
	// contains filtered or unexported fields
}

LineBuffer accumulates streamed bytes and emits complete lines. Carriage returns are stripped; a trailing partial line (no newline yet) is buffered until completed by a later Feed or surfaced by Flush. The zero value is ready to use.

func (*LineBuffer) Fed

func (lb *LineBuffer) Fed() bool

Fed reports whether Feed has ever been called. Finalize uses it to tell a caller that streamed the output from one that collected it and passed the whole thing at the end.

func (*LineBuffer) Feed

func (lb *LineBuffer) Feed(chunk []byte) []string

Feed appends chunk and returns any complete lines it produced.

func (*LineBuffer) Flush

func (lb *LineBuffer) Flush() []string

Flush returns the buffered partial tail (if any) and clears the buffer.

type Option

type Option func(*providerConfig)

Option configures a provider constructor.

func WithAllowedModes

func WithAllowedModes(modes []string) Option

WithAllowedModes restricts Request.Mode; an unlisted mode yields ErrUnsupportedMode.

func WithBaseEnv

func WithBaseEnv(env map[string]string) Option

WithBaseEnv sets a base environment applied to every command.

func WithBinary

func WithBinary(path string) Option

WithBinary overrides the CLI binary path/name.

func WithMCPConfig

func WithMCPConfig(filename string, strict bool) Option

WithMCPConfig sets the merged MCP config filename written under WorkspacePath and whether to append the provider's strict-mcp flag.

func WithModelEnv

func WithModelEnv(key string) Option

WithModelEnv names the Request.Env key to source the model value from. The resolved value is emitted as the provider's model flag (e.g. claude --model).

func WithName

func WithName(name string) Option

WithName overrides the registered provider name.

type PermissionMode

type PermissionMode string

PermissionMode selects whether a provider bypasses its approval prompts.

const (
	PermissionDefault PermissionMode = ""
	PermissionBypass  PermissionMode = "bypass"
)

type PluginRef

type PluginRef struct {
	Name string
	Path string
}

PluginRef references a Claude Code plugin directory by name and path.

type Provider

type Provider interface {
	Name() string
	Capabilities() Capabilities
	NewSession() Session
}

Provider is a CLI agent backend (claude, codex, gemini).

func NewClaude

func NewClaude(opts ...Option) Provider

NewClaude returns a Claude Code Provider.

func NewCodex

func NewCodex(opts ...Option) Provider

NewCodex returns a Codex Provider.

func NewGemini

func NewGemini(opts ...Option) Provider

NewGemini returns a Gemini Provider.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry maps provider names to Providers.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty Registry.

func (*Registry) Get

func (r *Registry) Get(name string) (Provider, error)

Get returns the provider registered under name, or an error if absent.

func (*Registry) Names

func (r *Registry) Names() []string

Names returns the registered provider names, sorted.

func (*Registry) Register

func (r *Registry) Register(p Provider)

Register adds (or overwrites) a provider keyed by its Name().

type Request

type Request struct {
	RunID           string
	Mode            string // free-form, app-defined
	Prompt          string
	SystemPrompt    string // claude: --append-system-prompt; codex/gemini: prepended to prompt
	WorkspacePath   string
	Model           string // optional; provider maps to its model flag
	Env             map[string]string
	Plugins         []PluginRef    // claude --plugin-dir + .mcp.json merge
	ExtraMCPServers map[string]any // caller-injected MCP servers, merged before plugin servers
	// NoMCP runs with an empty MCP config instead of the user's own.
	//
	// An empty ExtraMCPServers map cannot express this: no servers means no
	// --mcp-config flag, which means the CLI loads everything the developer has
	// configured. That is right for an interactive session and wrong for using
	// the CLI as an inference backend — booting every server took longer than
	// the model spent thinking, and a call that can reach the operator's own
	// MCP servers is not reproducible in any sense.
	//
	// Ignored when ExtraMCPServers or Plugins supply servers: asking for both
	// none and some is a caller bug, and the explicit servers are the clearer
	// intent.
	NoMCP           bool
	PermissionMode  PermissionMode // PermissionDefault | PermissionBypass
	Sandbox         bool           // false (zero value) = headless: emit skip-sandbox/trust/git-check flags. true = run inside the CLI's own sandbox/approval flow.
	ResumeSessionID string         // claude --resume / codex resume <id>
	Continue        bool           // claude --continue
	ExtraArgs       []string       // escape hatch appended before the prompt
}

Request is the app-agnostic input to BuildCommand. App-specific policy text is passed via SystemPrompt; anything else via ExtraArgs/Env. No PolicyJSON/UserID/ ProjectID/SaaS fields are baked in — those stay in the calling application.

type Result

type Result struct {
	ExitCode int
	Summary  string
	Usage    Usage
	// Failed is the provider's own verdict on the turn, which is not the same
	// as the exit code and not always visible in it.
	//
	// Only Claude reports one: its result frame carries is_error. Codex uses
	// its `error` item for warnings as well as failures — a truncated skill
	// description arrives as one — so treating that as a verdict would mark
	// healthy turns as failed, and inventing a signal is worse than not having
	// it. Gemini has none either. For those two this stays false and the caller
	// is no worse off than before.
	//
	// A `claude` whose OAuth token has been revoked writes "Failed to
	// authenticate" as an assistant message, sets is_error on the result frame,
	// and exits zero. A caller reading only the message and the exit code takes
	// an authentication failure for the model's answer — and if that answer is
	// being written into a file, the failure is laundered into an artefact with
	// nothing anywhere saying the model never ran.
	Failed bool
}

Result is the terminal outcome of a session.

type Session

type Session interface {
	BuildCommand(ctx context.Context, req Request) (CommandSpec, error)
	ParseChunk(chunk []byte) ([]Event, error)
	Finalize(ctx context.Context, fullOutput []byte, exitCode int) (Result, []Event, error)
	SessionID() string
}

Session is a single invocation lifecycle: build the command, parse streamed chunks into events, and finalize into a Result.

type Usage

type Usage struct {
	Model            string
	InputTokens      int64
	OutputTokens     int64
	CacheTokens      int64
	EstimatedCostUSD float64
}

Usage accumulates token usage and cost across a session.

Directories

Path Synopsis
cmd
agentexec-mcp module
Package hooks parses Claude Code / Codex hook payloads, reads Claude transcripts, and installs hook commands into the CLIs' config files.
Package hooks parses Claude Code / Codex hook payloads, reads Claude transcripts, and installs hook commands into the CLIs' config files.
Package pty runs a command under a pseudo-terminal, streaming its combined output to a callback and capturing it.
Package pty runs a command under a pseudo-terminal, streaming its combined output to a callback and capturing it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL