Skip to content

Init: the one-command installer

OpenSwitchboard is installed, not forked (one production target): an operator should reach a running ask from an empty directory with one command, and a ready-to-deploy profile with one flag more. switchboard init — the registry command setup.init, spelled init on the CLI — is that command. It derives the installation's files from the checked-in examples instead of writing a config from scratch: .env is .env.example with the operator's values on their lines and every unfilled placeholder commented out; config/config.yaml is config.example.yaml edited in place, so the operator's config keeps the commentary that documents every optional block, still off; deploy/profile.json is the example's shape with the two Workers the smallest production has. Then it proves the result the way the product will read it — the config through the real loader, the capabilities from that config and that .env — and prints the commands to run next, verbatim.

Three rules hold everywhere in it. Flags first, prompts only for what is missing and only on a terminal: a process without a TTY is never asked anything, so a missing answer is a refusal naming the flag, never a hang; the GitHub App and Cloudflare are flags only. A secret goes to .env at mode 600 and nowhere else: no output carries a value, and the dry-run preview masks every secret line with a fixed mask so not even a length leaks. Nothing is overwritten by accident: an existing file is a conflict naming it and --force (a dry run writes nothing, so it previews either way), and a rerun with the same answers writes the same bytes.

Behavior

  1. The files. With a provider given, init plans .env (mode 600) and config/config.yaml (mode 644), in that order; with --cloudflare <account> --zone <zone> it also plans deploy/profile.json (644). Each planned file carries the names of the secrets it holds — SLACK_BOT_TOKEN, SLACK_APP_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY, GITHUB_APP_PRIVATE_KEY, whichever were given — and the other two files carry none.
  2. .env from .env.example. A line NAME=… — commented out or not — whose name has a value becomes NAME=<value>; an uncommented placeholder with no value is commented out, so nothing reads xoxb-... as a token; every comment stays; a name the example lacks is appended in its own paragraph. A value with a newline, a quote, a # or surrounding whitespace is written as one double-quoted line with \n escapes — the form the example shows for the PEM and the one Node's own parser (loadEnvFile, util.parseEnv) expands — so --github-private-key-file's PEM reads back whole.
  3. config.yaml from config.example.yaml, edited in place. The yaml document keeps every comment. organization is the answer; the anthropic provider stays only with --anthropic-key; the openai provider stays only with --openai-compatible <baseUrl>, taking that URL, and loses its apiKeyEnv when no --model-key was given (a local endpoint needs none); --model <m> sets defaults.models.{general,coding,review} to openai/<m> — required when the endpoint is the only provider, optional beside Anthropic (the example's defaults stand otherwise). Every optional block stays as the example has it: commented out, off. The header line that tells a reader to copy the example is replaced by one that says init wrote the file.
  4. deploy/profile.json from deploy/profile.example.json. account and zone are the answers; the Workers are bot (<name> / <name>.<zone>) and memory (<name>-memory / <name>-memory.<zone>), --name defaulting to switchboard; configSource is the example's. From the published package the profile has all four Workers — resident (<name>-resident / <name>-resident.<zone>) and sandbox (<name>-sandbox / <name>-sandbox.<zone>) beside the two — and says "images": "registry": there is no tree to build an image from, so its Workers deploy the release's published images, which deploy all copies into the account registry itself (release-and-deploy.md item 26), and the next commands put every Worker's secrets; a checkout's profile has the two smallest Workers, says nothing about images and builds. The result must pass parseProfile — the same validation deploy reads it with — or the refusal carries the profile's own problems. The profile is written where deploy all runs from (release-and-deploy.md item 24): the repository root (isCheckoutRoot) in a checkout, and — when this CLI is the published npm package (deps.setup.package() is set) — the directory init runs in, which becomes the operator directory the deploy commands read it from, with the Worker configs rendered under .switchboard/ there. Elsewhere (the container image, a checkout's subdirectory) --cloudflare is unavailable, naming both places; the local files need neither.
  5. Coherent flags, every problem at once. No provider; --openai-compatible alone without --model; --model or --model-key without --openai-compatible; one Slack token without the other; a partial GitHub App (--github-app-id, --github-installation-id, --github-private-key-file are all or none); --zone without --cloudflare; --cloudflare without --zone — each is a line of one invalid_input refusal, all of them reported together. The option schemas refuse a malformed account (32 hex), zone (a bare DNS name), Worker name, endpoint URL or App id before the planner runs, naming the option and never the value.
  6. Nothing overwritten without --force. A planned path that already exists is a conflict naming every such path and --force; nothing is written. --force matters to a real write only: under --dry-run (item 9) an existing path is no conflict, since nothing is written. With --force the same files are planned and the output marks each one replaced; the host writes every file through a fresh temp file beside the target, created at 600 (a new name, so the creation mode holds), chmod'ed to the planned mode and renamed over the target — a secret never exists on disk at a looser mode, whether .env is created or replaced, and a failed write leaves no temp file. A file init does not plan is never a conflict.
  7. Proven, then said. The written config is loaded through parseAppConfigText — the loader the bot and the CLI use — and capabilitiesFrom runs over it with the written .env's variables under the process environment (a shell variable wins over the file, as loadEnvFileIfPresent leaves it). The output names the files with their modes and statuses, the providers, the capabilities (execution local · github on · memory off · …, in the contract's axis order) and the next commands; deploy init's own output is nested under workerConfigs when a profile was written. No value ever appears.
  8. Next commands, verbatim. In a checkout: npm run cli -- ask "what can you do?", npm run dev (the bot from source), and docker compose up -d with project.json's image fact; a profile adds deploy secrets memory, deploy secrets bot, and deploy all with MEMORY_TOKEN in its environment. From the published npm package (packaging.md; deps.setup.package() is project.json's package there and undefined elsewhere): npx <package> ask "what can you do?", then npx <package> start — the bot, the same process the image runs, from this directory with no Docker (packaging.md item 8) — then the same bot as a one-line docker run of the published image for a machine that would rather run a container, and, with a profile, the deploy steps as npx <package> deploy … (item 4: one deploy secrets per Worker of the four). Outside a checkout otherwise (the container): docker run of the published image with --env-file .env and config/ mounted read-only at /app/config, for ask and for the bot.
  9. --dry-run. Nothing is written and deploy init is not run, whether or not the files exist — the preview is the same over a fresh directory and over an installation, with or without --force; each file's status is planned and carries a previewmaskedPreview: every NAME=… line of a secret name shows NAME=••••••••, everything else as written. The text render prints each preview under a --- <path> --- header.
  10. Flags first, prompts second, never both for one answer. With a prompt (deps.setup.prompt, a terminal on both stdin and stdout), init asks — in this order and only for what no flag gave — the organization (plain), the Anthropic key (secret; an empty answer asks for the OpenAI-compatible base URL, then its model, then its key, empty for a local endpoint), and Slack: with neither token given, the app-level token (secret; empty skips Slack) then the bot token; with one token given, the missing one — whichever it is — so half a pair is never refused on a terminal (without one it is item 5's refusal). A secret is read with readline's output pointed at nothing, so the keystrokes never echo. Without a prompt, a missing organization or provider is invalid_input naming the flag. The GitHub App and Cloudflare are never prompted.
  11. deploy init through the registry function. With a profile written, the handler calls deployInit.handler with the same deps it is bound to — the same profile loader, the same template reader and file writer deploy init uses on the host — so every Worker the profile names gets its wrangler.jsonc, and a template that cannot render fails exactly as deploy init fails.
  12. CLI only, setup:write. surfaces: { chat: false, mcp: false, http: false }: the command writes the installation directory — the operator root of release-and-deploy.md item 24: a checkout, or from the package SWITCHBOARD_HOME / the working directory when it already holds an installation / ~/.switchboard, created as needed, so no mkdir precedes init; when that is not the directory the operator stands in, the first line reads wrote to <root>: (deps.setup.root()) — and reads their terminal. cli:local holds every grant; the policy table has one row, setup:write on command for the grant's holder. init on the CLI is CLI_SHORTHANDS in src/cli.ts: the word is replaced by setup init before the shared grammar binds the rest — init --help is the derived help, init --bogus the grammar's invalid_input — and both name the command as typed: usage: init …, never the registry's setup init.
  13. The image installs too. docker-entrypoint.sh is the image's ENTRYPOINT (installed as switchboard): no arguments run the bot (node dist/index.js — docker compose, the Cloudflare container), node/sh/bash run as given, anything else runs the CLI (init, ask, <group> <verb>); the Dockerfile has no CMD and carries .env.example, config/config.example.yaml, deploy/profile.example.json and project.json under the paths the host half reads from /app. Its build copies src/ alone, so no production module under src/ may import a file outside it — deploy/, packages/ and scripts/ are not in the image, and a test over every relative import holds that line (an escaping import compiles in the checkout and fails npm run build in the image). package.json names dist/cli.js as the switchboard bin and the CLI's source starts with the shebang a bin needs; the CLI's run guard resolves argv[1] through realpath so a bin symlink still starts main().

Validation criteria

CriterionEvidence
An Anthropic key alone plans .env (600) and config.yaml (644) and no profile; the config loads with one provider and the example's default models; unfilled placeholders are commented out; every optional block stays off (items 1–3, 7)[unit] src/setup/plan.test.ts::planInit — the files::an Anthropic key alone…
The written config keeps the example's commentary, says init wrote it, and is byte-stable on a rerun (item 3)[unit] ::the config keeps the example's commentary…
An OpenAI-compatible endpoint alone: the openai provider with that URL, OPENAI_API_KEY when a key is given, every agent on openai/<model>; a local endpoint without --model-key has no apiKeyEnv (item 3)[unit] ::an OpenAI-compatible endpoint alone…, ::a local endpoint needs no key…
Both providers keep the example's Anthropic defaults unless --model points every agent at the endpoint (item 3)[unit] ::both providers…
Slack tokens and the GitHub App land on their example lines; the PEM is one double-quoted line Node's parser reads back whole; the ids are not secrets; github computes on (items 1, 2, 7)[unit] ::Slack tokens and the GitHub App land in .env…
--cloudflare + --zone writes the example's shape with bot + memory named from --name, the example's configSource, and it parses (item 4)[unit] ::--cloudflare + --zone: deploy/profile.json from the example…
Capabilities come from the written config + .env with the process environment winning; nothing optional is on (item 7)[unit] src/setup/plan.test.ts::planInit — the capability summary and the next commands::capabilities come from the written config…
The next commands in a checkout, with a profile, from the published npm package, and outside a checkout (item 8)[unit] src/setup/plan.test.ts::planInit — the capability summary and the next commands::in a checkout the next commands are…, ::from the published npm package the next commands are…, ::outside a checkout (the container)…, src/core/commands/setup.test.ts::setup.init — flags::writes .env then config/config.yaml with the given values…
No provider, an endpoint without a model, a model/key without an endpoint, one Slack token, a partial GitHub App, a zone without an account or an account without a zone — each named, all at once (item 5)[unit] src/setup/plan.test.ts::planInit — refusals::no provider is invalid_input…, ::an endpoint without a model, a model or key without an endpoint…
--cloudflare outside a checkout and not from the package (the container) is unavailable naming both places; from the package it writes the profile with all four Workers in registry image mode (it parses so) and the next commands are the package's deploy steps for every Worker (items 4, 8)[unit] ::--cloudflare outside a checkout and not from the package…, src/setup/plan.test.ts::planInit — the capability summary and the next commands::from the published npm package the next commands are…
.env.example has a line, commented out or not, for every credential the accounts guide's table says the bot reads from its environment (BRAVE_SEARCH_API_KEY included) — the line init fills (item 2)[unit] src/setup/plan.test.ts::the example \.env` is derived from::names every credential the accounts guide's table says the bot reads…`
An existing file is a conflict naming every file and --force; --force plans the same files; an unplanned file is never a conflict (item 6)[unit] ::an existing file is a conflict…
renderEnv: value replaces its line (commented or not), unfilled placeholder commented out, comments kept, unknown names appended; a multi-line value is one quoted line with \n escapes that parses back (item 2)[unit] src/setup/plan.test.ts::renderEnv and maskedPreview::a given value replaces its example line…, ::a multi-line value is written as one double-quoted line…
maskedPreview masks every secret line with a fixed mask and leaves the rest; a file without secrets previews as written (item 9)[unit] ::maskedPreview replaces every secret line's value…
The host writes at the planned mode, creating directories; replacing a 644 .env tightens it to 600; the bytes go through a temp file at 600 renamed over the target and nothing else is left behind (items 1, 6)[unit] src/setup/host.test.ts::writePlannedFile::creates the file's directory and writes it at its mode…, ::replacing an existing, looser file tightens it to 600…, ::the bytes never exist at a looser mode…
Templates come from the package root and a missing one is named; the package root is the checkout, a temp dir is not; the image and package facts are project.json's and a checkout is not the published package; hostSetupIO is scoped to the working directory (items 4, 8)[unit] src/setup/host.test.ts::the package and the working directory::*
No prompt without a terminal on both ends; a plain answer is trimmed, a secret answer never echoes (item 10)[unit] src/setup/host.test.ts::ttyPrompter::*
The command is CLI-only setup:write with exactly the installer's flags (item 12)[unit] src/core/commands/setup.test.ts::setup.init — flags::is CLI-only, a setup:write, and its options are the installer's flags
Writes .env then config.yaml at 600/644; the output names files, modes, providers, capabilities and next commands and never a secret (items 1, 7, 8)[unit] ::writes .env then config/config.yaml with the given values…
The key file is read from the working directory, a missing one is not_found naming it, the PEM reaches .env and nothing else (items 2, 5)[unit] ::--github-private-key-file is read from the working directory…
With --cloudflare the profile is written and the Worker configs rendered through deployInit.handler; the output nests workerConfigs and the next commands add the deploy steps (items 4, 8, 11)[unit] ::--cloudflare + --zone also writes deploy/profile.json and then renders the Worker configs…
An existing file is conflict with nothing written; --force marks it replaced (item 6)[unit] src/core/commands/setup.test.ts::setup.init — refusals and --dry-run::an existing file is a conflict naming it…
Incoherent flags are invalid_input; --cloudflare outside a checkout unavailable; the schema refuses a bad account, zone, name, URL or id without echoing it; nothing written (items 4, 5)[unit] ::incoherent flags are invalid_input with every problem…
Without a prompt a missing organization or provider is invalid_input naming the flag (item 10)[unit] ::without a prompt, a missing organization or provider is invalid_input…
--dry-run writes nothing, every file is planned with a masked preview, the render shows would write: and each --- path --- block; no secret on the wire (item 9)[unit] ::--dry-run writes nothing and returns each file's masked preview…
--dry-run over existing files previews them as planned, masked, nothing written and no conflict; the same files without it are the conflict, and --force writes them (items 6, 9)[unit] src/core/commands/setup.test.ts::setup.init — a dry run over existing files::--dry-run previews whether or not the files exist…
Prompt order and secrecy: organization (plain), Anthropic key (secret), Slack app token (secret, empty skips); never the GitHub App or Cloudflare (item 10)[unit] src/core/commands/setup.test.ts::setup.init — prompts::asks only for what the flags did not give…
An empty Anthropic answer asks for the endpoint, model and key; a given app token asks for the bot token; flags given are never asked again (item 10)[unit] ::an empty Anthropic answer asks for the endpoint…
Half a Slack pair on a terminal asks for the missing token, whichever it is; without a terminal it is the refusal naming both flags (items 5, 10)[unit] ::half a Slack pair on a terminal asks for the missing token…
init … on the CLI is setup init … through the shared grammar; init --help is the derived help and init --bogus is invalid_input, both with usage: init …; the catalogue lists setup init (item 12)[unit] src/cli.test.ts::buildCoreCommands — the one catalogue every in-process binding shares (index.ts, cli.ts)::phase 4b: the CLI catalogue carries every command…, src/core/commandConformance.test.ts::command conformance — setup.init::help (CLI --help and chat --help) names every argument and every option flag
Every generated case of the conformance suite — required-only, all options set, each flag on and off, a type mismatch per constrained field, an unknown option — binds, succeeds or is refused with the one code, on the CLI, with no secret in the output; the policy row admits cli:local and the operator and refuses the rest (items 5, 12)[unit] src/core/commandConformance.test.ts::command conformance — setup.init::*, src/core/authz/policy.test.ts::*setup:write*
The entrypoint runs the bot with no arguments and the CLI with any; an explicit node … runs as given; the Dockerfile installs it as switchboard with no CMD and carries the examples and project.json; package.json names the bin and the CLI has its shebang (item 13)[unit] src/setup/entrypoint.test.ts::docker-entrypoint.sh::*, src/setup/entrypoint.test.ts::Dockerfile::*, src/setup/entrypoint.test.ts::package.json::*
The image copies src/ alone and no production module under src/ imports a file outside it — a module reaching into deploy/ is named (item 13)[unit] src/setup/entrypoint.test.ts::Dockerfile::copies src/ alone for the bot's build…
From an empty directory, init with real values followed by the tutorial's Part 1 reaches an answer from ask; with --cloudflare, Part 3 reaches a live /healthz from the written profile (items 1–4, 8, 11)[agent] Follow Get started as written on a machine with nothing set up, in an empty directory: npx @coreplane/switchboard init --organization <org> --anthropic-key <key>wrote: lists .env (mode 600) and config/config.yaml, ls -l .env shows -rw-------, npx @coreplane/switchboard ask "what can you do?" answers; then npx @coreplane/switchboard init --force --organization <org> --anthropic-key <key> --cloudflare <account> --zone <zone>deploy/profile.json and Worker configs from deploy/profile.json: with a written .switchboard/deploy/<worker>/wrangler.jsonc line per Worker, and the tutorial's Part 3 from deploy secrets memory on ends with curl -sS https://<name>.<zone>/healthz answering JSON. Human-gated: needs a real key, a Slack workspace and a Cloudflare account.