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 install copies 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 CloudOperation over 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 erase are SafetyClass-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:

KindExamplesWhere they come from
Built-in (framework) commandsnew, add, build, dev, deploy, test, graph, doctor, installship with the CLI; operate on the project's declared targets
Project taskstask run seed-db, task run sync-stickersdiscovered 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 + MCPYes — same types, in-processNo — a second model to keep in sync
"Kotlin/JVM everywhere possible" (standing rule)FitsViolates
Drives wrangler / dagger (Node tools)Subprocess (already how it works)Subprocess too — no real advantage
Rich outputMordant (KMP, same author as Clikt)chalk / ink
Reuses reaktor-graph / auth / FlexBufferDirectlyOnly via a bridge
DistributionGraalVM native-image · Homebrew · SDKMANnpm 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 -graph shim 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/bin is not on PATH, append an export PATH line to the detected shell rc (.zshrc/.bashrc/config.fish) or set the Windows user PATH; --no-modify-path prints 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) and TextStyles (bold, dim, underline) compose with + and apply as functions: t.println((bold + brightGreen)("deployed")).
  • Tables — table { header { row(...) }; body { row(...) } } with column(i) { align }, borderType, and per-cell styles, for reaktor task list, reaktor cloud inventory, and the blast-radius view in §5 — grouped and colored by danger.
  • Animated progress — progressBarLayout { … } driven by the runner's CloudEvent stream through mordant-coroutines (animateInCoroutine), so reaktor deploy/build show live progress and spinners (Spinner.Dots) instead of a frozen prompt.
  • Markdown — mordant-markdown's Markdown(…) widget renders reaktor graph node <id>, reaktor task explain, and long help directly in the terminal.
  • Interactive selection — terminal.interactiveSelectList(targets, title = …) gives reaktor deploy/reaktor new a picker when an argument is omitted; interactiveMultiSelectList powers 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 --yes rather than hanging. OSC-8 hyperlinks let reaktor graph emit 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 jvmMain reaktor-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/jlink bundles a trimmed JRE — larger and slower than native-image but trivial to produce; fine for the first internal cut.
  • Channels. The install.sh bootstrap, a Homebrew tap, and an SDKMAN candidate all deliver the native binary; reaktor self update keeps 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 CloudOperation over ProcessToolRunner/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 login obtains the token gated commands require.
  • Unified system graph — reaktor graph reads and (gated) mutates it; operations are themselves graph nodes.
  • Multitenancy — reaktor tenant export|erase are 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> and reaktor logs <service>, with the service completed from WranglerInventoryProvider. Thirty hand-written rows become two commands; adding a worker adds a verb for free.
  • fastlane:android:release, maestro:ios:login → reaktor appAndroid / reaktor appDarwin expose the full mobile task dashboard; execution stays explicit through reaktor appAndroid build, reaktor appAndroid fastlane release, reaktor appDarwin maestro login, and reaktor test appDarwin; the embedded MAESTRO_TEAM_ID/MAESTRO_APP_FILE env 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}) → declared dependsOn the 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

PhaseWorkResult
✅ C0 — scaffold (done)reaktor-cli module; Clikt 5 + Mordant 3; root + ReaktorEnv; --help/--version; reaktor logs over ProcessRunnerA 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 targetsWorks 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 nextDrives 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 nextreaktor 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 nextFirst-run UX; CLI feels first-class
C5 — distributionGraalVM native-image; Homebrew tap; SDKMANA fast, installable binary
C6 — converge faces & extensibilitySame TaskCatalog + OperationGate behind reaktor-mcp and the Desktop Ops pane; module-contributed subcommandsOne 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 reaktor key inside the existing package.json (reusing scripts/workspaces), not a reaktor.toml — reuse what the project already has and don't add another top-level config file. Gradle-only projects get a reaktor { } 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 whether reaktor new templates 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 keep runBlocking at the command boundary.
  • Windows PATH/install ergonomics. reaktor install on 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/mordant v3.0.2 (Kotlin Multiplatform rich terminal; modules mordant, mordant-coroutines, mordant-markdown): Terminal, TextColors/TextStyles, table {}/grid {}, progressBarLayout, Markdown, YesNoPrompt/interactiveSelectList, AnsiLevel capability detection — docs at ajalt.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

See also

  • 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.