Repository navigation
Running Nanotune end to end in CI, scripts, and Docker without a TTY #118
will-lamerton
announced in
Articles
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Built by the Nano Collective, a community collective building AI tooling not for profit, but for the community.
The headline of the v1.6.0 release is that Nanotune works without a terminal. This article is the long version: what the new shape of the CLI looks like under the hood, how to chain commands the way you would chain any other tool from CI, and where the boundaries are between commands that finish on their own and commands that genuinely need a keyboard.
If you have ever piped
nanotune statusintotee run.logand watched a 40-line React reconciler stack trace land in your log file, this is the post that explains why that happened, why it does not happen any more, and how to write pipelines against the new shape.Why every command used to crash without a TTY
Nanotune is built on Ink, a React-based TUI library. Ink enables raw mode on
process.stdinas soon as anything in the component tree callsuseInput. We useduseInputto wire up "press any key to exit" frames and help bars across the TUI, which sounds harmless until you realise that raw mode is enabled the moment the React tree mounts, not the moment the user presses a key.If
stdinis not a TTY, Ink throws:...and then React tries to render a frame anyway, which is where the stack trace comes from. The throw is fast, the stack trace is long, and exit code is 1, so every command looked broken from the outside, including read-only commands that never needed a keypress in the first place.
nanotune status,nanotune data validate, andnanotune judge testall paid the same tax.The fix lives in
src/lib/tty.ts. The two helpers are deliberately tiny so they are easy to test and impossible to misread:The strict
=== truecheck matters. Node leavesisTTYasundefined(notfalse) on non-TTY streams, so a truthy-but-not-true value would silently let raw mode slip through and reintroduce the crash. The unit test for that case is insrc/lib/tty.spec.tsand is the guard we rely on.The split: render-and-exit vs interactive-only
Every Nanotune command now classifies into one of two buckets, and the bucket determines the behaviour under non-interactive conditions. There is no third mode, and commands do not silently degrade in surprising ways.
Render-and-exit commands
status,data validate,train,export,benchmark, andjudge testrender their final output and exit on their own when there is no keyboard. The plumbing for that is two new components:useKeyInput(handler, isActive = true)is a drop-in for Ink'suseInputthat passesisActive: falsewheneversupportsRawMode()returns false. Ink never tries to enable raw mode, so there is nothing to throw.useAutoExit(done, failed)calls Ink'sexit()as soon asdoneis true and there is no keyboard to wait on. Iffailedis true, it setsprocess.exitCode = 1first, so shell chains behave correctly.<ExitHint>is a small component that renders the "Press any key to exit" line only when there is a keyboard to act on. Without one it returnsnull, so captured output is clean.Concretely,
useAutoExit(true, !hasConfig)instatus.tsxanduseAutoExit(status === 'done' || status === 'error', status === 'error')indata/import.tsxare the only places that decide whether to callexit(). The status command exits as soon as it has rendered the report. The import command exits as soon as the import resolves. Both surface a real exit code on failure.Interactive-only commands
init,data add,data list,chat, andjudge configuregenuinely need a keyboard: they prompt, paginate, or run a REPL. There is no honest way to run them without a TTY. v1.6.0 makes that explicit insrc/cli.tsx:If a script accidentally runs
nanotune chatin CI, it now prints one clear sentence and exits 1 instead of crashing:Two consequences worth noting. First, the message is the same wording across every interactive-only command, because it comes from the same helper. Second, the non-zero exit code means a CI job that accidentally invokes one of these fails the step instead of carrying on as if it succeeded. That is the right failure mode for a misuse, and it is the change that makes the split safe to lean on from automation.
The new
--yesflag ondata importdata importis the one command in the render-and-exit bucket that, until 1.6.0, needed a keystroke to confirm. It is now flagged explicitly:Three things to take away from the snippet:
useEffectracing the prompt.interactiveRequiredMessagehelper used elsewhere, with one extra line that points the user at the flag they need.yesis true (statusstarts at'importing'rather than'preview'), so the rendered output is the same on a TTY as it is in a pipe.--yesis a deliberately small flag. It does not change what gets imported. It does not skip validation. It only skips the y/n prompt. If you want the import to be re-validated bynanotune data validateafterwards, you still have to run it.A full non-interactive pipeline
The simplest useful pipeline is the one the changelog points at: validate, train, export. With the new exit-code semantics on render-and-exit commands, the chain is just
&&:In CI that might look like:
A few notes on why each step is shaped that way:
data import --yesis the only data command that would otherwise need a keystroke. The other data commands (add,list) are interactive-only and should not appear in a non-interactive pipeline.data validateexits non-zero on duplicate examples, broken alternation, missing fields, or under the minimum example count, so the&&chain breaks early and CI fails loudly. This was the practical reason to fix exit codes in 1.6.0; before,data validatewould have crashed with a stack trace before reporting any problems.train --iterations 200overrides the configured iteration count at the CLI, which keeps the pipeline reproducible regardless of what is inconfig.jsonon the runner.export -q q8_0produces an 8-bit GGUF. The pre-built llama.cpp binaries are downloaded automatically by Nanotune, so the runner does not need a compiler toolchain.benchmark --preset medium --timeout 60000selects the laptop preset (8 threads, 20 GPU layers, 4096 ctx, 256 max tokens) and gives each test a 60-second ceiling, which avoids a slow generation holding the step open. Benchmark reports land inbenchmarks/as both JSON and Markdown.You can drop
data validateif you trust the input, but it is cheap and gives you a precise error if the JSONL drifts. The validation report covers duplicates, broken turn alternation, missing fields, context-message consistency, and minimum example count.Docker without
-tThe classic Docker failure mode was:
In 1.6.0 the same command prints a clean status report and exits. A working Dockerfile looks like:
Then:
Notice there is no
-tflag ondocker run. There is noscriptwrapper. There is nounbuffer. The container does not need an interactive PTY because every command in the chain is a render-and-exit command that finishes on its own.If you accidentally include
nanotune chatin the Dockerfile'sCMD, you will get the single-sentenceinteractiveRequiredMessageand a non-zero exit code. That is the signal to fix the command, not a bug to work around with-t.cron and systemd timers
cron jobs and systemd timers are the other common non-interactive context. They are simpler than CI because there is no YAML layer, just shell:
Things worth checking on cron specifically:
crondoes not allocate a TTY, soprocess.stdin.isTTYisundefined. Render-and-exit commands detect that and exit cleanly.MAILTO=""in the crontab prevents the cron daemon from trying to email the output to the user. With render-and-exit commands the output is meaningful, so the right place for it is a log file (>> /var/log/nanotune.log 2>&1).nanotune train. That was the behaviour under 1.5.x too, but it now works because the command exits on its own instead of hanging on a "press any key" frame.For systemd, the equivalent
Serviceblock looks like:StandardOutput=append:writes to a log file without truncating, which keeps history.StandardError=inheritlets systemd route stderr to the journal alongside stdout.Things that are deliberately still interactive
It is worth being explicit about what v1.6.0 did not change, because the boundary is part of the design.
nanotune initwalks you through creating ananotune.config.json, including the project name, base model, training defaults, and thecontextMessagerole. That is a guided setup, not a batch operation. Running it in CI would just produce a half-configured stub.nanotune data addbuilds examples turn by turn, with a "Add another turn?" prompt and an Esc-to-save escape hatch. The whole flow is a REPL-shaped conversation. We have not shipped a flag that lets you pipe in examples one per line, because the multi-turn shape is hard to express without a real prompt.nanotune chatis the inference REPL. It uses Ink to render streaming tokens and reads slash commands (/reset,/system,/stats,/help,/exit) from the keyboard. There is no scripted equivalent on purpose; if you want a non-interactive smoke test of an exported model, usenanotune benchmarkwith a tiny dataset instead.nanotune data listis interactive because it paginates a potentially long examples table and lets you delete rows by index. If you need a non-interactive listing,cat data/train.jsonlis the right tool.nanotune judge configurewrites ajudge.jsonthat can hold a literal API key, and it walks you through provider and model selection. The key file is now written with0600permissions (also new in 1.6.0), but the configuration flow itself is still interactive on purpose.What this means for contributors
If you are adding a new command or touching an existing one, there are two rules that keep the non-interactive story intact.
useInputdirectly. UseuseKeyInputfromsrc/components/index.ts. It wraps Ink'suseInputwith asupportsRawMode()guard, so the crash cannot recur by accident.useAutoExit(done, failed)and render their keyboard hints through<ExitHint>, both of which no-op when there is no keyboard. Interactive-only commands should be wired through therenderInteractivehelper insrc/cli.tsx, which prints the standard message and exits 1 on a non-TTY.If a command is genuinely render-and-exit but you forget the
useAutoExitcall, the command will hang forever on the "Press any key to exit" frame in CI, which is the symptom to look for in a code review. If a command is interactive-only but you accidentally callrender(node)directly, the test that fails first is the one insrc/lib/tty.spec.tsthat asserts the message wording is plain text (noatand at most three lines).The other internal change worth knowing:
c8's coverageexcludepreviously pointed atsource/app/App.tsx, a path that has never existed, which let spec files count as covered source and inflated the badge to 88.62%. The new exclude issrc/**/*.spec.ts{,x}anddist/**, which moves the reported figure to a real 78.97%. That is not a bug, but it is the kind of number that surprises people on first read, so: yes, the coverage number went down. The tests did not.Putting it together
The shape v1.6.0 leaves you with is roughly: anything that can run without a keyboard runs without a keyboard and exits with a real code; anything that genuinely cannot do that fails fast with a clear message and a non-zero exit. The pipeline you would write against any normal Unix tool is the pipeline you can now write against Nanotune, with
data import --yesas the only flag you need to remember.If you hit a command that does not behave the way you expect under CI, the place to start is
supportsRawMode()insrc/lib/tty.ts. That is the single source of truth for the TTY check, and it is the function that decides between render-and-exit and interactive-only in every command. If a future command needs to land in the render-and-exit bucket, it should calluseAutoExitand render hints through<ExitHint>. If it needs to land in the interactive-only bucket, it should go throughrenderInteractivein the CLI layer.Source, issues, and the changelog live at github.com/Nano-Collective/nanotune. If something fails under CI in a way the rules above do not cover, open an issue with the command, the output, and the CI environment, and we will work through it.
All reactions