Reaktor CLI — one command line for every reaktor app
Start with Reaktor Build Tools for the plain map of how Dependeasy, Reaktor CLI, and the current toolchain fit together. This page goes deeper into the CLI implementation.
Every modern framework ships a command line — cargo for Rust, flutter for Flutter, next for Next.js, ng for Angular, dotnet, rails. It is how you scaffold an app, run it, build it, deploy it, and inspect it, and it is most developers' first ten minutes with the framework. reaktor needs the same: reaktor, a CLI that ships with the framework and works on any reaktor app — yours, bestbuds, or one a stranger builds next year.
That framing is the whole point of this revision. The CLI is not a runner for bestbuds' package.json. It is a framework tool whose built-in commands (new, build, dev, deploy, test, graph, doctor) operate on whatever reaktor project you are standing in by reading that project's declared targets and tasks — never a hardcoded service list. bestbuds is simply the first consumer and a useful stress test (a real project with ~70 ad-hoc scripts); its migration is the worked example in §10, not the design.
It is built in Kotlin with Clikt + Mordant, and it installs itself onto your PATH so reaktor is available from any terminal. The rest of this document is the concrete design.
- A framework tool, not a script-runner built-in commands work on any reaktor app by reading its declared targets; project tasks are discovered, not hardcoded
- Clikt + Mordant on the JVM typed composable subcommands (Clikt) + rich terminal output (Mordant), both Kotlin Multiplatform; the oclif comparison is confined to §2
- Self-installing
reaktor installcopies the binary into~/.reaktor/bin, puts it on PATH, and installs shell completions; a one-line bootstrap (curl … | sh, Homebrew, SDKMAN) gets you there from nothing - Executes through reaktor-cloud every command is a
CloudOperationover the existing runners — the CLI adds typing, discovery, gating, and rendering, not new execution - One catalog, three faces the CLI (terminal), reaktorDesktop (GUI), and reaktor-mcp (agents) project the same task/operation model
- Gated by default
deploy,db migrate,tenant eraseareSafetyClass-gated graph operations — confirmation, blast radius, audit, and a reaktor token - Extensible a reaktor module can contribute subcommands the same way it projects onto the graph — a thin per-module contribution, no fork
1. Why a CLI — and why it is a framework tool
reaktor models a whole product as a graph and stands up apps across five runtimes. Developers need a fast, scriptable, headless way to act on a project — scaffold it, run it, build a target, deploy a worker, tail a log, run a migration, inspect the graph. That is a CLI's job, and the demand is already visible in the ad-hoc scripts every reaktor project accumulates (bestbuds has ~70).
The mistake would be to build a runner for those scripts. A framework CLI earns its keep only if it works for every app built on the framework, including ones that do not exist yet. So reaktor has two kinds of commands:
| Kind | Examples | Where they come from |
|---|---|---|
| Built-in (framework) commands | new, add, build, dev, deploy, test, graph, doctor, install | ship with the CLI; operate on the project's declared targets |
| Project tasks | task run seed-db, task run sync-stickers | discovered from the current project (§4); never hardcoded |
reaktor build web works in any reaktor app because "web" is a declared target the CLI reads from the project, not a name baked into the binary. A third-party app gets a working reaktor deploy on day one without the CLI knowing anything about it, and can expose its own custom tasks without forking. That is the difference between a framework tool and a project script-runner — and it is the reason this CLI is worth building once, well, for everyone.
It is also one of three faces of a single model: the CLI (terminal), reaktorDesktop (GUI), and reaktor-mcp (agents) all project the same task/operation catalog and execute through the same runners. The CLI is the thinnest, fastest face.
2. The framework choice — Clikt, not oclif
This is the one section on the alternative, by request. The short version: Clikt + Mordant, on the JVM.
The deciding fact is that the CLI must share one runtime with the rest of reaktor's tooling. The operations it runs already live on the JVM — reaktor-cloud's ProcessToolRunner / DaggerToolProvider / PulumiToolProvider are jvmMain Kotlin, by a deliberate decision (the cloud plane is JVM-native, no Node sidecar). reaktorDesktop is JVM/Compose; reaktor-mcp is JVM. A Clikt CLI shares the catalog, the executors, the gating, and the auth client with all of them in-process. An oclif CLI would be a second runtime in TypeScript, re-modeling the same operations and bridging back to the JVM executors — the exact drift the one-catalog design exists to prevent.
| Clikt (Kotlin/JVM) | oclif (TypeScript/Node) | |
|---|---|---|
| Shares catalog/executors/auth with Desktop + MCP | Yes — same types, in-process | No — a second model to keep in sync |
| "Kotlin/JVM everywhere possible" (standing rule) | Fits | Violates |
Drives wrangler / dagger (Node tools) | Subprocess (already how it works) | Subprocess too — no real advantage |
| Rich output | Mordant (KMP, same author as Clikt) | chalk / ink |
| Reuses reaktor-graph / auth / FlexBuffer | Directly | Only via a bridge |
| Distribution | GraalVM native-image · Homebrew · SDKMAN | npm i -g |
oclif's one genuine edge — being native to the Node/Workers fleet — buys nothing, because wrangler and dagger are invoked as supervised subprocesses regardless of the CLI's language. The only alternative worth naming is picocli (Java, the most battle-tested GraalVM native-image story) — choose it only if native-image is your dominant constraint; otherwise Clikt's Kotlin-idiomatic composable API and shared-author Mordant integration fit reaktor better. kotlinx-cli is dormant. Verdict: Clikt + Mordant. The rest of this document is that design.
3. The command surface
A Clikt subcommand tree. Built-in commands first, then the discovered-task escape hatch:
reaktor new <template> [name] # scaffold a reaktor app (web / app / server / fullstack)
reaktor add <module> # add a reaktor module (auth, db, media, notifications…) to this project
reaktor dev [target] # run a dev server for a declared target
reaktor build [target] # build one/all declared targets
reaktor deploy <target> [--env] # gated; deploy a declared target/service
reaktor test [target] [--unit|--e2e|--device]
reaktor logs <service> # tail a deployed service
reaktor graph <show|query|diff|node> # inspect the project's unified system graph
reaktor db <migrate|console|status> # the project's stores (memgraph/clickhouse/postgres)
reaktor tenant <list|create|export|erase> # gated multitenancy ops
reaktor cloud <inventory|pulumi|dagger> # reaktor-cloud providers
reaktor auth <login|whoami|token> # the reaktor-auth client
reaktor task <list|run|explain> # the project's discovered task catalog
reaktor doctor # environment & dependency checks
reaktor install # put `reaktor` on this machine's PATH (§6)
reaktor self <update|uninstall> # manage the installed CLI
Every verb is target/task-driven, not name-driven: reaktor deploy social works because social is a service the project declares (or that WranglerInventoryProvider discovers), not because the CLI has a deploySocial baked in. reaktor task list shows whatever the current project declares. The grammar mirrors the domain action target shape and is fully discoverable (reaktor --help, reaktor deploy --help, completions).
How you actually invoke things (built today). Beyond the built-ins, the CLI now creates shortcuts for the things the current project actually declares. Gradle modules, targets, services, stores, direct ops scripts, and every colon-namespaced script family are addressable without remembering whether the backing implementation is npm, Gradle, Kubernetes, Wrangler, or a shell script:
reaktor # the overview: project model (npm + gradle) + the full command surface
reaktor tasks # organized: exact targets, libraries, toolchains, stores, families
reaktor appAndroid # target dashboard: actions + Gradle + Fastlane + Maestro + tests
reaktor appAndroid build # declared target build → fastlane:android:release
reaktor appAndroid fastlane # target-scoped family list
reaktor appAndroid fastlane release # target-scoped family run → fastlane:android:release
reaktor appAndroid maestro login # target-scoped family run → maestro:android:login
reaktor appDarwin maestro login # iOS target, exact targets/appDarwin name
reaktor socialServer # Cloudflare deploy/logs + target-local npm scripts
reaktor socialServer npm dev # npm script in targets/socialServer/package.json
reaktor app # gradle module dashboard: build/test/check/raw task
reaktor app build # gradle module task → ./gradlew :app:build
reaktor app test # gradle module task → ./gradlew :app:test
reaktor gradle # visual Gradle inventory: target modules vs libraries
reaktor reaktorWeb # target dashboard: dev/deploy/test/perf
reaktor reaktorWeb dev # declared target dev → npm run reaktor
reaktor deploy reaktorWeb # declared target → npm run deployReaktor (runs immediately, no prompt)
reaktor deploy reaktorServer -n # shell fallback → targets/reaktorServer/deploy.sh (dry-run)
reaktor reaktorServer logs # k3s fallback → kubectl logs deployment/app-server
reaktor dagger # top-level Dagger functions, equivalent to cloud dagger
reaktor clickhouse # store shortcut → npm run clickhouse
reaktor test # list all target/script/gradle test entry points
reaktor test reaktorWeb playwright # test family → test:reaktorWeb:playwright
reaktor deploy chat # deploy<Name> service → npm run deployChat
reaktor deploy reaktorWeb --confirm # opt in to a y/n confirm; --dry-run / -n to preview
reaktor fastlane android release # script family → fastlane:android:release
reaktor fastlane android # partial path → lists the matching scripts
reaktor maestro ios login # script family → maestro:ios:login
reaktor reaktorServer karate auth smoke # target-scoped API contract → karate:reaktorServer:auth:smoke
reaktor notificationServer karate auth smoke # target-scoped Worker contract → karate:notificationServer:auth:smoke
reaktor messagingServer karate auth smoke # target-scoped Worker contract → karate:messagingServer:auth:smoke
reaktor reaktorServer k6 auth smoke # low-pressure auth SLO probe → k6:reaktorServer:auth:smoke
reaktor reaktorServer k6 auth load # explicit load probe → k6:reaktorServer:auth:load
reaktor keploy record # partial path → lists start/status/stop
reaktor run deployWithCheck # any script, by exact name
reaktor logs chat # logs<Name> script, or `wrangler tail chat`
reaktor run # list all npm scripts (the full flat list)
reaktor docs # serve the Docusaurus docs locally (dev server; includes private/ pages)
The dashboard is the important ergonomics change: a target name is now an inventory view, not a hidden run. In bestbuds, reaktor appAndroid shows the app-store target actions, the related Gradle module, every fastlane:android:* script, every maestro:android:* flow, and the test:bestbudsAndroid:* entry points in one place. reaktor socialServer shows Cloudflare deploy/logs plus the target-local npm scripts in targets/socialServer/package.json. Execution remains explicit (build, test, fastlane release, maestro login, npm dev, etc.), so exploring the task surface is safe.
Nothing here is hardcoded: the families (fastlane, maestro, karate, k6, keploy, perf, …), the target dashboards, the direct Gradle module commands, and the deployable service shortcuts are derived from the project's own files, so a different reaktor app gets its own surface.
4. Project discovery & extensibility
This is what makes the CLI general. When reaktor <anything> runs, it first locates and loads the current project, then operates on what that project declares.
Locate. Walk up from the working directory for the project marker — a package.json with a top-level reaktor key — and resolve the project root (the same way cargo finds Cargo.toml or git finds .git). reaktor deliberately reuses files the project already has instead of adding another top-level config: the descriptor is one key in package.json, read alongside that file's existing scripts and workspaces and the project's settings.gradle.kts.
Load. Read the project's declared model:
data class ReaktorProject(
val root: Path,
val targets: List<Target>, // declared: web, server, android, ios, worker:"social"…
val tasks: TaskCatalog, // declared tasks + tasks derived from inventory
val inventory: CloudInventory?, // from WranglerInventoryProvider / k8s / pulumi
val graph: SystemGraphHandle?, // the project's unified system graph, if present
)
A project declares its targets in a reaktor key inside its existing package.json — no new file, nothing added to the top-level directory. The targets map reaktor's concepts onto the scripts and gradle tasks the project already has, so the CLI reuses them:
{
"name": "bestbuds-main",
"reaktor": {
"name": "bestbuds",
"targets": {
"reaktorWeb": { "runtime": "js-web", "workspace": "targets/reaktorWeb", "dev": "reaktor", "deploy": "deployReaktor" },
"android": { "runtime": "android", "build": "fastlane:android:release", "test": "test:bestbudsAndroid:maestro" }
},
"services": { "from": "workspaces", "deployPrefix": "deploy", "logsPrefix": "logs" }
},
"scripts": { "deployReaktor": "…", "fastlane:android:release": "…" },
"workspaces": ["targets/reaktorWeb", "…"]
}
Now reaktor appAndroid renders the Android task dashboard, reaktor build appAndroid runs the existing fastlane:android:release script, reaktor app renders Gradle library tasks, reaktor app build runs ./gradlew :app:build, reaktor socialServer npm dev runs a target-local npm script, reaktor deploy reaktorWeb runs deployReaktor, reaktor deploy reaktorServer resolves targets/reaktorServer/deploy.sh, reaktor run <script> runs any root npm script, and reaktor logs <target> reuses a logs<Name> script, a Kubernetes deployment discovered from a target deploy script, or finally wrangler tail — with zero project-specific code in the CLI. A Gradle-centric project uses the same key with "gradle": ":server:deploy" targets, and the CLI reads settings.gradle.kts for the module list.
Extend. Two levels, mirroring the module ⇄ graph layering pattern:
- Tasks — any project adds verbs by declaring tasks (above). Zero code.
- Subcommands — a reaktor module can contribute a whole subcommand group (e.g.
reaktor media optimize) by shipping a thin CLI contribution that the CLI discovers, exactly as a module projects onto the graph via a thin-graphshim rather than a hardcoded dependency. The CLI core never imports the module; it loads contributions at startup via an explicit registry (KMP has no portable ServiceLoader, so registration is explicit, not classpath magic).
The result: the framework ships the universal commands; each project and each module contributes its own surface; nothing is hardcoded to one app.
5. Concrete Clikt design
The reaktor-cli module lives in the reaktor framework (it is tooling over reaktor-cloud/graph/auth; it consumes an app like bestbuds only as a target it operates on). Primary source set is jvmMain because it reuses reaktor-cloud's jvmMain runners.
Dependencies. com.github.ajalt.clikt:clikt, com.github.ajalt.mordant:mordant:3.0.2 (plus mordant-coroutines for animated runs and mordant-markdown for reaktor graph/explain), reaktor-core, reaktor-cloud, reaktor-graph (read), reaktor-auth (client). Clikt is built on Mordant, so the whole CLI shares one Terminal (via the Clikt context) for styling, tables, progress, and prompts.
The root command + shared context. One ReaktorEnv carries the loaded project, the executor, auth, and the gate down the subcommand tree via Clikt's context:
class ReaktorEnv(
val project: ReaktorProject, // discovered (§4); null-object if outside a project
val runner: CloudToolRunner, // reaktor-cloud: Process / Dagger / Pulumi
val auth: AuthClient, // reaktor-auth token for gated ops
val gate: OperationGate, // SafetyClass confirm + blast radius + audit
val term: Terminal, // Mordant
)
class Reaktor : CliktCommand(name = "reaktor") {
override val printHelpOnEmptyArgs = true
init { versionOption(BuildInfo.version) }
private val env by findOrSetObject { ReaktorEnv.discover() } // load once, share down
override fun run() { } // group node; work is in leaves
}
A built-in command — deploy, gated, suspending (Clikt 5.x SuspendingCliktCommand bridges the runners' suspend API), with the service argument completed from the project's inventory:
class Deploy : SuspendingCliktCommand(name = "deploy") {
private val env by requireObject<ReaktorEnv>()
private val target by argument().completionCandidates(FromProjectInventory)
private val environment by option("--env").enum<DeployEnv>().default(DeployEnv.Dev)
private val dryRun by option("--dry-run").flag()
override suspend fun run() {
val op = env.project.tasks.operation("deploy", target, environment) // a CloudOperation, not a string
if (dryRun) { env.term.println(op.plan().render()); return }
env.gate.require(op) // confirm + blast radius + audit + token
env.runner.run(op).collect { event -> env.term.println(event.render()) } // stream CloudEvents via Mordant
}
}
The discovered-task command — task run resolves anything the project declared, with no per-task class:
class TaskRun : SuspendingCliktCommand(name = "run") {
private val env by requireObject<ReaktorEnv>()
private val id by argument().completionCandidates(FromProjectTasks)
override suspend fun run() {
val op = env.project.tasks.byId(id) ?: throw UsageError("Unknown task '$id'. Try `reaktor task list`.")
if (op.danger >= Danger.Medium) env.gate.require(op)
env.runner.run(op).collect { env.term.println(it.render()) }
}
}
Wiring it up. A static command tree (good for native-image; minimal reflection):
suspend fun main(args: Array<String>) = Reaktor()
.subcommands(
New(), Add(), Dev(), Build(), Deploy(), Test(), Logs(),
Graph(), Db(), Tenant(), Cloud(), Auth(),
Task().subcommands(TaskList(), TaskRun(), TaskExplain()),
Doctor(), Install(), Self().subcommands(SelfUpdate(), SelfUninstall()),
)
.also { CliContributions.discover().forEach(it::registerOrMerge) } // module-contributed subcommands (§4)
.main(args)
Two implementation tiers, the same split as elsewhere: catalog-backed commands (deploy/logs/test/build) are thin generics resolving a CloudOperation from the project; hand-written rich commands (graph query, db console, auth login, new) hold real interactive logic and reuse reaktor modules directly. Execution never re-implements a tool — deploy ends in a Dagger @func() or a wrangler subprocess via reaktor-cloud; the CLI's job is typing, discovery, gating, and rendering.
Gating & auth. env.gate.require(op) enforces the devops gated-operation contract, and it leans on Mordant for the human side — the blast radius is a table, the confirmation a YesNoPrompt, and a destructive op re-types its id:
suspend fun OperationGate.require(op: CloudOperation) {
term.println(table { // blast radius as a Mordant table
captionTop((bold + brightRed)("${op.verb} — review before continuing"))
header { row("target", "env", "touches") }
body { op.blastRadius.forEach { row(it.target, it.env, it.summary) } }
})
auth.requireToken(op.scope) // reaktor-auth
val ok = when {
!term.terminalInfo.interactive -> yesFlag // non-TTY (CI): obey --yes, never hang on a prompt
op.danger >= Danger.High -> term.prompt("Type '${op.id}' to confirm") == op.id
else -> YesNoPrompt("Proceed?", term).ask() == true
}
if (!ok) throw Abort()
audit.record(op) // audit trail
}
The same OperationGate backs the Desktop confirm dialog and the MCP permission prompt — one policy, three faces. Everyday tasks never prompt: confirmation is opt-in (reaktor deploy … --confirm) or reserved for operations that declare a high danger/SafetyClass (e.g. tenant erase, a prod deploy) — and even then it honors a no-prompt/CI preference and TTY detection rather than blocking. --dry-run/-n previews any run without executing it.
6. Self-install — reaktor install
The CLI puts itself on the user's PATH so reaktor works from any terminal, the way rustup, deno install, and volta do. Two stages: a bootstrap to get the binary, then reaktor install to register it.
Bootstrap (from nothing). Any one of:
curl -fsSL https://reaktor.build/install.sh | sh # detects OS/arch, downloads the native-image binary
brew install reaktor-build/tap/reaktor # Homebrew
sdk install reaktor # SDKMAN
cd reaktor/reaktor-cli && ./gradlew installDist \
&& ./build/install/reaktor/bin/reaktor install # from source — the path that works today
The bootstrap script does the minimum: detect platform, download the right native-image binary into ~/.reaktor/bin, and invoke reaktor install.
reaktor install is itself a Clikt command — this is the actual built implementation. It self-locates from the classpath, copies the app into ~/.reaktor/libexec, writes a launcher at ~/.reaktor/bin/reaktor, and appends that directory to PATH in the shell rc:
class Install : CliktCommand() {
private val env by requireObject<ReaktorEnv>()
private val noModifyPath by option("--no-modify-path").flag()
override fun run() {
val dest = File(System.getProperty("user.home"), ".reaktor")
val libexec = File(dest, "libexec").also { if (it.exists()) it.deleteRecursively(); it.mkdirs() }
appHome().copyRecursively(libexec, overwrite = true) // self-locate via java.class.path
val bin = File(dest, "bin").apply { mkdirs() }
File(bin, "reaktor").apply { // a launcher that execs the real app
writeText("#!/bin/sh\nexec \"" + File(libexec, "bin/reaktor").absolutePath + "\" \"\$@\"\n")
setExecutable(true)
}
if (!noModifyPath) ensureOnPath(bin) // append `export PATH=…` to ~/.zshrc
env.terminal.println(green("✓") + " installed reaktor → " + dim(bin.path))
}
}
Shell completions are a fast-follow (C-next); the PATH + launcher flow above is what runs today.
What it does, cross-platform:
- Place the binary in
~/.reaktor/bin(override with--dir); symlink on macOS/Linux, copy on Windows. - Ensure PATH — if
~/.reaktor/binis not on PATH, append anexport PATHline to the detected shell rc (.zshrc/.bashrc/config.fish) or set the Windows user PATH;--no-modify-pathprints instructions instead (for users who manage PATH themselves). - Shell completions — generate and install Clikt completion scripts for the detected shell so
reaktor depl<TAB>and service/target completion work. - Verify — point the user at
reaktor doctor.
Lifecycle. reaktor self update re-downloads the latest binary and re-links; reaktor self uninstall removes the PATH entry, completions, and ~/.reaktor; reaktor doctor checks the install (binary on PATH, completions present), the toolchain (wrangler/dagger/pulumi/JDK reachable), and cloud auth (so a missing CLOUDFLARE_API_TOKEN is reported up front, not as a silent 400 mid-deploy).
7. Rich terminal output — Mordant
Clikt is built on Mordant (ajalt/mordant, v3.0.2, same author, also Kotlin Multiplatform), so the CLI gets one Terminal and a real widget toolkit — not just colored strings. What the CLI uses:
- Styled output —
TextColors(brightGreen,rgb("#…"), the(fg on bg)background syntax) andTextStyles(bold,dim,underline) compose with+and apply as functions:t.println((bold + brightGreen)("deployed")). - Tables —
table { header { row(...) }; body { row(...) } }withcolumn(i) { align },borderType, and per-cell styles, forreaktor task list,reaktor cloud inventory, and the blast-radius view in §5 — grouped and colored by danger. - Animated progress —
progressBarLayout { … }driven by the runner'sCloudEventstream throughmordant-coroutines(animateInCoroutine), soreaktor deploy/buildshow live progress and spinners (Spinner.Dots) instead of a frozen prompt. - Markdown —
mordant-markdown'sMarkdown(…)widget rendersreaktor graph node <id>,reaktor task explain, and long help directly in the terminal. - Interactive selection —
terminal.interactiveSelectList(targets, title = …)givesreaktor deploy/reaktor newa picker when an argument is omitted;interactiveMultiSelectListpowers batch fan-out ("deploy these services"). - Confirmation —
YesNoPrompt(...).ask()and a type-the-id check back the gate's destructive-op confirmation (§5). - Capability-aware — Mordant auto-detects color level (
AnsiLevel),NO_COLOR, and whether output is a TTY, so piped/CI output drops color and animation automatically and a non-interactive run requires--yesrather than hanging. OSC-8 hyperlinks letreaktor graphemit clickable links to source/Desktop where supported.
This is the parity that lets the CLI and the Desktop pane feel like the same product at different fidelities — and because Mordant is multiplatform, it works under the GraalVM native-image build (§8).
8. Distribution & startup
JVM cold start is the one real cost, and it is solvable:
- GraalVM native-image (the target). Compiles the JVM CLI to a self-contained native binary with ~native startup (tens of ms) while still reusing the
jvmMainreaktor-cloud runners. The command tree is static, so reflection metadata is small; Clikt and Mordant run under native-image. - Why not Kotlin/Native directly. Clikt is KMP and would compile to a native binary — but reaktor-cloud's runners are
jvmMain(ProcessBuilder, the Pulumi Java API), so a Kotlin/Native CLI could not reuse them and would have to re-implement execution. JVM + native-image keeps the shared executors and gets fast startup — the reason to stay on the JVM. - App-image fallback.
jpackage/jlinkbundles a trimmed JRE — larger and slower than native-image but trivial to produce; fine for the first internal cut. - Channels. The
install.shbootstrap, a Homebrew tap, and an SDKMAN candidate all deliver the native binary;reaktor self updatekeeps it current.
Sequencing: ship app-image first to unblock dogfooding, move to native-image once the command tree stabilizes.
9. How it fits the reaktor ecosystem
The CLI is a connector; naming the connections keeps it from becoming a parallel system:
- The task catalog — the per-project source of truth the CLI projects (the devops plane defines it; a project's scripts are its legacy form).
- reaktor-cloud — the executors. Every run is a
CloudOperationoverProcessToolRunner/DaggerToolProvider/PulumiToolProvider. - Dagger — the build/test/deploy pipeline-as-code the CLI invokes; CI calls the same
@func()s. - reaktor-mcp — the agent face: the same catalog + gate exposed as MCP tools, so an agent and the CLI run identical, identically-gated operations.
- reaktorDesktop — the GUI face over the same catalog and runners.
- reaktor-auth —
reaktor auth loginobtains the token gated commands require. - Unified system graph —
reaktor graphreads and (gated) mutates it; operations are themselves graph nodes. - Multitenancy —
reaktor tenant export|eraseare the gated, one-predicate-per-store tenant operations made executable.
10. bestbuds as the first consumer — a migration example
bestbuds is where the CLI is proven, not what it is designed around. Its root package.json holds ~70 scripts — a real, messy catalog that stress-tests the discovery model. The mapping is mechanical and illustrates how any existing project adopts the CLI:
deployChat,deployMedia, … (17) +logsChat, … (13) →reaktor deploy <service>andreaktor logs <service>, with the service completed fromWranglerInventoryProvider. Thirty hand-written rows become two commands; adding a worker adds a verb for free.fastlane:android:release,maestro:ios:login→reaktor appAndroid/reaktor appDarwinexpose the full mobile task dashboard; execution stays explicit throughreaktor appAndroid build,reaktor appAndroid fastlane release,reaktor appDarwin maestro login, andreaktor test appDarwin; the embeddedMAESTRO_TEAM_ID/MAESTRO_APP_FILEenv become declared task inputs, not string literals.radar,memgraph,clickhouse→reaktor infra <…>/reaktor db console <store>.- The hidden
&&task-DAG (perf:reaktor → perf:reaktorWeb → {bundle, playwright}) → declareddependsOnthe CLI can show and parallelize.
Concretely, bestbuds now has a reaktor key in its existing package.json for legacy action metadata, but the visible target names come from targets/: appAndroid, appDarwin, appWeb, reaktorWeb, socialServer, mediaServer, reaktorServer, and the rest exactly as they exist on disk. This was added without removing any of the ~70 scripts, which the CLI reuses as-is. reaktor tasks, reaktor appAndroid, reaktor appWeb, reaktor socialServer, reaktor reaktorServer, reaktor doctor, and reaktor deploy reaktorWeb already run against bestbuds today. Eventually the redundant scripts can become a thin generated facade, but nothing is removed yet. Nothing in the CLI is bestbuds-specific — the same one-key addition adopts the CLI in a brand-new third-party reaktor app.
11. Build plan & status
| Phase | Work | Result |
|---|---|---|
| ✅ C0 — scaffold (done) | reaktor-cli module; Clikt 5 + Mordant 3; root + ReaktorEnv; --help/--version; reaktor logs over ProcessRunner | A runnable, installed reaktor skeleton |
| ✅ C1 — project discovery (done) | ReaktorProject loader — the package.json reaktor key + scripts/workspaces + settings.gradle.kts; Target; tasks/run/dev/build/deploy driven by declared targets | Works on any reaktor project, not just bestbuds |
| 🟡 C2 — execution + ops (partly done) | cloud dagger/pulumi/inventory (subprocess to the tool CLIs), db console/status/migrate, infra, auth login/token/whoami (local token + JWT decode) built; routing through the in-process reaktor-cloud runners + OperationGate audit + token-enforced gating next | Drives Dagger/Pulumi/wrangler + the stores |
| ✅ C3 — self-install (done) | reaktor install · self update (rebuilds from the recorded source) · self uninstall · doctor; shell completions + install.sh/Homebrew/SDKMAN bootstrap next | reaktor available from any terminal |
| ✅ C4 — rich + scaffold (done) | reaktor new (scaffolds a working app) · add · explain · graph (project model); Mordant tables; animated progress/streamed-log widgets next | First-run UX; CLI feels first-class |
| C5 — distribution | GraalVM native-image; Homebrew tap; SDKMAN | A fast, installable binary |
| C6 — converge faces & extensibility | Same TaskCatalog + OperationGate behind reaktor-mcp and the Desktop Ops pane; module-contributed subcommands | One model, three faces; third-party extensibility |
C0–C4 are built — the whole everyday command surface (run/build/deploy/logs + the script families), introspection (graph/explain/tasks), ops (db/cloud/infra), auth, scaffolding (new), and self-install/update — all at reaktor/reaktor-cli, installed on PATH, running against bestbuds. What remains: C2's deeper half (route execution through the in-process reaktor-cloud Dagger/Pulumi runners with CloudEvent streaming + an OperationGate audit trail + token-enforced gating on destructive ops), C5 (GraalVM native-image + Homebrew/SDKMAN), and C6 (share the catalog with reaktor-mcp + the Desktop Ops pane, and module-contributed subcommands).
12. Open decisions
- Project descriptor format — decided. A
reaktorkey inside the existingpackage.json(reusingscripts/workspaces), not areaktor.toml— reuse what the project already has and don't add another top-level config file. Gradle-only projects get areaktor { }block; graph-derived discovery comes later. - Native-image vs app-image first. App-image unblocks dogfooding (C0–C4); native-image at C5.
- Module-contributed subcommands. Explicit registry (KMP-safe, recommended) vs KSP-generated registration (reaktor-compiler, later). No classpath magic — KMP has no portable ServiceLoader.
- Name & alias.
reaktor(+rk)? And whetherreaktor newtemplates live in the CLI or are fetched from a registry. - Coroutines under native-image. Validate
SuspendingCliktCommand+ kotlinx-coroutines under GraalVM early (a known sharp edge), or keeprunBlockingat the command boundary. - Windows PATH/install ergonomics.
reaktor installon Windows (user PATH edit, completions for PowerShell) needs its own testing pass.
13. References
CLI framework & output
- Clikt —
ajalt/clikt(Kotlin Multiplatform command-line parser; suspending commands, subcommands, typed options/arguments, completion) - Mordant —
ajalt/mordantv3.0.2 (Kotlin Multiplatform rich terminal; modulesmordant,mordant-coroutines,mordant-markdown):Terminal,TextColors/TextStyles,table {}/grid {},progressBarLayout,Markdown,YesNoPrompt/interactiveSelectList,AnsiLevelcapability detection — docs atajalt.github.io/mordant - picocli — the Java/GraalVM-native-image reference (the considered alternative); oclif — the TypeScript/Node option (§2); kotlinx-cli — dormant
Self-install & distribution
- rustup ·
deno install· Volta ·bun— self-installing CLI prior art (PATH + completions + self-update) - GraalVM
native-image;jpackage/jlink; Homebrew taps; SDKMAN candidates
reaktor
- reaktor-devops — the task catalog, Dagger pipeline, gated operations
- reaktor-cloud-pane — the executors and the GUI face
- reaktor-agentic-tooling — the MCP/agent face of the same operations
- reaktor-core — the Feature/Adapter foundation; the module-contribution pattern
- auth-analysis · reaktor-multitenancy — the token and tenant operations gated commands use
See also
- Reaktor DevOps — deployment as a gated graph operation; the catalog the CLI projects
- Reaktor Cloud Pane — the runners and the operator GUI
- The Reaktor Control Plane — MCP tools over the same operation model
- Reaktor Core — the foundation the CLI module builds on, and the contribution pattern it reuses
Related docs
- DevOps - Use before changing the build/deploy/ops pipeline, the Kotlin platform model, deployment partitioning, gated deploy operations, or the reaktorDesktop Deploy pane; pairs with reaktor-cloud-pane (operator view) and secrets-management.
- Cloud Pane - Use when building the operator view over product infrastructure and tool execution.
- Unified Graph - Use before building Reaktor Engine graph importers, cloud/resource nodes, operation providers, or MCP graph tools.
- Security Plan - Use before enabling deploy actions, remote agents, or privileged commands.
- Gemini DevOps Agent - Use before handing DevOps execution from Codex or Claude to Antigravity CLI, adding agent-run recipes, or wiring build/test/deploy operations into reaktorDesktop.
- Visual Language - Use when designing the node-based authoring/orchestration language and its editor, compiler, and agentic surface.