donut-cli
SkillDev toolsrelated to donut CLI. Use when working on donut-cli.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the donut-cli skill
What this skill tells your AI
The instructions your AI receives, as published by nerds-odd-e/doughnut in .agents/skills/cli/SKILL.md and read by ahel’s review.
TypeScript CLI for Donut. Lives in cli/.
Structure
cli/
src/ # Production code: `main`/`run`, `nonInteractiveCli`, `interactiveInkSession` (TTY → Ink edge), Ink app, `commands/`
src/commonUIComponents/ # Context-neutral Ink UI reused across stages and the main prompt (borders, guidance lists, y/n, past user block, stage key context)
src/sessionScrollback/ # One Ink `<Static>` session history above the live column; transcript factories + recall answered rows; append context for stages
src/commands/ # Slash-command and subcommand implementations + aggregated help
src/shims/ # Modules referenced only from the esbuild `bundle` script (aliases)
tests/ # Vitest unit tests (*.test.ts)
vitest.config.ts
tsconfig.json
package.json
TypeScript module exports
Keep each module’s public surface small: export only what other modules actually use. Prefer leaving helpers, constants, and types unexported when they are implementation details. Do not add exports “for tests” or “maybe later” — if only tests need a symbol, test through a stable-boundary entry point when possible (unit-testing skill; see Vitest: observable behavior below). Avoid widening the export list when a single import site could instead live next to the code.
Commands
| Task | Command |
|---|---|
| Build bundle | pnpm cli:bundle |
| Run tests | pnpm cli:test |
| Format | pnpm cli:format |
| Lint | pnpm cli:lint |
For dough-test-optimization, measure pnpm cli:test as the complete ordinary
feedback path, including the Python tests. For per-test Vitest durations, use
CURSOR_DEV=true nix develop -c pnpm -C cli exec vitest run --reporter=json;
that narrower profile does not replace verification or timing of the complete
CLI command.
Architecture notes
CLI architecture guidance lives in this rule and in cli/ code layout.
Apply architectural changes when a feature needs them; challenge the fit first.
Session scrollback (interactive) — Past session lines use one Ink <Static> (append-only) above the live column (stage + MainInteractivePrompt). Generic SessionScrollback stays domain-agnostic; shell transcript shapes live in interactiveCliTranscript.tsx; recall “answered” outcomes use recallAnsweredScrollback.tsx; stages append via sessionScrollbackAppendContext.
Interactive TTY boundary — interactiveInkSession.ts only checks TTY, prints the welcome banner, and calls Ink render with injectable stdin/stdout. Domain behavior stays in InteractiveCliApp and commands/.
User-visible slash-command errors — Map failures to assistant text with userVisibleSlashCommandError (see cli/tests/userVisibleSlashCommandError.test.ts). Red past-assistant blocks use pastAssistantErrorBlock.tsx for committed transcript lines.
Stage keyboard routing — SetStageKeyHandlerContext (stageKeyForwardContext.tsx): the shell registers one handler so Esc and other stage keys are handled without competing Ink useInput instances; stages that need it install via context (e.g. AsyncAssistantFetchStage).
Terminal column width (TTY layout)
Do not use JavaScript string .length or UTF-16 code units to measure how wide text is on screen. Terminals use column count: CJK and many emoji render as 2 columns; grapheme clusters (flags, ZWJ families, text + VS16) must be measured as units.
Vitest: observable behavior
Style ("small test" practice — stable boundary, data over mocks, focused assertions, concise makeMe): the unit-testing skill — follow that first.
For interactive behavior, prefer runInteractive (from interactive.js, implemented in interactiveInkSession.ts) with a mock TTY stdin and assert stdout / visible output — the test may not import the module you changed; coverage through the CLI surface is enough. For argv routing (version, help, interactive fallback), use run from run.js (see cli/tests/index.test.ts). E2E vs unit-test layering for slices: dough-slice-planning skill; write unit tests in the "small test" style (unit-testing skill).
Mocking Donut HTTP from unit tests — The Donut backend is an external dependency (unit-testing skill exception). Use vi.spyOn on donut-api controller static methods (e.g. RecallsController.recalling, MemoryTrackerController.showMemoryTracker) and mockResolvedValue with the SDK success shape ({ data: … }, cast as Awaited<ReturnType<typeof Controller.method>> when needed). Build data values that match backend / SDK types with makeMe from donut-test-fixtures/makeMe (e.g. makeMe.aMemoryTracker, makeMe.aNoteRealm, makeMe.aDueMemoryTrackersList) instead of ad hoc object literals — and keep those fixtures concise per unit-testing skill. Do not use http.createServer to fake /api/… for ordinary command behavior. Reserve a real local HTTP server for tests whose subject is transport or error classification (e.g. status codes), not for happy-path recall or token flows.
No fixed-time waits in unit tests — Do not use sleep, setTimeout(…, N) with a duration, or similar wall-clock delays to “let Ink/React catch up.” Prefer driving the real async surface: setImmediate / microtask turns in a loop until an observable condition holds (e.g. frames or stdout contains the expected text), with a turn-count cap and a clear failure message if the condition never becomes true. E2E may still use bounded retries where appropriate; Vitest unit tests under cli/tests/ should stay deterministic without arbitrary milliseconds.
Ink + React + Node (avoid flaky interactive tests)
- Defer
useApp().exit()/ unmount after UI updates: Do not callexit()from the same synchronous turn as a slash command that still has to append transcript state (e.g. “Bye.”)./exitis special-cased inInteractiveCliApp: after the assistant line is committed, auseEffectrunsexit(). In Node,setTimeout(…, 0)can still run beforesetImmediatework used by React/Ink — avoidsetTimeout(…, 0)for this ordering. - Stable
useInputhandler: PassuseCallback(with correct deps) touseInput, not a new inline function every render. Ink’suseInputeffect depends on the handler reference; a new function each render tears down and re-attaches the internal listener and can drop keystrokes under load. ink-testing-library+ stdin:useInputregisters viauseEffect—stdin.writeimmediately afterrender()can race empty listeners. Before real input, wait on an observable (e.g. write a harmless probe key,waitForFramesuntillastFrame()shows it, then undo if needed), or userenderInkWhenCommandLineReadyfromcli/tests/inkTestHelpers.ts(probe key + wait; seeInteractiveCliAppink tests).- Ink test async helpers: Import
waitForFrames,waitForLastFrame, andstripAnsifromcli/tests/inkTestHelpers.tsinstead of duplicating thesetImmediatepoll loop (or ANSI stripping) in each test file. AfterrenderInkWhenCommandLineReady, preferlastStrippedFrame()(current frame, ANSI-stripped),waitForLastFrameToInclude(pattern), andwaitForFramesToInclude(pattern)wherepatternis a substring orRegExp(combined scrollback is ANSI-stripped for matching). UsewaitForFrames/ rawframes.join('\n')when the assertion must see SGR sequences (e.g.\x1b[100m) that stripping would remove. <Static>scrollback: Session history is rendered inside Ink<Static>; capturedframescan repeat the same scrollback text every frame. For “appears once on screen” or row budgets, preferlastFrame()/lastStrippedFrame(), not counting substrings acrossframes.join('\n').- Typing simulations: Do not use
setImmediateper character as “Ink is ready.” Wait until the frame shows the expected buffer (or combinedframestext) before the nextstdin.write.
Domain terminology
Vocabulary for Cucumber steps and page objects (e2e_test/start/pageObjects/cli/). Exact TTY behavior (past messages, user input history, cursor, rendering) lives in code + Vitest; scenario-shaped coverage in e2e_test/features/cli/*.feature.
| Term | Definition |
|---|---|
| Non-interactive output | Full stdout for E2E subcommand spawns (e.g. installed version / update) and similar one-shot runs; no PTY interactive input-ready control sequence. |
| Past CLI assistant messages | Interactive: past CLI output blocks in the session scrollback (shell assistant lines, errors, session summaries, and recall answered lines such as Correct! / Reviewed: — the latter as RecallAnsweredItem, not duplicate onSettled assistant text). Gherkin: in past CLI assistant messages. |
| Past user messages | Interactive: past user lines as gray-background blocks (\x1b[100m…), one blank padded row above the text (E2E checks this); one padded row below before the command line (see Vitest pastUserMessageBlock / InteractiveCliApp.test). Gherkin: in past user messages. Recall y/n confirmations (stop recall, load more, just-review; prompt footer may show (y/n) or (Y/n) / (y/N) when Enter commits a default) do not add a separate past user message row — only the outcome lines appear. On Load more from next 3 days?, Esc declines load more (same outcome as n, session summary), not the card-level leave-recall confirm. |
| User input history | TTY: committed lines for ↑↓ recall + persistence (mainInteractivePrompt/history.ts, shared store inputHistory/) while the command-line Ink region has focus. The live command buffer is single-line (newlines from paste become spaces; no Shift+Enter newline). Masked before storage/display. Recall y/n answers are not appended (same rule as past user messages). |
| Current Stage | Conceptual state during a multi-step or long-running command (e.g. recall session, slow interactive network call). |
| Current Stage Indicator | On the TTY, when a stage is surfaced: the first line of the Current prompt block — full terminal width on the Current stage band (e.g. “Recalling” while in recall). Not part of Current guidance. |
| Current stage band | Shared background for the Current Stage Indicator line and, when the indicator is shown, the Current prompt separator under it, so the top of the block reads as one strip. Implemented as CURRENT_STAGE_BAND_BACKGROUND_SGR in cli/src/renderer.ts. |
| Current prompt | Block above the command line (live typing strip): optional Current Stage Indicator + separator (banded when the indicator is shown), then wrapped lines (MCQ stem and notebook line, fetch-wait prompt, y/n text, token-list copy, etc.). Recall MCQ (TTY): numbered choices live in Current guidance, not here. |
| Current guidance | Below the command line: / hints, token lists, MCQ numbered choices (wrapped to terminal width; ↑↓ selects by choice index). |
CLI E2E
Features: e2e_test/features/cli/. Steps: e2e_test/step_definitions/cli.ts (thin glue only). Page objects and terminal assertions: e2e_test/start/pageObjects/cli/, especially outputAssertions.ts — put new “what appears in the terminal?” checks there (retries, ANSI-stripped snapshot text on failure, screenshot on the final throw path).
- Run: Cypress Node tasks spawn
cli/dist/donut-cli.bundle.mjsvianode(same locally and in GitHub Actions). Before spawn,ensureCliBundleFreshrebuilds the bundle whencli/src,cli/package.json,cli/tsconfig.json, orpackages/donut-api/srcare newer than the bundle. SetDONUT_CLI_E2E_USE_TSX=1to forcepnpm -C cli exec tsx src/index.tsfor debugging. Installation scenarios use the E2E install bundle path (see@bundleCliE2eInstall). @bundleCliE2eInstall: Buildscli/dist/e2e-install-donut-cli.bundle.mjsbefore each scenario and removes it after; the local LB (scripts/local-lb.mjs) serves/doughnut-cli-latest/doughnutfrom that file when present so install tests do not overwritecli/dist/donut-cli.bundle.mjs.- Active CLI E2E (CI):
e2e_test/features/cli/cli_install_and_run.featurenon-ignored scenarios only.installCliruns the install script. Non-interactive steps userunInstalledCli(node <installed binary> …in a managed PTY, same geometry and env merge as interactive; waits for exit code 0) andcli.nonInteractiveOutput().expectContains→cliAssertwithstrippedTranscript(nonInteractiveCliOutputAssertRequestinoutputAssertions.ts). The Install and run the CLI in interactive mode scenario usesrunInstalledCliInteractive,cliInteractiveWriteLinefor slash input, and transcript assertionsinteractiveCli().pastCliAssistantMessages().expectContains/pastUserMessages().expectDisplayed(twocliAssertrequests: full-buffer gray-block rules, then stripped-transcript blank-line-above). Assertions run in the plugin viacliAssert→tty-assertmanaged session, not browser-side buffer polling. On assert failure, the plugin saves a viewport PNG and, whentty-asserthas recorded at least two distinct viewport frames, an animated GIF (buildViewportAnimationGif), under the current spec folder viasaveBufferToCurrentSpecFolder(e2e_test/config/cliE2ePluginTasks.ts,cypressSpecScreenshotSink.ts).
Build output
- Bundle:
cli/dist/donut-cli.bundle.mjs(shebang) - Release:
gs://dough-frontend-01/doughnut-cli-latest/doughnut(cli-release.yml) - Local install URL: local LB serves
/doughnut-cli-latest/doughnut(seescripts/local-lb.mjs,docs/gcp/prod_env.md)
Ink + esbuild (react-devtools-core): Ink may load devtools.js, which imports react-devtools-core. That package is optional in Ink (used when DEV=true; see Ink’s README). Esbuild still resolves the import when producing the single-file bundle, so cli/package.json bundle aliases react-devtools-core to cli/src/shims/react-devtools-core-stub.ts. The shipped bundle therefore does not depend on installing react-devtools-core. For React DevTools against an unbundled run (e.g. pnpm -C cli exec tsx src/index.ts), install react-devtools-core and use DEV=true as Ink documents.
Install scripts
- Bash:
backend/src/main/resources/install.sh - PowerShell:
backend/src/main/resources/install.ps1 - Served at
/install(InstallController;?win32=truefor PowerShell)
Signals
- GitHub stars
- 49
- Forks
- 72
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
cli-nerds-odd-e- Source
- github.com/nerds-odd-e/doughnut