The dotkit command line
Design, not yet implemented
This page is the approved design (issue #3). Stream B (#5) implements it; until then the commands below do not exist. When they do, this page becomes their reference. To see it move, play the mock: node spikes/cli-mock/dotkit.mjs tour.
Principles
- Plain and calm. Say what happens to the person's app, not which tool does it. OpenTofu, Dagger and cloud vocabulary appear only under
--verbose. No emoji. - Every prompt has a flag.
--yesnever prompts. An agent can do anything a person can. - Two audiences, two channels. Humans read stderr;
--jsonputs one document on stdout. - Motion only where a person watches. No header, animation or colour when piped, in CI or with
--json.NO_COLORkeeps the static header without colour;DOTKIT_REDUCED_MOTIONkeeps header and colour without animation.
| say | not |
|---|---|
Starting the local cloud | Bootstrapping MiniStack emulator container |
Your app: http://localhost:4566/_alb/shop-web/ | Outputs: web_runtime_url = "http://..." |
Docker is not reachable, so the local cloud cannot start. | Cannot connect to the Docker daemon at unix:///var/run/docker.sock |
Header
init and up open with the gear, drawn in braille from the logo's geometry in the terminal's own text colour, so it reads on light and dark themes. init animates it once (about 2 s: the gear spins in, the teeth land, the centre dot turns green); up shows the final frame at once.
⢠⣶⣶⡄
⣾⣿⣷⣿⣿⣿⣿⣾⣿⣷
⣀⣽⣿⠏⠁ ⠈⠹⣿⣯⣀ DOTkit
⢸⣿⣿⣿ ⣿⣿ ⣿⣿⣿⡇
⠉⣻⣿⣆⡀ ⢀⣰⣿⣟⠉ is an Ops Toolkit
⢿⣿⡿⣿⣿⣿⣿⢿⣿⡿
⠘⠿⠿⠃DOT bold, kit and the tagline dim. In a terminal under 9 rows, the gear gives way to one line, DOTkit · is an Ops Toolkit. The reference for #5 is spikes/cli-mock/: the gear geometry and braille raster in gear.mjs, colours and spinner in terminal.mjs, ported into packages/cli/src/ui/.
Colour
The palette of the site and the logo animation; everything else is the terminal's own colours.
| role | where | truecolor | 256 colours | 16 colours |
|---|---|---|---|---|
| verified | ● done, the gear's centre dot | #34da95 | 42 | green |
| in motion | the spinner, flowing green to indigo | #34da95 → #3e63dd | 42 → 63 | green |
| failure | ✖ and the failed step's name | the terminal's red | red | red |
| ink, hints | text; durations and hints dimmed | the terminal's foreground |
Detected once per run: COLORTERM=truecolor|24bit → truecolor, a TERM with 256color → 256, otherwise 16. NO_COLOR, --json, a pipe or CI=true → no colour at all; every state still reads from its glyph (●, ✖, the spinner) and its words.
dotkit init
Detection first, then only real choices. Provider and target are shown, not asked, while each has a single option.
┌ Set up shop for DOTkit
│
◇ Found in this folder
│ Dockerfile port 3000 (EXPOSE) → build this image
│ package.json name "shop" → app name
│ score.yaml none → will be created
│
◆ App name
│ shop
│
◆ Image
│ ● Build ./Dockerfile
│ ○ Use an image from a registry
│
◆ Components (space to select, enter to confirm)
│ ◼ openobserve logs and traces, with a search UI
│
◆ Wire web to openobserve? Yes / No
│ + OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS
│
◇ Runs on
│ local: MiniStack on your Docker, the same code path as AWS serverless
│ Other environments later: dotkit env add
│
◇ Wrote score.yaml
│
└ Next: dotkit up
Same without questions: dotkit init --name shop --image . --yes && dotkit add openobserve --wire| found | read | drives |
|---|---|---|
Dockerfile | the first EXPOSE | image . (build) and service.ports target port |
package.json | name | app name |
pyproject.toml | [project] name | app name |
go.mod | last segment of module | app name |
score.yaml | exists | kept as is; the wizard only offers components it does not have |
| nothing | the folder name | app name; image defaults to "from a registry", and a fourth question asks the port (--port) |
Detection reads files only; it never runs the person's code or package manager. Flags: --name, --image <ref|.>, --port <n>, --provider, --target, --yes; components through dotkit add <component>, the one way to add one, now or later: picking one in the wizard runs the same flow, wiring question included. With --yes, an answer that is neither detected nor given fails with exit 2 naming its flag: no guessing. dotkit.yaml is written only when an answer differs from the defaults.
dotkit add
A component's manifest lists the lines that wire an app to it (contract §3, defaults); add shows them and asks before touching your container.
$ dotkit add openobserve --as obs
◆ Wire web to openobserve? Yes / No
│ + OTEL_EXPORTER_OTLP_ENDPOINT: ${resources.obs.otlp_endpoint}
│ + OTEL_EXPORTER_OTLP_HEADERS: 'Authorization=Basic ${resources.obs.auth_token}'
│
◇ Wrote score.yaml: resources.obs, and the two lines in web
│
└ Next: dotkit upNothing is written until every question is answered. Flags: --as <key> names the resource (default: the component's name; this page uses obs, as the contract's example does, which gives the obs_* output names); --wire (the answer --yes gives) or --no-wire; --container <name> when the app has more than one container, otherwise add asks which. A key that already exists is left as is, and add only offers the wiring if it is missing. Unwired, the component runs but your app's logs and traces do not reach it, and up says so in its results.
dotkit up
A numbered list. The running step has a spinner (a braille ratchet, ⣾⣷⣯⣟⡿⢿⣻⣽, its colour flowing green to indigo), a live phrase, and its elapsed time; a finished step collapses to a green ●, its name and duration. Deploy adds a bar fed by the plan's resource count.
shop · local · aws/serverless
● 1 Prepare secrets 0.1s
● 2 Generate the infrastructure 0.4s
● 3 Start the local cloud 3.2s
● 4 Prepare state storage 0.6s
● 5 Prepare the deployer 18s
● 6 Build your image 42s
⣟ 7 Deploy ████████████░░░░░░░░ 7/12 creating the load balan… 1m 04s ● 7 Deploy 1m 51s
Your app http://localhost:4566/_alb/shop-web/
openobserve http://localhost:4566/_alb/shop-obs/
sign in as root@dotkit.local, password: obs root_password in .dotkit/local/secrets.json
your app's logs and traces land here
Ready in 2m 58s. Changes: dotkit plan Stop: dotkit down- Step 3 only for an emulator environment; step 6 only when an image is
.. A skipped step is not shown and the numbers stay consecutive, so a cloud environment without a build runs 1-5. --envmay be left out when there is one environment (with nodotkit.yaml:local).- The live phrase comes from OpenTofu's progress lines, mapped to words by the provider package (resource type to "creating the load balancer"), so the CLI stays provider-neutral.
- The live line never wraps, because a wrapped line cannot be redrawn in place. It is cut to the terminal width in this order: the phrase is shortened (
creating the load balan…), then dropped, then the bar goes, then the step's name is shortened; the spinner, the step number, the count and the time always stay. The layout is chosen once per step, so it never jumps. - Without motion (
NO_COLOR,DOTKIT_REDUCED_MOTION), a step prints○ 7 Deploy startedwhen it starts, then its finished line: nothing redraws, and a long step still shows it is running. --verbose: the raw OpenTofu and Dagger lines, indented under their step.- No TTY or
CI=true:[7/7] Deploy ... done (1m 51s), one line per step, then the results. - After a failed run, the next command starts with one line about it:
Last run: up failed at Deploy (11/12) at 14:32 · log: .dotkit/local/runs/…-up.log.
dotkit plan
shop · local · aws/serverless
● 1-5 Prepared 19s
● 6 Build your image 4s
● 7 Compare with what runs 21s
3 to add · 1 to change · 0 to destroy
web 1 to change new image
openobserve 3 to add storage, two secrets
Nothing changes until: dotkit up Details: dotkit plan --verboseGrouped by component; shared pieces (network, registry) appear as shared when they change. With an image ., plan builds it too (from cache when nothing changed; nothing is pushed) so it can compare digests: new image means your code changed. How the digest is known without a push is #9's to settle.
dotkit down and dotkit output
This removes what dotkit created in local: 12 resources (web, openobserve).
Kept: your files, .dotkit/local/secrets.json, the state history.
◆ Remove them? No / Yes
● Remove 12/12 46s
Removed. Start again: dotkit up Forget everything: dotkit purge --env localWithout a TTY, every command that would ask (init, add, down, unlock) needs its flag or --yes, or it stops with exit 2 naming the flag (contract §8).
url http://localhost:4566/_alb/shop-web/
obs_ui_url http://localhost:4566/_alb/shop-obs/
obs_otlp_endpoint http://localhost:4566/_alb/shop-obs/api/default
obs_auth_token •••••••• dotkit output obs_auth_token --show-secretsNames are the root outputs of the generated code (contract §3b): url for the app, then <resource>_<output> for each component. The OpenObserve login is not an output: the email is the root_email input, the password the generated root_password in .dotkit/<env>/secrets.json.
Failures and run records
A failure is never just "run it again": it says what changed before it, so the person (or the agent) knows whether a rerun is safe.
✖ 7 Deploy 11/12 6m 12s
web did not become healthy in time. Its last log lines:
│ Error: Cannot find module '/app/server.js'
│ Node.js v24.14.1
Changed before the failure: 11 of 12 resources created and kept; nothing deleted.
web's service is marked for replacement: the next up recreates it.
Next: fix the start command in your Dockerfile, then dotkit up (it rebuilds the image)
Full log, with web's last 50 lines: .dotkit/local/runs/2026-10-06T12-32-08-up.log| situation | "changed" says | next |
|---|---|---|
failed before Deploy (Docker down, invalid score.yaml, lock held) | nothing in the environment changed | fix the cause, rerun |
| failed during Deploy | what was created, changed or deleted, from the state | fix, up continues; plan shows what remains; down undoes |
| a run was killed and left the state locked | nothing; the lock's id, operation and time | emulator envs: dotkit unlock --env local; cloud envs: not in v2, the error says why |
| a contract rule refused the input | nothing | the file and key to change, or the contract's exception rule |
✖ 2 Generate the infrastructure
score.yaml: containers.web.volumes is not supported (contract §2, accepted Score features).
Changed: nothing. Next: remove the key, or propose a change (exception rule):
dotkit.dev/contract/component-contractRun records. generate, plan, up, down and unlock write .dotkit/<env>/runs/<UTC time>-<command>.log (everything --verbose shows) and a .json (the --json document plus what changed), appended as the run goes, so a crash, a kill or Ctrl-C still leaves them; the last 20 are kept and purge removes them. When a deploy fails, the record also keeps the last 50 log lines of each unhealthy workload, when the platform kept them; otherwise the failure says so and names where to look (the emulator's container, or the provider's console). No secret ever reaches them: the writer masks every secret the CLI holds (the values of secrets.json, the state passphrase, your provider credentials, the registry login, the sensitive root outputs such as obs_auth_token), each also base64- and URL-encoded, whatever its length; files are mode 600. Values the CLI never holds are printed by OpenTofu as (sensitive value). The same mask applies wherever the CLI shows a workload's lines: the record, the failure block on stderr and --json. The one gap: an app that logs a secret the CLI does not hold. Conformance step 1 checks the record against the same set (contract §5.7, §7). unlock works in emulator environments, where the state and its lock live on your machine; it shows the lock and frees it after you confirm.
--json and CI
With --json, stdout carries exactly one JSON document when the command ends; everything for humans goes to stderr (plain lines, no header, no animation). Exit code 0 success, 1 a step failed, 2 invalid input (files, flags).
{
"command": "up",
"ok": true,
"environment": {
"name": "local",
"provider": "aws",
"target": "serverless",
"region": "us-east-1",
"emulator": "ministack",
"emulatorEndpoint": "http://localhost:4566",
"workloadEndpoint": "http://host.docker.internal:4566"
},
"steps": [
{ "id": "secrets", "status": "done", "seconds": 0.1 },
{ "id": "generate", "status": "done", "seconds": 0.4 },
{ "id": "deploy", "status": "done", "seconds": 111, "resources": 12 }
],
"plan": { "add": 12, "change": 0, "destroy": 0 },
"outputs": {
"url": "http://localhost:4566/_alb/shop-web/",
"obs_ui_url": "http://localhost:4566/_alb/shop-obs/",
"obs_auth_token": { "sensitive": true }
},
"errors": []
}Step ids: secrets, generate, emulator, bootstrap, init, build, deploy (up); compare (plan); remove (down); unlock. The example is shortened: steps lists every step run and outputs every root output. A failed step has "status": "failed", and errors holds { "step", "cause", "fix", "changed", "log" } plus "contract": "§2" when a contract rule decided it; changed is { "created", "changed", "deleted" } counts from the state. Sensitive outputs never carry a value in JSON; dotkit output <name> --show-secrets --json is the only way to read one, and it warns.
| context | header | animation | colour | output |
|---|---|---|---|---|
| interactive terminal | yes (init animates) | yes | yes | stderr |
piped, or CI=true | no | no | no | plain lines, stderr |
--json | no | no | no | one document, stdout |
NO_COLOR | static | no | no | stderr |
DOTKIT_REDUCED_MOTION | static | no | yes | stderr |

