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.
- Code:
src/setup/plan.ts(the pure planner:planInit,renderEnv,maskedPreview; answers + templates + the world → the files with modes and secret names, the loaded config, the capabilities, the next commands, or a refusal with a code and every problem);src/setup/host.ts(the host half:readTemplatesfrom the package root,writePlannedFileat its mode,isCheckoutRoot,ttyPrompter,publishedImage,hostSetupIO);src/core/commands/setup.ts(setup.init: the flags, the prompt order, the refusal codes, the hand-off todeploy.init's handler, the render);src/cli.ts(CLI_SHORTHANDS:init=setup init);docker-entrypoint.sh+Dockerfile(the image runs the CLI when given arguments and carries the examples);package.json(bin.switchboard). - Tests:
src/setup/plan.test.ts,src/setup/host.test.ts,src/setup/entrypoint.test.ts,src/core/commands/setup.test.ts; the CLI spelling insrc/cli.test.ts; the registry-driven cases insrc/core/commandConformance.test.tsover thesetupslice ofsrc/core/testing/conformanceFixture.ts; the policy row insrc/core/authz/policy.test.ts. - Docs: Get started (the tutorial built on it), Deploy, Reference: CLI (the generated row), command-registry.md (the command's row in the catalogue).
Behavior
- The files. With a provider given,
initplans.env(mode600) andconfig/config.yaml(mode644), in that order; with--cloudflare <account> --zone <zone>it also plansdeploy/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. .envfrom.env.example. A lineNAME=…— commented out or not — whose name has a value becomesNAME=<value>; an uncommented placeholder with no value is commented out, so nothing readsxoxb-...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\nescapes — 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.config.yamlfromconfig.example.yaml, edited in place. Theyamldocument keeps every comment.organizationis the answer; theanthropicprovider stays only with--anthropic-key; theopenaiprovider stays only with--openai-compatible <baseUrl>, taking that URL, and loses itsapiKeyEnvwhen no--model-keywas given (a local endpoint needs none);--model <m>setsdefaults.models.{general,coding,review}toopenai/<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 saysinitwrote the file.deploy/profile.jsonfromdeploy/profile.example.json.accountandzoneare the answers; the Workers arebot(<name>/<name>.<zone>) andmemory(<name>-memory/<name>-memory.<zone>),--namedefaulting toswitchboard;configSourceis the example's. From the published package the profile has all four Workers —resident(<name>-resident/<name>-resident.<zone>) andsandbox(<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, whichdeploy allcopies 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 passparseProfile— the same validationdeployreads it with — or the refusal carries the profile's own problems. The profile is written wheredeploy allruns 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 directoryinitruns 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)--cloudflareisunavailable, naming both places; the local files need neither.- Coherent flags, every problem at once. No provider;
--openai-compatiblealone without--model;--modelor--model-keywithout--openai-compatible; one Slack token without the other; a partial GitHub App (--github-app-id,--github-installation-id,--github-private-key-fileare all or none);--zonewithout--cloudflare;--cloudflarewithout--zone— each is a line of oneinvalid_inputrefusal, 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. - Nothing overwritten without
--force. A planned path that already exists is aconflictnaming every such path and--force; nothing is written.--forcematters to a real write only: under--dry-run(item 9) an existing path is no conflict, since nothing is written. With--forcethe same files are planned and the output marks each onereplaced; 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.envis created or replaced, and a failed write leaves no temp file. A fileinitdoes not plan is never a conflict. - Proven, then said. The written config is loaded through
parseAppConfigText— the loader the bot and the CLI use — andcapabilitiesFromruns over it with the written.env's variables under the process environment (a shell variable wins over the file, asloadEnvFileIfPresentleaves 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 underworkerConfigswhen a profile was written. No value ever appears. - Next commands, verbatim. In a checkout:
npm run cli -- ask "what can you do?",npm run dev(the bot from source), anddocker compose up -dwithproject.json'simagefact; a profile addsdeploy secrets memory,deploy secrets bot, anddeploy allwithMEMORY_TOKENin its environment. From the published npm package (packaging.md;deps.setup.package()isproject.json'spackagethere and undefined elsewhere):npx <package> ask "what can you do?", thennpx <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-linedocker runof the published image for a machine that would rather run a container, and, with a profile, the deploy steps asnpx <package> deploy …(item 4: onedeploy secretsper Worker of the four). Outside a checkout otherwise (the container):docker runof the published image with--env-file .envandconfig/mounted read-only at/app/config, foraskand for the bot. --dry-run. Nothing is written anddeploy initis 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 isplannedand carries apreview—maskedPreview: everyNAME=…line of a secret name showsNAME=••••••••, everything else as written. The text render prints each preview under a--- <path> ---header.- Flags first, prompts second, never both for one answer. With a prompt (
deps.setup.prompt, a terminal on both stdin and stdout),initasks — 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 isinvalid_inputnaming the flag. The GitHub App and Cloudflare are never prompted. deploy initthrough the registry function. With a profile written, the handler callsdeployInit.handlerwith the samedepsit is bound to — the same profile loader, the same template reader and file writerdeploy inituses on the host — so every Worker the profile names gets itswrangler.jsonc, and a template that cannot render fails exactly asdeploy initfails.- 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 packageSWITCHBOARD_HOME/ the working directory when it already holds an installation /~/.switchboard, created as needed, so nomkdirprecedesinit; when that is not the directory the operator stands in, the first line readswrote to <root>:(deps.setup.root()) — and reads their terminal.cli:localholds every grant; the policy table has one row,setup:writeoncommandfor the grant's holder.initon the CLI isCLI_SHORTHANDSinsrc/cli.ts: the word is replaced bysetup initbefore the shared grammar binds the rest —init --helpis the derived help,init --bogusthe grammar'sinvalid_input— and both name the command as typed:usage: init …, never the registry'ssetup init. - The image installs too.
docker-entrypoint.shis the image'sENTRYPOINT(installed asswitchboard): no arguments run the bot (node dist/index.js— docker compose, the Cloudflare container),node/sh/bashrun as given, anything else runs the CLI (init,ask,<group> <verb>); the Dockerfile has noCMDand carries.env.example,config/config.example.yaml,deploy/profile.example.jsonandproject.jsonunder the paths the host half reads from/app. Its build copiessrc/alone, so no production module undersrc/may import a file outside it —deploy/,packages/andscripts/are not in the image, and a test over every relative import holds that line (an escaping import compiles in the checkout and failsnpm run buildin the image).package.jsonnamesdist/cli.jsas theswitchboardbin and the CLI's source starts with the shebang a bin needs; the CLI's run guard resolvesargv[1]throughrealpathso a bin symlink still startsmain().
Validation criteria
| Criterion | Evidence |
|---|---|
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. |