Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions scripts/grunt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/features/admin/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 28 additions & 6 deletions src/shared/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -379,6 +389,8 @@ const buildSnapshot = async (raw: Map<string, string>): Promise<void> => {
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";

Expand Down Expand Up @@ -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");
},
Expand Down Expand Up @@ -793,6 +808,7 @@ export const settings = {
clearPaymentProvider: async (): Promise<void> => {
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<void> => {
Expand Down Expand Up @@ -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<void>,
paymentProvider: async (v: PaymentProviderType): Promise<void> => {
await writeRaw(CONFIG_KEYS.PAYMENT_PROVIDER, v);
data.payment_provider = v;
data.payment_provider_setting = v;
},
setPaymentProviderNone: async (): Promise<void> => {
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,
Expand Down
59 changes: 59 additions & 0 deletions src/shared/settings-nags.ts
Original file line number Diff line number Diff line change
@@ -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;
};
15 changes: 15 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/templates/admin/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -33,6 +34,7 @@ const navLink = (href: string, label: string, active: string): JSX.Element => (
export const AdminNav = ({ session, active }: AdminNavProps): JSX.Element => (
<>
{isReadOnly() && <Raw html={READ_ONLY_BANNER} />}
{session.adminLevel === "owner" && <SettingsNagBanner />}
<nav id="main-nav">
<ul>
{navLink("/admin/", "Events", active)}
Expand Down
20 changes: 20 additions & 0 deletions src/ui/templates/admin/settings-nag-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getSettingsNagItems } from "#shared/settings-nags.ts";

export const SettingsNagBanner = (): JSX.Element | null => {
const items = getSettingsNagItems();
if (items.length === 0) {
return null;
}
return (
<aside class="settings-nag-banner" role="status">
<p><strong>Finish setting up your site:</strong></p>
<ul>
{items.map((item) => (
<li>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
</aside>
);
};
Loading
Loading