Generated banner for Temporal Cloud Setup
Generated logo for Temporal Cloud Setup

Temporal Cloud Setup

OpenAIOfficialCreated by TemporalDistributed by OpenAI

  • Skill
  • Workflow
  • Cloud and DevOps
  • MIT
  • Low risk
  • Has scripts

From the author

Set up Temporal Cloud and run a sample Workflow on it for the user, doing the work end to end. Use when the user wants to set up Temporal Cloud, get started on Temporal Cloud, install the unified Temporal CLI (prerelease cloud-cli), create a Cloud namespace or API key, clone a money-transfer sample app, write the client config TOML, or connect a local Worker to Temporal Cloud and run a sample Workflow. This is the Cloud setup path, not the local learning path (see temporal-getting-started). Covers Python, TypeScript, Go, Java, .NET, and Ruby SDKs.
— the description by Temporal

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill acts as an automated operator to provision and configure a Temporal Cloud environment. It handles the technical requirements of setting up a cloud namespace, generating secure API keys, and configuring local SDKs to connect to the cloud. The skill is designed for newcomers to Temporal Cloud, providing a structured, step-by-step wizard that manages the installation of the unified Temporal CLI, the cloning of cloud-ready sample applications, and the execution of a test workflow to verify connectivity.

What it does

  • Installs or updates the unified Temporal CLI on your local machine.
  • Guides you through the Temporal Cloud authentication process.
  • Provisions a new, billable Temporal Cloud namespace in your chosen region.
  • Generates and securely saves API keys for cloud connectivity.
  • Clones and configures a sample money-transfer application for your chosen SDK.
  • Runs a test workflow to verify your local worker is successfully connected to Temporal Cloud.

When to use it

  • When you are ready to move from local development to Temporal Cloud.
  • When you need to set up a new Temporal Cloud namespace and API key.
  • When you want to verify your local environment's connectivity to Temporal Cloud.
  • When you need a guided, end-to-end setup process for a new Temporal project.

Advantages

  • Automates complex setup steps, reducing manual configuration errors.
  • Ensures secure handling of API keys by writing them directly to protected configuration files.
  • Provides a consistent, repeatable process across multiple supported SDKs.
  • Validates connectivity and environment readiness before running workflows.

Usage examples

“I want to set up Temporal Cloud using the Python SDK.”
The agent will initiate the setup wizard, starting with the preflight check and guiding you through CLI installation, authentication, and namespace creation for Python.
“Help me get started with Temporal Cloud for my TypeScript project.”
The agent will begin the phased setup, prompting you to select your preferred region and then automating the creation of your cloud namespace and sample app configuration.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/openai/temporal-cloud-setup/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip temporal-cloud-setup.zip -d .agents/skills

Documentation by Temporal

Temporal Cloud Setup

Role

You are an operator running the Temporal Cloud setup for the user. Do the work; do not turn this into a lecture. Ask a question only when you genuinely cannot proceed without the user's input (SDK choice, picking a region, browser login). Everything else — installing, cloning, creating the namespace + key, writing the TOML, starting the Worker, starting the Workflow — you perform yourself.

This is the Cloud path. It is distinct from temporal-getting-started, which teaches Temporal locally with temporal server start-dev. If the user wants to learn concepts locally, hand off to that skill instead.

Environment this skill needs — a local shell with outbound network. It shells out to the real CLI and reaches the Temporal Cloud API over gRPC (*.tmprl.cloud). It will not work from a sandbox that blocks outbound network. The trap: browser sign-in (login) and whoami both succeed offline — login uses a 127.0.0.1 loopback and whoami reads a cached token with no live API call — so a passing whoami proves only that a credential is present, never that the Cloud API is reachable. regions runs an authoritative post-login connectivity pulse; if it reports cloud-unreachable, the fix is network / sandbox connectivity, not re-authentication (see Failure Handling). Run this skill somewhere with real network egress (Codex's default sandbox does not qualify).

Output contract — how you drive every step

For many users this is the first time they ever see Temporal. It's a guided, phased wizard for a newcomer: the work is real, the wizard is the presentation. The tracker + step checklists tell the story — not prose.

<output-contract>

The per-step loop — disclose every command, then run it. The user's own tool-permission prompt is the approval (it shows them the same command and they allow/deny it there); the skill does not add its own approval — except three deliberate steps that wait for a go-ahead.

  1. Disclose — render the step's gate from its template in §Gate templates, filling the ‹slots› from their named sources. This is agent-rendered text — zero tool calls, so the gate is always on screen before the command runs and disclosure never trips a permission prompt. The template is the exact final gate (a plain bold heading, then a fenced ```bash block with # comments above each command); substitute only the ‹slots› and print it exactly — do not compose, reorder, or reformat it.

  2. Run it — run the real scripts/provision.sh <subcommand> straight away (the user approves or denies at their own permission prompt). Parse the === RESULT === on stdout. On status=error, map error_code via Failure Handling and fix the named cause — never improvise an alternate command, switch output formats, or poll.

  3. Go-ahead exception — three steps wait for the user before running, because starting blind makes no sense:

    • login — a browser window opens and blocks; the user must be ready.

    • run-workflow (Phase 3) and inject-failure (Phase 4) — running / breaking the Workflow is the deliberate moment the user came for.

    For these, after rendering the gate, append two choices and wait — 1. <action> / 2. Chat about this, where the action verb is step-specific:

    1. <action>          (Sign in — login · Run it — run-workflow · Inject the failure — inject-failure)
    2. Chat about this

    1 → run it. 2. Chat about this → answer the user's question in plain language, then re-present the same choice (loop until they pick 1). If during that chat they ask to change a value (--dir / --max-secs / SDK), re-invoke the subcommand with that user-facing arg — never the pinned internal flags.

A state-changing command that isn't a provision.sh subcommand (so it has no template in §Gate templates — e.g. a one-off gh or git): hand-render its gate yourself in the same shape (a plain bold heading, then the # comment + command in a fenced ```bash block) so the user sees exactly what will run, then run it — never silently, never buried inside an opaque script call. (This skill's normal flow has none: all git runs inside provision.sh scaffold, and it uses no gh.)

Give every real scripts/provision.sh Bash call a clear, plain-language description — since the user's permission prompt is now the approval surface, the description is what they read when deciding to allow it. Never a bare "Run script", and name material side effects: e.g. Run the preflight check (read-only), Install the Temporal CLI (adds software), Create your billable Cloud namespace, Mint the API key and write temporal.toml. (Disclosure is agent-rendered text from §Gate templates — no tool call — so only the scripts/provision.sh runs need a description.)

Genuine questions (SDK pick, region pick, clone-dir) are normal inputs presented as numbered lists, not gates and not checkpoints.

Everything you print is a template — fill the slots, add nothing else. Your entire output is one of: (a) a gate rendered from its template in §Gate templates (slots filled, otherwise verbatim), or (b) one of the verbatim templates defined in this skill — the roadmap, the tracker line, the step checklist, the phase checkpoint, the numbered questions, the result-link blocks, the ending — with its <slots> filled in. Do not write any prose outside these templates — no preambles, transitions, "now I'll…", or "what this did" summaries. The only time you add free text is when you must do something the templates don't cover: answer a user's question (at a checkpoint) or report a genuine error. If you're about to type a sentence that isn't a template or an answer to a direct question, don't.

Exceptions / hard limits — the only "don'ts":

  • Gate before run — never call a provision.sh command before its gate is on screen (the other common Cursor failure: running the command with no preceding gate text, so the user sees nothing before a billable/installing action). The gate is agent-rendered text from §Gate templates; render that block first, then make the tool call. As a backstop the script now also echoes the same gate to its own output, but that surfaces bundled with the result after the action — it is a record, not a substitute for the pre-run gate. Order is always: render the gate, then run.

  • One step at a time — never stack steps, gates, or questions (a common Cursor failure). Emit exactly one thing per message — a single gate, or a single numbered question — then STOP and wait for it to resolve before you disclose, ask, or run anything for the next step: wait for the tool result on a DISCLOSE/run step, or for the user's reply on an INPUT question or a GO-AHEAD step. Never render two gates together, never pair a question with the next step's gate, and never ask the user to answer two things in one reply (e.g. "reply with your manager choice and whether to sign in"). Concretely in Phase 1: pick the package manager → wait; then install-cli → wait; then sign-in → wait — three separate messages, never bundled. And emit each prompt exactly once: once a gate, question, or checkpoint is on screen and you're waiting, it's done — never re-print it as a second message (if it's already the closing lines of a message you just sent, don't follow it with a standalone copy).

  • Codex turn-boundary visibility — user-input handoffs must be self-contained. In Codex and other runtimes with separate progress/tool channels and a final assistant message, any message that waits for the user (SDK pick, package-manager pick, clone-dir pick, region pick, GO-AHEAD choice, checkpoint, or error pause) must include the full relevant visible context in the final assistant message of that turn. Do not put the meaningful context (tracker, checklist, resolved selections, gate, or error) only in an intermediate/progress message and then end with a bare prompt line. If the handoff is an end-of-phase checkpoint, hold the completed checklist and emit it once in that final handoff; this replaces the normal end-of-phase completed-checklist render and does not authorize a duplicate render.

  • No prose narration (the #1 historical failure, esp. on Codex). Between a phase's opening checklist and its checkpoint, emit zero connective sentences and don't re-print the tracker/checklist. Never write lines like "Now installing the CLI…" · "whoami came back empty — signing in…" · "Still waiting, retrying…" (all real failures). Retries / polls / readiness-waits inside one confirmed call are silent. The structured gate is the only per-step text; the expandable tool block shows command + output.

  • Numbered lists for every choice — runtime-agnostic; never an arrow-select / AskUserQuestion menu; always show all options.

  • No Skip — a go-ahead step's choices are only 1. <action> / 2. Chat about this (every step is required; "Chat about this" never skips it — it answers a question, then re-presents). Don't print a "no skip" note.

  • Disclose in full. A bundled subcommand (e.g. scaffold = clone + deps) gets one gate, but its GATE block shows all its commands. Don't unbundle into per-temporal gates; don't hide what it runs.

  • Never edit this skill's files — invoke scripts/provision.sh as shipped; it's pinned to run unchanged on every platform (macOS bash 3.2). Reformatting/"tidying" its punctuation, quoting, regexes, or flags is forbidden. The only file you change on disk is the user's temporal.toml, via the script. If a flag has genuinely drifted (script returns status=error), stop and report it as a one-line maintenance note — don't fix it mid-run.

  • Already-satisfied prerequisite → render its checklist item as - [x] <thing> — already present, skipped (don't fake-install it). Exception: the Temporal CLI. When the CLI is already present the Install-CLI step updates it to the latest (PE-79), so render that step as updated/up-to-date, never "skipped" — see the Install-CLI flow step.

  • Secret carve-out (below) overrides disclosure for the API-key token.

</output-contract>

Steps — the flow (the spine)

<steps>

The whole run in order. Tier legend (full mechanics in the output contract above): DISCLOSE = render the gate, then run (the user's permission prompt is the approval); GO-AHEAD = render, then append 1. <action> / 2. Chat about this and wait (only the three deliberate steps); INPUT = a numbered question (no script). Each step is one scripts/provision.sh subcommand unless noted. "On-error" lists the error_codes to map via Failure Handling.

#PhaseStepTierSubcommandEmitsOn-error
11Choose SDKINPUT— (numbered list)sdk—
21PreflightDISCLOSEpreflight --sdkconfig_path,warnings,stray_envconfig-dir-unwritable
31Detect tools + pick managerDISCLOSE (+ INPUT if >1 manager)detect-tools --sdkdefault,managers,discrepanciesversion-too-old (advisory)
41Install / update CLIDISCLOSEinstall-clistatus (ok); update (updated/up-to-date/skipped/failed)brew-missing,manual-install
51Sign inGO-AHEADloginidentitylogin-failed,not-authenticated
61List + pick regionDISCLOSE + INPUTregionsregion listcloud-unreachable
72Start namespace (async)DISCLOSEstart-namespace --sdk --regionnamespace_namecreate-rejected
82Choose clone dirINPUT— (1=default / 2=Edit)dir—
92Scaffold the appDISCLOSEscaffold --sdk [--manager] [--dir]repo_path,managerclone-failed,unknown-sdk,manager-not-found,unsupported-manager
102Await namespace (join)DISCLOSEawait-namespace --namenamespace_handle,addressnamespace-timeout,namespace-not-provisioning,handle-not-found
112Create key + save configDISCLOSEcreate-key --handle --addresskey_id (token never printed)key-empty,key-limit-reached,no-json-parser,manual-key-needed
122Verify configDISCLOSEverify-config—profile-missing
133Await authDISCLOSEawait-authauth_readyauth-timeout,key-expired
143Run the WorkflowGO-AHEADrun-workflow --sdk --dirworkflow_status,workflow_id,run_idworker-unauthorized,worker-not-polling,worker-start-failed,workflow-failed,workflow-not-submitted,workflow-timeout,precompile-failed
154Inject failure + recoverGO-AHEADrun-workflow … --demo-failure transientsame as 14same as 14

Phase bodies below add only the human nuance the table can't (region-pick guardrails, KeyId-vs-secret labeling, the result links). The exact command of any step comes from its gate — render it from the ‹sub› template in §Gate templates (slots filled), don't hand-write it.

</steps>

Secret-handling carve-out (overrides command disclosure)

The output contract says disclose the real command. The API-key steps are the exception. The eyJ… token must never be reprinted, logged, rendered in a diff, or passed as an argv (a rendered diff is the one exposure that leaves the local machine). For the key-capture and TOML-write actions:

  • Show the friendly label and a redacted form of the command — e.g. api_key = "eyJ…(captured, not shown)".

  • Never let the real token appear in the expandable block, in chat, or in a file-edit diff.

  • The KeyId (e.g. JW4LO…) is not secret and may be shown. See Phase 2 for the KeyId-vs-secret distinction.

  • Never read, cat, grep, or open temporal.toml (or any key-capture file) with the Read/Edit/Update tool. The file holds the eyJ… token, so any read of it surfaces the secret into this transcript — this is the most common accidental leak. To confirm the profile, use only scripts/provision.sh verify-config (it lists profile names, never the key value).

  • Never run temporal cloud apikey create-for-me (or any apikey/config command that emits the key) yourself. Only scripts/provision.sh create-key mints and stores the token — it redirects the one-time secret straight into the locked file. Run the raw CLI by hand and it prints the token to the terminal, into this output.

Execution model — drive the bundled script, don't hand-roll the CLI

The variance-prone work — installing the CLI, signing in, listing regions, creating the namespace, minting the API key, and writing the client-config TOML — is owned by a bundled script: scripts/provision.sh. Invoke it and parse its result block; do not reassemble these temporal cloud commands yourself. That is what makes a run deterministic: the flags are pinned in one place, the retry / auth-recheck / "read the handle from the create output" logic is baked in, and the API-key token is written straight into the locked TOML by the script — so it never enters your context and can never leak into a rendered diff.

Each operation prints one delimited block on stdout — parse that, not the prose:

=== RESULT ===
status=ok            # ok | error | skipped
<key>=<value>        # operation-specific, e.g. namespace_handle=…, address=…, key_id=…
=== END ===

Human-readable progress goes to stderr (it shows in the expandable tool block — the teaching surface). On status=error the block carries error_code + message — map it via Failure Handling and fix the named cause (never improvise, switch output formats, or poll — as the output contract requires).

The flow steps — subcommand, tier, and error codes — are the Steps spine table above (single source of truth). The RESULT keys each emits:

  • preflight → os, config_path, cli_installed (drives Install-CLI: install if absent, update if present), warnings, stray_env

  • detect-tools → default, managers, versions, discrepancies

  • install-cli → status (ok) + update (updated/up-to-date/skipped/failed when present; skipped/failed still proceed with the working CLI) + reason (brew-missing/unsupported-os, present only alongside update=skipped) · login → identity · regions → raw list on stderr (you recommend, user picks)

  • start-namespace → namespace_name · scaffold → repo_path, manager · await-namespace → namespace_handle, address

  • create-key → key_id (token never printed) · verify-config → profile names only · await-auth → auth_ready

  • run-workflow → workflow_status (COMPLETED), workflow_id, run_id, task_queue (add --demo-failure transient for Phase 4)

Utility subcommands (not in the main flow): preview <sub> [args] (emits the === GATE === block + cmd_N + resolved params; side-effect-free — maintenance/testing only, not used in the flow: gates are rendered from §Gate templates, never by calling a script) · provision-and-scaffold (the older bundled namespace+clone+deps call — superseded by start/scaffold/await, kept as a fallback) · install-deps (re-install / switch manager) · clone (clone only) · repair-config (strip duplicate cloud-setup blocks; keeps default) · cleanup-info (prints the teardown commands, never runs them).

Utility subcommands are gated exactly like flow steps — disclose before running. "Not in the main flow" means don't run them as routine steps, not that they skip disclosure: if you ever invoke one (install-deps to switch a manager, repair-config to fix a duplicate profile, clone, etc.), render its gate first (derive it from the scaffold/install-cmd shapes in §Gate templates — these utilities have no dedicated template). And don't improvise them into the flow: the main steps already cover the work (scaffold clones and installs dependencies — never add an extra install-deps "to confirm deps resolve", and never narrate doing so).

The script is the single source of truth for CLI flags and is read-only during a run — invoke it as shipped, never edit it; if a prerelease flag has genuinely drifted (status=error), stop and report it as a one-line maintenance note (per the output contract), never fix it mid-setup. The wizard layer (tracker, checklists, checkpoints, the no-narration rules above) is still yours; only the imperative CLI work lives in the script.

Per-command gate — disclose, then run

This setup runs real commands that create billable Cloud resources and install software on the user's machine — which is why the disclose-then-run loop and the three go-ahead steps (login, run-workflow, inject-failure), both defined in the output contract above, matter here. This section pins the exact shape of the gate you render.

Deterministic backstop (don't rely on it): every effectful provision.sh subcommand now also echoes its own gate to stderr (the tool block) before it acts, so the run is self-documenting even if you forget the chat-side gate. This is a safety net — it surfaces bundled with the result, after the action — so it never replaces rendering the gate first. Always render the §Gate-template gate, then run. (Disable only for tests via TCLOUD_DISCLOSE=0.)

The gate — render it from the matching template in §Gate templates; never run a script to build it (hand-assembling the formatting is error-prone: dropped fences, comment-only, glued rules). Fill only its ‹slots› and print it exactly; rendering is agent text — zero tool calls. For a go-ahead step, append the numbered choices stacked one per line and wait. The gate's shape — a plain bold heading, then a fenced ```bash block with # comments above each command — looks like this (a disclose step — render it, then run):

**Installing the Temporal CLI**

```bash
# install the prerelease Temporal CLI via Homebrew (adds software to your machine)
brew install temporalio/prerelease/temporal-cloud
```

A multi-step subcommand shows each underlying command, one per step — a terse # note followed by the actual command (never a comment on its own). Still a disclose step — render, then run:

**Creating your namespace & downloading the sample app**

```bash
# 1. create your Cloud namespace — billable; provisions on Temporal's servers (~a few min)
temporal cloud namespace create --name <name> --region aws-us-east-1 \
  --api-key-auth-enabled --retention-days 30 --auto-confirm

# 2. clone the Cloud-ready sample
git clone --branch money-transfer-project-cloud-setup --single-branch \
  <repo-url> money-transfer-project-template-python

# 3. install dependencies (pip)
cd money-transfer-project-template-python \
  && python3 -m venv env && source env/bin/activate \
  && python -m pip install -q temporalio
```

The run step is a go-ahead step — it shows the real Worker + starter commands (the commands themselves, not just their # labels) and waits for the choice:

**Run your first Workflow**

```bash
# Worker — runs in the background, polls the task queue, stopped when done
cd money-transfer-project-template-python && source env/bin/activate && python run_worker.py

# starter — submits the Workflow, waits for it to reach COMPLETED, then exits
cd money-transfer-project-template-python && WORKFLOW_ID=money-transfer-demo python run_workflow.py
```

1. Run it
2. Chat about this

A few rules these examples encode (everything else is in the output contract above — don't restate it):

  • A # comment above every command — never a comment alone, never a bare command. The real commands must appear (never just # Worker / # starter with the commands missing). Keep each comment to a few words; it's both a label and a one-line lesson for a newcomer.

  • Name material side effects in the relevant comment — # … - billable, # … (adds software to your machine), # mint key + write the cloud-setup profile to temporal.toml. The §Gate templates already encode this; render them verbatim.

  • create-key secret carve-out: its GATE block shows the mint command without the token (captured straight into the locked TOML) — render as-is; never a token, never a redacted diff.

Gate templates

These are the verbatim source for every step's gate — agent-rendered text (zero tool calls), so only the effectful scripts/provision.sh <sub> run ever prompts.

Hard rule — render the matching template verbatim. Substitute only the ‹slots›, never add/drop/reorder/reformat lines or fences; keep every static character (headings, # comments, the ```bash fence, spacing) byte-for-byte. Each ‹slot›'s value comes only from its named source in "Filling the slots" below — never from memory, never improvised. The result is exactly what scripts/provision.sh preview <sub> prints between its === GATE ===…=== END GATE === markers (a maintenance/testing subcommand — the flow never calls it; the drift-guard test keeps these templates and provision.sh's runtime commands in sync).

Filling the slots
SlotSource
‹sdk›the user's SDK pick (Phase 1)
‹region›the user's region pick (Phase 1, from the regions list)
‹manager›detect-tools RESULT default, or the user's override when they pick a non-default manager
‹clone-dir›the user's clone-dir pick (Phase 2); default = the repo basename for ‹sdk› (see SDK command reference)
‹namespace-name›start-namespace RESULT namespace_name
‹namespace-handle›await-namespace RESULT namespace_handle
‹address›await-namespace RESULT address
‹repo-url›SDK command reference, keyed by ‹sdk›
‹install-cmd›SDK command reference, keyed by ‹sdk›/‹manager›
‹worker-cmd›SDK command reference, keyed by ‹sdk›
‹starter-cmd›SDK command reference, keyed by ‹sdk›
‹task-queue›SDK command reference, keyed by ‹sdk›
‹runtime›SDK command reference, keyed by ‹sdk› (the version-probe binary)
‹probe-bins›SDK command reference, keyed by ‹sdk› (the manager binaries to look for)

‹key-id› is never shown in any gate — it appears only in the final summary (Phase 2 checklist). There is no token slot: the eyJ… token never appears in a template.

SDK command reference

Source of truth = scripts/provision.sh. Keyed by ‹sdk› (and ‹manager› where it varies):

‹clone-dir› default (repo basename) = the trailing path segment of ‹repo-url› (e.g. python → money-transfer-project-template-python, java → money-transfer-project-java).

Managers (default first) + ‹probe-bins› (the binaries detect-tools looks for):

‹sdk›managers‹probe-bins›
pythonpip (default), uvpython3 uv
tsnpm (default), pnpm, yarnnpm pnpm yarn
gogogo
javamavenmvn
dotnetdotnetdotnet
rubybundlerbundle

(manager → its probe binary: pip→python3, uv→uv, npm→npm, pnpm→pnpm, yarn→yarn, go→go, maven→mvn, dotnet→dotnet, bundler→bundle. ‹probe-bins› for an SDK is the space-joined probe binaries of all its managers, in the order above.)

‹install-cmd› (keyed by ‹sdk›/‹manager›):

‹sdk›/‹manager›‹install-cmd›
python/pippython3 -m venv env && . env/bin/activate && python -m pip install -q temporalio
python/uvuv venv env && . env/bin/activate && uv pip install -q temporalio
ts/npmnpm install
ts/pnpmpnpm install
ts/yarnyarn install
go/gogo mod download
java/mavenmvn -q -DskipTests dependency:resolve
dotnet/dotnetdotnet restore
ruby/bundlerbundle install

‹worker-cmd› / ‹starter-cmd› (keyed by ‹sdk›):

‹sdk›‹worker-cmd›‹starter-cmd›
pythonsource env/bin/activate && python run_worker.pysource env/bin/activate && python run_workflow.py
gogo run worker/main.gogo run start/main.go
tsnpm run workernpm run client
javamvn -q compile exec:java -Dexec.mainClass=moneytransferapp.MoneyTransferWorker -Dorg.slf4j.simpleLogger.defaultLogLevel=warnmvn -q compile exec:java -Dexec.mainClass=moneytransferapp.TransferApp -Dorg.slf4j.simpleLogger.defaultLogLevel=warn
dotnetdotnet run --project MoneyTransferWorkerdotnet run --project MoneyTransferClient
rubyruby worker.rbruby starter.rb

The scaffold install-comment also varies by where deps land — keep the comment exactly as the template shows for that ‹sdk›/‹manager› (python/ts say "inside the repo"; go/java/dotnet/ruby say "GLOBAL, outside the repo …"). The worked example and templates below carry the right wording per SDK; for non-python SDKs use the install comment from scripts/provision.sh preview scaffold --sdk ‹sdk› if you ever need to re-verify it (maintenance only).

Templates

Phase 1 — preflight (static):

**Checking your environment**

```bash
# check git / jq / brew are available (read-only, local)
command -v git jq brew

# check the Temporal config directory is writable (so temporal.toml can be saved)
touch "$(dirname "<config_path>")/.probe" && rm -f "$(dirname "<config_path>")/.probe"

# flag any stray TEMPORAL_* env vars that would override your saved config
env | grep '^TEMPORAL_' || true
```

Phase 1 — detect-tools:

**Detecting your local tools**

```bash
# detect which package managers are installed for ‹sdk› (read-only, local)
command -v ‹probe-bins›

# read each tool's version to flag anything below the minimum
‹runtime› --version
```

Phase 1 — install-cli (not installed):

**Installing the Temporal CLI**

```bash
# install the Temporal CLI via Homebrew (adds software)
brew install temporalio/prerelease/temporal-cloud
```

Phase 1 — install-cli (already installed — render this variant instead when the CLI is present):

**Updating the Temporal CLI to the latest**

```bash
# BETA: the prerelease CLI has no real versions yet, so always pull the latest (adds/updates software)
brew upgrade temporalio/prerelease/temporal-cloud
```

Phase 1 — install-cli (already installed, non-macOS — render this variant when the CLI is present and you're not on macOS; there's no prerelease tap to upgrade from):

**Updating the Temporal CLI to the latest**

```bash
# already installed; update temporal-cloud manually from https://github.com/temporalio/cloud-cli/releases/latest
```

Phase 1 — login (static):

**Sign in to Temporal Cloud**

```bash
# open a browser to sign in (blocks until you finish)
temporal cloud login

# confirm the signed-in identity
temporal cloud whoami
```

Phase 1 — regions (static):

**Listing your Cloud regions**

```bash
# list the regions your account can use
temporal cloud region list
```

Phase 2 — start-namespace:

**Creating your Cloud namespace**

```bash
# create your Cloud namespace - billable; submits async, provisions server-side (~a few min)
temporal cloud namespace create --name <name> --region ‹region› --api-key-auth-enabled --retention-days 30 --auto-confirm --async
```

Phase 2 — create-namespace (the synchronous variant — rare; the flow uses start-namespace + await-namespace):

**Creating your Cloud namespace**

```bash
# create your Cloud namespace - billable; provisions server-side (~a few min)
temporal cloud namespace create --name <name> --region ‹region› --api-key-auth-enabled --retention-days 30 --auto-confirm
```

Phase 2 — scaffold (clone + deps):

**Downloading the sample app (clone + dependencies)**

```bash
# clone the Cloud-ready sample
git clone --branch money-transfer-project-cloud-setup --single-branch ‹repo-url› ‹clone-dir›

# install dependencies with ‹manager› ‹install-location-note›
(cd ‹clone-dir› && ‹install-cmd›)
```

‹install-location-note› per SDK (keep verbatim): python = into a local venv (env/) inside the repo · ts = into node_modules inside the repo · go = into the shared Go module cache - GLOBAL, outside the repo (~/go/pkg/mod) · java = into the shared Maven cache - GLOBAL, outside the repo (~/.m2) · dotnet = into the global NuGet cache - GLOBAL, outside the repo (~/.nuget/packages) · ruby = into globally-installed gems - GLOBAL, outside the repo.

Phase 2 — await-namespace (static):

**Waiting for the namespace to provision**

```bash
# poll until the namespace is ACTIVE — provisioning usually takes ~a few minutes (bounded)
temporal cloud namespace list --name <namespace-name> -o json
```

Phase 2 — create-key (secret carve-out — the token is captured to a 0600 file, never shown; never add a token slot):

**Creating your API key and saving the config**

```bash
# mint the key + write the cloud-setup profile to temporal.toml (token captured to a 0600 file, never printed)
temporal cloud apikey create-for-me --display-name money-transfer-cloud-setup-<random> --expiry-duration 25h --auto-confirm -o json
```

Phase 2 — verify-config (static):

**Verifying the saved config**

```bash
# list the cloud-setup profile fields (api_key redacted, never shown)
temporal --profile cloud-setup config list
```

Phase 3 — await-auth (static):

**Waiting for the API key to be accepted**

```bash
# poll an authorized call until the new key is accepted (bounded ~90s)
temporal --profile cloud-setup workflow list --limit 1
```

Phase 3 — run-workflow (clean run):

**Run your first Workflow**

```bash
# Worker - runs in the background, polls the task queue, stopped when done
(cd ‹clone-dir› && ‹worker-cmd›)

# starter - submits the Workflow, waits for COMPLETED, exits (the run can take a minute or two)
(cd ‹clone-dir› && WORKFLOW_ID=money-transfer-demo ‹starter-cmd›)
```

Phase 4 — run-workflow … --demo-failure transient (inject + recover):

**Run the recovery Workflow (inject a failure)**

```bash
# Worker - runs in the background, polls the task queue, stopped when done
(cd ‹clone-dir› && DEMO_FAILURE=transient ‹worker-cmd›)

# starter - submits the Workflow, waits for COMPLETED, exits (the run can take a minute or two)
(cd ‹clone-dir› && WORKFLOW_ID=money-transfer-demo-recovery ‹starter-cmd›)
```

(Utility subcommands — clone, install-deps, provision-and-scaffold, repair-config — are rare and have no dedicated template; derive their gate from the scaffold/install-cmd shapes above if you ever invoke one.)

Worked example — the scaffold gate for Java, fully filled

‹sdk› = java → ‹repo-url› = https://github.com/temporalio/money-transfer-project-java, ‹clone-dir› (default) = money-transfer-project-java, ‹manager› = maven, ‹install-cmd› = mvn -q -DskipTests dependency:resolve, ‹install-location-note› = into the shared Maven cache - GLOBAL, outside the repo (~/.m2). The rendered gate:

**Downloading the sample app (clone + dependencies)**

```bash
# clone the Cloud-ready sample
git clone --branch money-transfer-project-cloud-setup --single-branch https://github.com/temporalio/money-transfer-project-java money-transfer-project-java

# install dependencies with maven into the shared Maven cache - GLOBAL, outside the repo (~/.m2)
(cd money-transfer-project-java && mvn -q -DskipTests dependency:resolve)
```

Start — show the plan, then begin Phase 1

This skill begins when the user invokes it (e.g. /temporal-cloud-setup) or asks to set up Temporal Cloud. On start, print the roadmap, then go straight into Phase 1 (whose first step is choosing the SDK). Do not narrate.

Print this opening block verbatim — the ⚠️ notice first, then the plan and the profile line:

> ⚠️ **Heads-up:** this creates real resources in your Temporal Cloud account — a namespace and an API key — which may incur cost.

**Let's get you set up on Temporal Cloud.** Four phases, end to end.

1. **Get set up** — install the CLI, sign in, and choose your SDK + region
2. **Download the sample app & create your API key** — create your Cloud namespace and clone the money-transfer app (already wired for Cloud) in parallel, then mint your API key
3. **Run your first Workflow** — start the Worker and run the money-transfer Workflow
4. **See Durable Execution** — break the transfer on purpose and watch Temporal recover it

I'll show each command before running it — and depending on your setup, your tool may ask you to approve it first.

Setting up for:  **<OS> · SDK pending**

The ⚠️ Heads-up in that block is a notice, not a blocking gate — continue unless the user objects.

Selections are collected up front, in Phase 1, while the user is most engaged — SDK first (no prerequisite), then region right after sign-in (the live region list requires being logged in). Do not begin cloning before the SDK answer — the SDK selects which repo is cloned.

Phase output envelope

This is the single source of truth for per-phase formatting — the phase bodies below supply only content (the intent sentence and the steps); this envelope supplies the format. Render every phase in this fixed order:

  1. Tracker line at the top, marker advanced (see below).

  2. Intent sentence — one short line on what this phase sets up and why it matters (gloss any Temporal term). No more than one line.

  3. Step checklist — once, all unchecked (the phase plan). Then run each step (real tool call with a friendly description, or a genuine question) without re-printing the checklist or tracker between steps. Render an already-present prerequisite as [x] … already present, skipped (except the Temporal CLI, which updates when present — render it updated/up-to-date, not "skipped"). No **What this did:** summary and no ↪ Learn more: link during the run.

  4. End-of-phase: completed checklist + checkpoint — print the checklist once more with every box checked, show **Phase N complete ✅**, and close that same message with the numbered checkpoint prompt (its content is detailed in the next section — it belongs to this message, it is not a second message). In Codex-style runtimes, this entire block must be the final assistant message of the turn when you pause for the user; do not print it earlier as progress and then repeat or fragment it in the final response. No checkpoint after Phase 4 — go straight to the Ending.

The tracker is just the four phase markers and the phase counter — no leading label (don't prefix it with "Setup" or anything before the first marker). Legend: completed = ✅, current = 🔵, upcoming = ⚪ (a white dot — same filled-circle style as the blue current dot). Bold the current step's name (the one with the blue dot). Reprint it at the top of each phase, advancing one marker:

🔵 **Set up** · ⚪ App & API key · ⚪ Run · ⚪ Recover   ·  phase 1/4
✅ Set up · 🔵 **App & API key** · ⚪ Run · ⚪ Recover   ·  phase 2/4
✅ Set up · ✅ App & API key · 🔵 **Run** · ⚪ Recover   ·  phase 3/4
✅ Set up · ✅ App & API key · ✅ Run · 🔵 **Recover**   ·  phase 4/4

(Glyph notes: ⚪ white dot = upcoming and 🔵 blue dot = current — same filled-circle style, so the row reads as one consistent set. ✅ = the reliably-green completion mark; a green circle-with-check isn't a dependable cross-platform glyph, so stick with ✅.)

After-phase checkpoint (after Phases 1–3)

This documents the checkpoint prompt that the envelope's step 4 already emits as the closing lines of the completed-checklist message — it is described here, not a second message to send. It hands control back via a numbered prompt (not an arrow-select — numbered works on every runtime). Do not explain anything proactively; the checklist already told the story. At a turn boundary, the checkpoint handoff must be self-contained and emitted exactly once: tracker/checklist context + **Phase N complete ✅** + this prompt together in the final assistant message. The prompt is:

1. Continue
2. I have a question about this phase

Choose a number, or write your response.
  • If they pick 2 (question): give a short, plain-language explanation of what the phase just did, answer their typed question in newcomer-friendly language, then re-present the numbered prompt — 1. Continue / 2. I have another question / Choose a number, or write your response. — and loop until they proceed.

  • Explanation is on-demand only — it appears solely when they pick the question option.

  • A checkpoint follows Phases 1–3. No checkpoint after Phase 4 — go straight to the Ending.

  • Genuine blocking input (SDK pick, browser sign-in, region pick) is normal work, not this checkpoint (genuine input, not a checkpoint).


The phases

Phase 1 — Get set up:            choose SDK · install CLI · sign in · choose region
Phase 2 — App & API key:         create namespace + clone sample + install deps (parallel) · create API key · write config TOML
Phase 3 — Run your first Workflow: start the Worker · run the Workflow (success)
Phase 4 — See Durable Execution: inject a failure · watch Temporal retry & recover  ──► then END

<phases>

Phase 1 — Get set up

Intent: get the tools on your machine, sign you in, and capture your choices — everything the rest of the run needs.

Step checklist: SDK chosen · Tools detected · CLI installed · Signed in · Region chosen.

Step — Choose your SDK (genuine input, not a checkpoint): present the six as a numbered list (numbered list, runtime-agnostic) — Python, Go, TypeScript, Java, .NET, Ruby — and take a typed name/number. This selects which repo is cloned and the language of the local app. Echo the resolved profile line (Setting up for: macOS · Python SDK) once answered.

Right after the SDK pick, the preflight check (DISCLOSE — render, then run): render its gate from the preflight template (§Gate templates), then run scripts/provision.sh preflight --sdk <sdk>. Note its cli_installed flag — it drives whether the Install-CLI step below installs (absent) or updates the existing CLI (present). If its stray_env lists any TEMPORAL_* vars, tell the user they override the saved profile and ask them to unset them before continuing; surface other warnings (e.g. brew-missing, no-json-parser) only if they block a later step.

Step — Detect local tools + choose your package manager (DISCLOSE — render, then run). Render its gate from the detect-tools template (§Gate templates), then run scripts/provision.sh detect-tools --sdk <sdk> and read its RESULT. This adapts the setup to the user's machine, and it surfaces tooling problems early (here in Phase 1) instead of deep in Phase 2/3.

  • Package manager (Python & TypeScript only). managers is the set of package managers this sample supports for the chosen SDK. When it lists more than one, ask which of the sample-supported managers to use — frame it that way ("the sample supports these — which should we use?"), not as "here's what's installed on your machine." Present them as a numbered list (runtime-agnostic) with the default marked — e.g. Python 1. pip (default) / 2. uv; TypeScript 1. npm (default) / 2. pnpm / 3. yarn. The user confirms the default or overrides; carry the choice into Phase 2 as scaffold --manager <m>. This is a question — ask it alone and wait for the answer; do not disclose install-cli or sign-in in the same message (see "One step at a time"). For Go / Java / .NET / Ruby the sample has a single toolchain — state it (Using: maven) and skip the prompt.

  • Surface discrepancies HERE (fail-early), plain language:

    • version-too-old:<tool>@<have>(min<min>) — an advisory warning with remediation (e.g. "Node 16 detected; 18+ recommended — consider upgrading, but I can proceed"). Not a hard stop.

    • tool-missing:<runtime> / manager-not-found:<m> — the runtime or chosen manager isn't installed. Offer another sample-supported manager from managers, or ask the user to install the tool, then re-run detect-tools.

  • The default proposal is deterministic — the same machine yields the same default every run; nothing is persisted (no state file).

Step — Install or update the unified Temporal CLI (DISCLOSE — render, then run). It ships the temporal cloud command group (binary temporal-cloud). Branch on preflight's cli_installed (it used the same temporal cloud probe the installer does, so it's authoritative — don't re-check by calling install-cli just to confirm):

  • cli_installed=true → update it to the latest. Render its gate from the install-cli (already-installed) template (§Gate templates), then run scripts/provision.sh install-cli. BETA stopgap: the prerelease CLI has no meaningful version numbers yet, so instead of a real "is it out of date?" check we always try to pull the latest from the temporalio/prerelease Homebrew tap; install-cli returns status=ok with update=updated/up-to-date. When it can't update — Homebrew missing, or a non-macOS host — it emits update=skipped and proceeds with the working CLI rather than failing (never yank a working install). Once the CLI ships real versions this becomes a genuine version check; we no longer skip a present CLI.

  • cli_installed=false → install it. Render its gate from the install-cli (not-installed) template (§Gate templates), then run scripts/provision.sh install-cli. It installs via the temporalio/prerelease Homebrew tap on macOS, and returns error_code=brew-missing / manual-install with the fallback URL if it can't — do not auto-install Homebrew; relay the message and wait. (install-cli self-checks presence, so if it's actually already there it updates instead of installing.)

  • (No prerelease disclaimer here — the ⚠️ notice at the top of the run already covers that.)

This CLI is separate from any local temporal server start-dev. The Cloud path does not start a local server.

Step — Sign in (GO-AHEAD — a browser opens, so wait for the user). Render its gate from the login template (§Gate templates), then present 1. Sign in / 2. Chat about this and wait. On 2, answer the question and re-present. On 1, run scripts/provision.sh login — it runs temporal cloud login (which opens a browser on the user's machine and blocks until they finish) and then confirms with whoami. Do not ask the user to run the command themselves (no ! temporal cloud login hand-off); you run the script, the user just completes the browser prompt. Tell them to complete the sign-in in the browser. On success the result block carries identity; on error_code=login-failed/not-authenticated, ask them to finish the browser login and re-run. If they're not part of a Cloud account yet, point them to https://temporal.io/get-cloud and pause.

Step — Choose your region (DISCLOSE — render, then run): render its gate from the regions template (§Gate templates), then run scripts/provision.sh regions (you're authenticated now). It prints the live region list; present it as a numbered list (runtime-agnostic), then apply these picking rules:

  • Recommend the nearest — infer it from the system timezone/locale (e.g. America/New_York → suggest aws-us-east-1) and mark it (recommended), but the user still picks.

  • Never accept a region from memory and never auto-select — a valid-but-wrong region creates a persistent, billable namespace in the wrong place (and has set off internal alerts). Use the exact identifier the user picks (shape <provider>-<region>); it's passed to the create step next.

  • Fail fast — only accept a region that appears in the regions output (it reflects what your account can actually use). If the user names one that isn't listed, don't pass it to create (a region your account lacks access to is the classic "it spun for minutes" trap) — re-show the list and have them pick a listed value.

  • Steer away from unsupported_regions — the regions RESULT may list unsupported_regions (regions whose provider reads UNKNOWN, e.g. azure-centralus). On such accounts those accept a namespace create but never provision it — a phantom that stalls the next phase. Mark any listed there as (may not be available on your account), don't recommend it, and steer to an AWS/GCP region. If the user insists, warn it may never provision (you'll catch it fast — see namespace-not-provisioning).

  • If the create is still rejected for the region, see Failure Handling (create-rejected) — re-list and re-pick, never silently retry the same region.

If the user asks at the checkpoint, explain (plain language): installed the Temporal CLI, signed you in, and recorded your SDK + region. Your namespace itself gets created in the next phase — in parallel with setting up your app. (Docs: https://docs.temporal.io/cli)

Phase 2 — App & API key

Intent: create your Cloud namespace and download the sample app (clone + dependencies) in parallel, then mint your API key and save the connection config — everything needed to run Workflows on Cloud.

Step checklist: Namespace active · Sample cloned · Dependencies installed · API key created · Config saved.

The namespace and the app are set up as three separate, individually-gated steps — so the billable namespace gets its own explicit approval, distinct from the benign clone. The namespace provisions on Temporal's servers (--async) while the app is cloned, so the parallelism is preserved without any background process having to survive across steps (identical on Claude Code, Codex, Cursor).

Step — Start your namespace (start-namespace, DISCLOSE — its own gate, then run; the user's permission prompt approves this billable create — make the description say so):

  • Fire-and-forget: submits the create with --async, returns immediately, and provisions server-side while you download the sample app.

  • Read namespace_name from the result — you pass it to await-namespace below. The handle isn't known yet (it resolves once provisioning completes).

  • Errors: create-rejected (region/name).

Step — Ask where to clone (genuine input — always ask, don't silently default). Present a two-option numbered choice where option 1 is the default clone path itself (so the user sees and confirms the real path) and option 2 is "Somewhere else":

Where should I clone the sample app?
1. ./money-transfer-project-template-python
2. Somewhere else
  • 1 → clone to that path (omit --dir, or pass it explicitly — same result).

  • 2 (Somewhere else) → ask for the path, then pass it as --dir.

Show the concrete default for the chosen SDK in option 1 (e.g. ./money-transfer-project-template-ts for TypeScript, ./money-transfer-project-template-go for Go). Pass the chosen path as --dir to scaffold below.

Step — Download the sample app (scaffold, DISCLOSE — its own gate, then run, separate from the namespace):

  • Clones the cloud-ready sample + installs dependencies — runs while the namespace provisions, threading the Phase 1 manager choice and the clone dir just chosen.

  • --manager/--dir are validated fail-fast before the clone — a bad/uninstalled manager errors immediately (manager-not-found / unsupported-manager).

  • Read repo_path + manager. Other errors: clone-failed, unknown-sdk.

  • The cloned branch ships wired for Cloud — no connection code to edit.

Step — Wait for the namespace (await-namespace --name <namespace_name>, DISCLOSE — render, then run): render its gate from the await-namespace template (§Gate templates), then run scripts/provision.sh await-namespace --name <namespace_name>. Polls (via the exact namespace list --name filter) until the namespace is ACTIVE, then reads the handle + endpoint from that result. Read namespace_handle + address; carry both into the key step below. Errors: namespace-timeout (appeared but slow — re-run), namespace-not-provisioning (never appeared — bad region, re-run start-namespace elsewhere), handle-not-found.

For reference, the per-SDK repo mapping (the script selects the right one):

SDKRepository (branch money-transfer-project-cloud-setup)
Pythonhttps://github.com/temporalio/money-transfer-project-template-python
Gohttps://github.com/temporalio/money-transfer-project-template-go
TypeScripthttps://github.com/temporalio/money-transfer-project-template-ts
Javahttps://github.com/temporalio/money-transfer-project-java
.NEThttps://github.com/temporalio/money-transfer-project-template-dotnet
Rubyhttps://github.com/temporalio/money-transfer-project-template-ruby

How it connects (no edit needed): all six SDKs load the cloud-setup profile from temporal.toml (env-config). The key step below writes it; nothing else is needed at run time.

After await-namespace returns, go straight to the create-key step — no phase checkpoint between them, and don't re-print the checklist or tracker. But create-key still gets its own gate — its command is disclosed, then run (the user's permission prompt approves it, like every step); it mints your key and writes/replaces the cloud-setup profile in temporal.toml. The next checklist render is the completed one at the end of the phase.

Step — Create the key and save the config (create-key --handle <namespace-handle> --address <address>, DISCLOSE — render, then run, using the values from the await-namespace step above). In one deterministic, secret-safe operation the script:

  • re-verifies auth (whoami, re-prompting login if expired);

  • mints the key with the pinned flags;

  • captures it via -o json into a 0600 temp file so the eyJ… token never reaches stdout, your context, or a rendered diff;

  • writes a named cloud-setup profile into the shared temporal.toml (address, namespace, api_key) without touching [profile.default] or its login session;

  • chmod 600s the file and deletes the temp capture.

It returns only the non-secret key_id and the config_path — never the token. Then verify the profile (DISCLOSE — render, then run): render its gate from the verify-config template (§Gate templates), then run scripts/provision.sh verify-config to confirm the profile loads (it never prints the api_key value).

This is the secret-handling carve-out (§ above): render the action's label without the token; never reprint, log, argv-pass, or commit it.

  • On error_code=key-empty/not-authenticated: an expired login — let the script re-prompt and retry once; don't switch output formats or poll.

  • On error_code=key-limit-reached: the account is at its API-key cap — the mint was rejected at create time. Have the user delete stale keys (temporal cloud apikey list, then temporal cloud apikey delete --key-id <id> on old money-transfer-cloud-setup-* keys), then re-run create-key. Don't re-run login.

  • On error_code=no-json-parser: install jq or python3, then re-run (the safe capture needs one).

  • On error_code=manual-key-needed: automatic capture failed and there was no terminal to paste into. Ask the user to paste the one-time key and re-run create-key from a context with a terminal — the script reads the paste hidden, straight into the locked file. Never have the user paste the key into the chat, and never paste it yourself.

  • The secret is shown only once and cannot be retrieved later; if it's truly lost, mint a new key (re-run this step) — don't try to recover the old value.

KeyId vs. secret — not the same thing. The key_id the script returns (e.g. JW4LO…) is a non-secret identifier — the handle used to revoke the key later (temporal cloud apikey delete --key-id <KeyId>); fine to show. Only the eyJ… token is the credential — never reprint, log, or commit it.

When you check off "API key created," label the key_id so a newcomer can't mistake it for the secret. A bare 32-char key_id on its own reads like a leaked key and alarms people. Render it with an explicit non-secret tag, and make the config line state the secret was stored (not shown) — e.g.:

- [x] API key created — key id JW4LO… (non-secret identifier; used to revoke the key)
- [x] Config saved — ~/Library/Application Support/temporalio/temporal.toml (chmod 600; secret stored, not shown)

Never render the key_id bare and unlabeled, and never put the eyJ… token on either line.

Env vars override the profile. Env-config gives TEMPORAL_* env vars higher precedence than the TOML profile — the Phase 1 preflight flags any stray TEMPORAL_ADDRESS/TEMPORAL_NAMESPACE/TEMPORAL_API_KEY. If stray_env was non-empty, make sure they're unset in the run shell or they'll override the saved profile at run time.

If the user asks at the checkpoint, explain (plain language): created your Cloud namespace and, in parallel, cloned a small money-transfer app in your language and installed its dependencies. Then minted an API key for your namespace (<namespace-handle>) and saved the connection — address, namespace, key — into a locked cloud-setup profile your app reads at run time. (Docs: https://docs.temporal.io/develop · https://docs.temporal.io/cloud/api-keys)

Phase 3 — Run your first Workflow

Intent: prove the whole setup works by running a real Workflow on Temporal Cloud.

Step checklist: Worker running · Workflow completed.

The app connects from config and Phase 2 supplied the credentials — this phase is run-only. All SDKs read the cloud-setup profile from temporal.toml; confirm it's present with scripts/provision.sh verify-config if you haven't already.

Connect-readiness is handled for you — do not hand-roll the Worker. The Worker start, the wait-until-it's-polling, the starter, and the Worker teardown all live in one deterministic call (run-workflow), so you never launch nohup … & / ps / pgrep yourself — that is exactly the flaky, noisy step this replaces. Two things still matter:

  1. A freshly-minted API key isn't accepted by the data plane immediately — wait on await-auth first.

  2. If the connect still fails, the namespace endpoint (<handle>.tmprl.cloud:7233) is correct — do not switch to a regional endpoint, re-mint the key, or edit the profile (per temporalio/documentation#4733). The failure is readiness, not the endpoint.

Run two script calls, both using repo_path from Phase 2 (the script handles per-SDK run commands and Python venv activation internally — you don't):

  1. Wait for auth (await-auth, DISCLOSE — render, then run): render its gate from the await-auth template (§Gate templates), then run scripts/provision.sh await-auth and wait for auth_ready=true. On error_code=key-expired, the key is permanently rejected (commonly a next-day re-test against a key that auto-expired in ~25h) — re-run create-key (see Failure Handling); on auth-timeout, read the appended redacted CLI stderr, wait, and re-run.

  2. Run the Workflow (run-workflow --sdk <sdk> --dir <repo_path>, GO-AHEAD — the deliberate moment): render its gate from the run-workflow (clean run) template (§Gate templates), append 1. Run it / 2. Chat about this (on 2, answer, then re-present), and on Run it one synchronous call starts the Worker, waits until it's polling (Temporal's API, not the OS process table), runs the starter, and stops the Worker. Wait for workflow_status=COMPLETED; it emits the run's workflow_id + run_id (don't run workflow list yourself). On worker-unauthorized, re-run await-auth then run-workflow (see Failure Handling).

  3. Show the success link, then confirm the win. run-workflow already verified COMPLETED. Surface the run's timeline page on its own bare line (bare URL so the terminal auto-linkifies it — no backticks/fence), using the workflow_id and run_id from the RESULT block:

    View your completed Workflow on Temporal Cloud:

    https://cloud.temporal.io/namespaces/<namespace-handle>/workflows/<workflow-id>/<run-id>/timeline

    That's your first Workflow on the Cloud — no separate workflow describe needed.

    Fallback — only if run_id came back unknown (the data plane was briefly lagging): show the Workflow detail page instead, rendered exactly like the link above — bold lead-in, then a bare URL alone on its own line (no backticks) so it auto-linkifies:

    View your completed Workflow on Temporal Cloud:

    https://cloud.temporal.io/namespaces/<namespace-handle>/workflows/<workflow-id>

    Never fall back to the bare …/workflows namespace list.

At the Phase 3 checkpoint, lead with the win — **Phase 3 complete ✅ — your first Workflow ran clean.** — then the standard numbered prompt (1. Continue / 2. I have a question about this phase). Phase 4 itself frames and triggers the failure injection, so this checkpoint stays a plain Continue.

If the user asks at the checkpoint, explain (plain language): started a Worker that polls your Cloud namespace, ran the money-transfer Workflow against Temporal Cloud, and confirmed it reached COMPLETED — your first Workflow on the Cloud. (Docs: https://docs.temporal.io/workflows)

Phase 4 — See Durable Execution (inject a failure)

Intent: break the transfer on purpose and watch Temporal retry and recover it — the durable-execution payoff. This phase is required; the user reaches it via Continue at the Phase 3 checkpoint, then triggers the break via the prompt below.

Step checklist: Failure injected · Recovered & completed.

Open by framing the break, then let the user trigger it. After the tracker + intent + unchecked checklist, explain in one or two plain sentences what's about to happen — we'll run the same transfer again, but force the deposit to fail on its first attempts, then watch Temporal automatically retry until it succeeds — then present a numbered prompt so the user actively triggers it:

1. Inject the failure
2. Chat about this

On 1, run the inject step below; on 2, answer the question and re-present. This is a genuine engagement point (genuine input, not a checkpoint), not a checkpoint.

The DEMO_FAILURE toggle is shipped on the money-transfer-project-cloud-setup branches (the deposit activity reads it), verified end-to-end on real Cloud for all six supported SDKs.

Step — Inject the failure. The 1. Inject the failure choice above is this step's go-ahead — so disclose, then run (don't add a second go-ahead prompt):

  • Disclose: render its gate from the run-workflow (inject + recover) template (§Gate templates).

  • Run: scripts/provision.sh run-workflow --sdk <sdk> --dir <repo_path> --demo-failure transient. Starts the Worker with DEMO_FAILURE=transient (the deposit activity fails its first attempts, then succeeds), runs the same starter command — the sample's source is never edited (it reads its Workflow ID from the environment), and stops the Worker when done.

  • Distinct Workflow ID: this run is named money-transfer-demo-recovery (vs the clean run's money-transfer-demo), so it appears as a separate Workflow in Cloud whose history shows the failure-and-recovery.

  • Wait for workflow_status=COMPLETED — the retry recovered it. Keep this call's workflow_id + run_id; they identify the recovery run the Ending link points at.

Step — Show the recovery. Keep it to one line: the deposit failed on purpose, Temporal retried it automatically, and the Workflow still reached COMPLETED (the withdrawal never re-ran) — then send them to the dashboard to see it. Don't walk through the worker logs or event history line-by-line; the dashboard CTA carries the detail.

(Variant — advanced, manual.) DEMO_FAILURE=permanent makes the deposit fail non-retryably, so the refund compensation runs — the saga/rollback story. Two caveats:

  • Run it by hand, not through run-workflow — run-workflow expects COMPLETED and would report a non-zero starter as workflow-failed.

  • Terminal state differs by SDK — narrate what the history actually shows, don't assert one outcome:

    • Python / Go / TypeScript / .NET → FAILED (the original error propagates after the refund).

    • Java / Ruby → COMPLETED (their saga returns after compensating).

There is no checkpoint after Phase 4 — go straight to the Ending, whose Cloud-UI link is the recovered run's timeline page (money-transfer-demo-recovery).

If the user asks (plain language): we made the deposit fail on purpose; Temporal retried it automatically and the Workflow still finished correctly — no lost state, no manual recovery. (Docs: https://docs.temporal.io/encyclopedia/retry-policies)

</phases>

Ending the Skill

Once Phase 4 has shown the recovery, the setup is done. Close with a short summary and the recovery link — keep it tight, no big recap table. Do exactly this:

  1. No Worker should still be running — run-workflow stops its Worker when it returns, so Phases 3 and 4 leave nothing polling Cloud. Only stop a process by hand if you ran the advanced manual DEMO_FAILURE=permanent variant.

  2. Show the recovered Workflow, then close with the summary. First the Cloud UI link (bold lead-in, bare URL alone on its own line, no backticks) — the recovery run's timeline page, using the workflow_id/run_id Phase 4 emitted:

    View your recovered Workflow on Temporal Cloud:

    https://cloud.temporal.io/namespaces/<namespace-handle>/workflows/<workflow-id>/<run-id>/timeline

    Fallback — only if run_id is unknown: show the Workflow detail page the same way — bold lead-in, bare URL on its own line:

    View your recovered Workflow on Temporal Cloud:

    https://cloud.temporal.io/namespaces/<namespace-handle>/workflows/<workflow-id>

    — never the bare …/workflows list.

    Then close with a short summary (a few plain sentences, no table) that makes the durable-execution payoff concrete — for example:

    🎉 You're set up on Temporal Cloud — and you just watched Durable Execution in action. Your money-transfer Workflow ran on real Cloud infrastructure, and when the deposit failed on purpose, Temporal automatically retried it until it succeeded — the transfer still completed, with no lost state and not a line of retry code from you. That's the whole idea: you write the business logic; Temporal makes it survive failures and run to completion.

  3. One-line note: the API key is saved in temporal.toml (give the path, chmod 600) and auto-expires in ~25 hours — don't share raw terminal logs and never commit the TOML.

The summary above is the payoff — keep it to that one short recap. The only calls-to-action are the two Workflow timeline links — the completed run (Phase 3) and the recovered run (here at the end). Don't loop back, re-run, keep teaching, offer teardown, or suggest other next steps — a successful completion is the terminal state.

Failure Handling

On status=error, map the error_code via references/failure-handling.md and fix the exact cause it names — never improvise an alternate command, switch output formats, or poll. Read that file only when an error fires (progressive disclosure); the Steps spine's On-error column indexes which codes each step can emit.

Files

  • scripts/provision.sh — the deterministic executor. Owns preflight / detect-tools / preview / install / login / regions / namespace-create / install-deps (manager-parameterized) / key-mint+config-write / verify / await-auth / run-workflow (Worker + starter) / clone / repair-config / cleanup-info. Invoke it and parse its === RESULT === block (see "Execution model" above); it is the single source of truth for the pinned CLI flags, the per-SDK run commands, and the per-(SDK,manager) install matrix + minimum-version table. Pure bash, portable across Claude Code, Codex, and Cursor. Read-only during a run — invoke it, never edit it (read-only script).

  • references/unified-cli.md — background on the prerelease CLI and the client-config TOML: login/whoami, region list, namespace create, apikey create-for-me, file locations, and the auth-override gotcha. The script encodes these; read the reference when a flag drifts and you need to update the script.

  • references/sdk-cloud.md — per-SDK table: repo + cloud branch, task-queue name, how each connects (cloud-setup profile), and worker/starter run commands. No connection edits — the branch is pre-wired.

  • references/failure-handling.md — the error_code → remediation map. Read it only when a subcommand returns status=error (progressive disclosure — the happy path never opens it); the Failure Handling section above is a one-line pointer to it, and the Steps spine's On-error column is the index.

Files

scripts/provision.sh

#!/usr/bin/env bash
#
# provision.sh — deterministic executor for the temporal-cloud-setup skill.
#
# ===========================================================================
#  DO NOT EDIT THIS FILE WHILE RUNNING THE SKILL. Invoke it as shipped.
#  It is written to work UNCHANGED on every platform (macOS bash 3.2 / BSD
#  awk included). Do not reformat its punctuation, quoting, regexes, or flags.
#  If a prerelease flag has genuinely drifted, that is a deliberate human
#  maintenance edit -- not something an agent does mid-setup.
# ===========================================================================
#
# WHY THIS EXISTS
#   The skill's variance-prone work (create the namespace, mint + capture the
#   API key, write the client-config TOML) is the part where an agent tends to
#   improvise — polling, switching output formats, hunting the filesystem for an
#   account-id, or leaking the secret into a rendered diff. This script makes
#   that work identical on every run: fixed flags, baked-in error handling, and
#   the secret never leaves the script (it is written straight into the locked
#   TOML; only the non-secret KeyId is ever printed).
#
#   The agent INVOKES this script and PARSES its structured RESULT block — it
#   does not reassemble these commands itself. The wizard UX (phases, tracker,
#   checkpoints, narration rules) stays in SKILL.md; the determinism lives here.
#
# PORTABILITY
#   Pure bash, self-locating, no Claude/Codex-specific assumptions. Runs the
#   same under Claude Code and Codex (both just shell out to it).
#
# OUTPUT CONTRACT
#   Human-readable progress goes to STDERR (shown in the agent's tool block).
#   The machine-readable result goes to STDOUT as a single delimited block:
#
#       === RESULT ===
#       status=ok                # ok | error | skipped
#       <key>=<value>            # operation-specific (see each subcommand)
#       === END ===
#
#   On failure: status=error plus error_code=<slug> and message=<one line>.
#   The token (eyJ...) is NEVER emitted on stdout, stderr, or in any result key.
#
# CLI FLAGS ARE PINNED HERE (single source of truth)
#   These match the flags verified end-to-end against the prerelease CLI and
#   documented in references/unified-cli.md. The unified CLI is in PRERELEASE;
#   if a flag is rejected, fix it HERE (and in the reference) — one place, not
#   per-run in the agent's head.
#
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

# ---- output helpers ---------------------------------------------------------

log()  { printf '%s\n' "$*" >&2; }                 # human progress -> stderr
die()  {                                            # structured error -> stdout
  local code="$1"; shift
  printf '=== RESULT ===\nstatus=error\nerror_code=%s\nmessage=%s\n=== END ===\n' \
    "$code" "$*"
  exit 1
}
result_open()  { printf '=== RESULT ===\n'; printf 'status=%s\n' "${1:-ok}"; }
result_kv()    { printf '%s=%s\n' "$1" "$2"; }
result_close() { printf '=== END ===\n'; }

require_cmd() { command -v "$1" >/dev/null 2>&1; }

# ---- secret redaction (single source of truth) ------------------------------
# redact  -> filter: read stdin, write a SCRUBBED copy to stdout. Removes the two
# secret shapes this skill can surface: an `api_key = <value>` / `apikey: <value>`
# assignment (value replaced with "(redacted)") and any JWT-shaped token (eyJ... ->
# eyJ...(redacted); API keys are JWTs). Factored out of cmd_create_key /
# cmd_verify_config so EVERY place that echoes captured CLI stderr uses one scrub —
# no secret ever reaches stdout, stderr, or a log unredacted. `sed -E` + `{6,}` are
# already used elsewhere here, so this stays within the macOS bash 3.2 / BSD sed target.
redact() {
  sed -E 's/([Aa][Pp][Ii][_-]?[Kk][Ee][Yy][[:space:]]*[=:]).*/\1 (redacted)/g; s/eyJ[A-Za-z0-9._-]{6,}/eyJ...(redacted)/g'
}

# ---- per-call timeout (single source of truth) ------------------------------
# run_bounded <secs> <cmd...>  -> run cmd with a per-call wall-clock timeout so a
# single wedged invocation can't block a polling loop. Prefers `timeout` / `gtimeout`
# when present (they also bound the whole process group); otherwise a pure-shell
# watchdog, because macOS ships no `timeout`: background the call, poll up to <secs>
# in 1s ticks, then TERM (and, as a backstop, KILL) it. `wait` reaps the child in
# every path (no zombies) and yields the command's REAL exit status when it finished
# in time; on expiry we return RUN_BOUNDED_TIMEOUT (124, matching GNU `timeout`) so a
# caller can tell "timed out" apart from "ran and returned non-zero". The caller owns
# stdout/stderr redirection (it is inherited by the backgrounded command as-is).
RUN_BOUNDED_TIMEOUT=124
run_bounded() {
  local secs="$1"; shift
  if require_cmd timeout;  then timeout  "$secs" "$@"; return $?; fi
  if require_cmd gtimeout; then gtimeout "$secs" "$@"; return $?; fi
  "$@" &
  local cpid=$! waited=0 rc
  while kill -0 "$cpid" 2>/dev/null; do
    if [ "$waited" -ge "$secs" ]; then
      kill -TERM "$cpid" 2>/dev/null
      sleep 1
      kill -KILL "$cpid" 2>/dev/null
      wait "$cpid" 2>/dev/null
      return "$RUN_BOUNDED_TIMEOUT"
    fi
    sleep 1
    waited=$(( waited + 1 ))
  done
  wait "$cpid"; rc=$?
  return "$rc"
}

# ---- config-file location (TEMPORAL_CONFIG_FILE wins, else per-OS default) ---

config_path() {
  if [ -n "${TEMPORAL_CONFIG_FILE:-}" ]; then
    printf '%s' "$TEMPORAL_CONFIG_FILE"; return
  fi
  case "$(uname -s)" in
    Darwin) printf '%s' "$HOME/Library/Application Support/temporalio/temporal.toml" ;;
    Linux)  printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}/temporalio/temporal.toml" ;;
    *)      printf '%s' "$HOME/.config/temporalio/temporal.toml" ;;   # best effort
  esac
}

# ---- per-SDK repo table (single source of truth; mirrors references/sdk-cloud.md)
# Branch is always money-transfer-project-cloud-setup (pre-wired for Cloud).

repo_url_for_sdk() {
  case "$1" in
    python)     printf 'https://github.com/temporalio/money-transfer-project-template-python' ;;
    go)         printf 'https://github.com/temporalio/money-transfer-project-template-go' ;;
    ts|typescript) printf 'https://github.com/temporalio/money-transfer-project-template-ts' ;;
    java)       printf 'https://github.com/temporalio/money-transfer-project-java' ;;
    dotnet|.net|net) printf 'https://github.com/temporalio/money-transfer-project-template-dotnet' ;;
    ruby)       printf 'https://github.com/temporalio/money-transfer-project-template-ruby' ;;
    *)          printf '' ;;
  esac
}

# ---- per-SDK run matrix (verified against the money-transfer-project-cloud-setup
# branches; mirrors references/sdk-cloud.md). Worker command is long-running; the
# starter blocks until the Workflow returns its result. These are the SINGLE source
# of truth for the run step so the agent never guesses entrypoints/targets.
worker_cmd_for() {
  case "$1" in
    python)          printf 'source env/bin/activate && python run_worker.py' ;;
    go)              printf 'go run worker/main.go' ;;
    ts|typescript)   printf 'npm run worker' ;;
    java)            printf 'mvn -q compile exec:java -Dexec.mainClass=moneytransferapp.MoneyTransferWorker -Dorg.slf4j.simpleLogger.defaultLogLevel=warn' ;;
    dotnet|.net|net) printf 'dotnet run --project MoneyTransferWorker' ;;
    ruby)            printf 'ruby worker.rb' ;;
    *)               printf '' ;;
  esac
}
starter_cmd_for() {
  case "$1" in
    python)          printf 'source env/bin/activate && python run_workflow.py' ;;
    go)              printf 'go run start/main.go' ;;
    ts|typescript)   printf 'npm run client' ;;
    java)            printf 'mvn -q compile exec:java -Dexec.mainClass=moneytransferapp.TransferApp -Dorg.slf4j.simpleLogger.defaultLogLevel=warn' ;;
    dotnet|.net|net) printf 'dotnet run --project MoneyTransferClient' ;;
    ruby)            printf 'ruby starter.rb' ;;
    *)               printf '' ;;
  esac
}
# task-queue name the worker polls — the readiness probe checks for a poller here.
task_queue_for() {
  case "$1" in
    python|go)          printf 'TRANSFER_MONEY_TASK_QUEUE' ;;
    java|dotnet|.net|net) printf 'MONEY_TRANSFER_TASK_QUEUE' ;;
    ts|typescript|ruby) printf 'money-transfer' ;;
    *)                  printf '' ;;
  esac
}

# ---- per-(SDK, package-manager) adaptation matrices --------------------------
# Single source of truth for which managers each SDK sample ACTUALLY supports,
# derived from the money-transfer-project-cloud-setup branches
# (see references/sdk-cloud.md). Only python and ts ship a choice; the rest are
# single-manager. We never offer a manager the sample can't honor (e.g. poetry on
# the python sample, which ships no pyproject.toml).

# managers_for <sdk> -> space-separated candidate managers in PREFERENCE order
# (default = first one whose binary is on PATH). For SDKs that ship a lockfile the
# lockfile's manager leads, so the default honors the committed lockfile (ts->npm).
managers_for() {
  case "$1" in
    python)          printf 'pip uv' ;;        # sample has no manifest; pip default, uv faster override
    ts|typescript)   printf 'npm pnpm yarn' ;; # package-lock.json committed -> npm leads
    go)              printf 'go' ;;
    java)            printf 'maven' ;;         # pom.xml only; no mvnw/gradlew/build.gradle
    dotnet|.net|net) printf 'dotnet' ;;
    ruby)            printf 'bundler' ;;
    *)               printf '' ;;
  esac
}

# runtime_for <sdk> -> the language-runtime binary whose version we check/warn on.
runtime_for() {
  case "$1" in
    python)          printf 'python3' ;;
    ts|typescript)   printf 'node' ;;
    go)              printf 'go' ;;
    java)            printf 'java' ;;
    dotnet|.net|net) printf 'dotnet' ;;
    ruby)            printf 'ruby' ;;
    *)               printf '' ;;
  esac
}

# manager_bin <manager> -> the executable to look for on PATH for that manager.
manager_bin() {
  case "$1" in
    pip)     printf 'python3' ;;   # pip is invoked as `python3 -m pip`
    uv)      printf 'uv' ;;
    npm)     printf 'npm' ;;
    pnpm)    printf 'pnpm' ;;
    yarn)    printf 'yarn' ;;
    go)      printf 'go' ;;
    maven)   printf 'mvn' ;;
    dotnet)  printf 'dotnet' ;;
    bundler) printf 'bundle' ;;
    *)       printf '' ;;
  esac
}

# min_version_for <tool> -> minimum tested version (advisory; a shortfall is a
# WARNING with remediation, never an auto-fix or a hard block). Empty = no minimum.
min_version_for() {
  case "$1" in
    python3) printf '3.8' ;;
    node)    printf '18' ;;        # Node 16 is EOL; @temporalio/* targets 18+
    go)      printf '1.21' ;;
    java)    printf '8' ;;         # Temporal Java SDK supports JDK 8+ (deliberately below the 11 line)
    dotnet)  printf '6.0' ;;
    ruby)    printf '3.0' ;;
    uv)      printf '0.1' ;;
    npm)     printf '8' ;;
    pnpm)    printf '8' ;;
    yarn)    printf '1.22' ;;
    maven)   printf '3.6' ;;       # mvn
    bundler) printf '2.0' ;;       # bundle
    *)       printf '' ;;
  esac
}

# install_cmd_for <sdk> <manager> -> the dependency-install command string.
# SINGLE SOURCE OF TRUTH for installs, shared by cmd_install_deps, the internal
# provision-and-scaffold path, and `preview`. Empty = unsupported (sdk,manager) pair.
install_cmd_for() {
  local sdk="$1" mgr="$2"
  case "$sdk" in
    python)
      case "$mgr" in
        pip) printf 'python3 -m venv env && . env/bin/activate && python -m pip install -q temporalio' ;;
        uv)  printf 'uv venv env && . env/bin/activate && uv pip install -q temporalio' ;;
        *)   printf '' ;;
      esac ;;
    ts|typescript)
      case "$mgr" in
        npm)  printf 'npm install' ;;
        pnpm) printf 'pnpm install' ;;
        yarn) printf 'yarn install' ;;
        *)    printf '' ;;
      esac ;;
    go)              [ "$mgr" = go ]      && printf 'go mod download' ;;
    java)            [ "$mgr" = maven ]   && printf 'mvn -q -DskipTests dependency:resolve' ;;
    dotnet|.net|net) [ "$mgr" = dotnet ]  && printf 'dotnet restore' ;;
    ruby)            [ "$mgr" = bundler ] && printf 'bundle install' ;;
    *)               printf '' ;;
  esac
}

# install_location_note <sdk> -> short phrase stating WHERE the install writes, so the
# disclosure is explicit when deps land in a shared GLOBAL cache outside the repo
# (Go/Java/.NET/Ruby) vs. a location local to the clone (Python venv, TS node_modules).
install_location_note() {
  case "$1" in
    python)          printf 'into a local venv (env/) inside the repo' ;;
    ts|typescript)   printf 'into node_modules inside the repo' ;;
    go)              printf 'into the shared Go module cache - GLOBAL, outside the repo (~/go/pkg/mod)' ;;
    java)            printf 'into the shared Maven cache - GLOBAL, outside the repo (~/.m2)' ;;
    dotnet|.net|net) printf 'into the global NuGet cache - GLOBAL, outside the repo (~/.nuget/packages)' ;;
    ruby)            printf 'into globally-installed gems - GLOBAL, outside the repo' ;;
    *)               printf 'into the repo' ;;
  esac
}

# resolve_pkg_inputs <sdk> <manager-or-empty>  — resolve the package manager and its
# install command for an SDK. Sets globals PKG_MGR / PKG_ICMD / PKG_CANDIDATES and
# returns 0; returns 2 if the SDK has no manager mapping, 3 if the chosen manager isn't
# supported. Uses globals + a return code (not stdout + `die`) on purpose: a `die` inside
# a `$(...)` call would exit only the subshell, so the CALLER maps the code to the error.
resolve_pkg_inputs() {
  local sdk="$1" mgr_in="$2"
  PKG_CANDIDATES="$(managers_for "$sdk")"
  [ -n "$PKG_CANDIDATES" ] || return 2
  PKG_MGR="${mgr_in:-${PKG_CANDIDATES%% *}}"
  PKG_ICMD="$(install_cmd_for "$sdk" "$PKG_MGR")"
  [ -n "$PKG_ICMD" ] || return 3
  return 0
}

# die_pkg <rc> <sdk>  — map resolve_pkg_inputs' non-zero return to the right error.
die_pkg() {
  case "$1" in
    2) die unknown-sdk "no package-manager mapping for SDK '$2'" ;;
    3) die unsupported-manager "manager '$PKG_MGR' not supported for SDK '$2' (supported: $(printf '%s' "$PKG_CANDIDATES" | tr ' ' ','))" ;;
  esac
}

# ver_norm <string> -> leading dotted-numeric version (e.g. "go1.22.0"->"1.22.0").
ver_norm() { printf '%s' "$1" | grep -oE '[0-9]+(\.[0-9]+){0,3}' | head -n1; }

# ver_ge <have> <want> -> exit 0 if HAVE >= WANT (component-wise, bash 3.2-safe).
# Splits on '.' via a saved/restored IFS — NOT a `... | read` pipe, which would
# run read in a subshell and lose the captured components.
ver_ge() {
  local have want h1 h2 h3 w1 w2 w3 oldifs
  have="$(ver_norm "$1")"; want="$(ver_norm "$2")"
  [ -n "$have" ] || return 1
  [ -n "$want" ] || return 0
  oldifs="$IFS"; IFS=.
  set -- $have; h1="${1:-0}"; h2="${2:-0}"; h3="${3:-0}"
  set -- $want; w1="${1:-0}"; w2="${2:-0}"; w3="${3:-0}"
  IFS="$oldifs"
  if [ "$h1" -ne "$w1" ]; then [ "$h1" -gt "$w1" ]; return; fi
  if [ "$h2" -ne "$w2" ]; then [ "$h2" -gt "$w2" ]; return; fi
  [ "$h3" -ge "$w3" ]
}

# tool_version <runtime-or-manager-binary> -> a bare version string, or empty if
# absent/unparseable. Handles the binaries whose --version output is non-standard
# (go, java's legacy 1.8 scheme on stderr).
tool_version() {
  local bin="$1" raw v
  require_cmd "$bin" || return 0
  case "$bin" in
    go)   raw="$(go version 2>/dev/null)" ;;
    java) raw="$(java -version 2>&1 | head -n1)" ;;       # java prints version to stderr
    mvn)  raw="$(mvn -version 2>/dev/null | head -n1)" ;;
    *)    raw="$("$bin" --version 2>&1 | head -n1)" ;;
  esac
  v="$(ver_norm "$raw")"
  # Java's legacy "1.8.0_292" scheme: map 1.N -> N so numeric compare treats 8 as 8.
  if [ "$bin" = java ]; then
    case "$v" in 1.*) v="${v#1.}" ;; esac
  fi
  printf '%s' "$v"
}

# ---- JSON field extraction (jq preferred, python3 fallback) -----------------

json_field() {  # json_field <file> <field>
  local file="$1" field="$2"
  if require_cmd jq; then
    jq -r --arg f "$field" '.[$f] // empty' "$file" 2>/dev/null
  elif require_cmd python3; then
    python3 -c 'import json,sys
try:
    d=json.load(open(sys.argv[1]))
    v=d.get(sys.argv[2])
    print(v if v is not None else "")
except Exception:
    print("")' "$file" "$field"
  else
    printf ''
  fi
}

# =============================================================================
# Subcommands
# =============================================================================

cmd_preflight() {
  # SDK is optional and only used to echo the repo_url. Accept --sdk (the
  # convention every other subcommand uses) and a bare positional for back-compat.
  local sdk=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      -*) die bad-args "unknown arg: $1" ;;
      *) sdk="$1"; shift ;;
    esac
  done
  log "Preflight checks..."
  local os; os="$(uname -s)"
  local cfg; cfg="$(config_path)"

  local warnings=()
  require_cmd git || warnings+=("git-missing")
  if ! require_cmd jq && ! require_cmd python3; then
    warnings+=("no-json-parser")   # needed to capture the API key safely
  fi
  if [ "$os" = "Darwin" ] && ! require_cmd brew; then
    warnings+=("brew-missing")     # only a problem if the CLI also isn't installed
  fi
  # Stray TEMPORAL_* env vars OVERRIDE the TOML profile (env-config precedence).
  local stray=()
  [ -n "${TEMPORAL_ADDRESS:-}" ]   && stray+=("TEMPORAL_ADDRESS")
  [ -n "${TEMPORAL_NAMESPACE:-}" ] && stray+=("TEMPORAL_NAMESPACE")
  [ -n "${TEMPORAL_API_KEY:-}" ]   && stray+=("TEMPORAL_API_KEY")

  # NB: no up-front internet probe. A coarse HTTPS reachability check can't see the
  # case that actually bites -- a PARTIAL block where login/whoami work (offline /
  # loopback) but the gRPC Cloud API is blocked -- and it risks false-positives behind
  # a captive portal / proxy. The authoritative connectivity gate is the post-login
  # region pulse in cmd_regions (validates output, not exit code); a fully-offline user
  # also fails loudly at install-cli / login. So we do NOT gate preflight on the network.

  # Config-dir writable: writing the client-config TOML is OUR op -- no external
  # tool emits an error for us -- and today it fails LATE, after a billable API key
  # is minted. A tiny create/write/remove probe catches an unwritable config dir up
  # front. (The one fs check worth keeping; work-dir/disk checks are left to git/npm/
  # pip, which already fail loudly on them.)
  local cfg_dir; cfg_dir="$(dirname "$cfg")"
  local probe="$cfg_dir/.tcloud-write-probe.$$"
  if ! ( mkdir -p "$cfg_dir" 2>/dev/null && : > "$probe" 2>/dev/null ); then
    die config-dir-unwritable "Can't write to the Temporal config directory ($cfg_dir). Fix its permissions, or set TEMPORAL_CONFIG_FILE to a writable path, then re-run -- otherwise the client-config TOML can't be saved after the API key is minted."
  fi
  rm -f "$probe" 2>/dev/null

  result_open ok
  result_kv os "$os"
  result_kv config_path "$cfg"
  result_kv cli_installed "$(cloud_cli_present && echo true || echo false)"
  [ -n "$sdk" ] && result_kv repo_url "$(repo_url_for_sdk "$sdk")"
  result_kv warnings "$(IFS=,; echo "${warnings[*]:-}")"
  result_kv stray_env "$(IFS=,; echo "${stray[*]:-}")"
  result_close
}

# cmd_detect_tools: local-setup adaptation. For the chosen SDK,
# detect which package managers the sample supports AND are installed, pick a
# deterministic default (first available in preference order), report tool
# versions, and surface discrepancies (missing runtime/manager, version-too-old)
# so the agent can present them EARLY (Phase 1) — not deep in a later phase.
# Side-effect free: only reads versions, never installs or clones. Deterministic:
# same environment -> same default every run (no state file).
cmd_detect_tools() {
  local sdk=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$sdk" ] || die bad-args "--sdk is required"
  local candidates; candidates="$(managers_for "$sdk")"
  [ -n "$candidates" ] || die unknown-sdk "no package-manager mapping for SDK '$sdk'"

  log "Detecting tools for '$sdk'..."
  local runtime rt_ver rt_min available="" default="" versions="" disc=""
  runtime="$(runtime_for "$sdk")"

  # Runtime: presence + version vs. minimum.
  rt_ver="$(tool_version "$runtime")"
  rt_min="$(min_version_for "$runtime")"
  if [ -z "$rt_ver" ]; then
    disc="${disc:+$disc,}tool-missing:$runtime"
  else
    versions="${versions:+$versions,}$runtime@$rt_ver"
    if [ -n "$rt_min" ] && ! ver_ge "$rt_ver" "$rt_min"; then
      disc="${disc:+$disc,}version-too-old:$runtime@$rt_ver(min$rt_min)"
    fi
  fi

  # Managers: build the available list (preference order) + record versions/discrepancies.
  local m bin ver vmin
  for m in $candidates; do
    bin="$(manager_bin "$m")"
    if require_cmd "$bin"; then
      available="${available:+$available,}$m"
      [ -z "$default" ] && default="$m"
      ver="$(tool_version "$bin")"
      [ -n "$ver" ] && versions="${versions:+$versions,}$m@$ver"
      vmin="$(min_version_for "$m")"
      if [ -n "$ver" ] && [ -n "$vmin" ] && ! ver_ge "$ver" "$vmin"; then
        disc="${disc:+$disc,}version-too-old:$m@$ver(min$vmin)"
      fi
    fi
  done
  # No supported manager installed at all -> name the preferred (first candidate) as missing.
  if [ -z "$available" ]; then
    disc="${disc:+$disc,}manager-not-found:${candidates%% *}"
  fi

  result_open ok
  result_kv sdk "$sdk"
  result_kv runtime "${rt_ver:+$runtime@$rt_ver}"
  result_kv managers "$available"
  result_kv default "$default"
  result_kv candidates "$(printf '%s' "$candidates" | tr ' ' ',')"
  result_kv versions "$versions"
  result_kv discrepancies "$disc"
  result_close
}

# cloud_cli_present -> 0 iff the unified CLI's `cloud` command GROUP works. `temporal`
# alone can be the OSS CLI without the cloud group, so test the group, not just the
# binary. Shared by preflight (cli_installed) and install-cli so the two presence
# checks never disagree. Probe with `temporal cloud --help`: it exits 0 whenever the
# `cloud` group exists and needs no auth. (Do NOT use `temporal cloud version` — on the
# prerelease CLI `version` is a top-level flag, not a `cloud` subcommand, so it exits 1
# even when the CLI is installed; that false negative sent install-cli into a reinstall
# loop that died with install-verify-failed.)
cloud_cli_present() { temporal cloud --help >/dev/null 2>&1; }

# cli_version -> the real CLI version line (e.g. "temporal version 1.7.2 (...)").
# NB: from `temporal --version`, NOT `temporal cloud version` (the latter prints help,
# which is the "version=The Temporal Cloud CLI provides commands…" bug).
cli_version() { temporal --version 2>/dev/null | head -n1; }

cmd_install_cli() {
  if cloud_cli_present; then
    # BETA stopgap (PE-79): the prerelease temporal-cloud CLI has no meaningful
    # version numbers yet, so we can't do a real "is it out of date?" check. For the
    # beta period we ALWAYS try to pull the latest from the prerelease tap instead of
    # skipping. Never yank a working install: if we can't update (Homebrew missing,
    # non-macOS host, or brew errors) we warn and proceed with the CLI that's there.
    # Replace this with a real version comparison once the CLI ships versions.
    local up_out up_rc
    case "$(uname -s)" in
      Darwin)
        if require_cmd brew; then
          log "Temporal CLI present — updating to the latest prerelease..."
          up_out="$(brew upgrade temporalio/prerelease/temporal-cloud 2>&1)"; up_rc=$?
          result_open ok
          # The updated/up-to-date label is best-effort (matched against brew's
          # unstructured output) and NON-load-bearing: every branch here emits
          # status=ok and proceeds, so a mislabel never breaks a working install.
          if [ "$up_rc" -ne 0 ]; then
            log "brew upgrade failed; keeping the working CLI. Output:"
            log "$up_out"
            result_kv update failed
          elif printf '%s\n' "$up_out" | grep -qi 'upgrading'; then
            result_kv update updated
          else
            result_kv update up-to-date
          fi
          result_kv version "$(cli_version)"
          result_close; return
        fi
        log "Temporal CLI present but Homebrew not found — can't auto-update; proceeding with the installed CLI. Install Homebrew (https://brew.sh) or download temporal-cloud from https://github.com/temporalio/cloud-cli/releases/latest to update manually."
        result_open ok
        result_kv update skipped
        result_kv reason brew-missing
        result_kv version "$(cli_version)"
        result_close; return ;;
      *)
        log "Temporal CLI present on a non-macOS host — no prerelease tap to update from; proceeding with the installed CLI. Update temporal-cloud manually from https://github.com/temporalio/cloud-cli/releases/latest."
        result_open ok
        result_kv update skipped
        result_kv reason unsupported-os
        result_kv version "$(cli_version)"
        result_close; return ;;
    esac
  fi
  case "$(uname -s)" in
    Darwin)
      if require_cmd brew; then
        log "Installing temporal-cloud via Homebrew..."
        if brew install temporalio/prerelease/temporal-cloud >&2; then
          # (b) Verify the install actually took, rather than trusting brew's exit.
          if ! cloud_cli_present; then
            die install-verify-failed "brew reported success but 'temporal cloud --help' still fails — check PATH / shell rehash, then re-run."
          fi
          result_open ok
          result_kv installed_via brew
          result_kv version "$(cli_version)"
          result_close; return
        fi
        die install-failed "brew install temporalio/prerelease/temporal-cloud failed; see output above"
      fi
      die brew-missing "Homebrew not found. Install from https://brew.sh, or download temporal-cloud from https://github.com/temporalio/cloud-cli/releases/latest and put it on PATH."
      ;;
    *)
      die manual-install "Download temporal-cloud from https://github.com/temporalio/cloud-cli/releases/latest, extract, and put it on PATH; then re-run."
      ;;
  esac
}

cmd_login() {
  # `temporal cloud login` opens a browser and BLOCKS until the user finishes.
  log "Opening browser for Temporal Cloud sign-in... complete it in the browser."
  if ! temporal cloud login >&2; then
    die login-failed "temporal cloud login did not complete; finish the browser sign-in and retry."
  fi
  local who; who="$(temporal cloud whoami 2>/dev/null | head -n1)"
  if [ -z "$who" ]; then
    die not-authenticated "whoami returned empty after login — sign-in may not have completed."
  fi
  result_open ok; result_kv identity "$who"; result_close
}

cmd_regions() {
  # `whoami` is the cheap "are we even signed in" check -- it proves a CREDENTIAL is
  # PRESENT, nothing more. It runs OFFLINE (cached token, no live API call), so it is
  # NOT a connectivity signal: never treat a passing whoami as proof the Cloud API is
  # reachable. The region fetch below is the authoritative connectivity + authz pulse.
  if ! temporal cloud whoami >/dev/null 2>&1; then
    die not-authenticated "Not signed in. Run the login step first."
  fi

  # ---- POST-LOGIN CONNECTIVITY PULSE (the one authoritative "can we actually reach
  # the Cloud API with these creds" gate) --------------------------------------------
  # The prerelease CLI returns exit 0 even when the network call fails, so we validate
  # the OUTPUT, never the exit code (exit-0 hardening -- deliberately narrowed to this
  # pulse + the preflight probe; downstream steps trust the connection this establishes).
  # `region list` is non-empty for ANY valid account, so an empty result is an
  # unambiguous "couldn't reach the API" -- almost always a blocked/partial network
  # (login + whoami succeed offline; the gRPC API is blocked). We STOP with
  # cloud-unreachable and do NOT loop on re-auth: re-auth cannot fix a blocked network,
  # and that misdiagnosis (empty list -> "confirm auth, re-run") is the exact bug this
  # pulse closes. We reuse this single fetch for the SELECTION step below -- connectivity
  # is validated HERE, in one place; selection carries no network logic and no extra call.
  log "Listing available Cloud regions..."
  local err_file; err_file="$(mktemp "${TMPDIR:-/tmp}/tcloud-regions.XXXXXX")"
  local list; list="$(temporal cloud region list 2>"$err_file")"
  local err; err="$(cat "$err_file" 2>/dev/null)"; rm -f "$err_file"
  if [ -z "$list" ]; then
    # Empty output = failed pulse. Surface any stderr as a hint, but never DEPEND on it:
    # the exit code lied, and the text may be empty or drift between prerelease builds.
    die cloud-unreachable "Temporal Cloud returned no regions -- the Cloud API is unreachable with your current session. This is a network/sandbox block, NOT missing auth (login and whoami work offline). Do NOT re-run login. Check outbound network and your sandbox's egress to the Temporal Cloud gRPC API (*.tmprl.cloud), then re-run.${err:+ (CLI stderr: $err)}"
  fi
  # Region guard: flag regions whose CloudProvider renders as UNKNOWN. On some accounts
  # those (e.g. azure-centralus) ACCEPT a namespace create but never provision it (a
  # phantom that later stalls await-namespace). Emit them so the agent can warn the user
  # and steer to a known AWS/GCP region. ($1 = region id, $2 = CloudProvider column.)
  local unsupported
  unsupported="$(printf '%s\n' "$list" | awk '$2=="UNKNOWN"{print $1}' | tr '\n' ',' | sed 's/,$//')"
  # Echo the raw list to stderr so the agent can present it; emit nothing
  # secret. Recommendation is left to the agent (timezone heuristic) so the
  # script never invents a region that isn't in the list.
  printf '%s\n' "$list" >&2
  result_open ok
  [ -n "$unsupported" ] && result_kv unsupported_regions "$unsupported"
  result_close
}

# user_tag  -> up to 8 [a-z0-9] from the Temporal user's email LOCAL-PART (everything
# before '@'; the '@' and domain are never included, so the name is never email-shaped).
# Falls back to 'user' if the email is unavailable or sanitizes to empty.
user_tag() {
  local email=""
  if require_cmd jq; then
    email="$(temporal cloud whoami --output json 2>/dev/null | jq -r '.user.spec.email // empty' 2>/dev/null)"
  elif require_cmd python3; then
    email="$(temporal cloud whoami --output json 2>/dev/null | python3 -c 'import json,sys
try:
    print(json.load(sys.stdin).get("user",{}).get("spec",{}).get("email","") or "")
except Exception:
    print("")' 2>/dev/null)"
  fi
  local tag
  tag="$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]' | cut -d@ -f1 | tr -cd 'a-z0-9' | cut -c1-8)"
  [ -n "$tag" ] || tag="user"
  printf '%s' "$tag"
}

# rand_tag  -> 8 random [a-z0-9] for per-namespace uniqueness (portable: /dev/urandom).
rand_tag() {
  local r
  r="$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom 2>/dev/null | head -c 8)"
  [ "${#r}" -eq 8 ] || r="$(date +%s | tail -c 9)"   # fallback if /dev/urandom is unavailable
  printf '%s' "$r"
}

# namespace_name_for  -> unique name:  quickstartai-<user>-<random>
#   <user>   = up to 8 chars of the Temporal user's email local-part (see user_tag)
#   <random> = 8 random [a-z0-9] (see rand_tag) for per-namespace uniqueness
# No SDK / timestamp / telemetry marker — just an anonymized per-user tag + uniqueness.
# Cloud appends .<account-id> itself, which does NOT count toward the <=39 char limit.
# (The SDK arg is ignored now; kept for call-site compatibility.)
namespace_name_for() {
  local name
  name="quickstartai-$(user_tag)-$(rand_tag)"
  [ "${#name}" -le 39 ] || die name-too-long "namespace name '$name' exceeds 39 chars"
  printf '%s' "$name"
}

# namespace_active_info <exact-name>  -> prints "<handle>|<grpc-address>" + returns 0 when
# the namespace EXISTS and is ACTIVE. Return codes let the caller tell a phantom (a create
# that was accepted but never provisions) from a slow-but-real provision:
#   0 = ACTIVE (handle|address printed)
#   3 = EXISTS but not yet ACTIVE (ACTIVATING) — genuinely provisioning
#   1 = ABSENT (not in the list) or list unreadable
#
# Why the server-side exact `--name` filter (not a plain `namespace list`): an account
# can hold hundreds of namespaces and `namespace list` PAGES at 100, so scanning a single
# unfiltered page silently misses ours when its name sorts past the first page. The old
# resolver did exactly that, then fell back to CONSTRUCTING `<name>.<acct>` from another
# entry and returning immediately — so it reported "provisioned" on the first poll
# without ever confirming the namespace existed or was ready, leaving create-key/await-auth
# to run against an endpoint that wasn't serving yet ("no children to pick from"). The
# filtered result also carries the authoritative endpoint, so we COPY the address from
# the API response — never assemble it. Primary = `endpoints.mtls_grpc_address` (the
# tmprl.cloud form API-key auth uses), fallback = `endpoints.grpc_address` (regional);
# both straight from the response. (Constructing it broke for providers whose endpoint
# isn't the `<handle>.tmprl.cloud` shape.) Needs jq or python3.
#
# State: this CLI build reports ACTIVE as state==3 (the `namespace list` table column
# renders 3 as "ACTIVE"); a just-created namespace is state==1 (ACTIVATING) for a few
# minutes. API-key auth uses mtls_grpc_address (`<handle>.tmprl.cloud:7233`), which only
# starts serving once the namespace reaches ACTIVE.
namespace_active_info() {
  local name="$1" raw fields handle state mtls grpc addr
  raw="$(temporal cloud namespace list --name "$name" -o json 2>/dev/null)" || return 1
  [ -n "$raw" ] || return 1
  # Pull all four fields in ONE pass (jq, or python3 fallback), pipe-delimited. Use '|'
  # (not tab) as the delimiter so IFS-splitting preserves EMPTY fields — none of the
  # values (handle, numeric state, host:port endpoints) ever contains a '|'.
  if require_cmd jq; then
    fields="$(printf '%s' "$raw" | jq -r 'if (.Namespaces|length)>0 then (.Namespaces[0] | [(.namespace//""),((.state // "")|tostring),(.endpoints.mtls_grpc_address//""),(.endpoints.grpc_address//"")] | join("|")) else "" end' 2>/dev/null)"
  elif require_cmd python3; then
    fields="$(printf '%s' "$raw" | python3 -c 'import json,sys
try:
    a=json.load(sys.stdin).get("Namespaces",[]); x=a[0] if a else {}
    e=x.get("endpoints",{}) or {}
    print("|".join([str(x.get("namespace","")), str(x.get("state","")), str(e.get("mtls_grpc_address","")), str(e.get("grpc_address",""))]) if a else "")
except Exception:
    print("")')"
  else
    return 1
  fi
  IFS='|' read -r handle state mtls grpc <<EOF
$fields
EOF
  [ -n "$handle" ] || return 1                          # absent — not in the list
  [ "$state" = "3" ] || return 3                        # exists but ACTIVATING (state 1), not ACTIVE yet
  # Copy the endpoint from the API: mtls primary, regional grpc fallback. Only as an
  # absolute last resort (both fields absent — not seen on an ACTIVE namespace) do we
  # construct, so a missing field can't strand the run.
  addr="$mtls"; [ -n "$addr" ] || addr="$grpc"
  [ -n "$addr" ] || addr="${handle}.tmprl.cloud:7233"
  printf '%s|%s' "$handle" "$addr"
}

# await_active_namespace <exact-name> <max-secs>  -> poll until the namespace is ACTIVE.
# Echoes "<handle>|<address>" + returns 0 on success. Return codes distinguish the two
# failure modes so the caller can message them differently:
#   2 = PHANTOM — the namespace never appeared in the list within NS_PHANTOM_GRACE_SECS.
#       A real create shows up as ACTIVATING within seconds; persistent absence means the
#       async create was accepted but isn't provisioning (commonly an unavailable region,
#       e.g. azure-centralus whose provider reads UNKNOWN). Fail FAST instead of burning
#       the full --max-secs.
#   1 = TIMEOUT — it appeared (ACTIVATING) but didn't reach ACTIVE within --max-secs.
# This is where the provisioning wait BELONGS (it used to leak into await-auth, which then
# mislabeled a still-provisioning namespace as "API key rejected").
await_active_namespace() {
  local name="$1" max="${2:-600}" waited=0 interval=2 info rc ever_seen=0
  local grace="${NS_PHANTOM_GRACE_SECS:-75}"
  while :; do
    info="$(namespace_active_info "$name")"; rc=$?
    [ "$rc" -eq 0 ] && { printf '%s' "$info"; return 0; }
    [ "$rc" -eq 3 ] && ever_seen=1                       # appeared (ACTIVATING) -> genuinely provisioning
    [ "$ever_seen" -eq 0 ] && [ "$waited" -ge "$grace" ] && return 2   # never showed up -> phantom
    [ "$waited" -ge "$max" ] && return 1
    sleep "$interval"; waited=$(( waited + interval ))
  done
}

# cmd_start_namespace: TRUE fire-and-forget. Submit the create with --async
# and return immediately — provisioning runs SERVER-SIDE (no local background job to
# survive across tool calls, so this works identically on Claude Code/Codex/Cursor).
# We GENERATE the name (namespace_name_for), so we don't need the create's output;
# join later with `await-namespace --name <name>`, which reads the handle from
# `namespace list -o jsonl`.
cmd_start_namespace() {
  local sdk="" region=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      --region) region="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$sdk" ]    || die bad-args "--sdk is required"
  [ -n "$region" ] || die bad-args "--region is required"
  temporal cloud whoami >/dev/null 2>&1 || die not-authenticated "Not signed in; run login again."

  local name out rc
  name="$(namespace_name_for "$sdk")"
  log "Submitting namespace '$name' in '$region' (--async; provisions on Temporal's servers)..."
  # --async returns as soon as the create is accepted; the namespace then provisions
  # server-side over a few minutes. A non-zero exit here is a SUBMIT failure (region/
  # name), surfaced immediately so a bad value never leaves a half-created namespace.
  # Capture rc on the SAME line — a later `printf | tail` (or the `if !` negation) would
  # reset $? to the pipeline's status and mask the real create exit code.
  out="$(temporal cloud namespace create \
        --name "$name" --region "$region" \
        --api-key-auth-enabled --retention-days 30 --auto-confirm --async 2>&1)"; rc=$?
  if [ "$rc" -ne 0 ]; then
    printf '%s\n' "$out" | tail -n 20 >&2
    die create-rejected "namespace create --async was rejected (exit $rc) — usually region or name format; see output above."
  fi
  printf '%s\n' "$out" >&2

  result_open ok
  result_kv namespace_name "$name"
  result_kv region "$region"
  result_close
}

# cmd_await_namespace: the JOIN. Poll `namespace list --name <name> -o json` until the
# async namespace is ACTIVE (state==3), bounded by NS_AWAIT_MAX_SECS. Reads the handle and
# endpoint from that authoritative, pagination-immune result. Gating on ACTIVE (not merely
# "visible") is the fix for the false-ready bug that left downstream steps connecting to a
# still-provisioning endpoint.
cmd_await_namespace() {
  local name="" max="${NS_AWAIT_MAX_SECS:-600}"
  while [ $# -gt 0 ]; do
    case "$1" in
      --name) name="$2"; shift 2 ;;
      --max-secs) max="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$name" ] || die bad-args "--name is required (namespace_name from start-namespace)"
  temporal cloud whoami >/dev/null 2>&1 || die not-authenticated "Not signed in; run login again."

  log "Waiting for namespace '$name' to become ACTIVE (polling 'namespace list --name')..."
  local info
  local rc
  info="$(await_active_namespace "$name" "$max")"; rc=$?
  case "$rc" in
    0) ;;
    2) die namespace-not-provisioning "namespace '$name' never appeared after ~${NS_PHANTOM_GRACE_SECS:-75}s — the create was accepted but isn't provisioning (commonly an unavailable region, e.g. azure-centralus). Re-run start-namespace with an AWS/GCP region." ;;
    *) die namespace-timeout "namespace '$name' not ACTIVE after ~${max}s; a brand-new namespace can take a few minutes to provision. Re-run await-namespace to resume the wait. Do NOT switch endpoints or edit the profile." ;;
  esac

  result_open ok
  result_kv namespace_handle "${info%%|*}"
  result_kv address "${info#*|}"
  result_close
}

# cmd_create_namespace: blocking convenience wrapper = start + await in one call
# (preserves the simple synchronous op for tests / a non-parallel fallback). To
# overlap provisioning with clone+deps, call start-namespace / await-namespace
# directly instead.
cmd_create_namespace() {
  local sdk="" region=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      --region) region="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$sdk" ]    || die bad-args "--sdk is required"
  [ -n "$region" ] || die bad-args "--region is required"

  # Start (RESULT captured to extract the generated name; its logs flow to stderr),
  # then await prints the single final RESULT.
  local start_out name
  start_out="$(cmd_start_namespace --sdk "$sdk" --region "$region")"
  name="$(printf '%s\n' "$start_out" | sed -n 's/^namespace_name=//p')"
  if [ -z "$name" ]; then
    printf '%s\n' "$start_out"   # re-emit the start error RESULT
    exit 1
  fi
  cmd_await_namespace --name "$name"
}

# key_quota_stderr <file>  -> 0 (true) if the captured stderr reads like an API-key
# CAP/quota rejection. Broad on the quota words (limit/maximum/quota/exceeded/too
# many) but ANCHORED to key/apikey in proximity, so an unrelated 'limit' elsewhere
# in CLI chatter doesn't misfire. Case-insensitive; either word order.
key_quota_stderr() {
  grep -qiE '(api[ _-]?key|key)[^\n]*(limit|maximum|quota|exceeded|too many)|(limit|maximum|quota|exceeded|too many)[^\n]*(api[ _-]?key|key)' "$1" 2>/dev/null
}

# die_key_limit_reached  -> the single, actionable message for the API-key cap.
# Used by both create-key failure paths (non-zero exit and exit-0-with-error drift).
die_key_limit_reached() {
  die key-limit-reached "API-key limit reached for this account — the mint was rejected at the cap, not an auth or output problem. Delete stale keys, then re-run create-key: list them with 'temporal cloud apikey list' and remove old money-transfer-cloud-setup-* keys with 'temporal cloud apikey delete --key-id <id>'."
}

cmd_create_key() {
  # Unique display name per run: `apikey create-for-me` ERRORS when a key matching the spec
  # (same display name) already exists and --idempotent isn't set — and --idempotent would
  # "succeed" without returning a fresh token (it's write-once), which we need. A random
  # suffix guarantees a brand-new key every time, so a re-run (or re-mint) never conflicts.
  local handle="" address="" keyname="money-transfer-cloud-setup-$(rand_tag)"
  while [ $# -gt 0 ]; do
    case "$1" in
      --handle) handle="$2"; shift 2 ;;
      --address) address="$2"; shift 2 ;;
      --display-name) keyname="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$handle" ]  || die bad-args "--handle is required (from create-namespace)"
  [ -n "$address" ] || die bad-args "--address is required (from create-namespace)"
  if ! require_cmd jq && ! require_cmd python3; then
    die no-json-parser "need jq or python3 to capture the API key safely; install one and retry."
  fi

  # Re-verify auth immediately before minting the key.
  temporal cloud whoami >/dev/null 2>&1 || die not-authenticated "Not signed in; run login again before the key step."

  # Capture the key WITHOUT printing it. The token goes only into 0600 temp files and
  # is read straight into a shell var. We capture stdout AND stderr to SEPARATE locked
  # files: this prerelease CLI has emitted the freshly-minted key to stderr / human text
  # rather than as JSON on stdout, so the old `2>/dev/null` discarded the token and the
  # result looked empty (`key-empty`). We parse JSON when it's there and fall back to a
  # JWT-pattern match over EITHER stream, so capture works regardless of output shape.
  umask 077
  local tmp tmperr
  tmp="$(mktemp "${TMPDIR:-/tmp}/tcloud-key.XXXXXX")"
  tmperr="$(mktemp "${TMPDIR:-/tmp}/tcloud-key.XXXXXX")"
  chmod 600 "$tmp" "$tmperr"
  # shellcheck disable=SC2064
  trap "rm -f '$tmp' '$tmperr'" EXIT

  log "Creating API key (token captured to a locked file — never printed)..."
  if ! temporal cloud apikey create-for-me \
        --display-name "$keyname" \
        --description "money-transfer Cloud setup" \
        --expiry-duration 25h \
        --auto-confirm \
        -o json > "$tmp" 2>"$tmperr"; then
    # Show a redacted tail for context — never the token.
    redact < "$tmperr" 2>/dev/null | tail -n 5 >&2
    # Account at the API-key cap? The mint is rejected at create time with quota
    # language; surface that as a distinct, actionable code rather than a generic fail.
    if key_quota_stderr "$tmperr"; then die_key_limit_reached; fi
    die key-create-failed "apikey create-for-me exited non-zero; re-check auth (whoami/login). See the redacted output above."
  fi
  if [ ! -s "$tmp" ] && [ ! -s "$tmperr" ]; then
    die key-empty "apikey create returned nothing on either stream — almost always an expired login; run whoami/login and retry once."
  fi

  local token keyid
  # 1. Preferred: structured JSON on stdout.
  token="$(json_field "$tmp" token)"
  [ -n "$token" ] || token="$(json_field "$tmp" secretKey)"
  keyid="$(json_field "$tmp" keyId)"
  [ -n "$keyid" ] || keyid="$(json_field "$tmp" id)"
  # 2. Fallback for output drift: the key was emitted to stderr or as human text. Pull
  #    the JWT by pattern from EITHER stream (API keys are JWTs: 'eyJ' + two more
  #    dot-separated base64url segments). Stays in a var — never printed, never argv.
  if [ -z "$token" ]; then
    token="$(grep -hoE 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' "$tmp" "$tmperr" 2>/dev/null | head -n1)"
  fi
  if [ -z "$keyid" ]; then  # key_id is NOT secret; best-effort scrape if JSON wasn't present
    keyid="$(grep -hoiE 'key[ _-]?id["'"'"':= ]+[A-Za-z0-9_-]{16,}' "$tmp" "$tmperr" 2>/dev/null | head -n1 | grep -oE '[A-Za-z0-9_-]{16,}$')"
  fi
  if [ -z "$token" ]; then
    # (a) Automatic capture failed (no JSON, no JWT pattern). Fall back to a HIDDEN
    # paste straight from the controlling terminal — the token goes into a local var
    # and then into the locked file; it is never echoed, printed, or passed as argv.
    # If there is no interactive tty, bail with a code the agent maps to "ask the user
    # to paste, then re-run".
    if [ -r /dev/tty ]; then
      log "Automatic key capture failed. Paste the one-time API key (input hidden), then press Enter:"
      IFS= read -rs -t 120 token < /dev/tty || token=""   # -t guards against a hang when no input arrives
      printf '\n' >&2
    fi
  fi
  if [ -z "$token" ]; then
    # Exit-0-with-error drift: some prerelease builds print a cap/quota rejection to
    # stderr yet still exit 0, so there's no token to capture. Classify that here (we
    # only reach this with no token) as key-limit-reached rather than manual-key-needed.
    if key_quota_stderr "$tmperr"; then
      redact < "$tmperr" 2>/dev/null | tail -n 5 >&2
      die_key_limit_reached
    fi
    die manual-key-needed "could not capture the key from the CLI output (checked stdout+stderr, JSON + token pattern) and no terminal to paste into — have the user paste it, then re-run create-key from a context with a terminal."
  fi

  # Write the cloud-setup profile straight into the TOML (default untouched).
  write_profile "$handle" "$address" "$token"
  # token goes out of scope; wipe the temp files now (trap is the backstop).
  rm -f "$tmp" "$tmperr"; trap - EXIT

  local cfg; cfg="$(config_path)"
  result_open ok
  result_kv key_id "${keyid:-unknown}"          # KeyId is NOT secret — safe to show
  result_kv config_path "$cfg"
  result_kv profile cloud-setup
  result_close
}

# strip_cloud_setup <file>  -> stdout
# Emits <file> with EVERY [profile.cloud-setup] and [profile.cloud-setup.tls]
# section removed, preserving all other profiles (notably [profile.default] and
# its oauth/login session). Purely textual line-skipping, so it repairs a file
# that duplicate sections have already made TOML-invalid. Tolerates leading
# whitespace on headers; the (\.|\]) boundary keeps it from matching a different
# profile that merely shares the prefix (e.g. [profile.cloud-setup-staging]).
strip_cloud_setup() {
  awk '
    /^[[:space:]]*\[profile\.cloud-setup(\.|\])/ { skip=1; next }
    /^[[:space:]]*\[/ { if (skip) skip=0 }
    skip { next }
    { print }
  ' "$1"
}

# write_profile <handle> <address> <token>
# Replaces any existing [profile.cloud-setup(.tls)] block(s), preserves
# everything else (notably [profile.default] with its oauth/login session),
# chmod 600.
write_profile() {
  local handle="$1" address="$2" token="$3"
  local cfg dir tmp oldumask
  # Self-contained perms: don't depend on the caller's umask. The temp file holds the
  # live token, so it must be 0600 from the moment of creation; tighten the config dir too.
  oldumask="$(umask)"; umask 077
  cfg="$(config_path)"
  dir="$(dirname "$cfg")"
  mkdir -p "$dir"; chmod 700 "$dir" 2>/dev/null || true
  tmp="$(mktemp "${dir}/temporal.toml.XXXXXX")"
  chmod 600 "$tmp"
  # A failed mv (read-only target, full disk) must NOT strand a 0600 temp file holding the
  # token. RETURN trap is the backstop for any early return; the mv path rm's explicitly.
  # shellcheck disable=SC2064
  trap "rm -f '$tmp'" RETURN

  if [ -f "$cfg" ]; then
    strip_cloud_setup "$cfg" > "$tmp"   # drop existing/duplicate cloud-setup section(s)
  fi

  {
    printf '\n[profile.cloud-setup]\n'
    printf 'address = "%s"\n' "$address"
    printf 'namespace = "%s"\n' "$handle"
    printf 'api_key = "%s"\n' "$token"
    printf '\n[profile.cloud-setup.tls]\n'
    printf 'disabled = false\n'   # TLS is required for Temporal Cloud; set it explicitly (empty section left it ambiguous, causing connect failures)
  } >> "$tmp"

  if ! mv "$tmp" "$cfg"; then
    rm -f "$tmp"; umask "$oldumask"
    die config-write-failed "could not write the profile to $cfg — check directory permissions / free disk, then re-run create-key."
  fi
  chmod 600 "$cfg"
  umask "$oldumask"
  log "Wrote [profile.cloud-setup] to $cfg (chmod 600; api_key not shown)."
}

cmd_verify_config() {
  # `config list` lists profile names, not the key value — but scrub defensively
  # so that even a CLI version that DID echo the key can't leak it through here.
  local out
  if ! out="$(temporal --profile cloud-setup config list 2>&1)"; then
    die profile-missing "could not read the cloud-setup profile; was create-key run?"
  fi
  printf '%s\n' "$out" | redact >&2
  result_open ok; result_kv profile cloud-setup; result_close
}

# cmd_await_auth: deterministic readiness gate to run BEFORE the Worker. A
# freshly-minted API key (and the namespace's just-enabled API-key auth) is not
# accepted by the data plane immediately, so a Worker that connects too soon hits
# `Request unauthorized` and crashes. Poll the cheapest authorized data-plane call
# (`workflow list`, exit 0 == accepted) until it succeeds, bounded. Foreground,
# synchronous — no background, no parallelism.
# await_auth_permanent <file>  -> 0 (true) if the poll's stderr is a HIGH-CONFIDENCE
# PERMANENT key failure that will NEVER clear by waiting. Deliberately NARROW: it
# requires a permanent qualifier (expired / invalid / revoked / not found / disabled /
# malformed) ANCHORED to jwt/key/token/credential context — mirroring key_quota_stderr's
# proximity anchoring.
#
# Anchor set includes `jwt` because the Temporal Cloud API gateway (Envoy) returns the
# auth failure as a gRPC status whose desc is a JWT-filter message, NOT "api key" text.
# Real captures against the prerelease CLI (`workflow list`):
#   * no/empty key   -> `Unauthenticated desc = Jwt is missing`
#   * bad/wrong key  -> `Unauthenticated desc = Jwt issuer is not configured`
#   * expired key    -> `Unauthenticated desc = Jwt is expired`   (Envoy default;
#                       e.g. a ~25h key polled next-day)
# See references/unified-cli.md. Only `expired` is treated as unambiguously permanent
# here: a valid key that is merely PROPAGATING has a future exp, so it can never emit
# "Jwt is expired" — the safety bias holds. A bare transient `Jwt is missing` /
# `issuer is not configured` / `Request unauthorized` / `permission denied` (which can
# appear during propagation) must NOT match, so the poll keeps waiting instead of
# wrong-fast-failing. `expired` stays anchored so a TLS/certificate "expired" (no
# jwt/key/token context) never masquerades as a key failure.
await_auth_permanent() {
  grep -qiE '(jwt|api[ _-]?key|token|credential)[^\n]*(expired|invalid|revoked|not found|disabled|malformed)|(expired|invalid|revoked|malformed)[^\n]*(jwt|api[ _-]?key|token|credential)' "$1" 2>/dev/null
}

cmd_await_auth() {
  local max="${AUTH_READY_MAX_SECS:-90}" interval="${AUTH_POLL_INTERVAL_SECS:-5}" waited=0
  # Per-call timeout so a single wedged `workflow list` can't block the whole loop.
  # `waited` accrues real wall-clock (poll time + sleep), so a slow/hung endpoint
  # still honors ~max seconds of total budget rather than max*call_to.
  local call_to="${AUTH_POLL_CALL_TIMEOUT:-15}"
  log "Waiting for the API key to be accepted before starting the Worker (auth readiness)..."
  # Capture the poll's stderr (never stdout) to a locked temp file so a permanent auth
  # failure is diagnosable instead of collapsing into a generic timeout. EXIT trap so
  # the file is removed on success (return) AND on any die (exit).
  umask 077
  local tmperr; tmperr="$(mktemp "${TMPDIR:-/tmp}/tcloud-auth.XXXXXX")"; chmod 600 "$tmperr"
  # shellcheck disable=SC2064
  trap "rm -f '$tmperr'" EXIT
  local last_err="" rc t0 t1
  while :; do
    : > "$tmperr"
    # exit 0 == the key is accepted. The poll's own exit code still gates success;
    # stderr is only ever used to CLASSIFY a failure (and, redacted, to report it).
    t0="$(date +%s)"
    run_bounded "$call_to" temporal --profile cloud-setup workflow list --limit 1 >/dev/null 2>"$tmperr"
    rc=$?
    if [ "$rc" -eq 0 ]; then
      rm -f "$tmperr"; trap - EXIT
      result_open ok; result_kv auth_ready true; result_kv waited_secs "$waited"; result_close
      return 0
    fi
    # Keep the most recent NON-timeout CLI stderr as the diagnostic tail (a per-call
    # timeout produces no CLI message; don't let it erase the last real one). Redacted
    # at capture, so nothing secret is ever held or surfaced.
    if [ "$rc" -ne "$RUN_BOUNDED_TIMEOUT" ] && [ -s "$tmperr" ]; then
      last_err="$(redact < "$tmperr" | grep -v '^[[:space:]]*$' | tail -n 1)"
    fi
    # High-confidence PERMANENT key failure -> fast-fail; a dead/expired/revoked key
    # will NEVER clear by waiting, so don't spin the full bound. The classifier is
    # deliberately narrow (see await_auth_permanent): a bare transient `unauthorized`
    # /`permission denied` during propagation stays transient and keeps polling — a
    # wrong fast-fail is worse than a bounded wait. NOTE: if a prerelease exited 0 on
    # an auth *failure* the poll would false-pass above before we get here; that's a
    # connectivity-pulse concern, handled separately.
    if [ "$rc" -ne "$RUN_BOUNDED_TIMEOUT" ] && await_auth_permanent "$tmperr"; then
      die key-expired "API key rejected as expired/invalid — it will not clear by waiting (keys auto-expire in ~25h, so a next-day re-test hits a dead key). Re-run create-key to mint a fresh key (it overwrites the [profile.cloud-setup] block). If you are at the API-key cap, delete stale keys first (temporal cloud apikey list; temporal cloud apikey delete --key-id <id>), then re-run create-key.${last_err:+ (CLI stderr: $last_err)}"
    fi
    if [ "$waited" -ge "$max" ]; then
      die auth-timeout "API key still not accepted after ${max}s. A just-minted key/namespace can lag — wait and re-run await-auth; if it never clears, re-run create-key. Do NOT switch endpoints or edit the profile.${last_err:+ (last CLI stderr: $last_err)}"
    fi
    sleep "$interval"
    # Accrue real elapsed (poll + sleep) so the ~max budget is wall-clock, not
    # per-interval: a slow/hung endpoint would otherwise take max*call_to seconds.
    t1="$(date +%s)"
    waited=$(( waited + (t1 - t0) ))
  done
}

# worker_polling <task-queue>  -> exit 0 if >=1 Worker is polling the queue.
# API-based readiness: `task-queue describe -o json` reports a top-level `pollers`
# array (null/empty when no Worker has polled). This is a DATA-plane call (works on
# the prerelease, unlike the control-plane `cloud namespace get -o json`); it also
# returns non-zero while auth is not yet accepted, which the caller treats as "not
# polling yet" (the Worker would be failing for the same reason).
worker_polling() {
  local tq="$1" raw n
  raw="$(temporal --profile cloud-setup task-queue describe --task-queue "$tq" -o json 2>/dev/null)" || return 1
  [ -n "$raw" ] || return 1
  if require_cmd jq; then
    n="$(printf '%s' "$raw" | jq -r '(.pollers // []) | length' 2>/dev/null)"
  elif require_cmd python3; then
    n="$(printf '%s' "$raw" | python3 -c 'import json,sys
try:
    d=json.load(sys.stdin); p=d.get("pollers") or []
    print(len(p))
except Exception:
    print(0)')"
  else
    n=0
  fi
  [ "${n:-0}" -ge 1 ]
}

# latest_workflow_ids  -> echoes "<workflowId> <runId>" for the most recent execution
# in the cloud-setup namespace. `workflow list -o json` is an array whose [0] is the
# newest run; element shape is { "execution": { "workflowId", "runId" }, ... }. The
# namespace is dedicated to this setup, so --limit 1 is the run we just completed.
latest_workflow_ids() {
  local raw wid="" rid=""
  raw="$(temporal --profile cloud-setup workflow list --limit 1 -o json 2>/dev/null)" || return 1
  [ -n "$raw" ] || return 1
  if require_cmd jq; then
    wid="$(printf '%s' "$raw" | jq -r '.[0].execution.workflowId // empty' 2>/dev/null)"
    rid="$(printf '%s' "$raw" | jq -r '.[0].execution.runId // empty' 2>/dev/null)"
  elif require_cmd python3; then
    wid="$(printf '%s' "$raw" | python3 -c 'import json,sys
try:
    a=json.load(sys.stdin); e=(a[0] if a else {}).get("execution",{}) or {}
    print(e.get("workflowId",""))
except Exception:
    print("")')"
    rid="$(printf '%s' "$raw" | python3 -c 'import json,sys
try:
    a=json.load(sys.stdin); e=(a[0] if a else {}).get("execution",{}) or {}
    print(e.get("runId",""))
except Exception:
    print("")')"
  fi
  [ -n "$wid" ] && [ -n "$rid" ] || return 1
  printf '%s %s' "$wid" "$rid"
}

# workflow_status_for <workflowId> [runId]  -> echoes the execution status string
# (e.g. WORKFLOW_EXECUTION_STATUS_COMPLETED) for that Workflow's run, or EMPTY if
# it isn't found yet / the call is momentarily unreadable (treat empty as "not
# yet"). Pin the run with [runId] when known so a reused WorkflowId can never
# read a prior run's status. Always returns 0 so a flaky call doesn't abort.
workflow_status_for() {
  local wid="$1" rid="${2:-}" raw
  if [ -n "$rid" ]; then
    raw="$(temporal --profile cloud-setup workflow describe --workflow-id "$wid" --run-id "$rid" -o json 2>/dev/null)" || return 0
  else
    raw="$(temporal --profile cloud-setup workflow describe --workflow-id "$wid" -o json 2>/dev/null)" || return 0
  fi
  [ -n "$raw" ] || return 0
  if require_cmd jq; then
    printf '%s' "$raw" | jq -r '.workflowExecutionInfo.status // empty' 2>/dev/null
  elif require_cmd python3; then
    printf '%s' "$raw" | python3 -c 'import json,sys
try:
    print(json.load(sys.stdin).get("workflowExecutionInfo",{}).get("status","") or "")
except Exception:
    print("")'
  fi
}

# stop_group <pid>  -> terminate a backgrounded job AND its children. The job is
# launched under `set -m` so it leads its own process group (pgid == pid); a kill
# on the negative pid reaches the whole tree (go/mvn/npm spawn child processes that
# a bare `kill <pid>` would orphan, leaving Workers polling Cloud). Best-effort.
stop_group() {
  local p="${1:-}"
  [ -n "$p" ] || return 0
  kill -TERM "-$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null || true
  sleep 1
  kill -KILL "-$p" 2>/dev/null || kill -KILL "$p" 2>/dev/null || true
  wait "$p" 2>/dev/null || true
  return 0
}

# cmd_run_workflow: THE single-call worker+starter path. The Worker is a
# long-running process that must stay alive WHILE the starter triggers a Workflow —
# but background jobs don't reliably survive across tool calls on Codex/Cursor. So,
# like provision-and-scaffold, this owns the whole dance inside ONE synchronous call:
# launch the Worker detached (own process group) -> wait until it is polling the task
# queue (API-based readiness, not ps/pgrep) -> run the starter in the foreground until
# the Workflow returns -> tear the Worker down -> emit one RESULT block. The Worker only
# needs to live within this call, so no cross-tool-call survival is required.
# Run await-auth BEFORE this so the just-minted key is already accepted.
cmd_run_workflow() {
  local sdk="" dir="" demo="" max="${RUN_WF_MAX_SECS:-180}" ready_max="${WORKER_READY_MAX_SECS:-120}"
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      --dir) dir="$2"; shift 2 ;;
      --demo-failure) demo="$2"; shift 2 ;;
      --max-secs) max="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$sdk" ] || die bad-args "--sdk is required"
  [ -n "$dir" ] || die bad-args "--dir is required (repo_path from provision-and-scaffold)"
  [ -d "$dir" ] || die bad-args "repo dir not found: $dir"
  local wcmd scmd tq
  wcmd="$(worker_cmd_for "$sdk")"; scmd="$(starter_cmd_for "$sdk")"; tq="$(task_queue_for "$sdk")"
  { [ -n "$wcmd" ] && [ -n "$scmd" ] && [ -n "$tq" ]; } || die unknown-sdk "no run-command mapping for SDK '$sdk'"
  # SAFETY INVARIANT: these are SDK-keyed CONSTANT command strings (eval'd below). User
  # input (dir/region/handle/name) is NEVER interpolated into them — it's applied via
  # `cd "$dir"` and `export WORKFLOW_ID=...`. Assert no shell expansion ever sneaks in.
  case "$wcmd$scmd" in *'$'*|*'`'*) die internal-bad-cmd "run-command table must not contain shell expansions" ;; esac
  [ "$demo" = "" ] || [ "$demo" = "transient" ] || die bad-args "--demo-failure only supports 'transient'"

  # Workflow ID: distinct, self-describing names so the clean run and the
  # failure-and-recovery run show up as two different Workflows in Cloud. The
  # sample starters read WORKFLOW_ID from the env (defaulting to the clean name),
  # so this never edits the sample source.
  local wf_name="money-transfer-demo"
  [ "$demo" = "transient" ] && wf_name="money-transfer-demo-recovery"

  # Pre-compile compiled SDKs so Worker and starter skip build time entirely,
  # keeping the timing windows (ready_max, max) bounded to Temporal operations only.
  case "$sdk" in
    java)
      log "  -> pre-compiling Java sample app (mvn compile)..."
      ( cd "$dir" || exit 127; mvn -q compile ) || die precompile-failed "Maven compile failed; check output above. Ensure Java + Maven are installed and the machine has network access for first-time dependency download."
      ;;
    dotnet|.net|net)
      log "  -> pre-compiling .NET sample app (dotnet build)..."
      ( cd "$dir" || exit 127; dotnet build -v q ) || die precompile-failed "dotnet build failed; check output above. Ensure the .NET SDK is installed."
      ;;
  esac

  local job wlog slog wpid="" sp="" src waited swaited
  job="$(mktemp -d "${TMPDIR:-/tmp}/tcloud-run.XXXXXX")"
  wlog="$job/worker.log"; slog="$job/starter.log"
  # Safety net: tear down the Worker + starter and the temp dir on ANY exit —
  # including an external SIGTERM (e.g. the agent's Bash-tool timeout firing before
  # our own --max-secs). Without this, a kill mid-poll orphans the Worker, leaving
  # it polling Cloud (a reported leak). SIGKILL can't be trapped, but a tool-timeout
  # sends TERM, and a normal/`die` exit runs the EXIT trap. stop_group tolerates an
  # empty pid, so this is safe before wpid/sp are assigned.
  # NOTE the `${var:-}` guards: at a NORMAL exit this function has already returned,
  # so its `local` sp/wpid/job are out of scope when the EXIT trap fires — a bare
  # `$sp` under `set -u` prints "sp: unbound variable" after the result block. The
  # `:-` makes those no-ops at normal exit, while a SIGTERM mid-run fires the trap
  # with the function still on the stack (locals in scope), so the Worker is killed.
  trap 'stop_group "${sp:-}" 2>/dev/null; stop_group "${wpid:-}" 2>/dev/null; [ -n "${job:-}" ] && rm -rf "$job" 2>/dev/null' EXIT INT TERM

  # 1. Launch the Worker detached, in its own process group (set -m), output -> wlog.
  if [ "$demo" = "transient" ]; then
    log "Starting the Worker with DEMO_FAILURE=transient (background) for '$sdk'..."
  else
    log "Starting the Worker (background) for '$sdk'..."
  fi
  set -m
  ( cd "$dir" || exit 127
    [ "$demo" = "transient" ] && export DEMO_FAILURE=transient
    eval "$wcmd" ) >"$wlog" 2>&1 &
  wpid=$!
  set +m

  # 2. Readiness: wait until the Worker registers as a poller (API), or it dies first.
  log "  -> waiting for the Worker to start polling task queue '$tq'..."
  waited=0
  while :; do
    if worker_polling "$tq"; then break; fi
    if ! kill -0 "$wpid" 2>/dev/null; then
      tail -n 20 "$wlog" >&2
      if grep -qiE 'unauthorized|permission denied' "$wlog" 2>/dev/null; then
        rm -rf "$job" 2>/dev/null
        die worker-unauthorized "Worker exited with an auth error before polling — the key may not be accepted yet. Run await-auth, then retry run-workflow."
      fi
      rm -rf "$job" 2>/dev/null
      die worker-start-failed "Worker exited before polling; see log tail above (e.g. missing deps/venv or wrong dir). Repo: $dir"
    fi
    if [ "$waited" -ge "$ready_max" ]; then
      # API didn't show a poller in time; accept a log marker as a fallback signal.
      if grep -qiE 'poll|worker started|started worker' "$wlog" 2>/dev/null; then
        log "  [warn] no poller via task-queue API within ${ready_max}s, but the Worker log shows polling — proceeding."
        break
      fi
      stop_group "$wpid"
      tail -n 20 "$wlog" >&2
      rm -rf "$job" 2>/dev/null
      die worker-not-polling "Worker did not register as a poller on '$tq' within ${ready_max}s; see log tail above. Worker log: $wlog"
    fi
    sleep 3; waited=$(( waited + 3 ))
  done
  log "  [ok] Worker is polling '$tq'."

  # 3. Run the starter. It submits the Workflow; some samples then block on the
  #    result, but others (e.g. Java) start it async and exit immediately. Launch
  #    it in its own group so a timeout kill is clean.
  log "  -> running the starter (Workflow runs against Cloud)..."
  set -m
  ( cd "$dir" || exit 127
    export WORKFLOW_ID="$wf_name"
    eval "$scmd" ) >"$slog" 2>&1 &
  sp=$!
  set +m

  # 3b. Wait for the WORKFLOW itself to reach COMPLETED on the server — NOT merely
  #     for the starter to exit. An async starter returns before the Workflow
  #     finishes, so gating on its exit code falsely reports success and tears the
  #     Worker down mid-run, orphaning the execution. Keep the Worker alive while
  #     we poll, bounded by --max-secs. Pin the RunId (parsed from the starter
  #     log) so a reused WorkflowId can never read a prior run's status.
  local wf_id="$wf_name" run_id="" wf_status=""
  swaited=0; src=""; local nowf_waited=0
  while :; do
    # Pin THIS run's RunId from the starter log as soon as it's printed (Java/Go/TS).
    if [ -z "$run_id" ]; then
      run_id="$(grep -ioE 'run[ _-]?id["'"'"':= ]+[A-Za-z0-9-]{8,}' "$slog" 2>/dev/null | head -n1 | grep -oE '[A-Za-z0-9-]{8,}$')"
    fi
    # Reap the starter when it exits. Once it's fully reaped its stdout is flushed,
    # so make a definitive RunId parse here before we'd fall back to a by-id query.
    if [ -z "$src" ] && ! kill -0 "$sp" 2>/dev/null; then
      wait "$sp"; src=$?
      [ -z "$run_id" ] && run_id="$(grep -ioE 'run[ _-]?id["'"'"':= ]+[A-Za-z0-9-]{8,}' "$slog" 2>/dev/null | head -n1 | grep -oE '[A-Za-z0-9-]{8,}$')"
    fi

    # Only read status once it can be attributed to THIS run — either the RunId is
    # pinned, or the starter has exited 0 (a sync SDK that waited for completion, so
    # the new run is now the latest). Querying by WorkflowId before then could read a
    # prior reused-WorkflowId run's COMPLETED status and bail before our run even ran.
    if [ -n "$run_id" ]; then
      wf_status="$(workflow_status_for "$wf_id" "$run_id")"
    elif [ -n "$src" ] && [ "$src" -eq 0 ]; then
      wf_status="$(workflow_status_for "$wf_id")"
    else
      wf_status=""
    fi
    case "$wf_status" in
      *COMPLETED) break ;;
      *FAILED|*TERMINATED|*CANCELED|*TIMED_OUT)
        stop_group "$sp" 2>/dev/null; stop_group "$wpid"
        tail -n 30 "$slog" >&2; rm -rf "$job" 2>/dev/null
        die workflow-failed "Workflow ended ${wf_status##*STATUS_} (not COMPLETED). See log tail above." ;;
    esac
    # Starter died non-zero and never produced a trackable Workflow -> a real failure.
    if [ -n "$src" ] && [ "$src" -ne 0 ] && [ -z "$run_id" ] && [ -z "$wf_status" ]; then
      stop_group "$wpid"
      tail -n 30 "$slog" >&2; rm -rf "$job" 2>/dev/null
      die workflow-failed "starter exited ${src} and no Workflow was submitted. See log tail above."
    fi
    # Fast-fail: a starter that exited 0 but produced NO trackable Workflow (no RunId
    # pinned, and a by-id lookup stays empty) never actually submitted one — e.g. a client
    # that catches its own connect/start error and still exits 0 (the .NET sample does
    # exactly this). Give the server a brief settle window for a flaky read, then fail
    # clearly instead of burning the full --max-secs as a misleading timeout.
    if [ -n "$src" ] && [ "$src" -eq 0 ] && [ -z "$run_id" ] && [ -z "$wf_status" ]; then
      nowf_waited=$(( nowf_waited + 2 ))
      if [ "$nowf_waited" -ge "${NOWF_SETTLE_SECS:-15}" ]; then
        stop_group "$wpid"
        tail -n 30 "$slog" >&2; rm -rf "$job" 2>/dev/null
        die workflow-not-submitted "starter exited 0 but no Workflow was submitted within ${NOWF_SETTLE_SECS:-15}s — it likely swallowed a connect/start error (some samples catch-and-exit-0). See log tail above."
      fi
    else
      nowf_waited=0
    fi
    if [ "$swaited" -ge "$max" ]; then
      stop_group "$sp" 2>/dev/null; stop_group "$wpid"
      # Only terminate if the execution is genuinely still RUNNING — never kill a
      # Workflow that actually completed but whose status was momentarily unreadable.
      if [ -n "$wf_status" ] && [ -z "${wf_status##*RUNNING}" ]; then
        log "  [cleanup] terminating orphaned workflow '$wf_id' (local timeout)..."
        if [ -n "$run_id" ]; then
          temporal --profile cloud-setup workflow terminate --workflow-id "$wf_id" --run-id "$run_id" --reason "run-workflow timed out after ${max}s" >/dev/null 2>&1 || true
        else
          temporal --profile cloud-setup workflow terminate --workflow-id "$wf_id" --reason "run-workflow timed out after ${max}s" >/dev/null 2>&1 || true
        fi
      fi
      tail -n 20 "$slog" >&2; rm -rf "$job" 2>/dev/null
      die workflow-timeout "Workflow did not reach COMPLETED within ${max}s; see log tail above. Worker log: $wlog"
    fi
    sleep 2; swaited=$(( swaited + 2 ))
  done

  # 4. Workflow reached COMPLETED on the server — now tear the Worker down.
  stop_group "$sp" 2>/dev/null
  stop_group "$wpid"
  log "  [ok] Worker stopped."

  # IDs for the run link. The Workflow ID is authoritative ($wf_name — set via
  # WORKFLOW_ID and honored by every sample), so report it directly. run_id was
  # pinned from the starter log during the completion wait above; if the sample
  # never printed it (a sync SDK that already completed), resolve it from the
  # data plane (retried briefly, only accepting the row that matches OUR
  # Workflow ID). May still be empty, in which case the caller links to the
  # Workflow detail page (never the bare namespace list).
  if [ -z "$run_id" ]; then
    local ids="" tries=0
    while [ "$tries" -lt 6 ]; do
      ids="$(latest_workflow_ids)" || ids=""
      if [ -n "$ids" ] && [ "${ids%% *}" = "$wf_id" ]; then run_id="${ids##* }"; break; fi
      tries=$(( tries + 1 )); sleep 2
    done
  fi

  result_open ok
  result_kv workflow_status COMPLETED
  result_kv workflow_id "$wf_id"
  result_kv run_id "${run_id:-unknown}"
  result_kv task_queue "$tq"
  result_kv worker_log "$wlog"
  [ "$demo" = "transient" ] && result_kv demo_failure transient
  result_close
}

cmd_clone() {
  local sdk="" dir=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      --dir) dir="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$sdk" ] || die bad-args "--sdk is required"
  local url; url="$(repo_url_for_sdk "$sdk")"
  [ -n "$url" ] || die unknown-sdk "no repo mapping for SDK '$sdk'"
  local target="${dir:-$(basename "$url")}"

  log "Cloning ${url} (branch money-transfer-project-cloud-setup) into ${target}..."
  if ! git clone --branch money-transfer-project-cloud-setup --single-branch "$url" "$target" >&2; then
    die clone-failed "git clone failed for $url"
  fi
  result_open ok
  result_kv repo_url "$url"
  result_kv path "$target"
  result_close
}

# deps_already_present <sdk> <dir>  -> exit 0 if deps look installed (skip them).
# Manager-independent: an env dir / node_modules / satisfied bundle means a re-run
# need not reinstall. Best-effort; called from inside the target dir.
deps_already_present() {
  case "$1" in
    python)        [ -d env ] ;;
    ts|typescript) [ -d node_modules ] ;;
    ruby)          bundle check >/dev/null 2>&1 ;;
    *)             return 1 ;;   # go/java/dotnet: install is cheap / handled at compile
  esac
}

# install_deps_for <sdk> <manager> <dir>  — manager-parameterized dependency
# install, detect-then-skip, backed by the (sdk,manager) matrix in install_cmd_for.
# All output goes to stderr (caller redirects) so it never pollutes the RESULT
# block. Best-effort: a deps hiccup warns but does not abort provisioning.
install_deps_for() {
  local sdk="$1" mgr="$2" dir="$3" cmd
  cmd="$(install_cmd_for "$sdk" "$mgr")"
  if [ -z "$cmd" ]; then
    log "  [warn] no install command for sdk '$sdk' + manager '$mgr' — skipping deps"
    return 0
  fi
  # SAFETY INVARIANT: install_cmd_for returns SDK/manager-keyed CONSTANT strings (eval'd
  # below). No user input is interpolated; assert no shell expansion can sneak in.
  case "$cmd" in *'$'*|*'`'*) die internal-bad-cmd "install command table must not contain shell expansions" ;; esac
  ( cd "$dir" 2>/dev/null || { log "  [warn] could not enter $dir for deps"; exit 0; }
    if deps_already_present "$sdk"; then
      log "  [skip] $sdk dependencies already present"
    else
      log "  -> installing deps via '$mgr': $cmd"
      eval "$cmd"
    fi
  ) || log "  [warn] dependency install reported a problem (continuing; can be retried)"
}

# cmd_install_deps: standalone manager-parameterized install. Lets the
# agent install (or re-install) deps with an explicitly chosen/overridden manager.
# Defaults --manager to the SDK's deterministic default when omitted. Dies with
# manager-not-found if the chosen manager's binary isn't on PATH (a clear,
# fail-fast error rather than a confusing downstream Worker crash).
cmd_install_deps() {
  local sdk="" mgr="" dir=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      --manager) mgr="$2"; shift 2 ;;
      --dir) dir="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$sdk" ] || die bad-args "--sdk is required"
  [ -n "$dir" ] || die bad-args "--dir is required (repo_path)"
  [ -d "$dir" ] || die bad-args "repo dir not found: $dir"
  local candidates bin
  resolve_pkg_inputs "$sdk" "$mgr" || die_pkg $? "$sdk"
  mgr="$PKG_MGR"; candidates="$PKG_CANDIDATES"   # mgr defaulted to the preferred candidate
  bin="$(manager_bin "$mgr")"
  require_cmd "$bin" || die manager-not-found "package manager '$mgr' (needs '$bin') is not installed; install it or pick another (supported: $(printf '%s' "$candidates" | tr ' ' ','))."

  install_deps_for "$sdk" "$mgr" "$dir"
  result_open ok
  result_kv sdk "$sdk"
  result_kv manager "$mgr"
  result_kv repo_path "$dir"
  result_close
}

# cmd_scaffold: set up the app only — clone the cloud-ready sample + install deps,
# NO namespace work. Pairs with start-namespace/await-namespace so the app
# setup is its own gated step that overlaps the server-side namespace provisioning.
# Validates the manager fail-fast (manager-not-found / unsupported-manager) BEFORE
# cloning, same as provision-and-scaffold.
cmd_scaffold() {
  local sdk="" dir="" mgr=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      --dir) dir="$2"; shift 2 ;;
      --manager) mgr="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$sdk" ] || die bad-args "--sdk is required"
  local url candidates target bin
  url="$(repo_url_for_sdk "$sdk")"
  [ -n "$url" ] || die unknown-sdk "no repo mapping for SDK '$sdk'"
  resolve_pkg_inputs "$sdk" "$mgr" || die_pkg $? "$sdk"
  mgr="$PKG_MGR"; candidates="$PKG_CANDIDATES"
  bin="$(manager_bin "$mgr")"
  require_cmd "$bin" || die manager-not-found "package manager '$mgr' (needs '$bin') is not installed; install it or pick another (supported: $(printf '%s' "$candidates" | tr ' ' ','))."
  target="${dir:-$(basename "$url")}"

  log "Cloning the sample for '$sdk' into '$target'..."
  if ! git clone --branch money-transfer-project-cloud-setup --single-branch "$url" "$target" >&2; then
    die clone-failed "git clone failed for $url"
  fi
  log "  [ok] cloned"
  log "  -> installing dependencies ($sdk via $mgr)..."
  install_deps_for "$sdk" "$mgr" "$target" >&2
  log "  [ok] dependencies ready"

  result_open ok
  result_kv repo_path "$target"
  result_kv manager "$mgr"
  result_close
}

# cmd_provision_and_scaffold: THE single-call parallel path. Runs as one
# ordinary SYNCHRONOUS foreground command; internally backgrounds the (synchronous,
# never --async) namespace create and overlaps it with clone + deps, then `wait`s
# and joins. Because the background job lives and dies inside this one invocation,
# no cross-tool-call survival is needed -> works on Claude Code, Codex, and Cursor.
# Emits append-only progress lines to stderr; one RESULT block on stdout.
cmd_provision_and_scaffold() {
  local sdk="" region="" dir="" mgr=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      --region) region="$2"; shift 2 ;;
      --dir) dir="$2"; shift 2 ;;
      --manager) mgr="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  [ -n "$sdk" ]    || die bad-args "--sdk is required"
  [ -n "$region" ] || die bad-args "--region is required"
  temporal cloud whoami >/dev/null 2>&1 || die not-authenticated "Not signed in; run login again."

  local url name target nsout nspid rc out handle candidates bin
  url="$(repo_url_for_sdk "$sdk")"
  [ -n "$url" ] || die unknown-sdk "no repo mapping for SDK '$sdk'"
  # Resolve the manager up front (default = preferred candidate) and fail fast if
  # an explicitly chosen one is unsupported/uninstalled, BEFORE we create the
  # namespace + clone (so a bad --manager never leaves half-provisioned state).
  resolve_pkg_inputs "$sdk" "$mgr" || die_pkg $? "$sdk"
  mgr="$PKG_MGR"; candidates="$PKG_CANDIDATES"
  bin="$(manager_bin "$mgr")"
  require_cmd "$bin" || die manager-not-found "package manager '$mgr' (needs '$bin') is not installed; install it or pick another (supported: $(printf '%s' "$candidates" | tr ' ' ','))."
  name="$(namespace_name_for "$sdk")"
  target="${dir:-$(basename "$url")}"

  log "Provisioning in parallel: namespace '$name' (background) + sample app for '$sdk'..."

  # Background the SYNCHRONOUS create (never --async); capture its output to a file.
  nsout="$(mktemp "${TMPDIR:-/tmp}/tcloud-ns.XXXXXX")"
  ( temporal cloud namespace create \
      --name "$name" --region "$region" \
      --api-key-auth-enabled --retention-days 30 --auto-confirm ) >"$nsout" 2>&1 &
  nspid=$!
  log "  -> namespace create started in the background (provisioning takes a few minutes)"

  # Foreground overlap: clone + deps. On clone failure, stop the namespace job too.
  log "  -> cloning sample into '$target'..."
  if ! git clone --branch money-transfer-project-cloud-setup --single-branch "$url" "$target" >&2; then
    kill "$nspid" 2>/dev/null; wait "$nspid" 2>/dev/null; rm -f "$nsout"
    die clone-failed "git clone failed for $url"
  fi
  log "  [ok] cloned"
  log "  -> installing dependencies ($sdk via $mgr)..."
  install_deps_for "$sdk" "$mgr" "$target" >&2
  log "  [ok] dependencies ready"

  # Join: wait for the namespace create to finish, then resolve the handle.
  log "  -> joining: waiting for the namespace to finish provisioning..."
  wait "$nspid"; rc=$?
  out="$(cat "$nsout")"; rm -f "$nsout"
  if [ "$rc" -ne 0 ]; then
    printf '%s\n' "$out" | tail -n 20 >&2
    die create-rejected "namespace create exited $rc — usually region or name format; see output above."
  fi
  # Resolve from the authoritative, pagination-immune `--name` filter and wait until the
  # namespace is ACTIVE (a just-submitted create is ACTIVATING for a few minutes). The
  # filtered result carries the real endpoint, so read the address from it.
  local info
  info="$(await_active_namespace "$name" "${NS_AWAIT_MAX_SECS:-600}")"; rc=$?
  case "$rc" in
    0) ;;
    2) die namespace-not-provisioning "namespace '$name' never appeared after ~${NS_PHANTOM_GRACE_SECS:-75}s — the create was accepted but isn't provisioning (commonly an unavailable region, e.g. azure-centralus). Re-run with an AWS/GCP region." ;;
    *) die handle-not-found "namespace '$name' created but not ACTIVE after retries (still provisioning?). Re-run provision-and-scaffold or await-namespace to resume the wait." ;;
  esac
  handle="${info%%|*}"
  log "  [ok] namespace active: $handle"

  result_open ok
  result_kv namespace_handle "$handle"
  result_kv address "${info#*|}"
  result_kv repo_path "$target"
  result_kv manager "$mgr"
  result_close
}

# cleanup PRINTS the destructive commands; it never runs them.
cmd_cleanup_info() {
  local handle="" keyid=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --handle) handle="$2"; shift 2 ;;
      --key-id) keyid="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done
  local cfg; cfg="$(config_path)"
  result_open ok
  result_kv config_path "$cfg"
  result_kv revoke_key_cmd "temporal cloud apikey delete --key-id ${keyid:-<key-id>}"
  result_kv delete_namespace_cmd "temporal cloud namespace delete --namespace ${handle:-<handle>}"
  result_kv note "Run these yourself; never --auto-confirm a delete. Then remove the [profile.cloud-setup] block from the config file."
  result_close
}

# repair-config: strip ALL [profile.cloud-setup] block(s) from the shared TOML,
# preserving [profile.default]. Use when prior/partial runs left duplicate
# cloud-setup sections that made the file unparseable. Never reads the file into
# the agent's context (the work is in awk); never touches other profiles. After
# repair, re-run create-key to write one fresh, valid profile.
cmd_repair_config() {
  local cfg; cfg="$(config_path)"
  if [ ! -f "$cfg" ]; then
    result_open ok; result_kv config_path "$cfg"; result_kv removed_blocks 0
    result_kv note "no config file present — nothing to repair"; result_close; return
  fi
  local before dir tmp
  before="$(grep -cE '^[[:space:]]*\[profile\.cloud-setup\]' "$cfg" 2>/dev/null || true)"
  dir="$(dirname "$cfg")"
  tmp="$(mktemp "${dir}/temporal.toml.XXXXXX")"
  chmod 600 "$tmp"
  strip_cloud_setup "$cfg" > "$tmp"
  mv "$tmp" "$cfg"
  chmod 600 "$cfg"
  log "Stripped cloud-setup profile(s) from $cfg (was ${before:-0}); [profile.default] preserved. Re-run create-key to write a fresh one."
  result_open ok
  result_kv config_path "$cfg"
  result_kv removed_blocks "${before:-0}"
  result_kv note "cloud-setup stripped; default preserved. Run create-key to recreate the profile."
  result_close
}

# emit_gate <heading> [<comment> <command>]...  (determinism lever)
# Prints a READY-TO-RENDER gate block on stdout, AFTER the RESULT block, delimited
# by `=== GATE ===` / `=== END GATE ===`. The agent renders the text between the
# markers VERBATIM (then appends the numbered choices) instead of assembling the
# fenced block from prose rules — which is error-prone to hand-assemble
# (dropped fence, comment-only, glued rules). ASCII only; comment goes ABOVE its
# command (green-comment style). Same idea as pinning the CLI flags: move the exact
# format into the script so the model only has to print it.
emit_gate() {
  # Plain bold heading, then a fenced ```bash``` block: a "# " comment above each
  # command. Keep the fence — without it a leading "#" renders as a markdown
  # heading. The agent renders this verbatim, so it can't be mangled by hand.
  local heading="$1"; shift
  printf '=== GATE ===\n'
  printf '**%s**\n\n' "$heading"
  printf '```bash\n'
  local first=1
  while [ $# -ge 2 ]; do
    [ "$first" -eq 1 ] || printf '\n'
    first=0
    printf '# %s\n%s\n' "$1" "$2"
    shift 2
  done
  printf '```\n'
  printf '=== END GATE ===\n'
}

# cmd_preview: dry-run. Resolve and PRINT the concrete command(s) a
# subcommand would run, plus the resolved user-facing parameters, with NO side
# effects (no clone, no temporal calls, no installs) — this powers the per-command
# confirm gate and the "Edit" path in SKILL.md. Deterministic and offline: the
# namespace NAME is shown as its template (it is randomized at real run time), so
# preview never needs the network or auth. Emits cmd_1/cmd_2/... in the RESULT AND
# a ready-to-render `=== GATE ===` block (see emit_gate) the agent prints verbatim.
cmd_preview() {
  local sub="${1:-}"; shift || true
  [ -n "$sub" ] || die bad-args "preview needs a subcommand, e.g. preview run-workflow --sdk python --dir D"
  local sdk="" region="" dir="" mgr="" handle="" address="" demo="" maxsecs=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk) sdk="$2"; shift 2 ;;
      --region) region="$2"; shift 2 ;;
      --dir) dir="$2"; shift 2 ;;
      --manager) mgr="$2"; shift 2 ;;
      --handle) handle="$2"; shift 2 ;;
      --address) address="$2"; shift 2 ;;
      --demo-failure) demo="$2"; shift 2 ;;
      --max-secs) maxsecs="$2"; shift 2 ;;
      *) die bad-args "unknown arg: $1" ;;
    esac
  done

  local url target candidates wcmd scmd tq wf_name icmd
  case "$sub" in
    clone)
      [ -n "$sdk" ] || die bad-args "preview clone needs --sdk"
      url="$(repo_url_for_sdk "$sdk")"; [ -n "$url" ] || die unknown-sdk "no repo mapping for SDK '$sdk'"
      target="${dir:-$(basename "$url")}"
      result_open ok
      result_kv preview clone
      result_kv repo_url "$url"
      result_kv clone_dir "$target"
      result_kv cmd_1 "git clone --branch money-transfer-project-cloud-setup --single-branch $url $target"
      result_close
      emit_gate "Downloading the sample app (clone only)" \
        "clone the Cloud-ready sample" "git clone --branch money-transfer-project-cloud-setup --single-branch $url $target" ;;
    install-deps)
      [ -n "$sdk" ] || die bad-args "preview install-deps needs --sdk"
      resolve_pkg_inputs "$sdk" "$mgr" || die_pkg $? "$sdk"
      mgr="$PKG_MGR"; icmd="$PKG_ICMD"; candidates="$PKG_CANDIDATES"
      result_open ok
      result_kv preview install-deps
      result_kv manager "$mgr"
      result_kv supported_managers "$(printf '%s' "$candidates" | tr ' ' ',')"
      result_kv clone_dir "${dir:-<repo_path>}"
      result_kv cmd_1 "(cd ${dir:-<repo_path>} && $icmd)"
      result_close
      emit_gate "Installing the sample's dependencies ($mgr)" \
        "install dependencies with $mgr $(install_location_note "$sdk")" "(cd ${dir:-<repo_path>} && $icmd)" ;;
    provision-and-scaffold)
      [ -n "$sdk" ] || die bad-args "preview provision-and-scaffold needs --sdk"
      url="$(repo_url_for_sdk "$sdk")"; [ -n "$url" ] || die unknown-sdk "no repo mapping for SDK '$sdk'"
      resolve_pkg_inputs "$sdk" "$mgr" || die_pkg $? "$sdk"
      mgr="$PKG_MGR"; icmd="$PKG_ICMD"
      target="${dir:-$(basename "$url")}"
      result_open ok
      result_kv preview provision-and-scaffold
      result_kv region "${region:-<region>}"
      result_kv manager "$mgr"
      result_kv clone_dir "$target"
      result_kv namespace_name_template "quickstartai-<user>-<random> (randomized at run; <=39 chars)"
      result_kv cmd_1 "temporal cloud namespace create --name <name> --region ${region:-<region>} --api-key-auth-enabled --retention-days 30 --auto-confirm"
      result_kv cmd_2 "git clone --branch money-transfer-project-cloud-setup --single-branch $url $target"
      result_kv cmd_3 "(cd $target && $icmd)"
      result_close
      emit_gate "Creating your namespace & downloading the sample app" \
        "create your Cloud namespace - billable; provisions server-side (~a few min)" "temporal cloud namespace create --name <name> --region ${region:-<region>} --api-key-auth-enabled --retention-days 30 --auto-confirm" \
        "clone the Cloud-ready sample" "git clone --branch money-transfer-project-cloud-setup --single-branch $url $target" \
        "install dependencies with $mgr $(install_location_note "$sdk")" "(cd $target && $icmd)" ;;
    run-workflow)
      [ -n "$sdk" ] || die bad-args "preview run-workflow needs --sdk"
      wcmd="$(worker_cmd_for "$sdk")"; scmd="$(starter_cmd_for "$sdk")"; tq="$(task_queue_for "$sdk")"
      { [ -n "$wcmd" ] && [ -n "$scmd" ] && [ -n "$tq" ]; } || die unknown-sdk "no run-command mapping for SDK '$sdk'"
      wf_name="money-transfer-demo"; [ "$demo" = "transient" ] && wf_name="money-transfer-demo-recovery"
      result_open ok
      result_kv preview run-workflow
      result_kv repo_dir "${dir:-<repo_path>}"
      result_kv workflow_id "$wf_name"
      result_kv task_queue "$tq"
      result_kv max_secs "${maxsecs:-180}"
      [ "$demo" = "transient" ] && result_kv demo_failure transient
      result_kv cmd_1 "(cd ${dir:-<repo_path>} && ${demo:+DEMO_FAILURE=transient }$wcmd)   # Worker (background)"
      result_kv cmd_2 "(cd ${dir:-<repo_path>} && WORKFLOW_ID=$wf_name $scmd)   # starter"
      result_close
      local rw_head="Run your first Workflow"
      [ "$demo" = "transient" ] && rw_head="Run the recovery Workflow (inject a failure)"
      emit_gate "$rw_head" \
        "Worker - runs in the background, polls the task queue, stopped when done" "(cd ${dir:-<repo_path>} && ${demo:+DEMO_FAILURE=transient }$wcmd)" \
        "starter - submits the Workflow, waits for COMPLETED, exits" "(cd ${dir:-<repo_path>} && WORKFLOW_ID=$wf_name $scmd)" ;;
    create-namespace)
      result_open ok
      result_kv preview create-namespace
      result_kv region "${region:-<region>}"
      result_kv namespace_name_template "quickstartai-<user>-<random> (randomized at run; <=39 chars)"
      result_kv cmd_1 "temporal cloud namespace create --name <name> --region ${region:-<region>} --api-key-auth-enabled --retention-days 30 --auto-confirm"
      result_close
      emit_gate "Creating your Cloud namespace" \
        "create your Cloud namespace - billable; provisions server-side (~a few min)" "temporal cloud namespace create --name <name> --region ${region:-<region>} --api-key-auth-enabled --retention-days 30 --auto-confirm" ;;
    start-namespace)
      result_open ok
      result_kv preview start-namespace
      result_kv region "${region:-<region>}"
      result_kv namespace_name_template "quickstartai-<user>-<random> (randomized at run; <=39 chars)"
      result_kv cmd_1 "temporal cloud namespace create --name <name> --region ${region:-<region>} --api-key-auth-enabled --retention-days 30 --auto-confirm --async   # submits, returns immediately; provisions server-side"
      result_close
      emit_gate "Creating your Cloud namespace" \
        "create your Cloud namespace - billable; submits async, provisions server-side (~a few min)" \
        "temporal cloud namespace create --name <name> --region ${region:-<region>} --api-key-auth-enabled --retention-days 30 --auto-confirm --async" ;;
    await-namespace)
      result_open ok
      result_kv preview await-namespace
      result_kv cmd_1 "temporal cloud namespace list --name <namespace-name> -o json   # polled until the namespace is ACTIVE (bounded)"
      result_close
      emit_gate "Waiting for the namespace to provision" \
        "poll until the namespace exists and is ACTIVE (bounded)" \
        "temporal cloud namespace list --name <namespace-name> -o json" ;;
    scaffold)
      [ -n "$sdk" ] || die bad-args "preview scaffold needs --sdk"
      url="$(repo_url_for_sdk "$sdk")"; [ -n "$url" ] || die unknown-sdk "no repo mapping for SDK '$sdk'"
      resolve_pkg_inputs "$sdk" "$mgr" || die_pkg $? "$sdk"
      mgr="$PKG_MGR"; icmd="$PKG_ICMD"
      target="${dir:-$(basename "$url")}"
      result_open ok
      result_kv preview scaffold
      result_kv manager "$mgr"
      result_kv clone_dir "$target"
      result_kv cmd_1 "git clone --branch money-transfer-project-cloud-setup --single-branch $url $target"
      result_kv cmd_2 "(cd $target && $icmd)"
      result_close
      emit_gate "Downloading the sample app (clone + dependencies)" \
        "clone the Cloud-ready sample" "git clone --branch money-transfer-project-cloud-setup --single-branch $url $target" \
        "install dependencies with $mgr $(install_location_note "$sdk")" "(cd $target && $icmd)" ;;
    create-key)
      # Never show the token; the command itself is fine (it carries no secret).
      result_open ok
      result_kv preview create-key
      result_kv handle "${handle:-<namespace-handle>}"
      result_kv address "${address:-<address>}"
      result_kv cmd_1 "temporal cloud apikey create-for-me --display-name money-transfer-cloud-setup --expiry-duration 25h --auto-confirm -o json   # token captured to a 0600 file, never printed"
      result_close
      emit_gate "Creating your API key and saving the config" \
        "mint the key + write the cloud-setup profile to temporal.toml (token captured to a 0600 file, never printed)" \
        "temporal cloud apikey create-for-me --display-name money-transfer-cloud-setup --expiry-duration 25h --auto-confirm -o json" ;;
    install-cli)
      # Presence-aware (read-only): disclose what cmd_install_cli will do. BETA
      # stopgap (PE-79): a present CLI is updated to the latest prerelease (not
      # skipped) while the CLI has no real versions; an absent CLI is installed.
      if cloud_cli_present; then
        local uc_cmd uc_note
        case "$(uname -s)" in
          Darwin) uc_cmd="brew upgrade temporalio/prerelease/temporal-cloud"; uc_note="update the Temporal CLI to the latest prerelease via Homebrew (adds/updates software)" ;;
          *)      uc_cmd="# already installed; update temporal-cloud manually from https://github.com/temporalio/cloud-cli/releases/latest"; uc_note="the Temporal CLI is already installed; on this OS, update temporal-cloud manually from the releases page" ;;
        esac
        result_open ok
        result_kv preview install-cli
        result_kv cli_present true
        result_kv cmd_1 "$uc_cmd"
        result_close
        emit_gate "Updating the Temporal CLI to the latest" "$uc_note" "$uc_cmd"
      else
        local ic_cmd ic_note
        case "$(uname -s)" in
          Darwin) ic_cmd="brew install temporalio/prerelease/temporal-cloud"; ic_note="install the Temporal CLI via Homebrew (adds software)" ;;
          *)      ic_cmd="# download temporal-cloud from https://github.com/temporalio/cloud-cli/releases/latest and put it on PATH"; ic_note="install the Temporal CLI (download the binary, put it on PATH)" ;;
        esac
        result_open ok
        result_kv preview install-cli
        result_kv cli_present false
        result_kv cmd_1 "$ic_cmd"
        result_close
        emit_gate "Installing the Temporal CLI" "$ic_note" "$ic_cmd"
      fi ;;
    login)
      result_open ok
      result_kv preview login
      result_kv cmd_1 "temporal cloud login   # opens a browser on your machine; blocks until you finish"
      result_kv cmd_2 "temporal cloud whoami   # confirms the signed-in identity"
      result_close
      emit_gate "Sign in to Temporal Cloud" \
        "open a browser to sign in (blocks until you finish)" "temporal cloud login" \
        "confirm the signed-in identity" "temporal cloud whoami" ;;
    regions)
      result_open ok
      result_kv preview regions
      result_kv cmd_1 "temporal cloud region list"
      result_close
      emit_gate "Listing your Cloud regions" \
        "list the regions your account can use" \
        "temporal cloud region list" ;;
    await-auth)
      result_open ok
      result_kv preview await-auth
      result_kv cmd_1 "temporal --profile cloud-setup workflow list --limit 1   # polled until the new API key is accepted (bounded ~90s)"
      result_close
      emit_gate "Waiting for the API key to be accepted" \
        "poll an authorized call until the new key is accepted (bounded ~90s)" \
        "temporal --profile cloud-setup workflow list --limit 1" ;;
    verify-config)
      result_open ok
      result_kv preview verify-config
      result_kv cmd_1 "temporal --profile cloud-setup config list   # lists profile fields; api_key is redacted, never shown"
      result_close
      emit_gate "Verifying the saved config" \
        "list the cloud-setup profile fields (api_key redacted, never shown)" \
        "temporal --profile cloud-setup config list" ;;
    preflight)
      result_open ok
      result_kv preview preflight
      result_close
      emit_gate "Checking your environment" \
        "check git / jq / brew are available (read-only, local)" "command -v git jq brew" \
        "flag any stray TEMPORAL_* env vars that would override your saved config" "env | grep '^TEMPORAL_' || true" ;;
    detect-tools)
      [ -n "$sdk" ] || die bad-args "preview detect-tools needs --sdk"
      candidates="$(managers_for "$sdk")"; [ -n "$candidates" ] || die unknown-sdk "no package-manager mapping for SDK '$sdk'"
      local dt_bins="" dt_m dt_b
      for dt_m in $candidates; do dt_b="$(manager_bin "$dt_m")"; dt_bins="${dt_bins:+$dt_bins }$dt_b"; done
      result_open ok
      result_kv preview detect-tools
      result_close
      emit_gate "Detecting your local tools" \
        "detect which package managers are installed for $sdk (read-only, local)" "command -v $dt_bins" \
        "read each tool's version to flag anything below the minimum" "$(runtime_for "$sdk") --version" ;;
    repair-config)
      result_open ok
      result_kv preview repair-config
      result_kv cmd_1 "strip [profile.cloud-setup] block(s) from temporal.toml (keeps [profile.default])"
      result_close
      emit_gate "Repairing temporal.toml (strip duplicate cloud-setup profiles)" \
        "remove any existing [profile.cloud-setup] block(s); keeps [profile.default]" "scripts/provision.sh repair-config" ;;
    *)
      die bad-args "preview does not support subcommand '$sub' (try: preflight, detect-tools, install-cli, login, regions, start-namespace, scaffold, await-namespace, provision-and-scaffold, install-deps, clone, create-namespace, create-key, await-auth, run-workflow, verify-config, repair-config)" ;;
  esac
}

# announce_gate <subcommand> [args...]   (deterministic disclosure floor)
# Before an effectful subcommand acts, print ITS gate to STDERR so the tool block
# ALWAYS records what the command runs — even when the agent skips rendering the
# chat-side gate from §Gate templates. Reuses cmd_preview's single-source gate
# text, so the disclosure can never drift from the real command; strips the
# machine markers and frames it as
# plain human disclosure. Never blocks the real work: any preview failure (an arg
# preview doesn't know, an unsupported sub) is swallowed and the command proceeds.
# Opt out with TCLOUD_DISCLOSE=0 (the test harness sets this to keep stderr clean).
# It is a RECORD, not a pre-consent prompt — in the request/response tool model it
# surfaces bundled with the result; the chat-side gate remains the pre-action one.
announce_gate() {
  [ "${TCLOUD_DISCLOSE:-1}" = "0" ] && return 0
  local sub="$1"; shift
  # Forward only the flags cmd_preview understands; drop the rest (--name,
  # --display-name, …) so disclosure never dies on an arg the real command accepts
  # but preview doesn't. (--display-name carries no secret; it's dropped for parity
  # with the preview create-key gate, which already shows the command tokenless.)
  local pv=()
  while [ $# -gt 0 ]; do
    case "$1" in
      --sdk|--region|--dir|--manager|--handle|--address|--demo-failure|--max-secs)
        if [ $# -ge 2 ]; then pv+=("$1" "$2"); shift 2; else shift; fi ;;
      *) shift ;;
    esac
  done
  local block
  # cmd_preview is offline + side-effect-free; a `die` inside it exits only this
  # command-substitution subshell, so `|| return 0` keeps the real work going.
  block="$(cmd_preview "$sub" ${pv[@]+"${pv[@]}"} 2>/dev/null)" || return 0
  # Keep only the lines between the GATE markers (drop the markers + the RESULT block).
  block="$(printf '%s\n' "$block" | sed -n '/^=== GATE ===$/,/^=== END GATE ===$/{/^=== GATE ===$/d;/^=== END GATE ===$/d;p;}')"
  [ -n "$block" ] || return 0
  {
    printf 'disclosure — the command(s) this step runs (auto-recorded):\n'
    printf '%s\n' "$block"
  } >&2
}

usage() {
  cat >&2 <<'EOF'
provision.sh — deterministic executor for temporal-cloud-setup

Usage: provision.sh <command> [args]

Commands:
  preflight [--sdk S]                       Environment checks + config path + stray TEMPORAL_* vars
  detect-tools --sdk S                      Detect supported+installed package managers, pick a default,
                                            report versions, surface discrepancies
  preview <subcommand> [args]               Dry-run: print the concrete command(s) a subcommand would run
                                            (no side effects) — powers the per-command confirm/Edit gate
  install-cli                               Install the prerelease temporal-cloud CLI (updates it to latest if already present)
  login                                     temporal cloud login (browser) + whoami
  regions                                   List Cloud regions (raw list to stderr)
  start-namespace --sdk S --region R        Fire-and-forget: submit `namespace create --async` (server-side); emits namespace_name
  await-namespace --name N [--max-secs M]   Join: poll `namespace list` until N provisions; emits namespace_handle + address
  scaffold --sdk S [--dir D] [--manager M]  Set up the app only: clone the cloud-ready sample + install deps; emits repo_path, manager
  create-namespace --sdk S --region R       Blocking wrapper = start + await (non-parallel fallback); emits namespace_handle + address
  create-key --handle H --address A         Mint API key, write [profile.cloud-setup] TOML; emits key_id (token never printed)
  verify-config                             temporal --profile cloud-setup config list
  await-auth                                Wait until the new API key is accepted (poll `workflow list`) before the Worker
  run-workflow --sdk S --dir D [--demo-failure transient] [--max-secs N]
                                            Single synchronous call: start Worker (bg) -> wait until polling -> run starter -> stop Worker; emits workflow_status, workflow_id, run_id
  clone --sdk S [--dir D]                   Clone the cloud-ready sample for the SDK
  install-deps --sdk S --dir D [--manager M]  Install sample deps with a chosen/default package manager
  provision-and-scaffold --sdk S --region R [--dir D] [--manager M]
                                            Single synchronous call: namespace create (bg) || clone+deps; emits namespace_handle, address, repo_path, manager
  repair-config                             Strip duplicate/old [profile.cloud-setup] blocks (keeps default)
  cleanup-info --handle H --key-id K        Print (do not run) the cleanup commands

All commands print a delimited "=== RESULT ===" block on stdout for the agent to parse.
EOF
}

main() {
  local cmd="${1:-}"; shift || true
  # Deterministic disclosure floor: echo the subcommand's gate to stderr before it
  # acts, so the tool block records what ran even if the agent skipped the chat-side
  # gate. `preview` only prints, `cleanup-info` runs nothing, help/empty don't act —
  # none need an echo (and cmd_preview can't render them anyway).
  case "$cmd" in
    preview|cleanup-info|""|-h|--help|help) : ;;
    *) announce_gate "$cmd" "$@" ;;
  esac
  case "$cmd" in
    preflight)        cmd_preflight "$@" ;;
    detect-tools)     cmd_detect_tools "$@" ;;
    preview)          cmd_preview "$@" ;;
    install-deps)     cmd_install_deps "$@" ;;
    install-cli)      cmd_install_cli "$@" ;;
    login)            cmd_login "$@" ;;
    regions)          cmd_regions "$@" ;;
    start-namespace)  cmd_start_namespace "$@" ;;
    await-namespace)  cmd_await_namespace "$@" ;;
    scaffold)         cmd_scaffold "$@" ;;
    create-namespace) cmd_create_namespace "$@" ;;
    create-key)       cmd_create_key "$@" ;;
    verify-config)    cmd_verify_config "$@" ;;
    await-auth)       cmd_await_auth "$@" ;;
    run-workflow)     cmd_run_workflow "$@" ;;
    clone)            cmd_clone "$@" ;;
    provision-and-scaffold) cmd_provision_and_scaffold "$@" ;;
    repair-config)    cmd_repair_config "$@" ;;
    cleanup-info)     cmd_cleanup_info "$@" ;;
    ""|-h|--help|help) usage ;;
    *) usage; die bad-command "unknown command: $cmd" ;;
  esac
}

main "$@"

Requirements and compatibility

Written for: OpenAI Codex, Claude Code

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Claude Code frontmatter, Names Claude Code tools, Needs an API key, Needs a command-line tool, Needs Python, Needs Node.js

Security

What our scanner noticed· Low risk

  • Instructions that use the network
  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Temporal
Publisher
OpenAI (Official)
Distributed by
OpenAI
Source
openai/plugins · plugins/temporal/skills/temporal-cloud-setup · commit d420d13
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from plugins/temporal/LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Temporal or OpenAI.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download temporal-cloud-setup.zip