ux

package
v0.32.1 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package ux is the CLI's audience-aware I/O layer (impl/3.1, impl/3.2). Commands never call fmt.Println / huh directly — they go through an Audience, so the CLI shape-shifts by mode (human vs agent/service-account) and every output byte, on BOTH stdout and stderr, passes the fail-closed redaction funnel in this file.

Index

Constants

View Source
const (
	ExitOK             = 0 // success (for execute: broker returned an upstream response, even a 4xx/5xx)
	ExitError          = 1 // generic/local failure: transport, missing arg, fencing, CLI bug
	ExitDenied         = 2 // Jentic said no: broker policy denial or resolve failure — change the ask, don't retry
	ExitTimeoutPending = 3 // --wait expired while still pending: retry later is meaningful
	ExitPartial        = 4 // partial approval of a composite request: inspect per-item status
)

Exit-code taxonomy (13 §4). core.Run maps any ExitCoder to these; nothing calls os.Exit directly (arch rule).

View Source
const (
	CodeMissingArgument    = "MISSING_ARGUMENT"
	CodeConfirmBlocked     = "INTERACTIVE_CONFIRM_BLOCKED"
	CodeFenced             = "FENCED_COMMAND"
	CodeNotAuthenticated   = "NOT_AUTHENTICATED"
	CodePendingApproval    = "PENDING_APPROVAL"
	CodeResolveFailed      = "RESOLVE_FAILED"
	CodeBrokerDenied       = "BROKER_DENIED"
	CodeTimeoutPending     = "TIMEOUT_PENDING"
	CodePartialApproval    = "PARTIAL_APPROVAL"
	CodeConfinementMissing = "CONFINEMENT_UNAVAILABLE"
	CodeTransportError     = "TRANSPORT_ERROR"
	CodeInternalError      = "INTERNAL_ERROR"
	// CodeMigrationRequired gates every command on an unmigrated V1 machine
	// (legacy ~/.jentic profiles, no MIGRATED marker): the V2 CLI no longer
	// reads the legacy store, so nothing can run until `jentic migrate` copies
	// it into the XDG context model. actionable_step is always "jentic migrate".
	CodeMigrationRequired = "MIGRATION_REQUIRED"
)

error_code enum (13 §3a). Closed set: agents treat an unknown code as INTERNAL_ERROR-like ("stop and report"). New codes are additive; add them here AND to errorCodeExit.

View Source
const (
	StatusCreated    = "created"
	StatusAdded      = "added"
	StatusUpdated    = "updated"
	StatusSwitched   = "switched"
	StatusDeleted    = "deleted"
	StatusRegistered = "registered"
	StatusPending    = "pending"
)

Status verbs — the closed set of outcomes a Result may report. Named constants (not literals per call site) prevent the "added"/"created"/"switched" drift the map[string]string approach allowed. Mirrors 13 §2.

Variables

This section is empty.

Functions

func IsSensitiveKey

func IsSensitiveKey(key string) bool

IsSensitiveKey is the exported form of the redactor's secret-shaped-key predicate. It exists so the architecture sweep (tests/arch, Test1H) asserts against the ACTUAL runtime heuristic — allowlist included — rather than a hand-maintained copy that can silently drift (F8-35). It is the single source of truth for "does this property name look like a secret".

func MarshalForFile

func MarshalForFile(data any) []byte

MarshalForFile is the exported, redacted, indented marshal for commands that write an envelope to a file (e.g. `history export -o out.json`) instead of through the Audience. It runs the SAME three-layer redaction funnel and schema-version stamping as stdout output, so a secret can never leak just because the destination is a file. Indented for human readability; still valid machine JSON.

func RedactBytes

func RedactBytes(data []byte) []byte

RedactBytes is the exported byte-level backstop for command code that emits a raw upstream/API body (e.g. the `jentic api` passthrough, `execute --raw`, `inspect`, `apis spec`) rather than a marshaled envelope. It applies the SAME redaction guarantee as every other output path so a secret in an API response can't leak to a machine parser.

When data is valid JSON it is parsed and run through the STRUCTURED key pass (redactValue), which redacts a sensitive key regardless of its value's JSON type — so a secret carried as a number, object, array, or bool is caught, not just a `"key":"string"` pair (the reKV byte regex alone matched string values only; review round-3 P0). Shape is preserved when NOTHING is redacted: the structured pass reports whether it changed anything, and if not, RedactBytes runs only the byte backstop over the ORIGINAL bytes — so shape-preserving callers like WriteJSON (which feed already-indented JSON through here) keep their exact layout and key order. Only when a sensitive value is actually redacted does the document get re-marshaled (matching the input's indentation); on that path key order may change, which is fine because the document already differs. The byte backstop then still runs to catch secrets embedded in free-form string values (bearer tokens, PEM blocks) that carry no sensitive key.

When data is NOT valid JSON (e.g. a Markdown inspect body, a YAML spec, a plain-text error) it falls back to the byte backstop alone — the same behaviour as before — because there is no structure to walk.

func RegisterSensitiveFields

func RegisterSensitiveFields(pkgPath string, table map[string][]string)

RegisterSensitiveFields merges a plane's generated SensitiveFields table (bare type name -> []json field names) into the redaction registry, qualifying every key with pkgPath (GEN-23) so lookups are collision-proof across planes and against app structs. Idempotent and additive; call once per plane at startup. pkgPath is the plane package's import path (control/broker), which redactTagged reads back from reflect.Type.PkgPath at redaction time.

func WithAudience

func WithAudience(ctx context.Context, aud Audience) context.Context

WithAudience stores the resolved Audience in the context for downstream commands.

func WriteJSONLine

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

WriteJSONLine writes ONE compact, redacted JSON document followed by a newline to w. It is the streaming primitive for tail-style commands (e.g. `events watch`) that emit an unbounded NDJSON sequence rather than a single terminal envelope — Render is for one final document, this is for a stream. It runs the same redaction funnel as every other output path, so a streamed event cannot leak a secret. Errors from the underlying writer are returned so the caller can stop the tail (e.g. on a closed pipe).

Types

type AgentError

type AgentError struct {
	SchemaVersion string         `json:"schema_version"`
	ErrorCode     string         `json:"error_code"`
	Error         string         `json:"error"`
	Actionable    string         `json:"actionable_step,omitempty"`
	Details       map[string]any `json:"details,omitempty"`
}

AgentError is the stderr error envelope — the Go shape of the contract in 13 §3. That document is canonical; this struct mirrors it.

type AgentUX

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

AgentUX is the ruthlessly strict machine mode shared by `agent` and `service-account` (impl/3.1 §0): never prompts, no color, one JSON document per Render on stdout, structured error envelope on stderr.

func NewAgentUX

func NewAgentUX(assumeYes bool) *AgentUX

NewAgentUX builds the agent audience. The palette is always no-color.

func (*AgentUX) Ask

func (a *AgentUX) Ask(_, flagName string, _ bool) (string, error)

Ask cannot prompt an agent; it returns a MISSING_ARGUMENT CodedError.

func (*AgentUX) AskConfirm

func (a *AgentUX) AskConfirm(_ string) (bool, error)

AskConfirm returns true only if the global --yes pre-authorized; otherwise it returns an INTERACTIVE_CONFIRM_BLOCKED CodedError.

func (*AgentUX) ForcesNoColor

func (a *AgentUX) ForcesNoColor() bool

ForcesNoColor reports that agent output must never carry ANSI (protects LLM parsers).

func (*AgentUX) IsFenced

func (a *AgentUX) IsFenced() bool

IsFenced reports that agents are locked out of admin commands.

func (*AgentUX) Render

func (a *AgentUX) Render(data any)

Render writes one compact, redacted JSON document to stdout.

func (*AgentUX) ReportError

func (a *AgentUX) ReportError(err error, step string)

ReportError writes the redacted structured error envelope to stderr.

func (*AgentUX) Theme

func (a *AgentUX) Theme() Palette

Theme returns the agent (always no-color) palette.

type Audience

type Audience interface {
	// Ask prompts for missing information. Humans get a TUI prompt; agents get a
	// typed CodedError explaining the missing flag (no interactive fallback).
	Ask(question, flagName string, required bool) (string, error)

	// AskConfirm prompts for a destructive action. Humans get [y/N]; agents
	// auto-reject unless the global --yes was passed.
	AskConfirm(warning string) (bool, error)

	// Render writes a success payload to stdout. Intentionally VOID: the only
	// realistic failure is a marshal error on an exotic value (channel/func) — a
	// programmer bug — which marshalRedacted turns into a guaranteed-encodable error
	// envelope, so stdout always carries exactly one valid JSON document. Callers
	// treat Render as a terminal "this succeeded".
	Render(data any)

	// ReportError writes a failure to stderr (never stdout — stdout is reserved for
	// Render). Humans get a red line; agents get the structured error envelope. Both
	// pass the byte-level redaction backstop (review M6).
	ReportError(err error, actionableNextStep string)

	// Theme returns the active palette.
	Theme() Palette

	// IsFenced reports whether this mode is forbidden from state-mutating admin
	// commands (the root interceptor enforces it — impl/3.2 §2).
	IsFenced() bool

	// ForcesNoColor reports whether ANSI must be suppressed regardless of theme, to
	// protect downstream machine parsers.
	ForcesNoColor() bool
}

Audience is the sole gatekeeper between a command's business logic and the caller's terminal. Commands must NEVER call fmt.Println / huh directly — they go through an Audience so the CLI shape-shifts by mode (impl/3.1 §1).

func FromContext

func FromContext(ctx context.Context) Audience

FromContext retrieves the Audience. If none is present it FAILS CLOSED to strict agent mode (never a human prompt): reaching this branch means the root PersistentPreRunE didn't run — a wiring bug. Silently dropping a human into no-prompt agent mode is confusing to debug, so warn loudly on stderr (never stdout — that must stay machine-parseable).

type CodedError

type CodedError struct {
	Code       string         // e.g. "MISSING_ARGUMENT", "BROKER_DENIED"
	Msg        string         // human/LLM-readable prose (redacted on output)
	Actionable string         // machine-runnable recovery step, when one exists
	Details    map[string]any // e.g. {"agent_directive": ..., "http_status": 403}
	// contains filtered or unexported fields
}

CodedError is the typed error every fenced/validation/denial path returns so the error envelope can carry a closed-enum error_code. The enum values and their exit codes are owned by 13 §3a — do not invent codes here; add them to errorCodeExit in contract.go.

func (*CodedError) Error

func (e *CodedError) Error() string

func (*CodedError) ExitCode

func (e *CodedError) ExitCode() int

ExitCode makes every CodedError satisfy pkg/core's ExitCoder mechanically, so core.Run maps the closed enum to the exit taxonomy (13 §4) with no per-command wiring. Without it, a forgotten wrapper would exit 1 even for BROKER_DENIED (exit 2) or TIMEOUT_PENDING (exit 3). The code->exit table lives in contract.go.

func (*CodedError) IsReported

func (e *CodedError) IsReported() bool

IsReported reports whether an Audience already rendered this error.

func (*CodedError) MarkReported

func (e *CodedError) MarkReported()

MarkReported flags the error as already rendered by an Audience. Called by every ReportError implementation; command code never needs it.

type Export

type Export struct {
	SchemaVersion string `json:"schema_version"`
	// TraceID is the correlation pivot the export was queried by (impl/5.0 §2).
	TraceID string `json:"trace_id,omitempty"`
	// Items is the exported records (kept as `any` so the same envelope serves any
	// export; history passes []control.ExecutionResponse).
	Items any `json:"items"`
	// Count is the number of exported records after filtering.
	Count int `json:"count"`
}

Export is the versioned envelope for bulk export commands (history export). It wraps a fully-walked, filtered result set (never a single page) plus the pivot it was queried by, so an agent can template a workflow from a run's real executions. SchemaVersion is stamped at render time like the other envelopes.

type HumanUX

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

HumanUX is the interactive mode: huh prompts, themed accents, indented JSON / formatted lines on stdout, a red error line on stderr.

func NewHumanUX

func NewHumanUX(t Palette, assumeYes bool) *HumanUX

NewHumanUX builds the human audience with the resolved palette and the global --yes setting.

func (*HumanUX) Ask

func (h *HumanUX) Ask(question, flagName string, required bool) (string, error)

Ask prompts the human for a value, or fails with a missing-flag CodedError when the session is not promptable.

func (*HumanUX) AskConfirm

func (h *HumanUX) AskConfirm(warning string) (bool, error)

AskConfirm asks the human for y/N confirmation (auto-yes when --yes was set).

func (*HumanUX) ForcesNoColor

func (h *HumanUX) ForcesNoColor() bool

ForcesNoColor reports that human mode respects theme/color preferences.

func (*HumanUX) IsFenced

func (h *HumanUX) IsFenced() bool

IsFenced reports that humans may run admin commands.

func (*HumanUX) Render

func (h *HumanUX) Render(data any)

Render writes data to stdout: Page footers, Result status lines, or indented JSON.

func (*HumanUX) ReportError

func (h *HumanUX) ReportError(err error, step string)

ReportError writes a redacted, styled error line (and optional next step) to stderr.

func (*HumanUX) Theme

func (h *HumanUX) Theme() Palette

Theme returns the resolved human palette.

type List

type List struct {
	SchemaVersion string `json:"schema_version"`
	Data          any    `json:"data"`
	// HasMore/NextCursor mirror the Jentic list APIs' cursor pagination. NextCursor
	// is omitted when empty; HasMore is always present so an agent can branch on it
	// without a null check.
	HasMore    bool   `json:"has_more"`
	NextCursor string `json:"next_cursor,omitempty"`
	// Meta carries command-specific summary fields that are NOT the collection
	// itself (e.g. catalog's catalog_total / manifest_age_seconds). Kept under one
	// key so the top level stays a stable, closed shape across every list command;
	// omitted entirely when a command has no summary.
	Meta map[string]any `json:"meta,omitempty"`
}

List is the canonical, versioned envelope for data-plane list output (AGT-1/ AGT-5). Before this, list commands wrote ad-hoc maps: most used {data, has_more, next_cursor} but `endpoints` used {endpoints} and NONE carried schema_version, so an agent could not (a) detect the envelope version or (b) rely on a single collection/pagination key across commands. This struct makes every list emit the same three keys plus schema_version; marshalRedacted stamps the version like the other envelopes. Data is `any` so each command passes its concrete non-nil slice (an empty result serialises as [], not null).

func NewList

func NewList(data any, nextCursor string, meta map[string]any) List

NewList builds a List envelope. nextCursor is empty on the last (or only) page; hasMore is derived from it so callers cannot set the two inconsistently. meta, when non-empty, carries command-specific summary fields under `meta`.

type Page

type Page struct {
	SchemaVersion string `json:"schema_version"`
	Items         any    `json:"items"`
	// NextToken is the opaque cursor for the next page; empty on the last page.
	NextToken string `json:"next_token,omitempty"`
}

Page is the pagination envelope for list commands (13 §2, impl/3.3). It carries the underlying items plus the opaque cursor for the next page. Cursor form only — Jentic list APIs are cursor-paginated (data + has_more + next_cursor); there is no page-number UX.

func NewPage

func NewPage[T any](items []T, nextToken string) Page

NewPage builds a Page envelope from a typed item slice and the opaque next-page cursor (empty on the last page). It is the bridge from the SDK's client/paginate walk helper (whose Page[T].Next is the cursor) to the machine contract's rendered envelope: a command walks pages with paginate.All/ForEach, then hands the collected items here for Render. Kept generic so callers pass their concrete []T without an interface conversion at the call site.

func (Page) HasNext

func (p Page) HasNext() bool

HasNext reports whether another page is available.

func (Page) NextHint

func (p Page) NextHint() string

NextHint is the human-mode footer telling the user how to fetch the next page. Cursor APIs use `--cursor <token>` (never `--page N`), so the hint is explicit about the token to pass.

type Palette

type Palette = theme.Palette

Palette aliases the global theme palette so command code depends only on ux.

type Plan

type Plan struct {
	SchemaVersion string `json:"schema_version"`
	Operation     string `json:"operation"`
	DryRun        bool   `json:"dry_run"`
	Payload       any    `json:"payload,omitempty"`
}

Plan is the machine-readable execution plan emitted for --dry-run/--export-plan (impl/5.0 §5): the operation a mutating command WOULD invoke and the hydrated payload it would send, without firing the call. It runs through the same redaction funnel as every other rendered value, so a plan can't leak a secret in its payload. SchemaVersion is stamped at render time like the other envelopes.

type Result

type Result struct {
	// SchemaVersion pins the envelope shape for agents (13 §2/§6). Call sites may
	// leave it empty; marshalRedacted stamps the current version at render time.
	SchemaVersion string `json:"schema_version"`
	// Status is the outcome verb (StatusCreated, StatusSwitched, ...).
	Status string `json:"status"`
	// Resource is the kind of thing acted on ("environment", "context", "identity").
	Resource string `json:"resource,omitempty"`
	// Name is the human/identifier name of the affected resource, when it has one.
	Name string `json:"name,omitempty"`
	// ID is the server- or client-assigned identifier, when one was produced.
	ID string `json:"id,omitempty"`
	// Message is optional human-oriented context (e.g. "awaiting operator approval").
	Message string `json:"message,omitempty"`
	// Fields is the escape hatch for genuinely command-specific data. Prefer a
	// first-class field above; use this only when nothing else fits.
	Fields map[string]any `json:"fields,omitempty"`
}

Result is the canonical success payload for state-mutating commands (context use, env add, identity add, credential create, identity register, ...). It replaces ad-hoc map[string]string literals: those had no compile-time safety and drifted on key names and status verbs. Sensitive fields MUST use `redact:"true"` so layer 1 scrubs them — never put a raw secret in Fields.

Jump to

Keyboard shortcuts

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