diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..4ca068aa --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "allthings", + "description": "allthings evenings in San Francisco, for people who build software, in Claude Code.", + "owner": { + "name": "allthings", + "url": "https://allthings.dev" + }, + "plugins": [ + { + "name": "allthings", + "source": "./plugins/allthings", + "description": "The next allthings evening above your prompt, /at and /imin, tool rows in the slash grammar, the allthings themes and voice, and the allthings MCP server.", + "category": "community" + } + ] +} diff --git a/.github/scripts/claude-code.sh b/.github/scripts/claude-code.sh new file mode 100755 index 00000000..383e872e --- /dev/null +++ b/.github/scripts/claude-code.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Installs Claude Code at the version CLAUDE_CODE_VERSION pins, puts it on the +# job's PATH, and lays the types Claude Code writes beside the allthings mod +# (plugins/allthings/.claude-plugin/types/, gitignored) so its tsconfig and +# the type-aware lint resolve `claude-code`. Laying them loads the plugin +# headless under a throwaway HOME: no sign-in, no model call, no turn. +set -euo pipefail + +: "${CLAUDE_CODE_VERSION:?set CLAUDE_CODE_VERSION}" +temp="${RUNNER_TEMP:-$(mktemp -d)}" +prefix="$temp/claude-code" +mkdir -p "$prefix" +( + cd "$prefix" + bun add --exact "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" >/dev/null +) +bin="$prefix/node_modules/.bin" +if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$bin" >>"$GITHUB_PATH" +fi +"$bin/claude" --version + +plugin="${GITHUB_WORKSPACE:-$(pwd)}/plugins/allthings" +home="$temp/claude-home" +mkdir -p "$home" +# An unknown command is enough: the plugin loads, its types are laid, and the +# run ends without a turn. +HOME="$home" "$bin/claude" -p "/allthings-lay-types" --plugin-dir "$plugin" /dev/null 2>&1 || true +rm -rf "$home" +test -f "$plugin/.claude-plugin/types/claude-code/index.d.ts" || { + echo "::error::Claude Code did not lay the allthings mod's types" + exit 1 +} +head -n 1 "$plugin/.claude-plugin/types/claude-code/index.d.ts" diff --git a/.github/workflows/brand.yaml b/.github/workflows/brand.yaml index 69995c7b..9506e6af 100644 --- a/.github/workflows/brand.yaml +++ b/.github/workflows/brand.yaml @@ -6,6 +6,9 @@ on: paths: - "brand/**" - "app/public/brand/**" + # The Claude Code plugin's themes and palette, held to the tokens. + - "plugins/allthings/themes/**" + - "plugins/allthings/hooks/palette.ts" - "package.json" - "bun.lock" - ".github/workflows/brand.yaml" @@ -14,6 +17,8 @@ on: paths: - "brand/**" - "app/public/brand/**" + - "plugins/allthings/themes/**" + - "plugins/allthings/hooks/palette.ts" - "package.json" - "bun.lock" - ".github/workflows/brand.yaml" diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 3dc7c670..805e2c87 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -50,6 +50,13 @@ jobs: working-directory: tools/pr-header run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh" + # The allthings mod (plugins/allthings) types against the declarations + # Claude Code lays beside it; its tsconfig extends them. + - name: 🤖 Lay the Claude Code mod's types + run: bash "$GITHUB_WORKSPACE/.github/scripts/claude-code.sh" + env: + CLAUDE_CODE_VERSION: "2.1.293" + # web/src imports the manifest its build writes to web/dist. - name: 🏗️ Build web working-directory: web diff --git a/.github/workflows/plugin.yaml b/.github/workflows/plugin.yaml new file mode 100644 index 00000000..af351bd7 --- /dev/null +++ b/.github/workflows/plugin.yaml @@ -0,0 +1,61 @@ +name: Claude plugin + +# The allthings plugin for Claude Code (plugins/allthings): its manifests +# validate, its mod type-checks against the types Claude Code writes, and the +# mod's tests pass under Claude Code's own test kit, with no session, sign-in +# or network. +on: + push: + branches: ["main"] + paths: + - "plugins/**" + - ".claude-plugin/**" + - ".github/workflows/plugin.yaml" + - ".github/scripts/claude-code.sh" + pull_request: + paths: + - "plugins/**" + - ".claude-plugin/**" + - ".github/workflows/plugin.yaml" + - ".github/scripts/claude-code.sh" + +permissions: + contents: read + +concurrency: + group: plugin-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Mods need 2.1.287 or later; bump this with the version the README names. + CLAUDE_CODE_VERSION: "2.1.293" + TYPESCRIPT_VERSION: "5.9.3" + +jobs: + mod: + name: 🧩 The mod validates, type-checks and passes its tests + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: 🤖 Install Claude Code and lay the mod's types + run: bash "$GITHUB_WORKSPACE/.github/scripts/claude-code.sh" + + - name: ✅ Validate the marketplace and the plugin + run: | + claude plugin validate --strict . + claude plugin validate --strict plugins/allthings + + - name: 🔍 Type check the mod + run: bunx --package "typescript@${TYPESCRIPT_VERSION}" tsc -p plugins/allthings + + - name: 🏄 Run the mod's tests + run: claude plugin test plugins/allthings diff --git a/.gitignore b/.gitignore index ba2c1472..fa2ad778 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ playwright-report/ # Alchemy local state, wherever a test or a deploy runs from .alchemy/ + +# The types Claude Code lays beside a mod each time it loads it +plugins/*/.claude-plugin/types/ diff --git a/brand/tests/claude-plugin.test.ts b/brand/tests/claude-plugin.test.ts new file mode 100644 index 00000000..25398326 --- /dev/null +++ b/brand/tests/claude-plugin.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { colors, tokens } from "../src/tokens.ts"; + +// The Claude Code plugin (plugins/allthings) draws with the brand's colors: +// its themes and its mod's palette are held to the tokens here, so a token +// that changes moves them too. + +const plugin = join(import.meta.dir, "../../plugins/allthings"); + +const hex = Object.fromEntries( + colors(tokens).map((color) => [color.name, color.hex.toUpperCase()]), +); + +type Theme = { + name: string; + base: string; + overrides: Record; +}; + +async function theme(slug: string): Promise { + return (await Bun.file( + join(plugin, "themes", `${slug}.json`), + ).json()) as Theme; +} + +describe("the Claude Code plugin's colors", () => { + test("allthings night is Night's palette: Glow for the accent, Lavender for meta", async () => { + const night = await theme("allthings-night"); + expect(night.name).toBe("allthings night"); + expect(night.base).toBe("dark"); + expect(night.overrides).toMatchObject({ + claude: hex["glow"], + text: hex["paper"], + inactive: hex["lavender"], + suggestion: hex["lavender"], + promptBorder: hex["ruleNight"], + userMessageBackground: hex["nightRaised"], + selectionBg: hex["ruleNight"], + }); + }); + + test("allthings paper is Paper's palette: Ink text, Bridge for the accent", async () => { + const paper = await theme("allthings-paper"); + expect(paper.name).toBe("allthings paper"); + expect(paper.base).toBe("light"); + expect(paper.overrides).toMatchObject({ + claude: hex["bridge"], + text: hex["ink"], + inactive: hex["karlText"], + suggestion: hex["violet"], + promptBorder: hex["rulePaper"], + userMessageBackground: hex["karl"], + selectionBg: hex["rulePaper"], + }); + }); + + test("the mod's palette is the tokens' own", async () => { + const source = await Bun.file(join(plugin, "hooks/palette.ts")).text(); + const palette = Object.fromEntries( + [...source.matchAll(/export const (\w+) = "(#[0-9A-F]{6})";/g)].map( + ([, name, value]) => [name, value], + ), + ); + expect(palette).toEqual({ + NIGHT: hex["night"], + PAPER: hex["paper"], + INK: hex["ink"], + BRIDGE: hex["bridge"], + GLOW: hex["glow"], + LAVENDER: hex["lavender"], + DUSK: hex["dusk"], + VIOLET: hex["violet"], + }); + }); +}); diff --git a/plugins/allthings/.claude-plugin/plugin.json b/plugins/allthings/.claude-plugin/plugin.json new file mode 100644 index 00000000..770bf968 --- /dev/null +++ b/plugins/allthings/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "allthings", + "version": "0.3.0", + "description": "allthings evenings in San Francisco, for people who build software: the next evening above your prompt, /at and /imin, tool rows in the slash grammar, and the allthings themes and voice.", + "author": { + "name": "allthings", + "url": "https://allthings.dev" + }, + "homepage": "https://allthings.dev", + "repository": "https://github.com/allthingsweb-dev/allthingsweb", + "keywords": [ + "evenings", + "meetups", + "hackathons", + "san francisco", + "software", + "community", + "mod" + ], + "mcpServers": "./mcp.json", + "types": "./types/index.d.ts" +} diff --git a/plugins/allthings/README.md b/plugins/allthings/README.md new file mode 100644 index 00000000..b9942ea8 --- /dev/null +++ b/plugins/allthings/README.md @@ -0,0 +1,69 @@ +# allthings for agents + +Evenings for people who build software, in the neighborhoods of San Francisco, inside your coding agent. + +The plugin gives an agent the allthings MCP server (`list_events`, `get_event`, `list_speakers`, `get_community`) and two skills, `find-evenings` and `evening-briefing`. In Claude Code it also brings a mod that takes over the interface with the allthings/\_ look, two themes and an output style. + +| File | For | +| ---------------------------- | ----------------------------------------------------------------------------------------------------- | +| `plugin.json`, `mcp.json` | The [Agent Plugins](https://agent-plugins.org) manifest and MCP config, read by every host | +| `.claude-plugin/plugin.json` | Claude Code's manifest: the same plugin, its MCP server from `mcp.json`, and the mod's state contract | +| `skills/` | The two skills | +| `hooks/`, `types/`, `tests/` | The Claude Code mod and its tests | +| `themes/`, `output-styles/` | The allthings night and allthings paper themes, and the allthings output style | + +## Try it in Claude Code + +Mods need Claude Code 2.1.287 or later in the terminal (`claude --version`), or 2.1.286 in the Desktop app (`/status`). This plugin is tested with 2.1.293. + +For one session, from a clone of this repository: + +```bash +claude --plugin-dir plugins/allthings +``` + +To keep it, add this repository as a marketplace and install the plugin: + +```bash +claude plugin marketplace add allthingsweb-dev/allthingsweb +claude plugin install allthings@allthings +``` + +or, at the Claude Code prompt, `/plugin marketplace add allthingsweb-dev/allthingsweb` and then `/plugin install allthings@allthings`. Run `/plugin` to see `allthings` among the active mods. + +## What the mod does + +- **Tool rows in the slash grammar.** Each tool call's row reads as a verb, a slash and what it acts on: `read/src/app.ts`, `edit/home.tsx`, `bash/bun test`, `search/"curation"`, `fetch/allthings.dev`, `agent/Find the venue table`, and `allthings/list_events` for an MCP tool. The slash is Bridge on light themes and Glow on dark ones. The status dot, the error color, `Interrupted` and the result under the row are Claude Code's own; whatever else Claude Code's row shows (a line range, a glob, an agent type) follows in dim. Rows inside an expanded group, which carry their output, and the Desktop app's rows, which expand and collapse themselves, stay as Claude Code draws them. +- **The spinner wanders the city.** While Claude works the spinner reads `allthings/_ shipping in SoMa…`, the cursor blinking, then `pairing in the Mission…`, `debugging in Dogpatch…`, `refactoring in the Tenderloin…`, `deploying from FiDi…`, through 36 real neighborhoods. Claude Code's elapsed time and tokens stay after it. +- **The next evening, above the prompt.** A slim Night band: the date, `allthings/_`, the neighborhood, the host, and an **I'm in →** button that opens the evening's Luma page. On the day it counts down (`tonight · in 2h 14m`); during the evening it says `live now`; once you've pressed the button it says `see you at/`. No evening ahead, or no network, and there is no band. +- **`/at`** prints the next evening's card: when, where, who's on stage, and where to say you're in. **`/at `** prints any evening's. **`/imin`** opens the next evening's Luma page. All three answer at once, with no Claude turn, even while Claude is working. + +With `reduceMotion` on in `/config`, the cursors stay solid. + +### Where the data comes from + +The band and the commands call `list_events` (upcoming, limit 1) and `get_event` on the MCP server this plugin configures in `mcp.json`, (allthings.dev/mcp), so a change of URL there, such as a local server while developing, moves them too. The next evening is cached in the plugin's store and asked for again about every 30 minutes, or when it ends. When the site can't be reached the mod stays quiet: a cached evening shows for up to two hours, then the band goes. A headless run (`claude -p`, the SDK) asks the site only when a command does. + +`claude plugin validate plugins/allthings` lists everything the mod reaches: the network (`$.http.fetch`, to the MCP URL alone), the plugin's own files (`$.fs.read`, for `mcp.json`), its store, `/config`'s theme and reduce-motion rows, the `COLORFGBG` variable, and `$.process.run` for `open`, `xdg-open` or `start` when you press **I'm in**. + +## Themes and the output style + +`/theme` lists **allthings night** and **allthings paper**. Claude Code colors its own interface and leaves the terminal's background alone, so set the terminal's background to Night (`#1B1729`) or Paper (`#F4F1EC`) to match. The themes take their colors from the brand tokens (`brand/all-things.tokens.json`), and a test in `brand/` holds them there. + +The **allthings** output style keeps Claude Code's coding instructions and changes only the voice: plain and warm, lowercase where natural, neighborhoods by name, evenings rather than events, "I'm in" and never RSVP. It is opt-in: pick it with `/output-style allthings:allthings`, or under **Output style** in `/config`. + +## Developing the mod + +Claude Code writes the mod's type declarations into `.claude-plugin/types/` (gitignored) each time it loads the plugin from a folder, as with `--plugin-dir`. Then, from the repository root: + +```bash +claude plugin validate --strict plugins/allthings +bunx --package typescript@5.9.3 tsc -p plugins/allthings +claude plugin test plugins/allthings +``` + +The tests run on Claude Code's own test kit, with no session, sign-in or network: every answer from the site is a fixture in `tests/fixtures.ts`. CI (`.github/workflows/plugin.yaml`) runs the three on every change to the plugin. + +## Next + +Hack mode, `/hack start` and `/hack submit` for hackathon evenings, is planned for v2 and isn't built yet. diff --git a/plugins/allthings/hooks/city.ts b/plugins/allthings/hooks/city.ts new file mode 100644 index 00000000..dad97fc6 --- /dev/null +++ b/plugins/allthings/hooks/city.ts @@ -0,0 +1,173 @@ +/** + * San Francisco, as the mod names it: the neighborhoods the spinner wanders + * through, and the one an evening's venue stands in. + * + * Only real neighborhoods, by the names locals use (brand/foundations.md, + * "Places"): FiDi, not the Financial District. + */ + +/** A neighborhood and how a sentence names it ("in the Mission"). */ +type Hood = { name: string; inSentence: string }; + +const hood = (name: string, article = ""): Hood => ({ + name, + inSentence: article ? `${article} ${name}` : name, +}); + +/** The spinner's walk through the city, in order. */ +export const NEIGHBORHOODS: readonly Hood[] = [ + hood("SoMa"), + hood("Mission", "the"), + hood("Dogpatch"), + hood("Tenderloin", "the"), + hood("FiDi"), + hood("East Cut"), + hood("Potrero Hill"), + hood("Mission Bay"), + hood("Hayes Valley"), + hood("North Beach"), + hood("Chinatown"), + hood("Nob Hill"), + hood("Russian Hill"), + hood("Union Square"), + hood("Mid-Market"), + hood("Japantown"), + hood("Castro", "the"), + hood("Noe Valley"), + hood("Bernal Heights"), + hood("Glen Park"), + hood("Haight", "the"), + hood("Cole Valley"), + hood("NoPa"), + hood("Richmond", "the"), + hood("Sunset", "the"), + hood("Marina", "the"), + hood("Cow Hollow"), + hood("Pacific Heights"), + hood("Jackson Square"), + hood("Telegraph Hill"), + hood("Excelsior", "the"), + hood("Bayview"), + hood("Presidio", "the"), + hood("Duboce Triangle"), + hood("Lower Haight"), + hood("Western Addition", "the"), +]; + +/** What the spinner says someone is doing there, and the preposition it takes. */ +const WORK: readonly (readonly [verb: string, preposition: string])[] = [ + ["shipping", "in"], + ["pairing", "in"], + ["debugging", "in"], + ["refactoring", "in"], + ["deploying", "from"], + ["reviewing", "in"], + ["testing", "in"], + ["profiling", "in"], + ["rebasing", "in"], + ["prototyping", "in"], + ["bisecting", "in"], + ["typechecking", "in"], + ["benchmarking", "in"], + ["merging", "in"], + ["migrating", "in"], + ["hacking", "in"], + ["sketching", "in"], + ["pushing", "from"], + ["linting", "in"], + ["demoing", "in"], +]; + +/** + * The spinner's nth line, such as "shipping in SoMa": the verbs and the + * neighborhoods each cycle on their own, so the pairs keep changing. + */ +export function phraseAt(n: number): string { + const index = Math.max(0, Math.floor(n)); + const [verb, preposition] = WORK[index % WORK.length] ?? ["shipping", "in"]; + const place = NEIGHBORHOODS[index % NEIGHBORHOODS.length] ?? NEIGHBORHOODS[0]; + return `${verb} ${preposition} ${place?.inSentence ?? "SoMa"}`; +} + +/** Venues the community has met at, by street address (brand/foundations.md). */ +const VENUES: readonly (readonly [address: string, neighborhood: string])[] = [ + ["201 spear st", "East Cut"], + ["100 1st st", "East Cut"], + ["45 fremont st", "FiDi"], + ["351 california st", "FiDi"], + ["50 beale st", "FiDi"], + ["525 market st", "FiDi"], + ["585 market st", "FiDi"], + ["660 market st", "FiDi"], + ["1 post st", "FiDi"], + ["40 o'farrell st", "Union Square"], + ["760 market st", "Union Square"], + ["444 de haro st", "Potrero Hill"], + ["277 carolina st", "Potrero Hill"], + ["pier 70", "Dogpatch"], + ["1242 market st", "Mid-Market"], + ["360 ritch st", "SoMa"], + ["620 treat ave", "Mission"], + ["500 terry a francois blvd", "Mission Bay"], +]; + +/** Otherwise the neighborhood a ZIP code mostly covers, by its local name. */ +const ZIPS: Readonly> = { + "94102": "Mid-Market", + "94103": "SoMa", + "94104": "FiDi", + "94105": "East Cut", + "94107": "SoMa", + "94108": "Union Square", + "94109": "Nob Hill", + "94110": "Mission", + "94111": "FiDi", + "94112": "Excelsior", + "94114": "Castro", + "94115": "Western Addition", + "94116": "Parkside", + "94117": "Haight", + "94118": "Inner Richmond", + "94121": "Outer Richmond", + "94122": "Inner Sunset", + "94123": "Marina", + "94124": "Bayview", + "94127": "West Portal", + "94129": "Presidio", + "94130": "Treasure Island", + "94131": "Noe Valley", + "94132": "Lakeshore", + "94133": "North Beach", + "94134": "Visitacion Valley", + "94158": "Mission Bay", +}; + +/** + * The neighborhood of a venue's address: a venue the community knows by its + * street address, else the ZIP code's, else null (left out, never guessed). + */ +export function neighborhoodOf(address: string | null): string | null { + if (address === null) return null; + const plain = address + .toLowerCase() + .replaceAll("’", "'") + .replaceAll(/\bstreet\b/g, "st") + .replaceAll(/\s+/g, " "); + // Words, so "45 fremont st" is found in "sentry, 45 fremont st, ..." and + // never inside "145 fremont st". + const words = ` ${plain.replaceAll(/[^a-z0-9']+/g, " ").trim()} `; + for (const [street, neighborhood] of VENUES) { + if (words.includes(` ${street} `)) return neighborhood; + } + if (!/san francisco|\bsf\b/.test(plain)) return null; + const zip = /\b(94\d{3})\b/.exec(plain)?.[1]; + return zip === undefined ? null : (ZIPS[zip] ?? null); +} + +/** Where a turn's walk starts: a number from its id, so each turn sets out somewhere else. */ +export function seedOf(turnId: string): number { + let hash = 0; + for (const char of turnId) + hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 9_973; + return hash; +} diff --git a/plugins/allthings/hooks/evenings.ts b/plugins/allthings/hooks/evenings.ts new file mode 100644 index 00000000..169fc6af --- /dev/null +++ b/plugins/allthings/hooks/evenings.ts @@ -0,0 +1,322 @@ +/** + * Evenings from the site's MCP server: where the server is, how to ask it, + * what its answers mean, and what the band and `/at` say about them. + * + * Everything here is plain functions of their arguments; register.tsx makes + * the calls through `$`. + */ + +import type { Evening, EveningTalk } from "../types"; +import { neighborhoodOf } from "./city"; +import { + clockOf, + dayOf, + isEveningStart, + isSameDay, + spanOf, + untilOf, +} from "./time"; + +/** How long a fetched next evening counts as fresh: refresh about every 30 minutes. */ +export const FRESH_MS = 30 * 60_000; + +/** How long a cached evening outlives a failed refresh before the band goes quiet. */ +export const GRACE_MS = 2 * 60 * 60_000; + +/** The wordmark, always one word, the cursor after the slash. */ +export const WORDMARK = "allthings"; + +/** + * The MCP server's URL from a plugin MCP config (`mcpServers`, or servers at + * the top level): the `allthings` server's, else the first with an http(s) + * URL. Null when the file names none. + */ +export function endpointOf(configText: string): string | null { + let config: unknown; + try { + config = JSON.parse(configText); + } catch { + return null; + } + if (!isRecord(config)) return null; + const servers = isRecord(config.mcpServers) ? config.mcpServers : config; + const named = servers.allthings; + const candidates = [named, ...Object.values(servers)]; + for (const server of candidates) { + if (isRecord(server) && typeof server.url === "string") { + const url = parseUrl(server.url); + if ( + url !== null && + (url.protocol === "https:" || url.protocol === "http:") + ) { + return url.href; + } + } + } + return null; +} + +/** The body of a JSON-RPC `tools/call` request for one tool. */ +export function toolCallBody( + name: string, + args: Record, +): string { + return JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }); +} + +/** The headers a stateless streamable-HTTP MCP server takes a call with. */ +export const TOOL_CALL_HEADERS: Readonly> = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": "2025-06-18", +}; + +/** A tool's answer: its structured content, or the text of its error. */ +export type ToolAnswer = + | { isError: false; structured: unknown } + | { isError: true; message: string }; + +/** + * Reads one JSON-RPC answer from a JSON or event-stream body. Throws when the + * body is neither, or the server answered with an RPC error. + */ +export function toolAnswerOf(body: string, contentType: string): ToolAnswer { + const json = contentType.includes("text/event-stream") + ? body + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .join("") + : body; + const rpc: unknown = JSON.parse(json); + if (!isRecord(rpc)) throw new Error("not a JSON-RPC answer"); + if (isRecord(rpc.error)) { + const message = rpc.error.message; + throw new Error( + typeof message === "string" ? message : "the server refused the call", + ); + } + const result = rpc.result; + if (!isRecord(result)) throw new Error("no result"); + if (result.isError === true) { + const content = Array.isArray(result.content) ? result.content : []; + const message = content + .map((part: unknown) => + isRecord(part) && typeof part.text === "string" ? part.text : "", + ) + .join("\n"); + return { isError: true, message }; + } + return { isError: false, structured: result.structuredContent }; +} + +/** + * The topic of allthings/ from an evening's name: what follows "all + * things" ("All Things Agent Setups" is agent-setups), else the name less its + * city ("Effect San Francisco" is effect). Lowercase, words joined by "-". + */ +export function topicOf(name: string): string { + const plain = name + .replaceAll(/[^\p{L}\p{N}\s/&.+-]/gu, " ") + .replaceAll(/\s+/g, " ") + .trim(); + const after = /^all\s*things\s*[/:–-]?\s*(.+)$/i.exec(plain)?.[1]; + const topic = (after ?? plain.replace(/\s+(san francisco|sf)$/i, "")).trim(); + const slug = topic + .toLowerCase() + .replaceAll(/\s+/g, "-") + .replaceAll(/[^\p{L}\p{N}.+-]/gu, ""); + return slug === "" ? "_" : slug; +} + +/** An EventSummary (`list_events`) or Event (`get_event`) as an Evening; null when it isn't one. */ +export function eveningOf(raw: unknown): Evening | null { + if (!isRecord(raw)) return null; + const { slug, name, url, status, startsAt, endsAt } = raw; + if ( + typeof slug !== "string" || + typeof name !== "string" || + typeof url !== "string" || + typeof startsAt !== "string" || + typeof endsAt !== "string" || + !Number.isFinite(Date.parse(startsAt)) || + !Number.isFinite(Date.parse(endsAt)) + ) { + return null; + } + const venue = isRecord(raw.venue) ? raw.venue : null; + const venueName = typeof venue?.name === "string" ? venue.name : null; + const address = typeof venue?.address === "string" ? venue.address : null; + const hosts = Array.isArray(raw.hosts) + ? raw.hosts.flatMap((host: unknown) => + isRecord(host) && typeof host.name === "string" ? [host.name] : [], + ) + : []; + const talks: EveningTalk[] = Array.isArray(raw.talks) + ? raw.talks.flatMap((talk: unknown) => + isRecord(talk) && typeof talk.title === "string" + ? [ + { + title: talk.title, + speakers: Array.isArray(talk.speakers) + ? talk.speakers.flatMap((speaker: unknown) => + isRecord(speaker) && typeof speaker.name === "string" + ? [speaker.name] + : [], + ) + : [], + }, + ] + : [], + ) + : []; + return { + slug, + name, + topic: topicOf(name), + url, + status: status === "live" || status === "past" ? status : "upcoming", + startsAt, + endsAt, + venueName, + address, + neighborhood: neighborhoodOf(address), + rsvpUrl: typeof raw.rsvpUrl === "string" ? raw.rsvpUrl : null, + recordingUrl: + typeof raw.recordingUrl === "string" ? raw.recordingUrl : null, + isHackathon: raw.isHackathon === true, + hosts, + talks, + }; +} + +/** The evenings of a `list_events` answer, in the server's order. */ +export function eveningsOf(structured: unknown): Evening[] { + if (!isRecord(structured) || !Array.isArray(structured.events)) { + throw new Error("list_events answered without events"); + } + return structured.events.flatMap((raw: unknown) => { + const evening = eveningOf(raw); + return evening === null ? [] : [evening]; + }); +} + +/** Where an evening stands at an instant. */ +export type Phase = + /** Ahead, on another day. */ + | "upcoming" + /** Ahead, later today in San Francisco. */ + | "today" + /** Started and not yet over. */ + | "live" + /** Over. */ + | "past"; + +export function phaseOf(evening: Evening, now: number): Phase { + const starts = Date.parse(evening.startsAt); + const ends = Date.parse(evening.endsAt); + if (now >= ends) return "past"; + if (now >= starts || evening.status === "live") return "live"; + return isSameDay(starts, now) ? "today" : "upcoming"; +} + +/** The host to name: the hosting companies `get_event` gave, else the venue's name. */ +export function hostOf(evening: Evening): string | null { + if (evening.hosts.length > 0) return evening.hosts.join(" & "); + return evening.venueName; +} + +/** The band's first slot: the date, the countdown on the day, or "live now". */ +export function whenOf(evening: Evening, now: number): string { + const starts = Date.parse(evening.startsAt); + const phase = phaseOf(evening, now); + if (phase === "live") return "live now"; + if (phase === "today") + return `${isEveningStart(starts) ? "tonight" : "today"} · in ${untilOf(now, starts)}`; + if (phase === "upcoming") return `${dayOf(starts)} · ${clockOf(starts)}`; + return dayOf(starts); +} + +/** The lockup, allthings/, with the cursor while the evening hasn't happened. */ +export function lockupOf( + evening: Evening, + now: number, + cursor: string, +): string { + return `${WORDMARK}/${evening.topic}${phaseOf(evening, now) === "past" ? "" : cursor}`; +} + +/** The sign-off after someone says they're in. */ +export function signOffOf(evening: Evening): string { + return `see you at/${evening.topic}`; +} + +/** An address less its city, state and country: "201 Spear St 12th floor". */ +export function streetOf(address: string): string { + return address + .replace(/,\s*(san francisco|sf)\b.*$/i, "") + .replace(/^[^,0-9]+,\s*(?=\d)/, "") + .trim(); +} + +/** A URL as a person reads it: no scheme, no www, no trailing slash. */ +export function shortUrl(url: string): string { + return url + .replace(/^https?:\/\//, "") + .replace(/^www\./, "") + .replace(/\/$/, ""); +} + +/** + * The text `/at` prints, which Claude reads too: the lockup, when and where, + * who's on stage, and where to say you're in. + */ +export function cardText(evening: Evening, now: number): string { + const starts = Date.parse(evening.startsAt); + const ends = Date.parse(evening.endsAt); + const phase = phaseOf(evening, now); + const host = hostOf(evening); + const lines = [lockupOf(evening, now, "_")]; + const when = + phase === "live" + ? `live now, until ${clockOf(ends)}` + : `${dayOf(starts)} · ${spanOf(starts, ends)}${phase === "today" ? ` · in ${untilOf(now, starts)}` : ""}`; + lines.push( + [when, evening.neighborhood, host === null ? null : `hosted at ${host}`] + .filter((part) => part !== null) + .join(" · "), + ); + if (evening.address !== null) lines.push(streetOf(evening.address)); + for (const talk of evening.talks) { + lines.push( + talk.speakers.length > 0 + ? `· ${talk.title} — ${talk.speakers.join(", ")}` + : `· ${talk.title}`, + ); + } + if (phase === "past") { + if (evening.recordingUrl !== null) + lines.push(`recording → ${evening.recordingUrl}`); + } else if (evening.rsvpUrl !== null) { + lines.push(`I'm in → ${evening.rsvpUrl}`); + } + lines.push(evening.url); + return lines.join("\n"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseUrl(text: string): URL | null { + try { + return new URL(text); + } catch { + return null; + } +} diff --git a/plugins/allthings/hooks/hooks.json b/plugins/allthings/hooks/hooks.json new file mode 100644 index 00000000..7947b477 --- /dev/null +++ b/plugins/allthings/hooks/hooks.json @@ -0,0 +1,4 @@ +{ + "description": "allthings/_ for Claude Code: tool rows in the slash grammar, the wandering spinner, the next evening's band, /at and /imin", + "modules": ["./register.tsx"] +} diff --git a/plugins/allthings/hooks/palette.ts b/plugins/allthings/hooks/palette.ts new file mode 100644 index 00000000..04fbc09d --- /dev/null +++ b/plugins/allthings/hooks/palette.ts @@ -0,0 +1,34 @@ +/** + * The brand's colors (brand/all-things.tokens.json), and which one the slash + * takes: Bridge on light grounds, Glow on Night and other dark ones. + */ + +export const NIGHT = "#1B1729"; +export const PAPER = "#F4F1EC"; +export const INK = "#141210"; +export const BRIDGE = "#C0362C"; +export const GLOW = "#FF6A3D"; +export const LAVENDER = "#DACFFF"; +export const DUSK = "#D9D3E0"; +export const VIOLET = "#5B34D6"; + +/** + * The slash's color for a Claude Code theme setting (`/config`'s `theme` + * row: `dark`, `light-daltonized`, a custom or plugin theme's id, `auto`). + * `auto` follows the terminal: COLORFGBG's background when it says, else dark. + */ +export function slashColorOf( + theme: unknown, + colorFgBg: string | undefined, +): string { + const name = typeof theme === "string" ? theme.toLowerCase() : ""; + if (/paper|light/.test(name)) return BRIDGE; + if (/night|dark/.test(name)) return GLOW; + return isLightTerminal(colorFgBg) ? BRIDGE : GLOW; +} + +/** COLORFGBG is "fg;bg" in ANSI color numbers: 7 and 15 are light grounds. */ +function isLightTerminal(colorFgBg: string | undefined): boolean { + const background = colorFgBg?.split(";").at(-1); + return background === "7" || background === "15"; +} diff --git a/plugins/allthings/hooks/register.tsx b/plugins/allthings/hooks/register.tsx new file mode 100644 index 00000000..437841a4 --- /dev/null +++ b/plugins/allthings/hooks/register.tsx @@ -0,0 +1,673 @@ +/** + * allthings/_ for Claude Code: tool rows in the slash grammar, the spinner as + * the wordmark wandering the city, the next evening in a Night band above the + * prompt, and /at and /imin, which answer at once with no turn. + * + * Every hook here restyles; none hides what Claude Code shows. A failure to + * reach the site is quiet: no band, and a plain line from the commands. + * + * The engine reads `on(...)` and `$.noun.method(...)` from this source, so + * they are spelled in full, and the helpers that take `$` are top-level + * functions of this file. + */ + +import { atom, read, update } from "claude-code"; +import type { CommandSpec, EngineInterface, Register } from "claude-code"; + +import type { Card, Evening } from "../types"; +import { phraseAt, seedOf } from "./city"; +import { + cardText, + endpointOf, + eveningOf, + eveningsOf, + FRESH_MS, + GRACE_MS, + hostOf, + phaseOf, + shortUrl, + signOffOf, + streetOf, + TOOL_CALL_HEADERS, + toolAnswerOf, + toolCallBody, + whenOf, + WORDMARK, + type ToolAnswer, +} from "./evenings"; +import { + BRIDGE, + DUSK, + GLOW, + LAVENDER, + NIGHT, + PAPER, + slashColorOf, + VIOLET, +} from "./palette"; +import { rowOf } from "./rows"; +import { clockOf, dayOf, spanOf } from "./time"; + +type Engine = EngineInterface; + +const nextEvening = atom({ plugin: "allthings", key: "next" } as const, null); +const fetchedAt = atom({ plugin: "allthings", key: "fetchedAt" } as const, 0); +const clock = atom({ plugin: "allthings", key: "now" } as const, 0); +const tick = atom({ plugin: "allthings", key: "tick" } as const, 0); +const clicked = atom({ plugin: "allthings", key: "clicked" } as const, []); +const slash = atom({ plugin: "allthings", key: "slash" } as const, GLOW); +const reduceMotion = atom( + { plugin: "allthings", key: "reduceMotion" } as const, + false, +); +const cards = atom({ plugin: "allthings", key: "cards" } as const, {}); + +/** The cursor blinks once a second: on for half of it, off for the other. */ +export const BLINK_MS = 500; + +/** The band's clock, and the check for a stale or finished evening, move this often. */ +export const MINUTE_MS = 30_000; + +/** The spinner's line moves on to the next neighborhood every this many blinks (6 seconds). */ +export const PHRASE_TICKS = 12; + +/** After a failed refresh, wait this long before asking again for an evening that just ended. */ +const RETRY_MS = 5 * 60_000; + +/** How many /at cards the session keeps drawable. */ +const CARDS_KEPT = 20; + +/** The session's folder, to draw paths relative to it. */ +let cwd = ""; +/** Whether a main-loop turn is running, so the spinner's cursor blinks. */ +let isWorking = false; +/** Where this turn's walk through the city starts. */ +let seed = 0; +/** When the mod last asked the site, successfully or not. */ +let attemptedAt = 0; +/** The refresh under way, which every caller meanwhile shares. */ +let refreshing: Promise | null = null; +/** Tool calls drawn inside an expanded group, whose rows carry their output. */ +const unfolded = new Set(); + +export const register: Register = (on) => { + on("session.start", async ($, e, next) => { + cwd = e.cwd; + const started = await next(e); + await loadSaved($); + await readSettings($); + await minute($, { refresh: false }); + // Only a session someone watches draws the band and the cursor; a + // headless run (claude -p, the SDK) asks the site only when a command does. + if (e.isInteractive) { + $.clock.every(BLINK_MS, () => { + void blink($); + }); + $.clock.every(MINUTE_MS, () => { + void minute($, { refresh: true }); + }); + // Ask the site once the session is up, never holding its start. + $.clock.after(1, () => { + void minute($, { refresh: true }); + }); + } + await offer($, { + name: "at", + description: "The next allthings evening, or any evening by its slug", + argumentHint: "[slug]", + immediate: true, + }); + await offer($, { + name: "imin", + description: "Open the next allthings evening's page to say you're in", + immediate: true, + }); + return started; + }); + + // /clear, /resume and /branch reset $.state without a new session.start. + on( + "classic.SessionStart", + { source: ["clear", "resume", "fork"] }, + async ($, e, next) => { + const result = await next(e); + await loadSaved($); + await readSettings($); + await minute($, { refresh: false }); + return result; + }, + ).catch(($, e, beneath) => beneath(e)); + + on("config.set", { key: ["theme", "reduceMotion"] }, async ($, e, next) => { + const result = await next(e); + if (result.deny === undefined) await readSettings($); + return result; + }).catch(($, e, beneath) => beneath(e)); + + on("turn.start", async ($, e, next) => { + isWorking = true; + seed = seedOf(e.turnId); + return next(e); + }); + + on("turn.complete", async ($, e, next) => { + if (e.agentId === undefined) isWorking = false; + return next(e); + }); + + // The spinner: allthings/_ with its cursor, then a working verb somewhere in + // the city. Claude Code keeps drawing the elapsed time and tokens after it. + on("ui.render", { component: "Spinner" }, async ($, e, next) => { + const isStill = await read($, reduceMotion); + const t = await read($, tick); + const mark = `${WORDMARK}/${!isStill && t % 2 === 1 ? " " : "_"}`; + if (e.props.message !== null) { + return next({ + ...e, + props: { ...e.props, message: `${mark} · ${e.props.message}` }, + }); + } + // The desktop's word says what the step is doing: keep it. + if (e.surface !== "terminal" && e.props.word !== "Working") { + return next({ + ...e, + props: { ...e.props, word: `${mark} · ${e.props.word}` }, + }); + } + const phrase = phraseAt(seed + Math.floor(t / PHRASE_TICKS)); + return next({ ...e, props: { ...e.props, word: `${mark} ${phrase}` } }); + }); + + // An expanded group draws each call's output in its row: those rows stay + // Claude Code's own, so nothing is lost. + on("ui.render", { component: "ToolGroup" }, ($, e, next) => { + if (e.props.isExpanded) { + for (const call of e.props.calls) { + if (call.tool_use_id !== undefined) unfolded.add(call.tool_use_id); + } + } + return next(e); + }); + + // A tool call's row in the slash grammar: read/src/app.ts. The status dot, + // the error color and "Interrupted" stay; the result below is Claude Code's. + on("ui.render", { component: "ToolUse" }, async ($, e, next) => { + // The desktop's rows expand and collapse themselves: leave them be. + if (e.surface !== "terminal" || unfolded.has(e.props.tool_use_id)) + return next(e); + const { Box, Text } = $.ui.resolve(e); + const row = rowOf(e.props.tool, e.props.input, cwd); + const color = await read($, slash); + const isFailed = e.props.isErrored || e.props.isInterrupted; + const isDim = + e.props.isRunning && + !(await read($, reduceMotion)) && + (await read($, tick)) % 2 === 1; + const dot = isFailed ? "error" : e.props.isRunning ? "inactive" : "success"; + return ( + + + + {"⏺ "} + + + {row.verb} + / + {row.arg} + {row.detail === null ? null : ( + {` (${row.detail})`} + )} + + + {e.props.isInterrupted ? ( + {" ⎿ Interrupted"} + ) : null} + + ); + }); + + // The band: the next evening on Night, and a button to say you're in. + on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => { + if (e.props.hasSurvey) return next(e); + const evening = await read($, nextEvening); + if (evening === null) return next(e); + const now = await read($, clock); + const phase = phaseOf(evening, now); + if (phase === "past") return next(e); + const isIn = (await read($, clicked)).includes(evening.slug); + const isStill = await read($, reduceMotion); + const cursor = !isStill && (await read($, tick)) % 2 === 1 ? " " : "_"; + const host = hostOf(evening); + const columns = e.props.bodyColumns; + const { Box, Text, Button } = $.ui.resolve(e); + return ( + + + + + {whenOf(evening, now)} + + {" · "} + + {WORDMARK} + / + {`${evening.topic}${cursor}`} + + {evening.neighborhood !== null && columns >= 56 ? ( + {` · ${evening.neighborhood}`} + ) : null} + {host !== null && columns >= 80 ? ` · hosted at ${host}` : null} + + + {isIn ? ( + + see you at/ + {evening.topic} + + ) : evening.rsvpUrl === null ? null : ( +