From 8d5892e3e6b25a4b09d56e3081f421f3841c3574 Mon Sep 17 00:00:00 2001 From: Rebot <96078724+reboot-dev-bot@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:50:16 +0000 Subject: [PATCH] [Release] Synchronize for release --- .claude-plugin/marketplace.json | 1 + BUILD.bazel | 5 + README.md | 17 +- install.sh | 238 ++++++++---- skills/chat-app/SKILL.md | 32 +- .../chat-app/references/api-method-types.md | 8 +- skills/chat-app/references/gotchas.md | 31 +- .../chat-app/references/pop-out-to-web-app.md | 79 ++++ skills/chat-app/references/project-shell.md | 22 +- skills/chat-app/references/react-app-tsx.md | 4 +- .../chat-app/references/react-scaffolding.md | 362 ++++++++++++++---- skills/inspect/SKILL.md | 112 ++++++ skills/inspect/agents/openai.yaml | 4 + .../python/references/lifecycle-dockerfile.md | 40 +- skills/python/references/lifecycle-rbtrc.md | 6 +- .../references/lifecycle-reboot-cloud.md | 44 +-- skills/python/references/lifecycle-secrets.md | 46 ++- skills/python/references/state-collections.md | 14 +- skills/run/SKILL.md | 20 +- skills/upgrade/migrations/next/README.md | 4 +- .../next/drop-react-extensions-for-vite.md | 34 ++ .../migrations/next/frontend-serving-flags.md | 33 ++ .../next/reorganize-frontend-layout.md | 43 +++ skills/web-app/SKILL.md | 2 +- 24 files changed, 924 insertions(+), 277 deletions(-) create mode 100644 BUILD.bazel create mode 100644 skills/chat-app/references/pop-out-to-web-app.md create mode 100644 skills/inspect/SKILL.md create mode 100644 skills/inspect/agents/openai.yaml create mode 100644 skills/upgrade/migrations/next/drop-react-extensions-for-vite.md create mode 100644 skills/upgrade/migrations/next/frontend-serving-flags.md create mode 100644 skills/upgrade/migrations/next/reorganize-frontend-layout.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b7f4ca2..6e54c38 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -19,6 +19,7 @@ "./skills/chat-app", "./skills/web-app", "./skills/run", + "./skills/inspect", "./skills/python", "./skills/upgrade" ] diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 0000000..0d305df --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,5 @@ +filegroup( + name = "install_sh", + srcs = ["install.sh"], + visibility = ["//visibility:public"], +) diff --git a/README.md b/README.md index 22220a2..068a233 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ agent. The same skills work in both **Claude Code** and **Codex**. ## Included Skills -| Skill | Description | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -| [`app`](skills/app/) | Build a Reboot app from a description — routes to `chat-app` or `web-app` | -| [`chat-app`](skills/chat-app/) | Build AI Chat Apps (MCP Apps) for ChatGPT, Claude, VSCode, Goose, and others — layers on top of `python` | -| [`web-app`](skills/web-app/) | Build standalone Reboot Web Apps — a Reboot backend behind a browser-facing React frontend — layers on top of `python` | -| [`run`](skills/run/) | Run an existing Reboot app — detects MCP Chat App vs. Web App and starts every process it needs | -| [`python`](skills/python/) | Reboot Python framework reference: proto- or pydantic-defined APIs, Servicers, contexts, refs, scheduling, and testing | -| [`upgrade`](skills/upgrade/) | Upgrade an existing Reboot app to this plugin's Reboot version — applies migration steps, bumps pins, regenerates | +| Skill | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [`app`](skills/app/) | Build a Reboot app from a description — routes to `chat-app` or `web-app` | +| [`chat-app`](skills/chat-app/) | Build AI Chat Apps (MCP Apps) for ChatGPT, Claude, VSCode, Goose, and others — layers on top of `python` | +| [`web-app`](skills/web-app/) | Build standalone Reboot Web Apps — a Reboot backend behind a browser-facing React frontend — layers on top of `python` | +| [`run`](skills/run/) | Run an existing Reboot app — detects MCP Chat App vs. Web App and starts every process it needs | +| [`inspect`](skills/inspect/) | Inspect a running Reboot app's persisted state from the CLI with `rbt inspect` — list state types, list state IDs, dump one actor as JSON | +| [`python`](skills/python/) | Reboot Python framework reference: proto- or pydantic-defined APIs, Servicers, contexts, refs, scheduling, and testing | +| [`upgrade`](skills/upgrade/) | Upgrade an existing Reboot app to this plugin's Reboot version — applies migration steps, bumps pins, regenerates | The skill files are tool-agnostic. Claude Code invokes skills by name (e.g. `/chat-app`); Codex selects a skill automatically from its diff --git a/install.sh b/install.sh index ca610ff..c132cd3 100755 --- a/install.sh +++ b/install.sh @@ -175,66 +175,152 @@ codex_plugin_path() { }' } -# Merge a marked region into ~/.codex/config.toml that: +# Merge the plugin's settings into ~/.codex/config.toml: # - enables hooks (off by default in Codex), # - prepends the plugin's bin/ to PATH for every subprocess, and # - (optionally, controlled by $3) disables Codex's # seccomp+landlock sandbox to work around -# https://github.com/openai/codex/issues/24933. See the inline -# comment in this function for details. The caller must have -# gathered the user's consent before passing "true" here, since -# the sandbox is a global Codex setting. +# https://github.com/openai/codex/issues/24933. Inside the +# sandbox, Python `asyncio.call_soon_threadsafe` -- and +# everything built on it (`run_in_executor`, `aiofiles`, even +# `asyncio.run`'s own shutdown) -- silently stalls because +# Codex's seccomp filter denies SYS_sendto on the AF_UNIX +# socketpair asyncio uses for cross-thread loop wakeups. That +# breaks `rbt dev run` and `rbt generate` (which are heavily +# asyncio-based). Until the Codex bug is fixed, the only way to +# make the plugin usable under Codex is to opt out of the +# sandbox entirely. The caller must have gathered the user's +# consent before passing "true" here, since the sandbox is a +# global Codex setting. # # Codex's `shell_environment_policy.set` replaces PATH wholesale, so the # install-time PATH is baked in after the plugin's bin/ — re-running the -# installer refreshes it. The region holds only top-level dotted keys, -# so it can be prepended ahead of any tables in the user's config, and -# re-running replaces it in place. +# installer refreshes it. +# +# The merge runs as inline Python using `tomlkit` (a style-preserving +# TOML editor) through the plugin's bundled `uv` shim. +# +# Every key the installer sets carries a +# `# reboot-plugin (managed)` comment. As a backstop, the merged +# config is parsed by Codex itself before being kept; if that parse +# fails the original config is restored and the install fails with +# manual instructions. merge_codex_config() { local config="$1" local root="$2" local disable_sandbox="${3:-false}" - local begin="# >>> reboot-plugin (managed) >>>" - local end="# <<< reboot-plugin (managed) <<<" mkdir -p "$(dirname "$config")" touch "$config" - # Drop any existing managed region. - local stripped - stripped="$(awk -v b="$begin" -v e="$end" ' - $0==b {skip=1; next} - skip && $0==e {skip=0; next} - !skip {print} - ' "$config")" - - { - printf '%s\n' "$begin" - printf 'features.hooks = true\n' - printf 'shell_environment_policy.set.PATH = "%s/bin:%s"\n' \ - "$root" "$PATH" - if [ "$disable_sandbox" = "true" ]; then - # Disable Codex's seccomp+landlock sandbox. Inside the - # sandbox, Python `asyncio.call_soon_threadsafe` -- and - # everything built on it (`run_in_executor`, `aiofiles`, - # even `asyncio.run`'s own shutdown) -- silently stalls - # because Codex's seccomp filter denies SYS_sendto on the - # AF_UNIX socketpair asyncio uses for cross-thread loop - # wakeups. That breaks `rbt dev run` and `rbt generate` - # (which are heavily asyncio-based). Until the Codex bug - # is fixed, the only way to make the plugin usable under - # Codex is to opt out of the sandbox entirely. See - # https://github.com/openai/codex/issues/24933. - printf '# Disabled because Codex sandbox breaks Python asyncio cross-thread\n' - printf '# wakeup; see https://github.com/openai/codex/issues/24933.\n' - printf 'sandbox_mode = "danger-full-access"\n' - fi - printf '%s\n' "$end" - if [ -n "$stripped" ]; then - printf '\n%s\n' "$stripped" - fi - } >"$config.reboot.tmp" - mv "$config.reboot.tmp" "$config" + local merge_py + merge_py="$(cat <<'PYEOF' +import os +import shutil +import sys + +import tomlkit + +config_path, path_value, disable_sandbox = sys.argv[1:4] + +# Drop the marked region that earlier installer versions prepended; +# its settings are re-added below. +BEGIN = "# >>> reboot-plugin (managed) >>>" +END = "# <<< reboot-plugin (managed) <<<" +kept = [] +skip = False +with open(config_path) as config: + for line in config: + stripped = line.rstrip("\n") + if stripped == BEGIN: + skip = True + elif skip and stripped == END: + skip = False + elif not skip: + kept.append(line) + +document = tomlkit.parse("".join(kept)) + + +def set_key(table, key, value, note="reboot-plugin (managed)"): + item = tomlkit.item(value) + item.comment(note) + table[key] = item + + +if "features" not in document: + document["features"] = tomlkit.table() +set_key(document["features"], "hooks", True) + +if "shell_environment_policy" not in document: + document["shell_environment_policy"] = tomlkit.table() +policy = document["shell_environment_policy"] +if "set" not in policy: + policy["set"] = tomlkit.table() +set_key(policy["set"], "PATH", path_value) + +if disable_sandbox == "true": + set_key( + document, + "sandbox_mode", + "danger-full-access", + "reboot-plugin (managed); see " + "https://github.com/openai/codex/issues/24933", + ) + +temporary = config_path + ".reboot.tmp" +with open(temporary, "w") as config: + config.write(tomlkit.dumps(document)) +shutil.copymode(config_path, temporary) +os.replace(temporary, config_path) +PYEOF +)" + + # `codex plugin list` doubles as a parser of config.toml. + # Remember whether it worked before the merge, so that a + # pre-existing failure is not blamed on the merged config below. + local validator_ok=false + codex plugin list >/dev/null 2>&1 && validator_ok=true + + local backup="$config.reboot.bak" + cp "$config" "$backup" + + # The Python above writes the config atomically at its very end, + # so a failure anywhere in it leaves the original config behind. + if ! "$root/bin/uv" run --quiet --no-project \ + --with "tomlkit==0.15.0" python -c "$merge_py" \ + "$config" "$root/bin:$PATH" "$disable_sandbox"; then + rm -f "$backup" "$config.reboot.tmp" + merge_codex_config_manual_help "$config" "$root" "$disable_sandbox" + return 1 + fi + + # Backstop: have Codex parse the merged config, so that even a + # merge tomlkit and Codex's own TOML parser disagree about can + # never leave Codex unable to start. + if [ "$validator_ok" = "true" ] \ + && ! codex plugin list >/dev/null 2>&1; then + mv "$backup" "$config" + merge_codex_config_manual_help "$config" "$root" "$disable_sandbox" + return 1 + fi + rm -f "$backup" +} + +# Manual-fallback instructions for merge_codex_config's failure +# paths; the original config is intact when this is printed. +merge_codex_config_manual_help() { + local config="$1" + local root="$2" + local disable_sandbox="$3" + error "Could not update $config with the plugin's settings;" + error " your original config is unchanged. Please add the" + error " following settings to it manually:" + error " features.hooks = true" + error " shell_environment_policy.set.PATH = \"$root/bin:\"" + if [ "$disable_sandbox" = "true" ]; then + error " sandbox_mode = \"danger-full-access\"" + fi } install_codex() { @@ -274,8 +360,8 @@ install_codex() { printf >&2 " Doing so writes ${BOLD}sandbox_mode = \"danger-full-access\"${RESET} into\n" printf >&2 " ~/.codex/config.toml. That setting is ${BOLD}global${RESET}: it affects every\n" printf >&2 " Codex session on this machine, not just sessions that touch the\n" - printf >&2 " Reboot plugin. You can undo it later by removing the managed\n" - printf >&2 " region from ~/.codex/config.toml.\n" + printf >&2 " Reboot plugin. You can undo it later by removing the sandbox_mode\n" + printf >&2 " line (tagged '# reboot-plugin') from ~/.codex/config.toml.\n" printf >&2 "\n" printf >&2 " If you decline, the Codex install will be skipped entirely (a Codex\n" printf >&2 " install without the sandbox opt-out cannot run the plugin's main\n" @@ -311,8 +397,8 @@ install_codex() { # are auto-configured by Codex from the bundled # `.codex-plugin/plugin.json` and `.agents/plugins/marketplace.json`. # Codex prints its own errors to stderr; the explicit guards make - # sure we don't write a half-broken managed region into config.toml - # when either step fails. + # sure we don't write the plugin's settings into config.toml when + # either step fails. codex plugin marketplace add "$source" \ || { error "codex plugin marketplace add failed."; return 1; } codex plugin add "$PLUGIN_NAME@$MARKETPLACE_NAME" \ @@ -335,11 +421,13 @@ install_codex() { # Enable hooks, put the plugin's bin/ on PATH, and disable the # Codex sandbox (user already consented above). merge_codex_config \ - "${CODEX_HOME:-$HOME/.codex}/config.toml" "$root" "true" + "${CODEX_HOME:-$HOME/.codex}/config.toml" "$root" "true" \ + || return 1 log "Set sandbox_mode = \"danger-full-access\" in ~/.codex/config.toml" log " to work around https://github.com/openai/codex/issues/24933." - log " Remove the managed region from ~/.codex/config.toml to undo." + log " Remove the sandbox_mode line (tagged '# reboot-plugin') from" + log " ~/.codex/config.toml to undo." log "Pre-installing dependencies for Codex..." prewarm "$root/bin" @@ -349,26 +437,34 @@ install_codex() { # --- Main ---------------------------------------------------------------- -have_claude=false -have_codex=false -command -v claude >/dev/null 2>&1 && have_claude=true -command -v codex >/dev/null 2>&1 && have_codex=true - -if ! $have_claude && ! $have_codex; then - error "Neither Claude Code nor Codex was found." - printf >&2 "Install one of them and try again:\n" - printf >&2 " Claude Code: https://docs.anthropic.com/en/docs/claude-code/setup\n" - printf >&2 " Codex: https://developers.openai.com/codex/cli\n" - exit 1 -fi +main() { + local have_claude=false + local have_codex=false + command -v claude >/dev/null 2>&1 && have_claude=true + command -v codex >/dev/null 2>&1 && have_codex=true + + if ! $have_claude && ! $have_codex; then + error "Neither Claude Code nor Codex was found." + printf >&2 "Install one of them and try again:\n" + printf >&2 " Claude Code: https://docs.anthropic.com/en/docs/claude-code/setup\n" + printf >&2 " Codex: https://developers.openai.com/codex/cli\n" + exit 1 + fi -$have_claude && install_claude -$have_codex && install_codex + $have_claude && install_claude + $have_codex && install_codex + + if [ "${#INSTALLED_FOR[@]}" -eq 0 ]; then + ok "Installed for: (none — Codex install skipped)." + else + ok "Installed for: $(IFS=', '; echo "${INSTALLED_FOR[*]}")." + fi + printf >&2 "\nStart a new agent session, then ask to build a Reboot app" + printf >&2 " — e.g. ${BOLD}build a todo chat app${RESET}.\n" +} -if [ "${#INSTALLED_FOR[@]}" -eq 0 ]; then - ok "Installed for: (none — Codex install skipped)." -else - ok "Installed for: $(IFS=', '; echo "${INSTALLED_FOR[*]}")." +# Run the install when this file is executed, directly or via +# `curl | bash`; a `source` of the file only defines its functions. +if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then + main "$@" fi -printf >&2 "\nStart a new agent session, then ask to build a Reboot app" -printf >&2 " — e.g. ${BOLD}build a todo chat app${RESET}.\n" diff --git a/skills/chat-app/SKILL.md b/skills/chat-app/SKILL.md index 40ddaaf..13df919 100644 --- a/skills/chat-app/SKILL.md +++ b/skills/chat-app/SKILL.md @@ -136,8 +136,9 @@ they cover aren't restated inline below. | [`references/api-method-types.md`](references/api-method-types.md) | The pydantic API file. `User`-type front door, `mcp=Tool()` / `mcp=None`, `UI()` (including parameterized UI props), `factory=True` on `create`, `Workflow(...)` declaration shape. Full Counter API example. | | [`references/api-state-shapes.md`](references/api-state-shapes.md) | Two recurring state shapes: `list[Item]` with `default_factory=list`; single nested `Model` sub-objects as `Optional[X] = Field(tag=N, default=None)` hydrated in factory `create` (Gotcha #13). The state-inside-state regression and how to compose state actors via string ID + `ref(id)`. | | [`references/servicer-patterns.md`](references/servicer-patterns.md) | Servicer-side patterns: `UserServicer` calling `.create(context)`, Workflow Servicer with `MyType.ref()` (no-arg) magic, inline writers via `.per_workflow("alias").write(context, fn)` / `.per_iteration("alias").write(...)` / `.always().write(...)`, scheduling a workflow from a Transaction with `.schedule()`. | -| [`references/react-scaffolding.md`](references/react-scaffolding.md) | The `web/` shell: `package.json` (per-UI `build:` scripts, no auto-discovery wrappers), `vite.config.ts` (load-bearing — copy exactly), `tsconfig.json` / `tsconfig.app.json` / `tsconfig.node.json`, `index.css` theme variables, `ui//index.html`, `ui//main.tsx`. | +| [`references/react-scaffolding.md`](references/react-scaffolding.md) | The `frontend/` shell: `package.json` (`npm run build` runs `build.mjs`, which auto-discovers and builds every UI), `vite.config.ts` (load-bearing — copy exactly), `build.mjs`, `tsconfig.json` / `tsconfig.app.json` / `tsconfig.node.json`, `index.css` theme variables, `mcp//index.html`, `mcp//main.tsx`. | | [`references/react-app-tsx.md`](references/react-app-tsx.md) | `App.tsx` — generated `use()` hook usage (reader subscriptions + mutation calls), Python-snake → TypeScript-camel field naming, Zod-validated request/response types. Full Counter `App.tsx` + `App.module.css` example. | +| [`references/pop-out-to-web-app.md`](references/pop-out-to-web-app.md) | Adding a "pop out into the web app" button to a UI widget: recover the bound entity ID with the generated hook's `state_id`, build a deep-link URL, and open it via the host's `useMcpApp().openLink({ url })` (the sandboxed iframe blocks `window.open`) with a `window.open` fallback. The web-app side reads the ID from the URL; shared `rbt_session` keeps the user signed in across surfaces. | | [`references/gotchas.md`](references/gotchas.md) | The numbered MCP-Chat-App–specific trip list (1–19): `mcp=Tool()`/`mcp=None` required, `factory=True` on app-type `create`, `MyType.ref()` not `cls.ref()`/`self.ref()` in workflows, `.schedule()` from a Transaction, Optional+`default=None` for nested Models, `.read()` only on no-arg ref inside a workflow, method-name PascalCase → generated `.Request`, etc. | | [`references/auth-oauth-providers.md`](references/auth-oauth-providers.md) | Choosing your `Application(oauth=...)` provider via `OAuthProviderByEnvironment(dev=..., prod=...)` (the recommended `dev=Development(), prod=Google(...)` shape; a selected `None` arm fails startup), the `Google` / `GitHub` / `Auth0` / `Development` / `Anonymous` providers and where credentials come from (incl. the `/__/oauth/callback` URL), and the user-ID-namespace gotcha that makes switching providers after launch infeasible — choose deliberately **before** real users have state. | | [`references/auth-custom-oauth-provider.md`](references/auth-custom-oauth-provider.md) | **Writing your own OAuth provider** when none of the shipped ones fits (self-hosted Keycloak, internal SSO, an IdP Auth0 can't broker): subclass `RegisteredOAuthProvider`, implement `authorization_url` + `exchange_code` → `ExchangeResult` (the user id must be **stable** — it becomes `context.auth.user_id`), the `validate()` / `mount_routes()` hooks, and `token_service_id` + `OAuthTokens` for `store_tokens=True` support. | @@ -559,19 +560,26 @@ a worked set are in │ ├── example_prompts.py # Wizard example prompts │ └── servicers/ │ └── .py # Servicer implementation -└── web/ +└── frontend/ ├── package.json + ├── build.mjs # Discovers + builds every UI ├── tsconfig.json ├── tsconfig.app.json ├── tsconfig.node.json ├── vite.config.ts - ├── index.css # Theme variables - └── ui/ - └── / - ├── index.html - ├── main.tsx # RebootClientProvider entry - ├── App.tsx # React component - └── App.module.css + ├── api/ # Generated React bindings (rbt generate) + ├── mcp/ + │ └── / + │ ├── index.html + │ ├── index.css # Theme variables + │ ├── main.tsx # RebootClientProvider entry + │ ├── App.tsx # React component + │ └── App.module.css + └── web/ # Optional standalone browser SPA + ├── index.html + └── src/ + ├── main.tsx + └── App.tsx ``` ## Step-by-Step Build Flow @@ -600,14 +608,14 @@ application directory.** [`references/project-shell.md`](references/project-shell.md) and `python/references/lifecycle-application-entry.md`. 7. `npm create @reboot-dev/ui`. -8. `cd web && npm install`. +8. `cd frontend && npm install`. 9. `uv run rbt generate` (React bindings need `node_modules`). 10. Customize React UIs — see [`references/react-scaffolding.md`](references/react-scaffolding.md) - for the `web/` shell and + for the `frontend/` shell and [`references/react-app-tsx.md`](references/react-app-tsx.md) for `App.tsx` patterns. -11. `cd web && npm run build`. +11. `cd frontend && npm run build`. 12. **Write and run backend unit tests covering each user-facing user story before handing the app off.** Enumerate the user stories from the plan — every action the user should be able diff --git a/skills/chat-app/references/api-method-types.md b/skills/chat-app/references/api-method-types.md index d75053e..f48c019 100644 --- a/skills/chat-app/references/api-method-types.md +++ b/skills/chat-app/references/api-method-types.md @@ -171,7 +171,7 @@ api = API( # section above for the full rule. show_clicker=UI( request=None, - path="web/ui/clicker", + path="frontend/mcp/clicker", title="Counter Clicker", description="Interactive clicker UI for the counter.", ), @@ -227,7 +227,7 @@ class DashboardConfig(Model): # still the tool-call target, implicit. show_dashboard=UI( request=DashboardConfig, - path="web/ui/dashboard", + path="frontend/mcp/dashboard", title="Counter Dashboard", description="Dashboard UI. Use `personalized_message` to impart " "wisdom on the topic of counting things.", @@ -271,7 +271,7 @@ User=Type( methods=Methods( show_person=UI( request=ShowPersonProps, - path="web/ui/person", + path="frontend/mcp/person", ... ), ), @@ -289,7 +289,7 @@ Person=Type( methods=Methods( show=UI( request=None, - path="web/ui/person", + path="frontend/mcp/person", title="Person", description="Open the visual UI for this Person.", ), diff --git a/skills/chat-app/references/gotchas.md b/skills/chat-app/references/gotchas.md index f8f04ee..288de10 100644 --- a/skills/chat-app/references/gotchas.md +++ b/skills/chat-app/references/gotchas.md @@ -38,7 +38,7 @@ The list below is what's specific to the MCP-Chat-App layer: 7. **Use `--default-config=hmr`** in `.rbtrc` (not `--default=hmr`). -8. **`UI(path="web/ui/")`** — path is relative to project +8. **`UI(path="frontend/mcp/")`** — path is relative to project root. 9. **`UI(request=)`** passes config as React component @@ -155,15 +155,15 @@ The list below is what's specific to the MCP-Chat-App layer: Always use `async def fn(state): ...`. (See `python/references/servicer-workflow.md`.) -19. **`web/dist/ui//index.html` is the right location** for +19. **`frontend/dist/mcp//index.html` is the right location** for the built MCP UI — that's where the MCP server's `_resolve_dist_path` looks. The `vite.config.ts` shipped with - this skill produces it correctly out of the box (Vite emits HTML - at its source-relative path; the `entryFileNames`/ - `assetFileNames` overrides only affect the JS/CSS bundle names, - which `viteSingleFile` then inlines). If you see - `Web artifact 'web/dist/ui//index.html' is missing`, run - `cd web && npm run build` — do **not** rewrite the Vite config + this skill produces it correctly out of the box: the + `RBT_BUILD_TARGET=mcp:` build roots itself at `mcp//`, + so its `index.html` lands at the nested path while + `viteSingleFile` inlines the JS/CSS. If you see + `Web artifact 'frontend/dist/mcp//index.html' is missing`, run + `cd frontend && npm run build` — do **not** rewrite the Vite config to emit a flat `dist/.html`; that breaks discovery. 20. **LLM / model API calls go in a `Workflow`, never a @@ -183,3 +183,18 @@ The list below is what's specific to the MCP-Chat-App layer: Effects: Transaction or Workflow?), `python/references/servicer-workflow.md`, and `python/references/agent-pydantic-ai.md`. + +21. **Don't add `generate --react-extensions` to `.rbtrc`.** This is + a Python-backend app that generates the React client on its own + into `web/api/`. Vite — plus `tsc` under the + `moduleResolution: "bundler"` the scaffolded tsconfigs use — + resolves the generated client's relative imports without explicit + `.js` extensions, so the flag buys nothing here. The two cases + where a project _does_ need it both require something this skill + never produces: a webpack/`ts-loader` bundler (this skill uses + Vite), or a `--nodejs`/`--web` target sharing the React output + directory and forcing matching extensions (a Python backend has + no Node.js client). Adding the flag anyway also breaks a future + `--mobile` (React Native) client — `rbt generate` rejects + `--react-extensions` with `--mobile`, because Metro can't resolve + the `.js`-suffixed imports back to their `.ts` sources. diff --git a/skills/chat-app/references/pop-out-to-web-app.md b/skills/chat-app/references/pop-out-to-web-app.md new file mode 100644 index 0000000..cdd13b1 --- /dev/null +++ b/skills/chat-app/references/pop-out-to-web-app.md @@ -0,0 +1,79 @@ +--- +title: Pop-out button — deep-linking an MCP UI widget to your web app +impact: MEDIUM +impactDescription: An MCP UI widget runs in a sandboxed iframe that blocks `window.open`, so a naive "open in browser" button silently does nothing in most hosts. Use the bound entity's `state_id` to build a deep link and ask the host to open it via the MCP Apps `openLink` request, with a `window.open` fallback. +tags: react, app-tsx, pop-out, deep-link, openLink, state_id, useMcpApp, dual-surface, web-app +--- + +## Pop-out button — deep-linking an MCP UI widget to your web app + +When the same backend also serves a standalone web app (see the +`web-app` skill), an MCP UI widget can offer a "pop out into the +web app" button that opens the **same entity** in a full browser +tab. Two facts make this work: + +1. The generated `use()` hook exposes the resolved entity ID + as `state_id`, so a widget that auto-resolved its entity from + the MCP session can still recover the concrete ID. +2. The host opens external URLs on the widget's behalf via the MCP + Apps `openLink` request — necessary because the widget's + sandboxed iframe blocks `window.open` (the host doesn't grant + `allow-popups`), so a direct `window.open` silently no-ops. + +### Recipe + +```tsx +import { useMcpApp } from "@reboot-dev/reboot-react"; +import { useCounter } from "@api//v1/_rbt_react"; + +export const ClickerApp = () => { + const counter = useCounter(); + + // The resolved entity ID — even though `useCounter()` inferred + // it from the MCP session rather than an explicit `{ id }`. + const counterId = counter.state_id; + + // The MCP host handle; `null` when not running under a host. + const mcpApp = useMcpApp(); + + const handlePopOut = async () => { + // Pass the entity ID to the web app as a query param it reads + // on load to render that one entity. + const url = + "https://your-web-app.example/?counter=" + encodeURIComponent(counterId); + + // Ask the host to open the link; `window.open` is blocked in + // the sandboxed iframe. Fall back to `window.open` when not + // under an MCP host (or the host declines the request). + if (mcpApp?.openLink) { + try { + const { isError } = await mcpApp.openLink({ url }); + if (!isError) { + return; + } + } catch { + // Fall through to `window.open`. + } + } + window.open(url, "_blank", "noopener"); + }; + + return ; +}; +``` + +### The web-app side + +The web app reads the query param on load and shows just that +entity — e.g. `?counter=` selects a single-counter view via +`useCounter({ id })`. + +### Notes + +- `useMcpApp()` is re-exported from `@reboot-dev/reboot-react`; its + value is the `@modelcontextprotocol/ext-apps` `App`, whose + `openLink({ url })` resolves to `{ isError }` (the host denies + blocked domains or a cancelled request). +- Hardcoding the web app's origin is fine for local development; + for deployed apps, source it from config rather than assuming a + port. diff --git a/skills/chat-app/references/project-shell.md b/skills/chat-app/references/project-shell.md index 8e6553e..4676ae4 100644 --- a/skills/chat-app/references/project-shell.md +++ b/skills/chat-app/references/project-shell.md @@ -25,7 +25,7 @@ adds on top: Same shape as `python/references/lifecycle-rbtrc.md`. Chat apps add `--default-config=hmr` plus `:hmr` and `:dist` named configs that route Envoy to a Vite dev server (HMR) or a built -`web/dist/` (production): +`frontend/dist/` (production): ``` # Find API definitions in 'api/'. @@ -34,9 +34,8 @@ generate api/ # Tell `rbt generate` where to put generated files. generate --python=backend/api/ -# Generate React bindings for web apps (into "web/api/"). -generate --react=web/api -generate --react-extensions +# Generate React bindings for web apps (into "frontend/api/"). +generate --react=frontend/api # Watch if any source files are modified. dev run --watch=backend/**/*.py @@ -59,14 +58,16 @@ dev run --application=backend/src/main.py dev run --default-config=hmr # HMR: Vite dev server proxied through Envoy. -# Run Vite in a separate terminal: cd web && npm run dev -# Envoy routes "/__/web/**" to Vite for HMR support. -dev run:hmr --mcp-frontend-host=http://localhost:4444 +# Run Vite in a separate terminal: cd frontend && npm run dev +# Envoy routes "/__/frontend/**" to Vite for HMR support. +dev run --frontend-root-path=frontend -# Dist mode: serve pre-built artifacts from "web/dist/" (no Vite HMR). +dev run:hmr --frontend-host=http://localhost:4444 + +# Dist mode: serve pre-built artifacts from "frontend/dist/" (no Vite HMR). # Usage: uv run rbt dev run --config=dist -# Requires: cd web && npm run build -dev run:dist --mcp-frontend-host="" +# Requires: cd frontend && npm run build +dev run:dist --frontend-dist-path=frontend/dist # When expunging, expunge that state we've saved. dev expunge --application-name= @@ -77,6 +78,7 @@ dev expunge --application-name= # block above, minus the dev-only knobs (`--watch`, `--env-file`, # the `:hmr`/`:dist` configs). serve run --python +serve run --frontend-dist-path=frontend/dist --frontend-root-path=frontend serve run --application=backend/src/main.py serve run --application-name= serve run --tls=external diff --git a/skills/chat-app/references/react-app-tsx.md b/skills/chat-app/references/react-app-tsx.md index f7f41a0..1194a2f 100644 --- a/skills/chat-app/references/react-app-tsx.md +++ b/skills/chat-app/references/react-app-tsx.md @@ -7,7 +7,7 @@ tags: react, app-tsx, hooks, useType, css-module, snake-camel, app-tsx-example, ## React App.tsx — Generated Hooks and Component Patterns -`web/ui//App.tsx` is the React component for one UI. The +`frontend/mcp//App.tsx` is the React component for one UI. The generated `use()` hook returns reader subscriptions and mutation functions: @@ -195,7 +195,7 @@ export const ClickerApp: FC = () => { }; ``` -## `web/ui//App.module.css` +## `frontend/mcp//App.module.css` ```css .container { diff --git a/skills/chat-app/references/react-scaffolding.md b/skills/chat-app/references/react-scaffolding.md index be37ee9..1f4b09d 100644 --- a/skills/chat-app/references/react-scaffolding.md +++ b/skills/chat-app/references/react-scaffolding.md @@ -1,20 +1,20 @@ --- title: React Scaffolding — package.json, vite.config.ts, tsconfigs, index.css impact: CRITICAL -impactDescription: The shell files for the `web/` tree. `vite.config.ts` is load-bearing — flattening the HTML output breaks MCP UI artifact discovery (the server resolves `web/dist//index.html`, the **nested** Vite default). Per-UI build scripts (no auto-discovery wrappers); tsconfigs split into `app` and `node` halves. -tags: web, react, vite, tsconfig, package-json, css, build, hmr, scaffolding, ui-name +impactDescription: The shell files for the `frontend/` tree. `vite.config.ts` is load-bearing — flattening the HTML output breaks MCP UI artifact discovery (the server resolves `frontend/dist/mcp//index.html`, the **nested** Vite default). `npm run build` runs `build.mjs`, which auto-discovers and builds every UI; tsconfigs split into `app` and `node` halves. +tags: web, react, vite, tsconfig, package-json, css, build, hmr, scaffolding, build-mjs --- ## React Scaffolding — package.json, vite.config.ts, tsconfigs, index.css -Everything below is MCP-Chat-App-specific. The `web/` tree is owned -by this skill — `python` knows nothing about it. +Everything below is MCP-Chat-App-specific. The `frontend/` tree is +owned by this skill — `python` knows nothing about it. -## `web/package.json` +## `frontend/package.json` -**Use explicit per-UI build scripts as shown below. Do NOT create a -`build.js` or any auto-discovery wrapper — use `npm run build:` -scripts directly.** +**`npm run build` type-checks and then runs `build.mjs`, which +auto-discovers and builds every UI. Don't hand-maintain per-UI +`build:` scripts.** ```json { @@ -24,10 +24,7 @@ scripts directly.** "type": "module", "scripts": { "dev": "vite", - "build:": "vite build --mode ", - "build:watch:": "vite build --mode --watch", - "build": "tsc --noEmit && npm run build:", - "build:watch": "concurrently \"npm:build:watch:*\"" + "build": "tsc -b && node build.mjs" }, "dependencies": { "@modelcontextprotocol/ext-apps": "1.5.0", @@ -39,10 +36,10 @@ scripts directly.** "zod": "^4.0.0" }, "devDependencies": { + "@types/node": "^20.11.5", "@types/react": "^18.2.67", "@types/react-dom": "^18.2.22", "@vitejs/plugin-react": "^4.7.0", - "concurrently": "^9.1.2", "typescript": "^5.9.2", "vite": "^6.3.5", "vite-plugin-singlefile": "^2.0.3" @@ -50,108 +47,251 @@ scripts directly.** } ``` -For multiple UIs, add `build:` and `build:watch:` entries -for each UI, and update the `build` script to chain them: - -``` -"build": "tsc --noEmit && npm run build:ui1 && npm run build:ui2" -``` - -## `web/vite.config.ts` - -**CRITICAL: Copy this file EXACTLY.** Don't try to "flatten" the HTML -output. The MCP server resolves the UI artifact at -`web/dist//index.html` (where `` matches the -`path=` you set in `UI(...)`) — i.e. the **nested** Vite default. -The `entryFileNames` / `assetFileNames` overrides in this config -flatten the JS/CSS bundle names (which `viteSingleFile` then inlines -into the HTML), but the HTML itself stays at its source-relative -path. If you see `Web artifact 'web/dist/ui//index.html' is missing`, -the fix is `cd web && npm run build`, **not** rewriting this file -to emit `dist/.html`. +Adding or removing a UI needs no script changes: `build.mjs` +discovers every `mcp//index.html` (and the `web/` SPA) and +builds each one. + +## `frontend/vite.config.ts` + +**CRITICAL: Copy this file EXACTLY.** One config drives three jobs: +the `serve` dev server (HMR for every `mcp/` UI and the `web/` +SPA under `/__/frontend/`), and two builds selected by the +`RBT_BUILD_TARGET` env var (set per UI by `build.mjs`) — +`RBT_BUILD_TARGET=mcp:` (build one MCP UI) and +`RBT_BUILD_TARGET=web` (build the SPA). Each `mcp:` build roots +itself at `mcp//` so +its `index.html` lands at the **nested** `dist/mcp//index.html` — +the path the MCP server resolves — with the JS/CSS inlined by +`viteSingleFile`. If you see +`Web artifact 'frontend/dist/mcp//index.html' is missing`, the +fix is `cd frontend && npm run build`, **not** rewriting this file. ```typescript // Vite configuration for Reboot UIs. +// +// One config drives three jobs. The dev server is selected by +// `command === "serve"`; the two build shapes are selected by the +// `RBT_BUILD_TARGET` env var, set per UI by `build.mjs`. `mode` keeps +// its conventional Vite values — `development` (serve) / `production` +// (build) — so the matching `.env.` files load as usual: +// +// * `vite` (serve): a single dev server that delivers HMR for every +// `mcp/` UI AND the standalone `web/` SPA, all under +// `base: "/__/frontend/"`. Envoy proxies that prefix to this dev +// server (`run --config=hmr`). +// * `RBT_BUILD_TARGET=mcp: vite build`: builds one MCP UI into +// a single, self-contained `dist/mcp//index.html` (assets +// inlined via `vite-plugin-singlefile`). The framework serves it +// at `/__/frontend/mcp//index.html` in dist mode. +// * `RBT_BUILD_TARGET=web vite build`: builds the `web/` SPA into +// `dist/web/` with normal (non-inlined) assets. Its `base` is +// `/__/frontend/web/` so asset URLs resolve when served at that +// prefix. In Vite's `production` mode, it reads +// `web/.env.production`. import fs from "fs"; import path from "path"; import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import { viteSingleFile } from "vite-plugin-singlefile"; -// Auto-discover UIs from ui/ directory. -const uiDir = path.resolve(__dirname, "ui"); -const uis: Record = - Object.fromEntries( - fs - .readdirSync(uiDir) - .filter((d) => fs.existsSync(path.join(uiDir, d, "index.html"))) - .map((name) => [ - name, - { input: `ui/${name}/index.html`, output: `${name}.html` }, - ]) - ); - -export default defineConfig(({ command, mode }) => { - // Path alias for API imports (@api/... -> ./api/...). - const resolve = { - alias: { - "@api": path.resolve(__dirname, "./api"), +// A served directory under `/__/frontend/` requested without its +// trailing slash (e.g. `/__/frontend/web`) doesn't match Vite's static +// index.html serving, which expects `/__/frontend/web/`, so it 404s. +// Redirect the slash-less form to the canonical trailing-slash form so +// the `web/` SPA and each `mcp/` UI load with or without the +// trailing slash — matching how the framework's dist-mode server +// behaves. Only a path that resolves to a real directory with an +// `index.html` is redirected, so Vite's own internal module URLs +// (`@vite/client`, `@react-refresh`) and source or asset files fall +// through untouched. +function redirectFrontendDirTrailingSlash(root: string): Plugin { + const prefix = "/__/frontend/"; + return { + name: "reboot-frontend-dir-trailing-slash", + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = req.url ?? ""; + const queryAt = url.indexOf("?"); + const pathname = queryAt === -1 ? url : url.slice(0, queryAt); + if (pathname.startsWith(prefix) && !pathname.endsWith("/")) { + const subpath = pathname.slice(prefix.length); + if (fs.existsSync(path.join(root, subpath, "index.html"))) { + const query = queryAt === -1 ? "" : url.slice(queryAt); + // 302 (not 301): a permanent redirect would be cached by the + // browser, which is wrong for a dev server whose routes can + // change between runs. + res.statusCode = 302; + res.setHeader("Location", `${pathname}/${query}`); + res.end(); + return; + } + } + next(); + }); }, - dedupe: ["react", "react-dom", "zod"], }; +} - // Dev server configuration. +// When a `web/` SPA exists, treat it as the dev server's home: the bare +// `/`, `/__/frontend`, and `/__/frontend/` otherwise 404 (there's no +// index there), so silently redirect them to `/__/frontend/web/`. Also +// print the friendlier `http:///` at startup instead of the +// `/__/frontend/` base URL Vite would otherwise show (which is a dead +// link). +function serveWebAppAtRoot(root: string): Plugin { + const webIndex = path.resolve(root, "web", "index.html"); + const target = "/__/frontend/web/"; + const homes = new Set(["/", "/__/frontend", "/__/frontend/"]); + return { + name: "reboot-serve-web-app-at-root", + configureServer(server) { + if (!fs.existsSync(webIndex)) return; + server.middlewares.use((req, res, next) => { + const url = req.url ?? ""; + const queryAt = url.indexOf("?"); + const pathname = queryAt === -1 ? url : url.slice(0, queryAt); + if (homes.has(pathname)) { + const query = queryAt === -1 ? "" : url.slice(queryAt); + res.statusCode = 302; + res.setHeader("Location", target + query); + res.end(); + return; + } + next(); + }); + // Print `http:///` instead of the `/__/frontend/` base. + const printUrls = server.printUrls.bind(server); + server.printUrls = () => { + const urls = server.resolvedUrls; + if (urls) { + const toRoot = (u: string) => u.replace(/\/__\/frontend\/$/, "/"); + urls.local = urls.local.map(toRoot); + urls.network = urls.network.map(toRoot); + } + printUrls(); + }; + }, + }; +} + +// Auto-discover MCP UIs: every `mcp//` with an `index.html`. +// There may be no `mcp/` directory at all (a web-only frontend, or +// one whose last MCP UI was removed), so guard the read. +const mcpDir = path.resolve(__dirname, "mcp"); +const mcpNames: string[] = fs.existsSync(mcpDir) + ? fs + .readdirSync(mcpDir) + .filter((name) => fs.existsSync(path.resolve(mcpDir, name, "index.html"))) + : []; + +// Path alias for API imports (`@api/...` -> `./api/...`). +const resolve = { + alias: { + "@api": path.resolve(__dirname, "./api"), + }, + dedupe: ["react", "react-dom", "zod"], +}; + +export default defineConfig(({ command }) => { + // Dev server: serves both the MCP UIs and the `web/` SPA. // // UIs use a double iframe architecture: // MCP Host -> srcdoc (origin=null) -> iframe (origin=localhost:9991) // - // The inner iframe loads from Envoy ("/__/web/**"), which proxies to - // Vite. Because the inner iframe has a real origin, Vite's URLs work - // normally. `base: "/__/web/"` ensures all paths route through Envoy. + // The inner iframe loads from Envoy ("/__/frontend/**"), which + // proxies to Vite. Because the inner iframe has a real origin, + // Vite's URLs work normally. `base: "/__/frontend/"` ensures all + // paths route through Envoy. + // + // The standalone `web/` SPA is served at `/__/frontend/web/` by this + // same server. It reaches the backend via `VITE_REBOOT_URL` (see + // `web/.env.development`) rather than its own origin, so opening it + // straight from this dev server exercises the real cross-origin + // frontend/backend path — the same shape as a production deploy. // // Hot Module Replacement works automatically: Vite's client connects - // to the page's origin, and Envoy proxies WebSocket upgrades to Vite. - // This also works with tunnels (ngrok) since the tunnel points to - // Envoy. + // to the page's origin, and Envoy proxies WebSocket upgrades to + // Vite. This also works with tunnels (ngrok) since the tunnel + // points to Envoy. if (command === "serve") { const port = parseInt(process.env.RBT_VITE_PORT || "4444", 10); return { - plugins: [react()], + plugins: [ + react(), + redirectFrontendDirTrailingSlash(__dirname), + serveWebAppAtRoot(__dirname), + ], root: ".", + // Read `.env*` from `web/` (alongside the SPA), so the `web/` + // SPA's `VITE_REBOOT_URL` is picked up by both this serve and the + // `web/` production build, which is also rooted there. + envDir: path.resolve(__dirname, "web"), resolve, - base: "/__/web/", + base: "/__/frontend/", server: { port, strictPort: true, - // Listen on all interfaces since requests come through Envoy - // (and tunnels). + // Listen on all interfaces since requests come through + // Envoy (and tunnels). host: true, allowedHosts: true, }, }; } - // Build mode: `vite build --mode ` - const ui = uis[mode]; - if (!ui) { - const valid = Object.keys(uis).join(", "); - throw new Error(`Unknown UI: ${mode}. Use --mode with: ${valid}`); + // Which UI a build targets is read from `RBT_BUILD_TARGET` (set by + // `build.mjs`), not Vite's `mode` — so `mode` stays `production` + // and the `web/` build below reads `web/.env.production`. + const target = process.env.RBT_BUILD_TARGET ?? ""; + + // Build the standalone `web/` SPA into `dist/web/`. We root the + // build at `web/` so `index.html` and its `assets/` land directly + // under `dist/web/` (rather than `dist/web/web/`). Keep assets as + // separate files (a normal multi-file build) and set `base` so + // their URLs resolve when the SPA is served at `/__/frontend/web/`. + if (target === "web") { + return { + plugins: [react()], + root: path.resolve(__dirname, "web"), + base: "/__/frontend/web/", + build: { + outDir: path.resolve(__dirname, "dist/web"), + emptyOutDir: true, + }, + resolve, + }; + } + + // Build one MCP UI (`RBT_BUILD_TARGET=mcp:`). We root the + // build at `mcp//` so the output lands directly at + // `dist/mcp//index.html`, a single self-contained file + // (assets inlined by `vite-plugin-singlefile`). The framework + // serves it at `/__/frontend/mcp//index.html` in dist mode. + const name = target.startsWith("mcp:") ? target.slice("mcp:".length) : ""; + if (!mcpNames.includes(name)) { + const valid = mcpNames.map((n) => `mcp:${n}`).join(", "); + throw new Error( + `Unknown build target: ${target || "(unset)"}. Set ` + + `RBT_BUILD_TARGET=web or one of: ${valid}.` + ); } return { plugins: [react(), viteSingleFile()], + root: path.resolve(__dirname, "mcp", name), + base: "/__/frontend/", + // Read `.env*` from `web/` (the shared frontend env), so an MCP UI + // can pick up e.g. `VITE_WEB_APP_URL` for a pop-out link. + envDir: path.resolve(__dirname, "web"), build: { - outDir: "dist", - emptyOutDir: false, + outDir: path.resolve(__dirname, "dist/mcp", name), + emptyOutDir: true, assetsInlineLimit: 100000000, cssCodeSplit: false, rollupOptions: { - input: ui.input, output: { inlineDynamicImports: true, - entryFileNames: ui.output.replace(".html", ".js"), - assetFileNames: ui.output.replace(".html", ".[ext]"), }, }, }, @@ -160,7 +300,56 @@ export default defineConfig(({ command, mode }) => { }); ``` -## `web/tsconfig.json` +## `frontend/build.mjs` + +`npm run build` runs this after `tsc`. It discovers every UI and +builds each through `vite.config.ts` (selecting the UI via +`RBT_BUILD_TARGET`), so adding or removing a UI never touches the +build script. + +```javascript +// Builds every Reboot UI in this frontend in one shot, so +// `npm run build` keeps working as UIs are added or removed. +// +// `vite.config.ts` knows how to build a *single* target, selected by +// the `RBT_BUILD_TARGET` env var (`mcp:` for one MCP UI, or +// `web` for the SPA). This script is the discover-and-loop around it: +// it finds every `mcp//index.html`, builds each into +// `dist/mcp//index.html`, then builds the `web/` SPA into +// `dist/web/` if a `web/index.html` exists. +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { build } from "vite"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Auto-discover MCP UIs: every `mcp//` with an `index.html`. +const mcpDir = path.resolve(__dirname, "mcp"); +const mcpNames = fs.existsSync(mcpDir) + ? fs + .readdirSync(mcpDir) + .filter((name) => fs.existsSync(path.resolve(mcpDir, name, "index.html"))) + : []; + +// Build each MCP UI. `vite.config.ts` reads the target from +// `RBT_BUILD_TARGET`; we leave Vite's `mode` at its build default +// (`production`) so a `web/.env.production` would be honored. +for (const name of mcpNames) { + console.log(`Building mcp/${name} ...`); + process.env.RBT_BUILD_TARGET = `mcp:${name}`; + await build(); +} + +// Build the standalone `web/` SPA if it exists. +if (fs.existsSync(path.resolve(__dirname, "web", "index.html"))) { + console.log("Building web ..."); + process.env.RBT_BUILD_TARGET = "web"; + await build(); +} +``` + +## `frontend/tsconfig.json` ```json { @@ -172,7 +361,7 @@ export default defineConfig(({ command, mode }) => { } ``` -## `web/tsconfig.app.json` +## `frontend/tsconfig.app.json` ```json { @@ -198,11 +387,11 @@ export default defineConfig(({ command, mode }) => { "@api/*": ["./api/*"] } }, - "include": ["ui"] + "include": ["mcp", "web", "vite-env.d.ts"] } ``` -## `web/tsconfig.node.json` +## `frontend/tsconfig.node.json` ```json { @@ -226,7 +415,18 @@ export default defineConfig(({ command, mode }) => { } ``` -## `web/index.css` +## `frontend/vite-env.d.ts` + +Vite's ambient client type declarations, which type `*.module.css` +imports and `import.meta.env`. It lives at the `frontend/` root, a +sibling of `mcp/` and `web/`, and `tsconfig.app.json`'s `include` lists +it by name so `tsc -b` type-checks against it. + +```ts +/// +``` + +## `frontend/mcp//index.css` ```css :root { @@ -272,7 +472,7 @@ body { } ``` -## `web/ui//index.html` +## `frontend/mcp//index.html` ```html @@ -289,14 +489,14 @@ body { ``` -## `web/ui//main.tsx` +## `frontend/mcp//main.tsx` ```tsx import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { RebootClientProvider } from "@reboot-dev/reboot-react"; import { ClickerApp } from "./App"; -import "../../index.css"; +import "./index.css"; createRoot(document.getElementById("root")!).render( diff --git a/skills/inspect/SKILL.md b/skills/inspect/SKILL.md new file mode 100644 index 0000000..3088a18 --- /dev/null +++ b/skills/inspect/SKILL.md @@ -0,0 +1,112 @@ +--- +name: inspect +description: Inspect the live state of a running Reboot application from the command line with `rbt inspect`. Lists the application's state types, lists the state IDs (actor IDs) for a type, and prints a single actor's state as JSON. Works against both a local `rbt dev run` backend and a deployed Reboot Cloud app via `--application-url` and `--admin-credential`. Use this to answer "what state types does this app have?", "which actors of this type exist?", or "what is stored in this actor right now?" — i.e. to debug persisted state from the terminal instead of the `/__/inspect` web dashboard. +argument-hint: "[type list | state list --type= | state get --type= --id=]" +allowed-tools: Bash, Read +--- + +# inspect — Inspect a Running Reboot Application's State + +> **Version notices:** if `rbt` reports a version mismatch or that a +> newer Reboot is available, the [upgrade skill](../upgrade/SKILL.md) +> says how and when to react. + +`rbt inspect` reads the persisted state of a **running** Reboot +application — the same data the `/__/inspect` web dashboard shows, +but from the terminal so it is scriptable and pipeable. Reach for it +to debug what an app has actually stored: which state types exist, +which actors of a type exist, and what a single actor currently holds. + +Use it whenever the question is about _runtime state_ rather than +code — e.g. "did `create` actually persist this field?", "which +account IDs exist?", "what does actor `alice` look like right now?". +It does not modify anything; it only reads. + +> This skill **inspects** a running app; it does not start one. To +> bring an app up first, see the [run skill](../run/SKILL.md). To +> change what an app stores, see the +> [python skill](../python/SKILL.md). + +## The three operations + +```sh +# 1. List the application's registered state types (full names). +rbt inspect type list --application-url= + +# 2. List the known state IDs (actor IDs) for one type. +rbt inspect state list --type= --application-url= + +# 3. Print a single actor's state as JSON. +rbt inspect state get --type= --id= \ + --application-url= +``` + +The usual flow is to drill down: `type list` to discover the exact +type name to pass to `--type`, then `state list` to find the +`--id`, then `state get` to dump that actor. + +- `--type` takes the **full, package-qualified** type name exactly as + `type list` prints it (e.g. `bank.v1.Account`, `tests.reboot.Echo`) + — not the bare Python class name. +- `--id` is the actor's state ID: the same string you pass to + `.ref(id)` / `.create(context, id, ...)`. +- `state get` prints the state as a JSON object. Pipe it to `jq` or a + file like any other JSON. + +## Connecting: `--application-url` and `--admin-credential` + +Both flags match `rbt export` / `rbt import` (see +`../python/references/lifecycle-reboot-cloud.md`). + +- `--application-url` is the app's API URL — **not** the `/__/inspect` + page URL. It is the address printed as **"Your API is available + at:"** when the app starts. + - **Local dev:** the URL `rbt dev run` prints (e.g. + `http://localhost:9991`). + - **Reboot Cloud:** the URL `rbt cloud up` prints (e.g. + `https://.prod1.rbt.cloud:9991`). +- `--admin-credential` is the app's admin secret, stored under the + `SECRET_REBOOT_ADMIN_TOKEN` secret. + - **Local dev:** defaults to `dev`, so you can omit it. + - **Reboot Cloud:** required — pass the value you set for + `SECRET_REBOOT_ADMIN_TOKEN` (see + `../python/references/lifecycle-secrets.md`). + +### Examples + +Against a local `rbt dev run` backend (admin credential defaults to +`dev`): + +```sh +rbt inspect type list --application-url=http://localhost:9991 + +rbt inspect state list \ + --type=bank.v1.Account \ + --application-url=http://localhost:9991 + +rbt inspect state get \ + --type=bank.v1.Account --id=alice \ + --application-url=http://localhost:9991 +``` + +Against a deployed Reboot Cloud app (admin credential required): + +```sh +rbt inspect state get \ + --type=bank.v1.Account --id=alice \ + --application-url=https://.prod1.rbt.cloud:9991 \ + --admin-credential="$SECRET_REBOOT_ADMIN_TOKEN" +``` + +## Notes + +- **Read-only.** `rbt inspect` never mutates state; it is safe to run + against production. +- **`state get` routes to the actor's host automatically**, even in a + multi-server deployment, and reassembles large states transparently. +- **Pipeable output.** `type list` and `state list` print one entry + per line; `state get` prints a single JSON object. None of it is + colorized when the output is not a TTY, so it composes with `grep`, + `jq`, etc. +- **Web equivalent.** The `/__/inspect` page (linked from `rbt dev run` and `rbt cloud up` output) shows the same state plus recent + calls in a browser; `rbt inspect` is the CLI counterpart. diff --git a/skills/inspect/agents/openai.yaml b/skills/inspect/agents/openai.yaml new file mode 100644 index 0000000..38a0ab1 --- /dev/null +++ b/skills/inspect/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Inspect Reboot App State" + short_description: "Inspect a running Reboot app's state from the CLI (state types, state IDs, single-actor JSON)" + default_prompt: "Use Inspect Reboot App State to see what a running Reboot app has stored." diff --git a/skills/python/references/lifecycle-dockerfile.md b/skills/python/references/lifecycle-dockerfile.md index b07a8c6..fbd9dea 100644 --- a/skills/python/references/lifecycle-dockerfile.md +++ b/skills/python/references/lifecycle-dockerfile.md @@ -64,22 +64,22 @@ static web server for an embedded UI artifact, add two layers between ```dockerfile # After `COPY .rbtrc`, before `RUN rbt generate`: -COPY web/ web/ +COPY frontend/ frontend/ -# Standard rbt codegen — generates `backend/api/` AND `web/api/`. +# Standard rbt codegen — generates `backend/api/` AND `frontend/api/`. RUN rbt generate -# Build the React UIs into `web/dist/`. For MCP apps the Reboot app +# Build the React UIs into `frontend/dist/`. For MCP apps the Reboot app # itself is also the static web server, so it needs these static # assets. -RUN cd web && npm ci && npm run build +RUN cd frontend && npm ci && npm run build # Then the existing `COPY backend/src/ backend/src/` and `CMD ...`. ``` -`COPY web/` lands **before** `RUN rbt generate` on purpose: the -generator writes fresh React bindings into `web/api/`, and if `web/` -were copied after, any stale local `web/api/` from a previous run +`COPY frontend/` lands **before** `RUN rbt generate` on purpose: the +generator writes fresh React bindings into `frontend/api/`, and if `frontend/` +were copied after, any stale local `frontend/api/` from a previous run would clobber the freshly generated copy. ## Why the Layering Matters @@ -89,10 +89,10 @@ layers least- to most-frequently-changing: 1. `COPY requirements.lock requirements.txt` + `RUN pip install` — rebuilt only when dependencies change. -2. `COPY api/` + `COPY .rbtrc` + (for MCP) `COPY web/` — rebuilt when +2. `COPY api/` + `COPY .rbtrc` + (for MCP) `COPY frontend/` — rebuilt when the API schema or `.rbtrc` (or web sources) change. 3. `RUN rbt generate` — runs whenever the previous layer changes. -4. (For MCP) `RUN cd web && npm ci && npm run build` — frontend build. +4. (For MCP) `RUN cd frontend && npm ci && npm run build` — frontend build. 5. `COPY backend/src/` — rebuilt on every backend edit. 6. `CMD ["rbt", "serve", "run"]` — never invalidated. @@ -111,6 +111,10 @@ production lines that need to be in `.rbtrc` for a Cloud deploy: # Tell `rbt serve` this is a Python application. serve run --python +# If the app bundles a frontend (built to `frontend/dist/`), serve it +# at `/__/frontend/` from the built-in HTTP server. +serve run --frontend-dist-path=frontend/dist --frontend-root-path=frontend + # Entry point. serve run --application=backend/src/main.py @@ -130,7 +134,7 @@ Keep the build context small and the image clean. Two variants matching the two Dockerfile patterns above; both derived from [the `reboot-agent-wiki` example's `.dockerignore`](https://github.com/reboot-dev/reboot-agent-wiki/blob/main/.dockerignore). -**Backend-only** (no `web/` content makes it into the image): +**Backend-only** (no `frontend/` content makes it into the image): ```gitignore # Python runtime caches and venv — rebuilt inside the image. @@ -163,8 +167,8 @@ web/ README.md ``` -**Bundled-web (MCP / chat app)** — keep `web/` sources the in-image -`npm ci && npm run build` needs, drop everything else under `web/`: +**Bundled-web (MCP / chat app)** — keep `frontend/` sources the in-image +`npm ci && npm run build` needs, drop everything else under `frontend/`: ```gitignore # Python runtime caches and venv — rebuilt inside the image. @@ -179,11 +183,11 @@ __pycache__/ # Generated code — regenerated inside the image. backend/api/ -web/api/ +frontend/api/ # Node deps + build output — rebuilt inside the image. -web/node_modules/ -web/dist/ +frontend/node_modules/ +frontend/dist/ # VCS. .git/ @@ -207,9 +211,9 @@ README.md - Base image version drifts from the `reboot==` pin. The base image bakes in a specific `rbt` CLI and runtime — a mismatch surfaces as obscure protocol or codegen errors on first deploy. -- Running `rbt generate` **before** `COPY web/` (for the bundled-web - variant). The fresh `web/api/` bindings get clobbered by the stale - local copy when `web/` is finally copied in. Follow the example's +- Running `rbt generate` **before** `COPY frontend/` (for the bundled-web + variant). The fresh `frontend/api/` bindings get clobbered by the stale + local copy when `frontend/` is finally copied in. Follow the example's order. - Putting production flags on the `CMD` line instead of in `.rbtrc`'s `serve run` config. It works, but truth is now split across two diff --git a/skills/python/references/lifecycle-rbtrc.md b/skills/python/references/lifecycle-rbtrc.md index 5f7cecc..7879f8d 100644 --- a/skills/python/references/lifecycle-rbtrc.md +++ b/skills/python/references/lifecycle-rbtrc.md @@ -143,10 +143,12 @@ the command line: ```sh # Default config (HMR / Vite). dev run --default-config=hmr -dev run:hmr --mcp-frontend-host=http://localhost:4444 +dev run --frontend-root-path=frontend + +dev run:hmr --frontend-host=http://localhost:4444 # Dist mode (no Vite). -dev run:dist --mcp-frontend-host="" +dev run:dist --frontend-dist-path=frontend/dist ``` Run with `rbt dev run` (uses `--default-config=hmr`) or diff --git a/skills/python/references/lifecycle-reboot-cloud.md b/skills/python/references/lifecycle-reboot-cloud.md index 72debe5..64a3bef 100644 --- a/skills/python/references/lifecycle-reboot-cloud.md +++ b/skills/python/references/lifecycle-reboot-cloud.md @@ -94,43 +94,19 @@ On success, `rbt cloud up` prints three URLs: The `__/inspect` page is the production equivalent of the inspect page `rbt dev run` exposes locally — it shows live state and -recent calls. +recent calls. For the same state from the terminal (scriptable, +pipeable), use `rbt inspect` against this app's API URL — see the +[inspect skill](../../inspect/SKILL.md). ## Secrets: `rbt cloud secret set/list/delete` -> **Cross-reference:** `lifecycle-secrets.md` is the canonical -> secrets doc — it covers both delivery mechanisms (`rbt dev run` -> shell env vars vs. Cloud's `rbt cloud secret set`) and the -> "don't" list (no secrets in `.rbtrc`, no `REBOOT_*`/`RBT_*` -> names, etc.). This section is the Cloud-side detail. - -Secrets are application-scoped and surfaced to the app as -environment variables. Names must be uppercase environment-style -identifiers (letters, digits, underscores; not starting with a -digit). The prefixes `REBOOT_*` and `RBT_*` are reserved by the -platform. - -```sh -# Set values, reading them out of your shell environment so -# they don't appear in shell history or process listings (the -# `REBOOT_CLOUD_API_KEY` env var is read automatically): -export REBOOT_CLOUD_API_KEY= -export STRIPE_API_KEY=sk_live_... -rbt cloud secret set STRIPE_API_KEY \ - --application-name=my-app \ - --organization=my-org - -# Or pass `KEY=VALUE` directly (value will appear in shell -# history — only do this for non-sensitive values): -rbt cloud secret set FEATURE_FLAG=on ... - -# Inspect / remove: -rbt cloud secret list ... -rbt cloud secret delete STRIPE_API_KEY ... -``` - -Read secrets inside the app the same way as any other -environment variable (e.g. `os.environ["STRIPE_API_KEY"]`). +Cloud secrets are delivered to the app as environment variables via +`rbt cloud secret set/list/delete`. `lifecycle-secrets.md` is the +canonical doc — it covers the full command shape (including batching +every secret into one `set` so the backend rolls out only once, and +why no follow-up `rbt cloud up` is needed), the reserved +`REBOOT_*`/`RBT_*` names, and how the app reads the values +(`os.environ["KEY"]`). ## Logs: `rbt cloud logs` diff --git a/skills/python/references/lifecycle-secrets.md b/skills/python/references/lifecycle-secrets.md index b92a517..3d44898 100644 --- a/skills/python/references/lifecycle-secrets.md +++ b/skills/python/references/lifecycle-secrets.md @@ -82,34 +82,49 @@ stays git-ignored. ## In Cloud: `rbt cloud secret set KEY` `rbt cloud secret set` writes a secret into the application's -managed secret store; it's surfaced to the running app as an -environment variable on next deploy. The full command shape (and -`list` / `delete`) is documented in `lifecycle-reboot-cloud.md`; -the minimum to remember: +managed secret store. If the app is already up, the Cloud backend +then rolls out a new revision on its own so the value is live as an +environment variable — the rollout happens server-side, not in the +CLI. **The platform does the rollout for you; do not run `rbt cloud up` afterward.** ```sh -# Read the value from your shell env (recommended — keeps the -# secret out of shell history and process listings; the API key -# is read from the `REBOOT_CLOUD_API_KEY` env var automatically): +# Read the values from your shell env (recommended — keeps the +# secrets out of shell history and process listings; the API key +# is read from the `REBOOT_CLOUD_API_KEY` env var automatically). +# Pass every secret in ONE command so the app rolls out just once: export REBOOT_CLOUD_API_KEY= -export STRIPE_API_KEY=sk_live_... -rbt cloud secret set STRIPE_API_KEY \ +export AUTH0_DOMAIN=... AUTH0_CLIENT_ID=... AUTH0_CLIENT_SECRET=... +rbt cloud secret set \ + AUTH0_DOMAIN AUTH0_CLIENT_ID AUTH0_CLIENT_SECRET \ --application-name=my-app \ --organization=my-org # Or pass `KEY=VALUE` inline (value appears in shell history — # only do this for non-sensitive values): rbt cloud secret set FEATURE_FLAG=on ... + +# Inspect / remove (same `--application-name` / `--organization` +# args as `set`): +rbt cloud secret list ... +rbt cloud secret delete AUTH0_CLIENT_SECRET ... ``` +Secrets are application-scoped. `rbt cloud secret list` prints the +names currently set; `rbt cloud secret delete NAME ...` removes one +or more. + +`rbt cloud secret set` takes any number of `KEY` / `KEY=VALUE` +arguments. Setting them all in one invocation has the backend roll +out a single revision; calling it once per secret makes the backend +roll out the app once per call, so batch them. Once the command +returns, the backend's new revision is already deploying with the +secrets available as `os.environ["KEY"]` inside the app. + Names must be uppercase environment-style identifiers (letters, digits, underscores; not starting with a digit). The prefixes `REBOOT_*` and `RBT_*` are reserved by the platform — pick a different name for your own secrets. -After `rbt cloud secret set`, the next `rbt cloud up` deploys with -the secret available as `os.environ["KEY"]` inside the app. - ## Reading Secrets in Application Code The same code works in both environments: @@ -156,6 +171,13 @@ provider-swap story. `.env` only when `rbt dev run` is given `--env-file=.env` (in `.rbtrc` or on the CLI); a `.env` with no `--env-file` does nothing. +- **Don't run `rbt cloud up` after `rbt cloud secret set`.** When + the app is up, the Cloud backend already rolls out a new revision + in response to the secret change; a follow-up `up` is a redundant + second rollout. +- **Don't set secrets one per command.** Each call makes the + backend roll out the app again; pass all the keys to a single + `rbt cloud secret set` so it rolls out once. - **Don't reuse `REBOOT_*` or `RBT_*` prefixes.** They're reserved by the platform — `rbt cloud secret set REBOOT_X` will fail. - **Don't assume Cloud and dev share secrets.** Setting a value in diff --git a/skills/python/references/state-collections.md b/skills/python/references/state-collections.md index 8ac318f..ebbe90c 100644 --- a/skills/python/references/state-collections.md +++ b/skills/python/references/state-collections.md @@ -163,8 +163,9 @@ at construction) and forwards reads/writes through the map. > `OrderedMap.ref(f"{context.state_id}-people")` inside a workflow). > Even though this "works", it hides the parent → map relationship > from the state schema: nothing on `UserState` says this user owns -> a People index, so `rbt inspect` and the cloud console see only -> scalar fields and the relationship lives as a string-formatting +> a People index, so [`rbt inspect`](../../inspect/SKILL.md), the +> `/__/inspect` page, and the cloud console see only scalar fields +> and the relationship lives as a string-formatting > convention inside the servicer. It also repeats the magic string > at every callsite — every reader, writer, and workflow that > touches the map — so renaming the suffix, sharding the index, or @@ -258,7 +259,8 @@ Invoice actor) and to every stdlib state type (`OrderedMap`, ```python class UserState(Model): # The reference is a first-class field. Allocated once in `create`; - # persisted forever; visible in `rbt inspect` and the cloud console. + # persisted forever; visible to `rbt inspect`, the `/__/inspect` + # page, and the cloud console. profile_id: str = Field(tag=1, default="") drafts_index_id: str = Field(tag=2, default="") inbox_queue_id: str = Field(tag=3, default="") @@ -300,9 +302,9 @@ form runs: 1. **The state schema documents the actor graph.** Reading `UserState` should be enough to see which other actors this user - owns or points at. `rbt inspect` and the cloud console show the - state, not the servicer source — without a persisted ID, the - relationship is invisible at runtime. + owns or points at. `rbt inspect`, the `/__/inspect` page, and the + cloud console show the state, not the servicer source — without a + persisted ID, the relationship is invisible at runtime. 2. **One place to change, not N.** The persisted ID centralizes the parent → child edge in a single field. Renaming, sharding, or moving the index to a per-concern owner (see diff --git a/skills/run/SKILL.md b/skills/run/SKILL.md index 949c266..453677d 100644 --- a/skills/run/SKILL.md +++ b/skills/run/SKILL.md @@ -42,14 +42,14 @@ strongest first. - An API file under `api/` uses `mcp=Tool()`, `mcp=None`, or `UI(` (the `UI()` method type). - `.rbtrc` has a `dev run --default-config=hmr` line together with - a `dev run:hmr --mcp-frontend-host=...` line. -- The frontend uses the nested `web/ui//index.html` layout. + a `dev run:hmr --frontend-host=...` line. +- The frontend uses the nested `frontend/mcp//index.html` layout. **Web App** — all of: - No `mcp=` / `UI(` anywhere under `api/`. - A single SPA entry at `web/index.html` (top of `web/`, not under - `web/ui/`). + `frontend/mcp/`). If the signals genuinely conflict, or none match, ask the user ("Is this an MCP Chat App or a standalone Web App?"). Do not guess — @@ -60,8 +60,9 @@ the app type changes whether the setup wizard is opened. From the project root: - Backend: if `.venv/` is missing, run `uv sync`. -- Frontend: if `web/node_modules/` is missing, run - `cd web && npm install`. +- Frontend: if the frontend's `node_modules/` is missing, run + `npm install` in the frontend directory — `frontend/` for an MCP + Chat App, `web/` for a standalone Web App. ## Step 4 — Secrets: the git-ignored env file @@ -136,8 +137,11 @@ confusing to a developer who cannot see the backend terminal. ### Frontend — both app types +Run the Vite dev server from the frontend directory: `frontend/` for +an MCP Chat App, `web/` for a standalone Web App. + ```sh -cd web && npm run dev +cd frontend && npm run dev # or `cd web` for a standalone Web App ``` ### Setup wizard — MCP Chat Apps only @@ -205,7 +209,9 @@ Either way the inspector binds the fixed port 6274. Confirm every process is up from its logs, then give the user: -- the application's own inspect-page URL; +- the application's own inspect-page URL (and that `rbt inspect` + inspects the same state from the CLI — see the + [inspect skill](../inspect/SKILL.md)); - for a Chat App — the setup-wizard URL (`http://localhost:9991`, already opened for them, for connecting an MCP client) and a first prompt to try (e.g. "Create a new todo list and show it to diff --git a/skills/upgrade/migrations/next/README.md b/skills/upgrade/migrations/next/README.md index 06cf806..26c5a3d 100644 --- a/skills/upgrade/migrations/next/README.md +++ b/skills/upgrade/migrations/next/README.md @@ -6,7 +6,9 @@ change. If your PR changes something that existing applications must react to — an API rename, a changed default, a new required file, a `.rbtrc` change, anything a `reboot==` app would have to do beyond bumping its pin and regenerating — add a fragment **in the -same PR**. +same PR**. A fragment is only warranted when an existing app must +_do_ something; a change that asks nothing of existing apps needs +no fragment. Migration fragments are not a changelog. One file per PR, named `.md` (e.g. `rename-foo-option.md`; separate files avoid merge conflicts between concurrent PRs). Write diff --git a/skills/upgrade/migrations/next/drop-react-extensions-for-vite.md b/skills/upgrade/migrations/next/drop-react-extensions-for-vite.md new file mode 100644 index 0000000..5d86306 --- /dev/null +++ b/skills/upgrade/migrations/next/drop-react-extensions-for-vite.md @@ -0,0 +1,34 @@ +## `generate --react-extensions` is no longer needed for Vite frontends + +Reboot's React frontends — including AI Chat App UIs — build with +Vite (or are type-checked by `tsc` under `moduleResolution: "bundler"` +/ `"node"`), all of which resolve the generated client's relative +imports without explicit `.js` file extensions. The +`generate --react-extensions` flag, which adds those extensions, is +now only relevant to webpack + `ts-loader` setups, and `rbt generate` +rejects it outright when a `--mobile` (React Native) client is also +requested — Metro cannot resolve the `.js`-suffixed imports back to +their `.ts` sources. + +If your `.rbtrc` contains a `generate --react-extensions` line and +your web frontend builds with Vite (the default for Reboot React and +AI Chat Apps), remove that line: + +``` +generate --react-extensions +``` + +Do **not** remove it in either of these cases: + +- You bundle the React client with webpack/`ts-loader`, which needs + the extensions. +- Your `--react` target writes to the **same output directory** as a + `--nodejs` (or `--web`) target that uses extensions — e.g. + `generate --react=api` and `generate --nodejs=api` both into `api/`. + Reboot requires every target sharing a directory to agree on their + `--*-extensions` setting, so the React client must keep extensions + to match the Node.js one. (Generate the React client into its own + directory if you want to drop them.) + +Leave any `generate --nodejs-extensions` line untouched: Node.js +clients run directly under Node's ESM loader and still require it. diff --git a/skills/upgrade/migrations/next/frontend-serving-flags.md b/skills/upgrade/migrations/next/frontend-serving-flags.md new file mode 100644 index 0000000..dd9e412 --- /dev/null +++ b/skills/upgrade/migrations/next/frontend-serving-flags.md @@ -0,0 +1,33 @@ +## Frontend serving: CLI flags and the `/__/frontend/` URL prefix + +This is the one migration note for the frontend-serving CLI changes; it +grows over the frontend-serving commits. + +### `--mcp-frontend-host` renamed to `--frontend-host` + +The `rbt dev run` / `rbt serve run` frontend dev-proxy is no longer +MCP-specific — it fronts web apps too — so its flag `--mcp-frontend-host` +is now just `--frontend-host`, and the `RBT_MCP_FRONTEND_HOST` env var is +now `RBT_FRONTEND_HOST`. + +In your `.rbtrc`, rename every `--mcp-frontend-host` to `--frontend-host`. + +### URL prefix is now `/__/frontend/`, and the frontend root is named explicitly + +Reboot now serves every frontend — MCP UIs and web apps alike — under a +single fixed URL prefix `/__/frontend/` (it was `/__/web/`), and it no +longer discovers the frontend root by walking for a `vite.config.ts`; you +name it explicitly with `--frontend-root-path`. + +In each `vite.config.ts`, change the dev-server `base` from +`base: "/__/web/"` to `base: "/__/frontend/"`. In `.rbtrc`, name the +frontend root once on the base `dev run` line (both the `:hmr` and +`:dist` configs inherit it, so it isn't repeated), and give the dist +configs `--frontend-dist-path`. For a frontend rooted at `web/`: + +``` +dev run --frontend-root-path=web +dev run:hmr --frontend-host=http://localhost:4444 +dev run:dist --frontend-dist-path=web/dist +serve run --frontend-root-path=web --frontend-dist-path=web/dist +``` diff --git a/skills/upgrade/migrations/next/reorganize-frontend-layout.md b/skills/upgrade/migrations/next/reorganize-frontend-layout.md new file mode 100644 index 0000000..710a95c --- /dev/null +++ b/skills/upgrade/migrations/next/reorganize-frontend-layout.md @@ -0,0 +1,43 @@ +## Reorganize the frontend into `frontend/{mcp,web,api}` + +An app's frontend used to be split across `web/ui/` for MCP UIs, `web/` +for everything else, and `web/api/` for generated bindings. The canonical +layout is now a single `frontend/` tree: `mcp/` for the MCP UIs, `web/` +for a browser SPA, and `api/` for generated bindings. + +Move your frontend to match: + +- `web/ui/` -> `frontend/mcp/` +- `web/api/` -> `frontend/api/` +- the rest of `web/` -> `frontend/web/` + +Then update everything that named the old paths: `.rbtrc` +(`--react=frontend/api`, `--frontend-root-path=frontend`, +`--frontend-dist-path=frontend/dist`, watch globs), the Dockerfile, and +any `UI(path=...)` values or imports. + +Move your `vite-env.d.ts` — the `/// ` +shim that types `*.module.css` imports and `import.meta.env` — to the +`frontend/` root (a sibling of `mcp/`, `web/`, and `api/`), not under +`frontend/web/src/`, and list it in each `tsconfig.app.json`'s +`include` alongside your UI directories +(`["mcp", "web", "vite-env.d.ts"]`). Otherwise the shim falls off the +type-check path and MCP UIs fail `tsc` with `TS2307: Cannot find module './App.module.css'`. + +Adopt the new `frontend/vite.config.ts`: it auto-discovers your MCP UIs +under `mcp/` and builds each into the single-file +`dist/mcp//index.html` the framework serves under `/__/frontend/`. +It reads which UI to build from the `RBT_BUILD_TARGET` environment +variable (the build scripts set it for you) instead of overloading +Vite's `--mode`, which stays free for its usual `.env.` +selection — drop any `vite build --mode ` invocations you carry. +Rather than reproduce it here, copy it from the chat-app skill's +`references/react-scaffolding.md`, where it's written out in full, with +commentary. + +Add `frontend/build.mjs` too (also from +`references/react-scaffolding.md`): it discovers and builds every UI in +one shot, so `npm run build` keeps working as UIs come and go. Set +`frontend/package.json`'s build script to +`"build": "tsc -b && node build.mjs"`, dropping any per-UI +`build:` scripts and the `concurrently` devDependency. diff --git a/skills/web-app/SKILL.md b/skills/web-app/SKILL.md index d818270..27381e1 100644 --- a/skills/web-app/SKILL.md +++ b/skills/web-app/SKILL.md @@ -407,7 +407,7 @@ Before writing code, analyze the user's request: Key differences from a `chat-app` layout: - `web/index.html` lives at the top of `web/` (single SPA entry), - **not** under `web/ui//index.html`. + **not** under `frontend/mcp//index.html`. - `vite.config.ts` is the **stock** Vite config — no nested-output override, no `viteSingleFile` plugin. There's no MCP host resolving artifacts by path.