ax

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Jul 24, 2026 License: Apache-2.0 Imports: 31 Imported by: 0

README

ax-go

Agentic Experience (AX) foundation for Go CLI tools — the "Common DNA" for the rshade portfolio.

Go License Docs Coverage Cross-Compile

📖 Documentation: https://rshade.github.io/ax-go/

Status: Released (pre-v1.0, 0.x). The current pinnable release is v0.3.0: go get github.com/rshade/ax-go@v0.3.0. Output contracts are frozen in code — core primitives such as ax.Error, ax.Execute, ax.NewLogger, ax.ParseConfig, and ax.NewEntityID are covered by contract tests, and all public output shapes are pinned by golden fixtures. v0.2.0 added the import-isolated contract packages (contract, config, schema, id) for thin consumers; v0.3.0 adds the public mcp package (mcp.Serve, mcp.NewCommand), the ax.Guard / ax.Perform dry-run side-effect guards, and the retryable / retry_after_seconds error recovery fields. Releases are tagged by release-please from the Conventional Commit history; see CHANGELOG.md for release history.

Stability guarantee while in 0.x: a patch upgrade (0.x.PATCH) is always safe — patch releases are bug-fixes-only and stay backward-compatible. A minor bump (0.MINOR.0) MAY contain breaking changes (to the Go API or to machine-payload shapes like ax.Error / __schema), and breaking changes never auto-promote to 1.0.0. The full policy — including what counts as a breaking change and the deprecation lifecycle — lives in the constitution's Stability & SemVer and Deprecation Lifecycle principles (.specify/memory/constitution.md).

Security: please report suspected vulnerabilities privately through GitHub Security Advisories. See SECURITY.md for the full policy. Do not open public issues for unpatched vulnerabilities.

Mission

ax-go is the shared foundation that standardizes Agentic Experience (AX) across Go-based CLI tools. Its goal is simple:

Ensure all Go-based CLI tools are as powerful and predictable for LLM agents as they are for human engineers.

Rather than every CLI reinventing how it talks to an autonomous agent — how it emits data, reports errors, exposes its command tree, and stays safe under retries — ax-go encodes those conventions once so the whole portfolio shares the same predictable behavior.

Public Import Surfaces

Use the root package for full CLI runtime behavior:

import ax "github.com/rshade/ax-go"

The root ax facade remains the ergonomic surface for complete Cobra CLIs: ax.Execute, telemetry lifecycle, structured logging, __schema command wiring, HTTP/gRPC helpers, and trace-aware envelopes.

Use isolated contract packages for thin consumers that only need stable machine contracts without root runtime adapters:

import (
    "github.com/rshade/ax-go/config"
    "github.com/rshade/ax-go/contract"
    "github.com/rshade/ax-go/id"
    "github.com/rshade/ax-go/schema"
)
  • contract: exit codes, mode resolution, context metadata, success/error envelopes, and strict JSON/NDJSON writers.
  • config: bounded Hujson reads and comment-preserving RFC 6902 patches.
  • schema: ax-native and MCP-compatible command schema shapes/builders.
  • id: UUID v4 idempotency keys and UUID v7 entity/resource IDs.

Use the isolated logging package when you want structured, trace-correlated logging without the runtime:

import "github.com/rshade/ax-go/logging"
  • logging: the zerolog-backed logger, stream separation, and trace_id/span_id on every line — with no OTel SDK, no OTLP exporter, no gRPC, no Cobra, and no net/http.

Import-isolation tests keep those public contract packages free of the root facade, telemetry exporters/SDK setup, logger/Loki, HTTP instrumentation, and gRPC runtime adapters.

Choosing a surface
You need Import
Logging only, smallest binary logging
Stable machine contracts only contract, config, schema, id
Logging plus Loki direct push root ax
Logging plus OTel export or ax.Execute root ax

The size difference is the point. A logging-only consumer links 103 packages; the same program on the root facade links 410:

Program Stripped binary
imports logging 2,261,257 bytes
imports root ax 12,013,833 bytes
reduction 81.2%

Measured on linux/amd64 with Go 1.26.5 and -trimpath -ldflags="-s -w", and enforced on every PR by make size-check against the two committed probe programs examples/logging and examples/rootlogging. Absolute sizes drift with the toolchain; the ratio does not, because both probes move together.

logging differs from the four contract packages in what it is allowed to link: zerolog and the OpenTelemetry trace API are required there (the logger's method set names them, and they are what makes trace correlation work), while net/http and crypto/tls are forbidden. net/http is the single largest size lever, which is why Loki direct push — which needs it — stays available only through root ax.

Both surfaces name one logger. ax.Logger, ax.Labels, and ax.LoggerOption are identity-preserving aliases of the same declarations logging exposes, so a logger from either is accepted by the other with no conversion, and an option manufactured by root ax — including ax.WithLokiFromEnv() — is accepted by logging.NewLogger. There is one implementation, one backend, and one trace-correlation hook behind both names.

These packages provide no live tracing. They link zero gRPC and always have, and that same import isolation keeps the OpenTelemetry SDK out of them, so none of them starts a span, extracts TRACEPARENT / TRACESTATE, or resolves an active span context. contract.TraceIDFromContext and contract.SpanIDFromContext read back metadata a caller already stored with contract.WithMetadata — they resolve nothing themselves, and return contract.ZeroTraceID / contract.ZeroSpanID when no metadata is present. Because those zero values are well-formed W3C IDs rather than empty strings, a context that was never populated produces valid-looking output carrying no trace at all.

Live tracing is provided only by the root ax package: ax.StartTelemetry (W3C propagation and TRACEPARENT extraction), the recording root span ax.Execute opens around the command, and ax.NewLogger's trace_id / span_id log correlation. Thin consumers that need real trace IDs must import root ax and accept the runtime weight the isolated packages exist to avoid — or keep the root facade and drop the heavy optional pieces at build time, as described in Slimming the binary.

Use the mcp package to run a CLI as a live MCP server (see Running as an MCP server):

import "github.com/rshade/ax-go/mcp"
  • mcp: a thin entry point (mcp.Serve, mcp.NewCommand) that exposes an ax-go command tree as a live MCP server over the official MCP Go SDK. The SDK and all protocol/transport/dispatch mechanics stay behind internal/mcpserver.

Core Standards

These are the non-negotiable mandates every tool built on ax-go must follow.

The Golden Rule — stream separation
  • stdout is strictly reserved for the final data payload (JSON).
  • stderr carries everything else: logs, progress indicators, and structured error envelopes.

This lets an agent pipe stdout straight into a JSON parser while humans and log collectors read stderr.

Deterministic exit codes
Code Meaning
0 success
1 unknown / internal error
2 validation / bad input
3 network / timeout
4 authentication / permission
Determinism is machine-readable trust

Given the same inputs, two runs of the same command produce byte-identical stdout (modulo documented non-deterministic fields — timestamps, trace_id, auto-generated idempotency_key). This is the machine equivalent of trust: agents diff outputs across runs to detect drift, so determinism is what lets an agent safely delegate to an ax-go CLI. It is a stronger guarantee than the AX literature demands, and it is the project's clearest differentiator.

Machine discoverability (__schema)

Every tool implements a __schema command that emits a structured JSON map of its command tree, flags, types, and examples — so agents can ground themselves without guessing. The primary format is ax-native JSON, with __schema --as=mcp available as an MCP-compatible adapter. The discoverability contract is now owned by the schema package and the absorbed decisions in specs/010-import-isolated-contracts/research.md.

Each command declares an explicit non_deterministic_fields list. Commands that emit ax.Envelope[T] register their payload type once; varying payload fields carry the marking at their definition:

type result struct {
    EntityID string `json:"entity_id" ax:"nondeterministic"`
}

ax.WithNonDeterministicFields[result](cmd)

Registration adds the standard meta.trace_id, meta.span_id, and meta.idempotency_key locators. Tagged payload fields appear as data.* locators, and unregistered commands emit an explicit empty list. The same list is available as nonDeterministicFields in __schema --as=mcp; it is the authoritative mask for deterministic output comparisons.

Running as an MCP server

The same command tree that powers __schema --as=mcp can run as a live MCP server with no per-tool work, via the mcp package. Mount the reserved mcp-server subcommand (an explicit opt-in — it executes commands, so it is not auto-mounted like __schema):

import (
    "github.com/rshade/ax-go"
    "github.com/rshade/ax-go/mcp"
)

func main() {
    root := newRootCommand()
    root.AddCommand(mcp.NewCommand(root, mcp.WithVersion(version)))
    os.Exit(ax.Execute(context.Background(), root))
}

Run it over stdio (the default; the idiomatic MCP subprocess model) or a streamable HTTP transport:

mycli mcp-server                                            # stdio
mycli mcp-server --transport=http --addr=127.0.0.1:8080     # loopback HTTP
mycli mcp-server --transport=http --addr=0.0.0.0:8080 --allow-non-loopback
  • Discovery: every non-hidden command becomes a tool (reusing schema.BuildMCPSchema); __schema and mcp-server are excluded.
  • Execution: tools/call runs the command in machine/JSON mode and returns its verbatim stdout payload; a non-zero exit returns the ax.Error envelope with IsError set, and the server keeps serving.
  • Safety: HTTP binds loopback by default and fails closed (exit 2) on a non-loopback bind without --allow-non-loopback; ax-go holds no credentials and runs no auth flow, so put authn/authz in front of an exposed endpoint.
  • Streams & tracing: protocol I/O stays on the transport channel and logs on stderr; each call is a span that continues the caller's W3C trace.
  • Out of scope (MVP): incremental MCP streaming/progress notifications, an auth flow, and a standalone cmd/ launcher (the runnable instance is any adopting CLI's mounted subcommand). A real build version is required (WithVersion or -ldflags); dev/unknown is rejected at startup.
Asymmetric JSON flow
  • Input: accept Hujson (comments and trailing commas) for human convenience. Config reads are capped at 1 MiB by default at the read boundary; use ax.WithMaxConfigBytes when a CLI intentionally supports a larger bounded config. The public helpers are ax.ParseConfig(ctx, reader, &cfg, ...) and ax.ParseConfigFile(ctx, path, &cfg, ...), so slow cooperative sources can be canceled through context.Context.
  • Output: emit strict, minified JSON for bounded payloads; emit NDJSON for streaming / unbounded result sets.

Config read rejections are standard ax.Error envelopes. Oversized input uses the frozen error_code config_too_large; an out-of-range cap (negative or above ax.MaxConfigBytesCeiling, 1 GiB) uses config_max_bytes_invalid. Invalid Hujson or schema mismatches use config_invalid, and a nil ParseConfigOption uses config_option_invalid. All map to exit code 2 and are discoverable with errors.As(err, &axErr). Reads accept Hujson extensions, but payload writes remain strict JSON.

To mutate an existing Hujson config without stripping user comments, ax.PatchConfig(ctx, reader, patch, ...) and ax.PatchConfigFile(ctx, path, patch, ...) apply RFC 6902 JSON Patch operations to the Hujson AST. Comments survive; whitespace is normalized to canonical Hujson formatting. PatchConfigFile writes back atomically (temp file + rename) and preserves file permissions. An invalid patch document or a failed patch operation uses the frozen error_code config_patch_invalid (exit code 2); an invalid existing config uses config_invalid.

Agent-safety primitives
  • --idempotency-key — auto-generated UUID v4 if absent and surfaced in the output envelope, killing duplicate-create from agent retries.

  • --dry-run — universal middleware that emits the same envelope with dry_run: true and performs no side effects.

  • ax.Guard / ax.Perform — helpers that guard a side-effecting operation on the dry-run flag, so commands stop hand-rolling if ax.DryRunFromContext(ctx) { ... } else { ... }. Guard runs an effect unless dry-run is active (and reports whether it ran); Perform runs the real commit, or a read-only rehearse preview under dry-run that surfaces the same validation errors without mutating. Each writes a single suppression line to stderr (never stdout) when it skips. The envelope's dry_run: true still flows automatically.

    // Skip-only: writeReport runs for real, is suppressed under --dry-run.
    wrote, err := ax.Guard(ctx, func(ctx context.Context) error {
        return writeReport(ctx, path)
    })
    
    // Faithful preview: validate under --dry-run, mutate for real.
    err = ax.Perform(ctx,
        func(ctx context.Context) error { return validatePatch(ctx, path, doc) }, // dry-run
        func(ctx context.Context) error { return ax.PatchConfigFile(ctx, path, doc) }, // real
    )
    
  • --format flag / AGENT_MODE env var / TTY auto-detect — selects machine vs. human output mode. The precedence is --format flag, then AGENT_MODE, then TTY detection.

Standard ax.Error envelope

A structured, machine-readable error format emitted to stderr. Schema defined by the root ax.Error facade and the isolated contract.Error type.

Required fields: error_code, message, trace_id, tool, version, schema_version. Optional remediation fields let an agent self-correct instead of just reporting:

  • actionable_fix (string) — a best-effort human/machine hint for fixing the failure.
  • suggestions (string array) — candidate recovery actions.
  • retryable (bool) — whether a naive retry of the same command is safe. Tri-state: true (safe), false (do not retry), or absent (unspecified); absence is distinguishable from explicit false, set via ax.WithRetryable.
  • retry_after_seconds (integer) — relative backoff, in whole seconds, before a retry should be attempted (delta-seconds, never an absolute timestamp, so output stays byte-identical across runs); meaningful only when retryable is true, set via ax.WithRetryAfterSeconds.

All optional fields are omitted when unset, so a default error envelope is byte-identical to one emitted before these fields existed.

Engineering Standards

  • Allocation discipline: track allocations via standard testing.B benchmarks rather than asserting numeric bars. The logger hot path is measured by BenchmarkLogger* (see spec 011): the enabled emit path, the filtered fast path, the no-trace-context path, typed fields, and labelled loggers all measure 0 allocs/op. The single allocating path is emitting with an active trace context (2 allocs/op, ~48 B/op) from formatting the hex trace/span IDs — a bounded, documented exception, not a regression. Run go test -run '^$' -bench '^BenchmarkLogger' -benchmem ./... to reproduce.
  • Trace propagation: contexts carry and propagate W3C Trace Context IDs by default, via the OpenTelemetry SDK in the root ax package (ADR-0004; real export lifecycle delivered by spec 004). The import-isolated packages (contract, config, schema, id) carry no live tracing — see Public Import Surfaces.
  • Observability backends: Grafana Loki for log aggregation via opt-in direct push (AX_LOKI_URL; see specs/007-loki-direct-push/research.md); Tempo / Jaeger / Honeycomb-compatible for traces via OTel.
  • ID strategy: OTel trace/span IDs for observability; UUID v4 for idempotency keys; UUID v7 for resource and entity IDs. Never mix observability IDs with resource/entity IDs.
  • CLI framework: built on Cobra (ADR-0008). ax.Execute() wraps Cobra execution for mode resolution, schema wiring, error-envelope output, and OTel flush-on-exit.
  • Structured logging: ax.NewLogger(ctx) returns an ax.Logger backed by zerolog with trace correlation wired in (Constitution Principle VIII; the single-backend guardrail is Principle VI).
  • Telemetry lifecycle: ax.Execute() opens a recording root span around the command, so logs written with cmd.Context() carry non-zero trace_id and span_id even when no collector is configured. Set OTEL_EXPORTER_OTLP_ENDPOINT to enable OTLP HTTP trace export; a plaintext http:// local collector is allowed, while https:// uses verified TLS and TLS verification is never disabled. Set AX_OTEL_DEBUG=1 to print human-readable span data to stderr for local debugging. Both destinations can be enabled together, all telemetry stays off stdout, and exporter failures degrade to a stderr diagnostic without changing the command's stdout payload or exit code. Export attempts and shutdown are bounded by the telemetry shutdown budget (default 2s, configurable with ax.WithTelemetryShutdownTimeout).
  • Idiomatic Go: package name is ax. Keep abstractions narrow and tied to accepted ADRs.

Architecture Decisions (ADRs)

The ADRs are a frozen legacy decision log. New public API or runtime behavior changes go through the Spec Kit workflow and record decisions in the feature's research.md; retired ADR decisions are absorbed there before the ADR file is deleted.

ADR Title Status
0004 Trace ID Format Accepted (2026-05-28)
0008 CLI Framework — Cobra Accepted (2026-05-28)

Absorbed decisions for mode resolution, error envelopes, schema output, ID strategy, and import layout live in specs/010-import-isolated-contracts/research.md. The structured-logging (zerolog) choice is now governed by Constitution Principles VI and VIII; its full decision record is absorbed into specs/011-hot-path-benchmarks/research.md. Remaining frozen ADR text and rationale live in docs/adr/.

Repository Layout

The primary public import path remains github.com/rshade/ax-go as ax. Narrow public contract packages exist only for thin consumers: contract, config, schema, and id. Private implementation mechanics live under internal/ so they do not become accidental public API. Public JSON contract fixtures live under testdata/. Runnable support binaries belong under cmd/ when real command behavior exists. pkg/, src/, and broad public subpackages remain intentionally avoided.

Examples

The runnable integration command in examples/integration/ exercises the public ax-go API from a real Cobra CLI. It covers bounded JSON envelopes, NDJSON streaming, Hujson config parsing, in-place Hujson patching, __schema, structured ax.Error output, idempotency keys, and stderr logging.

go run ./examples/integration --format=json --idempotency-key=demo-key --name=Ada
go run ./examples/integration stream --format=json --count=3
go run ./examples/integration patch-config --format=json --config=config.json \
  --patch='[{"op":"replace","path":"/name","value":"Grace"}]'
go run ./examples/integration __schema
go run ./examples/integration fail --format=json

Build-time version injection

Production CLIs built on ax-go should resolve their version once at process startup and pass the same value to every version surface. Keep the linker target as a writable var, then use ax.ResolveVersion:

var version string // set by -ldflags "-X main.version=..."

func run(ctx context.Context, root *cobra.Command) int {
    resolved := ax.ResolveVersion(version)

    logger := ax.NewLogger(ctx, ax.WithLoggerLabels(ax.Labels{
        Application: "mytool",
        Version:     resolved,
    }))
    _ = logger

    return ax.Execute(ctx, root, ax.WithVersion(resolved))
}

ResolveVersion returns a non-placeholder injected value when present, otherwise it falls back to the running binary's Go build metadata (Main.Version, then vcs.revision with a dirty marker) and finally to 0.0.0-unknown. It never returns an empty string or the bare placeholders dev or unknown.

Build the integration example with the documented injection target:

make build-example
./bin/ax-integration __schema

Slimming the binary (optional OTLP and gRPC)

Two optional, negative build constraints let you decline the two heaviest dependency roots. Both default to off, so existing consumers need do nothing — setting neither tag is the state every build is already in.

Tag Declines Default
ax_no_otlp OTLP HTTP trace export absent (export active)
ax_no_grpc ax.GRPCDial and gRPC client instrumentation absent (helper present)
go build -tags=ax_no_grpc,ax_no_otlp -ldflags="-s -w" ./cmd/yourcli
Decline both, or neither

The size benefit requires both. Each tag removes one of two independent roots over the same gRPC subtree, so one alone buys you almost nothing:

Tags Δ stripped size grpc packages left
ax_no_grpc −0.00% 66
ax_no_otlp −15.1% 64
both −63.3% 0

Measured on linux/amd64 against a root-facade fixture consumer (14,893,218 → 5,460,130 bytes); windows/amd64 gives −62.5%. With both tags set, go list -deps reports exactly zero packages from google.golang.org/grpc, google.golang.org/protobuf, go.opentelemetry.io/proto/otlp, and github.com/grpc-ecosystem/grpc-gateway/v2.

What you keep

Tracing degrades to no export, never to no tracing. In every configuration:

  • W3C TRACEPARENT/TRACESTATE extraction and continuation
  • a recording root span around ax.Execute
  • trace_id/span_id on every log line emitted inside that span
  • AX_OTEL_DEBUG local span output on stderr
  • ax.HTTPClient / ax.NewHTTPClient, fully instrumented
  • byte-identical __schema and ax.Error payloads, stream separation, exit codes, --dry-run, and --idempotency-key
What you give up

With ax_no_otlp: OTLP network export. A configured OTEL_EXPORTER_OTLP_ENDPOINT is not an error — ax stays fail-open, emitting one ax: otel exporter disabled: … line on stderr per telemetry start and succeeding normally. That is by design, but it means a misconfigured build degrades silently to no export. Watch for that diagnostic.

With ax_no_grpc: ax.GRPCDial is absent from the build. Calling it fails at compile time with Go's standard undefined: ax.GRPCDial; the toolchain gives a library no way to customise that message, so the explanation lives in the package documentation (grpc_disabled.go). To restore it, drop the tag — nothing else changes: no source edit, no import change, no API difference.

ax.GRPCDial is the only public identifier whose presence varies with a build tag. This is enforced on every PR by make surface-check, which type-checks all seven public packages across 4 configurations × 6 GOOS/GOARCH profiles and diffs the result against a committed baseline.

The target injects git describe --tags --always --dirty into main.version:

go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" \
  -o bin/ax-integration ./examples/integration

Override VERSION for release and reproducible builds:

make build-example VERSION=v1.2.3

The same resolved value feeds __schema.version, the ax.Error envelope version, and the logger version label.

Roadmap

The live roadmap — immediate focus, sequenced near-term and long-term items, and completed milestones — is tracked in ROADMAP.md, with every open item filed as a labeled GitHub issue.

Compatibility

ax-go version and Go version
ax-go version Minimum Go version Notes
v0.x (current) 1.26.5 Pre-v1.0; 0.x.PATCH is always safe; 0.MINOR.0 may break
v1.x TBD Stable API; targeted via the Spec Kit stability workflow

The minimum Go version is the go directive in go.mod. Patch releases (0.x.PATCH) are always backward-compatible. Minor releases (0.MINOR.0) may break either the Go API surface or machine-payload shapes (ax.Error, __schema). See Constitution Principle XI for the full stability and SemVer policy.

Downstream consumers
Consumer Pinned ax-go version Notes
(no downstream consumers yet — first consumer pinning notes will appear here)

Once a downstream project tags a release that pins an ax-go version, add a row here. See CONTRIBUTING.md for the update process.

Brand

The ax-go mascot (a robot gopher shouldering an axe), the geometric logomark, the color palette, and usage guidelines live in docs/brand/. The logomark also serves as the docs site-header logo and the source of the favicon.

Contributing

Before changing public behavior, use the Spec Kit feature workflow. Read the constitution, absorb any governing frozen ADR decisions into the feature's research.md, and keep README plus examples/integration/ current with the public contract. Do not create or edit ADRs for new work.

See CONTRIBUTING.md for the compatibility matrix update process and release checklist.

License

Licensed under the Apache License 2.0.

Documentation

Overview

Package ax provides the Agentic Experience foundation for Go CLI tools.

The package keeps machine payloads on stdout, operational output on stderr, and exposes shared primitives for mode resolution, error envelopes, discoverability schemas, idempotency keys, logging, and trace propagation.

Index

Examples

Constants

View Source
const (
	// DefaultMaxConfigBytes is the default maximum config size: 1 MiB.
	DefaultMaxConfigBytes int64 = isolatedconfig.DefaultMaxBytes
	// MaxConfigBytesCeiling is the largest valid config read limit: 1 GiB.
	MaxConfigBytesCeiling int64 = isolatedconfig.MaxBytesCeiling
)
View Source
const (
	// ExitSuccess indicates successful completion.
	ExitSuccess = contract.ExitSuccess
	// ExitInternal indicates an unknown or internal error.
	ExitInternal = contract.ExitInternal
	// ExitValidation indicates invalid input or failed validation.
	ExitValidation = contract.ExitValidation
	// ExitNetwork indicates a network failure or timeout.
	ExitNetwork = contract.ExitNetwork
	// ExitAuth indicates an authentication or permission failure.
	ExitAuth = contract.ExitAuth
)
View Source
const (
	// ModeJSON is the machine-readable mode used by agents and pipelines.
	ModeJSON = contract.ModeJSON
	// ModeHuman is the human-readable mode used for interactive terminals.
	ModeHuman = contract.ModeHuman
)
View Source
const (
	// ZeroTraceID is a valid zero-value W3C trace ID for no-active-span cases.
	ZeroTraceID = contract.ZeroTraceID
	// ZeroSpanID is a valid zero-value W3C span ID for no-active-span cases.
	ZeroSpanID = contract.ZeroSpanID
)
View Source
const DefaultHTTPTimeout = 30 * time.Second

DefaultHTTPTimeout is the default total timeout applied to clients returned by HTTPClient and NewHTTPClient. It bounds the full request lifecycle (connect, redirects, reading the response body) so an outbound call can never block forever.

View Source
const (
	// ErrorSchemaVersion is the current SemVer version of the error envelope.
	ErrorSchemaVersion = contract.ErrorSchemaVersion
)
View Source
const ModeDetectionRule = contract.ModeDetectionRule

ModeDetectionRule documents the output-mode resolution precedence applied by ResolveMode. It is surfaced verbatim in __schema output.

View Source
const SchemaVersion = isolatedschema.SchemaVersion

SchemaVersion is the current SemVer version for ax-native schemas.

Variables

This section is empty.

Functions

func DryRunFromContext

func DryRunFromContext(ctx context.Context) bool

DryRunFromContext reports whether dry-run behavior is active.

func ErrorExitCode

func ErrorExitCode(err error) int

ErrorExitCode maps an error to the deterministic ax-go process exit code.

func Execute

func Execute(ctx context.Context, root *cobra.Command, opts ...ExecuteOption) int

Execute wraps Cobra execution with AX mode resolution, idempotency, schema, error-envelope, and telemetry lifecycle behavior. It returns a deterministic exit code and leaves process termination to the caller.

The version reported in __schema output and error envelopes comes from WithVersion. When WithVersion is not supplied, Execute falls back to ResolveVersion("") — link-time injection, then Go build metadata, then the "0.0.0-unknown" sentinel — so the version surfaced to agents is never empty.

When the command returns an *Error, Execute normalizes a copy of it (filling in trace ID, tool, and version) before writing the envelope to stderr; the caller's *Error value is never mutated.

Example

ExampleExecute wraps a Cobra root command with the full AX lifecycle — telemetry, mode resolution, and idempotency-key injection — and maps the result to a deterministic exit code instead of exiting the process. The command reads the resolved values back out of its context. With an explicit --idempotency-key and a buffered, non-TTY stdout the run is fully deterministic: the payload is the only stdout output and stderr stays empty.

package main

import (
	"bytes"
	"context"
	"fmt"

	"github.com/spf13/cobra"

	ax "github.com/rshade/ax-go"
)

func main() {
	var stdout, stderr bytes.Buffer

	root := &cobra.Command{
		Use: "app",
		RunE: func(cmd *cobra.Command, _ []string) error {
			mode, _ := ax.ModeFromContext(cmd.Context())
			key, _ := ax.IdempotencyKeyFromContext(cmd.Context())
			return ax.WriteJSON(cmd.OutOrStdout(), struct {
				Mode   ax.Mode `json:"mode"`
				DryRun bool    `json:"dry_run"`
				Key    string  `json:"key"`
			}{
				Mode:   mode,
				DryRun: ax.DryRunFromContext(cmd.Context()),
				Key:    key,
			})
		},
	}
	root.SetArgs([]string{"--dry-run", "--idempotency-key=abc"})

	code := ax.Execute(
		context.Background(),
		root,
		ax.WithStdout(&stdout),
		ax.WithStderr(&stderr),
		ax.WithEnv(func(string) string { return "" }),
		ax.WithStdoutIsTTY(false),
	)

	fmt.Println("exit:", code)
	fmt.Print(stdout.String())
	fmt.Println("stderr bytes:", stderr.Len())
}
Output:
exit: 0
{"mode":"json","dry_run":true,"key":"abc"}
stderr bytes: 0

func Flush added in v0.1.0

func Flush(ctx context.Context, l Logger) error

Flush performs a best-effort, non-destructive drain of any buffered Loki log entries for the given Logger. It blocks until the buffer is empty, the context is cancelled, or an internal 2-second deadline elapses — whichever comes first. Remaining entries are dropped after the deadline.

The error return is reserved for future sink implementations: the Loki sink's Drain returns nil on every path (push failures are fail-open diagnostics on the configured writer, never returned errors), so Flush currently always returns nil. Callers should keep checking it — new sinks may surface drain failures — but a failed Loki push must never change the CLI exit code.

Flush is a no-op (returns nil) when:

  • l has no Loki sink (AX_LOKI_URL was not set)
  • l is nil
  • the sink's background goroutine already stopped because its logger context was cancelled

Callers may invoke Flush multiple times; later writes remain deliverable by a later Flush call. Callers should invoke Flush in their shutdown path, before os.Exit or cobra.Command cleanup, to ensure in-flight log lines reach Loki.

Example

ExampleFlush shows the shutdown pattern: Flush drains any buffered Loki log entries before the process exits. When no Loki sink is active (AX_LOKI_URL unset), Flush is a no-op that returns nil immediately.

package main

import (
	"bytes"
	"context"
	"os"
	"time"

	ax "github.com/rshade/ax-go"
)

func main() {
	os.Unsetenv("AX_LOKI_URL") // ensure no-op for this example
	var buf bytes.Buffer
	logger := ax.NewLogger(
		context.Background(),
		ax.WithLoggerWriter(&buf),
		ax.WithLokiFromEnv(),
	)
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	_ = ax.Flush(ctx, logger)
}

func GRPCDial

func GRPCDial(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error)

GRPCDial dials target with OTel client instrumentation.

This helper is absent from builds using the ax_no_grpc build constraint, where calling it fails at compile time with "undefined: ax.GRPCDial". See grpc_disabled.go for the restoration step.

func Guard added in v0.3.0

func Guard(ctx context.Context, effect func(context.Context) error) (bool, error)

Guard runs effect unless dry-run is active in ctx.

When dry-run is inactive it executes effect and returns (true, effect's error), preserving the error's wrap chain so errors.Is/errors.As keep working. When dry-run is active it skips effect entirely — guaranteeing no side effect — emits a single suppression line to stderr (only when effect is non-nil), and returns (false, nil). A nil effect is a no-op returning (false, nil); Guard never panics on a missing callback.

Guard maps no exit code itself: it returns effect's error verbatim for the caller to map via ErrorExitCode. The suppression line is written to stderr (never stdout) via the canonical logger, so stdout payload determinism is unaffected. A nil context is treated as dry-run inactive (the real path runs); Guard never panics on a missing context.

Example

ExampleGuard shows the skip-only guard: the effect runs normally, but under --dry-run it is suppressed entirely and Guard reports executed=false. The suppression line Guard writes to stderr is not shown here (examples capture stdout only).

package main

import (
	"context"
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	effect := func(context.Context) error {
		fmt.Println("writing report")
		return nil
	}

	// Real run: the effect executes.
	executed, err := ax.Guard(context.Background(), effect)
	fmt.Printf("executed=%v err=%v\n", executed, err)

	// Dry-run: the effect is skipped.
	dryRun := ax.WithDryRun(context.Background(), true)
	executed, err = ax.Guard(dryRun, effect)
	fmt.Printf("executed=%v err=%v\n", executed, err)
}
Output:
writing report
executed=true err=<nil>
executed=false err=<nil>

func HTTPClient

func HTTPClient() *http.Client

HTTPClient returns an HTTP client with OTel propagation instrumentation and the default bounded request timeout (DefaultHTTPTimeout). To override the timeout or set other options, use NewHTTPClient with WithHTTPTimeout. TLS verification uses the http.DefaultTransport secure defaults; never relax them.

func IdempotencyKeyFromContext

func IdempotencyKeyFromContext(ctx context.Context) (string, bool)

IdempotencyKeyFromContext returns the idempotency key stored in ctx.

func NewEntityID

func NewEntityID() (string, error)

NewEntityID returns a UUID v7 resource/entity identifier.

Example

ExampleNewEntityID returns a UUID v7 resource identifier in canonical 36-character form.

package main

import (
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	id, err := ax.NewEntityID()
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(len(id))
}
Output:
36

func NewHTTPClient added in v0.4.0

func NewHTTPClient(opts ...HTTPClientOption) *http.Client

NewHTTPClient returns an HTTP client with OTel propagation instrumentation and a bounded request timeout (DefaultHTTPTimeout unless overridden with WithHTTPTimeout). TLS verification uses the http.DefaultTransport secure defaults; never relax them.

func NewIdempotencyKey

func NewIdempotencyKey() string

NewIdempotencyKey returns a UUID v4 idempotency key.

Example

ExampleNewIdempotencyKey returns a UUID v4 string in canonical 36-character form, surfaced in the output envelope so retries are safe.

package main

import (
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	key := ax.NewIdempotencyKey()
	fmt.Println(len(key))
}
Output:
36

func NewSchemaCommand

func NewSchemaCommand(root *cobra.Command, opts ...SchemaOption) *cobra.Command

NewSchemaCommand builds the reserved __schema command.

func ParseConfig

func ParseConfig(ctx context.Context, r io.Reader, dst any, opts ...ParseConfigOption) error

ParseConfig parses Hujson from r under a bounded read cap and unmarshals into dst.

Example

ExampleParseConfig shows the read-side Hujson asymmetry: comments and trailing commas are accepted on input, and the result decodes into a normal struct.

package main

import (
	"context"
	"fmt"
	"strings"

	ax "github.com/rshade/ax-go"
)

func main() {
	const hujson = `{
		// comments and trailing commas are allowed on reads
		"name": "ax",
		"replicas": 3,
	}`

	var cfg struct {
		Name     string `json:"name"`
		Replicas int    `json:"replicas"`
	}
	if err := ax.ParseConfig(
		context.Background(),
		strings.NewReader(hujson),
		&cfg,
		ax.WithMaxConfigBytes(1<<10),
	); err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("%s x%d\n", cfg.Name, cfg.Replicas)
}
Output:
ax x3

func ParseConfigFile

func ParseConfigFile(ctx context.Context, path string, dst any, opts ...ParseConfigOption) error

ParseConfigFile opens path and applies ParseConfig's contract to its contents.

Example

ExampleParseConfigFile reads and decodes a Hujson configuration file from disk, applying the same 1 MiB read cap as ParseConfig.

package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"

	ax "github.com/rshade/ax-go"
)

func main() {
	dir, err := os.MkdirTemp("", "ax-config")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	defer os.RemoveAll(dir)

	path := filepath.Join(dir, "config.hujson")
	if err := os.WriteFile(path, []byte(`{"name": "ax"}`), 0o600); err != nil {
		fmt.Println("error:", err)
		return
	}

	var cfg struct {
		Name string `json:"name"`
	}
	if err := ax.ParseConfigFile(context.Background(), path, &cfg); err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(cfg.Name)
}
Output:
ax

func PatchConfig added in v0.0.2

func PatchConfig(ctx context.Context, r io.Reader, patch []byte, opts ...ParseConfigOption) ([]byte, error)

PatchConfig reads Hujson from r, applies RFC 6902 JSON patch operations, and returns the patched Hujson content with comments preserved.

Example

ExamplePatchConfig shows the comment-preserving write path: RFC 6902 patch operations mutate the Hujson AST in place so user comments survive.

package main

import (
	"context"
	"fmt"
	"strings"

	ax "github.com/rshade/ax-go"
)

func main() {
	const existing = `{
	// service endpoint
	"host": "localhost",
	"port": 8080,
}`
	patch := []byte(`[{"op":"replace","path":"/port","value":9090}]`)

	patched, err := ax.PatchConfig(
		context.Background(),
		strings.NewReader(existing),
		patch,
	)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(strings.Contains(string(patched), "// service endpoint"))
	fmt.Println(strings.Contains(string(patched), "9090"))
}
Output:
true
true

func PatchConfigFile added in v0.0.2

func PatchConfigFile(ctx context.Context, path string, patch []byte, opts ...ParseConfigOption) error

PatchConfigFile reads path as Hujson, applies RFC 6902 patch operations, and writes the patched result back to path atomically, preserving comments.

Example

ExamplePatchConfigFile reads a Hujson config file, applies RFC 6902 patch operations, and writes the result back atomically, preserving user comments.

package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	ax "github.com/rshade/ax-go"
)

func main() {
	dir, err := os.MkdirTemp("", "ax-patch")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	defer os.RemoveAll(dir)

	path := filepath.Join(dir, "config.hujson")
	initial := []byte(`{
	// production endpoint
	"host": "prod.example.com",
	"port": 443,
}`)
	if err := os.WriteFile(path, initial, 0o600); err != nil {
		fmt.Println("error:", err)
		return
	}

	patch := []byte(`[{"op":"replace","path":"/port","value":8443}]`)
	if err := ax.PatchConfigFile(context.Background(), path, patch); err != nil {
		fmt.Println("error:", err)
		return
	}

	result, err := os.ReadFile(path)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(strings.Contains(string(result), "// production endpoint"))
	fmt.Println(strings.Contains(string(result), "8443"))
}
Output:
true
true

func Perform added in v0.3.0

func Perform(ctx context.Context, rehearse, commit func(context.Context) error) error

Perform runs commit when dry-run is inactive, or the read-only rehearse preview when dry-run is active, so a dry-run surfaces the same validation errors as a real run without performing the mutation.

Real run: commit is executed (rehearse is ignored) and its error is returned with its wrap chain intact; a nil commit is a no-op returning nil. Dry-run: rehearse is executed (when non-nil) and its error is returned; commit is never executed. When the dry-run preview succeeds (or rehearse is nil) and a real commit would have run (commit is non-nil), a single suppression line is written to stderr. A failed rehearsal returns its error WITHOUT a suppression line, since the command already surfaces that error. A nil rehearse means a pure skip, equivalent to Guard.

Perform maps no exit code itself: it returns the running branch's error verbatim for the caller to map via ErrorExitCode. The suppression line is written to stderr (never stdout), so stdout payload determinism is unaffected. A nil context is treated as dry-run inactive (the real path runs); Perform never panics on a missing context.

Example

ExamplePerform shows the rehearse/commit pair: a real run performs commit, while --dry-run runs the read-only rehearse preview instead (surfacing the same validation errors) without performing the mutation.

package main

import (
	"context"
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	rehearse := func(context.Context) error {
		fmt.Println("validating only")
		return nil
	}
	commit := func(context.Context) error {
		fmt.Println("committing")
		return nil
	}

	// Real run: commit executes, rehearse is ignored.
	_ = ax.Perform(context.Background(), rehearse, commit)

	// Dry-run: rehearse executes, commit is skipped.
	dryRun := ax.WithDryRun(context.Background(), true)
	_ = ax.Perform(dryRun, rehearse, commit)
}
Output:
committing
validating only

func ResolveVersion

func ResolveVersion(injected string) string

ResolveVersion returns a non-empty tool version for agent-visible surfaces.

Resolution is deterministic for a given binary and uses this precedence: first the injected link-time value, then Go build metadata from the running binary, then the sentinel "0.0.0-unknown". The result is never empty and is never the bare strings "dev" or "unknown"; pass the returned value to WithVersion and WithLoggerLabels so __schema.version, ax.Error.version, and the logger "version" label agree.

Example

ExampleResolveVersion shows the deterministic injected-version path used by release builds. When the injected value is empty, ResolveVersion falls back to the running binary's build metadata and finally to "0.0.0-unknown".

package main

import (
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	fmt.Println(ax.ResolveVersion("v1.2.3"))
}
Output:
v1.2.3

func SpanIDFromContext

func SpanIDFromContext(ctx context.Context) string

SpanIDFromContext returns the active W3C span ID or ZeroSpanID.

func TraceIDFromContext

func TraceIDFromContext(ctx context.Context) string

TraceIDFromContext returns the active W3C trace ID or ZeroTraceID.

func WithDryRun

func WithDryRun(ctx context.Context, dryRun bool) context.Context

WithDryRun returns a context carrying the dry-run state.

func WithIdempotencyKey

func WithIdempotencyKey(ctx context.Context, key string) context.Context

WithIdempotencyKey returns a context carrying the idempotency key for the run.

func WithMode

func WithMode(ctx context.Context, mode Mode) context.Context

WithMode returns a context carrying the resolved output mode.

func WithNonDeterministicFields added in v0.4.0

func WithNonDeterministicFields[T any](cmd *cobra.Command)

WithNonDeterministicFields registers cmd as emitting the standard success envelope for T. It reflects T once and records its ax:"nondeterministic" fields for __schema output; a nil command is ignored.

func WriteError

func WriteError(w io.Writer, err error) error

WriteError writes err as a strict minified JSON error envelope followed by a newline.

func WriteJSON

func WriteJSON(w io.Writer, v any) error

WriteJSON writes v as strict minified JSON followed by a newline.

func WriteJSONLine

func WriteJSONLine(w io.Writer, v any) error

WriteJSONLine writes a single NDJSON line.

Types

type CommandSchema

type CommandSchema = isolatedschema.CommandSchema

CommandSchema describes a Cobra command and its direct children.

type Envelope

type Envelope[T any] = contract.Envelope[T]

Envelope is the standard bounded JSON success payload shape.

Example

ExampleEnvelope shows the envelope shape directly: a typed Data field and a Metadata block. span_id is omitted when empty.

package main

import (
	"fmt"
	"os"

	ax "github.com/rshade/ax-go"
)

func main() {
	env := ax.Envelope[string]{
		Data: "hello",
		Meta: ax.Metadata{TraceID: ax.ZeroTraceID},
	}
	if err := ax.WriteJSON(os.Stdout, env); err != nil {
		fmt.Println("error:", err)
	}
}
Output:
{"data":"hello","meta":{"trace_id":"00000000000000000000000000000000"}}

func NewEnvelope

func NewEnvelope[T any](ctx context.Context, data T) Envelope[T]

NewEnvelope wraps data with standard AX metadata from ctx.

Example

ExampleNewEnvelope wraps a payload in the standard success envelope, carrying trace metadata from the context. With no active span the IDs are the zero W3C values, so the output is deterministic.

package main

import (
	"context"
	"fmt"
	"os"

	ax "github.com/rshade/ax-go"
)

func main() {
	type result struct {
		ID string `json:"id"`
	}

	env := ax.NewEnvelope(context.Background(), result{ID: "abc"})
	if err := ax.WriteJSON(os.Stdout, env); err != nil {
		fmt.Println("error:", err)
	}
}
Output:
{"data":{"id":"abc"},"meta":{"trace_id":"00000000000000000000000000000000","span_id":"0000000000000000"}}

type Error

type Error = contract.Error

Error is the structured error envelope emitted to stderr.

Example

ExampleError shows how a consumer classifies a failure: recover the *ax.Error envelope with errors.As, then branch on the stable error_code and exit code without parsing human-facing text.

package main

import (
	"context"
	"errors"
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	err := ax.NewError(
		context.Background(),
		"config_max_bytes_invalid",
		"config max bytes must be between 0 and 1073741824",
		ax.WithErrorExitCode(ax.ExitValidation),
	)

	var axErr *ax.Error
	if errors.As(err, &axErr) {
		fmt.Println(axErr.ErrorCode)
		fmt.Println(axErr.ExitCode())
	}
}
Output:
config_max_bytes_invalid
2

func NewError

func NewError(ctx context.Context, code, message string, opts ...ErrorOption) *Error

NewError builds a structured error envelope using trace information from ctx.

Example

ExampleNewError builds a structured error envelope with an actionable fix and a deterministic exit code, then reads the exit code back out.

package main

import (
	"context"
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	err := ax.NewError(
		context.Background(),
		"config_too_large",
		"config exceeds maximum size of 1048576 bytes",
		ax.WithActionableFix("reduce the config or raise the limit with WithMaxConfigBytes"),
		ax.WithErrorExitCode(ax.ExitValidation),
	)

	fmt.Println(err)
	fmt.Println(ax.ErrorExitCode(err))
}
Output:
config exceeds maximum size of 1048576 bytes
2

type ErrorOption

type ErrorOption = contract.ErrorOption

ErrorOption configures a structured Error.

func WithActionableFix

func WithActionableFix(fix string) ErrorOption

WithActionableFix sets a best-effort remediation hint.

func WithErrorCause

func WithErrorCause(err error) ErrorOption

WithErrorCause attaches the underlying source error to the envelope.

func WithErrorContext

func WithErrorContext(fields map[string]any) ErrorOption

WithErrorContext merges domain-specific context fields into the envelope.

func WithErrorExitCode

func WithErrorExitCode(code int) ErrorOption

WithErrorExitCode sets the deterministic process exit code.

func WithErrorTool

func WithErrorTool(tool string) ErrorOption

WithErrorTool sets the emitting tool name.

func WithErrorVersion

func WithErrorVersion(version string) ErrorOption

WithErrorVersion sets the emitting tool version.

func WithRetryAfterSeconds added in v0.3.0

func WithRetryAfterSeconds(seconds int64) ErrorOption

WithRetryAfterSeconds sets a relative backoff hint, in whole seconds, before a retry.

func WithRetryable added in v0.3.0

func WithRetryable(retryable bool) ErrorOption

WithRetryable records whether a naive retry of the failed command is safe.

Example

ExampleWithRetryable shows a producer attaching machine-readable recovery guidance to a failure: an explicit retry-safety signal plus a relative backoff hint, so an agent can decide to retry (and how long to wait) without parsing human-facing text. The envelope is rendered to a buffer here for illustration; in a real CLI it is written to stderr.

package main

import (
	"bytes"
	"context"
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	err := ax.NewError(
		context.Background(),
		"network_timeout",
		"upstream timed out",
		ax.WithErrorTool("app"),
		ax.WithErrorVersion("v0.1.0"),
		ax.WithErrorExitCode(ax.ExitNetwork),
		ax.WithRetryable(true),
		ax.WithRetryAfterSeconds(30),
	)

	var buf bytes.Buffer
	_ = ax.WriteError(&buf, err)
	fmt.Print(buf.String())
}
Output:
{"error_code":"network_timeout","message":"upstream timed out","trace_id":"00000000000000000000000000000000","tool":"app","version":"v0.1.0","schema_version":"1.0.0","retryable":true,"retry_after_seconds":30}

func WithSuggestions

func WithSuggestions(suggestions ...string) ErrorOption

WithSuggestions sets optional candidate recovery actions.

type ErrorSchemaInfo

type ErrorSchemaInfo = isolatedschema.ErrorSchemaInfo

ErrorSchemaInfo describes the shared stderr error envelope.

type ExecuteOption

type ExecuteOption func(*executeConfig)

ExecuteOption configures Execute.

func WithEnv

func WithEnv(env func(string) string) ExecuteOption

WithEnv sets the environment lookup used by Execute.

func WithStderr

func WithStderr(w io.Writer) ExecuteOption

WithStderr sets the operational output stream.

func WithStdin

func WithStdin(r io.Reader) ExecuteOption

WithStdin sets the input stream for Cobra.

func WithStdout

func WithStdout(w io.Writer) ExecuteOption

WithStdout sets the machine payload output stream.

func WithStdoutIsTTY

func WithStdoutIsTTY(isTTY bool) ExecuteOption

WithStdoutIsTTY overrides TTY detection, primarily for tests.

func WithTelemetryShutdownTimeout

func WithTelemetryShutdownTimeout(timeout time.Duration) ExecuteOption

WithTelemetryShutdownTimeout sets the OTel shutdown timeout.

func WithVersion

func WithVersion(version string) ExecuteOption

WithVersion sets the tool version reported in schema and error envelopes. When omitted or empty, Execute falls back to ResolveVersion(""), which resolves build metadata and is never empty.

type FlagSchema

type FlagSchema = isolatedschema.FlagSchema

FlagSchema describes a command flag.

type HTTPClientOption added in v0.4.0

type HTTPClientOption func(*httpClientConfig)

HTTPClientOption configures NewHTTPClient.

func WithHTTPTimeout added in v0.4.0

func WithHTTPTimeout(d time.Duration) HTTPClientOption

WithHTTPTimeout sets the total request timeout on the returned client.

Non-positive durations are ignored and fall back to DefaultHTTPTimeout: a zero http.Client.Timeout disables the timeout entirely, which is exactly the unbounded-request hazard the client constructors exist to prevent.

type Labels

type Labels = logcore.Labels

Labels are low-cardinality Loki-indexed fields.

type Logger

type Logger = logcore.Logger

Logger is the canonical structured-logging surface, initially backed by zerolog. The single-backend guardrail (this interface is a migration seam, not a pluggable-backend selector) and the trace-correlation contract are governed by Constitution Principles VI and VIII.

Example

ExampleLogger shows the Logger surface beyond construction: WithLabels derives a logger that stamps low-cardinality labels on every line while keeping trace correlation. Keep labels low-cardinality (environment, application, host, version) — they are indexed in Loki.

package main

import (
	"bytes"
	"context"
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	var buf bytes.Buffer
	logger := ax.NewLogger(context.Background(), ax.WithLoggerWriter(&buf))
	labeled := logger.WithLabels(ax.Labels{Application: "app", Version: "v1.2.3"})
	labeled.Info(context.Background()).Msg("started")
	fmt.Print(buf.String())
}
Output:
{"level":"info","application":"app","version":"v1.2.3","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","message":"started"}

func NewLogger

func NewLogger(ctx context.Context, opts ...LoggerOption) Logger

NewLogger returns an ax Logger backed by zerolog and wired for trace correlation. When LoggerOptions include additional sinks (e.g. WithLokiFromEnv), every log line is fanned out to all sinks via io.MultiWriter alongside the primary writer.

Example

ExampleNewLogger builds the canonical structured logger: zerolog-backed, writing to the configured writer (stderr by default), with trace_id and span_id stamped on every line for trace correlation. With no active span the IDs are the zero W3C values, so the output is deterministic.

package main

import (
	"bytes"
	"context"
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	var buf bytes.Buffer
	logger := ax.NewLogger(context.Background(), ax.WithLoggerWriter(&buf))
	logger.Info(context.Background()).Msg("hello")
	fmt.Print(buf.String())
}
Output:
{"level":"info","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","message":"hello"}

type LoggerOption

type LoggerOption = logcore.Option

LoggerOption configures NewLogger.

func WithLoggerLabels

func WithLoggerLabels(labels Labels) LoggerOption

WithLoggerLabels attaches low-cardinality labels to every log line.

func WithLoggerLevel

func WithLoggerLevel(level zerolog.Level) LoggerOption

WithLoggerLevel sets the minimum zerolog level.

func WithLoggerWriter

func WithLoggerWriter(w io.Writer) LoggerOption

WithLoggerWriter sets the logger output writer. Defaults to stderr.

func WithLokiFromEnv added in v0.1.0

func WithLokiFromEnv() LoggerOption

WithLokiFromEnv returns a LoggerOption that enables direct Loki push when the AX_LOKI_URL environment variable is set. It reads AX_LOKI_URL and AX_LOKI_AUTH_TOKEN at construction time. If AX_LOKI_URL is empty or malformed, the option is a no-op and a warning is written to the logger's configured writer. Push is non-blocking; failures are reported as bounded diagnostics on the logger's configured writer (fail-open, FR-005) and never affect the CLI exit code. The caller must invoke ax.Flush to drain buffered entries at shutdown.

Push diagnostics (connection failures, non-2xx responses) resolve the configured writer lazily, so LoggerOption order between WithLokiFromEnv and WithLoggerWriter does not matter for them. Construction-time URL warnings are emitted when the option runs and go to the writer configured at that point.

Example

ExampleWithLokiFromEnv shows the no-op path: when AX_LOKI_URL is unset, WithLokiFromEnv adds no Loki sink and the logger behaves identically to a logger without it. No network connection is attempted. CLI authors add this option once; it activates only when operators set AX_LOKI_URL at runtime.

package main

import (
	"bytes"
	"context"
	"os"

	ax "github.com/rshade/ax-go"
)

func main() {
	os.Unsetenv("AX_LOKI_URL") // ensure deterministic no-op output
	var buf bytes.Buffer
	logger := ax.NewLogger(
		context.Background(),
		ax.WithLoggerWriter(&buf),
		ax.WithLokiFromEnv(),
	)
	logger.Info(context.Background()).Msg("no loki sink active")
}

type MCPSchema

type MCPSchema = isolatedschema.MCPSchema

MCPSchema is the lightweight MCP-compatible adapter shape.

func BuildMCPSchema

func BuildMCPSchema(root *cobra.Command) MCPSchema

BuildMCPSchema adapts the command tree to a simple MCP tools list.

Example

ExampleBuildMCPSchema adapts the command tree to the MCP tools-list shape emitted by __schema --as=mcp: each callable command becomes a tool whose inputSchema describes its flags as a JSON Schema object.

package main

import (
	"fmt"

	"github.com/spf13/cobra"

	ax "github.com/rshade/ax-go"
)

func main() {
	root := &cobra.Command{
		Use:   "app",
		Short: "test app",
	}
	root.Flags().String("config", "", "config file")

	mcpSchema := ax.BuildMCPSchema(root)
	fmt.Println(mcpSchema.Tools[0].Name)
	fmt.Println(mcpSchema.Tools[0].Description)
	fmt.Println(mcpSchema.Tools[0].InputSchema["type"])
}
Output:
app
test app
object

type MCPTool

type MCPTool = isolatedschema.MCPTool

MCPTool describes one command as an MCP-compatible tool.

type Metadata

type Metadata = contract.Metadata

Metadata carries common machine-readable envelope fields.

type Mode

type Mode = contract.Mode

Mode describes whether output should be optimized for agents or humans.

Example

ExampleMode shows agent-mode resolution: with no --format flag, no AGENT_MODE, and a non-TTY stdout, ax resolves to machine-readable JSON.

package main

import (
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	mode, err := ax.ResolveMode("", "", false)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(mode)
}
Output:
json

func ModeFromContext

func ModeFromContext(ctx context.Context) (Mode, bool)

ModeFromContext returns the resolved output mode stored in ctx.

func ParseMode

func ParseMode(value string) (Mode, error)

ParseMode parses an explicit output mode.

func ResolveMode

func ResolveMode(explicitFormat, agentMode string, stdoutIsTTY bool) (Mode, error)

ResolveMode applies output-mode precedence: explicit --format flag, then AGENT_MODE, then TTY detection.

type ParseConfigOption

type ParseConfigOption = isolatedconfig.Option

ParseConfigOption configures ParseConfig and ParseConfigFile.

func WithMaxConfigBytes

func WithMaxConfigBytes(maxBytes int64) ParseConfigOption

WithMaxConfigBytes sets the maximum config bytes for one parse invocation.

type Schema

type Schema = isolatedschema.Schema

Schema is the ax-native reflective JSON tree emitted by __schema.

Example

ExampleSchema shows the ax-native reflective schema tree: tool identity, the build-injected version, the mode-detection rule, the command tree, and the error-envelope contract. The schema carries its own schema_version, so it can evolve independently of the tool version.

package main

import (
	"fmt"

	"github.com/spf13/cobra"

	ax "github.com/rshade/ax-go"
)

func main() {
	root := &cobra.Command{
		Use:   "app",
		Short: "test app",
	}

	s := ax.BuildSchema(root, ax.WithSchemaVersion("v0.1.0"))
	fmt.Println(s.SchemaVersion)
	fmt.Println(s.Tool)
	fmt.Println(s.Version)
	fmt.Println(s.ModeDetection)
}
Output:
1.0.0
app
v0.1.0
--format flag > AGENT_MODE env > TTY detection

func BuildSchema

func BuildSchema(root *cobra.Command, opts ...SchemaOption) Schema

BuildSchema reflects a Cobra command tree into the ax-native schema.

Example

ExampleBuildSchema reflects a Cobra command tree into the versioned, ax-native schema document the reserved __schema command emits on stdout. The output is strict minified JSON with no timestamps or generated IDs, so it is byte-identical across runs.

package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"

	ax "github.com/rshade/ax-go"
)

func main() {
	root := &cobra.Command{
		Use:     "app",
		Short:   "test app",
		Example: "app run",
	}
	root.Flags().String("config", "", "config file")

	s := ax.BuildSchema(root, ax.WithSchemaVersion("v0.1.0"))
	if err := ax.WriteJSON(os.Stdout, s); err != nil {
		fmt.Println("error:", err)
	}
}
Output:
{"schema_version":"1.0.0","tool":"app","version":"v0.1.0","mode_detection":"--format flag \u003e AGENT_MODE env \u003e TTY detection","command":{"use":"app","short":"test app","example":"app run","flags":[{"name":"config","type":"string","usage":"config file"}],"non_deterministic_fields":[]},"error_envelope":{"schema_version":"1.0.0","required":["error_code","message","trace_id","tool","version","schema_version"],"optional":["actionable_fix","context","suggestions"],"non_deterministic_fields":["trace_id"]}}

type SchemaOption

type SchemaOption = isolatedschema.Option

SchemaOption configures BuildSchema and NewSchemaCommand.

func WithSchemaVersion

func WithSchemaVersion(version string) SchemaOption

WithSchemaVersion sets the tool version reported by __schema.

type Telemetry

type Telemetry struct {
	TracerProvider *sdktrace.TracerProvider
}

Telemetry owns the OTel provider lifecycle for a short-lived CLI process.

Example

ExampleTelemetry shows the lifecycle guard on Telemetry.Shutdown: it is nil-safe, so a CLI can defer Shutdown unconditionally on the handle — including a zero handle when setup was skipped — without a nil-pointer panic.

package main

import (
	"context"
	"fmt"

	ax "github.com/rshade/ax-go"
)

func main() {
	var telemetry *ax.Telemetry
	fmt.Println(telemetry.Shutdown(context.Background()))
}
Output:
<nil>

func StartTelemetry

func StartTelemetry(ctx context.Context, opts ...TelemetryOption) (context.Context, *Telemetry, error)

StartTelemetry installs W3C trace propagation and extracts TRACEPARENT.

It configures telemetry from the supplied environment lookup. An OTEL_EXPORTER_OTLP_ENDPOINT value enables OTLP HTTP export with synchronous, bounded attempts; AX_OTEL_DEBUG enables human-readable span output on the configured stderr writer. With neither set, telemetry is no-op for export while still allowing Execute to create a recording root span for log correlation.

StartTelemetry unconditionally replaces the process-wide OTel globals: any TextMapPropagator or TracerProvider an embedding application installed via otel.SetTextMapPropagator or otel.SetTracerProvider before this call is discarded. That clobbering is deliberate for now — ax-go targets short-lived CLI processes that own their telemetry lifecycle; cooperating with a pre-installed provider in long-running embedding apps is a known, deferred design decision.

StartTelemetry is fail-open: telemetry setup failures are reported to stderr. The error return is reserved for future use and is always nil today.

Example

ExampleStartTelemetry installs W3C propagation and configures the telemetry lifecycle with explicit stderr, service identity, and shutdown budget options.

package main

import (
	"bytes"
	"context"
	"fmt"
	"time"

	ax "github.com/rshade/ax-go"
)

func main() {
	var stderr bytes.Buffer
	ctx, telemetry, err := ax.StartTelemetry(
		context.Background(),
		ax.WithTelemetryEnv(func(string) string { return "" }),
		ax.WithTelemetryStderr(&stderr),
		ax.WithTelemetryServiceName("example"),
		ax.WithTelemetryServiceVersion("v1.2.3"),
		ax.WithTelemetryShutdownBudget(50*time.Millisecond),
	)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	if err := telemetry.Shutdown(context.Background()); err != nil {
		fmt.Println("shutdown:", err)
		return
	}

	fmt.Println(telemetry != nil)
	fmt.Println(ax.TraceIDFromContext(ctx))
	fmt.Println(stderr.Len())
}
Output:
true
00000000000000000000000000000000
0

func (*Telemetry) Shutdown

func (t *Telemetry) Shutdown(ctx context.Context) error

Shutdown flushes and shuts down the configured tracer provider.

type TelemetryOption

type TelemetryOption func(*telemetryConfig)

TelemetryOption configures StartTelemetry.

func WithTelemetryEnv

func WithTelemetryEnv(env func(string) string) TelemetryOption

WithTelemetryEnv sets the environment lookup used for telemetry configuration and trace extraction. StartTelemetry resolves OTEL_EXPORTER_OTLP_ENDPOINT and AX_OTEL_DEBUG (exporter and debug selection) as well as TRACEPARENT and TRACESTATE (inbound trace continuation) through this lookup, so a custom function controls all of them in tests and embedding scenarios.

func WithTelemetryServiceName added in v0.0.2

func WithTelemetryServiceName(name string) TelemetryOption

WithTelemetryServiceName sets the OTel resource service.name value.

The value should be a low-cardinality CLI name and must not include PII, credentials, resource IDs, or user-controlled command input.

func WithTelemetryServiceVersion added in v0.0.2

func WithTelemetryServiceVersion(version string) TelemetryOption

WithTelemetryServiceVersion sets the OTel resource service.version value.

The value should be the deterministic build-injected version reported by the adopting CLI and must not include PII or high-cardinality runtime data.

func WithTelemetryShutdownBudget added in v0.0.2

func WithTelemetryShutdownBudget(d time.Duration) TelemetryOption

WithTelemetryShutdownBudget sets the timeout budget for telemetry shutdown and synchronous exporter attempts.

Non-positive durations are ignored by StartTelemetry, which falls back to the default Execute telemetry shutdown timeout. A stuck collector maps to a fail-open stderr diagnostic rather than a command failure.

func WithTelemetryStderr added in v0.0.2

func WithTelemetryStderr(w io.Writer) TelemetryOption

WithTelemetryStderr sets the operational telemetry writer.

StartTelemetry writes fail-open diagnostics and AX_OTEL_DEBUG span output to this writer. It defaults to os.Stderr and must not be the command stdout.

Directories

Path Synopsis
Package config provides bounded Hujson config reads and patch operations.
Package config provides bounded Hujson config reads and patch operations.
Package contract provides import-isolated machine contracts for ax-go consumers.
Package contract provides import-isolated machine contracts for ax-go consumers.
examples
integration command
logging command
Command logging is the minimal consumer of ax-go's import-isolated logging surface, and the artifact internal/cmd/sizecheck measures against the absolute binary-size ceiling.
Command logging is the minimal consumer of ax-go's import-isolated logging surface, and the artifact internal/cmd/sizecheck measures against the absolute binary-size ceiling.
rootlogging command
Command rootlogging is the root-facade counterpart of examples/logging, and the denominator of the binary-size reduction ratio internal/cmd/sizecheck enforces.
Command rootlogging is the root-facade counterpart of examples/logging, and the denominator of the binary-size reduction ratio internal/cmd/sizecheck enforces.
Package id provides non-observability identifier helpers.
Package id provides non-observability identifier helpers.
internal
cli
cmd/apidiff-verdict command
Command apidiff-verdict scopes a go-apidiff report to ax-go's public API surface and decides whether a pull request introduces a breaking change that must be acknowledged.
Command apidiff-verdict scopes a go-apidiff report to ax-go's public API surface and decides whether a pull request introduces a breaking change that must be acknowledged.
cmd/benchcheck command
Command benchcheck enforces a performance regression budget by comparing two `go test -bench=.
Command benchcheck enforces a performance regression budget by comparing two `go test -bench=.
cmd/covercheck command
Command covercheck enforces per-package and repo-wide test-coverage floors against a Go coverage profile (the output of `go test -coverprofile=coverage.out -covermode=atomic ./...`).
Command covercheck enforces per-package and repo-wide test-coverage floors against a Go coverage profile (the output of `go test -coverprofile=coverage.out -covermode=atomic ./...`).
cmd/doccover command
Command doccover enforces ExampleXxx coverage on ax-go's primary API surface.
Command doccover enforces ExampleXxx coverage on ax-go's primary API surface.
cmd/sizecheck command
Command sizecheck enforces ax-go's binary-size guarantee for the import-isolated logging surface.
Command sizecheck enforces ax-go's binary-size guarantee for the import-isolated logging surface.
cmd/surfacecheck command
surfacecheck is internal maintainer/CI tooling: it scans the complete compiler-visible surface of every public package, under each supported build-tag configuration and target profile, compares the result against the reviewed live baseline, and cross-validates the permanent public-surface audit.
surfacecheck is internal maintainer/CI tooling: it scans the complete compiler-visible surface of every public package, under each supported build-tag configuration and target profile, compares the result against the reviewed live baseline, and cross-validates the permanent public-surface audit.
logcore
Package logcore is the zerolog-backed logger implementation shared by the two public logging surfaces: the root package ax and the import-isolated package logging.
Package logcore is the zerolog-backed logger implementation shared by the two public logging surfaces: the root package ax and the import-isolated package logging.
mcp
mcpserver
Package mcpserver is the internal MCP protocol engine behind the public mcp package.
Package mcpserver is the internal MCP protocol engine behind the public mcp package.
testutil
Package testutil provides reusable in-process test helpers for the ax-go module.
Package testutil provides reusable in-process test helpers for the ax-go module.
Package logging is the import-isolated public logging surface of ax-go.
Package logging is the import-isolated public logging surface of ax-go.
Package mcp exposes an ax-go CLI's command tree as a live Model Context Protocol (MCP) server with no per-tool work.
Package mcp exposes an ax-go CLI's command tree as a live Model Context Protocol (MCP) server with no per-tool work.
Package schema provides import-isolated command discoverability contracts.
Package schema provides import-isolated command discoverability contracts.

Jump to

Keyboard shortcuts

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