agentful 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/dist/index.cjs +659 -56
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@ npm i -g agentful
|
|
|
13
13
|
```bash
|
|
14
14
|
agentful login # once: confirm the code in your browser
|
|
15
15
|
agentful init # create or link the cloud project
|
|
16
|
-
agentful
|
|
16
|
+
agentful --model agentful/azure-gpt-5_4 --variant high
|
|
17
|
+
# start with an exact, visible model pin
|
|
17
18
|
agentful push # upload, cloud build, preview URL
|
|
18
19
|
agentful open # open the preview
|
|
19
20
|
```
|
|
@@ -32,7 +33,8 @@ agentful open # open the preview
|
|
|
32
33
|
| `models [--add-local]` | Show available models, manage your own providers |
|
|
33
34
|
| `upgrade` | Update the CLI to the latest published version |
|
|
34
35
|
| `licenses` | Licences of the software shipped with this CLI |
|
|
35
|
-
| `-s, --session <id>` | Resume a previous coding session |
|
|
36
|
+
| `-s, --session <id>` | Resume a previous coding session without changing its model |
|
|
37
|
+
| `-m, --model <provider/model> [--variant <name>]` | Pin an exact engine model and optional provider-specific variant (`agentful` and `agentful tui`) |
|
|
36
38
|
|
|
37
39
|
Inside the interface, `/` lists the Agentful commands (`/setup`, `/push`,
|
|
38
40
|
`/preview`, `/publish`, `/share`, `/status`) alongside the built-in ones.
|
|
@@ -78,6 +80,43 @@ license; `agentful licenses` shows the notice we are required to ship.
|
|
|
78
80
|
`~/.config/agentful/local-providers.json`. This file belongs to the user: the
|
|
79
81
|
CLI merges it into the engine config but never rewrites it. These requests go
|
|
80
82
|
straight to the provider — no credits, no platform region guarantee, no usage record.
|
|
83
|
+
- **Direct TUI connections** — provider connections created in the engine's
|
|
84
|
+
`/models` dialog. Their exact models come from the pinned engine's own catalog,
|
|
85
|
+
so the CLI preflight and the dialog no longer maintain conflicting inventories.
|
|
86
|
+
Requests go directly to that provider and use its billing and terms, outside
|
|
87
|
+
Agentful credits and region controls.
|
|
88
|
+
|
|
89
|
+
The start resolver is deterministic: explicit CLI flag > resumed session >
|
|
90
|
+
optional project pin > platform default. A project pin lives at
|
|
91
|
+
`.agentful/model.json`:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"model": "zai-coding-plan/glm-5.2",
|
|
96
|
+
"variant": "high"
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Model IDs are canonical engine IDs and are matched exactly against
|
|
101
|
+
`agentful-engine models <provider>` after the final session config is written.
|
|
102
|
+
An unknown model or an unconnected direct provider fails before the TUI starts.
|
|
103
|
+
The CLI prints the chosen model, variant, source, billing path and data path.
|
|
104
|
+
For a resume without `--model`, the session's persisted provider/model/variant
|
|
105
|
+
wins; an unreadable session model is left to the engine and is never silently
|
|
106
|
+
replaced with the project or platform default.
|
|
107
|
+
|
|
108
|
+
## Stream liveness and safe recovery
|
|
109
|
+
|
|
110
|
+
Each TUI starts its engine API on a random `127.0.0.1` port and observes
|
|
111
|
+
`/global/event`. Provider/status, token/reasoning, tool, question and idle
|
|
112
|
+
events are classified separately. A running tool or pending user question
|
|
113
|
+
suspends the silence timer.
|
|
114
|
+
|
|
115
|
+
- after 60 seconds without a real progress event, the TUI shows a warning;
|
|
116
|
+
- after 120 seconds it tells the user how to stop and offers recovery after exit;
|
|
117
|
+
- no timeout changes the model, sends a prompt or starts a retry;
|
|
118
|
+
- recovery requires an explicit `y` and only reopens the same session. It does
|
|
119
|
+
not continue automatically, so completed tool calls are not replayed by the CLI.
|
|
81
120
|
|
|
82
121
|
Local providers are governed by the organization's BYOK policy (`/api/org/me`):
|
|
83
122
|
`disabled` blocks them, `provider_allowlist` permits only the listed provider ids,
|
package/dist/index.cjs
CHANGED
|
@@ -3044,7 +3044,7 @@ var VERSION, brand, useColor, wrap, paint, sym, ui;
|
|
|
3044
3044
|
var init_branding = __esm({
|
|
3045
3045
|
"src/branding.ts"() {
|
|
3046
3046
|
"use strict";
|
|
3047
|
-
VERSION = "0.3.
|
|
3047
|
+
VERSION = "0.3.6" ? "0.3.6" : null.version;
|
|
3048
3048
|
brand = {
|
|
3049
3049
|
name: "agentful",
|
|
3050
3050
|
// the command users type
|
|
@@ -3591,7 +3591,7 @@ var ApiError = class extends Error {
|
|
|
3591
3591
|
};
|
|
3592
3592
|
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
|
|
3593
3593
|
var DEFAULT_RETRY = {
|
|
3594
|
-
attempts:
|
|
3594
|
+
attempts: 6,
|
|
3595
3595
|
delayMs: 3e4
|
|
3596
3596
|
};
|
|
3597
3597
|
function isRetryableStatus(status) {
|
|
@@ -6322,8 +6322,9 @@ async function openCommand() {
|
|
|
6322
6322
|
}
|
|
6323
6323
|
|
|
6324
6324
|
// src/commands/tui.ts
|
|
6325
|
-
var
|
|
6326
|
-
var
|
|
6325
|
+
var import_node_child_process6 = require("child_process");
|
|
6326
|
+
var import_promises4 = require("readline/promises");
|
|
6327
|
+
var import_node_path20 = require("path");
|
|
6327
6328
|
|
|
6328
6329
|
// src/lib/binWrapper.ts
|
|
6329
6330
|
var import_node_fs8 = require("fs");
|
|
@@ -6903,7 +6904,7 @@ function buildEngineConfig(opts) {
|
|
|
6903
6904
|
const models = {};
|
|
6904
6905
|
for (const entry of opts.catalog.models) models[entry.id] = { name: entry.name };
|
|
6905
6906
|
return {
|
|
6906
|
-
model: `${PROVIDER_ID}/${opts.catalog.defaultModel}`,
|
|
6907
|
+
model: opts.model || `${PROVIDER_ID}/${opts.catalog.defaultModel}`,
|
|
6907
6908
|
permission: { imagegen: opts.imagegenAvailable ? "allow" : "deny" },
|
|
6908
6909
|
theme: "agentful",
|
|
6909
6910
|
// We gate engine versions through our own manifest — the engine must not
|
|
@@ -6968,7 +6969,7 @@ async function resolveTheme() {
|
|
|
6968
6969
|
}
|
|
6969
6970
|
return agentful_theme_default;
|
|
6970
6971
|
}
|
|
6971
|
-
async function writeEngineSession(session, catalog, localProviders = {}, framework = "unknown", skillsInstruction = null, backendState) {
|
|
6972
|
+
async function writeEngineSession(session, catalog, localProviders = {}, framework = "unknown", skillsInstruction = null, backendState, model) {
|
|
6972
6973
|
const xdg = engineXdg();
|
|
6973
6974
|
const configDir2 = (0, import_node_path14.join)(xdg.configHome, "opencode");
|
|
6974
6975
|
const dataDir = (0, import_node_path14.join)(xdg.dataHome, "opencode");
|
|
@@ -6994,7 +6995,8 @@ async function writeEngineSession(session, catalog, localProviders = {}, framewo
|
|
|
6994
6995
|
catalog,
|
|
6995
6996
|
localProviders,
|
|
6996
6997
|
imagegenAvailable,
|
|
6997
|
-
instructionPaths
|
|
6998
|
+
instructionPaths,
|
|
6999
|
+
model
|
|
6998
7000
|
});
|
|
6999
7001
|
(0, import_node_fs12.writeFileSync)((0, import_node_path14.join)(configDir2, "config.json"), JSON.stringify(config, null, 2));
|
|
7000
7002
|
const authPath2 = (0, import_node_path14.join)(dataDir, "auth.json");
|
|
@@ -7029,7 +7031,7 @@ var skills_default = {
|
|
|
7029
7031
|
schema_version: 1,
|
|
7030
7032
|
skills: {
|
|
7031
7033
|
"agentful-template-contract": "---\nname: agentful-template-contract\ndescription: Agentful generated-project and template contract for static preview/publish compatibility, scaffold-safe file structure, host-agnostic assets, backend honesty, and no unsupported dependencies. Use when creating a new project, customizing a scaffold/template, editing generated app structure, or fixing preview/publish issues.\n---\n\n# Agentful Template Contract\n\n## Contract\n\nGenerate and edit projects for Agentful's existing platform:\n\n- Static preview and publish serve built files from S3/CloudFront/Cloudflare.\n- Package projects must build to `dist/`, `out/`, or `build/`.\n- Vanilla projects must work without a package install or build step.\n- Managed database and managed actions are configured in the Backend tab, not invented in code.\n\nDo not add server-first framework runtime dependencies (for example Remix / `@remix-run/*`, or a Next.js server). A referenced example repo may inform structure or styling, but this platform serves static builds and does not run those servers.\n\n## File Shape\n\n- Preserve the current stack and file layout unless the user explicitly asks for a migration.\n- For empty workspaces, follow the loaded scaffold skill exactly.\n- Put route- or feature-local UI near the feature. Create shared folders only after real reuse exists.\n- Avoid dumping grounds such as `helpers`, `misc`, or broad `lib` folders when ownership is clear.\n- Keep generated documentation short and accurate; do not describe features that do not exist.\n\n## Hosting Rules\n\n- Asset paths for project-owned files: SPAs with a client-side (history-mode)\n router use root-absolute paths (`base: '/'`, `/assets/...`); router-less or\n multi-page projects use relative paths.\n- Use `/api/...` only for platform runtime APIs.\n- Do not hardcode `mainmvp.com`, `agentful.dev`, preview domains, or user subdomains into app code.\n- Do not add a `<base>` tag.\n- For canonical, Open Graph, sitemap, and manifest URLs, use relative URLs or omit the origin.\n- For SPAs on static hosting, use history-mode routing with `base: '/'` (never\n hash routing) \u2014 the platform falls unknown deep-links back to `index.html`, so\n routes resolve on hard refresh with clean URLs (no `#`).\n\n## Backend Honesty\n\n- When a backend is configured (`status: active`), use it for real data, auth, and actions.\n- When no backend is configured, still build the requested UI from clearly-labeled sample/demo data (one replaceable module). Do not refuse or stop \u2014 surface the Backend-tab note in your final response instead.\n- Sample data must read as sample. Do NOT fake auth that \"logs in\", saves that claim to persist across reloads, or payment/webhook flows that pretend to fire \u2014 those mislead the user. Presentation data (example metrics, sample listings) is fine; a fake real-backend contract is not.\n- Never store secret keys in frontend code, templates, `.env`, or committed files.\n- Public client keys may be placeholders only when the target integration actually uses public keys.\n\n## Content Honesty\n\nDo not invent:\n\n- customer logos, testimonials, reviews, awards, revenue, user counts, certifications, compliance claims, or legal assurances\n- real prices, policies, medical/financial claims, or guarantees unless the user supplies them\n\nUse neutral placeholder copy or proof-ready sections instead.\n\n## Before Finishing\n\n- Verify imports, references, asset paths, and routes are defined.\n- Run the relevant build when a build script exists.\n- Confirm the expected output folder contains an `index.html`.\n- For UI work, include responsive behavior and basic loading, empty, error, and success states where the feature implies them.\n",
|
|
7032
|
-
"agentful-managed-db": "---\nname: agentful-managed-db\ndescription: Managed Database protocol for `database.mode == managed`. Covers schema upsert, CRUD against `/api/p/{PROJECT_ID}/data/*` and `/api/p/{PROJECT_ID}/auth/*`, error-code remediation, and per-framework client patterns (Vue, React, Svelte, SvelteKit, Astro, Vanilla). Load when the backend preamble shows `Database: managed`.\n---\n\n## When To Use\n\nLoad this skill **only** when `agentful-backend-state` reports `database.mode: \"managed\"` with `status: \"active\"`. Do not load it for `byo` (Supabase / custom server) or when the database is not configured.\n\n## Hard Rules\n\n1. **Collections do not auto-create.** Writes to a non-existent collection return 404 with `error.code: not_found`. For every collection your code reads or writes, if it is not listed in the backend preamble's `Managed collections` block, you MUST run `agentful-managed-collections upsert <project_id> '<json>'` BEFORE writing the code that touches it.\n2. **Never wrap data-API calls in a swallow-all `try/catch`.** Swallowing masks 4xx errors and produces apps that look-fine-but-write-nothing.\n3. **Never set a `seeded` flag unless every write returned 201.** Partial-success seeds drift state silently.\n4. **Do not call this a \"server\".** It is a managed database behind a gateway. Use \"the database\" when talking to the user.\n5. **Do not target `/data/_collections`** from generated code. That's the owner-only schema endpoint; use the `agentful-managed-collections` CLI for schema work.\n6. **On 5xx, do not speculate.** Surface `error.correlation_id` to the user verbatim and stop. Do not invent internal causes (DynamoDB, operators, system collections, etc.).\n\n## Authoring Protocol \u2014 for every collection touch\n\nRun, in order:\n\n1. **Read the preamble.** The `[BACKEND STATUS]` block lists existing `Managed collections` with their fields and access rules. If your target collection is there with the right shape, skip to step 3.\n2. **Upsert if missing or schema mismatch:**\n ```\n agentful-managed-collections upsert <project_id> '{\"name\":\"todos\",\"access_rule\":\"owner\",\"schema\":{\"fields\":[{\"name\":\"title\",\"type\":\"string\",\"required\":true},{\"name\":\"done\",\"type\":\"boolean\"}]}}'\n ```\n Field types: `string`, `text`, `number`, `boolean`, `select` (with `options`), `date` (ISO 8601 calendar date `YYYY-MM-DD`), `datetime` (ISO 8601 string), `json` (object or array). Any other type is rejected by the platform (400 with the allowed list).\n Access rules: `public` (anyone), `authenticated` (any logged-in end-user), `owner` (only `created_by` user), `admin` (only end-users whose `_users.role` is `admin`).\n Every successful upsert is also DECLARED: the command mirrors the collection (as the platform stored it) into `.agentful/backend.json` in the workspace and answers `\"declaration\": \".agentful/backend.json\"`. That file is the project's backend declaration \u2014 it travels with the code (a local `agentful push` applies it again, idempotently) and the Backend tab shows it. Do not edit it to claim a collection exists; the upsert output is the only confirmation. Never commit it to `.gitignore`.\n3. **Write the client code** using the patterns below. Use the EXACT field names from the schema. Do not invent fields.\n4. **Test the happy path** by inspecting the response. Real 201 / 200, not a swallowed error.\n\n## Choosing An Access Rule\n\n`access_rule` is set per collection at upsert time and enforced on every end-user\n(`/data/*`) request. There are exactly four rules \u2014 pick by use case:\n\n| Use case | Rule | Why |\n|---|---|---|\n| Content anyone may read/write without login (public poll, guestbook) | `public` | No JWT required. |\n| Public content the app seeds once and the UI only reads | `public` | Seed at build time; clients read only. |\n| Shared data **every** logged-in user may read AND edit (team wiki, shared catalog) | `authenticated` | Any valid end-user JWT passes. **No per-row owner check.** |\n| Per-user private data (todos, drafts, a user's own orders) | `owner` | Only the `created_by` end-user can read/update/delete each doc. |\n| Data only the app's admins may read/write (moderation queues, settings) | `admin` | Only end-users whose `_users.role` is `admin` (owner-set, see First Admin below). |\n| Per-user data an **admin must also access** (invoices, tickets, client records) | `owner` + admin via Action | `owner` protects the client; admin reads/writes through a Managed Action. See RBAC below. |\n| A field only the server may set (`role`, `plan`, `verified`, `balance`) | `owner`, with that field written **only** via an Action | No field-level rules exist \u2014 gate the whole mutation behind an Action. |\n\n**Two traps to design around:**\n\n1. **`authenticated` is NOT per-user isolation.** It means *every* logged-in\n user can read and write *all* documents in the collection. For \"each user\n sees only their own\", use `owner`.\n2. **There is no combined `owner_or_admin` rule and no role concept in the data\n layer.** A multi-role portal (admin / member / client) cannot be expressed by\n `access_rule` alone. The supported pattern is `owner` + a Managed Action that\n verifies the caller \u2014 see **RBAC & Secure Role Assignment** under Managed\n Actions. (Requires `server.mode == managed`.)\n\n## API Surface\n\nBase: `/api/p/{PROJECT_ID}/`\n\n**Auth (end-user):**\n- `POST auth/register` \u2014 `{email, password, display_name?}`. Two response shapes:\n - Verification pipeline ACTIVE (project has `config.auth`, default): `201 {verification_required:true, user:{...}}` \u2014 **NO token yet**; the user must confirm their email first (mail is sent automatically).\n - Legacy project (no `config.auth`) or `require_verified_login:false`: `201 {token, verification_required:false, user:{...}}`.\n- `POST auth/login` \u2014 `{email, password}` \u2192 `{token, user:{...}}`. Blocks with `403 email_unverified` when the project requires verified logins and the account is not verified yet \u2192 show a \"check your inbox\" state with a resend button.\n- `GET auth/me` \u2014 Bearer token \u2192 `{user:{...}}`\n- `POST auth/verify` \u2014 `{uid, token}` (from the mail link) \u2192 `{token, user}` (auto-login after verification).\n- `POST auth/resend-verification` \u2014 `{email}` \u2192 always `200 {sent:true}` (no user enumeration).\n- `POST auth/request-password-reset` \u2014 `{email}` \u2192 always `200 {sent:true}` (no user enumeration).\n- `POST auth/reset-password` \u2014 `{uid, token, password}` \u2192 `200 {reset:true}`. Also marks the mailbox verified.\n\n`user` shape: `{id, email, display_name, role, verified, provider, created_at}`.\n\n**Auth mail links:** verification/reset mails link to the deployed app as\n`{app_url}/?ta_action=verify&uid=\u2026&token=\u2026` and `{app_url}/?ta_action=reset&uid=\u2026&token=\u2026`.\n**Every generated app with auth MUST handle these two query params on load**\n(see Client Patterns).\n\n**Login methods governance:** offer ONLY the login methods the project's\n`config.auth.methods` allows (check with `agentful-auth-config get`;\ndefault `[\"email\"]`). Do NOT generate \"Sign in with Google\"/SSO buttons unless\n`google`/`oidc` is listed \u2014 the platform refuses unlisted methods server-side.\n\n**Google login (when `google` IS listed):** a \"Continue with Google\" button\ncalls `googleAuth.start()` (see Client Patterns) \u2192 central broker\n`api.agentful.dev/auth/oauth/google/start` \u2192 Google \u2192 back to the app with the\nJWT in the URL fragment; call `handleGoogleReturn()` at startup to complete\nthe login. Google users arrive `verified: true` (Google verified the mailbox),\nexisting email accounts with the same address are linked automatically, and\nthe `admin_email` bootstrap applies. Show `auth_error` codes as a friendly\nmessage (`auth_method_not_allowed` \u2192 \"Google login is not available for this\napp\"); never retry in a loop.\n\n**Data:**\n- `GET data/{collection}` \u2014 list (paginated; `?limit=`, `?cursor=`)\n- `GET data/{collection}/{docId}` \u2014 single doc\n- `POST data/{collection}` \u2014 body `{data: {...}}` \u2192 `{ok:true, data:{doc_id, collection, data, created_at}}`\n- `PUT data/{collection}/{docId}` \u2014 body `{data: {...}}` \u2192 updated doc\n- `DELETE data/{collection}/{docId}` \u2192 `{ok:true, data:{deleted, collection}}`\n\nEnd-user routes (above) require `Authorization: Bearer <token>` from `auth/register` or `auth/login`. The collection's `access_rule` enforces what each token may read/write.\n\n## Response Shapes\n\n**Success:** `{ \"ok\": true, \"data\": {...} }` \u2014 `data` for lists has `{documents:[...], count, cursor}`.\n\n**Error:** `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"correlation_id\"?: \"...\" } }`\n\nStatus codes are HTTP-conventional (201 on create, 200 on read/update/delete, 4xx for client errors, 5xx for platform).\n\n**Actions are the one exception:** `POST \u2026/actions/{name}` returns `{ \"ok\": true, \"data\": <whatever your action returned> }` with HTTP 200 whenever the action *ran to completion*. The outer `ok` only says \"the platform executed it\" \u2014 your own `{ error: '\u2026' }` return lands verbatim inside `data`, it does NOT flip the outer `ok` and cannot set the HTTP status (only a thrown error becomes `ok:false` / `runtime_error` 500). The client must therefore check `body.data.error` after the envelope check (see `callAction` below).\n\n## Error Code \u2192 Remediation\n\n| `error.code` | HTTP | What it means | What to do |\n|---|---|---|---|\n| `not_found` | 404 | Collection doesn't exist, OR doc id doesn't exist | If collection: run `agentful-managed-collections upsert` then retry. If doc: surface to user. |\n| `auth_required` | 401 | `/data/*` on a non-`public` collection without a `Authorization: Bearer` token | Send the end-user to login; do not retry without a token. |\n| `invalid_token` | 401 | The Bearer token on `/data/*` is invalid or expired (`wrong_project` 403 = token from another project) | Clear the stored token and show the login state. |\n| `email_unverified` | 403 | Login blocked until the user confirms their email | Show \"confirm your email\" state + resend button (`auth/resend-verification`). |\n| `invalid_token` | 400 | Verification/reset link invalid, expired, or already used | Offer resend (`resend-verification`) or a new reset request. |\n| `too_many_attempts` | 429 | Auth rate limit hit (failed logins / mail requests) | Tell the user to wait a few minutes; do not auto-retry. |\n| `email_infra_unconfigured` | 409 | Project has no email infrastructure selected | Tell the BUILDER (not the end-user): choose \"Agentful Email\" or BYO in project settings \u2192 Email infrastructure. |\n| `email_delivery_unavailable` | 409 | The app has no published URL yet \u2014 verification/reset mails need a deployed app to link to (the preview URL does not count) | Platform state, not a code bug: tell the BUILDER to publish the app; show the end-user \"sign-up by email is not available yet\". Google login works on the preview already. Never treat as transient; never retry in a loop. |\n| `email_send_failed` | 502 | Mail transport failed transiently | Tell the user to try again later. |\n| `schema_validation_failed` | 400 | Payload doesn't match the collection's schema | Re-read the schema in the preamble; fix field names/types/required-ness; do not retry blindly. |\n| `readonly_collection` | 403 | Collection or doc is system-protected (e.g. `_users` via end-user route) | Use the correct route (e.g. `auth/register` for `_users`); do not retry. |\n| `forbidden` | 403 | Access rule denied this end-user | Tell the user they need to log in / lack permission. |\n| `already_exists` | 409 | `doc_id` collision or conditional check failed | Let `doc_id` auto-generate (omit it). |\n| `too_large` | 413 | Document > 256 KB | Split or trim payload. |\n| `quota_exceeded` | 429 | DDB throttling / request-limit spillover (transient) | The doctor returns `action: retry_with_backoff`. Sleep + retry up to 3 times per `details.backoff_ms`. **Surface `correlation_id` to the user ONLY after all attempts exhaust.** |\n| `transient_storage_error` | 503 | DDB internal / service-unavailable (transient) | Same as `quota_exceeded`: doctor returns `retry_with_backoff`; engine handles silently until budget exhausts. |\n| `internal_storage_error` / `write_failed` / `delete_failed` | 500 | Platform-side error, NOT classified retryable | Delegate to `@agentful-managed-db-doctor` with `correlation_id`. Surface its `user_message` verbatim. Do NOT retry in a loop. Do NOT invent a root cause. |\n\n## Client Patterns\n\nA tiny client used everywhere. Define once per project; reuse for all collections.\n\n### Vanilla / shared base\n\n```js\n// src/lib/data.js\nconst BASE = `/api/p/${PROJECT_ID}`; // set PROJECT_ID at build time\nconst tokenKey = 'mm_auth_token';\n\nfunction authHeader() {\n const t = localStorage.getItem(tokenKey);\n return t ? { Authorization: `Bearer ${t}` } : {};\n}\n\nasync function jsonOrThrow(res) {\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.ok) {\n const err = new Error(body.error?.message || `HTTP ${res.status}`);\n err.code = body.error?.code;\n err.correlation_id = body.error?.correlation_id;\n err.status = res.status;\n throw err;\n }\n return body.data;\n}\n\nexport const auth = {\n async register(email, password, display_name) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/register`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password, display_name }),\n }));\n // verification_required \u2192 NO token yet; caller must show \"check your inbox\".\n if (data.token) localStorage.setItem(tokenKey, data.token);\n return data; // {verification_required, user, token?}\n },\n async login(email, password) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/login`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n }));\n localStorage.setItem(tokenKey, data.token);\n return data.user;\n },\n async verify(uid, token) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/verify`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ uid, token }),\n }));\n localStorage.setItem(tokenKey, data.token); // auto-login after verify\n return data.user;\n },\n async resendVerification(email) {\n return jsonOrThrow(await fetch(`${BASE}/auth/resend-verification`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email }),\n }));\n },\n async requestPasswordReset(email) {\n return jsonOrThrow(await fetch(`${BASE}/auth/request-password-reset`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email }),\n }));\n },\n async resetPassword(uid, token, password) {\n return jsonOrThrow(await fetch(`${BASE}/auth/reset-password`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ uid, token, password }),\n }));\n },\n logout() { localStorage.removeItem(tokenKey); },\n token() { return localStorage.getItem(tokenKey); },\n};\n\n// Google login (ONLY when 'google' \u2208 config.auth.methods \u2014 never generate\n// this button otherwise; the platform refuses unlisted methods server-side).\n// Redirects to the central Agentful OAuth broker; after Google consent the\n// broker 302s back to `redirect` with the app JWT in the URL FRAGMENT:\n// https://<your-app>/#token=<jwt>&provider=google (or #auth_error=<code>)\nexport const googleAuth = {\n start(redirect = location.origin + '/') {\n const url = new URL('https://api.agentful.dev/auth/oauth/google/start');\n url.searchParams.set('project_id', PROJECT_ID);\n url.searchParams.set('redirect', redirect); // must be THIS app's https origin\n location.href = url.toString();\n },\n};\n\n// REQUIRED whenever the Google button is generated: pick up the broker return\n// on app load (fragment token \u2192 login; auth_error \u2192 user-visible message).\nexport function handleGoogleReturn() {\n const h = new URLSearchParams(location.hash.slice(1));\n const token = h.get('token'), err = h.get('auth_error');\n if (!token && !err) return null;\n history.replaceState(null, '', location.pathname + location.search); // strip token from URL\n if (err) return { ok: false, error: err }; // e.g. auth_method_not_allowed, oauth_failed\n localStorage.setItem(tokenKey, token);\n return { ok: true, provider: h.get('provider') || 'google' };\n}\n\n// REQUIRED in every app with auth: handle the mail links on app load.\n// Call once at startup (before router init is fine).\nexport async function handleAuthMailAction() {\n const p = new URLSearchParams(location.search);\n const action = p.get('ta_action'), uid = p.get('uid'), token = p.get('token');\n if (!action || !uid || !token) return null;\n history.replaceState(null, '', location.pathname); // strip token from URL\n if (action === 'verify') {\n try { const user = await auth.verify(uid, token); return { action, ok: true, user }; }\n catch (e) { return { action, ok: false, error: e.code || 'invalid_token' }; }\n }\n if (action === 'reset') return { action, ok: true, uid, token }; // show new-password form, then auth.resetPassword(uid, token, pw)\n return null;\n}\n\nexport const data = {\n async list(collection, opts = {}) {\n const qs = new URLSearchParams(opts).toString();\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}${qs ? '?' + qs : ''}`, {\n headers: { ...authHeader() },\n }));\n },\n async get(collection, docId) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n headers: { ...authHeader() },\n }));\n },\n async create(collection, data) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ data }),\n }));\n },\n async update(collection, docId, data) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ data }),\n }));\n },\n async remove(collection, docId) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n method: 'DELETE',\n headers: { ...authHeader() },\n }));\n },\n};\n```\n\n### React / Vue / Svelte\n\nUse the same `data.js` / `data.ts` module above; wrap in framework-native state primitives.\n\n- **React:** call from `useEffect` for reads, `useState` for results; surface `err.code`/`err.correlation_id` to the user when caught. Do not put data calls in render bodies.\n- **Vue:** use `onMounted` for reads and a `ref()` for results; same error surfacing.\n- **Svelte / SvelteKit:** call from `onMount` (or a `load` function in SvelteKit); SvelteKit static-adapter projects must NOT use server `load` (no Node runtime in static deploy).\n- **Astro:** call only from client-side islands; no server fetch (static-only build).\n\n### TypeScript types\n\nGenerate the per-collection type from the preamble's field list:\n\n```ts\n// Example for a collection with fields: title:string*, done:boolean\ntype Todo = { title: string; done?: boolean };\n\n// And response wrappers:\ntype DataDoc<T> = { doc_id: string; collection: string; data: T; created_at: string };\ntype DataList<T> = { documents: DataDoc<T>[]; count: number; cursor?: string };\n```\n\n## Anti-patterns\n\n- \u274C `try { await data.create(...) } catch { /* ignore */ }` \u2014 masks failures.\n- \u274C Hardcoding `doc_id` for \"convenience\" \u2014 causes `already_exists` 409 on retry.\n- \u274C Writing to a collection name that doesn't appear in the preamble \u2014 `not_found` 404.\n- \u274C Using the schema endpoint `/data/_collections` from client code \u2014 owner-only, end-users get 403.\n- \u274C Storing the JWT anywhere other than `localStorage` under a project-scoped key; do not put it in cookies (CORS) or in `sessionStorage` (lost on tab close).\n- \u274C Telling the user \"the database is down\" because of a 5xx. Surface the `correlation_id` and stop.\n- \u274C Surfacing `correlation_id` on transient errors (`quota_exceeded`, `transient_storage_error`) before the doctor's `retry_with_backoff` budget is exhausted. The whole point is the user sees nothing while the retry loop is in play; only escalate after all `max_attempts` fail.\n\n## Diagnostic delegation\n\nIf you hit a 5xx that doesn't map to a retry-able 4xx in the table above, do not diagnose yourself. Delegate to `@agentful-managed-db-doctor` (added in PR 1.3) with the `correlation_id` from the response. The doctor has constrained tools and cannot fabricate platform internals.\n\n---\n\n## Managed Actions (only when `server.mode == managed`)\n\nManaged Actions are small Node.js functions the platform runs for the project at `/api/p/{PROJECT_ID}/actions/{name}`. Use them when a frontend operation needs (a) a secret API key, (b) a non-public/secured external API, or (c) server-enforced trust (price computation, signature verification, admin actions). For pure CRUD against the managed DB, use the data API directly; do NOT route everything through an action.\n\n### Hard rules (Managed Actions)\n\n1. **Upsert FIRST, fetch SECOND.** If your generated code calls `fetch('/api/p/.../actions/{name}')`, you MUST upsert the action via the CLI BEFORE writing the fetch call. An undeployed action returns `404 action_not_found`; the preamble's `Managed actions` list is the ground truth for what exists.\n2. **Only `ctx.fetch`, `ctx.data`, `ctx.secrets`, `ctx.user`, `ctx.body` and approved npm packages.** No `require('fs')`, `require('child_process')`, `require('http')`, `require('https')`, `require('net')`, `require('os')`, `require('path')`, `require('process')`, `require('vm')`, `require('cluster')`, `require('worker_threads')`. The validator rejects these at upload time with `{code: 'action_validation_failed', reason: 'disallowed_require'}`.\n3. **Secrets are read with `await ctx.secrets.get('<name>')` \u2014 never property access.** `ctx.secrets` has exactly one method, `get(key)`. `ctx.secrets.SOME_KEY` is silently `undefined`. Secret names must match `^(database|server)\\.[a-z][a-z0-9_]{0,126}$` \u2014 use `server.stripe_secret_key`, not `STRIPE_SECRET_KEY` (uppercase, unprefixed names cannot even be stored in Backend \u2192 Secrets).\n4. **Actions are publicly invokable \u2014 authorize the caller yourself.** `ctx.user` is the JWT-verified end-user (`{ id, email, pid }`) or `null`. Any action that touches a secret, writes data, or returns non-public information must start with a `ctx.user` check; nothing else gates who can call it.\n5. **Outbound calls from inside an action must use `ctx.fetch`**, not a bare `fetch`. `ctx.fetch` injects `X-Action-Depth` on self-action URLs so the platform's loop detector can break runaway recursion. External URLs are passed through unchanged.\n6. **Cost shape.** 5 s timeout, 256 MB RAM, 1 MB request body, per-project concurrent invocations capped at 10. Plan for `429 concurrency_exceeded` under load and surface it to the user gracefully (e.g. retry with backoff or \"try again in a moment\").\n\n### Action shape\n\n```js\n// .server/actions/checkout.js\nmodule.exports = async function (ctx) {\n // ctx.body \u2014 parsed JSON request body\n // ctx.user \u2014 JWT-verified end-user { id, email, pid }, or null if not logged in\n // ctx.data \u2014 same CRUD surface as the public data API, scoped to this project\n // ctx.secrets \u2014 async accessor for Backend \u2192 Secrets: await ctx.secrets.get('server.stripe_secret_key')\n // ctx.fetch \u2014 depth-aware fetch wrapper\n if (!ctx.user) return { error: 'auth_required' };\n const apiKey = await ctx.secrets.get('server.stripe_secret_key');\n if (!apiKey) return { error: 'missing_server_secret' };\n const stripeRes = await ctx.fetch('https://api.stripe.com/v1/checkout/sessions', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({ /* \u2026 */ }).toString(),\n });\n const session = await stripeRes.json();\n return { url: session.url };\n};\n```\n\n**What the caller receives.** The platform wraps the return value verbatim:\n`{ ok: true, data: { url } }` on success and \u2014 for the two early returns\nabove \u2014 `{ ok: true, data: { error: 'auth_required' } }`, still HTTP 200.\nKeep the `{ error: '<code>' }` convention for application-level failures\n(do not nest a second `{ok:false, error:{\u2026}}` envelope) and unwrap twice\nin the frontend:\n\n```js\n// src/lib/actions.js \u2014 one helper for every action call\nasync function callAction(name, payload) {\n const res = await fetch(`${BASE}/actions/${name}`, {\n method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify(payload ?? {}),\n });\n const result = await jsonOrThrow(res); // platform envelope: action_not_found, runtime_error, \u2026\n if (result && typeof result === 'object' && result.error) {\n const err = new Error(result.error); // the action's own { error: '<code>' }\n err.code = result.error;\n throw err;\n }\n return result;\n}\n```\n\n### Authoring protocol (engine flow)\n\n1. Read the preamble's `Managed actions` block. If your target name is already deployed with the right shape, skip to step 3.\n2. **Upsert:** write the action source to a local temp file, then `agentful-managed-actions upsert <project_id> <name> <file_path>`. The validator runs on both the public PUT-files path and this CLI; same rejection rules.\n3. Write the frontend `fetch('/api/p/<project_id>/actions/<name>', { method: 'POST', body: JSON.stringify(payload) })`. Include `Content-Type: application/json` on POSTs.\n4. Test once with `agentful-managed-actions invoke <project_id> <name> --body '{\"\u2026\":\"\u2026\"}'` to confirm the deployment landed.\n\n### Action-invocation error codes (response body)\n\n| `error.code` | HTTP | What it means | What to do |\n|---|---|---|---|\n| `action_not_found` | 404 | The action with that name doesn't exist | Upsert it first. |\n| `action_validation_failed` | 400 | Validator rejected the source (size, filename, disallowed require) | Fix per `reason` field; do not retry. |\n| `concurrency_exceeded` | 429 | Per-project cap reached | Backoff + retry, or surface to user. |\n| `action_loop_detected` | 508 | `X-Action-Depth >= 5` \u2014 too many self-calls in a chain | Refactor; you cannot self-recurse beyond depth 5. |\n| `timeout` | 408 | Action exceeded 5 s | Move heavy work out of the action or break into smaller calls. |\n| `body_too_large` | 413 | Request body > 1 MB | Trim the payload. |\n| `action_too_large` | 413 | Action source file > 256 KB | Split into multiple actions. |\n| `runtime_error` | 500 | Action threw at runtime | Read the message; common causes are unhandled rejections, missing `await`, or accessing undefined ctx fields. |\n\n### Anti-patterns\n\n- \u274C Putting a Stripe / OpenAI / Resend API key in frontend code. It must live in `ctx.secrets`.\n- \u274C `ctx.secrets.STRIPE_SECRET_KEY` (property access). The secrets API is `await ctx.secrets.get('server.stripe_secret_key')`; property access is silently `undefined`, and uppercase/unprefixed names cannot be stored at all.\n- \u274C `ctx.user.sub`. The verified user object is `{ id, email, pid }` \u2014 the JWT `sub` claim arrives as `ctx.user.id`.\n- \u274C Treating `ok: true` on an action response as \"the action succeeded\". The outer `ok` means \"it ran\"; your `{ error: '\u2026' }` sits inside `data`. A client that unwraps only once turns every action error into a phantom success.\n- \u274C An action that reads secrets or writes data without checking `ctx.user` first. Actions are publicly invokable; your check is the only authorization.\n- \u274C Calling `fetch('/api/p/.../actions/foo')` without first running `agentful-managed-actions upsert`. Will return 404.\n- \u274C Recursive actions calling themselves to \"spread work\". Will trip the depth guard at 5.\n- \u274C Using bare `fetch` instead of `ctx.fetch` from inside an action. The depth header won't propagate; you bypass the loop guard.\n- \u274C Naming actions `Hello.js`, `_internal.js`, or `actions/sub/foo.js`. The validator rejects (uppercase, leading underscore, subdirectory).\n- \u274C Hardcoding the API base URL into the action's `ctx.fetch` calls to other actions. Use a relative path or the project's own API origin.\n\n### `ctx.data` Is Privileged \u2014 It Bypasses `access_rule`\n\n`ctx.data` inside an action talks to the database **directly, with no\n`access_rule` enforcement**. It is a project-scoped, owner-level surface \u2014 the\nopposite of the `/data/*` end-user route:\n\n- It reads and writes **every** document in **every** collection, regardless of\n whether that collection is `owner`, `authenticated`, or `public`.\n- It does **not** check `created_by`. An action can read one user's `owner`\n docs and write into another user's.\n- Documents created via `ctx.data.create` are attributed `created_by: \"action\"`,\n never to an end-user. If you need owner attribution, store the owner's id in\n the document `data` yourself (e.g. `{ user_id: ctx.user.id, ... }`) and\n filter on it.\n- `ctx.data.list(collection, { limit })` returns a **plain array** of docs\n (`[{ doc_id, data, created_by, created_at }]`, NOT `{ documents: [...] }`),\n caps at 100, and does **no server-side filtering** \u2014 you filter in JS. For\n data sets that can exceed 100 rows, store an explicit owner/lookup field and\n design around the cap; do not assume `list` returns everything.\n\nThis is the intended mechanism for trusted/admin work. The trade-off: an action\nis only as safe as its own checks. **Always verify `ctx.user` before any\ncross-user read or write.**\n\n### RBAC & Secure Role Assignment (`owner` + Action)\n\nThe managed DB has **no role concept and no field-level validation**. On the\nend-user `/data/*` route the client controls the entire document body \u2014\nincluding any `role` field. Design around two facts:\n\n1. **Privilege escalation is possible by default.** If a `profiles` collection\n is `authenticated` or `owner`, a client can register and POST\n `{ role: \"admin\" }` for themselves. `owner` does NOT stop this \u2014 the user\n owns their own profile.\n2. **`owner` blocks admins too.** An `owner` collection correctly hides a\n client's data from other clients, but an admin also cannot read it over\n `/data/*`. Admin access must go through an action using `ctx.data`.\n\nSecure pattern \u2014 keep `role` server-owned and gate every change behind an\naction that verifies the **caller** is already an admin:\n\n```js\n// .server/actions/set-role.js \u2014 upsert BEFORE calling it from the client\nmodule.exports = async function (ctx) {\n if (!ctx.user) return { error: 'auth_required' };\n // 1. Verify the CALLER is an admin (ctx.data ignores access_rule, so this\n // works even though `profiles` is `owner`).\n const all = await ctx.data.list('profiles', { limit: 100 });\n const me = all.find(d => d.data.user_id === ctx.user.id);\n if (!me || me.data.role !== 'admin') return { error: 'forbidden' };\n // 2. Validate input, then apply to the target.\n const { target_user_id, role } = ctx.body || {};\n if (!['admin', 'member', 'client'].includes(role)) return { error: 'bad_role' };\n const target = all.find(d => d.data.user_id === target_user_id);\n if (!target) return { error: 'not_found' };\n await ctx.data.update('profiles', target.doc_id, { ...target.data, role });\n return { ok: true };\n};\n```\n\nRules for this pattern:\n\n- The client UI must **never** write the `role` field over `/data/*`. On\n self-registration, create the profile without `role` (or force a non-privileged\n default in the action) \u2014 never trust a client-sent role.\n- \"Owner OR admin\" **reads** (an admin viewing any client's invoices) use the\n same shape: keep the collection `owner`, expose admin access through an action\n that verifies `ctx.user` is an admin, then uses `ctx.data` to fetch across users.\n- **Bootstrapping the first admin:** see **First Admin \u2014 mode-correct\n protocol** below. Never invent a client-reachable route for it.\n- The 100-row `ctx.data.list` cap applies: if `profiles` can exceed 100 rows,\n this scan-in-JS lookup is unreliable. Until server-side filtering exists,\n store role lookups in a bounded collection or key admins by a known id set.\n\n## First Admin \u2014 mode-correct protocol\n\nWhen the app needs an admin (dashboard, moderation, `admin`-ruled collections),\nask the builder **\"How should the first admin account be created?\"** and offer\nONLY these options \u2014 they map to the platform's `_users.role` system\n(`user`/`admin`), which is what the `admin` access rule checks:\n\n1. **Fixed admin email (recommended).** Ask the builder for the address, then\n run:\n ```\n agentful-auth-config set $PROJECT_ID '{\"admin_email\":\"chef@firma.de\"}'\n ```\n Whoever registers (or later logs in) with exactly that address is promoted\n to `role: admin` **server-side, only after email verification** \u2014 no code\n needed in the app.\n2. **Manual via Data Manager.** The builder opens **Backend \u2192 Data Manager \u2192\n `_users` tab**, selects the registered user and sets the `role` dropdown to\n `admin` (the `verified` flag can also be set there if a mail never arrived).\n\n### `agentful-auth-config` CLI\n\n- `agentful-auth-config get $PROJECT_ID` \u2014 current `config.auth` state\n (`auth: null` = hardened pipeline not activated yet \u2192 registering works\n legacy-style without verification) plus `email_infrastructure`\n (`\"\"` = builder has not chosen one; verification mails will fail with\n `email_infra_unconfigured` until they pick one in project settings).\n- `agentful-auth-config set $PROJECT_ID '<json>'` \u2014 merge into `config.auth`.\n Keys: `methods` (subset of `email|google|oidc`; the server clamps against\n the org allowlist \u2014 verify the result in the response), `require_verified_login`\n (bool; default true once auth is configured), `admin_email`, `language`\n (`de`|`en`, auth-mail language).\n- Setting ANY key activates the hardened pipeline (verification mails +\n verified-login gate). Before activating it, run `get` and make sure\n `email_infrastructure` is not empty \u2014 otherwise tell the builder to choose\n Agentful Email or BYO in project settings first.\n- Email infrastructure is NOT settable via this CLI by design (audited\n builder decision, DE-data-region notice).\n\n**NEVER offer \"run SQL\" / \"insert into the database manually\" for\n`database.mode == managed` \u2014 there is no SQL surface; the managed DB is not a\nSQL database.** SQL-based instructions apply only to BYO-Supabase projects\n(different skill, different mode).\n\nPrefer the platform `_users.role` + `admin` access rule over inventing an\napp-level `profiles.role` system when the requirement is just \"one admin can\nsee/manage everything\" \u2014 the profiles-RBAC pattern above is for MULTI-role\napps (admin/member/client) that need roles beyond `user`/`admin`.\n",
|
|
7034
|
+
"agentful-managed-db": "---\nname: agentful-managed-db\ndescription: Managed Database protocol for `database.mode == managed`. Covers schema upsert, CRUD against `/api/p/{PROJECT_ID}/data/*` and `/api/p/{PROJECT_ID}/auth/*`, error-code remediation, and per-framework client patterns (Vue, React, Svelte, SvelteKit, Astro, Vanilla). Load when the backend preamble shows `Database: managed`.\n---\n\n## When To Use\n\nLoad this skill **only** when `agentful-backend-state` reports `database.mode: \"managed\"` with `status: \"active\"`. Do not load it for `byo` (Supabase / custom server) or when the database is not configured.\n\n## Hard Rules\n\n1. **Collections do not auto-create.** Writes to a non-existent collection return 404 with `error.code: not_found`. For every collection your code reads or writes, if it is not listed in the backend preamble's `Managed collections` block, you MUST run `agentful-managed-collections upsert <project_id> '<json>'` BEFORE writing the code that touches it.\n2. **Never wrap data-API calls in a swallow-all `try/catch`.** Swallowing masks 4xx errors and produces apps that look-fine-but-write-nothing.\n3. **Never set a `seeded` flag unless every write returned 201.** Partial-success seeds drift state silently.\n4. **Do not call this a \"server\".** It is a managed database behind a gateway. Use \"the database\" when talking to the user.\n5. **Do not target `/data/_collections`** from generated code. That's the owner-only schema endpoint; use the `agentful-managed-collections` CLI for schema work.\n6. **On 5xx, do not speculate.** Surface `error.correlation_id` to the user verbatim and stop. Do not invent internal causes (DynamoDB, operators, system collections, etc.).\n\n## Authoring Protocol \u2014 for every collection touch\n\nRun, in order:\n\n1. **Read the preamble.** The `[BACKEND STATUS]` block lists existing `Managed collections` with their fields and access rules. If your target collection is there with the right shape, skip to step 3.\n2. **Upsert if missing or schema mismatch:**\n ```\n agentful-managed-collections upsert <project_id> '{\"name\":\"todos\",\"access_rule\":\"owner\",\"schema\":{\"fields\":[{\"name\":\"title\",\"type\":\"string\",\"required\":true},{\"name\":\"done\",\"type\":\"boolean\"}]}}'\n ```\n Field types: `string`, `text`, `number`, `boolean`, `select` (with `options`), `date` (ISO 8601 calendar date `YYYY-MM-DD`), `datetime` (ISO 8601 string), `json` (object or array). Any other type is rejected by the platform (400 with the allowed list).\n Access rules: `public` (anyone), `authenticated` (any logged-in end-user), `owner` (only `created_by` user), `admin` (only end-users whose `_users.role` is `admin`).\n Every successful upsert is also DECLARED: the command mirrors the collection (as the platform stored it) into `.agentful/backend.json` in the workspace and answers `\"declaration\": \".agentful/backend.json\"`. That file is the project's backend declaration \u2014 it travels with the code (a local `agentful push` applies it again, idempotently) and the Backend tab shows it. Do not edit it to claim a collection exists; the upsert output is the only confirmation. Never commit it to `.gitignore`.\n3. **Write the client code** using the patterns below. Use the EXACT field names from the schema. Do not invent fields.\n4. **Test the happy path** by inspecting the response. Real 201 / 200, not a swallowed error.\n\n## Choosing An Access Rule\n\n`access_rule` is set per collection at upsert time and enforced on every end-user\n(`/data/*`) request. There are exactly four rules \u2014 pick by use case:\n\n| Use case | Rule | Why |\n|---|---|---|\n| Content anyone may read/write without login (public poll, guestbook) | `public` | No JWT required. |\n| Public content the app seeds once and the UI only reads | `public` | Seed at build time; clients read only. |\n| Shared data **every** logged-in user may read AND edit (team wiki, shared catalog) | `authenticated` | Any valid end-user JWT passes. **No per-row owner check.** |\n| Per-user private data (todos, drafts, a user's own orders) | `owner` | Only the `created_by` end-user can read/update/delete each doc. |\n| Data only the app's admins may read/write (moderation queues, settings) | `admin` | Only end-users whose `_users.role` is `admin` (owner-set, see First Admin below). |\n| Per-user data an **admin must also access** (invoices, tickets, client records) | `owner` + admin via Action | `owner` protects the client; admin reads/writes through a Managed Action. See RBAC below. |\n| A field only the server may set (`role`, `plan`, `verified`, `balance`) | `owner`, with that field written **only** via an Action | No field-level rules exist \u2014 gate the whole mutation behind an Action. |\n\n**Two traps to design around:**\n\n1. **`authenticated` is NOT per-user isolation.** It means *every* logged-in\n user can read and write *all* documents in the collection. For \"each user\n sees only their own\", use `owner`.\n2. **There is no combined `owner_or_admin` rule and no role concept in the data\n layer.** A multi-role portal (admin / member / client) cannot be expressed by\n `access_rule` alone. The supported pattern is `owner` + a Managed Action that\n verifies the caller \u2014 see **RBAC & Secure Role Assignment** under Managed\n Actions. (Requires `server.mode == managed`.)\n\n## API Surface\n\nBase: `/api/p/{PROJECT_ID}/`\n\n**Auth (end-user):**\n- `POST auth/register` \u2014 `{email, password, display_name?}`. Two response shapes:\n - Verification pipeline ACTIVE (project has `config.auth`, default): `201 {verification_required:true, user:{...}}` \u2014 **NO token yet**; the user must confirm their email first (mail is sent automatically).\n - Legacy project (no `config.auth`) or `require_verified_login:false`: `201 {token, verification_required:false, user:{...}}`.\n- `POST auth/login` \u2014 `{email, password}` \u2192 `{token, user:{...}}`. Blocks with `403 email_unverified` when the project requires verified logins and the account is not verified yet \u2192 show a \"check your inbox\" state with a resend button.\n- `GET auth/me` \u2014 Bearer token \u2192 `{user:{...}}`\n- `POST auth/verify` \u2014 `{uid, token}` (from the mail link) \u2192 `{token, user}` (auto-login after verification).\n- `POST auth/resend-verification` \u2014 `{email}` \u2192 always `200 {sent:true}` (no user enumeration).\n- `POST auth/request-password-reset` \u2014 `{email}` \u2192 always `200 {sent:true}` (no user enumeration).\n- `POST auth/reset-password` \u2014 `{uid, token, password}` \u2192 `200 {reset:true}`. Also marks the mailbox verified.\n\n`user` shape: `{id, email, display_name, role, verified, provider, created_at}`.\n\n**Auth mail links:** verification/reset mails link to the deployed app as\n`{app_url}/?ta_action=verify&uid=\u2026&token=\u2026` and `{app_url}/?ta_action=reset&uid=\u2026&token=\u2026`.\n**Every generated app with auth MUST handle these two query params on load**\n(see Client Patterns).\n\n**Login methods governance:** offer ONLY the login methods the project's\n`config.auth.methods` allows (check with `agentful-auth-config get`;\ndefault `[\"email\"]`). Do NOT generate \"Sign in with Google\"/SSO buttons unless\n`google`/`oidc` is listed \u2014 the platform refuses unlisted methods server-side.\n\n**Google login (when `google` IS listed):** a \"Continue with Google\" button\ncalls `googleAuth.start()` (see Client Patterns) \u2192 central broker\n`api.agentful.dev/auth/oauth/google/start` \u2192 Google \u2192 back to the app with the\nJWT in the URL fragment; call `handleGoogleReturn()` at startup to complete\nthe login. Google users arrive `verified: true` (Google verified the mailbox),\nexisting email accounts with the same address are linked automatically, and\nthe `admin_email` bootstrap applies. Show `auth_error` codes as a friendly\nmessage (`auth_method_not_allowed` \u2192 \"Google login is not available for this\napp\"); never retry in a loop.\n\n**Data:**\n- `GET data/{collection}` \u2014 list (paginated; `?limit=`, `?cursor=`)\n- `GET data/{collection}/{docId}` \u2014 single doc\n- `POST data/{collection}` \u2014 body `{data: {...}}` \u2192 `{ok:true, data:{doc_id, collection, data, created_at}}`\n- `PUT data/{collection}/{docId}` \u2014 body `{data: {...}}` \u2192 updated doc\n- `DELETE data/{collection}/{docId}` \u2192 `{ok:true, data:{deleted, collection}}`\n\nEnd-user routes (above) require `Authorization: Bearer <token>` from `auth/register` or `auth/login`. The collection's `access_rule` enforces what each token may read/write.\n\n## Response Shapes\n\n**Success:** `{ \"ok\": true, \"data\": {...} }` \u2014 `data` for lists has `{documents:[...], count, cursor}`.\n\n**Error:** `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"correlation_id\"?: \"...\" } }`\n\nStatus codes are HTTP-conventional (201 on create, 200 on read/update/delete, 4xx for client errors, 5xx for platform).\n\n**Actions are the one exception:** `POST \u2026/actions/{name}` returns `{ \"ok\": true, \"data\": <whatever your action returned> }` with HTTP 200 whenever the action *ran to completion*. The outer `ok` only says \"the platform executed it\" \u2014 your own `{ error: '\u2026' }` return lands verbatim inside `data`, it does NOT flip the outer `ok` and cannot set the HTTP status (only a thrown error becomes `ok:false` / `runtime_error` 500). The client must therefore check `body.data.error` after the envelope check (see `callAction` below).\n\n## Error Code \u2192 Remediation\n\n| `error.code` | HTTP | What it means | What to do |\n|---|---|---|---|\n| `not_found` | 404 | Collection doesn't exist, OR doc id doesn't exist | If collection: run `agentful-managed-collections upsert` then retry. If doc: surface to user. |\n| `auth_required` | 401 | `/data/*` on a non-`public` collection without a `Authorization: Bearer` token | Send the end-user to login; do not retry without a token. |\n| `invalid_token` | 401 | The Bearer token on `/data/*` is invalid or expired (`wrong_project` 403 = token from another project) | Clear the stored token and show the login state. |\n| `email_unverified` | 403 | Login blocked until the user confirms their email | Show \"confirm your email\" state + resend button (`auth/resend-verification`). |\n| `invalid_token` | 400 | Verification/reset link invalid, expired, or already used | Offer resend (`resend-verification`) or a new reset request. |\n| `too_many_attempts` | 429 | Auth rate limit hit (failed logins / mail requests) | Tell the user to wait a few minutes; do not auto-retry. |\n| `email_infra_unconfigured` | 409 | Project has no email infrastructure selected | Tell the BUILDER (not the end-user): choose \"Agentful Email\" or BYO in project settings \u2192 Email infrastructure. |\n| `email_delivery_unavailable` | 409 | The app has no published URL yet \u2014 verification/reset mails need a deployed app to link to (the preview URL does not count) | Platform state, not a code bug: tell the BUILDER to publish the app; show the end-user \"sign-up by email is not available yet\". Google login works on the preview already. Never treat as transient; never retry in a loop. |\n| `email_send_failed` | 502 | Mail transport failed transiently | Tell the user to try again later. |\n| `schema_validation_failed` | 400 | Payload doesn't match the collection's schema | Re-read the schema in the preamble; fix field names/types/required-ness; do not retry blindly. |\n| `readonly_collection` | 403 | Collection or doc is system-protected (e.g. `_users` via end-user route) | Use the correct route (e.g. `auth/register` for `_users`); do not retry. |\n| `forbidden` | 403 | Access rule denied this end-user | Tell the user they need to log in / lack permission. |\n| `already_exists` | 409 | `doc_id` collision or conditional check failed | Let `doc_id` auto-generate (omit it). |\n| `too_large` | 413 | Document > 256 KB | Split or trim payload. |\n| `quota_exceeded` | 429 | DDB throttling / request-limit spillover (transient) | The doctor returns `action: retry_with_backoff`. Sleep + retry up to 3 times per `details.backoff_ms`. **Surface `correlation_id` to the user ONLY after all attempts exhaust.** |\n| `transient_storage_error` | 503 | DDB internal / service-unavailable (transient) | Same as `quota_exceeded`: doctor returns `retry_with_backoff`; engine handles silently until budget exhausts. |\n| `internal_storage_error` / `write_failed` / `delete_failed` | 500 | Platform-side error, NOT classified retryable | Delegate to `@agentful-managed-db-doctor` with `correlation_id`. Surface its `user_message` verbatim. Do NOT retry in a loop. Do NOT invent a root cause. |\n\n## Client Patterns\n\nA tiny client used everywhere. Define once per project; reuse for all collections.\n\n### Vanilla / shared base\n\n```js\n// src/lib/data.js\nconst BASE = `/api/p/${PROJECT_ID}`; // set PROJECT_ID at build time\nconst tokenKey = 'mm_auth_token';\n\nfunction authHeader() {\n const t = localStorage.getItem(tokenKey);\n return t ? { Authorization: `Bearer ${t}` } : {};\n}\n\nasync function jsonOrThrow(res) {\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.ok) {\n const err = new Error(body.error?.message || `HTTP ${res.status}`);\n err.code = body.error?.code;\n err.correlation_id = body.error?.correlation_id;\n err.status = res.status;\n throw err;\n }\n return body.data;\n}\n\nexport const auth = {\n async register(email, password, display_name) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/register`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password, display_name }),\n }));\n // verification_required \u2192 NO token yet; caller must show \"check your inbox\".\n if (data.token) localStorage.setItem(tokenKey, data.token);\n return data; // {verification_required, user, token?}\n },\n async login(email, password) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/login`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n }));\n localStorage.setItem(tokenKey, data.token);\n return data.user;\n },\n async verify(uid, token) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/verify`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ uid, token }),\n }));\n localStorage.setItem(tokenKey, data.token); // auto-login after verify\n return data.user;\n },\n async resendVerification(email) {\n return jsonOrThrow(await fetch(`${BASE}/auth/resend-verification`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email }),\n }));\n },\n async requestPasswordReset(email) {\n return jsonOrThrow(await fetch(`${BASE}/auth/request-password-reset`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email }),\n }));\n },\n async resetPassword(uid, token, password) {\n return jsonOrThrow(await fetch(`${BASE}/auth/reset-password`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ uid, token, password }),\n }));\n },\n logout() { localStorage.removeItem(tokenKey); },\n token() { return localStorage.getItem(tokenKey); },\n};\n\n// Google login (ONLY when 'google' \u2208 config.auth.methods \u2014 never generate\n// this button otherwise; the platform refuses unlisted methods server-side).\n// Redirects to the central Agentful OAuth broker; after Google consent the\n// broker 302s back to `redirect` with the app JWT in the URL FRAGMENT:\n// https://<your-app>/#token=<jwt>&provider=google (or #auth_error=<code>)\nexport const googleAuth = {\n start(redirect = location.origin + '/') {\n const url = new URL('https://api.agentful.dev/auth/oauth/google/start');\n url.searchParams.set('project_id', PROJECT_ID);\n url.searchParams.set('redirect', redirect); // must be THIS app's https origin\n location.href = url.toString();\n },\n};\n\n// REQUIRED whenever the Google button is generated: pick up the broker return\n// on app load (fragment token \u2192 login; auth_error \u2192 user-visible message).\nexport function handleGoogleReturn() {\n const h = new URLSearchParams(location.hash.slice(1));\n const token = h.get('token'), err = h.get('auth_error');\n if (!token && !err) return null;\n history.replaceState(null, '', location.pathname + location.search); // strip token from URL\n if (err) return { ok: false, error: err }; // e.g. auth_method_not_allowed, oauth_failed\n localStorage.setItem(tokenKey, token);\n return { ok: true, provider: h.get('provider') || 'google' };\n}\n\n// REQUIRED in every app with auth: handle the mail links on app load.\n// Call once at startup (before router init is fine).\nexport async function handleAuthMailAction() {\n const p = new URLSearchParams(location.search);\n const action = p.get('ta_action'), uid = p.get('uid'), token = p.get('token');\n if (!action || !uid || !token) return null;\n history.replaceState(null, '', location.pathname); // strip token from URL\n if (action === 'verify') {\n try { const user = await auth.verify(uid, token); return { action, ok: true, user }; }\n catch (e) { return { action, ok: false, error: e.code || 'invalid_token' }; }\n }\n if (action === 'reset') return { action, ok: true, uid, token }; // show new-password form, then auth.resetPassword(uid, token, pw)\n return null;\n}\n\nexport const data = {\n async list(collection, opts = {}) {\n const qs = new URLSearchParams(opts).toString();\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}${qs ? '?' + qs : ''}`, {\n headers: { ...authHeader() },\n }));\n },\n async get(collection, docId) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n headers: { ...authHeader() },\n }));\n },\n async create(collection, data) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ data }),\n }));\n },\n async update(collection, docId, data) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ data }),\n }));\n },\n async remove(collection, docId) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n method: 'DELETE',\n headers: { ...authHeader() },\n }));\n },\n};\n```\n\n### React / Vue / Svelte\n\nUse the same `data.js` / `data.ts` module above; wrap in framework-native state primitives.\n\n- **React:** call from `useEffect` for reads, `useState` for results; surface `err.code`/`err.correlation_id` to the user when caught. Do not put data calls in render bodies.\n- **Vue:** use `onMounted` for reads and a `ref()` for results; same error surfacing.\n- **Svelte / SvelteKit:** call from `onMount` (or a `load` function in SvelteKit); SvelteKit static-adapter projects must NOT use server `load` (no Node runtime in static deploy).\n- **Astro:** call only from client-side islands; no server fetch (static-only build).\n\n### TypeScript types\n\nGenerate the per-collection type from the preamble's field list:\n\n```ts\n// Example for a collection with fields: title:string*, done:boolean\ntype Todo = { title: string; done?: boolean };\n\n// And response wrappers:\ntype DataDoc<T> = { doc_id: string; collection: string; data: T; created_at: string };\ntype DataList<T> = { documents: DataDoc<T>[]; count: number; cursor?: string };\n```\n\n## Anti-patterns\n\n- \u274C `try { await data.create(...) } catch { /* ignore */ }` \u2014 masks failures.\n- \u274C Hardcoding `doc_id` for \"convenience\" \u2014 causes `already_exists` 409 on retry.\n- \u274C Writing to a collection name that doesn't appear in the preamble \u2014 `not_found` 404.\n- \u274C Using the schema endpoint `/data/_collections` from client code \u2014 owner-only, end-users get 403.\n- \u274C Storing the JWT anywhere other than `localStorage` under a project-scoped key; do not put it in cookies (CORS) or in `sessionStorage` (lost on tab close).\n- \u274C Telling the user \"the database is down\" because of a 5xx. Surface the `correlation_id` and stop.\n- \u274C Surfacing `correlation_id` on transient errors (`quota_exceeded`, `transient_storage_error`) before the doctor's `retry_with_backoff` budget is exhausted. The whole point is the user sees nothing while the retry loop is in play; only escalate after all `max_attempts` fail.\n\n## Diagnostic delegation\n\nIf you hit a 5xx that doesn't map to a retry-able 4xx in the table above, do not diagnose yourself. Delegate to `@agentful-managed-db-doctor` (added in PR 1.3) with the `correlation_id` from the response. The doctor has constrained tools and cannot fabricate platform internals.\n\n---\n\n## Managed Actions (only when `server.mode == managed`)\n\nManaged Actions are small Node.js functions the platform runs for the project at `/api/p/{PROJECT_ID}/actions/{name}`. Use them when a frontend operation needs (a) a secret API key, (b) a non-public/secured external API, or (c) server-enforced trust (price computation, signature verification, admin actions). For pure CRUD against the managed DB, use the data API directly; do NOT route everything through an action.\n\n### Hard rules (Managed Actions)\n\n1. **Upsert FIRST, fetch SECOND.** If your generated code calls `fetch('/api/p/.../actions/{name}')`, you MUST upsert the action via the CLI BEFORE writing the fetch call. An undeployed action returns `404 action_not_found`; the preamble's `Managed actions` list is the ground truth for what exists.\n2. **The sandbox has NO module system.** No `require` of any kind (incl. `module.require`), no `import` / dynamic `import()`, no npm packages, no Node.js core modules (not even `crypto`), no `process` and no environment variables. \"Node.js 20\" is the host the platform runs actions on, NOT your action's API. An action is a plain script whose full surface is `ctx.fetch`, `ctx.data`, `ctx.secrets`, `ctx.user`, `ctx.body` plus these globals: `JSON`, `Math`, `Date`, `URL`/`URLSearchParams`, `Buffer`, `TextEncoder`/`TextDecoder`, `atob`/`btoa`, `AbortController`/`AbortSignal`, `Promise`, `RegExp`, standard primitives and `console`. There is deliberately no crypto primitive in sandbox code \u2014 key handling and cryptography live in the trusted platform host (`ctx.secrets`), never in generated action code; do not probe for one. The validator rejects module-system usage at upload time with `{code: 'action_validation_failed', reason: 'require_unavailable' | 'dynamic_import_unavailable' | 'esm_not_supported' | 'process_unavailable'}`.\n3. **Secrets are read with `await ctx.secrets.get('<name>')` \u2014 never property access.** `ctx.secrets` has exactly one method, `get(key)`. `ctx.secrets.SOME_KEY` is silently `undefined`. Secret names must match `^(database|server)\\.[a-z][a-z0-9_]{0,126}$` \u2014 use `server.stripe_secret_key`, not `STRIPE_SECRET_KEY` (uppercase, unprefixed names cannot even be stored in Backend \u2192 Secrets).\n4. **Actions are publicly invokable \u2014 authorize the caller yourself.** `ctx.user` is the JWT-verified end-user (`{ id, email, pid }`) or `null`. Any action that touches a secret, writes data, or returns non-public information must start with a `ctx.user` check; nothing else gates who can call it.\n5. **Outbound calls from inside an action must use `ctx.fetch`**, not a bare `fetch`. `ctx.fetch` injects `X-Action-Depth` on self-action URLs so the platform's loop detector can break runaway recursion. External URLs are passed through unchanged.\n6. **Cost shape.** 5 s timeout, 256 MB RAM, 1 MB request body, per-project concurrent invocations capped at 10. Plan for `429 concurrency_exceeded` under load and surface it to the user gracefully (e.g. retry with backoff or \"try again in a moment\").\n\n### Action shape\n\n```js\n// .server/actions/checkout.js\nmodule.exports = async function (ctx) {\n // ctx.body \u2014 parsed JSON request body\n // ctx.user \u2014 JWT-verified end-user { id, email, pid }, or null if not logged in\n // ctx.data \u2014 same CRUD surface as the public data API, scoped to this project\n // ctx.secrets \u2014 async accessor for Backend \u2192 Secrets: await ctx.secrets.get('server.stripe_secret_key')\n // ctx.fetch \u2014 depth-aware fetch wrapper\n if (!ctx.user) return { error: 'auth_required' };\n const apiKey = await ctx.secrets.get('server.stripe_secret_key');\n if (!apiKey) return { error: 'missing_server_secret' };\n const stripeRes = await ctx.fetch('https://api.stripe.com/v1/checkout/sessions', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({ /* \u2026 */ }).toString(),\n });\n const session = await stripeRes.json();\n return { url: session.url };\n};\n```\n\n**What the caller receives.** The platform wraps the return value verbatim:\n`{ ok: true, data: { url } }` on success and \u2014 for the two early returns\nabove \u2014 `{ ok: true, data: { error: 'auth_required' } }`, still HTTP 200.\nKeep the `{ error: '<code>' }` convention for application-level failures\n(do not nest a second `{ok:false, error:{\u2026}}` envelope) and unwrap twice\nin the frontend:\n\n```js\n// src/lib/actions.js \u2014 one helper for every action call\nasync function callAction(name, payload) {\n const res = await fetch(`${BASE}/actions/${name}`, {\n method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify(payload ?? {}),\n });\n const result = await jsonOrThrow(res); // platform envelope: action_not_found, runtime_error, \u2026\n if (result && typeof result === 'object' && result.error) {\n const err = new Error(result.error); // the action's own { error: '<code>' }\n err.code = result.error;\n throw err;\n }\n return result;\n}\n```\n\n### Authoring protocol (engine flow)\n\n1. Read the preamble's `Managed actions` block. If your target name is already deployed with the right shape, skip to step 3.\n2. **Upsert:** write the action source to a local temp file, then `agentful-managed-actions upsert <project_id> <name> <file_path>`. The validator runs on both the public PUT-files path and this CLI; same rejection rules.\n3. Write the frontend `fetch('/api/p/<project_id>/actions/<name>', { method: 'POST', body: JSON.stringify(payload) })`. Include `Content-Type: application/json` on POSTs.\n4. Test once with `agentful-managed-actions invoke <project_id> <name> --body '{\"\u2026\":\"\u2026\"}'` to confirm the deployment landed. **Read the result in two layers:** HTTP 200 + `{ok:true,data:\u2026}` only proves the action returned without throwing \u2014 the platform wraps whatever the action returns, so `data` may itself be `{error:'auth_required'}` or another app-level failure. An unauthenticated CLI invoke of a `ctx.user`-gated action therefore proves deployment only, never business success; the business path needs a real end-user JWT (log in through the app, or the platform E2E harness).\n\n### Action-invocation error codes (response body)\n\n| `error.code` | HTTP | What it means | What to do |\n|---|---|---|---|\n| `action_not_found` | 404 | The action with that name doesn't exist | Upsert it first. |\n| `action_validation_failed` | 400 | Validator rejected the source (size, filename, or require/import/process usage \u2014 the sandbox has no module system) | Fix per `reason` field; do not retry. |\n| `concurrency_exceeded` | 429 | Per-project cap reached | Backoff + retry, or surface to user. |\n| `action_loop_detected` | 508 | `X-Action-Depth >= 5` \u2014 too many self-calls in a chain | Refactor; you cannot self-recurse beyond depth 5. |\n| `timeout` | 408 | Action exceeded 5 s | Move heavy work out of the action or break into smaller calls. |\n| `body_too_large` | 413 | Request body > 1 MB | Trim the payload. |\n| `action_too_large` | 413 | Action source file > 256 KB | Split into multiple actions. |\n| `runtime_error` | 500 | Action threw at runtime | Read the message; common causes are unhandled rejections, missing `await`, or accessing undefined ctx fields. |\n\n### Anti-patterns\n\n- \u274C Putting a Stripe / OpenAI / Resend API key in frontend code. It must live in `ctx.secrets`.\n- \u274C `ctx.secrets.STRIPE_SECRET_KEY` (property access). The secrets API is `await ctx.secrets.get('server.stripe_secret_key')`; property access is silently `undefined`, and uppercase/unprefixed names cannot be stored at all.\n- \u274C `ctx.user.sub`. The verified user object is `{ id, email, pid }` \u2014 the JWT `sub` claim arrives as `ctx.user.id`.\n- \u274C Treating `ok: true` on an action response as \"the action succeeded\". The outer `ok` means \"it ran\"; your `{ error: '\u2026' }` sits inside `data`. A client that unwraps only once turns every action error into a phantom success.\n- \u274C An action that reads secrets or writes data without checking `ctx.user` first. Actions are publicly invokable; your check is the only authorization.\n- \u274C Calling `fetch('/api/p/.../actions/foo')` without first running `agentful-managed-actions upsert`. Will return 404.\n- \u274C Recursive actions calling themselves to \"spread work\". Will trip the depth guard at 5.\n- \u274C Using bare `fetch` instead of `ctx.fetch` from inside an action. The depth header won't propagate; you bypass the loop guard.\n- \u274C Naming actions `Hello.js`, `_internal.js`, or `actions/sub/foo.js`. The validator rejects (uppercase, leading underscore, subdirectory).\n- \u274C Hardcoding the API base URL into the action's `ctx.fetch` calls to other actions. Use a relative path or the project's own API origin.\n\n### `ctx.data` Is Privileged \u2014 It Bypasses `access_rule`\n\n`ctx.data` inside an action talks to the database **directly, with no\n`access_rule` enforcement**. It is a project-scoped, owner-level surface \u2014 the\nopposite of the `/data/*` end-user route:\n\n- It reads and writes **every** document in **every** collection, regardless of\n whether that collection is `owner`, `authenticated`, or `public`.\n- It does **not** check `created_by`. An action can read one user's `owner`\n docs and write into another user's.\n- Documents created via `ctx.data.create` are attributed `created_by: \"action\"`,\n never to an end-user. If you need owner attribution, store the owner's id in\n the document `data` yourself (e.g. `{ user_id: ctx.user.id, ... }`) and\n filter on it.\n- `ctx.data.list(collection, { limit })` returns a **plain array** of docs\n (`[{ doc_id, data, created_by, created_at }]`, NOT `{ documents: [...] }`),\n caps at 100, and does **no server-side filtering** \u2014 you filter in JS. For\n data sets that can exceed 100 rows, store an explicit owner/lookup field and\n design around the cap; do not assume `list` returns everything.\n\nThis is the intended mechanism for trusted/admin work. The trade-off: an action\nis only as safe as its own checks. **Always verify `ctx.user` before any\ncross-user read or write.**\n\n### RBAC & Secure Role Assignment (`owner` + Action)\n\nThe managed DB has **no role concept and no field-level validation**. On the\nend-user `/data/*` route the client controls the entire document body \u2014\nincluding any `role` field. Design around two facts:\n\n1. **Privilege escalation is possible by default.** If a `profiles` collection\n is `authenticated` or `owner`, a client can register and POST\n `{ role: \"admin\" }` for themselves. `owner` does NOT stop this \u2014 the user\n owns their own profile.\n2. **`owner` blocks admins too.** An `owner` collection correctly hides a\n client's data from other clients, but an admin also cannot read it over\n `/data/*`. Admin access must go through an action using `ctx.data`.\n\nSecure pattern \u2014 keep `role` server-owned and gate every change behind an\naction that verifies the **caller** is already an admin:\n\n```js\n// .server/actions/set-role.js \u2014 upsert BEFORE calling it from the client\nmodule.exports = async function (ctx) {\n if (!ctx.user) return { error: 'auth_required' };\n // 1. Verify the CALLER is an admin (ctx.data ignores access_rule, so this\n // works even though `profiles` is `owner`).\n const all = await ctx.data.list('profiles', { limit: 100 });\n const me = all.find(d => d.data.user_id === ctx.user.id);\n if (!me || me.data.role !== 'admin') return { error: 'forbidden' };\n // 2. Validate input, then apply to the target.\n const { target_user_id, role } = ctx.body || {};\n if (!['admin', 'member', 'client'].includes(role)) return { error: 'bad_role' };\n const target = all.find(d => d.data.user_id === target_user_id);\n if (!target) return { error: 'not_found' };\n await ctx.data.update('profiles', target.doc_id, { ...target.data, role });\n return { ok: true };\n};\n```\n\nRules for this pattern:\n\n- The client UI must **never** write the `role` field over `/data/*`. On\n self-registration, create the profile without `role` (or force a non-privileged\n default in the action) \u2014 never trust a client-sent role.\n- \"Owner OR admin\" **reads** (an admin viewing any client's invoices) use the\n same shape: keep the collection `owner`, expose admin access through an action\n that verifies `ctx.user` is an admin, then uses `ctx.data` to fetch across users.\n- **Bootstrapping the first admin:** see **First Admin \u2014 mode-correct\n protocol** below. Never invent a client-reachable route for it.\n- The 100-row `ctx.data.list` cap applies: if `profiles` can exceed 100 rows,\n this scan-in-JS lookup is unreliable. Until server-side filtering exists,\n store role lookups in a bounded collection or key admins by a known id set.\n\n## First Admin \u2014 mode-correct protocol\n\nWhen the app needs an admin (dashboard, moderation, `admin`-ruled collections),\nask the builder **\"How should the first admin account be created?\"** and offer\nONLY these options \u2014 they map to the platform's `_users.role` system\n(`user`/`admin`), which is what the `admin` access rule checks:\n\n1. **Fixed admin email (recommended).** Ask the builder for the address, then\n run:\n ```\n agentful-auth-config set $PROJECT_ID '{\"admin_email\":\"chef@firma.de\"}'\n ```\n Whoever registers (or later logs in) with exactly that address is promoted\n to `role: admin` **server-side, only after email verification** \u2014 no code\n needed in the app.\n2. **Manual via Data Manager.** The builder opens **Backend \u2192 Data Manager \u2192\n `_users` tab**, selects the registered user and sets the `role` dropdown to\n `admin` (the `verified` flag can also be set there if a mail never arrived).\n\n### `agentful-auth-config` CLI\n\n- `agentful-auth-config get $PROJECT_ID` \u2014 current `config.auth` state\n (`auth: null` = hardened pipeline not activated yet \u2192 registering works\n legacy-style without verification) plus `email_infrastructure`\n (`\"\"` = builder has not chosen one; verification mails will fail with\n `email_infra_unconfigured` until they pick one in project settings).\n- `agentful-auth-config set $PROJECT_ID '<json>'` \u2014 merge into `config.auth`.\n Keys: `methods` (subset of `email|google|oidc`; the server clamps against\n the org allowlist \u2014 verify the result in the response), `require_verified_login`\n (bool; default true once auth is configured), `admin_email`, `language`\n (`de`|`en`, auth-mail language).\n- Setting ANY key activates the hardened pipeline (verification mails +\n verified-login gate). Before activating it, run `get` and make sure\n `email_infrastructure` is not empty \u2014 otherwise tell the builder to choose\n Agentful Email or BYO in project settings first.\n- Email infrastructure is NOT settable via this CLI by design (audited\n builder decision, DE-data-region notice).\n\n**NEVER offer \"run SQL\" / \"insert into the database manually\" for\n`database.mode == managed` \u2014 there is no SQL surface; the managed DB is not a\nSQL database.** SQL-based instructions apply only to BYO-Supabase projects\n(different skill, different mode).\n\nPrefer the platform `_users.role` + `admin` access rule over inventing an\napp-level `profiles.role` system when the requirement is just \"one admin can\nsee/manage everything\" \u2014 the profiles-RBAC pattern above is for MULTI-role\napps (admin/member/client) that need roles beyond `user`/`admin`.\n\n---\n\n## Managed AI (platform-brokered LLM calls)\n\nManaged AI lets a generated app call an LLM through the platform:\n`/api/p/{PROJECT_ID}/ai/*`. The provider API key is stored **server-side by\nthe platform** (saved once by the end-user, or configured by the app owner)\nand is resolved only inside the platform broker \u2014 it never reaches the\nbrowser, the app bundle, actions, or the managed database.\n\n### Availability gate (fail-closed)\n\nManaged AI exists for this app **only** when the `[BACKEND STATUS]` preamble\nannounces `Managed AI: AVAILABLE` (equivalently: `agentful-backend-state`\nshows `managed_ai.available: true`). It also requires `database.mode ==\nmanaged` \u2014 the routes authenticate with the same end-user JWT as `/data/*`.\n\n**If it is NOT announced, the capability does not exist on this platform for\nthis app.** In that case:\n\n- Build the rest of the app; render AI features as clearly-labeled sample\n output (marked as sample), and tell the user plainly which platform\n capability is missing. Never wire a fake.\n- **NEVER improvise a substitute.** No collection that stores API keys\n (plain or \"encrypted\" \u2014 `api_key_enc` is the historical production\n failure), no cryptography in actions, no action that accepts an end-user's\n API key, no provider keys in `localStorage`/`sessionStorage`, no direct\n provider `fetch` from the browser with a user's key.\n- The one sanctioned alternative \u2014 only when `server.mode == managed` and\n the **owner** brings the key \u2014 is a Managed Action using\n `await ctx.secrets.get('server.openai_key')` + `ctx.fetch` to the\n provider (see Managed Actions above). That is app-funded, server-side AI;\n it must never accept or store end-user keys.\n\n### Two payment modes \u2014 decided at RUNTIME, not at build time\n\n`GET /ai/credentials` returns a `mode` field. Generated apps read it at\nruntime and adapt the UI; never hardcode the mode:\n\n- **`user_required`** (default): every logged-in end-user saves their own\n provider API key once; their key pays for their requests. UI: a small\n \"AI settings\" area with provider key form + presence/hint display.\n- **`project_shared`**: the app owner provides the provider access; end\n users never see a key form. UI: label the feature as provided by the app\n (e.g. \"AI features are provided by this app\") and handle the quota\n errors below. `PUT` answers `409 mode_project_shared` in this mode \u2014\n the app must not offer saving personal keys (existing personal keys stay\n listed and deletable).\n\nBoth modes require a **logged-in end-user** (`Authorization: Bearer` token\nfrom `auth/login`). Every `/ai/*` route answers `401 auth_required` without\nit \u2014 gate all AI UI behind login.\n\n### API surface\n\nBase: `/api/p/{PROJECT_ID}/ai/` \u2014 same envelope as the data API\n(`{ok:true, data:{\u2026}}` / `{ok:false, error:{code, message}}`).\n\n- `GET ai/credentials` \u2014 mode + stored-key metadata. **Never returns a key.**\n - `user_required`: `{mode:'user_required', credentials:[{provider, status,\n key_hint, created_at, updated_at, last_validated_at}]}`\n - `project_shared`: `{mode:'project_shared', provider, models:[\u2026],\n project_key_present, credentials:[\u2026]}`\n - `?include=providers` (user_required only): adds `providers:[{id, label,\n models:[\u2026]}]` \u2014 the live server-side allowlists. **Render provider and\n model pickers from this registry, never from a hardcoded list**: the\n platform adds models over time and a baked-in list ages with the app.\n Fetch it once per session (it is a few hundred model ids), cache it in\n state, and fall back to one cheap documented default per provider (table\n below) if the field is absent. In `project_shared` the response's pinned\n `provider`/`models` ARE the offer \u2014 no registry there.\n- `PUT ai/credentials/{provider}` \u2014 body `{api_key: '\u2026'}`. The platform\n validates the key against the real provider BEFORE storing; an invalid key\n is rejected (`credential_invalid`) and nothing is stored. Success returns\n metadata only: `{provider, status:'active', key_hint, updated_at}`.\n- `DELETE ai/credentials/{provider}` \u2014 `{provider, deleted:true}`.\n Idempotent; works in every mode.\n- `POST ai/generate` \u2014 body `{provider, model, prompt, system?, max_tokens?}`\n \u2192 `{text, provider, model, credential_mode, usage:{input_tokens,\n output_tokens}}`. Single-turn, non-streaming. In `project_shared`, omit\n `provider` (or send exactly the pinned one \u2014 any other id is refused, not\n redirected).\n\nBounded limits (enforced server-side): `prompt` \u2264 24000 chars, `system` \u2264\n4000 chars, `max_tokens` clamped to \u2264 4096 (default 1024), per-user request\nbudget (default 20 generate/min, 5 credential saves/min, 2 concurrent\ngenerations), provider timeout ~25 s. For chat features, keep conversation\nhistory client-side and include only the recent turns in `prompt`, trimming\nto stay under the cap.\n\n### Providers\n\n| provider id | example allowlisted models |\n|---|---|\n| `openai` | `gpt-4o-mini`, `gpt-4o`, `gpt-4.1-mini` |\n| `anthropic` | `claude-3-5-haiku-latest`, `claude-sonnet-4-0` |\n| `google` | `gemini-2.0-flash`, `gemini-2.5-flash` |\n| `deepseek` | `deepseek-chat`, `deepseek-reasoner` |\n| `zai` | `glm-5.3-flash`, `glm-5.3` |\n\nAdditional OpenAI-compatible providers are registered (`openrouter`,\n`fireworks-ai`, `moonshotai`, `nebius`, `baseten`, `siliconflow`). Models are\nallowlisted server-side per provider: an unknown provider or model returns\n`provider_not_allowed` / `model_not_allowed` **with the allowed list in the\nerror message** \u2014 surface that message. The table above lists stable\nDEFAULTS, not the full allowlists: the full, current lists come from\n`GET ai/credentials?include=providers` (see above) \u2014 build pickers from\nthat registry so the app never ages, and validate a stored model against it\n(fall back to the provider default when a stored id is no longer listed).\nPick ONE provider with a cheap default model per feature (e.g.\n`openai` + `gpt-4o-mini`) unless the user asks for a choice.\n\n### Client pattern\n\nAppend to the shared client (`src/lib/data.js` \u2014 reuses `jsonOrThrow` and\n`authHeader` from above):\n\n```js\nexport const ai = {\n // Runtime mode signal \u2014 call after login, before rendering AI UI.\n async status() {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials`, {\n headers: { ...authHeader() },\n })); // -> {mode, credentials, provider?, models?, project_key_present?}\n },\n async saveKey(provider, apiKey) {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials/${provider}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ api_key: apiKey }),\n })); // -> {provider, status:'active', key_hint, updated_at} \u2014 never the key\n },\n async deleteKey(provider) {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials/${provider}`, {\n method: 'DELETE', headers: { ...authHeader() },\n })); // -> {provider, deleted:true}\n },\n async generate({ provider, model, prompt, system, max_tokens }) {\n return jsonOrThrow(await fetch(`${BASE}/ai/generate`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ provider, model, prompt, system, max_tokens }),\n })); // -> {text, provider, model, credential_mode, usage}\n },\n};\n```\n\nMode-adaptive rendering, in every app with an AI feature:\n\n```js\nconst aiState = await ai.status();\nif (aiState.mode === 'project_shared') {\n // No key form. Label: \"AI features are provided by this app.\"\n // Generate with the pinned provider/models from aiState.\n} else {\n // user_required: render key settings (presence/hint) + generate UI\n // only when a credential for the chosen provider exists.\n}\n```\n\n### Settings UI rules (user_required)\n\n1. The key input is `type=\"password\"`. After a successful `saveKey`, **clear\n the input** and render the returned `key_hint` (last 4 chars) \u2014 the app\n never displays, stores, or echoes the full key anywhere.\n2. The key exists in exactly one place in the app: the in-flight `PUT` body.\n Never in `localStorage`/`sessionStorage`, never in app state beyond the\n form field, never in a collection, never in an action payload.\n3. Presence is what `GET ai/credentials` returns \u2014 render provider,\n `key_hint`, `updated_at`, and a delete button per stored credential.\n4. On `credential_invalid` during save: tell the user WHICH provider\n rejected the key and that nothing was stored (\"does the key match the\n selected provider?\"); do not retry automatically. Keep this message\n distinct from the runtime one (stored key expired/revoked \u2192 save a new\n key) \u2014 one generic text for both hides provider mismatches.\n5. The key save PUTs to the provider **currently selected in the form**,\n and persists that provider/model choice in the same action \u2014 never to a\n previously stored provider default. A key sent to yesterday's provider\n fails validation against the wrong API and reads like a broken key\n (real incident: FeedSum, 2026-09-01).\n6. Point users to their provider dashboard to create a key; the app never\n asks for anything but the key string.\n\n### Error code \u2192 user-facing handling\n\n| `error.code` | HTTP | Meaning | What the app does |\n|---|---|---|---|\n| `auth_required` | 401 | Not logged in | Show login; AI UI is login-gated. |\n| `feature_unavailable` | 503 | Platform AI is switched off | \"AI features are temporarily unavailable.\" Platform state \u2014 do not retry in a loop, nothing to fix in code. |\n| `no_credential` | 404 | user_required: no key stored for this provider | Open the AI settings / key form. |\n| `credential_invalid` | 400 | Provider rejected the key (save or generate) | user_required: ask for a new key. project_shared: \"Contact the app owner.\" |\n| `invalid_key_format` | 400 | Key string malformed (8\u2013512 chars, no whitespace) | Inline form validation message. |\n| `mode_project_shared` | 409 | PUT while the app provides AI | Hide/disable the key form (the mode signal was missed \u2014 re-check `status()`). |\n| `project_key_missing` | 503 | project_shared: owner has not configured the key yet | \"The app owner has not configured AI access yet.\" |\n| `project_quota_exhausted` | 429 | project_shared: app's daily AI budget used up | \"The app's daily AI budget is used up \u2014 resets at midnight UTC.\" |\n| `provider_not_allowed` / `model_not_allowed` | 400 | Provider/model not in the server allowlist | Surface the message (it names what IS allowed); fix the generated default. |\n| `missing_prompt` / `prompt_too_large` | 400/413 | Empty prompt / over the 24k-char cap | Trim client-side before sending. |\n| `rate_limited` / `concurrency_exceeded` | 429 | Per-user budget hit | \"Please wait a moment and try again.\" No auto-retry loops. |\n| `provider_rate_limited` | 429 | The provider rate-limited the key | Same message; the key's own quota is exhausted. |\n| `provider_timeout` | 504 | Provider took > ~25 s | \"The AI provider did not answer in time \u2014 try again.\" |\n| `provider_unreachable` / `provider_rejected` / `provider_error` | 502 | Provider-side failure | \"The AI provider had a problem \u2014 try again later.\" |\n| `credential_unreadable` | 500 | Stored credential cannot be decrypted | user_required: delete the key and save it again. project_shared: contact the app owner. |\n| `ai_config_invalid` | \u2014 | App's AI configuration is broken | \"Contact the app owner.\" |\n\n### project_shared \u2014 owner setup (tell the BUILDER, do not generate)\n\n`project_shared` is configured by the app **owner**, not by generated code:\nthe backend declaration (`.agentful/backend.json`, applied via `agentful\npush`) carries `\"ai\": {\"credential_mode\": \"project_shared\", \"provider\":\n\"openai\", \"daily_request_limit\": 500}`, and the owner stores the paying key\nin **Backend \u2192 Secrets** as `server.ai_<provider>_key` (e.g.\n`server.ai_openai_key`). Until the key is stored, `generate` answers\n`project_key_missing` \u2014 the declaration alone activates nothing. There is no\nengine CLI for this: when the user wants \"the app provides AI for all\nusers\", explain those two owner steps and generate the mode-adaptive UI \u2014\nwhich is correct in both modes anyway.\n\n### Managed AI vs. owner-key actions \u2014 pick the right lane\n\n- **End-user-facing text generation** (chat box, \"summarize this\",\n writing aids) \u2192 Managed AI routes. In `user_required` each user's key\n pays; in `project_shared` the owner pays with a mandatory daily quota.\n- **Server-side AI inside app logic** (a scheduled digest, enrichment\n before storing a document) with the OWNER's key \u2192 Managed Action with\n `ctx.secrets` + `ctx.fetch` (works independently of Managed AI\n availability).\n- **Never**: an action that receives an end-user's API key in its body, an\n action that proxies \"bring your own key\" traffic, or any app-built key\n storage. End-user keys are exclusively a platform concern.\n\n### Anti-patterns (Managed AI)\n\n- \u274C A `settings`/`api_keys` collection with an `api_key` or `api_key_enc`\n field \u2014 this is the FeedSum production incident. End-user keys never\n touch the managed database, in any encoding.\n- \u274C Hand-rolled cryptography anywhere (actions have no crypto primitive\n by design; the platform broker owns key handling).\n- \u274C Saving the key in `localStorage`/`sessionStorage` or app state \"so the\n user doesn't have to re-enter it\" \u2014 presence comes from `GET\n ai/credentials`, the key itself is never needed again client-side.\n- \u274C Calling `api.openai.com` (or any provider) directly from the browser\n with a user's key.\n- \u274C Rendering a key form without checking `mode` first, or hardcoding\n `project_shared` texts into a `user_required` app.\n- \u274C Retrying `credential_invalid`, `feature_unavailable`, or 429s in a\n loop.\n- \u274C Offering every provider/model in a giant dropdown. One provider, one\n default model, unless the user asks.\n",
|
|
7033
7035
|
"nextjs-scaffold": '---\nname: nextjs-scaffold\ndescription: Next.js 15 static export scaffold with React 19, TypeScript, and Tailwind CSS v4 generated manually without create-next-app. Use only for explicit Next.js requests or justified static export app needs.\n---\n\n# Next.js Scaffold\n\n## Stack Briefing\n\nNext.js 15 **static export** (`output: \'export\'`) + React 19 + TypeScript +\nTailwind v4, written manually (never `create-next-app`). Static export only \u2014\nno server actions, API routes, dynamic server rendering, or image optimization\ndependency. Build produces `out/` (not `dist/`). Use only for explicit Next or\njustified static multi-page React needs.\n\n## When To Use\n\nUse this skill when `selected_stack` is Next.js. Prefer Next.js only when the user explicitly asks for Next.js, asks for Next-specific features, or needs a React app architecture with static export and file-based routing.\n\nDo not use Next.js for a simple landing page or portfolio unless requested.\n\n## When Not To Use\n\n- Simple landing pages/portfolios \u2192 `vanilla-scaffold`.\n- General React app UI without Next-specific needs \u2192 `react-scaffold`.\n- Anything needing server actions, API routes, or SSR \u2014 unsupported here.\n\n## HARD STOP: Never Re-Scaffold An Existing App\n\nBefore writing ANY scaffold file, check the workspace. If `package.json` (or\nan existing `index.html` app shell for vanilla projects) and source files\nalready exist, this project already has an app \u2014 scaffolding is DONE and this\nskill must not overwrite it. Read the existing entry points and source tree\nfirst, then build ON TOP of the existing files: keep the entry points,\nrouting, and dependency choices already in place. Overwriting the scaffold\nfiles on an existing project destroys the user\'s app (this happened in\nproduction). If the existing code seems inconsistent with the request, ask\nthe user \u2014 never replace silently.\n\n## Required File Shape\n\nThis is the shape a Next.js project has here. Depending on how the project\nstarted, these files may already exist in the workspace \u2014 check first:\n\n```text\npackage.json build = `next build`; `npm run verify` runs the full gate\nnext.config.ts output: \'export\', trailingSlash, images.unoptimized\ntsconfig.json paths "@/*" -> ./src/*\neslint.config.mjs the only ESLint config in the project\npostcss.config.mjs\nnext-env.d.ts\npublic/\n favicon.svg keep it \u2014 without a favicon every page logs a 404\nsrc/\n app/\n layout.tsx owns <html>/<body> and the metadata\n page.tsx the root route: this IS the landing page\n globals.css THE global stylesheet \u2014 Tailwind by default\n.gitignore\n```\n\n**Empty workspace** \u2014 create exactly this shape.\n\n**Files already present** \u2014 **edit them, do not recreate them.** A project\nfrom `create-next-app` is close to this already; the usual gaps are the\n`Create Next App` metadata in `layout.tsx`, the demo SVGs in `public/`, and\nan `app/` directory at the root instead of under `src/`.\n\nThe build output is `out/`, not `dist/` \u2014 that is expected and the platform\naccepts it. Do not rename it.\n\nDo not use `next/font/google` by default because it can add build-time network dependency. Use system fonts unless the user explicitly requests custom fonts.\n\n## package.json\n\n```json\n{\n "name": "project-name",\n "private": true,\n "version": "0.1.0",\n "scripts": {\n "dev": "next dev",\n "build": "next build",\n "start": "next start",\n "verify": "tsc --noEmit && eslint . --max-warnings 0 && next build"\n },\n "dependencies": {\n "next": "16.2.9",\n "react": "19.2.4",\n "react-dom": "19.2.4"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4",\n "@types/node": "^20",\n "@types/react": "^19",\n "@types/react-dom": "^19",\n "eslint": "^9",\n "eslint-config-next": "16.2.9",\n "tailwindcss": "^4",\n "typescript": "^5"\n }\n}\n```\n\n## next.config.ts\n\n```ts\nimport type { NextConfig } from \'next\'\n\nconst nextConfig: NextConfig = {\n // Static export \u2014 the platform serves files, there is no Next.js server.\n output: \'export\',\n // Emits `about/index.html` instead of `about.html`, which a plain static\n // host can resolve without rewrite rules.\n trailingSlash: true,\n // next/image needs a server to optimise; without this the export fails.\n images: {\n unoptimized: true,\n },\n}\n\nexport default nextConfig\n```\n\n## postcss.config.mjs\n\n```js\nconst config = {\n plugins: {\n \'@tailwindcss/postcss\': {},\n },\n}\n\nexport default config\n```\n\n## App Files\n\n`src/app/layout.tsx`:\n\n```tsx\nimport type { Metadata } from \'next\'\nimport \'./globals.css\'\n\nexport const metadata: Metadata = {\n title: \'Replace this title\',\n description: \'Replace this with a one-sentence description of the site.\',\n icons: { icon: \'/favicon.svg\' },\n}\n\nexport default function RootLayout({\n children,\n}: Readonly<{\n children: React.ReactNode\n}>) {\n return (\n <html lang="en" className="h-full antialiased">\n <body className="min-h-full flex flex-col">{children}</body>\n </html>\n )\n}\n```\n\n`src/app/globals.css`:\n\n```css\n@import "tailwindcss";\n\n/* Design tokens. Tailwind v4 turns every entry here into a utility\n (--color-brand -> bg-brand/text-brand), so restyle the project by editing\n these values rather than sprinkling hex codes through the components. */\n@theme {\n --color-brand: #0070f3;\n --color-brand-contrast: #ffffff;\n --color-surface: #ffffff;\n --color-surface-muted: #f5f5f7;\n --color-ink: #111827;\n --color-ink-muted: #6b7280;\n --radius-card: 0.75rem;\n}\n\n:root {\n color-scheme: light;\n font-family: Inter, ui-sans-serif, system-ui, sans-serif;\n --app-bg: #ffffff;\n --app-fg: #111827;\n}\n\n/* An opaque background on html, body is mandatory \u2014 the page is never\n transparent. Keep this rule when you restyle. */\nhtml, body {\n margin: 0;\n min-height: 100%;\n min-width: 320px;\n background: var(--app-bg);\n color: var(--app-fg);\n}\n\n/* Keyboard focus baseline. Components may override it, but never remove it:\n the platform appends a generic fallback to any project whose CSS contains\n no `:focus` rule at all, and a real one belongs here. */\n:focus-visible {\n outline: 2px solid var(--color-brand, currentColor);\n outline-offset: 2px;\n}\n```\n\n## TypeScript Config\n\n`tsconfig.json`:\n\n```json\n{\n "compilerOptions": {\n "target": "ES2017",\n "lib": ["dom", "dom.iterable", "esnext"],\n "allowJs": true,\n "skipLibCheck": true,\n "strict": true,\n "noEmit": true,\n "esModuleInterop": true,\n "module": "esnext",\n "moduleResolution": "bundler",\n "resolveJsonModule": true,\n "isolatedModules": true,\n "jsx": "react-jsx",\n "incremental": true,\n "plugins": [{ "name": "next" }],\n "paths": { "@/*": ["./src/*"] }\n },\n "include": [\n "next-env.d.ts",\n "**/*.ts",\n "**/*.tsx",\n "**/*.mts",\n ".next/types/**/*.ts",\n ".next/dev/types/**/*.ts"\n ],\n "exclude": ["node_modules"]\n}\n```\n\n`next-env.d.ts`:\n\n```ts\n/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n```\n\n## Routing Rules\n\nUse file-based routing under `src/app/` with static export. Do not add\n`middleware`, dynamic server routes, or `generateStaticParams` that depend on a\nserver. All routes must be statically renderable.\n\n## Data And Backend Rules\n\n- No API routes or server actions (static export forbids them). For data/auth,\n use the platform backend via client `fetch()` \u2014 respect the STOP gate.\n- `images.unoptimized: true` is required; do not add the image optimization\n server dependency.\n\n## Asset Path Rules\n\n- Keep output host-agnostic; reference the project\'s own assets/routes\n relatively. Do not set an absolute `assetPrefix`/`basePath`. No `<base>` tag.\n\n## Common Failure Modes\n\n- Adding server actions/API routes \u2192 static export build fails.\n- Using `next/font/google` \u2192 build-time network dependency.\n- Renaming `out/` to `dist/` or adding postbuild move scripts.\n- Optimized `<Image>` without `unoptimized: true`.\n\n## ESLint Config\n\nEvery scaffold ships `eslint.config.mjs` (flat config) so the platform lint\ngate and the live ESLint diagnostics work from the very first turn. Next uses\n`eslint-config-next` rather than the hand-rolled rule set of `react-scaffold`\n\u2014 measured 2026-08-04, it covers strictly more:\n\n| canary | `eslint-config-next` |\n| --- | --- |\n| `useEffect(() => setV(item), [item])` | error \u2014 *"Calling setState synchronously within an effect"* (the react-scaffold class) |\n| `<img src="/x.png">` | error \u2014 `@next/next/no-img-element` (which the hand-rolled config never sees) |\n\n`eslint.config.mjs`:\n\n```js\nimport { defineConfig, globalIgnores } from "eslint/config";\nimport nextVitals from "eslint-config-next/core-web-vitals";\nimport nextTs from "eslint-config-next/typescript";\n\nconst eslintConfig = defineConfig([\n ...nextVitals,\n ...nextTs,\n // Override default ignores of eslint-config-next.\n globalIgnores([\n // Default ignores of eslint-config-next:\n ".next/**",\n "out/**",\n "build/**",\n "next-env.d.ts",\n ]),\n]);\n\nexport default eslintConfig;\n```\n\nThe platform runs `npx eslint . --max-warnings 0` before every build, so any\nviolation blocks the build. Do not add or remove rules, and never "fix" a\nviolation with `eslint-disable` or config edits \u2014 fix the code.\n\n## Verification\n\nRun install, then the full platform verification contract (see the build\nagent\'s "Build And Preview Rules"): typecheck, `npx eslint . --max-warnings 0`\n(the scaffold ships `eslint.config.mjs`, so this check ALWAYS applies and\ndecides acceptance), `npm run\nbuild`, and finally verify that `out/index.html` exists. Do not rename `out/`\nto `dist/`. `npm run build` alone is not what the platform gate checks \u2014 and\nNext\'s own build only surfaces lint when configured to. Never make the lint\nstep pass by disabling rules.\n',
|
|
7034
7036
|
"react-scaffold": '---\nname: react-scaffold\ndescription: React 19 with Vite, TypeScript, and Tailwind CSS v4 scaffold generated manually without npm create. Use for app UIs, dashboards, auth flows, CRUD interfaces, or explicit React requests.\n---\n\n# React Scaffold\n\n## Stack Briefing\n\nReact 19 + Vite + TypeScript, written manually (never `npm create`), with\nTailwind v4 as the default styling layer. Use it for app-like UIs where\ncomponent state and interaction justify a framework. Output must stay\nstatic-hostable: `base: \'/\'`, history routing (clean URLs, no `#`), build to\n`dist/`. Do not over-split into dozens of trivial components \u2014 keep the tree\npragmatic and typed.\n\n## When To Use\n\nUse this skill when `selected_stack` is React. React is appropriate for app-like interfaces, dashboard/admin UIs, authenticated user flows, complex client state, CRUD screens, and explicit React requests.\n\nDo not use React just because the workspace is empty. Static marketing/content sites should usually use `vanilla-scaffold`.\n\n## When Not To Use\n\n- Static marketing/content/portfolio sites \u2192 `vanilla-scaffold`.\n- Content-heavy multi-page sites better served by Astro \u2192 `astro-scaffold`.\n- Anything requiring SSR/server rendering \u2014 output here is static export only.\n\n## HARD STOP: Never Re-Scaffold An Existing App\n\nBefore writing ANY scaffold file, check the workspace. If `package.json` and\n`src/` already exist, this project already has an app \u2014 scaffolding is DONE\nand this skill must not overwrite it. Read the existing `package.json`,\n`src/App.tsx` and the `src/` tree first, then build ON TOP of the existing\nfiles: keep the entry points, routing, and dependency choices already in\nplace. Overwriting `package.json`/`App.tsx`/`main.tsx` on an existing project\ndestroys the user\'s app (this happened in production). If the existing code\nseems inconsistent with the request, ask the user \u2014 never replace silently.\n\n**Rewriting a file must never drop an `import "./x.css"` it carried.** Nothing\ncatches it: tsc/eslint do not read CSS, an unimported stylesheet is legal so\nthe bundler is silent, and the page renders unstyled (prod 2026-08-01: 38 of\n85 class names left the bundle, all gates green). Prefer `edit` over a full\n`write`; if you rewrite, carry the original import block over.\n\n## Required File Shape\n\nThis is the shape a React project has here. Depending on how the project\nstarted, these files may already exist in the workspace \u2014 check first:\n\n```text\nindex.html entry: favicon link, #root, module script\npackage.json build = `vite build`; `npm run verify` runs the full gate\nvite.config.ts base: \'/\', tailwindcss() + react()\ntsconfig.json ONE flat config, include: ["src"]\neslint.config.mjs the only ESLint config in the project\npublic/\n favicon.svg keep it \u2014 without a favicon every page logs a 404\nsrc/\n App.tsx placeholder shell: header/nav, hero, cards, footer\n main.tsx entry, imports ./index.css\n index.css THE stylesheet \u2014 Tailwind by default, see Styling\n vite-env.d.ts\n.gitignore\n```\n\n**Empty workspace** \u2014 create exactly this shape.\n\n**Files already present** \u2014 **edit them, do not recreate them.** Rewrite the\ncopy, restyle the `@theme` tokens, add components and routes, but keep the\nshape. Each part of it prevents a defect that has shipped to real users.\n\n**A project that came from an older `create-vite` starter** carries config\nthat silently disables checks the platform believes it ran. Repair config\nonly \u2014 never a full-file `write` of `App.tsx`, `main.tsx` or `package.json`,\nand never as a pretext to re-scaffold:\n\n| found | do |\n| --- | --- |\n| `tsconfig.json` with `"files": []` + `references` | always replace with the one flat config below and delete `tsconfig.app.json`/`tsconfig.node.json` \u2014 otherwise nothing is type-checked at all |\n| `eslint.config.js` | leave it alone if it is the only config. If you need the rule set below, move its contents into `eslint.config.mjs` and **delete the `.js`** \u2014 never let both exist |\n| `src/App.css` | leave a working import alone. If you rewrite `App.tsx`, carry `import \'./App.css\'` over, or fold the rules into `index.css` in the same edit \u2014 never drop it silently |\n\nSay in your summary which of these you changed and why.\n\n### One Stylesheet, Not Two\n\n`src/index.css` is the only stylesheet. Do **not** add `src/App.css`.\n\nA second stylesheet has to be imported from a component, and that import is\nthe single most fragile line in the project: one full-file `write` of\n`App.tsx` drops it, and nothing notices (prod `0njsblk0lye2vsx`, 2026-08-01 \u2014\nthe layout stylesheet left the bundle, every gate stayed green). With one\nstylesheet imported once from `main.tsx`, the failure has nowhere to happen.\n\nPut component styles wherever the chosen approach puts them \u2014 Tailwind\nclasses by default \u2014 and shared values in `index.css`.\n\n## package.json\n\n```json\n{\n "name": "project-name",\n "private": true,\n "version": "0.1.0",\n "type": "module",\n "scripts": {\n "dev": "vite",\n "build": "vite build",\n "preview": "vite preview",\n "verify": "tsc --noEmit && eslint . --max-warnings 0 && vite build"\n },\n "dependencies": {\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/vite": "^4.0.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "@vitejs/plugin-react": "^4.3.4",\n "eslint": "^9.0.0",\n "eslint-plugin-react-hooks": "^6.0.0",\n "tailwindcss": "^4.0.0",\n "typescript": "^5.0.0",\n "typescript-eslint": "^8.0.0",\n "vite": "^6.0.0"\n }\n}\n```\n\n`build` is `vite build` alone \u2014 no `tsc` in front of it. The platform runs the\ntype check itself before the build, and a `tsc` inside the build script also\nblocks the one-time build that runs when a starter is imported. `verify` is\nthe gate in one command; run it, not just `npm run build`.\n\n## vite.config.ts\n\n```ts\nimport { defineConfig } from \'vite\'\nimport react from \'@vitejs/plugin-react\'\nimport tailwindcss from \'@tailwindcss/vite\'\n\nexport default defineConfig({\n base: \'/\',\n plugins: [tailwindcss(), react()],\n})\n```\n\n## Entry Files\n\n`index.html`:\n\n```html\n<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <meta name="description" content="Replace this with a one-sentence description of the app." />\n <link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n <title>Replace this title</title>\n </head>\n <body>\n <div id="root"></div>\n <script type="module" src="./src/main.tsx"></script>\n </body>\n</html>\n```\n\nKeep the favicon link and keep `public/favicon.svg`. Without them the browser\nrequests `/favicon.ico` on every page load and the render check records a\nfailed request on an otherwise healthy project.\n\n`src/main.tsx`:\n\n```tsx\nimport React from \'react\'\nimport ReactDOM from \'react-dom/client\'\nimport App from \'./App\'\nimport \'./index.css\'\n\nReactDOM.createRoot(document.getElementById(\'root\')!).render(\n <React.StrictMode>\n <App />\n </React.StrictMode>,\n)\n```\n\n`src/index.css` as the starter ships it \u2014 Tailwind first, then tokens, then\nthe opaque page baseline:\n\n```css\n@import "tailwindcss";\n\n/* Design tokens. Tailwind v4 turns every entry here into a utility\n (--color-brand -> bg-brand/text-brand), so restyle the project by editing\n these values rather than sprinkling hex codes through the components. */\n@theme {\n --color-brand: #4f46e5;\n --color-brand-contrast: #ffffff;\n --color-surface: #ffffff;\n --color-surface-muted: #f5f5f7;\n --color-ink: #111827;\n --color-ink-muted: #6b7280;\n --radius-card: 0.75rem;\n}\n\n:root {\n color-scheme: light;\n font-family: Inter, ui-sans-serif, system-ui, sans-serif;\n --app-bg: #ffffff;\n --app-fg: #111827;\n}\n\n/* An opaque background on html, body is mandatory \u2014 the page is never\n transparent. Keep this rule when you restyle. */\nhtml, body {\n margin: 0;\n min-height: 100%;\n min-width: 320px;\n background: var(--app-bg);\n color: var(--app-fg);\n}\n\n/* Keyboard focus baseline. Components may override it, but never remove it:\n the platform appends a generic fallback to any project whose CSS contains\n no `:focus` rule at all, and a real one belongs here. */\n:focus-visible {\n outline: 2px solid var(--color-brand, currentColor);\n outline-offset: 2px;\n}\n```\n\nEvery `@theme` entry becomes a utility (`--color-brand` \u2192 `bg-brand`), so\nrestyle by editing tokens instead of scattering hex codes through components.\nIf the user chose a non-Tailwind approach, keep `:root` and the `html, body`\nrule and replace the rest \u2014 the opaque background is not optional.\n\n`src/vite-env.d.ts`:\n\n```ts\n/// <reference types="vite/client" />\n```\n\n## TypeScript Config\n\nOne flat `tsconfig.json` with `include`, no project references and no\n`tsconfig.app.json`/`tsconfig.node.json`.\n\nThis is not a style preference. The platform gate runs `tsc --noEmit` against\nthe root `tsconfig.json`. A `create-vite`-style root \u2014 `"files": []` plus\n`references` \u2014 makes that command compile **zero files**: it exits 0 on a\nproject full of type errors, and the gate reports a pass it never performed.\nMeasured 2026-08-04: a deliberate type error passes the referenced shape and\nfails the flat one. `vite.config.ts` is deliberately not type-checked.\n\n`tsconfig.json`:\n\n```json\n{\n "compilerOptions": {\n "target": "ES2020",\n "useDefineForClassFields": true,\n "lib": ["ES2020", "DOM", "DOM.Iterable"],\n "module": "ESNext",\n "skipLibCheck": true,\n "moduleResolution": "bundler",\n "allowImportingTsExtensions": true,\n "resolveJsonModule": true,\n "isolatedModules": true,\n "noEmit": true,\n "jsx": "react-jsx",\n "strict": true\n },\n "include": ["src"]\n}\n```\n\n## ESLint Config\n\nEvery scaffold ships **exactly one** ESLint config, `eslint.config.mjs` (flat\nconfig), so the platform lint gate and the live ESLint diagnostics work from\nthe first turn. Keep it exactly this minimal \u2014 correctness rules only, no\nstylistic rules, nothing that fights Prettier:\n\n`eslint.config.mjs`:\n\n```js\nimport tseslint from \'typescript-eslint\'\nimport reactHooks from \'eslint-plugin-react-hooks\'\n\nexport default tseslint.config(\n { ignores: [\'dist\'] },\n {\n files: [\'**/*.{ts,tsx}\'],\n extends: [tseslint.configs.base],\n plugins: { \'react-hooks\': reactHooks },\n rules: {\n \'react-hooks/rules-of-hooks\': \'error\',\n \'react-hooks/exhaustive-deps\': \'error\',\n \'react-hooks/set-state-in-effect\': \'error\',\n \'react-hooks/no-deriving-state-in-effects\': \'error\',\n \'@typescript-eslint/no-unused-vars\': [\n \'error\',\n { argsIgnorePattern: \'^_\', varsIgnorePattern: \'^_\' },\n ],\n },\n },\n)\n```\n\n**Never add a second config file.** ESLint resolves `eslint.config.js` before\n`eslint.config.mjs` and uses only the first one it finds, silently. A stray\n`.js` next to the `.mjs` therefore disables every rule above without a\nwarning \u2014 measured on ESLint 9: a violation that fails with the `.mjs` alone\npasses when both files exist. So in an existing project, either keep its\n`eslint.config.js` as the single config, or migrate it into\n`eslint.config.mjs` and delete the `.js` \u2014 never leave both behind.\n\nThe platform runs `npx eslint . --max-warnings 0` before every build, so any\nviolation blocks the build. Do not add rules, do not remove rules, and never\n"fix" a violation with `eslint-disable` or config edits \u2014 fix the code (see\nthe hooks patterns below).\n\n## Allowed Complexity\n\n- Add `src/components/`, `src/pages/`, `src/lib/`, `src/hooks/` only when a\n feature needs them \u2014 do not scaffold empty folders.\n- Introduce state libraries or data-fetching only when real shared state or\n server data exists; local `useState`/`useReducer` covers most cases.\n- Keep components typed (props/return types); avoid `any`.\n\n## CSS And Styling Expectations\n\nThe starter ships Tailwind v4 via `@tailwindcss/vite`, and that is the\ndefault: keep it and tokenize theme values in `@theme`.\n\n**The user\'s request wins.** If they ask for a different styling approach,\nfollow them and adjust the setup in the same turn:\n\n- **shadcn/ui** \u2014 is built ON Tailwind. Keep Tailwind, add the components.\n Never remove Tailwind to "make room" for it.\n- **A CSS-in-JS library** (Chakra, MUI, styled-components, Emotion) \u2014 add it\n and build with it. Leaving Tailwind installed is harmless (v4 emits only\n the classes you use), so remove it only if the user asks.\n- **Plain CSS / CSS Modules** \u2014 drop `@tailwindcss/vite` and `tailwindcss`\n from `package.json`, remove the plugin from `vite.config.ts`, and replace\n `@import "tailwindcss"` in `index.css` with your own base styles. Keep the\n `:root` tokens and the opaque `html, body` rule.\n\nWhatever the approach, these hold:\n\n- **One stylesheet** \u2014 the rule below is about losing an import, not about\n Tailwind. It applies to plain CSS just as much.\n- Mobile-first, responsive layouts; visible focus states; WCAG AA contrast.\n- The loaded `style-*` skill governs the visual language \u2014 apply its recipe.\n It is written to be independent of the styling technology.\n\n## Routing Rules\n\nIf adding React Router, use `BrowserRouter` (history mode) with `base: \'/\'` in\n`vite.config.ts`. The platform serves at the domain root and falls unknown\ndeep-links back to `index.html`, so routes resolve on hard refresh with clean\nURLs (no `#`). Never use `HashRouter`. Add `react-router-dom` only when routing\nis actually needed.\n\n## Data And Backend Rules\n\n- No database/server calls unless a backend is configured (respect the build\n agent\'s STOP gate). Do not invent mock backends or fake data arrays.\n- Keep secrets out of client code; only public keys via `.env.local`.\n\n## Asset Path Rules\n\n- Set `base: \'/\'` in `vite.config.ts`. The platform serves the project at the\n domain root, and history-mode routes need root-absolute assets.\n- Reference the project\'s own assets/routes with root-absolute paths (`/assets/...`);\n no `<base>` tag, no hardcoded platform URLs.\n\n## Verification\n\nRun install, then the full platform verification contract (see the build\nagent\'s "Build And Preview Rules"), not just `npm run build`:\n\n1. `npx tsc --noEmit`\n2. `npx eslint . --max-warnings 0` \u2014 a project scaffolded from this skill\n ships `eslint.config.mjs`, so this check ALWAYS applies and decides\n acceptance.\n3. `npm run build`\n4. `dist/index.html` exists\n\n`npm run verify` runs 1\u20133 in order. Never make step 2 pass by disabling rules\nor adding `eslint-disable`.\n\n## React Hooks Rules That Fail The Gate\n\nThe scaffold\'s `eslint.config.mjs` enables `eslint-plugin-react-hooks` rules\nthat reject patterns which compile and build fine. These are the ones that\nactually show up \u2014 avoid them while writing, not after:\n\n- **Never sync props into state inside an effect**\n (`useEffect(() => setForm(props.item), [props.item])` \u2192 `set-state-in-effect`).\n Derive the value during render, lift the state up, or remount the subtree\n with a `key` when the edited record changes \u2014 that is usually the intended\n "reset the form" semantic anyway.\n- **Do not open dialogs/editors from an effect that watches the URL.** Derive\n the open state from the route during render, or set it in the event handler\n that triggered the navigation.\n- **Do not bootstrap an external store from a render-time side effect.** Read\n it with `useSyncExternalStore`, or initialize it in the store module itself.\n- **Async loads:** only update state after an await when the effect has not\n been cleaned up (cancellation flag or `AbortController`).\n- **No impure render calls** \u2014 no `Math.random()`, `Date.now()`, or mutation\n during render. Move them into a lazy initializer or an event handler.\n- **Fast Refresh:** a module that exports a component must not also export\n unrelated non-component values (`react-refresh/only-export-components`).\n\nA `setTimeout`/microtask wrapper that only hides the violation from the linter\nis not a fix \u2014 it hides the same bug behind a race.\n\n## Common Failure Modes\n\n- Recreating the starter\'s files instead of editing them.\n- Adding `src/App.css` (or any second stylesheet) \u2014 see "One Stylesheet".\n- Adding `eslint.config.js` beside `eslint.config.mjs`, which silently\n disables the rule set.\n- Restoring the `create-vite` tsconfig trio, which makes the type check inert.\n- Putting `tsc` back into the `build` script.\n- Deleting `public/favicon.svg` or its `<link rel="icon">`.\n- Using `HashRouter` \u2192 ugly `#` URLs; use `BrowserRouter` (history mode).\n- Keeping relative `base: \'./\'` with history routing \u2192 assets 404 on a deep-link\n hard refresh; use `base: \'/\'`.\n- Leaving the starter\'s placeholder copy ("Replace this headline", "Brand",\n "First point") in the shipped page.\n- Over-splitting into trivial components; untyped `any` props.\n- Treating pre-install JSX type errors as source bugs (install deps first).\n',
|
|
7035
7037
|
"vue-scaffold": '---\nname: vue-scaffold\ndescription: Vue 3.5 with Vite, TypeScript, and Tailwind CSS v4 scaffold generated manually without npm create. Use for explicit Vue requests or existing Vue projects.\n---\n\n# Vue Scaffold\n\n## Stack Briefing\n\nVue 3.5 + Vite + TypeScript + Tailwind v4, written manually (never `npm create`).\nUse it for app-like Vue UIs. Output must stay static-hostable: `base: \'/\'`,\nhistory mode (clean URLs, no `#`), build to `dist/`. Use the Composition API; add Pinia or Vue Router\nonly when real shared state or routing actually exists.\n\n## When To Use\n\nUse this skill when `selected_stack` is Vue. Prefer Vue only when requested, when the existing project uses Vue, or when the user clearly wants a Vue-style app.\n\n## When Not To Use\n\n- Static marketing/content/portfolio sites \u2192 `vanilla-scaffold`.\n- Content-heavy multi-page sites \u2192 `astro-scaffold`.\n- Anything needing SSR \u2014 output here is static only.\n\n## HARD STOP: Never Re-Scaffold An Existing App\n\nBefore writing ANY scaffold file, check the workspace. If `package.json` (or\nan existing `index.html` app shell for vanilla projects) and source files\nalready exist, this project already has an app \u2014 scaffolding is DONE and this\nskill must not overwrite it. Read the existing entry points and source tree\nfirst, then build ON TOP of the existing files: keep the entry points,\nrouting, and dependency choices already in place. Overwriting the scaffold\nfiles on an existing project destroys the user\'s app (this happened in\nproduction). If the existing code seems inconsistent with the request, ask\nthe user \u2014 never replace silently.\n\n## Required File Shape\n\nThis is the shape a Vue project has here. Depending on how the project\nstarted, these files may already exist in the workspace \u2014 check first:\n\n```text\nindex.html entry: favicon link, #app, module script\npackage.json build = `vite build`; `npm run verify` runs the full gate\nvite.config.ts base: \'/\', tailwindcss() + vue()\ntsconfig.json ONE flat config, no references\neslint.config.mjs the only ESLint config in the project\npublic/\n favicon.svg keep it \u2014 without a favicon every page logs a 404\nsrc/\n App.vue placeholder shell: header/nav, hero, cards, footer\n main.ts entry, imports ./style.css\n style.css THE stylesheet \u2014 Tailwind by default\n env.d.ts vite/client types (inside src/, so the include glob covers it)\n shims-vue.d.ts lets plain `tsc` resolve *.vue \u2014 see below, load-bearing\n.gitignore\n```\n\n**Empty workspace** \u2014 create exactly this shape.\n\n**Files already present** \u2014 **edit them, do not recreate them.** Rewrite the\ncopy, restyle the `@theme` tokens, add components and routes, but keep the\nshape.\n\n**A project from an older `create-vue` starter** ships config that silently\ndisables checks the platform believes it ran. Repair config only \u2014 never a\nfull-file `write` of `App.vue`, `main.ts` or `package.json`:\n\n| found | do |\n| --- | --- |\n| `tsconfig.json` with `"files": []` + `references` | replace with the flat config below, delete `tsconfig.app.json`/`tsconfig.node.json`, and add `src/shims-vue.d.ts` \u2014 the trio makes the type check inert, but removing it without the shim makes it fail |\n| no ESLint config at all | add `eslint.config.mjs` below \u2014 without it the lint gate is inert and never sees a template bug |\n| `src/router/index.ts` with `routes: []`, an unused store | delete them, or give them real routes/state |\n\nCreate `src/components/`, `src/views/`, `src/router/`, or `src/stores/` only when needed.\n\n## package.json\n\n```json\n{\n "name": "project-name",\n "private": true,\n "version": "0.1.0",\n "type": "module",\n "scripts": {\n "dev": "vite",\n "build": "vite build",\n "preview": "vite preview",\n "verify": "vue-tsc --noEmit && eslint . --max-warnings 0 && vite build"\n },\n "dependencies": {\n "vue": "^3.5.0"\n },\n "devDependencies": {\n "@tailwindcss/vite": "^4.0.0",\n "@vitejs/plugin-vue": "^6.0.0",\n "eslint": "^9.0.0",\n "eslint-plugin-vue": "^10.0.0",\n "tailwindcss": "^4.0.0",\n "typescript": "^5.0.0",\n "typescript-eslint": "^8.0.0",\n "vite": "^6.0.0",\n "vue-tsc": "^2.2.0"\n }\n}\n```\n\n## vite.config.ts\n\n```ts\nimport { defineConfig } from \'vite\'\nimport vue from \'@vitejs/plugin-vue\'\nimport tailwindcss from \'@tailwindcss/vite\'\n\nexport default defineConfig({\n base: \'/\',\n plugins: [tailwindcss(), vue()],\n})\n```\n\n## Entry Files\n\n`index.html`:\n\n```html\n<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <meta name="description" content="Replace this with a one-sentence description of the app." />\n <link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n <title>Replace this title</title>\n </head>\n <body>\n <div id="app"></div>\n <script type="module" src="./src/main.ts"></script>\n </body>\n</html>\n```\n\n`src/main.ts`:\n\n```ts\nimport { createApp } from \'vue\'\nimport App from \'./App.vue\'\nimport \'./style.css\'\n\ncreateApp(App).mount(\'#app\')\n```\n\n`src/style.css`:\n\n```css\n@import "tailwindcss";\n\n/* Design tokens. Tailwind v4 turns every entry here into a utility\n (--color-brand -> bg-brand/text-brand), so restyle the project by editing\n these values rather than sprinkling hex codes through the components. */\n@theme {\n --color-brand: #41b883;\n --color-brand-contrast: #ffffff;\n --color-surface: #ffffff;\n --color-surface-muted: #f5f5f7;\n --color-ink: #111827;\n --color-ink-muted: #6b7280;\n --radius-card: 0.75rem;\n}\n\n:root {\n color-scheme: light;\n font-family: Inter, ui-sans-serif, system-ui, sans-serif;\n --app-bg: #ffffff;\n --app-fg: #111827;\n}\n\n/* An opaque background on html, body is mandatory \u2014 the page is never\n transparent. Keep this rule when you restyle. */\nhtml, body {\n margin: 0;\n min-height: 100%;\n min-width: 320px;\n background: var(--app-bg);\n color: var(--app-fg);\n}\n\n/* Keyboard focus baseline. Components may override it, but never remove it:\n the platform appends a generic fallback to any project whose CSS contains\n no `:focus` rule at all, and a real one belongs here. */\n:focus-visible {\n outline: 2px solid var(--color-brand, currentColor);\n outline-offset: 2px;\n}\n```\n\nTailwind is the **default**, not a mandate \u2014 the same rule as elsewhere: if\nthe user asks for a different styling approach, follow them and adjust the\nsetup in the same turn. Vue SFC `<style scoped>` blocks are idiomatic and fine\nalongside it; the thing to avoid is a second global stylesheet imported from a\ncomponent, because one full-file rewrite drops that import silently.\n\n## TypeScript Config\n\nOne flat `tsconfig.json` with `include`, no project references and no\n`tsconfig.app.json`/`tsconfig.node.json`.\n\nThis is not a style preference. The platform gate runs `tsc --noEmit` against\nthe root `tsconfig.json`. A `create-vue`-style root \u2014 `"files": []` plus\n`references` \u2014 makes that command compile **zero files**: it exits 0 on a\nproject full of type errors and the gate reports a pass it never performed.\n\n`vite.config.ts` is deliberately not type-checked.\n\n### `src/shims-vue.d.ts` Is Load-Bearing\n\nThe gate runs `tsc`, **never `vue-tsc`** \u2014 and plain `tsc` cannot resolve an\nimport of a `.vue` file. Without the shim the very first line of `main.ts`\nfails the gate on a phantom error (measured 2026-08-04):\n\n```text\nsrc/main.ts(2,17): error TS2307: Cannot find module \'./App.vue\'\n```\n\nSo the flat tsconfig and the shim are one change: applying either alone is\nworse than the broken state it replaces. Ship both.\n\n`src/shims-vue.d.ts`:\n\n```ts\n/* Lets plain `tsc` resolve `*.vue` imports.\n *\n * Load-bearing: the platform\'s prebuild gate runs `tsc --noEmit`, never\n * `vue-tsc`. Without this shim it stops at\n * src/main.ts: error TS2307: Cannot find module \'./App.vue\'\n * and every build fails on a phantom error.\n *\n * The shim types an SFC as a generic component, so `tsc` checks all `.ts`\n * files properly but not the internals of a `.vue` file. `npm run verify`\n * runs `vue-tsc --noEmit`, which does check those \u2014 use it before finishing.\n */\ndeclare module \'*.vue\' {\n import type { DefineComponent } from \'vue\'\n const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>\n export default component\n}\n```\n\nWhat this buys and what it does not \u2014 all three measured:\n\n| | `tsc` (the gate) | `vue-tsc` (`npm run verify`) |\n| --- | --- | --- |\n| type error in a `.ts` file | caught | caught |\n| type error inside a `.vue` SFC | **missed** \u2014 the shim types it generically | caught |\n\nThat is why `verify` runs `vue-tsc` and why passing the gate is not the same\nas being done.\n\n`tsconfig.json`:\n\n```json\n{\n "compilerOptions": {\n "target": "ES2020",\n "useDefineForClassFields": true,\n "module": "ESNext",\n "lib": ["ES2020", "DOM", "DOM.Iterable"],\n "skipLibCheck": true,\n "moduleResolution": "bundler",\n "allowImportingTsExtensions": true,\n "resolveJsonModule": true,\n "isolatedModules": true,\n "noEmit": true,\n "jsx": "preserve",\n "strict": true\n },\n "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]\n}\n```\n\n## Routing Rules\n\nIf adding Vue Router, use `createWebHistory()` (history mode) with `base: \'/\'` in\n`vite.config.ts`. The platform serves at the domain root and falls unknown\ndeep-links back to `index.html`, so routes resolve on hard refresh with clean\nURLs (no `#`). Never use `createWebHashHistory()`. Add `vue-router` only when\nrouting is needed.\n\n## Data And Backend Rules\n\n- No database/server calls unless a backend is configured (respect the build\n agent\'s STOP gate). Do not invent mock backends or fake data arrays.\n- Keep secrets out of client code; only public keys via `.env.local`.\n\n## Asset Path Rules\n\n- Set `base: \'/\'`. The platform serves the project at the domain root, and\n history-mode routes need root-absolute assets. Reference the project\'s own\n assets/routes with root-absolute paths. No `<base>` tag, no hardcoded platform URLs.\n\n## Common Failure Modes\n\n- Using `createWebHashHistory()` \u2192 ugly `#` URLs; use `createWebHistory()`.\n- Keeping relative `base: \'./\'` with history routing \u2192 assets 404 on a deep-link\n hard refresh; use `base: \'/\'`.\n- Leaving starter boilerplate in `App.vue`.\n- Adding Pinia/Router with no real need.\n\n## ESLint Config\n\nEvery scaffold ships `eslint.config.mjs` (flat config) so the platform lint\ngate and the live ESLint diagnostics work from the very first turn. Verified\nempirically 2026-08-02: `flat/essential` catches real template bugs\n(`vue/require-v-for-key` etc.); `vue/multi-word-component-names` is switched\nOFF because with `--max-warnings 0` it would block every `Hero.vue`-style\nsingle-word component \u2014 a naming convention, not a correctness rule:\n\n`eslint.config.mjs`:\n\n```js\nimport tseslint from \'typescript-eslint\'\nimport vue from \'eslint-plugin-vue\'\n\nexport default tseslint.config(\n { ignores: [\'dist\'] },\n {\n files: [\'**/*.ts\'],\n extends: [tseslint.configs.base],\n rules: {\n \'@typescript-eslint/no-unused-vars\': [\n \'error\',\n { argsIgnorePattern: \'^_\', varsIgnorePattern: \'^_\' },\n ],\n },\n },\n ...vue.configs[\'flat/essential\'],\n {\n files: [\'**/*.vue\'],\n languageOptions: { parserOptions: { parser: tseslint.parser } },\n rules: {\n \'vue/multi-word-component-names\': \'off\',\n },\n },\n)\n```\n\nThe platform runs `npx eslint . --max-warnings 0` before every build, so any\nviolation blocks the build. Do not add or remove rules, and never "fix" a\nviolation with `eslint-disable` or config edits \u2014 fix the code.\n\n## Verification\n\nRun install, then the full platform verification contract (see the build\nagent\'s "Build And Preview Rules"): typecheck, `npx eslint . --max-warnings 0`\n(the scaffold ships `eslint.config.mjs`, so this check ALWAYS applies and\ndecides acceptance), `npm run build`, and finally verify that\n`dist/index.html` exists. `npm run build` alone is not what the platform gate\nchecks. Never make the lint step pass by disabling rules.\n',
|
|
@@ -7301,6 +7303,42 @@ function writeLocalProvidersExample() {
|
|
|
7301
7303
|
var import_node_fs15 = require("fs");
|
|
7302
7304
|
var import_node_path17 = require("path");
|
|
7303
7305
|
var import_node_child_process4 = require("child_process");
|
|
7306
|
+
function parseSessionModel(raw) {
|
|
7307
|
+
if (!raw) return null;
|
|
7308
|
+
try {
|
|
7309
|
+
const parsed = JSON.parse(raw);
|
|
7310
|
+
const modelID = typeof parsed.modelID === "string" ? parsed.modelID : parsed.id;
|
|
7311
|
+
if (typeof parsed.providerID !== "string" || typeof modelID !== "string") return null;
|
|
7312
|
+
return {
|
|
7313
|
+
model: `${parsed.providerID}/${modelID}`,
|
|
7314
|
+
...typeof parsed.variant === "string" && parsed.variant ? { variant: parsed.variant } : {}
|
|
7315
|
+
};
|
|
7316
|
+
} catch {
|
|
7317
|
+
return null;
|
|
7318
|
+
}
|
|
7319
|
+
}
|
|
7320
|
+
function modelForSession(sessionID) {
|
|
7321
|
+
const dbPath = (0, import_node_path17.join)(engineXdg().dataHome, "opencode", "opencode.db");
|
|
7322
|
+
if (!(0, import_node_fs15.existsSync)(dbPath)) return null;
|
|
7323
|
+
const escaped = sessionID.replace(/'/g, "''");
|
|
7324
|
+
const res = (0, import_node_child_process4.spawnSync)(
|
|
7325
|
+
"sqlite3",
|
|
7326
|
+
[
|
|
7327
|
+
"-readonly",
|
|
7328
|
+
"-json",
|
|
7329
|
+
dbPath,
|
|
7330
|
+
`SELECT model FROM session WHERE id = '${escaped}' LIMIT 1;`
|
|
7331
|
+
],
|
|
7332
|
+
{ encoding: "utf8" }
|
|
7333
|
+
);
|
|
7334
|
+
if (res.status !== 0 || !res.stdout.trim()) return null;
|
|
7335
|
+
try {
|
|
7336
|
+
const rows = JSON.parse(res.stdout);
|
|
7337
|
+
return parseSessionModel(rows[0]?.model);
|
|
7338
|
+
} catch {
|
|
7339
|
+
return null;
|
|
7340
|
+
}
|
|
7341
|
+
}
|
|
7304
7342
|
function lastSessionForDirectory(cwd = process.cwd()) {
|
|
7305
7343
|
const dbPath = (0, import_node_path17.join)(engineXdg().dataHome, "opencode", "opencode.db");
|
|
7306
7344
|
if (!(0, import_node_fs15.existsSync)(dbPath)) return null;
|
|
@@ -7328,7 +7366,461 @@ function lastSessionForDirectory(cwd = process.cwd()) {
|
|
|
7328
7366
|
}
|
|
7329
7367
|
}
|
|
7330
7368
|
|
|
7369
|
+
// src/lib/modelPin.ts
|
|
7370
|
+
var import_node_fs16 = require("fs");
|
|
7371
|
+
var import_node_path18 = require("path");
|
|
7372
|
+
var PROJECT_MODEL_PIN = (0, import_node_path18.join)(".agentful", "model.json");
|
|
7373
|
+
function isCanonicalModel(value) {
|
|
7374
|
+
const slash = value.indexOf("/");
|
|
7375
|
+
return slash > 0 && slash < value.length - 1 && !/\s/.test(value);
|
|
7376
|
+
}
|
|
7377
|
+
function isVariant(value) {
|
|
7378
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value);
|
|
7379
|
+
}
|
|
7380
|
+
function assertModelPin(pin, source) {
|
|
7381
|
+
if (!isCanonicalModel(pin.model)) {
|
|
7382
|
+
throw new Error(
|
|
7383
|
+
`${source} model must use the canonical engine id provider/model (got ${JSON.stringify(pin.model)}).`
|
|
7384
|
+
);
|
|
7385
|
+
}
|
|
7386
|
+
if (pin.variant !== void 0 && !isVariant(pin.variant)) {
|
|
7387
|
+
throw new Error(`${source} variant is invalid (got ${JSON.stringify(pin.variant)}).`);
|
|
7388
|
+
}
|
|
7389
|
+
return pin;
|
|
7390
|
+
}
|
|
7391
|
+
function readProjectModelPin(cwd = process.cwd()) {
|
|
7392
|
+
const path = (0, import_node_path18.join)(cwd, PROJECT_MODEL_PIN);
|
|
7393
|
+
let raw;
|
|
7394
|
+
try {
|
|
7395
|
+
raw = (0, import_node_fs16.readFileSync)(path, "utf8");
|
|
7396
|
+
} catch (error) {
|
|
7397
|
+
const code = error.code;
|
|
7398
|
+
if (code === "ENOENT") return null;
|
|
7399
|
+
throw error;
|
|
7400
|
+
}
|
|
7401
|
+
let parsed;
|
|
7402
|
+
try {
|
|
7403
|
+
parsed = JSON.parse(raw);
|
|
7404
|
+
} catch {
|
|
7405
|
+
throw new Error(`${PROJECT_MODEL_PIN} is not valid JSON.`);
|
|
7406
|
+
}
|
|
7407
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
7408
|
+
throw new Error(`${PROJECT_MODEL_PIN} must contain an object with a model field.`);
|
|
7409
|
+
}
|
|
7410
|
+
const value = parsed;
|
|
7411
|
+
if (typeof value.model !== "string" || !value.model.trim()) {
|
|
7412
|
+
throw new Error(`${PROJECT_MODEL_PIN} must contain a non-empty model field.`);
|
|
7413
|
+
}
|
|
7414
|
+
if (value.variant !== void 0 && typeof value.variant !== "string") {
|
|
7415
|
+
throw new Error(`${PROJECT_MODEL_PIN} variant must be a string.`);
|
|
7416
|
+
}
|
|
7417
|
+
return assertModelPin({ model: value.model, variant: value.variant }, PROJECT_MODEL_PIN);
|
|
7418
|
+
}
|
|
7419
|
+
function readEngineVariants(xdg) {
|
|
7420
|
+
try {
|
|
7421
|
+
const parsed = JSON.parse(
|
|
7422
|
+
(0, import_node_fs16.readFileSync)((0, import_node_path18.join)(xdg.stateHome, "opencode", "model.json"), "utf8")
|
|
7423
|
+
);
|
|
7424
|
+
if (!parsed.variant || typeof parsed.variant !== "object" || Array.isArray(parsed.variant)) return {};
|
|
7425
|
+
return Object.fromEntries(
|
|
7426
|
+
Object.entries(parsed.variant).filter((entry) => typeof entry[1] === "string" && isVariant(entry[1]))
|
|
7427
|
+
);
|
|
7428
|
+
} catch {
|
|
7429
|
+
return {};
|
|
7430
|
+
}
|
|
7431
|
+
}
|
|
7432
|
+
function writeEngineVariant(xdg, model, variant) {
|
|
7433
|
+
assertModelPin({ model, variant }, "Selected");
|
|
7434
|
+
const path = (0, import_node_path18.join)(xdg.stateHome, "opencode", "model.json");
|
|
7435
|
+
let state = {};
|
|
7436
|
+
try {
|
|
7437
|
+
const parsed = JSON.parse((0, import_node_fs16.readFileSync)(path, "utf8"));
|
|
7438
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) state = parsed;
|
|
7439
|
+
} catch {
|
|
7440
|
+
}
|
|
7441
|
+
const existing = state.variant;
|
|
7442
|
+
const variants = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
|
|
7443
|
+
state.variant = { ...variants, [model]: variant };
|
|
7444
|
+
(0, import_node_fs16.writeFileSync)(path, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
7445
|
+
}
|
|
7446
|
+
function resolveModelPin(opts) {
|
|
7447
|
+
const cliPin = opts.cliModel ? assertModelPin({ model: opts.cliModel, variant: opts.cliVariant }, "CLI") : null;
|
|
7448
|
+
if (!cliPin && opts.cliVariant !== void 0 && !isVariant(opts.cliVariant)) {
|
|
7449
|
+
throw new Error(`CLI variant is invalid (got ${JSON.stringify(opts.cliVariant)}).`);
|
|
7450
|
+
}
|
|
7451
|
+
let model;
|
|
7452
|
+
let modelSource;
|
|
7453
|
+
if (cliPin) {
|
|
7454
|
+
model = cliPin.model;
|
|
7455
|
+
modelSource = "cli";
|
|
7456
|
+
} else if (opts.resumeRequested) {
|
|
7457
|
+
if (!opts.resumePin) {
|
|
7458
|
+
if (opts.cliVariant) {
|
|
7459
|
+
throw new Error("Cannot apply --variant because the resume session model is not readable; pass --model as well.");
|
|
7460
|
+
}
|
|
7461
|
+
return null;
|
|
7462
|
+
}
|
|
7463
|
+
model = assertModelPin({ model: opts.resumePin.model }, "Resume session").model;
|
|
7464
|
+
modelSource = "resume";
|
|
7465
|
+
} else if (opts.projectPin) {
|
|
7466
|
+
model = assertModelPin(opts.projectPin, "Project pin").model;
|
|
7467
|
+
modelSource = "project";
|
|
7468
|
+
} else {
|
|
7469
|
+
model = assertModelPin({ model: opts.platformDefault }, "Platform default").model;
|
|
7470
|
+
modelSource = "platform-default";
|
|
7471
|
+
}
|
|
7472
|
+
let variant;
|
|
7473
|
+
let variantSource;
|
|
7474
|
+
if (opts.cliVariant) {
|
|
7475
|
+
variant = opts.cliVariant;
|
|
7476
|
+
variantSource = "cli";
|
|
7477
|
+
} else if (opts.resumePin?.model === model && opts.resumePin.variant && isVariant(opts.resumePin.variant)) {
|
|
7478
|
+
variant = opts.resumePin.variant;
|
|
7479
|
+
variantSource = "resume";
|
|
7480
|
+
} else if (!opts.resumeRequested && opts.projectPin?.model === model && opts.projectPin.variant) {
|
|
7481
|
+
variant = opts.projectPin.variant;
|
|
7482
|
+
variantSource = "project";
|
|
7483
|
+
} else if (opts.engineVariants?.[model]) {
|
|
7484
|
+
variant = opts.engineVariants[model];
|
|
7485
|
+
variantSource = "engine-state";
|
|
7486
|
+
}
|
|
7487
|
+
return { model, variant, modelSource, variantSource };
|
|
7488
|
+
}
|
|
7489
|
+
|
|
7490
|
+
// src/lib/engineModels.ts
|
|
7491
|
+
var import_node_child_process5 = require("child_process");
|
|
7492
|
+
var import_node_util = require("util");
|
|
7493
|
+
var import_node_fs17 = require("fs");
|
|
7494
|
+
var import_node_path19 = require("path");
|
|
7495
|
+
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process5.execFile);
|
|
7496
|
+
function parseEngineModelsOutput(output) {
|
|
7497
|
+
return Array.from(new Set(
|
|
7498
|
+
output.split(/\r?\n/).map((line) => line.trim()).filter((line) => /^[^\s/]+\/\S+$/.test(line))
|
|
7499
|
+
)).sort();
|
|
7500
|
+
}
|
|
7501
|
+
function modelProvider(model) {
|
|
7502
|
+
return model.slice(0, model.indexOf("/"));
|
|
7503
|
+
}
|
|
7504
|
+
async function listEngineModels(binPath, xdg, provider) {
|
|
7505
|
+
const { stdout } = await execFileAsync(binPath, ["models", provider], {
|
|
7506
|
+
env: {
|
|
7507
|
+
...process.env,
|
|
7508
|
+
XDG_CONFIG_HOME: xdg.configHome,
|
|
7509
|
+
XDG_DATA_HOME: xdg.dataHome,
|
|
7510
|
+
XDG_STATE_HOME: xdg.stateHome,
|
|
7511
|
+
OPENCODE_DISABLE_AUTOUPDATE: "true"
|
|
7512
|
+
},
|
|
7513
|
+
encoding: "utf8",
|
|
7514
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
7515
|
+
timeout: 3e4
|
|
7516
|
+
});
|
|
7517
|
+
return parseEngineModelsOutput(stdout);
|
|
7518
|
+
}
|
|
7519
|
+
async function assertEngineModelAvailable(binPath, xdg, model, listModels = listEngineModels) {
|
|
7520
|
+
const provider = modelProvider(model);
|
|
7521
|
+
const models = await listModels(binPath, xdg, provider);
|
|
7522
|
+
if (!models.includes(model)) {
|
|
7523
|
+
const examples = models.slice(0, 5);
|
|
7524
|
+
const hint = examples.length ? ` Available for ${provider}: ${examples.join(", ")}.` : "";
|
|
7525
|
+
throw new Error(`Model ${model} is not in the final engine catalog.${hint}`);
|
|
7526
|
+
}
|
|
7527
|
+
}
|
|
7528
|
+
function readConnectedEngineProviders(xdg) {
|
|
7529
|
+
try {
|
|
7530
|
+
const parsed = JSON.parse(
|
|
7531
|
+
(0, import_node_fs17.readFileSync)((0, import_node_path19.join)(xdg.dataHome, "opencode", "auth.json"), "utf8")
|
|
7532
|
+
);
|
|
7533
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
7534
|
+
return Object.keys(parsed).filter((id) => id !== "agentful").sort();
|
|
7535
|
+
} catch {
|
|
7536
|
+
return [];
|
|
7537
|
+
}
|
|
7538
|
+
}
|
|
7539
|
+
function describeModelRoute(model, catalog, localProviders, connectedProviders) {
|
|
7540
|
+
const provider = modelProvider(model);
|
|
7541
|
+
const modelID = model.slice(provider.length + 1);
|
|
7542
|
+
if (provider === "agentful") {
|
|
7543
|
+
if (modelID.startsWith("byok/")) {
|
|
7544
|
+
return "hosted own-key route \u2014 billed by that provider; Agentful credits are not used";
|
|
7545
|
+
}
|
|
7546
|
+
const entry = catalog.models.find((candidate) => candidate.id === modelID);
|
|
7547
|
+
return entry?.byok ? "hosted own-key route \u2014 billed by that provider; Agentful credits are not used" : "Agentful platform route \u2014 credits, account region and organization rules apply";
|
|
7548
|
+
}
|
|
7549
|
+
if (Object.hasOwn(localProviders, provider)) {
|
|
7550
|
+
return "local own-key route \u2014 direct to the provider; no Agentful credits or region guarantee";
|
|
7551
|
+
}
|
|
7552
|
+
if (connectedProviders.includes(provider)) {
|
|
7553
|
+
return "direct TUI provider connection \u2014 provider billing/terms apply; no Agentful credits or region guarantee";
|
|
7554
|
+
}
|
|
7555
|
+
if (provider === "opencode") {
|
|
7556
|
+
return "engine-provided route \u2014 provider terms apply; outside Agentful credits and region controls";
|
|
7557
|
+
}
|
|
7558
|
+
return "external engine route \u2014 provider setup and terms apply; no Agentful credits or region guarantee";
|
|
7559
|
+
}
|
|
7560
|
+
|
|
7561
|
+
// src/lib/engineEvents.ts
|
|
7562
|
+
var import_node_net = require("net");
|
|
7563
|
+
function payloadOf(event) {
|
|
7564
|
+
return event.payload && typeof event.payload === "object" ? event.payload : event;
|
|
7565
|
+
}
|
|
7566
|
+
function propertiesOf(event) {
|
|
7567
|
+
return payloadOf(event).properties || {};
|
|
7568
|
+
}
|
|
7569
|
+
function partOf(event) {
|
|
7570
|
+
const properties = propertiesOf(event);
|
|
7571
|
+
return properties.part && typeof properties.part === "object" ? properties.part : {};
|
|
7572
|
+
}
|
|
7573
|
+
function classifyEngineEvent(event) {
|
|
7574
|
+
const payload = payloadOf(event);
|
|
7575
|
+
const type = String(payload.type || "");
|
|
7576
|
+
const part = partOf(event);
|
|
7577
|
+
const partType = String(part.type || "");
|
|
7578
|
+
const tool = String(part.tool || propertiesOf(event).tool || "");
|
|
7579
|
+
if (type.startsWith("question.") || type.startsWith("permission.") || partType === "tool" && tool === "question") return "question";
|
|
7580
|
+
if (type === "session.idle" || type === "session.status" && String(propertiesOf(event).status?.type || "") === "idle") {
|
|
7581
|
+
return "idle";
|
|
7582
|
+
}
|
|
7583
|
+
if (type.startsWith("tool.") || type.startsWith("file.") || partType === "tool") return "tool";
|
|
7584
|
+
if (type === "message.part.delta" || type === "message.part.updated" && ["text", "reasoning"].includes(partType)) return "token";
|
|
7585
|
+
if (type.startsWith("message.") || type.startsWith("session.") || type.startsWith("generation.") || type.startsWith("provider.")) return "provider";
|
|
7586
|
+
return "other";
|
|
7587
|
+
}
|
|
7588
|
+
function eventStatus(event) {
|
|
7589
|
+
const payload = payloadOf(event);
|
|
7590
|
+
const properties = propertiesOf(event);
|
|
7591
|
+
const part = partOf(event);
|
|
7592
|
+
const state = part.state && typeof part.state === "object" ? part.state : {};
|
|
7593
|
+
const status = properties.status && typeof properties.status === "object" ? properties.status : {};
|
|
7594
|
+
return String(state.status || status.type || payload.type || "");
|
|
7595
|
+
}
|
|
7596
|
+
var EngineLivenessPolicy = class {
|
|
7597
|
+
constructor(warningAfterMs = 6e4, actionAfterMs = 12e4, now = Date.now()) {
|
|
7598
|
+
this.warningAfterMs = warningAfterMs;
|
|
7599
|
+
this.actionAfterMs = actionAfterMs;
|
|
7600
|
+
this.lastActivityAt = now;
|
|
7601
|
+
}
|
|
7602
|
+
warningAfterMs;
|
|
7603
|
+
actionAfterMs;
|
|
7604
|
+
counts = {
|
|
7605
|
+
provider: 0,
|
|
7606
|
+
token: 0,
|
|
7607
|
+
tool: 0,
|
|
7608
|
+
question: 0,
|
|
7609
|
+
idle: 0,
|
|
7610
|
+
other: 0
|
|
7611
|
+
};
|
|
7612
|
+
isActive = false;
|
|
7613
|
+
isPausedForInteraction = false;
|
|
7614
|
+
isPausedForTool = false;
|
|
7615
|
+
lastActivityAt;
|
|
7616
|
+
didWarn = false;
|
|
7617
|
+
didAct = false;
|
|
7618
|
+
activityRevision = 0;
|
|
7619
|
+
lastCategory = "other";
|
|
7620
|
+
get revision() {
|
|
7621
|
+
return this.activityRevision;
|
|
7622
|
+
}
|
|
7623
|
+
observe(event, now = Date.now()) {
|
|
7624
|
+
const category = classifyEngineEvent(event);
|
|
7625
|
+
const status = eventStatus(event);
|
|
7626
|
+
this.counts[category] += 1;
|
|
7627
|
+
this.lastCategory = category;
|
|
7628
|
+
if (category === "idle") {
|
|
7629
|
+
this.isActive = false;
|
|
7630
|
+
this.isPausedForInteraction = false;
|
|
7631
|
+
this.isPausedForTool = false;
|
|
7632
|
+
return category;
|
|
7633
|
+
}
|
|
7634
|
+
if (category === "question") {
|
|
7635
|
+
const isCompleted = /replied|rejected|completed|error/.test(status);
|
|
7636
|
+
this.isPausedForInteraction = !isCompleted;
|
|
7637
|
+
this.isActive = true;
|
|
7638
|
+
this.reset(now);
|
|
7639
|
+
return category;
|
|
7640
|
+
}
|
|
7641
|
+
if (category === "tool") {
|
|
7642
|
+
const isRunning = /pending|running|started|execute\.before/.test(status);
|
|
7643
|
+
const isCompleted = /completed|error|failed|execute\.after/.test(status);
|
|
7644
|
+
if (isRunning) this.isPausedForTool = true;
|
|
7645
|
+
if (isCompleted) this.isPausedForTool = false;
|
|
7646
|
+
this.isActive = true;
|
|
7647
|
+
this.reset(now);
|
|
7648
|
+
return category;
|
|
7649
|
+
}
|
|
7650
|
+
if (category === "provider" || category === "token") {
|
|
7651
|
+
const type = String(payloadOf(event).type || "");
|
|
7652
|
+
const isTerminal = /(?:completed|failed|aborted|error)$/.test(type);
|
|
7653
|
+
const isGenerationActivity = category === "token" || type.startsWith("message.") || type.startsWith("provider.") || type.startsWith("generation.") || type === "session.status" && /busy|retry|running/.test(status);
|
|
7654
|
+
if (isTerminal) {
|
|
7655
|
+
this.isActive = false;
|
|
7656
|
+
this.isPausedForTool = false;
|
|
7657
|
+
} else if (isGenerationActivity) {
|
|
7658
|
+
this.isActive = true;
|
|
7659
|
+
this.reset(now);
|
|
7660
|
+
}
|
|
7661
|
+
}
|
|
7662
|
+
return category;
|
|
7663
|
+
}
|
|
7664
|
+
poll(now = Date.now()) {
|
|
7665
|
+
if (!this.isActive || this.isPausedForInteraction || this.isPausedForTool) return null;
|
|
7666
|
+
const silenceMs = now - this.lastActivityAt;
|
|
7667
|
+
if (!this.didWarn && silenceMs >= this.warningAfterMs) {
|
|
7668
|
+
this.didWarn = true;
|
|
7669
|
+
return "warning";
|
|
7670
|
+
}
|
|
7671
|
+
if (!this.didAct && silenceMs >= this.actionAfterMs) {
|
|
7672
|
+
this.didAct = true;
|
|
7673
|
+
return "action";
|
|
7674
|
+
}
|
|
7675
|
+
return null;
|
|
7676
|
+
}
|
|
7677
|
+
reset(now) {
|
|
7678
|
+
this.lastActivityAt = now;
|
|
7679
|
+
this.didWarn = false;
|
|
7680
|
+
this.didAct = false;
|
|
7681
|
+
this.activityRevision += 1;
|
|
7682
|
+
}
|
|
7683
|
+
};
|
|
7684
|
+
async function reserveLoopbackPort() {
|
|
7685
|
+
return new Promise((resolve2, reject) => {
|
|
7686
|
+
const server = (0, import_node_net.createServer)();
|
|
7687
|
+
server.once("error", reject);
|
|
7688
|
+
server.listen(0, "127.0.0.1", () => {
|
|
7689
|
+
const address = server.address();
|
|
7690
|
+
const port = address && typeof address === "object" ? address.port : 0;
|
|
7691
|
+
server.close((error) => error ? reject(error) : resolve2(port));
|
|
7692
|
+
});
|
|
7693
|
+
});
|
|
7694
|
+
}
|
|
7695
|
+
async function consumeSseStream(body, onEvent) {
|
|
7696
|
+
const reader = body.getReader();
|
|
7697
|
+
const decoder = new TextDecoder();
|
|
7698
|
+
let buffer = "";
|
|
7699
|
+
let dataLines = [];
|
|
7700
|
+
const dispatch = () => {
|
|
7701
|
+
if (!dataLines.length) return;
|
|
7702
|
+
const raw = dataLines.join("\n");
|
|
7703
|
+
dataLines = [];
|
|
7704
|
+
try {
|
|
7705
|
+
const event = JSON.parse(raw);
|
|
7706
|
+
if (event && typeof event === "object") onEvent(event);
|
|
7707
|
+
} catch {
|
|
7708
|
+
}
|
|
7709
|
+
};
|
|
7710
|
+
for (; ; ) {
|
|
7711
|
+
const { done, value } = await reader.read();
|
|
7712
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
7713
|
+
let newline;
|
|
7714
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
7715
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
7716
|
+
buffer = buffer.slice(newline + 1);
|
|
7717
|
+
if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
|
7718
|
+
else if (!line) dispatch();
|
|
7719
|
+
}
|
|
7720
|
+
if (done) break;
|
|
7721
|
+
}
|
|
7722
|
+
if (buffer.startsWith("data:")) dataLines.push(buffer.slice(5).trimStart());
|
|
7723
|
+
dispatch();
|
|
7724
|
+
}
|
|
7725
|
+
async function delay(ms, signal) {
|
|
7726
|
+
if (signal.aborted) return;
|
|
7727
|
+
await new Promise((resolve2) => {
|
|
7728
|
+
const timer = setTimeout(resolve2, ms);
|
|
7729
|
+
signal.addEventListener("abort", () => {
|
|
7730
|
+
clearTimeout(timer);
|
|
7731
|
+
resolve2();
|
|
7732
|
+
}, { once: true });
|
|
7733
|
+
});
|
|
7734
|
+
}
|
|
7735
|
+
async function showToast(fetchFn, baseUrl, directory, authorization, body) {
|
|
7736
|
+
try {
|
|
7737
|
+
await fetchFn(`${baseUrl}/tui/show-toast?directory=${encodeURIComponent(directory)}`, {
|
|
7738
|
+
method: "POST",
|
|
7739
|
+
headers: {
|
|
7740
|
+
"Content-Type": "application/json",
|
|
7741
|
+
...authorization ? { Authorization: authorization } : {}
|
|
7742
|
+
},
|
|
7743
|
+
body: JSON.stringify(body),
|
|
7744
|
+
signal: AbortSignal.timeout(3e3)
|
|
7745
|
+
});
|
|
7746
|
+
} catch {
|
|
7747
|
+
}
|
|
7748
|
+
}
|
|
7749
|
+
function startEngineEventMonitor(opts) {
|
|
7750
|
+
const fetchFn = opts.fetchFn || fetch;
|
|
7751
|
+
const controller = new AbortController();
|
|
7752
|
+
const policy = new EngineLivenessPolicy(opts.warningAfterMs, opts.actionAfterMs);
|
|
7753
|
+
const baseUrl = `http://127.0.0.1:${opts.port}`;
|
|
7754
|
+
const authorization = opts.password ? `Basic ${Buffer.from(`${opts.username || "opencode"}:${opts.password}`).toString("base64")}` : void 0;
|
|
7755
|
+
let didRequireAction = false;
|
|
7756
|
+
const timer = setInterval(() => {
|
|
7757
|
+
const decision = policy.poll();
|
|
7758
|
+
if (decision === "warning") {
|
|
7759
|
+
void showToast(fetchFn, baseUrl, opts.directory, authorization, {
|
|
7760
|
+
title: "Model stream is quiet",
|
|
7761
|
+
message: `No progress event for 60 s; last category: ${policy.lastCategory}. The model remains pinned and no retry was started.`,
|
|
7762
|
+
variant: "warning",
|
|
7763
|
+
duration: 12e3
|
|
7764
|
+
});
|
|
7765
|
+
} else if (decision === "action") {
|
|
7766
|
+
didRequireAction = true;
|
|
7767
|
+
void showToast(fetchFn, baseUrl, opts.directory, authorization, {
|
|
7768
|
+
title: "Recovery available",
|
|
7769
|
+
message: `No engine progress event for 120 s; last category: ${policy.lastCategory}. Exit with Esc twice for an optional same-session resume.`,
|
|
7770
|
+
variant: "error",
|
|
7771
|
+
duration: 2e4
|
|
7772
|
+
});
|
|
7773
|
+
}
|
|
7774
|
+
}, opts.pollIntervalMs || 1e3);
|
|
7775
|
+
timer.unref();
|
|
7776
|
+
const done = (async () => {
|
|
7777
|
+
while (!controller.signal.aborted) {
|
|
7778
|
+
try {
|
|
7779
|
+
const response = await fetchFn(`${baseUrl}/global/event`, {
|
|
7780
|
+
headers: {
|
|
7781
|
+
Accept: "text/event-stream",
|
|
7782
|
+
...authorization ? { Authorization: authorization } : {}
|
|
7783
|
+
},
|
|
7784
|
+
signal: controller.signal
|
|
7785
|
+
});
|
|
7786
|
+
if (!response.ok || !response.body) throw new Error(`SSE HTTP ${response.status}`);
|
|
7787
|
+
await consumeSseStream(response.body, (event) => {
|
|
7788
|
+
if (event.directory && event.directory !== opts.directory) return;
|
|
7789
|
+
const revision = policy.revision;
|
|
7790
|
+
policy.observe(event);
|
|
7791
|
+
if (didRequireAction && policy.revision > revision) didRequireAction = false;
|
|
7792
|
+
});
|
|
7793
|
+
} catch (error) {
|
|
7794
|
+
if (controller.signal.aborted || error.name === "AbortError") break;
|
|
7795
|
+
}
|
|
7796
|
+
await delay(300, controller.signal);
|
|
7797
|
+
}
|
|
7798
|
+
})().finally(() => clearInterval(timer));
|
|
7799
|
+
return {
|
|
7800
|
+
policy,
|
|
7801
|
+
done,
|
|
7802
|
+
get didRequireAction() {
|
|
7803
|
+
return didRequireAction;
|
|
7804
|
+
},
|
|
7805
|
+
stop() {
|
|
7806
|
+
controller.abort();
|
|
7807
|
+
}
|
|
7808
|
+
};
|
|
7809
|
+
}
|
|
7810
|
+
|
|
7331
7811
|
// src/commands/tui.ts
|
|
7812
|
+
async function confirmSameSessionRecovery(sessionID) {
|
|
7813
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
7814
|
+
const rl = (0, import_promises4.createInterface)({ input: process.stdin, output: process.stdout });
|
|
7815
|
+
try {
|
|
7816
|
+
const answer = await rl.question(
|
|
7817
|
+
`Resume ${sessionID} now? This only reopens the session; it does not send or retry anything. [y/N] `
|
|
7818
|
+
);
|
|
7819
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
7820
|
+
} finally {
|
|
7821
|
+
rl.close();
|
|
7822
|
+
}
|
|
7823
|
+
}
|
|
7332
7824
|
async function tuiCommand(opts = {}) {
|
|
7333
7825
|
console.log(banner());
|
|
7334
7826
|
const auth = await ensureAuth();
|
|
@@ -7346,9 +7838,41 @@ async function tuiCommand(opts = {}) {
|
|
|
7346
7838
|
const local = resolveLocalProviders(policy);
|
|
7347
7839
|
const framework = detectFramework(process.cwd());
|
|
7348
7840
|
const skills = skillsInstructionFor(framework, process.cwd());
|
|
7349
|
-
const
|
|
7841
|
+
const initialXdg = engineXdg();
|
|
7842
|
+
const initialPin = resolveModelPin({
|
|
7843
|
+
cliModel: opts.model,
|
|
7844
|
+
cliVariant: opts.variant,
|
|
7845
|
+
resumeRequested: Boolean(opts.session),
|
|
7846
|
+
resumePin: opts.session ? modelForSession(opts.session) : null,
|
|
7847
|
+
projectPin: opts.session || opts.model ? null : readProjectModelPin(),
|
|
7848
|
+
platformDefault: `agentful/${catalog.defaultModel}`,
|
|
7849
|
+
engineVariants: readEngineVariants(initialXdg)
|
|
7850
|
+
});
|
|
7851
|
+
const xdg = await writeEngineSession(
|
|
7852
|
+
session,
|
|
7853
|
+
catalog,
|
|
7854
|
+
local.providers,
|
|
7855
|
+
framework,
|
|
7856
|
+
skills,
|
|
7857
|
+
backendState,
|
|
7858
|
+
initialPin?.model
|
|
7859
|
+
);
|
|
7350
7860
|
const binDir = ensureCliOnPath();
|
|
7351
|
-
|
|
7861
|
+
const connectedProviders = readConnectedEngineProviders(xdg);
|
|
7862
|
+
const validateAndApplyPin = async (pin) => {
|
|
7863
|
+
if (!pin) return;
|
|
7864
|
+
const provider = modelProvider(pin.model);
|
|
7865
|
+
const hasProvider = provider === "agentful" || provider === "opencode" || Object.hasOwn(local.providers, provider) || connectedProviders.includes(provider);
|
|
7866
|
+
if (!hasProvider) {
|
|
7867
|
+
throw new Error(
|
|
7868
|
+
`Provider ${provider} is not connected. Start without this pin, connect it in /models, then retry.`
|
|
7869
|
+
);
|
|
7870
|
+
}
|
|
7871
|
+
await assertEngineModelAvailable(binPath, xdg, pin.model);
|
|
7872
|
+
if (pin.variant) writeEngineVariant(xdg, pin.model, pin.variant);
|
|
7873
|
+
};
|
|
7874
|
+
await validateAndApplyPin(initialPin);
|
|
7875
|
+
ui.info(`Signed in as ${auth.email} \u2014 billing and data path for the selected model are shown below.`);
|
|
7352
7876
|
if (project) ui.info(`Linked project: ${project.title} (\`agentful push\` for a live preview)`);
|
|
7353
7877
|
if (project && backendState === null) {
|
|
7354
7878
|
ui.info(paint.dim("Managed-backend state could not be read \u2014 the agent is told not to assume anything is enabled."));
|
|
@@ -7363,13 +7887,29 @@ async function tuiCommand(opts = {}) {
|
|
|
7363
7887
|
));
|
|
7364
7888
|
}
|
|
7365
7889
|
const localCount = Object.keys(local.providers).length;
|
|
7366
|
-
ui.info(
|
|
7890
|
+
ui.info(
|
|
7891
|
+
`Catalog: ${catalog.models.length} Agentful model(s), ${localCount} local provider(s), ${connectedProviders.length} direct TUI connection(s) \u2014 switch with /model.`
|
|
7892
|
+
);
|
|
7367
7893
|
if (localCount) {
|
|
7368
7894
|
ui.info(paint.dim(`${localCount} local provider(s) with your own key \u2014 billed by that provider.`));
|
|
7369
7895
|
}
|
|
7370
7896
|
if (local.blocked.length) {
|
|
7371
7897
|
ui.warn(`Local provider(s) not loaded: ${local.blocked.join(", ")} \u2014 ${local.policyReason || "blocked by policy"}`);
|
|
7372
7898
|
}
|
|
7899
|
+
if (initialPin) {
|
|
7900
|
+
const variant = initialPin.variant || "provider default";
|
|
7901
|
+
ui.info(`Pinned model: ${paint.accent(initialPin.model)} \xB7 variant ${paint.accent(variant)} \xB7 source ${initialPin.modelSource}.`);
|
|
7902
|
+
ui.info(paint.dim(describeModelRoute(
|
|
7903
|
+
initialPin.model,
|
|
7904
|
+
catalog,
|
|
7905
|
+
local.providers,
|
|
7906
|
+
connectedProviders
|
|
7907
|
+
)));
|
|
7908
|
+
} else if (opts.session) {
|
|
7909
|
+
ui.info(paint.dim(
|
|
7910
|
+
"Resume model metadata is not readable locally; the engine keeps the session model unchanged."
|
|
7911
|
+
));
|
|
7912
|
+
}
|
|
7373
7913
|
if (catalog.byokProviders.length) {
|
|
7374
7914
|
ui.info(paint.dim(
|
|
7375
7915
|
`Includes your own keys (${catalog.byokProviders.join(", ")}): billed by that provider` + (session.ai_region && session.ai_region !== "all" ? `, outside your "${session.ai_region}" region setting.` : ".")
|
|
@@ -7386,39 +7926,83 @@ async function tuiCommand(opts = {}) {
|
|
|
7386
7926
|
}
|
|
7387
7927
|
console.log("");
|
|
7388
7928
|
if (process.stdout.isTTY) process.stdout.write(`\x1B]0;${brand.displayName}\x07`);
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
XDG_CONFIG_HOME: xdg.configHome,
|
|
7400
|
-
XDG_DATA_HOME: xdg.dataHome,
|
|
7401
|
-
XDG_STATE_HOME: xdg.stateHome,
|
|
7402
|
-
// Belt and braces with `autoupdate: false` in the config: engine
|
|
7403
|
-
// versions are rolled out through our manifest, never self-updated.
|
|
7404
|
-
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
7405
|
-
// The engine would otherwise title the window "OC | <session>". We set
|
|
7406
|
-
// our own title just before spawning instead — it has to happen here,
|
|
7407
|
-
// because once the TUI takes the terminal the parent is suspended by
|
|
7408
|
-
// the OS and cannot write to it at all (verified 2026-07-20).
|
|
7409
|
-
OPENCODE_DISABLE_TERMINAL_TITLE: "true",
|
|
7410
|
-
// Consumed by the bundled imagegen tool.
|
|
7411
|
-
LLM_GATEWAY_URL: session.gateway_url,
|
|
7412
|
-
SESSION_TOKEN: session.token,
|
|
7413
|
-
...session.image_model ? { IMAGE_MODEL: session.image_model } : {}
|
|
7414
|
-
}
|
|
7929
|
+
let code = 0;
|
|
7930
|
+
let sessionID = opts.session;
|
|
7931
|
+
let selectedPin = initialPin;
|
|
7932
|
+
for (; ; ) {
|
|
7933
|
+
const port = await reserveLoopbackPort();
|
|
7934
|
+
const monitor = startEngineEventMonitor({
|
|
7935
|
+
port,
|
|
7936
|
+
directory: process.cwd(),
|
|
7937
|
+
username: process.env.OPENCODE_SERVER_USERNAME,
|
|
7938
|
+
password: process.env.OPENCODE_SERVER_PASSWORD
|
|
7415
7939
|
});
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7940
|
+
code = await new Promise((resolve2) => {
|
|
7941
|
+
const engineArgs = [
|
|
7942
|
+
"--hostname",
|
|
7943
|
+
"127.0.0.1",
|
|
7944
|
+
"--port",
|
|
7945
|
+
String(port),
|
|
7946
|
+
...selectedPin ? ["--model", selectedPin.model] : [],
|
|
7947
|
+
...sessionID ? ["--session", sessionID] : []
|
|
7948
|
+
];
|
|
7949
|
+
const child = (0, import_node_child_process6.spawn)(binPath, engineArgs, {
|
|
7950
|
+
stdio: "inherit",
|
|
7951
|
+
cwd: process.cwd(),
|
|
7952
|
+
env: {
|
|
7953
|
+
...process.env,
|
|
7954
|
+
// Slash commands run `agentful …` through the agent's bash tool, which
|
|
7955
|
+
// has no access to the user's shell aliases.
|
|
7956
|
+
PATH: `${binDir}${import_node_path20.delimiter}${process.env.PATH || ""}`,
|
|
7957
|
+
XDG_CONFIG_HOME: xdg.configHome,
|
|
7958
|
+
XDG_DATA_HOME: xdg.dataHome,
|
|
7959
|
+
XDG_STATE_HOME: xdg.stateHome,
|
|
7960
|
+
// Belt and braces with `autoupdate: false` in the config: engine
|
|
7961
|
+
// versions are rolled out through our manifest, never self-updated.
|
|
7962
|
+
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
7963
|
+
// The engine would otherwise title the window "OC | <session>".
|
|
7964
|
+
OPENCODE_DISABLE_TERMINAL_TITLE: "true",
|
|
7965
|
+
// Consumed by the bundled imagegen tool.
|
|
7966
|
+
LLM_GATEWAY_URL: session.gateway_url,
|
|
7967
|
+
SESSION_TOKEN: session.token,
|
|
7968
|
+
...session.image_model ? { IMAGE_MODEL: session.image_model } : {}
|
|
7969
|
+
}
|
|
7970
|
+
});
|
|
7971
|
+
child.on("exit", (childCode) => resolve2(childCode ?? 0));
|
|
7972
|
+
child.on("error", (err2) => {
|
|
7973
|
+
ui.fail(`Could not start the engine: ${err2.message}`);
|
|
7974
|
+
resolve2(1);
|
|
7975
|
+
});
|
|
7420
7976
|
});
|
|
7421
|
-
|
|
7977
|
+
monitor.stop();
|
|
7978
|
+
await monitor.done;
|
|
7979
|
+
if (!monitor.didRequireAction) break;
|
|
7980
|
+
const resumable = sessionID ? { id: sessionID, title: "" } : lastSessionForDirectory();
|
|
7981
|
+
if (!resumable) {
|
|
7982
|
+
ui.warn("The stream went quiet, but no same-directory session could be found for recovery.");
|
|
7983
|
+
break;
|
|
7984
|
+
}
|
|
7985
|
+
const counts = monitor.policy.counts;
|
|
7986
|
+
ui.warn(
|
|
7987
|
+
`Liveness action: provider ${counts.provider}, token/reasoning ${counts.token}, tool ${counts.tool}, question ${counts.question}, idle ${counts.idle}.`
|
|
7988
|
+
);
|
|
7989
|
+
if (!await confirmSameSessionRecovery(resumable.id)) {
|
|
7990
|
+
ui.info(`Safe recovery later: ${paint.accent(`agentful -s ${resumable.id}`)}`);
|
|
7991
|
+
break;
|
|
7992
|
+
}
|
|
7993
|
+
sessionID = resumable.id;
|
|
7994
|
+
selectedPin = resolveModelPin({
|
|
7995
|
+
cliModel: opts.model,
|
|
7996
|
+
cliVariant: opts.variant,
|
|
7997
|
+
resumeRequested: true,
|
|
7998
|
+
resumePin: modelForSession(sessionID),
|
|
7999
|
+
projectPin: null,
|
|
8000
|
+
platformDefault: `agentful/${catalog.defaultModel}`,
|
|
8001
|
+
engineVariants: readEngineVariants(xdg)
|
|
8002
|
+
});
|
|
8003
|
+
await validateAndApplyPin(selectedPin);
|
|
8004
|
+
ui.info("Reopening the same session. Review its completed tool results before asking it to continue.");
|
|
8005
|
+
}
|
|
7422
8006
|
try {
|
|
7423
8007
|
const credits = await request(
|
|
7424
8008
|
`${PB_URL}/api/credits/check`,
|
|
@@ -7500,28 +8084,28 @@ async function shareCommand(opts) {
|
|
|
7500
8084
|
}
|
|
7501
8085
|
|
|
7502
8086
|
// src/commands/pull.ts
|
|
7503
|
-
var
|
|
7504
|
-
var
|
|
8087
|
+
var import_node_fs18 = require("fs");
|
|
8088
|
+
var import_node_path21 = require("path");
|
|
7505
8089
|
init_branding();
|
|
7506
8090
|
init_branding();
|
|
7507
8091
|
function isProbablyBase64Binary(path) {
|
|
7508
8092
|
return /\.(png|jpe?g|gif|webp|ico|woff2?|ttf|otf|eot|pdf|zip|mp[34]|webm|avif)$/i.test(path);
|
|
7509
8093
|
}
|
|
7510
8094
|
function writeEntry(root, rel, value) {
|
|
7511
|
-
const target = (0,
|
|
7512
|
-
(0,
|
|
8095
|
+
const target = (0, import_node_path21.join)(root, rel);
|
|
8096
|
+
(0, import_node_fs18.mkdirSync)((0, import_node_path21.dirname)(target), { recursive: true });
|
|
7513
8097
|
const content = typeof value === "object" && value !== null && "content" in value ? String(value.content) : String(value ?? "");
|
|
7514
8098
|
if (isProbablyBase64Binary(rel)) {
|
|
7515
|
-
(0,
|
|
8099
|
+
(0, import_node_fs18.writeFileSync)(target, Buffer.from(content, "base64"));
|
|
7516
8100
|
} else {
|
|
7517
|
-
(0,
|
|
8101
|
+
(0, import_node_fs18.writeFileSync)(target, content, "utf8");
|
|
7518
8102
|
}
|
|
7519
8103
|
}
|
|
7520
8104
|
async function pullCommand(opts) {
|
|
7521
8105
|
console.log(banner());
|
|
7522
8106
|
const auth = await ensureAuth();
|
|
7523
8107
|
const project = requireProject();
|
|
7524
|
-
const nonHidden = (0,
|
|
8108
|
+
const nonHidden = (0, import_node_fs18.readdirSync)(process.cwd()).filter((n) => n !== ".agentful" && !n.startsWith("."));
|
|
7525
8109
|
if (nonHidden.length > 0 && !opts.force) {
|
|
7526
8110
|
throw new ApiError(
|
|
7527
8111
|
0,
|
|
@@ -7545,9 +8129,9 @@ async function pullCommand(opts) {
|
|
|
7545
8129
|
ui.warn(`Skipped ${rel} (HTTP ${resp.status})`);
|
|
7546
8130
|
continue;
|
|
7547
8131
|
}
|
|
7548
|
-
const target = (0,
|
|
7549
|
-
(0,
|
|
7550
|
-
(0,
|
|
8132
|
+
const target = (0, import_node_path21.join)(process.cwd(), rel);
|
|
8133
|
+
(0, import_node_fs18.mkdirSync)((0, import_node_path21.dirname)(target), { recursive: true });
|
|
8134
|
+
(0, import_node_fs18.writeFileSync)(target, Buffer.from(await resp.arrayBuffer()));
|
|
7551
8135
|
written++;
|
|
7552
8136
|
}
|
|
7553
8137
|
} else if (data.files) {
|
|
@@ -7599,6 +8183,25 @@ async function modelsCommand(opts) {
|
|
|
7599
8183
|
console.log(paint.dim(` \u25CB ${k.models.join(", ") || "(no model)"} \xB7 ${k.provider}`));
|
|
7600
8184
|
}
|
|
7601
8185
|
}
|
|
8186
|
+
const xdg = engineXdg();
|
|
8187
|
+
const connectedProviders = readConnectedEngineProviders(xdg);
|
|
8188
|
+
if (connectedProviders.length) {
|
|
8189
|
+
const binPath = await ensureEngine();
|
|
8190
|
+
console.log("");
|
|
8191
|
+
console.log(paint.bold("Direct TUI connections") + paint.dim(" (provider billing/terms; outside Agentful credits and region controls)"));
|
|
8192
|
+
for (const provider of connectedProviders) {
|
|
8193
|
+
try {
|
|
8194
|
+
const models = await listEngineModels(binPath, xdg, provider);
|
|
8195
|
+
if (!models.length) {
|
|
8196
|
+
console.log(` ${paint.warn("!")} ${provider} ${paint.dim("\u2014 connected, but no engine models found")}`);
|
|
8197
|
+
continue;
|
|
8198
|
+
}
|
|
8199
|
+
for (const model of models) console.log(` ${paint.success("\u25CF")} ${model}`);
|
|
8200
|
+
} catch {
|
|
8201
|
+
console.log(` ${paint.warn("!")} ${provider} ${paint.dim("\u2014 catalog could not be read")}`);
|
|
8202
|
+
}
|
|
8203
|
+
}
|
|
8204
|
+
}
|
|
7602
8205
|
console.log("");
|
|
7603
8206
|
console.log(paint.bold("Local providers") + paint.dim(" (your key, direct to the provider)"));
|
|
7604
8207
|
if (!decision.allowed) {
|
|
@@ -7632,7 +8235,7 @@ async function modelsCommand(opts) {
|
|
|
7632
8235
|
}
|
|
7633
8236
|
|
|
7634
8237
|
// src/commands/upgrade.ts
|
|
7635
|
-
var
|
|
8238
|
+
var import_node_child_process7 = require("child_process");
|
|
7636
8239
|
init_branding();
|
|
7637
8240
|
async function upgradeCommand() {
|
|
7638
8241
|
console.log(banner());
|
|
@@ -7661,7 +8264,7 @@ async function upgradeCommand() {
|
|
|
7661
8264
|
}
|
|
7662
8265
|
if (latest) ui.step(`Updating ${brand.name} v${VERSION} \u2192 v${latest}\u2026`);
|
|
7663
8266
|
else ui.step(`Updating ${brand.name} to the latest version\u2026`);
|
|
7664
|
-
const res = (0,
|
|
8267
|
+
const res = (0, import_node_child_process7.spawnSync)("npm", ["install", "-g", `${brand.name}@latest`], { stdio: "inherit" });
|
|
7665
8268
|
if (res.status === 0) {
|
|
7666
8269
|
ui.ok("Done. Run `agentful --version` to confirm.");
|
|
7667
8270
|
return;
|
|
@@ -7686,7 +8289,7 @@ var run = (fn) => async (...args) => {
|
|
|
7686
8289
|
process.exitCode = 1;
|
|
7687
8290
|
}
|
|
7688
8291
|
};
|
|
7689
|
-
program2.name("agentful").description("Agentful in your terminal \u2014 local development with push-to-cloud previews").version(VERSION).option("-s, --session <id>", "resume a previous session (same as `tui -s`)").action(run((opts, cmd) => {
|
|
8292
|
+
program2.name("agentful").description("Agentful in your terminal \u2014 local development with push-to-cloud previews").version(VERSION).option("-s, --session <id>", "resume a previous session (same as `tui -s`)").option("-m, --model <provider/model>", "pin an exact model from the final engine catalog").option("--variant <name>", "pin the provider-specific model variant (for example high)").action(run((opts, cmd) => {
|
|
7690
8293
|
const [unknown] = cmd.args;
|
|
7691
8294
|
if (unknown) {
|
|
7692
8295
|
throw new ApiError(
|
|
@@ -7705,7 +8308,7 @@ program2.command("push").description("Upload this project and get a live preview
|
|
|
7705
8308
|
program2.command("build-status").description("Show the cloud's record of the last build (the real error on failures)").action(run(buildStatusCommand));
|
|
7706
8309
|
program2.command("backend").description("Open this project's Backend tab (managed DB + server actions) in the browser").action(run(backendCommand));
|
|
7707
8310
|
program2.command("open").description("Open the live preview in the browser").action(run(openCommand));
|
|
7708
|
-
program2.command("tui").description("Start the Agentful coding TUI with your Agentful account").option("-s, --session <id>", "resume a previous session").action(run((opts) => tuiCommand(opts)));
|
|
8311
|
+
program2.command("tui").description("Start the Agentful coding TUI with your Agentful account").option("-s, --session <id>", "resume a previous session").option("-m, --model <provider/model>", "pin an exact model from the final engine catalog").option("--variant <name>", "pin the provider-specific model variant (for example high)").action(run((opts) => tuiCommand(opts)));
|
|
7709
8312
|
program2.command("publish <subdomain>").description("Publish the built project at https://<subdomain>.agentful.dev").action(run(publishCommand));
|
|
7710
8313
|
program2.command("share").description("Make the live preview public (anyone with the link)").option("--private", "make the preview owner-only again").action(run((opts) => shareCommand(opts)));
|
|
7711
8314
|
program2.command("pull").description("Download the cloud workspace into this directory").option("--force", "overwrite files in a non-empty directory").action(run((opts) => pullCommand(opts)));
|