Go CLI for DocsGPT: chat with agents from the terminal, one-shot questions in
pipelines, agents and sources as code, bench, host mode. User docs: README.md
(short) and docs/ (one page per area; update the page when behaviour changes).
Development rules: AGENTS.md.
cmd/docsgpt-cli/ → Entry point (package main), calls cmd.Execute(). Lives here
so `go install .../cmd/docsgpt-cli@latest` names the binary
docsgpt-cli rather than DocsGPT-cli (the module path's last
element). Blank-imports internal/earlytheme, which pins
lipgloss's background before bubbletea's init would query
the terminal (5s stall where it never answers); it runs
first by import-path order, checked by main_test.go
sdk/ → SEPARATE Go module github.com/arc53/DocsGPT-cli/sdk, package
docsgpt: the public chat client (Client, Send, SendStream,
Run + RunOptions/RunResult (RunWithTools: the old positional form, deprecated;
OnToolResult → ToolResult{Content, Parts}: a tool message with images,
which the server shows the model), Models, StreamHandler,
Source, APIError with Message/Code/RetryAfter, RetryPolicy:
Client.Retry, 3 retries 2s/4s/8s of a chat request that failed
before its response — 408/429/502-504, refused/reset/timeout,
Retry-After capped by MaxDelay, x-should-retry, never once a
stream has started; a blocking OnRetry counts towards the wait)
— OpenAI-compatible types and the tool-call loop;
Message.Parts (ContentPart: TextPart/ImagePart/FilePart/
AttachmentPart, data URLs) marshal as the content array,
Content first as a text part; read back into Parts, Text();
results carry conversation id, sources, model and usage. A stream
without [DONE] and without a finish_reason is an error, and so is a
tool_calls index outside 0-63. Stdlib-only, tagged sdk/vX.Y.Z
independently of the CLI, currently pre-v1. The CLI depends
on it through a pinned require in go.mod (imported as
`docsgpt "…/sdk"`, since the package name is not the last
path element); go.work points local builds at ./sdk, and
release builds set GOWORK=off to use the pinned version.
sdk/README.md is the module's user doc
docs/ → user docs: install (+ updating), quickstart, chat, tools,
configuration, agents-as-code, sources, bench, host, ci;
docs/README.md is the index
examples/ → agents/ (sample definition), bench/ (suite, one case per
feature), ci/github-actions.yml
deployment/ → install.sh / install.ps1 (attached to every release)
cmd/
root.go → Root command = the entry point (chat on a TTY, else one-shot ask), typo guard + questionArgs (extra words after a command: exit 2 with `To ask it as a question: docsgpt-cli -- "…"`), global flags (--url, --key, --token), chat flags (+ --no-stdin), help groups + usage template, Execute (startup config, update gate)
ask.go → Single-shot Q&A with streaming + tool support (hidden alias; the root runs it); --json (askResult on stdout, errors in it + errReported exit); @paths of the args attach files (attachFiles; a bad file = exit 2), --json `attachments`
attach.go → attachFiles: a message's marker files + the files its @paths name, numbered after them, read into content parts
errors.go → explainChatError: one actionable line per failure (401/403 → login, 404 → url, 429, 5xx, can't reach: refused/DNS/TLS/timeout); DOCSGPT_DEBUG=1 adds the raw error
retry.go → retrying(): a per-run client copy whose retry waits count down on a status ("Retrying (1/3) in 4s… (502 Bad Gateway)"); the retry setting
completion.go → cobra's completion command (under Tools) + config-only completions: key names, settings and their values; completing() keeps __complete off the update gate
whatsnew.go → chat: the release notes once after an update (first 10 lines, whats_new setting), /changelog
chat.go → Interactive chat (hidden alias; optional first message): drives the full-screen ui.Screen from a goroutine (loop over Screen.Next), the slash command table, !cmd, sessions (-c/-r//resume), screenTools (tools.UI in the transcript and panel), transcript printed on exit
config.go → config get / set / show / path + the settings menu; one `settings` table drives all of them
install.go → Hidden `install` (run by the install scripts), wiring over internal/install
update.go → Self-update to latest GitHub release (--check, --yes/-y: needed off a terminal, else ui.Confirm; --rollback, hidden --worker)
host.go → host daemon commands (pair [code]: ui.Input validated by the redeem call, then a ui.Select of start / install / nothing on a TTY, first stdin line otherwise; status, revoke, reset, install-/uninstall-service); wiring over internal/host
bench.go → Benchmark suites vs agents (bench / bench record / bench init; --model, --matrix, --run-tag, --agent-id)
manage.go → Exit codes (0/1/2, exitError/usageErr), PAT client construction, confirmations
login.go → login / logout / whoami / hidden keys: agent API keys + the PAT, the key picker, first-run key prompt (chatKey)
agents.go → agents list / export / plan / apply / delete (agents as code), agents trigger (incoming webhook); flags + output only, the flows live in internal/manage
sources.go → sources list / upload / delete, agents prompts / agents tools (old `prompts list` / `tools list` hidden)
utils.go → printError, codeBlocks/extractCommand, copyToClipboard, signalContext (INT/TERM/HUP cancel with a ui.Signal cause) + terminated
internal/
earlytheme/ → pins lipgloss's background before bubbletea's init (see cmd/docsgpt-cli)
config/
config.go → Unified config load/save/migrate from ~/.docsgpt/config.json; key/token/URL resolution (flag > env > config, ErrNoKey), key and token redaction
install/ → PATH install of the running binary (profiles, Windows user PATH), IsWritable
bench/
spec/ → Suite/case YAML format (bench.yaml + case.yaml), loading, validation, golden files; model/stream/attachments_mode/turns/expect.error/expect.stream
assert/ → Assertion engine: answer/json (gjson paths)/sources/tools/limits (incl. TTFT)/stream integrity/error (negative cases)/golden matchers
target/ → Four wire protocols: v1 (/v1/chat/completions, JSON or SSE, inline file parts), stream (/stream SSE, TTFT + frames), answer (/api/answer), webhook (+ /api/task_status polling); attachment upload; ServerError for negative cases
judge/ → LLM-as-judge grading via a second agent (model/temperature passthrough)
pricing/ → Cost table: /api/models pricing (tolerant field reader) + bench.yaml pricing overrides
runner/ → Worker pool, repeat/min_pass, fail-fast, golden record, judge wiring, multi-turn loop, model override, cost stamping, RunMatrix (--matrix)
report/ → Pretty/JSON(schema 2)/JUnit output, A/B compare, --matrix table + JSON, baselines in ~/.docsgpt/bench/<suite>/
manage/
client.go → PAT HTTP client (Bearer dgpt_pat_…, docsgpt-cli/<ver> User-Agent, context timeouts), typed APIError (message, error code, required_scope)
agents.go → /api/user/me, get_agents, export_agent, import_agent/plan + import_agent (Plan, Resolution, ApplyResult), delete_agent
sources.go → /api/sources, /api/upload (multipart, Idempotency-Key, deterministic default key), /api/task_status polling with backoff, delete_old; Ingest = upload → --wait → --replace
catalog.go → get_prompts, get_tools
webhooks.go → agent incoming webhooks: URL parsing/redaction (Webhook), /api/agent_webhook lookup, webhook POST (no Authorization, Idempotency-Key), agent run result decoding
trigger.go → agents trigger flow: webhook resolution, anonymous --wait with same-origin PAT fallback, ReadPayload, UsageError (exit 2)
documents.go → -f expansion: files / directories / stdin, verbatim multi-document YAML splitting, kind: Agent validation
resolve.go → --resolve parsing, mapping onto the server `resolution` object, missing/unavailable gating
host/
config.go → ~/.docsgpt/host.yml (device id, base URL, poll interval, log file; flat YAML, 0600), SpoolDir ~/.docsgpt/host-spool; identity.go: Ed25519 host.key + request signing
daemon.go → RunDaemon: spool lock + Recover, poll/SSE loop, idle heartbeat, auto-update + restart only when Jobs.Idle (holds the Baton in StateRestarting so no session opens), SIGINT/SIGTERM → Jobs.Shutdown (a second signal forces) (revoke → ErrRevoked, exit 0); banner.go startup lines, servicemode.go + console_*.go: Windows task logging to host.log
jobs.go → Jobs: runs each invocation in the background (outlives its SSE session), Cancel (`event: cancel`), Kick (retry now after a reconnect), Shutdown (stop commands as host_shutdown, flush 5 s), Recover (spooled reports; a command cut off by a crash → `interrupted`)
outbox.go → per-invocation outbox: ack, then stdout/stderr/control chunks in seq order, batched, retried 1 s → 60 s with jitter until 2xx; 404/410 or another 4xx (not 401/408/429) drops it; given up 1 h past the command's timeout; 1 MiB of unsent output (oldest dropped, control chunk `truncated`); journal <invocation>.ndjson in the spool, replayed on start, rewritten as it doubles
invocation.go → runCommand: `sh -c` / `cmd /C` in its own session (proc_unix.go Setsid; proc_windows.go taskkill /T /F), timeout or cancel → SIGTERM to the group, SIGKILL after 5 s; the denylist floor
install.go → InstallService / UninstallService over systemd (service.go), launchd (launchd.go), Task Scheduler (wintask.go)
transport.go → Signed polling + SSE session transport (`invocation`, `cancel`, `revoke`, `session_end`; every request sends `X-Device-Capabilities: cancel,outbox`); approval is decided server-side; pairing.go, device.go, revoke.go
context/
enricher.go → the <context> block: cwd, capped listing, AGENTS.md/CLAUDE.md (git root → cwd, 12KB), shell history (opt-in);
with tools: `System:` (OS/arch, sh -c or cmd.exe /C, no terminal),
<tools_guide> and <coding_guide> (toolGuide), last in the block
attach/
attach.go → File (id, path, name, image, size, sha256, ref, clipboard; Marker
`[image #1 · 240 KB]` / `[file #2 · spec.pdf · 1.2 MB]`), Open
(regular, non-empty, ≤100 MB; image by its bytes, a document
by the server's parser extensions, else text-sniffed like the
server), Parts (≤20 files, ≤100 MB in all, sha256, GIF → PNG)
paths.go → Paths (a paste that is only absolute paths of existing files:
shell escapes, quotes, file:// URLs; no backslash escapes on
Windows), Refs (@path / @"a b" naming existing files, trailing
punctuation dropped), Show (@path → marker, for the transcript)
clipboard.go → Clipboard interface + System: macOS osascript JXA (file URLs,
else public.png, else any NSImage → PNG), Linux wl-paste/xclip
(text/uri-list, image/png, JPEG → PNG), Windows PowerShell -STA
(FileDropList, GetImage); text via atotto. Paste saves the image
in $TMPDIR/docsgpt-clipboard
session/
session.go → saved chats: ~/.docsgpt/sessions/<slug>-<hash>/<time>_<id>.jsonl (slug = last ≤48 chars of the cwd, non-[A-Za-z0-9_] runs as `-`; hash = 8 hex of sha256(cwd); 0600, dirs 0700); header + message/state/name lines, each Record one append; List keeps the files whose header cwd is this cwd; Load/Turns (each with its server, key, conversation and index there), Usage, SetName, Delete, Fork
display/
theme.go → semantic palette (pi's OKHSL tones: hex + 256 + 16-color fallbacks,
dark/light; plain under NO_COLOR; tool block backgrounds a step
fainter than pi's, no 16-color value); also sets ui.Colors. InitTheme
runs in the root PersistentPreRun (hidden --theme > config > auto)
renderer.go → StreamRenderer: streamed markdown on a TTY (finished blocks
rendered once into scrollback, the block in progress redrawn
in place while it fits on screen); raw text when not a TTY.
Both strip control sequences first (StripControls, stateful
across chunks; non-TTY too, as piped output often lands on a
terminal). Wait() = "Thinking…" spinner (stderr) until the first visible
token; visible reasoning is a dim italic block of its own
markdown.go → glamour style built from the palette (no margins/fills); top-level
code fences drawn by us (dim ``` lines, 2-space indent, chroma
tokens colored from the palette). Glamour never wraps prose (its
wrapper miscounts hyphens, then re-wraps: orphan words/commas,
quote rows without a bar): goldmark + glamour's ANSI renderer at
width 0 with a `layout` AST pass (soft breaks → spaces, a list
item's later paragraphs on new lines, loose items a blank line
apart), then `rewrap` wraps at spaces only (wrapWords: words
never split unless wider than a row, styles carried per row)
under indent / quote bars / list hang. Unindented tables:
table.go (`renderTable`, pi's layout): parsed by the same
goldmark, each cell's inlines rendered by glamour on one line
(header wrapped in Strong; `<br>` → a line of the cell; links
placed per cell), a dim ┌─┬─┐ box, a ├─┼─┤ rule under the header
and between rows, GFM alignment kept; columns at their widest
cell while it fits, else shrunk toward their longest word
(capped at 30) and wrapped by wrapWords, never under their
widest character; too narrow → the raw markdown. Tables inside
lists/quotes stay glamour's. Copy: a table copies as shown
(copy.go markdownPlain). Links (incl. autolinks, bare
URLs) are marked in the AST (`markLinks`: U+FDD0/FDD1 emoji
nodes around the text, destination → "#" so glamour prints no
URL), then `placeLinks` swaps the markers for OSC 8 (ST) or a
dim ` (url)`, and `relink` makes each wrapped row open/close
its own links
links.go → hyperlinks: setting `hyperlinks` auto|on|off (SetHyperlinks in
the root PersistentPreRun) < DOCSGPT_HYPERLINKS=0|1|auto; auto =
pi's list (kitty, Ghostty, WezTerm, Warp, iTerm2 incl.
LC_TERMINAL over ssh, Windows Terminal, Alacritty, VS Code, Zed;
tmux only when `client_termfeatures` has hyperlinks; screen,
Terminal.app, unknown off). linkURL: http(s)/mailto, printable
ASCII (non-ASCII escaped via net/url), else not a hyperlink.
The model's own sequences are gone (StripControls) and the
markers dropped from its text before parsing
chrome.go → one dim header line (docsgpt · key · host · cwd) for ask; chat:
chatHeader (2-row mark + version, key hints, `Context` files),
ChatFooter (cwd + git branch read from .git/HEAD, worktrees and
detached too); the user-message block, Ago
blocks.go → the chat transcript's ui.Blocks, each rendered at the width and
cached per width (`fit` cuts over-wide rows, continuation rows
indented like the line): Header (banner once + chatHeader),
User (a ui.Prompt), Markdown, Sources, Note/Failure/Done/Text;
Answer (streamed: reasoning dim italic on top when shown, the
finished markdown blocks rendered once via commitPoint, the
last one again per frame; one glamour renderer kept per width);
ToolBlock (title + note, preview, output, ✓/✗ status, on
toolBox rows tinted by the outcome; keeps the output the
model gets, 2000 lines / 50 KB, lines cut to their last
4 KB; a ui.Expander: collapsed the last 5 lines under
`… N earlier lines · ctrl+o to expand`, expanded all kept;
Start (screenTools.Output) shows `running · 3s` under the
output from the first second until Close, redrawn by the
busy spinner's frames)
style.go → Accent/Muted/Dim/Success/Warn helpers, ErrorMsg (stderr)
tool.go → tool blocks on stderr: bold title, status line (✓/✗), TailView
(live last-5-lines region, `running · 3s` under it from the
first second, a 1s timer), DiffPreview for writes. With
colors and stderr a 256/truecolor TTY a block is a toolBox:
full-width rows on colToolBg (pi's Box(1,1): padding row
above/below, one column each side; resets re-apply the bg),
the live tail keeps the bottom padding, and ToolStatus
redraws the on-screen part on colToolOkBg/colToolFailBg
(skipped if the width changed). Titles wrap there (rows are
cut to the width). Plain (indented, no bg) otherwise
safe.go → Safe: control chars as ␛ ␍ ␊, format runes as \uXXXX, so an
escape sequence cannot hide part of a command. Every model/server
string in tool blocks, sources, agents/sources lists, whoami, host
pair/status and bench errors goes through it (cmd's textOrDash for
table cells; --json stays raw).
StripControls: removes ESC/OSC/DCS/C1 sequences whole and other
controls but \n \t, for text read as text (answers, resumed
chats, the user-message block, /export, error messages)
copy.go → how the chat's blocks copy (ui.Plain): joins, markdownPlain,
unwrapped (see chat command, Selection)
sources.go → dim numbered "Sources" block, OSC 8 links (linkURL, http(s)
only; unless hyperlinks are set off), TTY only
background*.go → auto theme: COLORFGBG, else one OSC 11 query (stdout TTY only,
150ms max); the answer also feeds glamour and lipgloss
banner.go → dino banner, top of the chat's transcript, default "once"
ui/ → inline bubbletea prompts: Select (list / inline row that stops at
its ends, filter, key shortcuts), Confirm/ConfirmWith, Input
(mask, validate), Spinner (stderr); TERM/HUP end a prompt with
ui.Signal; Stderr option for prompts drawn while stdout carries
an answer; Prompter (Inline, or a Screen's panel).
screen.go: the chat's full-screen program (see chat command);
selection.go: mouse selection; plain.go: Plain/Plainer,
Unwrap (how lines copy);
clipboard.go: Copy (system clipboard, else OSC 52);
editor.go: its input (editorModel, embedded); keys.go: ttyInput
(keyboard protocol → legacy bytes), NewlineKey; history.go:
prompt history file; fuzzy.go: popup/filter matching;
suspend_*.go: Ctrl+Z; vt_*.go: EnableVT (Windows: escape
processing on stdout/stderr from Execute, left on at exit;
the classic console starts with it off and bubbletea only
turns it on while a program runs); hold_*.go: echo off while ask streams,
DiscardInput
tools/
definitions.go → Tool schemas: run_command (timeout: seconds, ≤1h or --tool-timeout),
read_file (offset/limit; images), edit_file (path, edits[{old_text,
new_text}]), write_file; descriptions name the OS and shell and say
which tool to use for what (the server ignores our system prompt)
args.go → callArgs, read leniently: numbers as strings/floats, edits as an
array, one object, a JSON string, camelCase names, or old_text/
new_text beside path; expandPath (~, a leading @ when the path
without it exists)
approval.go → Session: per chat session / ask run; Handle → docsgpt.ToolResult;
title, approval, execution, status per call, shown and asked
through Session.UI. Approve / Always allow / Always approve (sets
AutoApprove) / Deny / Edit; readReason (when a read asks; home
compared with os.SameFile), writeReason (readReason + .git; asks
even after "Always allow writes", which is offered only for files
it covers), a working_directory with a readReason never auto-
allowed; resolve (symlinks through the existing part), secretNames;
editFile re-reads before writing (changed → error), keeps the mode
edit.go → applyEdits: each old_text matched in the original, exactly, else
normalized (trailing spaces, \r\n, typographic quotes/dashes/
spaces; fuzzyText maps back to the file's bytes); once each, no
overlaps; BOM kept, new_text gets \r\n in a CRLF file
image.go → readImage (PNG/JPEG/GIF/WebP/BMP by their bytes, ≤20 MB, sent as
an image part; the server scales it), readText (edit's input)
allow.go → alwaysKey: what "Always allow" covers, and when it is never offered
(its doc comment is the source of truth for the rules)
executor.go → runCommand (caller's ctx + timeout; own session without a
controlling terminal, whole group killed; Windows: hidden console,
taskkill /T by its System32 path, NoDefaultCurrentDirectoryInExePath=1 so cmd.exe
never runs a program from the working directory), regularFile + readFile (regular files only, chunked,
ctx-aware line ranges), writeFile (creates parents)
ui.go → UI: Open/Lines/Output/Close a block, Choose, Edit; stderrUI (the
default, ask): display's stderr blocks + inline prompts
shell.go → RunShell: the chat's `!cmd` (no approval, no time limit)
procgroup_*.go → own process group / session, whole-tree kill (Unix), taskkill (Windows)
truncate.go → model-bound output: last 2000 lines / 50KB, line/rune safe, with
a note about the dropped head; tailBuffer bounds the capture
update/
update.go → GitHub latest-release lookup, semver comparison, mode constants
apply.go → Asset download, sha256 verify, binary swap + backup, Rollback, host CheckAndApply
stage.go → Staged updates in ~/.docsgpt/staging (download now, apply next launch)
worker.go → Detached background worker (`update --worker`): check + stage
notify.go → Check state in ~/.docsgpt/update_check.json (latest, skip version, latest release notes, whats_new = version an update installed)
notes.go → MarkUpdated (Apply/ApplyStaged) / PendingNotes / ShownNotes, LatestNotes (cache < 1 day, else fetch, else stale cache), TidyNotes (drops GoReleaser's heading, merges, hashes)
restart_*.go → Post-update restart: exec(2) on Unix, exit(3) on Windows
detach_*.go → Platform detach for the worker process
docsgpt-cli with no args, stdin and stdout TTYs → chat; anything else (args, piped
stdin, stdout redirected) → ask. Subcommands win over questions. commandTypo
rejects (exit 2, with a -- <words> hint) a lone bare word that is a prefix or a
2-edit near miss of a command, or such a word followed by one of that command's
subcommands ("agnets list"); a quoted multi-word question or anything after -- is
never checked. Group commands (agents, sources, config, host) reject unknown
subcommands the same way (subcommandArgs). Only --url/--key/--token are global;
--no-stream/--no-context/--no-tools/--auto-approve/--tool-timeout live on the root,
ask and chat, --no-stdin on the root and ask (--timeout is a deprecated alias on ask/chat; --theme/--no-motion
hidden); -c/--continue and -r/--resume on the root and chat open the chat (a TTY
is required; arguments become its first message).
Root SilenceUsage + flag error func: usage errors exit 2 everywhere, runtime errors
print no usage; ui.ErrCancelled exits 1 without a message.
Agent key: --key <name> > DOCSGPT_API_KEY > default_key. login on a TTY: a
filterable picker of stored keys (choose = make default) + "Add a key or token…" →
masked ui.Input validated behind a spinner: dgpt_pat_… → GET /api/user/me
(PAT flow below), else GET /v1/models with the key (no tokens spent; 401/403 =
rejected, 404 = old server, stored unverified) → name prompt (default: slug of the
agent name, next free) → "Make it the default?" unless it is the first key. Piped
stdin (no TTY): key or token on the first line, --name, a new key becomes the
default. A key login also stores the base URL, unless a PAT is stored for another
server (warning instead). With no key, ask/chat on a TTY run the same prompt inline
(chatKey, keys only) and continue; off a TTY: "No API key…" exit 1. logout [name] | --token | --all (picker on a TTY; ui.Confirm, or --yes off a TTY;
--token shadows the global one, so a dgpt_pat_… given to it, = or as the
argument, names the stored token and must match it — tokenSwitch);
removing the default promotes the first remaining key. whoami: active key
(name, redacted, server, agent via /v1/models) + PAT identity; --json = PAT doc only;
neither configured (--json: no PAT) = exit 1, not a usage error.
keys is a hidden alias of the TTY picker (the old add/set/delete flags are gone).
- Unless
--no-stdin, reads stdin to EOF when it is not a character device (a TTY, /dev/null): pipes, sockets, files (readPipedStdin), at most 1 MB (cut with a stderr note). Alone it is the question; with args it is appended as<stdin>…</stdin>. After 1s without data (stdinHint), a TTY stderr showswaiting for piped input… (--no-stdin to skip): ssh without -t, CI runners that keep stdin open andwhile readloops need--no-stdin(or</dev/null) - Loads config from
~/.docsgpt/config.json, resolves API key (Bearer auth, first-run prompt viachatKey) and base URL - Unless
--no-context, prepends the<context>block (see chat) - Sends to
POST {base_url}/v1/chat/completionswith streaming - Handles tool calls (run_command, read_file, edit_file, write_file) with user approval loop; tools are only offered when stdin is a TTY or
--auto-approveis set. Tool UI (titles, approval prompt, command output, status) goes to stderr - stdout not a TTY: stdout carries only the answer, control sequences stripped (no header, sources, clipboard). On a TTY: header, rendered answer, dim
Sourcesblock, first bash/sh block copied to the clipboard (with a dim note)
Errors (every command) go to stderr; a missing question or a bad flag is a usage error (exit 2, flags with a --help pointer); Ctrl+C exits ask with 130; TERM and HUP cancel ask and chat like Ctrl+C (so the deferred terminal restores run) and exit 128 + the signal (143, 129): signalContext cancels with a ui.Signal cause, prompts return ui.Signal (bubbletea's own handler is off: it would submit the prompt on TERM), and exitCodeFor maps it.
One bubbletea program for the whole chat, ui.Screen, on the alternate screen
(like pi's fullscreen mode); runChat runs it on the main goroutine and the chat
logic (loop: -r/-c/header, then Screen.Next → handle) on another, which
drives it through methods that send closures into the program (doMsg): Add,
Clear, Changed (one redraw per ~33ms), Busy/Status (spinner + what Esc/Ctrl+C
cancel), Footer, Select/Input (blocking, in the panel), Mouse, Quit. Not on a TTY
or with TERM=dumb it refuses.
- Layout, bottom up: footer, editor (dim rules; at most 30% of the height), the
panel (a Select or Input, above the editor, which keeps its text but loses its
cursor), the queued messages (
Queued: …a row each, the first 3, then↳ alt+↑ to edit; in a short window onlyN queuedin the status row), one status row (spinnerThinking…/Answering…/Running…·esc to stop; right:↓ N new lines · end to jumpwhen scrolled back), and the transcript filling the rest: the blocks (ui.Block, see display's blocks.go) a blank line apart, top-aligned, only the visible rows sliced into the frame. Every write is one synchronized update (termOutput, CSI ?2026). - Scrolling:
top+follow(at the end, the view follows; scrolled back it stays,leftAtcounts new lines). Wheel via SGR mouse (bubbletea cell motion, the only mode it manages and restores): 1 line per event on macOS (the system accelerates, one event per line, pi's finding), else 3, 1 in bursts under 5ms. PgUp/PgDn (page − 1; a list panel keeps them), Shift+↑/↓ a line, Ctrl+↑/↓ to the previous/nextui.Promptblock (the user's messages), Home/End with an empty editor and no panel, Ctrl+Home/Ctrl+End always. Sending, and a panel opening, return to the end. A resize keeps a scrolled-back view on the same block, at the same share of it (blocks re-render at the new width). - Ctrl+O (pi's app.tools.expand):
expandedtoggles everyui.Expanderblock (ToolBlock; reasoning stays with /think), new ones are added in that state; a scrolled-back view keeps its top line, or goes to the start of the block at its top when that block folds; flashTool output expanded/collapsed. A click (no drag, no link) on one that folds toggles it alone, after the 500ms a second click would take (a double click selects a word instead); its first line keeps its row, or comes to the top from above the view. - Selection (
ui/selection.go, pi's fullscreen behaviour): with the mouse on, a left press in the transcript starts it, drag events (cell motion, 1002) extend it, the release copies it (ui.Copy, in a tea.Cmd) and flashesCopied N charactersin the status row (2s; errors 5s). 2/3 presses within 500ms on the same word: word (letters/digits/_ joined by / and -, . and ' inside) / logical line. Points are transcript (line, col), so it stays on its text while streaming; the top row (reached from below) or under the transcript scrolls a line per 50ms tick while held. Highlight: reverse video, re-set after every SGR, grapheme-aligned (wide chars whole). A click, Esc (before anything else it would do), a resize, Clear or mouse off clear it. A click (no drag) on an OSC 8 link opens it (openURL: open / xdg-open / rundll32; not over SSH) and flashesOpened <url>, as pi does: the terminal cannot while the chat holds the mouse. Copy text:ui.Unwrapover the rows'ui.Plain(Indent = decoration columns left out, Wrap + Sep = a soft wrap and what it took out); blocks implementingui.Plainersupply it, others copy as shown. display finds it incopy.go:joinsmatches each row's text into the block's unwrapped text (a message's source; markdown rendered byunwrappedat its longest paragraph's width + 64, at copy time only, cached per version) — a row that follows the row above across only whitespace is a wrap, with that whitespace as Sep (so hyphen and.breaks and fit-cut code join exactly);markdownPlainadds the code indent (2, between our fences) and quote bars. Tables copy as shown. - Clipboard (
ui.Copy, also /copy and ask): system clipboard (atotto), else or over SSH (SSH_TTY/SSH_CONNECTION/SSH_CLIENT) OSC 52 written through the screen'stermOutput(base64 capped at 100 000 bytes → an error); tmux needsset-clipboard on. - Input: the editor always takes keys (typing while an answer streams); Enter while
the chat is busy queues the message (sent after; Esc/Ctrl+C put the queue back
into the editor; Alt+↑ does too without stopping:
requeue, a blank line apart, before the draft, pastes kept collapsed and renumbered, one undo step). Esc or Ctrl+C with something cancellable running cancels it; idle, Ctrl+C clears, twice within 1s quits, Ctrl+D on empty quits (also while busy). A panel takes the keys only 300ms after it opened, so keys typed just before an approval appeared go to the editor. Ctrl+Z suspends (tea.Exec of ahandoff), Ctrl+G runs $EDITOR the same way, Ctrl+L repaints. - Terminal modes: alt screen, bracketed paste and mouse by bubbletea;
keysOn(kitty flags 1 + modifyOtherKeys 1) written in Init, after the alt screen is up (kitty keeps a flag stack per screen), and after every handoff with the mouse (bubbletea's RestoreTerminal re-enables neither);keysOffin each handoff and after Run. TERM/HUP (own handler) quit the program and come back asui.Signal; akill -INTinterrupts like the key. A panic in the chat goroutine kills the program (terminal restored) and re-panics on the main goroutine. - Exit: after Run, the transcript (only if something was asked or resumed) is
rendered at the final width and printed to the normal screen (not after HUP), then
Continue this chat with: docsgpt-cli -cwhen saved. - Keys of the editor: ttyInput translates
CSI code;mods uandCSI 27;mods;code ~back to legacy bytes (Shift+Enter →\n= ctrl+j, Ctrl+C → 0x03, Esc → ESC, …), holds an unfinished CSI (keys, SGR mouse reports) across reads, passes bracketed pastes through untouched. - Editor: dim rules above and below the text, a dim footer (left
~/dir (branch)and· <name>of a named chat, right↑in ↓out (Σ ↑in ↓out)tokens of the last exchange and the session once known,key · host, then+N command outputs,think on, thenauto-approvein the warning color; it is dim as a whole, so a differently styled item goes last; the left is cut from its start). Enter sends; Shift+Enter / Ctrl+J / Alt+Enter / a trailing\insert a newline; rows wrap after a space (wrapLine: a longer word where it reaches the edge, a paste marker never split, a space may hang into the cursor column); ↑/↓ move by visual row, history at the edges (~/.docsgpt/history, JSON string per line, 0600, 500 entries, entries ≤16KB and not matchingsecretLike; trimming writes a temp file and renames it); bracketed pastes >10 lines or >1000 chars become[paste #N +L lines]markers (one unit: the cursor never rests inside one, any deletion that reaches into one removes it whole; expanded on send, shown collapsed in the transcript); Ctrl+A/E/K/U/W, Alt+←/→; Ctrl+G opens $VISUAL/$EDITOR. Kill ring (killring.go, pi's): Ctrl+K/U/W, Alt+Backspace and Alt+D (Alt+Delete) kill onto it (at a line's edge the line break; a run of kills one entry, backward ones prepended; 100 kept, the pastes and files of killed markers with them), Ctrl+Y yanks the latest (markers renumbered), Alt+Y right after a yank swaps in the one before, round the ring; each one undo step; the ring outlives a send. Attachments:filesbesidepastes(FileMarker, same one-unit rules, in undo states, renumbered by requeue, kept for the $EDITOR result and the draft while browsing history); Ctrl+V runs attach.Paste in a tea.Cmd (clipMsg;clipis swappable for tests), a bracketed paste that is only file paths attaches them (not when the input starts with ! or /), errors go to the status row (notice). Screen.Next returns a ui.Message (Text, Shown, Files); SetInput takes files; PutBack returns an unsent message. Ctrl+- (0x1f = ctrl+_; keys.go maps kitty/modifyOtherKeys Ctrl+- and Ctrl+/ to it) undoes, pi's way: a stack of (lines, cursor, pastes) states, 100 deep, cleared on send; a typed word is one step with the space before it, a run of the same deleting key one step, a history recall one step back to the draft, the $EDITOR result one step; no redo (pi has none). Typing/opens the command popup (prefix then fuzzy matches, under the editor); Tab completes, Enter runs the exact or selected command at once, Esc closes it. - Commands (
chatCommands, one table for popup, /help, dispatch): /new (/clear: the transcript starts over), /resume, /name [title], /edit (also Esc twice on an empty idle input:ScreenOptions.EscEsc), /retry, /good, /bad (see Fork and feedback below), /copy (whole answer, or a pick of its code blocks), /export [file] (markdown, defaultdocsgpt-<date>.md;~/expanded; an existing file only after a confirmation, default No), /think, /approve (toggles the tools Session's AutoApprove; "Always allow" choices are kept), /key (switch or add a key → new conversation), /settings (the config menu in the panel; the mouse setting applies at once), /changelog (latest release notes), /help, /quit (/exit). An unknown/wordis an error;/path/like …is a message.!cmdruns throughtools.RunShell(no approval, no time limit, cancellable) into a tool block and its output is prepended to the next message;!!cmdis not sent. - Sending: a
display.Userblock, thenRunwith the messages +conversation_id; the server then takes the history from the stored conversation, an older one from the messages. Each answer segment is adisplay.Answerblock (a new one after every tool call); tool calls go throughscreenTools(a ToolBlock per call, approvals as an inline Select in the panel). A failed or interrupted turn is dropped (Interrupted.note) together with the conversation id (the server may keep a stopped exchange the history leaves out), and kept asunansweredfor /retry. Attachments (route: inline /v1 content parts — the server stores them as the conversation's attachment rows, deduped by sha256, so a later turn with the conversation id sees them): sent once with their message (re-sent within its tool rounds), then dropped from the history (Parts=nil); the session's user entry getsattachments(paths + hashes, never bytes), Turn.Files; resume shows markers (attach.Show), /edit and /retry re-attach marker files still on disk; a file that cannot go puts the message back (PutBack). Sources follow as a block;RunResult.Usage(summed over tool rounds) goes on the last assistant message'susageand into the footer. - Context: the server treats
systemmessages as a prompt override that agents ignore by default, so ask and chat put a<context>block (cwd, first 50 entries with/on dirs, AGENTS.md or else CLAUDE.md of every dir from the git root down to cwd within 12KB, shell history only withsend_last_commands, no placeholder text; with tools also the system/shell line and the tool and coding guides) before the first user message, and again only when it changes.--no-contextkeeps the tools;--no-toolsdrops them. Images read_file sent are dropped from the history and session after the run (Parts=nil), like attachments. - Sessions (
internal/session): created lazily with the first answer; header (cwd, server, key, conversation_id), thenmessagelines (the sent message,text= what was typed, sources on the last assistant message) andstatelines (server, key, conversation id) when any of them changes; loading takes the latest.-c= latest in cwd,-r//resume= filterable picker (first message · age · count · key). Resuming shows every exchange again under── resumed · 3h ago · N messages ──, restores history, the conversation id and the last context block; a chat of another key switches to it when stored (and no --key/env override), else warns and goes on in a new conversation; another server likewise. /new and /key start a new session.namelines (/name, picker Ctrl+R; "" clears; do not bump Updated) name a chat: footer, picker label, window title. The picker (Screen.SelectAction,Select.Actions) renames (Ctrl+R, an Input) or deletes (Ctrl+D, confirm, never the current chat) and reopens on the same row. - Fork and feedback (
cmd/chat_fork.go,cmd/chat_feedback.go): /edit picks a message (newest first),Session.Fork(i)copies the messages before it into a new unsaved session (headerparent, state lines kept, written with its first record; the old file stays), the history is replayed without a conversation id, the transcript is redrawn with── edited from message N ──, and the text goes into the editor (Screen.SetInput). /retry re-sends the unanswered message, or forks at the last question and sends it. /good and /bad POST/api/feedback(like/dislike,api_key,conversation_id,question_index=Turn.Index, counted per server conversation: a new id restarts at 0). The server accepts any index (200 even when no message is there) and inserts compression summaries as messages, so a very long conversation can be rated off by those; no comment field. - Title and notifications (
ui/terminal.go):Screen.Titlepushes the terminal title (CSI 22;0t), setsdocsgpt · <name or first question> · <dir>(OSC 0, printable only, ≤80), pops it after Run and around Ctrl+Z. Focus reports (tea.WithReportFocus; ttyInput takes CSI I/O out of the input) feedScreen.Notify, which only fires while blurred: OSC 9 (iTerm2), OSC 99 (kitty), OSC 777 (Ghostty, WezTerm, foot, rxvt, VTE), else BEL (also in tmux/screen). The chat notifies on an answer, a failure and an approval, unlessnotifyis off.Screen.TabStatussets iTerm2's session status (OSC 21337: subtitle + dot, others ignore it):workingfrom a send or!cmd,waitingfrom an approval's Choose until it is approved (screenTools.decided) or denied, once (Edit and the question again stay one wait; a cancel is left to the run's clear),done/failed(a failed answer, a!cmdexiting non-zero: RunShell's ok, a message not sent for its files); cleared by a stop, reset, Ctrl+Z (back on resume) and exit.Screen.ClearTabStatusonce at the chat's start clears what a killed chat left. Settingtab_statusauto|on|off: auto skips tmux, screen and TERM=linux; on sends there too (tmux: DCS passthrough; screen still drops it); changing it in /settings re-applies the current status (chatSession.tab). In ask, the approval prompt is a bubbletea program on stderr; it reads keys in raw mode, so Ctrl+C there cancels the run at once.
Modes via settings.auto_update ("on" default / "notify" / "off", config set auto_update); env kill switch DOCSGPT_NO_UPDATE_CHECK.
- On TTY launches,
updateGatein root.go decides the mode (skips dev builds, the update/host commands; Homebrew or unwritable installs downgrade on → notify) - A detached worker (
update --worker) refreshes the release cache daily and, in "on" mode, downloads + sha256-verifies the new binary into ~/.docsgpt/staging - The next launch validates the staged manifest and swaps it in near-instantly; the old binary is kept in ~/.docsgpt/backup for
update --rollback - Rollback records a skip version so auto-update won't reinstall it; a manual
updateclears the skip - Host daemons check every ~12h while idle — no session, no command running, every report delivered — (10 min boot delay), apply directly, then restart: exec(2) on Unix (same PID), exit(3) on Windows (Task Scheduler RestartOnFailure); all shipped service configs restart only on failure since a revoke exits 0
- Everything is stamped release-version-only:
updaterefuses "dev"/git-describe builds and Homebrew-managed binaries - What's new: every check stores the latest release's notes; Apply and ApplyStaged record the installed version, and the next chat on that version shows the notes once (cmd/whatsnew.go)
- Loads a suite dir (default
./bench): optionalbench.yamldefaults + any subdir with acase.yaml - Per case: uploads attachments (waits for the extraction task) or, with
attachments_mode: inline(v1 only), base64-embeds them as content parts; asks the agent through the case's target (v1/stream/answer/webhook), evaluatesexpectassertions - Model selection:
model:(suite/case) or--model→model_idon stream/answer,modelon v1; stamped into every result. NOTE: the server currently ignoresmodel_idfor agent-bound (api_key) requests — honoring it is a docsgpt-cloud change - Multi-turn
turns:—conversation_idcarried on stream/answer, messages replayed on v1; per-turnexpectoptional, caseexpectgrades the last turn; timeout per turn,limits.max_secondswhole case; judge sees the transcript - Assertions: answer text (contains/regex/…), JSON fields via gjson paths, sources count, tool calls, LLM-as-judge rubric (
judge.model/temperatureforwarded, verdicts recorded), latency/TTFT/token limits,expect.streamSSE integrity (stream target),expect.errornegative cases (server error expected; success fails), golden snapshots (bench record) - Repeat/min_pass for flaky LLMs,
--vsA/B,--matrix m1,m2per-model runs + comparison table (JSON keyed results[model][case]),--baseline lastregression diff,--json/--junitfor CI; exit codes 0/1/2 - Cost:
/api/modelsfetched once per base URL (tolerant pricing reader) merged withbench.yaml pricing:;cost_usdper run when usage (v1) and a price for the effective model exist - Webhook target sends
{"question": ...}— the server passes the serialized JSON verbatim as the agent query; no attachments/turns there, approval-gated tools auto-denied - Secrets: YAML values support
${VAR}interpolation (shell env > suite/.env > ./.env,$$escape, comment lines exempt, unset var = load error);--webhook-urlinjects the webhook token without YAML - Hygiene:
--run-tag/run_tag:→X-DocsGPT-Bench-Tag: bench:<tag>+docsgpt-cli/<ver> benchUser-Agent on target and judge requests (uploads/polls carry the User-Agent) - Agent by id:
agent_id:(suite or case; mutually exclusive withagent:at the same level, the case level wins as a whole) or--agent-id→ stream/answer sendagent_idin the body +Authorization: Bearer <PAT>(scopechat:run) instead ofapi_key; attachments upload (and its task polling) and the/api/modelspricing fetch carry the PAT too (pricing retries anonymously on 401/403). The PAT is only ever sent to the origin the user configured (--url, elseDOCSGPT_URL/config;spec.SameOrigin): anagent_idcase whose YAMLbase_urlpoints elsewhere is a case error, and pricing for such a URL is fetched anonymously.v1/webhook+agent_idis a load/run error;agent_idwithout a token is a case error; api_key runs are byte-for-byte unchanged
- A PAT (
dgpt_pat_…) is created in the web app; the CLI only consumes one (/api/user/tokensis closed to tokens). Resolution:--token>DOCSGPT_TOKEN>config.jsontoken; base URL:--url>DOCSGPT_URL> config. Tokens are only ever printed redacted (config.RedactToken, first 15 chars +…) logintakes the token from--token, piped stdin, or the TTY prompt (see Credentials above), validates it withGET /api/user/meand stores it together with the base URL that validated it, whether that came from--url,DOCSGPT_URLor the config (config stays 0600); when agent keys are stored and the server changes, it asks first (off a terminal: exit 2), as saving a key does for the token.whoamiprints user, token name, scopes and resource restrictions;logout --tokenremoves the stored tokeninternal/managesendsAuthorization: Bearer <PAT>; server failures become*manage.APIError—{success:false,message}, 401invalid_token, 403insufficient_scope(+required_scope),resource_not_allowed,not_available_to_tokensagents plan|apply -f: files, directories (*.yaml/*.yml, sorted, not recursive) and-; multi-document files are split textually (the server gets each document verbatim); non-Agentkinds are rejected before any request. ALL documents are planned first (POST /api/import_agent/plan {"yaml"}, scopeagents:write); if any reference ismissing/unavailableand not covered by--resolve, nothing is applied and the exit code is 1. ThenPOST /api/import_agent {"yaml","resolution"}per document, stopping at the first failure.plan=apply --dry-run--resolve <kind>:<selector>=<value>→ serverresolution:source:<name>=<id>→sources[name];tool:<sel>=reuse:<id>|create|skipandtool:<sel>.secret.<field>=<v>→tools["tool-N"] = {decision, tool_id, secrets};model:<display_name>=<api_key>→models[name] = {api_key}.source:…=skip/model:…=skipare CLI-side acknowledgements (the server has no such decision; it just leaves the reference off) and are never sent.<sel>=tool-Nor an unambiguous tool name/type; positional keys are refused across several documents; an entry matching nothing is a usage errorsources upload: multipartuser(legacy, required by the server),name, repeatedfile, with an explicit Content-Length and streamed file bodies. DefaultIdempotency-Key=docsgpt-cli-upload-+ sha256(name + sorted (basename, file sha256)), so CI retries dedupe;--waitpolls/api/task_status(1s → 10s backoff, 503 = transient, progress on stderr) until SUCCESS / FAILURE /--timeout; thededuplicatedtask id sentinel is not polled.--replace(needs--wait) then deletes the caller's older same-named sources (never the new one, never team-shared, never without a reportedsource_id, and never unless that id is in the current listing: a content revert repeats the Idempotency-Key, and the deduplicated reply then names the earlier, already deleted source; the command fails with exit 1 instead of deleting the only live one): the server resolves an agent's source name to the OLDEST match, so without it agents stay pinned to the first upload;agents applymust run afteragents trigger: payload from-f <file|->, validated as JSON (not null) before any request. Target =--webhook-url(elseDOCSGPT_WEBHOOK_URLwhen no agent id is given; no PAT needed) XOR<agent-id>(PAT, scopeagents:keys,GET /api/agent_webhook?id=; the returned token is re-rooted on the configured base URL, since the server builds the URL from itsAPI_URL). The webhook POST never carries the PAT (the server denies tokens on that route).--waitpolls/api/task_statusanonymously (the server does not require auth there) and falls back to the PAT on 401/403 only when the PAT's base URL has the webhook's origin; SUCCESS whose result is notstatus: success(quota_exceeded, idempotency guard) is a failure. The webhook token is never printed (Webhook.Stringredacts; errors pass throughWebhook.Redact)- Exit codes mirror bench: 0 ok, 1 failure/blocked/timeout, 2 usage or validation (
exitErrorin cmd/manage.go, mapped inExecute). These commands print errors to stderr; destructive ones (agents/sources delete,logout,host reset) ask withui.ConfirmviaconfirmDestructive, and refuse without--yesoff a terminal
- CLI sends
toolsarray in request - If model returns
finish_reason: "tool_calls", CLI shows the call's title ($ cmdwith· timeout 10mwhen the model set one,read path,edit path (+N −M)/write path (+N −M)with a short diff) and asks: Approve (a), Always allow (l, only when there is a narrow key), Always approve (p: every later call of the session unasked, i.e. AutoApprove), Deny (d), Edit (e, commands only: prefilled input, then asked again); the muted line under the row describes the highlighted choice. Ctrl+C/Esc at the prompt cancels the whole run - On approve: executes locally (command output in a live 5-line tail, then
✓ exit 0 · 1.2s/✗ …), sends result back asrole: "tool"message - Model continues with tool results — loop repeats until
finish_reason: "stop" - Security stance (like pi): approval is the only gate, there is no command blocklist.
read_fileasks only for files outside the working directory (symlinks resolved), when the working directory is the home directory or above it (compared as files, so case-insensitive spellings count), or for secret-looking names on the way (.env*,.envrc,.netrc,*.pem,*.key,id_*,.ssh,.aws,.docsgpt,*_history, …secretNames); devices, FIFOs and directories are refused. "Always allow" lasts for the session: writes and edits inside the cwd (not secret-looking, not in.git; others always ask), all reads, or later commands with the same key run in the cwd (aworking_directoryoutside it or secret-looking always asks) — the program plus its subcommand word (git status,npm test), the program alone for plain read-only programs (ls,cat,rg) or option-only calls — and each later command is re-checked. Never offered for shell syntax beyond quotes,VAR=valueprefixes, programs given as a path, code runners (shells, wrappers, interpreters,find,make,tar, editors,cmd/powershell/start), risky options (-c,-C,-e,-o,-x,--exec*,--upload-pack,--git-dir, …), risky subcommands (git config,npm exec,docker run), a path argument leaving the cwd (absolute,~,..), or options before the subcommand other than known value-less ones (git --no-pager); the doc comment ofalwaysKeyis the source of truth. Known gap (documented in docs/tools.md): a subcommand key covers its destructive forms (git branch→git branch -D). Commands run without the terminal, so password prompts fail at once.--auto-approve(or "Always approve", or/approvein chat) skips every prompt but still prints each title and status. Host mode (no person at the device) keeps its own denylist ininternal/host/invocation.go
Single file: ~/.docsgpt/config.json
{
"base_url": "https://gptcloud.arc53.com",
"default_key": "my-agent",
"keys": { "my-agent": "abc-123-key" },
"token": "dgpt_pat_…",
"settings": {
"send_current_directory": true,
"send_directory_contents": true,
"send_project_instructions": true,
"send_last_commands": false,
"number_of_last_commands": 3,
"auto_update": "on"
}
}token is optional (written by login, removed by logout, omitted when empty); the file is always written as a 0600 temp file and renamed into place (atomic, never readable by others, tightens an older permissive file). Environment overrides: DOCSGPT_API_KEY, DOCSGPT_TOKEN, DOCSGPT_URL. config get|set <key> use the setting names url (= base_url), default_key and the settings fields; config show redacts keys (config.RedactKey, first/last 4) and the token.
Settings missing from the file keep their defaults (Load decodes over DefaultConfig); send_last_commands defaults to false for new configs only, since saved configs always carry it. Also under ~/.docsgpt: history (chat prompts) and sessions/ (saved chats).
Auto-migrates from old ~/.docsgpt-keys.json + ~/.docsgpt-settings.json on first run.
spf13/cobra— CLI frameworkcharmbracelet/glamour+lipgloss— markdown rendering and styling (our own style, no auto-style query)alecthomas/chroma— code block highlighting, colored from the palettecharmbracelet/bubbletea(v1) — every interactive prompt, and the full-screen chatcharmbracelet/x/ansi,muesli/termenv,mattn/go-isatty,x/term,x/sys— widths and wrapping, color profile, TTY checks, raw mode / echo offatotto/clipboard— clipboard accessrivo/uniseg— grapheme widths for the chat's mouse selectionminio/selfupdate— atomic binary replacement for the update commandgolang.org/x/mod/semver— version comparisongopkg.in/yaml.v3— bench suite/case filestidwall/gjson— JSON path assertions in bench
go build -o /tmp/dg ./cmd/docsgpt-cli && /tmp/dg --help # outside the repo
make build # ./docsgpt-cli (gitignored), version stamped- Module path is
github.com/arc53/DocsGPT-cli(mixed case, matching the repo; the Go proxy escapes it as!docs!g!p!t-cli, which users never type).go.workis committed and spans.and./sdk cmd.Versionis stamped by ldflags for release andmake build;resolveVersionin root.go recovers it fromdebug.ReadBuildInfoforgo installbuilds, which carry no ldflags and would otherwise report "dev" and disable their own update checks- Releases:
.github/workflows/release.yml(dispatch, or a hand-pushedv*tag) → GoReleaser builds linux/darwin/windows (amd64+arm64) archives + checksums.txt with stable asset names, attachesdeployment/install.sh/install.ps1, and commits a Homebrew cask toarc53/homebrew-DocsGPT-cliwithHOMEBREW_TAP_TOKEN(skip_upload: autokeeps prereleases out of brew; the job only runs onarc53/DocsGPT-cli). Cut from Actions → Release → Run workflow (acli/sdkbump input; one run tags the sdk, pushes thego.modpin bump, then tags and releases the CLI in that order) — there is no local release target; seeRELEASING.md - Install script:
docs.ac/install-cliredirects toreleases/latest/download/install.sh, so the live installer is whatever the newest release carries — a fix lands only on the next tag. Both installers verify the archive againstchecksums.txt, then hand off todocsgpt-cli install, which owns the PATH logic for every platform - SSE parsed with stdlib bufio (no external SSE lib), separately in sdk/client.go, bench/target and host/transport
- Shell history (opt-in context): zsh, bash, fish
- Cross-platform: Unix + Windows