From 82995d414e17b036a1106cf6058140a1161f39ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 10:55:16 +0000 Subject: [PATCH 1/4] Add scripts/grunt for fan-out to opencode.ai Zen (Go) models A small Bash wrapper that POSTs a one-shot prompt to https://opencode.ai/zen/go/v1/chat/completions, prints the assistant text, and is safe to background for parallel dispatch. Trims stray whitespace from OPENCODE_API_KEY (the env value here had a leading space, which silently broke chat completions while leaving /models working). Detects reasoning-only finishes and tells the caller to raise -t rather than returning an empty string. Defaults to deepseek-v4-flash; -m switches model, -f attaches files inline, -s sets a system prompt, --models lists what the account can reach. --- scripts/grunt | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100755 scripts/grunt diff --git a/scripts/grunt b/scripts/grunt new file mode 100755 index 00000000..bde3e5f8 --- /dev/null +++ b/scripts/grunt @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# grunt — dispatch a one-shot prompt to opencode.ai Zen (Go subscription). +# +# Usage: +# scripts/grunt [-m MODEL] [-s SYSTEM] [-f FILE]... [-t MAX_TOKENS] [-j] [PROMPT] +# +# Flags: +# -m MODEL Model id (default: deepseek-v4-flash). See `scripts/grunt --models`. +# -s SYSTEM System prompt. May be passed multiple times (concatenated with blank lines). +# -f FILE Attach a file. Repeatable. Each file is appended to the user message +# as a fenced block headed by its path. +# -t MAX_TOKENS Max output tokens (default: 4096). +# -j Print the raw JSON response instead of just the assistant text. +# --models List available models for this account and exit. +# -h, --help Show this help. +# +# Input: PROMPT may be passed as a positional arg, or piped on stdin (or both — stdin +# is appended after the positional arg, separated by a blank line). At least one +# source of prompt text is required. +# +# Output: assistant message text on stdout. Reasoning-only finishes (where the model +# spent all its tokens thinking and produced no content) emit a single line to stderr +# and exit 2 — bump -t and retry. +# +# Auth: reads OPENCODE_API_KEY from the environment and trims surrounding whitespace. +# Exits 1 if unset. +# +# Designed for parallel fan-out: +# ( scripts/grunt -f a.ts "summarize" > a.out ) & +# ( scripts/grunt -f b.ts "summarize" > b.out ) & +# wait + +set -euo pipefail + +ENDPOINT="https://opencode.ai/zen/go/v1" +DEFAULT_MODEL="deepseek-v4-flash" + +usage() { sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; } + +model="$DEFAULT_MODEL" +system_parts=() +files=() +max_tokens=4096 +raw_json=0 +list_models=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -m) model="$2"; shift 2 ;; + -s) system_parts+=("$2"); shift 2 ;; + -f) files+=("$2"); shift 2 ;; + -t) max_tokens="$2"; shift 2 ;; + -j) raw_json=1; shift ;; + --models) list_models=1; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "grunt: unknown flag: $1" >&2; exit 64 ;; + *) break ;; + esac +done + +key="${OPENCODE_API_KEY:-}" +key="${key#"${key%%[![:space:]]*}"}" +key="${key%"${key##*[![:space:]]}"}" +if [[ -z "$key" ]]; then + echo "grunt: OPENCODE_API_KEY is not set" >&2 + exit 1 +fi + +if (( list_models )); then + curl -fsS "$ENDPOINT/models" -H "Authorization: Bearer $key" \ + | python3 -c 'import json,sys;[print(m["id"]) for m in json.load(sys.stdin)["data"]]' + exit 0 +fi + +prompt="${1:-}" +if [[ ! -t 0 ]]; then + stdin_text=$(cat) + if [[ -n "$stdin_text" ]]; then + [[ -n "$prompt" ]] && prompt="$prompt"$'\n\n'"$stdin_text" || prompt="$stdin_text" + fi +fi +if [[ -z "$prompt" && ${#files[@]} -eq 0 ]]; then + echo "grunt: no prompt (pass as arg, on stdin, or via -f)" >&2 + exit 64 +fi + +for f in "${files[@]}"; do + [[ -r "$f" ]] || { echo "grunt: cannot read file: $f" >&2; exit 66; } + prompt+=$'\n\n'"### $f"$'\n```\n'"$(cat "$f")"$'\n```' +done + +system="" +if (( ${#system_parts[@]} > 0 )); then + system=$(printf '%s\n\n' "${system_parts[@]}") +fi + +body=$( + jq -n \ + --arg model "$model" \ + --arg system "$system" \ + --arg prompt "$prompt" \ + --argjson max_tokens "$max_tokens" \ + '{model:$model, max_tokens:$max_tokens, + messages: ((if $system == "" then [] else [{role:"system",content:$system}] end) + + [{role:"user",content:$prompt}])}' +) + +response=$(curl -fsS "$ENDPOINT/chat/completions" \ + -H "Authorization: Bearer $key" \ + -H "Content-Type: application/json" \ + --data-binary "$body") + +if (( raw_json )); then + printf '%s\n' "$response" + exit 0 +fi + +python3 - "$response" <<'PY' +import json, sys +d = json.loads(sys.argv[1]) +if "error" in d: + sys.stderr.write(f"grunt: api error: {d['error'].get('message','?')}\n") + sys.exit(1) +msg = d["choices"][0]["message"] +text = msg.get("content") or "" +if not text.strip(): + reasoning = msg.get("reasoning") or msg.get("reasoning_content") or "" + sys.stderr.write( + f"grunt: model produced reasoning only (finish={d['choices'][0].get('finish_reason')}); " + f"raise -t. reasoning preview: {reasoning[:200]!r}\n" + ) + sys.exit(2) +sys.stdout.write(text) +if not text.endswith("\n"): + sys.stdout.write("\n") +PY From 46c23ce72493a07bee9ecbdce281ed01b19155a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 11:18:14 +0000 Subject: [PATCH 2/4] Add settings nag banner for unfinished setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners now see a banner above admin nav listing settings that need attention: payment provider not chosen, business email not set, no domain configured. The banner suppresses the domain item when neither Bunny CDN nor Bunny DNS is enabled (admin can't fix it). To distinguish "never saved" from "explicitly chose None" for payment provider without breaking existing readers (isPaymentsEnabled and friends already check for "stripe"/"square" explicitly), add a PaymentProviderSetting type ("stripe" | "square" | "none") and a parallel snapshot field/getter paymentProviderSetting that reads the same DB key. The legacy paymentProvider getter still maps "none" to null so every existing caller is untouched. Saving "None" in the form now writes the literal string "none" via setPaymentProviderNone() — clearPaymentProvider() (which deletes the row entirely) is preserved for the existing test that asserts deletion. Most of the new code was drafted by Kimi K2.6 via scripts/grunt: the nag function, the banner template, and the test file. Type plumbing and integration was edited directly to keep contracts tight. --- src/features/admin/settings.ts | 2 +- src/shared/db/settings.ts | 34 +++- src/shared/settings-nags.ts | 59 ++++++ src/shared/types.ts | 15 ++ src/ui/templates/admin/nav.tsx | 2 + .../templates/admin/settings-nag-banner.tsx | 20 ++ test/shared/settings-nags.test.ts | 172 ++++++++++++++++++ 7 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 src/shared/settings-nags.ts create mode 100644 src/ui/templates/admin/settings-nag-banner.tsx create mode 100644 test/shared/settings-nags.test.ts diff --git a/src/features/admin/settings.ts b/src/features/admin/settings.ts index e10a2bb0..09de05f3 100644 --- a/src/features/admin/settings.ts +++ b/src/features/admin/settings.ts @@ -337,7 +337,7 @@ const handlePaymentProviderPost = settingsHandler({ v === "none" ? "Payment provider disabled" : `Payment provider set to ${v}`, save: (v) => v === "none" - ? settings.update.clearPaymentProvider() + ? settings.update.setPaymentProviderNone() : settings.update.paymentProvider(v as PaymentProviderType), validate: (v) => v !== "none" && !isPaymentProvider(v) ? "Invalid payment provider" : null, diff --git a/src/shared/db/settings.ts b/src/shared/db/settings.ts index 43be3a6d..d94b83ec 100644 --- a/src/shared/db/settings.ts +++ b/src/shared/db/settings.ts @@ -31,8 +31,16 @@ import { deleteAllSessions } from "#shared/db/sessions.ts"; import { createUser, invalidateUsersCache } from "#shared/db/users.ts"; import { nowMs } from "#shared/now.ts"; import { DEFAULT_TIMEZONE } from "#shared/timezone.ts"; -import type { PaymentProviderType, Settings, Theme } from "#shared/types.ts"; -import { isPaymentProvider } from "#shared/types.ts"; +import type { + PaymentProviderSetting, + PaymentProviderType, + Settings, + Theme, +} from "#shared/types.ts"; +import { + isPaymentProvider, + isPaymentProviderSetting, +} from "#shared/types.ts"; import { createAppleWalletReadSettings, createAppleWalletUpdateSettings, @@ -229,6 +237,7 @@ type SpecificFields = { show_public_site: boolean; show_public_api: boolean; payment_provider: PaymentProviderType | null; + payment_provider_setting: PaymentProviderSetting | null; booking_fee: string; square_sandbox: boolean; currency: string; @@ -245,6 +254,7 @@ const data: SettingsData = { country: DEFAULT_COUNTRY, currency: "GBP", payment_provider: null, + payment_provider_setting: null, phone_prefix: "+44", show_public_api: false, show_public_site: false, @@ -379,6 +389,8 @@ const buildSnapshot = async (raw: Map): Promise => { const rawProvider = raw.get(CONFIG_KEYS.PAYMENT_PROVIDER); data.payment_provider = rawProvider && isPaymentProvider(rawProvider) ? rawProvider : null; + data.payment_provider_setting = + rawProvider && isPaymentProviderSetting(rawProvider) ? rawProvider : null; data.booking_fee = raw.get(CONFIG_KEYS.BOOKING_FEE) ?? "0"; data.square_sandbox = raw.get(CONFIG_KEYS.SQUARE_SANDBOX) === "true"; @@ -695,6 +707,9 @@ export const settings = { get paymentProvider(): PaymentProviderType | null { return snap("payment_provider"); }, + get paymentProviderSetting(): PaymentProviderSetting | null { + return snap("payment_provider_setting"); + }, get phonePrefix(): string { return snap("phone_prefix"); }, @@ -793,6 +808,7 @@ export const settings = { clearPaymentProvider: async (): Promise => { await deleteRaw(CONFIG_KEYS.PAYMENT_PROVIDER); data.payment_provider = null; + data.payment_provider_setting = null; }, contactPageText: encryptedUpdate(CONFIG_KEYS.CONTACT_PAGE_TEXT), country: async (v: string): Promise => { @@ -838,10 +854,16 @@ export const settings = { latestScriptVersionName: plaintextUpdate( CONFIG_KEYS.LATEST_SCRIPT_VERSION_NAME, ), - paymentProvider: rawUpdate( - CONFIG_KEYS.PAYMENT_PROVIDER, - "payment_provider", - ) as (v: PaymentProviderType) => Promise, + paymentProvider: async (v: PaymentProviderType): Promise => { + await writeRaw(CONFIG_KEYS.PAYMENT_PROVIDER, v); + data.payment_provider = v; + data.payment_provider_setting = v; + }, + setPaymentProviderNone: async (): Promise => { + await writeRaw(CONFIG_KEYS.PAYMENT_PROVIDER, "none"); + data.payment_provider = null; + data.payment_provider_setting = "none"; + }, showPublicApi: boolUpdate(CONFIG_KEYS.SHOW_PUBLIC_API, "show_public_api"), showPublicSite: boolUpdate( CONFIG_KEYS.SHOW_PUBLIC_SITE, diff --git a/src/shared/settings-nags.ts b/src/shared/settings-nags.ts new file mode 100644 index 00000000..18e1fed1 --- /dev/null +++ b/src/shared/settings-nags.ts @@ -0,0 +1,59 @@ +import { settings } from "#shared/db/settings.ts"; +import { isBunnyCdnEnabled, isBunnyDnsEnabled } from "#shared/config.ts"; + +/** + * Unique identifiers for settings nags that prompt the admin to complete + * required or recommended configuration. + */ +export type NagId = "payment-provider" | "business-email" | "domain"; + +/** + * A single settings nag item presented to the admin. + */ +export type NagItem = { + /** The nag identifier. */ + id: NagId; + /** Human-readable description of what needs to be configured. */ + label: string; + /** Deep link to the settings form where the value can be set. */ + href: string; +}; + +/** + * Returns an ordered list of settings nags for incomplete configuration. + * Items are returned in the order: payment-provider, business-email, domain. + * An empty array means there are no pending nags. + */ +export const getSettingsNagItems = (): NagItem[] => { + const items: NagItem[] = []; + + if (settings.paymentProviderSetting === null) { + items.push({ + id: "payment-provider", + label: "Choose a payment provider on the settings page (saving \"None\" is fine).", + href: "/admin/settings#settings-payment-provider", + }); + } + + if (settings.businessEmail === "") { + items.push({ + id: "business-email", + label: "Set a business email so users have a contact address.", + href: "/admin/settings#settings-business-email", + }); + } + + if ( + settings.customDomain === "" && + settings.bunnySubdomain === "" && + (isBunnyCdnEnabled() || isBunnyDnsEnabled()) + ) { + items.push({ + id: "domain", + label: "Set either a custom domain or a host subdomain in advanced settings.", + href: "/admin/settings-advanced#settings-custom-domain", + }); + } + + return items; +}; diff --git a/src/shared/types.ts b/src/shared/types.ts index a476bc36..9db52755 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -57,6 +57,21 @@ const PAYMENT_PROVIDERS: readonly PaymentProviderType[] = ["stripe", "square"]; /** Type guard: check if a string is a valid PaymentProviderType */ export const isPaymentProvider = createTypeGuard(PAYMENT_PROVIDERS); +/** Persisted payment-provider setting: an explicit provider, "none" (admin saved + * payments-disabled), or absent (never saved — drives the settings nag). */ +export type PaymentProviderSetting = PaymentProviderType | "none"; + +const PAYMENT_PROVIDER_SETTINGS: readonly PaymentProviderSetting[] = [ + "stripe", + "square", + "none", +]; + +/** Type guard: check if a string is a valid PaymentProviderSetting */ +export const isPaymentProviderSetting = createTypeGuard( + PAYMENT_PROVIDER_SETTINGS, +); + /** Event type: standard (one-time) or daily (date-based booking) */ export type EventType = "standard" | "daily"; diff --git a/src/ui/templates/admin/nav.tsx b/src/ui/templates/admin/nav.tsx index deff19db..e775feec 100644 --- a/src/ui/templates/admin/nav.tsx +++ b/src/ui/templates/admin/nav.tsx @@ -8,6 +8,7 @@ import { isReadOnly } from "#shared/env.ts"; import { CsrfForm } from "#shared/forms.tsx"; import { Raw } from "#shared/jsx/jsx-runtime.ts"; import type { AdminSession } from "#shared/types.ts"; +import { SettingsNagBanner } from "#templates/admin/settings-nag-banner.tsx"; /** Read-only mode banner HTML */ export const READ_ONLY_BANNER = @@ -33,6 +34,7 @@ const navLink = (href: string, label: string, active: string): JSX.Element => ( export const AdminNav = ({ session, active }: AdminNavProps): JSX.Element => ( <> {isReadOnly() && } + {session.adminLevel === "owner" && }