Documentation
¶
Overview ¶
Package patchcli is the Datum Cloud assistant (A2A) client shared by the two binaries that ship it: `patch` (cmd/patch, the standalone CLI the e2e harness drives) and `datumctl assistant` (cmd/milo-assistant, the datumctl plugin). It is a thin client over the official a2a-go client, proving the "the service is just one client away" architecture with a second consumer.
The two entrypoints differ only in how they resolve the service URL and the bearer token — the standalone CLI reads PATCH_URL/PATCH_TOKEN, the plugin takes the project from datumctl's injected environment and the token from datumctl's credentials helper. Everything past that seam is this package: Run is the standalone CLI's argv-driven entry, Invocation.Execute the resolved-input entry a cobra-based plugin builds by hand.
This file is the pure argument parser: it turns argv into a command value with NO side effects, so it is unit-testable without touching the network or process streams. Env fallbacks (PATCH_URL, PATCH_TOKEN) are resolved by Run, not here.
Grammar:
patch card [--project <p>] [--json] patch chat "<message>" --project <p> [--context-id <c>] [--json] patch chat -i --project <p> [--context-id <c>] patch chat -c --project <p> patch resume [<context-id>] --project <p> [--last] [--kubeconfig <k>] patch compact --project <p> --context-id <c> [--json] patch conversations list --project <p> [--json] patch conversations show <context-id> --project <p> [--json] patch conversations rename <context-id> <name> --project <p> [--json] patch gaps list --project <p> [--json] patch gaps reports --project <p> [--json] patch task get <id> [--json] patch task cancel <id> [--json]
Global flags (any command): --url <u>, --token <t>, --help/-h.
Chat-turn plumbing shared by the one-shot and interactive (REPL) chat modes: sending one turn, learning the conversation's contextId from the event stream, and the line-based REPL loop itself.
The chat TUI's composer: the multi-line textarea at the bottom of the full-screen chat (chat_tui.go drives its keys), the per-project prompt history that ↑/↓ and ctrl+r walk, and the paste chips that keep a large bracketed paste out of the visible input until the message is sent.
History lives in a plain file under the user's config dir, one prompt per line with newlines escaped. Everything here is best effort: a missing or unreadable config dir costs the session its recall, never the chat.
The chat TUI's "@" resource picker: the inline list that opens when an "@" is typed at a word boundary, first offering the project's resource kinds and then, once a "kind/" is settled on, that kind's instances.
It shares the one variable-height bar above the composer with the slash commands and the ctrl+r search line (see composerBar in chat_tui.go), so the transcript viewport trades exactly the rows this list takes.
What is on screen is derived from the composer's text and cursor, never from a mode flag — the same approach currentSuggestions takes — so there is no way for the list and the input to disagree. Fetching is the one thing that is stateful: discovery is cached for the session and each kind's instances are fetched once, on the keystroke that first narrows to that kind.
The chat TUI's conversation picker: the full-screen overlay behind `/resume` and `patch resume`, modelled on the session picker in coding agents like Claude Code — a search box on top, the project's conversations under it newest first, each as a title (its opening message) over an age/size/id line, and a live preview of the highlighted one alongside when the terminal is wide enough.
Listing and transcripts come from the conversations apiserver through the same ReadView as `patch conversations`, never the chat transport: per the apiserver design, discovery and resuming are separate paths, and this overlay is the discovery half.
Opt-in full-screen chat UI for the `patch` CLI, built on Bubble Tea v2. It is an alternative to the line-based REPL (runRepl) selected with `chat --tui`; the plain and interactive modes are untouched.
Layout: a header, a scrollable transcript viewport, a multi-line composer, and a one-line footer holding the session state (spinner while the assistant works, else the key hint) on the left with session badges right-aligned against it, all wrapped in a padded container. Assistant answers stream in live and are rendered as markdown via glamour, so bold/bullets/tables come out formatted rather than as raw `**`. The composer itself (its styling, prompt history and paste chips) lives in chat_composer.go; this file owns its keys.
Colors & contrast: Bubble Tea (not glamour) owns the OSC-11 terminal background query — glamour querying the terminal itself would leak the response into raw-mode input. We ask for the background in Init, learn dark/light from the reply, then build both the glamour style and the lipgloss speaker colors explicitly for that background.
Threading: the conversation's contextId is learned from the event stream (as the REPL does) and sent on every later turn, so the whole session is one conversation with memory.
Turn feedback: every finished turn is closed out by a "Worked for 23s" line under its block, the window title tracks whether a turn is running, and a bell (optionally an OSC 9 desktop notification — see notifyMode) says so to a user who has looked away.
Turns: each streaming turn owns a cancellable context, so esc can stop this turn without touching the session's. A stopped turn is finalized in place (kept text plus an "interrupted" marker) and its generation is bumped, which is what makes the abandoned goroutine's remaining messages stale. Messages typed while a turn runs are queued and sent one per turn as each finishes.
A2A client construction for the patch CLI, built on the official a2a-go client (a2aclient + agentcard resolver) — no hand-rolled protocol code.
The agent card is public, so it is fetched with the default resolver. The JSON-RPC transport is given an http.Client whose RoundTripper attaches the bearer token to every request; the well-known card fetch stays unauthed, matching the service (which serves the card publicly).
The token is a TokenSource, not a string, because the two entrypoints mint it differently: the standalone CLI has it in hand from PATCH_TOKEN, while the datumctl plugin shells out to datumctl's credentials helper for a short-lived one. Resolving per request rather than per process is what lets a long-lived `chat --tui` session outlive the token it started with.
`patch conversations` — browse (and name) the durable chat history exposed by the conversations aggregated apiserver (assistant.miloapis.com).
Per the apiserver design (decision #7, "an apiserver is not a chat transport"), listing and resuming are separate paths: this command is a read-only *discovery* view. It fetches raw API paths through ReadView, which prefers datumctl's own identity and falls back to kubectl — see readview.go for why that order, and why neither transport uses client-side discovery. Once you have a context id, resume it with `patch resume <id>` (or non-interactively, `patch chat --context-id <id>`).
`rename` is the one subcommand here that writes, and so the one that talks to the assistant service instead: the aggregated API is read-only by design, and the name belongs to the same conversation row the chat path owns.
API discovery for "@" mentions: what kinds of Datum resource exist in the caller's project, and what is actually there under one of them.
Both reads go through ReadView, so they carry the same identity and the same project scoping as the conversation views (see readview.go). Discovery is fetched once per session and instances lazily per kind, because a project can carry a lot of both and the picker only ever shows six rows.
Kubernetes offers two discovery shapes. Aggregated discovery (one request for every group and its resources) is asked for first via a custom Accept; an apiserver too old for it — or the kubectl transport, which cannot send a header at all — answers with the classic APIGroupList instead, which is recognized by its kind and walked group by group. Everything here is best effort: a failure becomes one line in the picker, never an error in the chat.
`patch gaps` — a provider service's own read view of capability-gap reports (see internal/gapreport, docs/capability-gap-reporting-design.md): records the assistant writes when it told a user it lacked a tool, lookup, or piece of knowledge a provider service should have supplied. --project here is the PROVIDER's own project (spec.reportingProject on its capability document), never the project the conversation that hit the gap ran in — the capabilitygapreports resource is namespaced by provider, so a caller only ever sees reports attributed to a provider they have access to. Same read path as `conversations`: raw API paths through ReadView, which prefers datumctl's identity and falls back to kubectl (readview.go).
The "@" mention syntax and its parser.
A mention is `@kind/name` — two segments, no namespace. Namespaces are deliberately not part of the syntax: ReadView already scopes every request to one project's control plane (the projects/<p>/control-plane prefix in readview.go), so the project is the scope the user is in and the thing the service is told about. Within it the picker lists a kind across all namespaces, and a mention is a hint for the model to look the resource up rather than an address to fetch it from — so a third segment would buy precision nobody needs at the cost of a token nobody wants to type.
Parsing is deliberately conservative. The submitted text is prose: it holds email addresses, code spans, and sentences that end in a full stop, and none of those should turn into a resource reference.
How the read views (`conversations`, `gaps`) reach the aggregated apiserver.
There are two transports, and which one is used decides WHOSE IDENTITY the request carries:
- Milo directly, over HTTPS, with a token from datumctl's credentials helper. This is the same identity `chat` already uses, and the same one the caller selected with `datumctl context use`.
- kubectl, with the caller's ambient kubeconfig — the fallback for the standalone `patch` binary, which has no datumctl to ask, and for anyone who passes --kubeconfig deliberately.
The first is preferred wherever it is available. The read views used to use only the second, on the reasoning that reading a Kubernetes API is a different act from calling the assistant service and should therefore use the caller's Kubernetes identity. That reasoning does not survive contact with the platform: Milo accepts the very token datumctl already mints, so there is no second identity to honor — only a second way to be pointed somewhere unintended. `kubectl` resolves its context from KUBECONFIG and ~/.kube/config, neither of which has anything to do with the datumctl context, so `datumctl assistant conversations list` would happily ask whichever unrelated cluster happened to be current and report that the API does not exist. That reads as "this feature is not deployed" when the truth is "you asked the wrong server".
Both transports fetch a raw path rather than a named resource, so neither depends on client-side discovery. That matters beyond tidiness: kubectl caches discovery per API host, so a freshly registered APIService keeps reporting `the server doesn't have a resource type "conversations"` from a stale cache long after it is live.
Turn recovery: what the client does when the SSE stream for a turn breaks before the service reported a terminal task state.
Streaming stays the default and the good case is untouched. This is the recovery path only: the work continues server-side and lands in the durable task store, so the client polls GetTask on the id it recorded and delivers the answer when the task finishes.
Two breaks reach here and they look different on the wire. An abrupt reset surfaces an error from the SSE reader. A graceful close does not: a2a-go parses the stream with a bufio.Scanner, and a clean EOF leaves scanner.Err() nil, so the event iterator simply ends. Neither carries a terminal state, which is what makes "no terminal state" — not "an error" — the signal that a turn was cut short.
Terminal rendering for the `patch` CLI. All output goes through an injected Io (Out/Err writers) so rendering is unit-testable against scripted event streams without touching os.Stdout/os.Stderr.
Convention: the assistant's ANSWER text goes to STDOUT; status transitions and decoration go to STDERR. So `patch chat … > answer.txt` captures just the reply and pipelines stay clean.
The wire is real A2A v1.0 (a2a-go types): task states arrive as the TASK_STATE_* enum values, which friendlyState maps back to the lowercase words the TS CLI printed (submitted, working, completed, …).
The two entrypoints into this package.
Run is the standalone `patch` CLI's: it parses argv, resolves the service URL/token (flags override PATCH_URL/PATCH_TOKEN), and executes. The datumctl plugin does its own flag parsing with cobra and its own URL/token resolution against datumctl's injected environment, then builds an Invocation and calls Invocation.Execute — the shared dispatch both share.
The process-stream implementation of Io, shared by both binaries that embed this package. Kept here rather than in each main so the two agree on the stdout/stderr split that makes `patch chat … > answer.txt` work.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var AuthHint = "check PATCH_TOKEN / --token"
AuthHint is appended to authentication failures to say where this binary gets its token. The standalone CLI reads PATCH_TOKEN; the datumctl plugin asks datumctl's credentials helper, so it replaces this at startup.
var ErrNothingToCompact = errors.New("nothing to compact")
ErrNothingToCompact is returned by [requestCompact] when the server ran the request successfully but found nothing to compact (its compacted:false response — see internal/server/compact.go). Distinguished from a transport or server-side failure so callers (patch compact, the chat TUI's /compact) can show a friendlier message instead of treating it as an error.
Functions ¶
func DiscoverBaseURL ¶
DiscoverBaseURL asks the aggregated apiserver where the assistant lives.
This is why `datumctl assistant` needs no PATCH_URL. The control-plane address names Milo, not the assistant, and nothing else advertises the service's address — so the service publishes it as a resource in its own API group, which the CLI already reads with the caller's datumctl credentials for `conversations` and `gaps`. No new credential, no hostname convention.
Cluster-scoped and singular: one assistant serves the control plane — hence a path with no namespace segment, unlike the other read views.
An empty URL is returned as an error rather than an empty string: the resource exists but the operator has not told the service its own address, and a caller needs to be told that rather than handed "".
Types ¶
type Invocation ¶
type Invocation struct {
Kind Kind
// JSON emits raw JSON (events for chat, objects otherwise).
JSON bool
// BaseURL is the assistant service's base URL. Required by KindCard,
// KindChat, KindCompact and the two task kinds; the conversations and
// gaps read views go to the aggregated apiserver instead and ignore it.
BaseURL string
// Token mints the bearer token. Used for the service AND, with APIHost,
// for the aggregated-API read views — they are one identity, not two.
// Nil leaves requests unauthenticated.
Token TokenSource
// Message is the chat turn's text. Empty is valid for Interactive/TUI.
Message string
// Project is the Milo project the task runs against — for the gaps read
// view, the PROVIDER's own project.
Project string
// ContextID continues an existing conversation (KindChat, KindCompact,
// KindConvRename); for KindResume it is optional and skips the picker.
ContextID string
// Continue resumes the project's most recently active conversation
// (-c/--continue, resume's --last) when ContextID is empty.
Continue bool
// Name is the conversation name KindConvRename sets.
Name string
// Interactive selects the line-based REPL, TUI the full-screen chat UI.
Interactive bool
TUI bool
// APIHost is Milo's host (datumctl's DATUM_API_HOST). With Token set and
// no Kubeconfig, the read views go straight to Milo as the datumctl
// identity; otherwise they fall back to kubectl. See readview.go.
APIHost string
// Kubeconfig overrides KUBECONFIG for the apiserver read views, and
// forces the kubectl transport even when APIHost is available.
Kubeconfig string
// ID is the task id for KindTaskGet / KindTaskCancel.
ID string
}
Invocation is one fully-resolved CLI command: the caller has already decided the service URL and how to mint a token, so Invocation.Execute does no environment lookups of its own.
type Kind ¶
type Kind int
Kind names the command an Invocation runs.
const ( // KindCard fetches and renders the agent card. KindCard Kind = iota + 1 // KindChat sends a chat turn (one-shot, REPL, or full-screen TUI). KindChat // KindCompact forces history compaction for one conversation. KindCompact // KindConvList lists the caller's conversations in a project. KindConvList // KindConvShow prints one conversation's transcript. KindConvShow // KindConvRename names one conversation. KindConvRename // KindGapList lists a provider project's distinct capability gaps. KindGapList // KindGapReports lists the individual reports behind those gaps. KindGapReports // KindResume opens the full-screen chat straight into the conversation // picker, or — with ContextID set — into that conversation with its // transcript loaded. KindResume // KindTaskGet fetches one task. KindTaskGet // KindTaskCancel cancels one task. KindTaskCancel )
type LineReader ¶
LineReader is the optional extension of Io that interactive mode needs: it reads one line of user input, reporting ok=false on end of input. The real CLI backs it with stdin; tests script it.
type ReadView ¶
type ReadView struct {
// contains filtered or unexported fields
}
ReadView fetches raw aggregated-API paths on the caller's behalf.
func ReadViewFor ¶
func ReadViewFor(inv Invocation) ReadView
readViewFor builds a ReadView from an invocation.
type TokenSource ¶
TokenSource yields a bearer token for the assistant service. It is called once per outgoing request, so a source backed by a credentials helper can return a freshly minted token as the previous one ages out.
func StaticToken ¶
func StaticToken(token string) TokenSource
StaticToken is the TokenSource for a token already in hand (the standalone CLI's --token / PATCH_TOKEN). An empty token leaves requests unauthenticated — the service answers with an auth error, which Run maps to exit 1.