From 979f0b9ecc8078f707c808e9657535842234eff6 Mon Sep 17 00:00:00 2001 From: Keegan Donley Date: Thu, 29 Oct 2026 12:02:47 -0500 Subject: [PATCH 1/7] KZN-19254 - add app builder CLI commands for working with business apps --- README.md | 643 ++- package.json | 4 +- pnpm-lock.yaml | 11 + src/__snapshots__/program.test.ts.snap | 4 + src/commands/app.ts | 333 ++ src/commands/auth.ts | 14 +- src/commands/block.ts | 12 +- src/commands/create.ts | 13 +- src/commands/encrypt.ts | 19 +- src/commands/interactive.test.ts | 160 + src/commands/setupCodex.ts | 20 + src/lib/agentHarness.ts | 103 + src/lib/agentSetup.test.ts | 123 + src/lib/agentSetup.ts | 186 + src/lib/agentSkills.test.ts | 209 + src/lib/appActions.test.ts | 1583 ++++++ src/lib/appActions.ts | 807 +++ src/lib/appFlow.ts | 3600 +++++++++++++ src/lib/appHeadless.test.ts | 4447 +++++++++++++++++ src/lib/appHeadless.ts | 2019 ++++++++ src/lib/appMerge.test.ts | 1450 ++++++ src/lib/appMerge.ts | 1327 +++++ src/lib/appPreviewDelete.test.ts | 932 ++++ src/lib/appState.test.ts | 313 ++ src/lib/appState.ts | 294 ++ src/lib/businessAppPayload.test.ts | 260 + src/lib/businessAppPayload.ts | 106 + src/lib/businessApps.test.ts | 529 ++ src/lib/businessApps.ts | 565 +++ src/lib/commandCore.test.ts | 287 ++ src/lib/commandCore.ts | 754 +++ src/lib/createCopilotFiles.test.ts | 53 +- src/lib/createCopilotFiles.ts | 53 +- src/lib/createHeadless.ts | 2 +- src/lib/createPlugin.test.ts | 18 + src/lib/createPlugin.ts | 4 +- src/lib/gitignore.test.ts | 11 + src/lib/gitignore.ts | 7 +- src/lib/interactive.test.ts | 79 + src/lib/interactive.ts | 89 + src/lib/kizenClient.test.ts | 395 ++ src/lib/kizenClient.ts | 238 +- src/lib/kizenTypes.ts | 24 +- src/lib/listSearch.test.ts | 21 + src/lib/listSearch.ts | 27 + src/lib/managedFiles.test.ts | 94 +- src/lib/managedFiles.ts | 92 +- src/lib/oauth.ts | 41 +- src/lib/oauthCommands.test.ts | 130 + src/lib/oauthCommands.ts | 33 +- src/lib/oauthSession.test.ts | 204 +- src/lib/oauthSession.ts | 12 + src/lib/oauthStore.ts | 79 +- src/lib/pushHeadless.test.ts | 617 ++- src/lib/pushHeadless.ts | 553 +- src/lib/setupClaude.test.ts | 91 +- src/lib/setupClaude.ts | 155 +- src/lib/setupCodex.test.ts | 219 + src/lib/setupCodex.ts | 51 + src/lib/sourceZip.test.ts | 1117 +++++ src/lib/sourceZip.ts | 943 ++++ src/lib/whoamiCommand.ts | 84 + src/program.test.ts | 407 +- src/program.ts | 6 + .../skills/kizen-business-apps/SKILL.md | 152 + .../claude/skills/kizen-custom-block/SKILL.md | 40 +- src/test/appHarness.ts | 1009 ++++ src/ui/AppUI.tsx | 137 + src/ui/ChoiceList.tsx | 50 + src/ui/ConfirmPrompt.tsx | 64 + src/ui/ErrorFrame.tsx | 31 + src/ui/Lines.tsx | 17 + src/ui/PushUI.tsx | 298 +- src/ui/SelectList.tsx | 68 +- src/ui/SessionFrame.tsx | 55 + src/ui/Working.tsx | 10 + src/ui/appUiFlow.test.ts | 2116 ++++++++ src/ui/appUiFlow.ts | 1710 +++++++ src/ui/commandSession.ts | 225 + 79 files changed, 32022 insertions(+), 1036 deletions(-) create mode 100644 src/commands/app.ts create mode 100644 src/commands/interactive.test.ts create mode 100644 src/commands/setupCodex.ts create mode 100644 src/lib/agentHarness.ts create mode 100644 src/lib/agentSetup.test.ts create mode 100644 src/lib/agentSetup.ts create mode 100644 src/lib/agentSkills.test.ts create mode 100644 src/lib/appActions.test.ts create mode 100644 src/lib/appActions.ts create mode 100644 src/lib/appFlow.ts create mode 100644 src/lib/appHeadless.test.ts create mode 100644 src/lib/appHeadless.ts create mode 100644 src/lib/appMerge.test.ts create mode 100644 src/lib/appMerge.ts create mode 100644 src/lib/appPreviewDelete.test.ts create mode 100644 src/lib/appState.test.ts create mode 100644 src/lib/appState.ts create mode 100644 src/lib/businessAppPayload.test.ts create mode 100644 src/lib/businessAppPayload.ts create mode 100644 src/lib/businessApps.test.ts create mode 100644 src/lib/businessApps.ts create mode 100644 src/lib/commandCore.test.ts create mode 100644 src/lib/commandCore.ts create mode 100644 src/lib/interactive.test.ts create mode 100644 src/lib/interactive.ts create mode 100644 src/lib/listSearch.test.ts create mode 100644 src/lib/listSearch.ts create mode 100644 src/lib/setupCodex.test.ts create mode 100644 src/lib/setupCodex.ts create mode 100644 src/lib/sourceZip.test.ts create mode 100644 src/lib/sourceZip.ts create mode 100644 src/lib/whoamiCommand.ts create mode 100644 src/templates/claude/skills/kizen-business-apps/SKILL.md create mode 100644 src/test/appHarness.ts create mode 100644 src/ui/AppUI.tsx create mode 100644 src/ui/ChoiceList.tsx create mode 100644 src/ui/ConfirmPrompt.tsx create mode 100644 src/ui/ErrorFrame.tsx create mode 100644 src/ui/Lines.tsx create mode 100644 src/ui/SessionFrame.tsx create mode 100644 src/ui/Working.tsx create mode 100644 src/ui/appUiFlow.test.ts create mode 100644 src/ui/appUiFlow.ts create mode 100644 src/ui/commandSession.ts diff --git a/README.md b/README.md index 345ddce..e3bf0a1 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,11 @@ Scaffolds a new Kizen plugin project. Interactive by default; passing any flag o The scaffold also includes `.github/copilot-instructions.md`, two path-scoped instruction files under `.github/instructions/`, and `.github/workflows/copilot-code-review.yml`. That workflow fetches the Kizen plugin docs from `kizen/app-engine` into `.copilot-docs/` before Copilot code review runs, so reviews cite the current documentation instead of rules copied into the plugin repo. It only takes effect once it is on the repository's default branch, and `.copilot-docs/` is gitignored. Plugins scaffolded before this setup existed can pick it up with `appbuilder setup-copilot`. +It also writes the coding-agent skills, once for Claude Code (`.claude/skills/`) and once for Codex (`.agents/skills/`): `kizen-custom-block` (building custom blocks, with its design guide) and `kizen-business-apps` (publishing with [`appbuilder app`](#appbuilder-app)). Plugins scaffolded before a skill existed pick it up with [`appbuilder setup-claude`](#appbuilder-setup-claude) or [`appbuilder setup-codex`](#appbuilder-setup-codex). + ### `appbuilder setup-copilot` -Writes the same Copilot review files into an existing plugin repo — run it from the repo root (a `kizen.json` must be there). The four files are CLI-owned, so the command overwrites them and prints `created`, `updated`, or `unchanged` per file; a clobbered local customization is recoverable from git. It also adds `.kizenapp/` and `.copilot-docs/` to `.gitignore` if they are missing. +Writes the same Copilot review files into an existing plugin repo — run it from the repo root (a `kizen.json` must be there). The four files are CLI-owned, so the command overwrites them and prints `created`, `updated`, or `unchanged` per file; a clobbered local customization is recoverable from git. A file (or a folder on its path) that is a symbolic link is refused, and nothing is written. It also adds `.kizenapp/` and `.copilot-docs/` to `.gitignore` if they are missing. | Flag | Default | Purpose | | ----------- | ------- | ----------------------------------------------- | @@ -122,7 +124,7 @@ As with `create`, the workflow only runs once it has reached the repository's de ### `appbuilder setup-claude` -Installs or refreshes the Claude Code skill for building custom blocks (`.claude/skills/kizen-custom-block/SKILL.md`) in the plugin in the current directory. `appbuilder create` writes the skill once; run `setup-claude` in an existing plugin to add it, or after upgrading the CLI to pick up the bundled version. With `--mcp` it only registers the Kizen MCP server instead and writes no files, so it can run in any directory (see below). +Installs or refreshes the Claude Code skills in the plugin in the current directory: `kizen-custom-block` for building custom blocks (`.claude/skills/kizen-custom-block/SKILL.md`) and `kizen-business-apps` for publishing with [`appbuilder app`](#appbuilder-app) (`.claude/skills/kizen-business-apps/SKILL.md`). `appbuilder create` writes the skills once; run `setup-claude` in an existing plugin to add them, or after upgrading the CLI to pick up the bundled versions. With `--mcp` it only registers the Kizen MCP server instead and writes no files, so it can run in any directory (see below). ```sh appbuilder setup-claude [--dry-run] [--include-lib] @@ -141,9 +143,28 @@ appbuilder setup-claude --mcp [--env ] [--force] [--dry-run] It also installs the skill's design guide (`.claude/skills/kizen-custom-block/design.md`), which tells the agent how to make blocks match native dashlets. With `--include-lib`, it installs the Kizen data helper library that block scripts import to read records and apply the dashboard's date and team filters. The library goes under the `entry` directory from `kizen.json` (`/lib/kizenData.js`, one per entry in a multi-plugin `kizen.json`), and under `src/` when `kizen.json` has no usable `entry`. Without the flag, it refreshes only the libraries that already exist. `appbuilder create --include-lib` writes it to `src/lib/kizenData.js`. -It prints one line per file (`created`, `updated` or `unchanged`) and a one-line summary. These files are managed by the CLI, so local edits to them, including edits to `kizenData.js`, are replaced when they're `updated`. Don't edit `kizenData.js`; put your own helpers in another file under `src/lib/`. It only writes these files and never touches the `.github/` Copilot files or block code. Without `--mcp`, it fails with exit code 1 when there's no `kizen.json` in the current directory. +It prints one line per file (`created`, `updated` or `unchanged`) and a one-line summary. These files are managed by the CLI, so local edits to them, including edits to `kizenData.js`, are replaced when they're `updated`. Don't edit `kizenData.js`; put your own helpers in another file under `src/lib/`. It only writes these files and never touches the `.github/` Copilot files, the Codex skills or block code. It writes only regular files inside the plugin folder: if a managed file, or any folder on its path, is a symbolic link, or something other than a file or folder is in the way, it writes nothing and fails with exit code 1, naming the path. That includes the entry directory: with `--include-lib` (or an existing `kizenData.js`), an entry directory such as `src` that is a symbolic link, as some monorepo setups use, is refused. Without `--mcp`, it also fails with exit code 1 when there's no `kizen.json` in the current directory. + +`block push` and every [`appbuilder app`](#appbuilder-app) command add the warning `Claude files managed by appbuilder are out of date; run appbuilder setup-claude` to their result's `warnings` when the plugin has any of the Claude skills and any managed file is missing or differs from the bundled one: either skill's files, or an existing `kizenData.js`. A plugin installed before a skill existed is therefore out of date until `setup-claude` adds it. A plugin with no Claude skill gets no warning. The warning never fails the command, and only headless runs show it. + +### `appbuilder setup-codex` + +The same skills for Codex, which discovers repository skills in `.agents/skills//SKILL.md`: it installs or refreshes `.agents/skills/kizen-custom-block/` (with `design.md`) and `.agents/skills/kizen-business-apps/` in the plugin in the current directory. Both commands render the skills from the same templates; the Codex copies name `setup-codex`, `.agents/skills` and Codex's sandbox escalation where the Claude copies name `setup-claude`, `.claude/skills` and Claude Code's sandbox settings. `appbuilder create` writes both sets. + +```sh +appbuilder setup-codex [--dry-run] [--include-lib] +``` + +| Flag | Default | Purpose | +| --------------- | ------- | ------------------------------------------------------------ | +| `--dry-run` | off | Report what would change without writing files. | +| `--include-lib` | off | Also install the Kizen data helper library (`kizenData.js`). | -`block push` adds the warning `Claude files managed by appbuilder are out of date; run appbuilder setup-claude` when the plugin has the skill and the skill or its design guide is missing or differs from the bundled one, or an existing `kizenData.js` differs from the bundled one. The warning never fails the push. +`--include-lib`, the per-file output, the summary (`Codex files: …`), the refusal to write through a symbolic link and the exit code 1 outside a plugin work as for `setup-claude`. The files are managed by the CLI, so a differing file is replaced without any flag. Inside Codex's sandbox `.agents/` is read-only, so an agent running `setup-codex` there needs to escalate it. + +`block push` and every `appbuilder app` command add `Codex files managed by appbuilder are out of date; run appbuilder setup-codex` under the same rule as the Claude warning, judged on the Codex skills; a plugin with both sets can get both warnings. + +There's no `--mcp`: Codex doesn't take an MCP headers helper from its CLI (`codex mcp add` has no flag for it), so registering one means editing `~/.codex/config.toml` by hand, and Codex gives the helper 10 seconds, which a first `npx` download of the pinned CLI can exceed. ### `appbuilder build` @@ -242,7 +263,7 @@ appbuilder block push [api_name] [flags] Interactive by default: pick the environment (only when you pass no `--env` and you're signed in to several, or the account you name is saved in several), the block, the surface (dashboard, homepage or chart group), the dashboard, then whether to update an existing custom code dashlet or create a new one, and confirm. Pushing to a production environment (`go`, `fmo`) asks you to type `y`. In a TTY, `--yes` skips the confirm prompt only when the headless write gate would apply, so `go` and `fmo` still ask for a typed `y` unless `--allow-production` is also passed; `--dry-run` shows the plan without writing. A dashlet edited in Kizen since the last push still prompts before overwriting unless `--force` is passed. -It runs headless when stdin or stdout is not a TTY, or when `--json` is passed. `--yes` never selects the mode. Headless runs never prompt: anything that would be a question fails with `needs_choice` and lists the choices. +It runs headless when stdin or stdout is not a TTY, under a coding agent or CI, or with `--json`, `--no-interactive` or `APPBUILDER_NO_INTERACTIVE` (see [Interactive or headless](#interactive-or-headless)). `--yes` never selects the mode. Headless runs never prompt: anything that would be a question fails with `needs_choice` and lists the choices. | Argument / flag | Default | Purpose | | -------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | @@ -258,13 +279,14 @@ It runs headless when stdin or stdout is not a TTY, or when `--json` is passed. | `--force` | off | Overwrite a dashlet that was edited in Kizen since the last push | | `--forget` | off | Clear the remembered target for this block before resolving | | `--json` | off | Print a JSON result to stdout (forces headless); the human summary goes to stderr | +| `--no-interactive` | off (`$APPBUILDER_NO_INTERACTIVE`) | Run headless even at a terminal | | `--env ` | the only signed-in environment, or with `--business` the only one where it's saved | Environment of the session to use. Not with `-c` or `--profile`. | | `--business ` | `$APPBUILDER_BUSINESS`, else the current account | Saved account to use, matched as for [`login --business`](#saved-accounts). Not with `-c` or `--profile`. | | `--user ` | `$APPBUILDER_USER` | With `--business`, the user id of that saved account. Not with `-c` or `--profile`. | -**Credentials.** Without `-c` or `--profile`, the push uses your [`appbuilder login`](#appbuilder-login-logout-whoami-and-auth-header) session for `--env`, or for the only environment you're signed in to. If you're signed in to several environments and pass no `--env`, the run is `needs_choice` with `choice: "environment"`. It uses that environment's current account, unless `--business` (and `--user`), or `APPBUILDER_BUSINESS` (and `APPBUILDER_USER`), names another saved account; then the environment without `--env` is the only one where that name matches a saved account, whether or not any account there is current, and several such environments are `needs_choice` the same way (see [Saved accounts](#saved-accounts)). If you're signed out, it fails with `credentials_invalid` and tells you to run `appbuilder login`; it never falls back to an API key. API keys (deprecated) are used only with `-c ` or `--profile `, and never touch the session; `--env`, `--business` or `--user` with either is a `usage_error`, and `APPBUILDER_BUSINESS` and `APPBUILDER_USER` are ignored. The credentials file must name a valid `environment` (`go`, `fmo`, `staging`, `integration`, `test1`); a missing or invalid one is refused with `credentials_invalid` rather than defaulting to production. Empty `apiKey`, `userId` or `businessId` are refused the same way. A `.kizenapp/config.json` `activeCredentialProfile` no longer selects an API key: the push uses the session and prints a one-line notice to stderr, so `--json` output stays clean. `credentialMode: "local"` doesn't affect block commands. +**Credentials.** Without `-c` or `--profile`, the push uses your [`appbuilder login`](#appbuilder-login-logout-whoami-and-auth-header) session for `--env`, or for the only environment you're signed in to. If you're signed in to several environments and pass no `--env`, the run is `needs_choice` with `choice: "environment"`. It uses that environment's current account, unless `--business` (and `--user`), or `APPBUILDER_BUSINESS` (and `APPBUILDER_USER`), names another saved account; then the environment without `--env` is the only one where that name matches a saved account, whether or not any account there is current, and several such environments are `needs_choice` the same way (see [Saved accounts](#saved-accounts)). If you're signed out, it fails with `not_signed_in`, whose `login.argv` is the `appbuilder login` command to run; it never falls back to an API key. API keys (deprecated) are used only with `-c ` or `--profile `, and never touch the session; `--env`, `--business` or `--user` with either is a `usage_error`, and `APPBUILDER_BUSINESS` and `APPBUILDER_USER` are ignored. The credentials file must name a valid `environment` (`go`, `fmo`, `staging`, `integration`, `test1`); a missing or invalid one is refused with `credentials_invalid` rather than defaulting to production. Empty `apiKey`, `userId` or `businessId` are refused the same way. A `.kizenapp/config.json` `activeCredentialProfile` no longer selects an API key: the push uses the session and prints a one-line notice to stderr, so `--json` output stays clean. `credentialMode: "local"` doesn't affect block commands. -**Write gating (headless).** `--dry-run` is always a dry run. Without `--yes` the run is a dry run too, and stderr says `Dry run only: pass --yes to write.` With `--yes`, `go` and `fmo` also need `--allow-production` (else `production_requires_flag`). If the target dashlet was pushed from here before and its content has since been changed in Kizen, both dry runs and writes stop with `drift_detected` unless `--force` is passed. +**Write gating (headless).** `--dry-run` is always a dry run. Without `--yes` the run is a dry run too, and stderr says `Dry run only: pass --yes to write.` With `--yes`, `go` and `fmo` also need `--allow-production` (else `production_requires_flag`). A dry run's `applyArgv` is the command that writes what it showed: the same push with `--yes`, pinned to its dashboard and dashlet (`--dashboard` plus `--dashlet` or `--create`) and to the account the run signed in as, with `--allow-production` only on production, where `requiresProductionConsent` is `true` so an agent asks the user first. If the target dashlet was pushed from here before and its content has since been changed in Kizen, both dry runs and writes stop with `drift_detected` unless `--force` is passed. **Remembered target.** A successful write records the target in `.kizenapp/pushes.json` (kept gitignored): one entry per environment, business, plugin and block. Pushing the block somewhere else replaces the entry. With no `--dashboard`, the next push goes to the remembered dashlet. If that dashboard or dashlet is gone, the push fails with `remembered_target_missing` and never silently creates a new one. When only the dashlet is gone the failure lists the dashboard's target `choices`, and `--create` adds a new block there; otherwise pass `--dashboard ` to choose another target, or `--forget` to clear it (honored in dry runs too; the result then carries `"forgotten": true`). A missing `pushes.json` just means nothing is remembered yet; one that can't be read, isn't valid JSON, or isn't a JSON array fails the push with `local_error` before anything is written, so fix or delete it. @@ -301,7 +323,26 @@ With `--json`, every result is one pretty-printed JSON object on stdout, and the "method": "PATCH", "path": "/dashboards/5f1e…/dashlet/9a2d…", "body": { "config": { "…": "…" } }, - "warnings": [] + "warnings": [], + "applyArgv": [ + "appbuilder", + "block", + "push", + "pipeline_summary", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--dashboard", + "5f1e…", + "--dashlet", + "9a2d…", + "--yes", + "--json" + ], + "requiresProductionConsent": false } ``` @@ -339,7 +380,7 @@ An applied write: `refresh` is only on applied writes, and only when the dashboard id is a uuid; otherwise it's left out. `script` is exactly `await window.__kizenCustomBlocks?.refresh('')`. Run it in a Kizen tab that already has `url` open to refetch that dashboard in place instead of reloading the page. The hook is registered on dashboards, homepages and chart groups. When it's available on the page, the script resolves to `{ refreshed: true, dashboardId }`. Otherwise it resolves to `undefined`: the page is an older Kizen build without the hook. `refreshed: true` means the dashboard query was invalidated, not that the target was on screen, which is why the agent matches the tab by `url` and screenshots afterwards. Human output prints the same script on a `Refresh an open tab:` line after the URL. -A failure exits 1: +A failure exits 2 when it stops for a decision (`needs_choice`) and 1 otherwise: ```json { @@ -353,21 +394,59 @@ A failure exits 1: "label": "Pipeline summary", "args": ["--dashboard", "5f1e…", "--dashlet", "9a2d…"], "pushKey": "appbuilder:my_plugin/pipeline_summary", - "isFromThisPlugin": true + "isFromThisPlugin": true, + "argv": [ + "appbuilder", + "block", + "push", + "pipeline_summary", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--dashboard", + "5f1e…", + "--dashlet", + "9a2d…", + "--json" + ] }, - { "value": "new", "label": "Create a new block", "args": ["--dashboard", "5f1e…", "--create"] } + { + "value": "new", + "label": "Create a new block", + "args": ["--dashboard", "5f1e…", "--create"], + "argv": [ + "appbuilder", + "block", + "push", + "pipeline_summary", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--dashboard", + "5f1e…", + "--create", + "--json" + ] + } ] } ``` -Optional failure fields: `choice` (`environment`, `block`, `dashboard` or `target`) and `choices` with `needs_choice`, `remembered_target_missing` and a `--dashlet` that is `not_found`; `issues` (the packager's validation issues) with `validation_failed`; `field` with `invalid_block`; `hint` with `auth_failed` and `forbidden`. Each choice's `args` are the exact CLI arguments that select it, to be appended to the command (dashboard choices also carry `surface` and `canEdit`). +Optional failure fields: `choice` (`environment`, `block`, `dashboard` or `target`) and `choices` with `needs_choice`, `remembered_target_missing` and a `--dashlet` that is `not_found`; `issues` (the packager's validation issues) with `validation_failed`; `field` with `invalid_block`; `hint` (what to do next) with `not_signed_in`, `auth_failed`, `forbidden`, `session_store_unwritable` and others; `login` (`{ "argv": [...] }`, the `appbuilder login` command for the user to run) with `not_signed_in`; and `sandbox` (`"codex_network_disabled"`) on a `network_error` when Codex's sandbox has the network turned off. Each choice's `argv` is the whole command that selects it: the push re-run with that choice and the account flags (`--json` too when the run had it), without `--yes`, so it's a dry run whose `applyArgv` then writes. Run it as given. `args` are only the flags that select it (dashboard choices also carry `surface` and `canEdit`). | Code | Meaning | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `needs_choice` | An environment, block, dashboard or target must be chosen; see `choice` and `choices` | | `validation_failed` | The plugin failed validation | | `invalid_block` | The plugin has no blocks, or the block would be rejected (`field` names the problem) | -| `credentials_invalid` | Not signed in (run `appbuilder login`), or the `-c`/`--profile` file's `environment` is missing or invalid | +| `not_signed_in` | No usable sign-in session (missing, expired, rejected or unreadable); `login.argv` is the `appbuilder login` command for the user to run | +| `credentials_invalid` | The `-c`/`--profile` file can't be read, its `environment` is missing or invalid, or it lacks `apiKey`, `userId` or `businessId` | | `auth_failed` | Kizen rejected the credentials | | `forbidden` | The credentials can't access the dashboard | | `not_found` | The dashboard, or the `--dashlet` on it, doesn't exist | @@ -375,11 +454,12 @@ Optional failure fields: `choice` (`environment`, `block`, `dashboard` or `targe | `drift_detected` | The dashlet was edited in Kizen since the last push; `--force` overwrites | | `production_requires_flag` | `--yes` against `go` / `fmo` without `--allow-production` | | `usage_error` | Conflicting flags (`--dashlet` with `--create`, `--dashlet` without `--dashboard`), a non-custom-code `--dashlet`, or a `--business` (or `APPBUILDER_BUSINESS`) that matches no saved account, or several | -| `network_error` | Kizen couldn't be reached | +| `network_error` | Kizen couldn't be reached; `sandbox: "codex_network_disabled"` when Codex's sandbox has the network off | +| `session_store_unwritable` | The session couldn't be saved to `~/.kizenappbuilder/oauth/` (a sandbox, or a full disk); `hint` says how to allow it | | `api_error` | Any other Kizen API failure | | `local_error` | A local failure that isn't a validation error: no `kizen.json`, packaging, reading `.kizenapp/pushes.json`, or writing it for `--forget` | -Sign-in sessions add these cases: not being signed in is `credentials_invalid`; several signed-in environments and no `--env` is `needs_choice` (`choice: "environment"`, each choice `{ value, label, args: ["--env", ] }`), and so is a `--business` (or `APPBUILDER_BUSINESS`) that matches saved accounts in several environments, whose retry commands keep `--business` and `--user`; a session that needs a new `appbuilder login` (expired, rejected or unreadable) is `auth_failed`; `--env`, `--business` or `--user` with `-c` or `--profile`, or an unknown `--env`, is `usage_error`; a `--business` (or `APPBUILDER_BUSINESS`) that matches no saved account, or several, is `usage_error` with a message that lists them, and so is `--user` without `--business` (or `APPBUILDER_USER` without `APPBUILDER_BUSINESS`); an unreachable token endpoint is `network_error`; and any other failure to refresh the session is `api_error`. +Sign-in sessions add these cases: not being signed in, or a session that needs a new `appbuilder login` (expired, rejected or unreadable), is `not_signed_in` with `login.argv` (`appbuilder login`, plus `--env` when the environment is known and `--business`/`--user` when the run named an account); several signed-in environments and no `--env` is `needs_choice` (`choice: "environment"`, each choice `{ value, label, args: ["--env", ], argv }`), and so is a `--business` (or `APPBUILDER_BUSINESS`) that matches saved accounts in several environments, whose retry commands keep `--business` and `--user`; `--env`, `--business` or `--user` with `-c` or `--profile`, or an unknown `--env`, is `usage_error`; a `--business` (or `APPBUILDER_BUSINESS`) that matches no saved account, or several, is `usage_error` with a message that lists them, and so is `--user` without `--business` (or `APPBUILDER_USER` without `APPBUILDER_BUSINESS`); an unreachable token endpoint is `network_error`; a refreshed session that can't be saved is `session_store_unwritable`; and any other failure to refresh the session is `api_error`. ### `appbuilder block targets` @@ -431,7 +511,7 @@ With `--object ` the shape is `{ ok, environment, businessId, object: { id, #### Agent loop -The sequence a coding agent follows to build a block and put it on a dashboard. After upgrading the CLI, run `appbuilder setup-claude` so the agent's skill matches the new commands. +The sequence a coding agent follows to build a block and put it on a dashboard. After upgrading the CLI, run `appbuilder setup-claude` (or `appbuilder setup-codex`) so the agent's skill matches the new commands. ```sh appbuilder block export @@ -450,9 +530,503 @@ Guardrails: - Never pass `--allow-production` unless the user named production. - Stop on `drift_detected` and ask the user before retrying with `--force`. -- On `needs_choice`, ask the user using `choices` (labels to show, `args` to append). +- On `needs_choice` (exit 2), ask the user using `choices` (labels to show), then run the chosen `argv` exactly as given. +- Run a dry run's `applyArgv` as given, and only after the user agreed; when `requiresProductionConsent` is `true`, only after they agreed to write to production. +- On `not_signed_in`, ask the user to run `login.argv` (it opens a browser). - On `remembered_target_missing`, ask whether to `--create` a new block rather than creating one. +### `appbuilder app` + +Publishes the plugin in the current folder as a Kizen Business App on the business you're signed in to. Each developer pushes their own preview of the app; publishing a preview makes it the next Live version, which every user of the business gets. The server numbers the releases (`kizen.json` `version` isn't sent). + +```sh +appbuilder app versions # Live, released and preview versions, and this folder's base +appbuilder app push [--force] [--base-from-preview ] # save this folder as your preview +appbuilder app publish [--force] [--expect-preview ] # make your preview the next Live version +appbuilder app merge [--release ] [--base | --accept-live-base] # merge Live (or release n) into this folder +appbuilder app rollback # restore released version n as your preview +appbuilder app pull [--out ] # download release n's source into a new folder +appbuilder app actions list # the app's JS actions and the objects they're linked to +appbuilder app actions link [--menu | --no-menu] # link a JS action to an object +appbuilder app actions unlink # remove that link +appbuilder app preview delete [--owner ] [--expect-updated ] [--delete-app] # delete your preview (or another developer's) +``` + +At a terminal each command is interactive: it shows the plan and asks before writing, and production (`go`, `fmo`) needs a typed `y`. It runs headless when stdin or stdout is not a TTY, under a coding agent or CI, or with `--json`, `--no-interactive` or `APPBUILDER_NO_INTERACTIVE` (see [Interactive or headless](#interactive-or-headless)). + +| Flag | Commands | Purpose | +| ------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `-c, --credentials ` | all | API key (deprecated): credentials JSON file to use instead of the session | +| `--profile ` | all | API key (deprecated): stored credential profile to use instead of the session | +| `--dry-run` | push, publish, merge, rollback, actions link/unlink, preview delete | Show what would change without writing | +| `--yes` | push, publish, merge, rollback, actions link/unlink, preview delete | Write without asking. Headless runs without it are dry runs. | +| `--allow-production` | push, publish, rollback, actions link/unlink, preview delete | Allow headless writes to `go` / `fmo` (`merge` writes only local files, so it has none) | +| `--menu`, `--no-menu` | actions link | Show the action in the object's Perform Action menus, or not. A new link isn't shown unless `--menu`; an existing link keeps its setting unless one is passed. | +| `--force` | push | Push even though files still have merge conflict markers | +| `--base-from-preview ` | push | Take this folder's base from your preview, while your preview is still source package ``. Only a `base_required` choice prints it (see [`base_required`](#base_required)) | +| `--force` | publish | Publish even though Live changed since your preview was based on it, or its base is unknown. Never publishes a preview pushed again since it was reviewed (`preview_replaced`) | +| `--expect-preview ` | publish | Publish only if your preview's source package is still `` (a dry run's `applyArgv` carries it) | +| `--release ` | merge | Merge release `` instead of Live | +| `--base ` | merge | The release this folder was copied from, when no base is recorded. A wrong one drops the changes made since then. | +| `--accept-live-base` | merge | Record Live (or `--release`) as this folder's base without merging, after merging by hand | +| `--out ` | pull | Folder to create (default `../-v`, next to the plugin folder) | +| `--owner ` | preview delete | Delete this user's preview instead of yours (a user id, as `versions` lists in `previews[].previewOwner.id`) | +| `--expect-updated ` | preview delete | Delete only if the preview's last update is still `` (a dry run's `applyArgv` carries it) | +| `--delete-app` | preview delete | Allow deleting the whole app when the preview is its only version (a dry run's `applyArgv` carries it only when it warned about that) | +| `--json` | all | Print one JSON result to stdout (forces headless); the human text goes to stderr | +| `--no-interactive` | all | Run headless even at a terminal (also `$APPBUILDER_NO_INTERACTIVE`) | +| `--env`, `--business`, `--user` | all | The session and saved account to use, as for [`block push`](#appbuilder-block-push) | + +Credentials work as for `block push`: the `appbuilder login` session by default, an API key only with `-c` or `--profile`. Business Apps need the Marketplace › Business App Developer permission at the Delete/All level; without it the commands fail with `forbidden` and say so in `hint`. + +#### Workflow + +1. `appbuilder app versions` shows where things stand. Its `next` is `merge` when Live moved past this folder's base, else `push` then `publish`. +2. `appbuilder app push` saves this folder as your preview. Other developers' previews and Live don't change. +3. `appbuilder app publish` makes your preview the next Live version (`nextReleaseAtLeast` in the dry run). Before promoting it, it checks that Live hasn't moved since your preview's base, both in the dry run and again just before the write. Your preview's base is read from your preview on the server (see [Moving to another machine](#moving-to-another-machine)), so it doesn't matter which folder or machine pushed it. +4. When someone else published in between, publish fails with `publish_conflict`: run `appbuilder app merge`, resolve any conflict markers, then push and publish again. + +The first push of an app that isn't on the business yet creates it (`published: false` in `versions` until then). A release needs a thumbnail; publishing a preview without one fails with `thumbnail_required`. + +#### Headless write gate and JSON results + +`push`, `publish`, `merge`, `rollback` and `preview delete` are dry runs headless unless `--yes` is passed, and `--yes` against `go` or `fmo` also needs `--allow-production` (else `production_requires_flag`). `merge` only writes local files, so it needs `--yes` but never `--allow-production`. `pull` writes only into a new folder and needs neither. `versions` is read-only. + +A dry run: + +```json +{ + "ok": true, + "applied": false, + "dryRun": true, + "reason": "no_yes", + "action": "push", + "environment": "staging", + "businessId": "b0c6…", + "userId": "7d1f…", + "app": { "baseApiName": "acme", "apiName": "acme_abc123", "name": "Acme", "liveVersion": "5" }, + "source": { "files": 12, "mode": "git", "bytes": 18342, "hash": "dfc2…" }, + "thumbnail": true, + "preview": { "exists": true, "updated": "2026-10-20T00:00:00.000Z" }, + "base": { "version": "5", "source": "recorded", "liveMoved": false }, + "conflictMarkers": [], + "notices": [], + "warnings": [], + "applyArgv": [ + "appbuilder", + "app", + "push", + "--yes", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ], + "requiresProductionConsent": false, + "next": [] +} +``` + +`applyArgv` is the command that applies what the dry run showed: the same command with `--yes`, plus `--allow-production` on `go` or `fmo`, pinned to the account the run signed in as (`--env`, `--business`, `--user`, or the `-c`/`--profile` it was given) so that a switch to another account meanwhile can't redirect it. A `publish` dry run pins the preview it reviewed (`--expect-preview `), a `merge` dry run the version it planned (`--release `, plus `--base ` when the base came from the flag or was detected), `rollback` its version, and `preview delete` the preview's owner (`--owner `, even for your own), the preview it reviewed (`--expect-updated `) and, only when it warned that the app goes too, `--delete-app`. A `push` apply uploads the folder as it is when it runs. `requiresProductionConsent` is `true` exactly when `applyArgv` carries `--allow-production`: an agent asks the user before running it. A dry run's `next` is empty, so the only way on is `applyArgv`. A push's `base.source` says where its base came from: `recorded` (this folder's state), `never_published` (no Live version, so none is needed), `detected` (the folder matches a release exactly), `preview` (it matches your preview exactly, and the base is your preview's, read from the server) or `base_from_preview` (`--base-from-preview`). A publish's `conflictCheck` compares your preview's base, read from its lineage on the server, with Live: `baseLiveVersion` and `baseSourcePackageId` are the lineage's, and `reason` says why it's a conflict. + +An applied write has `applied: true`, the command's results (`previewVersion`, `sourcePackageId` and `thumbnailId` for a push; `liveVersion` and `previousLiveVersion` for a publish; `status` and the file lists for a merge) and `next`, the commands that follow, each `{ "step", "argv" }`: a push is followed by a publish dry run (after the JS action link steps, when any action may need linking; see [`app actions`](#app-actions-linking-js-actions-to-objects)), a rollback by a publish dry run, a merge by push and publish dry runs. A merge that left conflict markers starts `next` with a step that has no command: + +```json +{ "step": "resolve", "files": ["src/a.js"], "markers": { "local": "HEAD", "incoming": "live" } } +``` + +`remembered` is `false` (with a warning) when `.kizenapp/apps.json` couldn't be written; the write itself still succeeded. + +A push adds the CLI's entries to `.gitignore` when they're missing and then zips the folder again, so the applied result's `source` describes the zip that was uploaded. If any file other than `.gitignore` changed since the plan (an edit made while the confirm was open, say), the push stops with `local_error` and uploads nothing; run it again to review the change. + +A publish that can't confirm that the release is the preview it reviewed (the preview was pushed again from elsewhere, or the check failed) still publishes, but says in `warnings` that this folder's base wasn't advanced. The next publish from this folder then stops with `publish_conflict`; merge, push and publish again. + +#### Failures and exit codes + +With `--json` a failure is `{ "ok": false, "code", "message", "hint"?, … }` on stdout. Exit `0` is success, dry runs included; exit `2` means the command stopped for a decision (`needs_choice` or `base_required`) and its `choices` say how to go on; every other failure exits `1`. Each choice carries `argv`, the whole command to run as given: `--json` and the run's account flags are already in it. + +```json +{ + "ok": false, + "code": "base_required", + "message": "No base version is recorded for this folder, and Live is v3. Merge Live into this folder, or record that it already contains Live, then push.", + "choice": "base", + "choices": [ + { + "value": "merge", + "label": "Preview merging Live v3 into this folder", + "args": [], + "argv": [ + "appbuilder", + "app", + "merge", + "--release", + "3", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ] + }, + { + "value": "accept", + "label": "This folder already contains Live v3", + "args": [], + "argv": [ + "appbuilder", + "app", + "merge", + "--release", + "3", + "--accept-live-base", + "--yes", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ] + } + ] +} +``` + +| Code | Meaning | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `needs_choice` | Signed in to several environments and no `--env`; each choice re-runs the command with one | +| `base_required` | No base is recorded for this folder and it doesn't match a release or your preview exactly (see below) | +| `publish_conflict` | Live moved since your preview's base, or that base was replaced (`conflict.reason: "base_replaced"`), or your preview's source doesn't say what it's based on (`"base_unknown"`). `next` is the `merge` dry run; a `push` dry run for `base_unknown`; or a new `publish` dry run when your preview changed since it was reviewed (`"preview_replaced"`: `--expect-preview` no longer matches, or it was pushed again between the plan and the promote, `--force` and the terminal confirm included) | +| `unresolved_conflicts` | Files still have merge conflict markers; `files` lists them. `push --force` pushes anyway. | +| `no_preview` | You have no preview to publish; push first | +| `preview_incomplete` | Your preview has no source package; push again | +| `preview_changed` | `preview delete`: the preview was pushed again since the dry run (`--expect-updated` no longer matches), or deleting it would delete the whole app without `--delete-app`; nothing was deleted. `next` is a new `preview delete` dry run | +| `thumbnail_required` | Your preview has no thumbnail, which a release needs; add one, push, then publish | +| `source_invalid` | The folder can't be zipped: no `kizen.json` in it, an unreadable `.kizenignore`, a folder the enclosing git repository ignores, or a file over 50 MiB (200 MiB in all) | +| `validation_failed` | The plugin failed validation; `issues` lists the problems | +| `forbidden` | Missing the Business App Developer permission (`hint`) | +| `production_requires_flag` | `--yes` against `go` / `fmo` without `--allow-production` | +| `not_signed_in` | No usable sign-in session; `login.argv` is the `appbuilder login` command for the user to run | +| `session_store_unwritable` | The session couldn't be saved to `~/.kizenappbuilder/oauth/` (usually an agent's sandbox); `hint` says how to allow it | +| `network_error` | Kizen couldn't be reached; `sandbox: "codex_network_disabled"` when Codex's sandbox has the network off | +| `credentials_invalid` | The `-c`/`--profile` API-key file is unusable | +| `auth_failed` | Kizen rejected the credentials | +| `not_found` | The version doesn't exist, the app isn't published to this business yet (`rollback`, `pull`, `merge`, `actions`, `preview delete`), there's no Live version to merge, no such JS action, object or link, or no such preview | +| `usage_error` | A bad argument, such as a version that isn't a release number, a `pull` folder that already exists, or `actions link`/`unlink` without `` or `` headless | +| `api_error`, `local_error` | Any other Kizen API failure, or a local one | + +#### `app actions`: linking JS actions to objects + +A JS action template does nothing until it's linked to an object. A link belongs to the app's install on the business and names the template by its `api_name`, so it survives new previews and releases. Create links with these commands (or the Kizen UI); an app's setup assistant may also have a step for it. + +`push`, `publish` and `versions` check the app's JS actions and warn about every one that may need linking, at a terminal and headless alike. With `--json` they carry the check as `jsActions`: + +```json +{ + "status": "checked", + "liveVersion": "5", + "actions": [ + { + "apiName": "hello_action", + "name": "Say hello", + "hintObjectName": "client_client", + "status": "linked", + "inLive": true, + "links": [ + { + "id": "8c1e…", + "objectId": "41aa…", + "objectName": "client_client", + "label": "Contacts", + "includePerformAction": true, + "unlinkArgv": [ + "appbuilder", + "app", + "actions", + "unlink", + "hello_action", + "41aa…", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ] + } + ], + "linkArgv": [ + "appbuilder", + "app", + "actions", + "link", + "hello_action", + "", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ], + "linkMenuArgv": [ + "appbuilder", + "app", + "actions", + "link", + "hello_action", + "", + "--menu", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ], + "linkNoMenuArgv": [ + "appbuilder", + "app", + "actions", + "link", + "hello_action", + "", + "--no-menu", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ] + }, + { + "apiName": "other_action", + "name": "Other", + "hintObjectName": "client_client", + "status": "unlinked", + "inLive": true, + "links": [], + "linkArgv": [ + "appbuilder", + "app", + "actions", + "link", + "other_action", + "", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ], + "linkMenuArgv": [ + "appbuilder", + "app", + "actions", + "link", + "other_action", + "", + "--menu", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ], + "linkNoMenuArgv": [ + "appbuilder", + "app", + "actions", + "link", + "other_action", + "", + "--no-menu", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ] + } + ], + "next": [ + { + "step": "actions", + "argv": [ + "appbuilder", + "app", + "actions", + "list", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ] + }, + { + "step": "link", + "argv": [ + "appbuilder", + "app", + "actions", + "link", + "other_action", + "client_client", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ] + } + ] +} +``` + +| Action `status` | Meaning | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `linked` | At least one link; `links` lists them, with the object and `includePerformAction` (whether it shows in the object's Perform Action menus; `null` when never set) | +| `unlinked` | In the Live version (or the app isn't on the business yet) with no link | +| `unknown` | Not in the Live version, only in a preview, so its links can't be listed. It may or may not be linked; link it like an `unlinked` one | + +Every action, wherever it's listed (`jsActions.actions` on `push`, `publish` and `versions`, and `actions` from `actions list`), carries the commands that act on it, pinned like every printed command to the account the run signed in as (`--env`, `--business`, `--user`, or its `-c`/`--profile`) and with `--json` when the run had it: `linkArgv`, `linkMenuArgv` (the same with `--menu`) and `linkNoMenuArgv` (with `--no-menu`, which also turns an existing link's menu off) are `link` dry runs whose one element `` is a placeholder. Replace exactly that element with an object's api name or id, and nothing else. Each listed link carries `unlinkArgv`, an `unlink` dry run naming its object by id. + +The check's own `status` is `checked`, `not_installed` (the app isn't on the business yet; nothing is linked), `no_live_version` (no links can be listed yet) or `failed` (a request failed: `error`, and a notice; the command itself still succeeds). `next` has an `actions list` step whenever an action may need linking (or the check failed), and a `link` dry run per `unlinked` action with a hint object. An `unknown` action gets no `link` step of its own: it may already be linked, so pick its object with the user. An applied push checks the app as the push left it: after a first push, the app is installed with only your preview, so the check is `no_live_version` and every action `unknown`. An applied push puts these steps first in its own `next`, before `publish`; an applied publish, and `versions`, end their own `next` with them (a dry run's `next` stays empty). + +Kizen lists only the links of templates in the install's Live version, which is why a preview-only action is `unknown`. Link one right after `appbuilder app push`, as you would a Live one; `link` notes that an existing link to that object can't be seen, and reports `already_linked` if there was one. On a Kizen environment that doesn't have the fix for linking preview-only actions (KZN-19521) yet, the link fails with `api_error` and a `hint` saying so: publish first, then link. + +`appbuilder app actions list` (read-only) returns `{ ok, environment, businessId, userId, app, published, status, actions, warnings, next }`: the actions of the Live version and your preview, with `next` holding the `link` dry runs. `link` and `unlink` are writes: headless dry runs without `--yes`, `--allow-production` on `go` and `fmo`, and a dry run's `applyArgv` names the object by id (and, for `link`, the menu setting it showed). `` is an object's api name (`client_client`), id, or display name ignoring case; Contacts counts. A name that matches several objects stops with exit 2 (`needs_choice`, `choice: "object"`), one choice per object by id. A `link` result has `jsAction`, `object`, `change` (`create`, `update` or `up_to_date`), `includePerformAction`, `existing` and, once applied, `status`: `created`, `updated`, `up_to_date` (nothing written), or `already_linked` (the server already had the link, and its menu setting was left alone: for an action only in a preview, a link made while its links can't be listed; for an action in Live, a link made after this command listed them, in which case run `appbuilder app actions list` and link it again to set the menu setting). A link belongs to the app's one install, shared by Live and every preview, and each user's version resolves it, so creating, removing or changing the menu setting of a link to an action in Live (`inLive: true`) changes what everyone using Live sees as soon as it's made; there's no preview of a link. The dry run (and the confirm at a terminal) warns about it in `warnings`, and so does the applied result. A preview-only action's link reaches only the users of a version that has the action, and a result that changed nothing (`up_to_date`, `already_linked`) carries no such warning. An `unlink` result has `jsAction`, `object`, `associationId` and `status: "unlinked"`; unlinking an action that's only in a preview fails with `not_found`, because its links can't be listed: publish it first. + +At a terminal, `link` and `unlink` work like the other writes (the plan, then a confirm; a typed `y` on production). Leave out `` or `` to pick them: `link` asks for the action (with its status), then the object (type to filter; the action's hint object starts highlighted, and objects that share a display name are told apart by api name), then whether it shows in the Perform Action menus unless `--menu` or `--no-menu` was passed; `unlink` asks which of the listed links to remove. Esc at a picker or `n` at the confirm cancels without writing, and so does Ctrl+C before the write starts. When `push`, `publish` or `versions` finds actions that may need linking, it offers "Link JS actions now", which runs `link`'s pickers right there. Headless, both arguments are required: a missing one is a `usage_error` naming it. + +#### `app preview delete` + +`appbuilder app preview delete` deletes your preview of the app, with every install of it and its OAuth connections. `--owner ` deletes another developer's preview instead (Kizen lets anyone with the Business App Developer permission do that); it takes a user id, not a name, and a user with no preview is `not_found`, with the previews in `hint`. It's a write like the others: a dry run headless without `--yes`, `--allow-production` on `go` and `fmo`, and at a terminal the plan and a confirm (a typed `y` on production). Esc or `n` at the confirm cancels, and so does Ctrl+C before the delete starts. + +Users who were using the preview switch to their own preview, or Live, and Kizen notifies them. When the preview is the app's only version (no release and no other preview), Kizen deletes the whole app from the business, with its settings and secrets. A dry run warns about each of these, and about deleting someone else's preview: + +```json +{ + "ok": true, + "applied": false, + "dryRun": true, + "reason": "no_yes", + "action": "preview delete", + "environment": "staging", + "businessId": "b0c6…", + "userId": "7d1f…", + "app": { "baseApiName": "acme", "apiName": "acme_abc123", "name": "Acme", "liveVersion": null }, + "owner": { "id": "7d1f…", "fullName": "Ada Lovelace", "yours": true }, + "preview": { + "updated": "2026-10-20T00:00:00.000Z", + "activeUsersCount": 2, + "installedCount": 3, + "otherActiveUsersCount": 1 + }, + "deletesApp": true, + "notices": [], + "warnings": [ + "This preview is the only version of Acme, so deleting it deletes the whole app from this business, with its settings and secrets.", + "1 other user is using this preview." + ], + "applyArgv": [ + "appbuilder", + "app", + "preview", + "delete", + "--owner", + "7d1f…", + "--expect-updated", + "2026-10-20T00:00:00.000Z", + "--delete-app", + "--yes", + "--env", + "staging", + "--business", + "b0c6…", + "--user", + "7d1f…", + "--json" + ], + "requiresProductionConsent": false, + "next": [] +} +``` + +`activeUsersCount` counts everyone using the preview, its owner included; `otherActiveUsersCount` leaves you out (the users the delete switches and notifies). Either is `null` when Kizen doesn't say. + +`applyArgv` reads the app again and refuses what the dry run didn't show: a preview that's gone is `not_found`; one pushed again since (its `updated` isn't `--expect-updated` any more) stops with `preview_changed`, and so does a delete that would now take the whole app with it when the dry run said the app stays (no `--delete-app`). A `--yes` run without `--delete-app` never deletes an app. At a terminal, deleting one takes a typed `y`, even with `--yes`, unless `--delete-app` is passed with it: `--yes --delete-app` is explicit consent and skips the confirm, as it does headless. When Kizen reports no last update for the preview (`preview.updated` is `null`), the dry run can't be pinned to it, so it has no `applyArgv` (and a notice says so). Nothing is deleted then. At a terminal the confirm re-checks the same way right before deleting. Kizen's delete takes no precondition, so a change in the moment between that last read and the delete itself can't be caught, as with `publish`'s promote. + +The applied result adds `status: "deleted"` and `appDeleted`: `true` when the app no longer exists on the business afterwards (read again after the delete), `null` with a warning when that read failed. When the dry run said the app goes but another version appeared before the delete, the app stays: `appDeleted` is `false`, and the result says so. Deleting your own preview also forgets it in `.kizenapp/apps.json` (this folder's base stays), and `next` is a `push` dry run; deleting someone else's changes nothing here, and `next` is empty. When the app is deleted with the preview (`appDeleted: true`), whoever's preview it was, every base recorded here for the app is forgotten too, since it named a version of an app that's gone. A base recorded for an app that's no longer on the business is ignored in any case: the next `push` says so and records the base afresh, as for a new app. + +#### Merging and conflict markers + +`appbuilder app merge` three-way merges Live (or `--release `) into this folder against the folder's recorded base, using `git` (it must be installed; the folder doesn't need to be a repository). Text conflicts get git's two-way markers: the side after `<<<<<<< HEAD` is this folder's file, the side before `>>>>>>> live` is the merged version's. The merge records which files have markers, and `app push` refuses with `unresolved_conflicts` until they're gone. An applied merge records the merged version as the folder's base, conflicts or not, so the next push is based on it. Files changed on both sides that aren't text keep your copy (`binaryConflicts`), files Live has but `.gitignore` or `.kizenignore` excludes here are not written (`ignoredIncoming`), and a file deleted on one side and edited on the other keeps the edit. + +When the merge can't run (no usable base, or no `git`), the result's `kind` is `fallback`: applying it extracts the version's source into a new folder under `.kizenapp/` so you can merge by hand, and its `choices` record a base and merge from it (`--base `) or record the version as already contained (`--accept-live-base`). + +#### `base_required` + +A folder with no recorded base (a fresh clone, or another developer's copy) can't be checked against Live. Its first `push` finds the base on its own when it can be sure of it: when the folder's files match your preview exactly, it takes your preview's base (`base.source: "preview"`; see below); otherwise, when they match Live or one of the latest releases exactly, that release (`"detected"`). Otherwise it stops with `base_required` and two choices: preview merging Live into the folder, or record that the folder already contains Live (`--accept-live-base`, only when that's true, or the next publish overwrites Live's changes). + +When you have a preview whose base the server records, there's a third choice, "This folder continues your preview (based on vN)": `appbuilder app push --base-from-preview `, a dry run that pushes this folder with your preview's base (`base.source: "base_from_preview"`). Pick it only when this folder really is your preview's source with more changes, for example the same work carried on from another machine; if the folder came from somewhere else, the next publish can overwrite Live's changes. It's never picked for you, `--yes` included. `` pins the preview the choice was offered for: if your preview is pushed again in the meantime, the push doesn't take its base (a notice says so) and goes on as if the flag weren't given, so it can stop with `base_required` again. + +With no base to merge from, the merge preview always comes back as a `fallback`. Its choices merge from the release the folder was copied from (`--base `, one choice per recent release) or record the version as already contained. If you know which release the folder came from, run that `--base` choice: it merges like any other merge. A wrong one drops the changes made since then. If you don't know, apply the fallback: it extracts Live's source into `.kizenapp/live-v-