diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7d3393..d819149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,3 +75,34 @@ jobs: - name: Test (Vitest) run: pnpm test + + create-claudius: + name: Validate create-claudius + runs-on: ubuntu-latest + defaults: + run: + working-directory: create-claudius + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: create-claudius/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm typecheck + + - name: Test (Vitest) + run: pnpm test + + - name: Build + run: pnpm build diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index ff4bcb7..678347b 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,20 +1,27 @@ name: Publish to npm -# Manual, on-demand publish of the widget to npm. +# Manual, on-demand publish of a package to npm. # # The release-please workflow already publishes automatically when it cuts a # release. This workflow exists for the cases that one can't handle: -# - bootstrapping the very first publish (the release for the current version -# already exists, so re-running release-please is a no-op), and -# - recovering from a failed release publish without waiting for the next -# release. +# - bootstrapping the very first publish of a package (the release for the +# current version already exists, so re-running release-please is a no-op), and +# - recovering from a failed release publish without waiting for the next release. # # Requires the NPM_TOKEN secret to be an npm token that can publish the package -# and bypasses 2FA (a classic Automation token, or a granular token with -# "All packages" + read/write). Publishes with provenance (needs id-token). +# (and create it on first publish) and bypasses 2FA — a granular token with +# "All packages" + read/write, or an account-scoped token. Publishes with +# provenance (needs id-token). on: workflow_dispatch: inputs: + package: + description: "Which package to publish" + type: choice + options: + - widget + - create-claudius + default: widget dist-tag: description: "npm dist-tag to publish under" required: false @@ -25,16 +32,16 @@ permissions: id-token: write # for npm provenance concurrency: - group: publish-npm + group: publish-npm-${{ github.event.inputs.package }} cancel-in-progress: false jobs: publish: - name: Publish widget to npm + name: Publish ${{ inputs.package }} to npm runs-on: ubuntu-latest defaults: run: - working-directory: widget + working-directory: ${{ inputs.package }} steps: - uses: actions/checkout@v4 @@ -47,7 +54,7 @@ jobs: node-version: 20 registry-url: https://registry.npmjs.org cache: pnpm - cache-dependency-path: widget/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.package }}/pnpm-lock.yaml - name: Install run: pnpm install --frozen-lockfile diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b0487a0..7cf30a7 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -103,3 +103,13 @@ jobs: pnpm install --frozen-lockfile pnpm build npm publish --provenance --access public + + - name: Build and publish create-claudius + if: ${{ steps.release.outputs.release_created }} + working-directory: create-claudius + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + pnpm install --frozen-lockfile + pnpm build + npm publish --provenance --access public diff --git a/README.md b/README.md index cfb7107..329312b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Claudius - Embeddable AI Chat Widget [![npm](https://img.shields.io/npm/v/claudius-chat-widget.svg)](https://www.npmjs.com/package/claudius-chat-widget) +[![create-claudius](https://img.shields.io/npm/v/create-claudius.svg?label=create-claudius)](https://www.npmjs.com/package/create-claudius) [![license](https://img.shields.io/npm/l/claudius-chat-widget.svg)](LICENSE) An open-source, embeddable AI chat widget powered by Claude. Drop it into any @@ -16,6 +17,19 @@ website with a single script tag, or install it as a React component. Claudius is two pieces: a Cloudflare Worker (keeps your Anthropic API key server-side) and a widget embed. +### Fastest: scaffold a project + +```bash +npm create claudius@latest +``` + +Answer a few prompts (framework — vanilla, React, or Next.js — theme, accent color, +worker API URL, and optionally a Cloudflare Worker) and you get a ready-to-run +project. See the +[scaffolding guide](https://claudius-docs.pages.dev/getting-started/scaffolding/). + +Prefer to wire things up by hand? Continue below. + ### 1. Deploy the worker ```bash diff --git a/create-claudius/LICENSE b/create-claudius/LICENSE new file mode 100644 index 0000000..fe1a266 --- /dev/null +++ b/create-claudius/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PMDevSolutions + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/create-claudius/README.md b/create-claudius/README.md new file mode 100644 index 0000000..d6ed96d --- /dev/null +++ b/create-claudius/README.md @@ -0,0 +1,68 @@ +# create-claudius + +[![npm](https://img.shields.io/npm/v/create-claudius.svg)](https://www.npmjs.com/package/create-claudius) + +Scaffold a [Claudius](https://claudius-docs.pages.dev) AI chat widget project in +one command. + +```bash +npm create claudius@latest +# or +pnpm create claudius +# or +yarn create claudius +``` + +You'll be prompted for: + +- **Project name** — the new directory / package name +- **Framework** — `vanilla` (CDN script embed), `react` (Vite), or `next` (Next.js App Router) +- **Theme** — `auto` / `light` / `dark`, or a built-in theme (`default`, `minimal`, `playful`, `corporate`) +- **Accent color** — a hex color for the widget +- **API URL** — your deployed Claudius worker endpoint +- Optionally, a **Cloudflare Worker** scaffold (`wrangler.toml` + KV stub + deploy runbook) + +The generated project runs out of the box: + +```bash +cd my-app +pnpm install +pnpm dev +``` + +## Non-interactive + +Pass flags to skip the prompts (handy for CI): + +```bash +npm create claudius@latest my-app -- \ + --template react \ + --theme auto \ + --accent "#4f46e5" \ + --api-url https://my-worker.workers.dev \ + --worker \ + --yes +``` + +| Flag | Values | Description | +| --- | --- | --- | +| `--template` | `vanilla` \| `react` \| `next` | Framework template | +| `--theme` | `auto` \| `light` \| `dark` \| `default` \| `minimal` \| `playful` \| `corporate` | Widget theme | +| `--accent` | `#rrggbb` | Accent color | +| `--api-url` | URL | Worker chat endpoint | +| `--worker` | — | Also scaffold a Cloudflare Worker | +| `--pm` | `npm` \| `pnpm` \| `yarn` \| `bun` | Package manager shown in next steps | +| `--yes`, `-y` | — | Accept defaults for any unspecified prompt | + +## What you get + +- **vanilla** — a static Vite site that loads the widget from the jsDelivr CDN via a + single ` + + +", + "package.json": "{ + "name": "demo-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "claudius-chat-widget": "^1.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} +", + "src/App.tsx": "import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; + +const pageStyle: React.CSSProperties = { + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + display: "grid", + placeItems: "center", + minHeight: "100vh", + margin: 0, +}; + +export function App() { + return ( +
+
+

demo-app

+

+ Edit src/App.tsx and configure the widget via its props. The + launcher is in the corner. +

+
+ + +
+ ); +} +", + "src/main.tsx": "import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); +", + "tsconfig.json": "{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "useDefineForClassFields": true + }, + "include": ["src", "vite.config.ts"] +} +", + "vite.config.ts": "import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); +", + "worker/.dev.vars.example": "# Copy this file to .dev.vars and fill in your key for local development: +# cp .dev.vars.example .dev.vars +ANTHROPIC_API_KEY= +", + "worker/.gitignore": "node_modules +.wrangler +dist +.dev.vars +.DS_Store +", + "worker/README.md": "# demo-app-worker + +A minimal [Cloudflare Worker](https://workers.cloudflare.com) that keeps your +Anthropic API key server-side and powers the Claudius widget. It exposes +\`POST /api/chat\` and \`GET /api/health\`, with CORS and a small KV-backed rate limit. + +> This is a trimmed starter. For the full-featured worker (analytics, configurable +> rate limits, richer error handling), see the +> [Claudius worker](https://github.com/PMDevSolutions/Claudius/tree/main/worker). + +## Local development + +\`\`\`bash +pnpm install +cp .dev.vars.example .dev.vars # then add your ANTHROPIC_API_KEY +pnpm dev # http://localhost:8787 +\`\`\` + +## Deploy + +\`\`\`bash +# 1. Create the KV namespace and paste the id(s) into wrangler.toml +npx wrangler kv namespace create RATE_LIMIT + +# 2. Store your API key as a secret +npx wrangler secret put ANTHROPIC_API_KEY + +# 3. Deploy +npx wrangler deploy +\`\`\` + +After deploying, set \`ALLOWED_ORIGIN\` (in \`wrangler.toml\` or the Cloudflare +dashboard) to your site's origin, and point the widget's \`apiUrl\` at the worker +URL. +", + "worker/package.json": "{ + "name": "demo-app-worker", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", + "hono": "^4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240909.0", + "typescript": "^5.6.0", + "wrangler": "^4.6.0" + } +} +", + "worker/src/index.ts": "import { Hono } from "hono"; +import { cors } from "hono/cors"; +import Anthropic from "@anthropic-ai/sdk"; + +interface Env { + ANTHROPIC_API_KEY: string; + ALLOWED_ORIGIN: string; + RATE_LIMIT: KVNamespace; + CLAUDE_MODEL?: string; + MAX_TOKENS?: string; +} + +interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +const DEFAULT_MODEL = "claude-haiku-4-5-20251001"; +const MAX_MESSAGE_LENGTH = 2000; +const RATE_LIMIT_PER_MINUTE = 20; + +// Customize your assistant's personality and knowledge here. +const SYSTEM_PROMPT = + "You are a helpful, concise assistant embedded on a website. " + + "Answer clearly and politely. If you don't know something, say so."; + +const app = new Hono<{ Bindings: Env }>(); + +app.use( + "/api/*", + cors({ + origin: (origin, c) => { + const allowed = (c.env.ALLOWED_ORIGIN || "http://localhost:5173") + .split(",") + .map((o: string) => o.trim()) + .filter(Boolean); + if (origin?.startsWith("http://localhost:")) return origin; + return origin && allowed.includes(origin) ? origin : allowed[0]; + }, + allowMethods: ["POST", "OPTIONS"], + allowHeaders: ["Content-Type"], + maxAge: 86400, + }), +); + +app.get("/api/health", (c) => c.json({ ok: true })); + +app.post("/api/chat", async (c) => { + let body: { messages?: ChatMessage[] }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body." }, 400); + } + + const messages = body.messages; + if (!Array.isArray(messages) || messages.length === 0) { + return c.json({ error: "A non-empty messages array is required." }, 400); + } + + // Minimal per-IP, per-minute rate limit backed by the RATE_LIMIT KV namespace. + const ip = c.req.header("cf-connecting-ip") ?? "unknown"; + const bucket = \`rl:\${ip}:\${Math.floor(Date.now() / 60000)}\`; + const count = parseInt((await c.env.RATE_LIMIT.get(bucket)) ?? "0", 10); + if (count >= RATE_LIMIT_PER_MINUTE) { + return c.json({ error: "Too many requests. Please wait a minute." }, 429, { + "Retry-After": "60", + }); + } + await c.env.RATE_LIMIT.put(bucket, String(count + 1), { expirationTtl: 120 }); + + const sanitized = messages.map((m) => ({ + role: m.role, + content: String(m.content ?? "").slice(0, MAX_MESSAGE_LENGTH), + })); + + try { + const client = new Anthropic({ apiKey: c.env.ANTHROPIC_API_KEY }); + const response = await client.messages.create({ + model: c.env.CLAUDE_MODEL ?? DEFAULT_MODEL, + max_tokens: c.env.MAX_TOKENS ? parseInt(c.env.MAX_TOKENS, 10) : 1024, + system: SYSTEM_PROMPT, + messages: sanitized, + }); + const textBlock = response.content.find((block) => block.type === "text"); + return c.json({ reply: textBlock && textBlock.type === "text" ? textBlock.text : "" }); + } catch { + return c.json({ error: "AI service temporarily unavailable. Please try again." }, 502); + } +}); + +export default app; +", + "worker/tsconfig.json": "{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"] +} +", + "worker/wrangler.toml": "name = "demo-app-worker" +main = "src/index.ts" +compatibility_date = "2024-09-23" + +[vars] +# Comma-separated list of origins allowed to call this worker. +ALLOWED_ORIGIN = "http://localhost:5173" +# Optional overrides: +# CLAUDE_MODEL = "claude-haiku-4-5-20251001" +# MAX_TOKENS = "1024" + +# Per-IP rate limiting is backed by this KV namespace. Create it with: +# npx wrangler kv namespace create RATE_LIMIT +# then paste the returned id (and preview_id) below. +[[kv_namespaces]] +binding = "RATE_LIMIT" +id = "placeholder" +preview_id = "placeholder" +", +} +`; + +exports[`scaffold > generates the next template 1`] = ` +{ + ".gitignore": "node_modules +.next +out +dist +*.local +.DS_Store +next-env.d.ts +", + "README.md": "# demo-app + +A [Next.js](https://nextjs.org) (App Router) + TypeScript app using the +[\`claudius-chat-widget\`](https://www.npmjs.com/package/claudius-chat-widget) +component. + +\`\`\`bash +pnpm install +pnpm dev +\`\`\` + +The widget is rendered by a client component (\`app/ClaudiusWidget.tsx\`) because it +relies on browser APIs: + +\`\`\`tsx +"use client"; +import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; +\`\`\` + +You need a running Claudius worker for the chat to respond. See the +[worker setup guide](https://claudius-docs.pages.dev/deployment/worker/), or +re-run the scaffolder with \`--worker\` to generate one. +", + "app/ClaudiusWidget.tsx": ""use client"; + +import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; + +// ChatWidget uses browser APIs and React state, so it must run on the client. +export function ClaudiusWidget() { + return ( + + ); +} +", + "app/layout.tsx": "import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "demo-app", + description: "A Claudius-powered app", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +", + "app/page.tsx": "import { ClaudiusWidget } from "./ClaudiusWidget"; + +export default function Home() { + return ( +
+
+

demo-app

+

+ Edit app/ClaudiusWidget.tsx to configure the widget. The launcher + is in the corner. +

+
+ + +
+ ); +} +", + "next.config.mjs": "/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; +", + "package.json": "{ + "name": "demo-app", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "claudius-chat-widget": "^1.6.0", + "next": "^14.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "typescript": "^5.6.0" + } +} +", + "tsconfig.json": "{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +", +} +`; + +exports[`scaffold > generates the react template 1`] = ` +{ + ".gitignore": "node_modules +dist +*.local +.DS_Store +", + "README.md": "# demo-app + +A [Vite](https://vite.dev) + React + TypeScript app using the +[\`claudius-chat-widget\`](https://www.npmjs.com/package/claudius-chat-widget) +component. + +\`\`\`bash +pnpm install +pnpm dev +\`\`\` + +The widget lives in \`src/App.tsx\`: + +\`\`\`tsx +import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; + +; +\`\`\` + +You need a running Claudius worker for the chat to respond. See the +[worker setup guide](https://claudius-docs.pages.dev/deployment/worker/), or +re-run the scaffolder with \`--worker\` to generate one. +", + "index.html": " + + + + + demo-app + + +
+ + + +", + "package.json": "{ + "name": "demo-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "claudius-chat-widget": "^1.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} +", + "src/App.tsx": "import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; + +const pageStyle: React.CSSProperties = { + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + display: "grid", + placeItems: "center", + minHeight: "100vh", + margin: 0, +}; + +export function App() { + return ( +
+
+

demo-app

+

+ Edit src/App.tsx and configure the widget via its props. The + launcher is in the corner. +

+
+ + +
+ ); +} +", + "src/main.tsx": "import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); +", + "tsconfig.json": "{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "useDefineForClassFields": true + }, + "include": ["src", "vite.config.ts"] +} +", + "vite.config.ts": "import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); +", +} +`; + +exports[`scaffold > generates the vanilla template 1`] = ` +{ + ".gitignore": "node_modules +dist +*.local +.DS_Store +", + "README.md": "# demo-app + +A static site with the [Claudius](https://claudius-docs.pages.dev) chat widget +embedded via the CDN — no build step required for the widget itself. + +\`\`\`bash +pnpm install +pnpm dev +\`\`\` + +The widget is configured in \`index.html\` via \`window.ClaudiusConfig\`: + +- \`apiUrl\` — your deployed Claudius worker (currently \`https://demo.workers.dev\`) +- \`theme\` — \`auto\` +- \`accentColor\` — \`#4f46e5\` + +You need a running Claudius worker for the chat to respond. See the +[worker setup guide](https://claudius-docs.pages.dev/deployment/worker/), or +re-run the scaffolder with \`--worker\` to generate one. +", + "index.html": " + + + + + demo-app + + + + + +
+

demo-app

+

+ Your Claudius chat widget is live in the corner. Configure it by editing + window.ClaudiusConfig in index.html. +

+
+ + + + + + +", + "package.json": "{ + "name": "demo-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^6.0.0" + } +} +", +} +`; diff --git a/create-claudius/src/__tests__/scaffold.test.ts b/create-claudius/src/__tests__/scaffold.test.ts new file mode 100644 index 0000000..ceb8d89 --- /dev/null +++ b/create-claudius/src/__tests__/scaffold.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, sep } from "node:path"; +import { scaffold } from "../scaffold.js"; +import { TEMPLATE_IDS } from "../frameworks.js"; + +const FIXED = { + projectName: "demo-app", + theme: "auto", + accent: "#4f46e5", + apiUrl: "https://demo.workers.dev", + // Pinned so snapshots don't churn when the package version bumps. + widgetVersion: "^1.6.0", +}; + +// Matches leftover scaffold tokens like {{PROJECT_NAME}} but NOT legitimate +// JSX inline styles like style={{ maxWidth: "32rem" }}. +const LEFTOVER_TOKEN = /\{\{[A-Z_]+\}\}/; + +async function walk(dir: string): Promise { + const out: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...(await walk(full))); + else out.push(full); + } + return out; +} + +/** Map of POSIX-relative path -> normalized content, key-sorted for stable snapshots. */ +async function snapshotTree(dir: string): Promise> { + const files = await walk(dir); + const entries: [string, string][] = []; + for (const file of files) { + const rel = relative(dir, file).split(sep).join("/"); + const content = (await readFile(file, "utf8")).replace(/\r\n/g, "\n"); + entries.push([rel, content]); + } + entries.sort(([a], [b]) => a.localeCompare(b)); + return Object.fromEntries(entries); +} + +describe("scaffold", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "create-claudius-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + for (const template of TEMPLATE_IDS) { + it(`generates the ${template} template`, async () => { + const target = join(dir, template); + await scaffold({ ...FIXED, template, worker: false, projectDir: target }); + + const tree = await snapshotTree(target); + expect(tree).toMatchSnapshot(); + + const keys = Object.keys(tree); + expect(keys).toContain(".gitignore"); + expect(keys).not.toContain("_gitignore"); + for (const [path, content] of Object.entries(tree)) { + expect(content, `leftover token in ${path}`).not.toMatch(LEFTOVER_TOKEN); + } + }); + } + + it("adds the worker scaffold when requested", async () => { + const target = join(dir, "with-worker"); + await scaffold({ ...FIXED, template: "react", worker: true, projectDir: target }); + + const tree = await snapshotTree(target); + expect(tree).toMatchSnapshot(); + + const keys = Object.keys(tree); + expect(keys).toContain("worker/wrangler.toml"); + expect(keys).toContain("worker/src/index.ts"); + expect(keys).toContain("worker/.gitignore"); + for (const content of Object.values(tree)) { + expect(content).not.toMatch(LEFTOVER_TOKEN); + } + }); + + it("injects the provided values into key files", async () => { + const target = join(dir, "values"); + await scaffold({ ...FIXED, template: "react", worker: false, projectDir: target }); + + const pkg = await readFile(join(target, "package.json"), "utf8"); + expect(pkg).toContain('"name": "demo-app"'); + expect(pkg).toContain('"claudius-chat-widget": "^1.6.0"'); + + const app = await readFile(join(target, "src", "App.tsx"), "utf8"); + expect(app).toContain('apiUrl="https://demo.workers.dev"'); + expect(app).toContain('theme="auto"'); + expect(app).toContain('accentColor="#4f46e5"'); + }); +}); diff --git a/create-claudius/src/frameworks.ts b/create-claudius/src/frameworks.ts new file mode 100644 index 0000000..4530b5e --- /dev/null +++ b/create-claudius/src/frameworks.ts @@ -0,0 +1,44 @@ +export type TemplateId = "vanilla" | "react" | "next"; + +export interface Framework { + id: TemplateId; + label: string; + hint: string; +} + +export const FRAMEWORKS: Framework[] = [ + { id: "vanilla", label: "Vanilla", hint: "Static site + CDN script embed" }, + { id: "react", label: "React", hint: "Vite + React + TypeScript" }, + { id: "next", label: "Next.js", hint: "App Router + TypeScript" }, +]; + +export const TEMPLATE_IDS: TemplateId[] = FRAMEWORKS.map((f) => f.id); + +export interface ThemeOption { + value: string; + label: string; +} + +export const THEMES: ThemeOption[] = [ + { value: "auto", label: "Auto (follows system)" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "default", label: "Default (built-in)" }, + { value: "minimal", label: "Minimal" }, + { value: "playful", label: "Playful" }, + { value: "corporate", label: "Corporate" }, +]; + +export const THEME_VALUES: string[] = THEMES.map((t) => t.value); + +export const DEFAULTS = { + projectName: "my-claudius-app", + template: "react" as TemplateId, + theme: "auto", + accent: "#4f46e5", + apiUrl: "https://your-worker.workers.dev", +}; + +export function isTemplateId(value: string): value is TemplateId { + return (TEMPLATE_IDS as string[]).includes(value); +} diff --git a/create-claudius/src/index.ts b/create-claudius/src/index.ts new file mode 100644 index 0000000..aeac05d --- /dev/null +++ b/create-claudius/src/index.ts @@ -0,0 +1,133 @@ +import { cancel, confirm, isCancel, log, note, outro } from "@clack/prompts"; +import pc from "picocolors"; +import { parseArgs } from "node:util"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isTemplateId, TEMPLATE_IDS } from "./frameworks.js"; +import { runPrompts, type ProvidedOptions } from "./prompts.js"; +import { isNonEmptyDir, scaffold } from "./scaffold.js"; + +const HELP = ` +${pc.bold("create-claudius")} — scaffold a Claudius AI chat widget project + +${pc.bold("Usage")} + npm create claudius@latest [dir] -- [options] + +${pc.bold("Options")} + -t, --template vanilla | react | next + --theme auto | light | dark | default | minimal | playful | corporate + --accent accent color, e.g. #4f46e5 + --api-url worker chat endpoint + --worker also scaffold a Cloudflare Worker + --pm package manager for the next-steps hint (npm|pnpm|yarn|bun) + -y, --yes accept defaults for anything not provided + -h, --help show this help + -v, --version show version +`; + +function selfVersion(): string { + try { + const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string }; + return pkg.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} + +function detectPm(explicit?: string): string { + if (explicit) return explicit; + const ua = process.env.npm_config_user_agent ?? ""; + if (ua.startsWith("pnpm")) return "pnpm"; + if (ua.startsWith("yarn")) return "yarn"; + if (ua.startsWith("bun")) return "bun"; + return "npm"; +} + +async function main(): Promise { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + template: { type: "string", short: "t" }, + theme: { type: "string" }, + accent: { type: "string" }, + "api-url": { type: "string" }, + worker: { type: "boolean" }, + pm: { type: "string" }, + yes: { type: "boolean", short: "y" }, + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, + }, + }); + + if (values.help) { + console.log(HELP); + return; + } + if (values.version) { + console.log(selfVersion()); + return; + } + + const provided: ProvidedOptions = { + projectName: positionals[0], + theme: values.theme, + accent: values.accent, + apiUrl: values["api-url"], + worker: values.worker, + yes: values.yes, + }; + + if (values.template !== undefined) { + if (!isTemplateId(values.template)) { + console.error( + pc.red(`Unknown template "${values.template}". Choose one of: ${TEMPLATE_IDS.join(", ")}`), + ); + process.exit(1); + } + provided.template = values.template; + } + + const result = await runPrompts(provided); + + const projectDir = resolve(process.cwd(), result.projectName); + if (isNonEmptyDir(projectDir)) { + if (provided.yes) { + log.error(`Target directory "${result.projectName}" already exists and is not empty.`); + process.exit(1); + } + const proceed = await confirm({ + message: `Directory "${result.projectName}" is not empty. Write into it anyway?`, + initialValue: false, + }); + if (isCancel(proceed) || !proceed) { + cancel("Aborted."); + process.exit(0); + } + } + + await scaffold({ ...result, projectDir, widgetVersion: `^${selfVersion()}` }); + + const pm = detectPm(values.pm); + const dev = pm === "npm" ? "npm run dev" : `${pm} dev`; + const steps = [`cd ${result.projectName}`, `${pm} install`, dev]; + if (result.worker) { + steps.push( + "", + pc.dim("# then deploy the worker"), + "cd worker", + `${pm} install`, + "npx wrangler kv namespace create RATE_LIMIT", + "npx wrangler secret put ANTHROPIC_API_KEY", + "npx wrangler deploy", + ); + } + note(steps.join("\n"), "Next steps"); + outro(pc.green("Done! Happy building with Claudius.")); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/create-claudius/src/prompts.ts b/create-claudius/src/prompts.ts new file mode 100644 index 0000000..8fb0c90 --- /dev/null +++ b/create-claudius/src/prompts.ts @@ -0,0 +1,128 @@ +import { cancel, confirm, intro, isCancel, select, text } from "@clack/prompts"; +import pc from "picocolors"; +import { DEFAULTS, FRAMEWORKS, THEMES, type TemplateId } from "./frameworks.js"; + +export interface PromptResult { + projectName: string; + template: TemplateId; + theme: string; + accent: string; + apiUrl: string; + worker: boolean; +} + +/** Values already supplied via CLI flags — these skip their prompt. */ +export interface ProvidedOptions { + projectName?: string; + template?: TemplateId; + theme?: string; + accent?: string; + apiUrl?: string; + worker?: boolean; + /** Accept defaults for everything not explicitly provided. */ + yes?: boolean; +} + +const PROJECT_NAME_RE = /^[a-zA-Z0-9._-]+$/; +const HEX_RE = /^#[0-9a-fA-F]{6}$/; + +function unwrap(value: T | symbol): T { + if (isCancel(value)) { + cancel("Scaffolding cancelled."); + process.exit(0); + } + return value as T; +} + +export async function runPrompts(provided: ProvidedOptions): Promise { + intro(pc.bgCyan(pc.black(" create-claudius "))); + + const yes = provided.yes ?? false; + + const projectName = + provided.projectName ?? + (yes + ? DEFAULTS.projectName + : unwrap( + await text({ + message: "Project name?", + placeholder: DEFAULTS.projectName, + defaultValue: DEFAULTS.projectName, + validate: (v) => + v && !PROJECT_NAME_RE.test(v.trim()) + ? "Use letters, numbers, dashes, dots, or underscores." + : undefined, + }), + )); + + const template = (provided.template ?? + (yes + ? DEFAULTS.template + : unwrap( + await select({ + message: "Which framework?", + options: FRAMEWORKS.map((f) => ({ value: f.id, label: f.label, hint: f.hint })), + initialValue: DEFAULTS.template, + }), + ))) as TemplateId; + + const theme = + provided.theme ?? + (yes + ? DEFAULTS.theme + : (unwrap( + await select({ + message: "Theme?", + options: THEMES, + initialValue: DEFAULTS.theme, + }), + ) as string)); + + const accent = + provided.accent ?? + (yes + ? DEFAULTS.accent + : unwrap( + await text({ + message: "Accent color (hex)?", + placeholder: DEFAULTS.accent, + defaultValue: DEFAULTS.accent, + validate: (v) => + v && !HEX_RE.test(v.trim()) + ? "Use a 6-digit hex color like #4f46e5." + : undefined, + }), + )); + + const apiUrl = + provided.apiUrl ?? + (yes + ? DEFAULTS.apiUrl + : unwrap( + await text({ + message: "Worker API URL?", + placeholder: DEFAULTS.apiUrl, + defaultValue: DEFAULTS.apiUrl, + }), + )); + + const worker = + provided.worker ?? + (yes + ? false + : unwrap( + await confirm({ + message: "Also scaffold a Cloudflare Worker?", + initialValue: false, + }), + )); + + return { + projectName: projectName.trim(), + template, + theme, + accent: accent.trim(), + apiUrl: apiUrl.trim(), + worker, + }; +} diff --git a/create-claudius/src/scaffold.ts b/create-claudius/src/scaffold.ts new file mode 100644 index 0000000..59d05a9 --- /dev/null +++ b/create-claudius/src/scaffold.ts @@ -0,0 +1,87 @@ +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { TemplateId } from "./frameworks.js"; + +export interface ScaffoldOptions { + /** Absolute path to the directory to create the project in. */ + projectDir: string; + /** Sanitized name used for the generated package.json and titles. */ + projectName: string; + template: TemplateId; + theme: string; + accent: string; + apiUrl: string; + /** Also scaffold a Cloudflare Worker into `/worker`. */ + worker: boolean; + /** Version range for `claudius-chat-widget`, e.g. "^1.6.0". */ + widgetVersion: string; +} + +function tokensFor(opts: ScaffoldOptions): Record { + return { + "{{PROJECT_NAME}}": opts.projectName, + "{{API_URL}}": opts.apiUrl, + "{{THEME}}": opts.theme, + "{{ACCENT_COLOR}}": opts.accent, + "{{WIDGET_VERSION}}": opts.widgetVersion, + }; +} + +function applyTokens(content: string, tokens: Record): string { + let out = content; + for (const [token, value] of Object.entries(tokens)) { + out = out.split(token).join(value); + } + return out; +} + +// npm strips `.gitignore` from published tarballs, so templates ship it as +// `_gitignore` and we rename on write. Same trick create-vite uses. +function destFileName(name: string): string { + return name === "_gitignore" ? ".gitignore" : name; +} + +async function copyTemplate( + srcDir: string, + destDir: string, + tokens: Record, +): Promise { + await mkdir(destDir, { recursive: true }); + const entries = await readdir(srcDir, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = join(srcDir, entry.name); + const destPath = join(destDir, destFileName(entry.name)); + if (entry.isDirectory()) { + await copyTemplate(srcPath, destPath, tokens); + } else { + const raw = await readFile(srcPath, "utf8"); + await writeFile(destPath, applyTokens(raw, tokens)); + } + } +} + +/** Absolute path to the bundled `templates/` directory (sibling of dist/ and src/). */ +export function templatesRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "../templates"); +} + +/** True when `dir` exists and contains at least one entry. */ +export function isNonEmptyDir(dir: string): boolean { + if (!existsSync(dir)) return false; + try { + return readdirSync(dir).length > 0; + } catch { + return false; + } +} + +export async function scaffold(opts: ScaffoldOptions): Promise { + const tokens = tokensFor(opts); + const root = templatesRoot(); + await copyTemplate(join(root, opts.template), opts.projectDir, tokens); + if (opts.worker) { + await copyTemplate(join(root, "worker"), join(opts.projectDir, "worker"), tokens); + } +} diff --git a/create-claudius/templates/next/README.md b/create-claudius/templates/next/README.md new file mode 100644 index 0000000..d075810 --- /dev/null +++ b/create-claudius/templates/next/README.md @@ -0,0 +1,23 @@ +# {{PROJECT_NAME}} + +A [Next.js](https://nextjs.org) (App Router) + TypeScript app using the +[`claudius-chat-widget`](https://www.npmjs.com/package/claudius-chat-widget) +component. + +```bash +pnpm install +pnpm dev +``` + +The widget is rendered by a client component (`app/ClaudiusWidget.tsx`) because it +relies on browser APIs: + +```tsx +"use client"; +import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; +``` + +You need a running Claudius worker for the chat to respond. See the +[worker setup guide](https://claudius-docs.pages.dev/deployment/worker/), or +re-run the scaffolder with `--worker` to generate one. diff --git a/create-claudius/templates/next/_gitignore b/create-claudius/templates/next/_gitignore new file mode 100644 index 0000000..b6906e9 --- /dev/null +++ b/create-claudius/templates/next/_gitignore @@ -0,0 +1,7 @@ +node_modules +.next +out +dist +*.local +.DS_Store +next-env.d.ts diff --git a/create-claudius/templates/next/app/ClaudiusWidget.tsx b/create-claudius/templates/next/app/ClaudiusWidget.tsx new file mode 100644 index 0000000..14de70a --- /dev/null +++ b/create-claudius/templates/next/app/ClaudiusWidget.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; + +// ChatWidget uses browser APIs and React state, so it must run on the client. +export function ClaudiusWidget() { + return ( + + ); +} diff --git a/create-claudius/templates/next/app/layout.tsx b/create-claudius/templates/next/app/layout.tsx new file mode 100644 index 0000000..5593299 --- /dev/null +++ b/create-claudius/templates/next/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "{{PROJECT_NAME}}", + description: "A Claudius-powered app", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/create-claudius/templates/next/app/page.tsx b/create-claudius/templates/next/app/page.tsx new file mode 100644 index 0000000..e58c1af --- /dev/null +++ b/create-claudius/templates/next/app/page.tsx @@ -0,0 +1,24 @@ +import { ClaudiusWidget } from "./ClaudiusWidget"; + +export default function Home() { + return ( +
+
+

{{PROJECT_NAME}}

+

+ Edit app/ClaudiusWidget.tsx to configure the widget. The launcher + is in the corner. +

+
+ + +
+ ); +} diff --git a/create-claudius/templates/next/next.config.mjs b/create-claudius/templates/next/next.config.mjs new file mode 100644 index 0000000..4678774 --- /dev/null +++ b/create-claudius/templates/next/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/create-claudius/templates/next/package.json b/create-claudius/templates/next/package.json new file mode 100644 index 0000000..0007b72 --- /dev/null +++ b/create-claudius/templates/next/package.json @@ -0,0 +1,22 @@ +{ + "name": "{{PROJECT_NAME}}", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "claudius-chat-widget": "{{WIDGET_VERSION}}", + "next": "^14.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "typescript": "^5.6.0" + } +} diff --git a/create-claudius/templates/next/tsconfig.json b/create-claudius/templates/next/tsconfig.json new file mode 100644 index 0000000..afedc74 --- /dev/null +++ b/create-claudius/templates/next/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/create-claudius/templates/react/README.md b/create-claudius/templates/react/README.md new file mode 100644 index 0000000..1b1d2e5 --- /dev/null +++ b/create-claudius/templates/react/README.md @@ -0,0 +1,23 @@ +# {{PROJECT_NAME}} + +A [Vite](https://vite.dev) + React + TypeScript app using the +[`claudius-chat-widget`](https://www.npmjs.com/package/claudius-chat-widget) +component. + +```bash +pnpm install +pnpm dev +``` + +The widget lives in `src/App.tsx`: + +```tsx +import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; + +; +``` + +You need a running Claudius worker for the chat to respond. See the +[worker setup guide](https://claudius-docs.pages.dev/deployment/worker/), or +re-run the scaffolder with `--worker` to generate one. diff --git a/create-claudius/templates/react/_gitignore b/create-claudius/templates/react/_gitignore new file mode 100644 index 0000000..a4d699a --- /dev/null +++ b/create-claudius/templates/react/_gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.local +.DS_Store diff --git a/create-claudius/templates/react/index.html b/create-claudius/templates/react/index.html new file mode 100644 index 0000000..d1c6d8d --- /dev/null +++ b/create-claudius/templates/react/index.html @@ -0,0 +1,12 @@ + + + + + + {{PROJECT_NAME}} + + +
+ + + diff --git a/create-claudius/templates/react/package.json b/create-claudius/templates/react/package.json new file mode 100644 index 0000000..afdc5ae --- /dev/null +++ b/create-claudius/templates/react/package.json @@ -0,0 +1,23 @@ +{ + "name": "{{PROJECT_NAME}}", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "claudius-chat-widget": "{{WIDGET_VERSION}}", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/create-claudius/templates/react/src/App.tsx b/create-claudius/templates/react/src/App.tsx new file mode 100644 index 0000000..a799179 --- /dev/null +++ b/create-claudius/templates/react/src/App.tsx @@ -0,0 +1,31 @@ +import { ChatWidget } from "claudius-chat-widget"; +import "claudius-chat-widget/style.css"; + +const pageStyle: React.CSSProperties = { + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + display: "grid", + placeItems: "center", + minHeight: "100vh", + margin: 0, +}; + +export function App() { + return ( +
+
+

{{PROJECT_NAME}}

+

+ Edit src/App.tsx and configure the widget via its props. The + launcher is in the corner. +

+
+ + +
+ ); +} diff --git a/create-claudius/templates/react/src/main.tsx b/create-claudius/templates/react/src/main.tsx new file mode 100644 index 0000000..fa02ea3 --- /dev/null +++ b/create-claudius/templates/react/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/create-claudius/templates/react/tsconfig.json b/create-claudius/templates/react/tsconfig.json new file mode 100644 index 0000000..88828ac --- /dev/null +++ b/create-claudius/templates/react/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "useDefineForClassFields": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/create-claudius/templates/react/vite.config.ts b/create-claudius/templates/react/vite.config.ts new file mode 100644 index 0000000..081c8d9 --- /dev/null +++ b/create-claudius/templates/react/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/create-claudius/templates/vanilla/README.md b/create-claudius/templates/vanilla/README.md new file mode 100644 index 0000000..a046a04 --- /dev/null +++ b/create-claudius/templates/vanilla/README.md @@ -0,0 +1,19 @@ +# {{PROJECT_NAME}} + +A static site with the [Claudius](https://claudius-docs.pages.dev) chat widget +embedded via the CDN — no build step required for the widget itself. + +```bash +pnpm install +pnpm dev +``` + +The widget is configured in `index.html` via `window.ClaudiusConfig`: + +- `apiUrl` — your deployed Claudius worker (currently `{{API_URL}}`) +- `theme` — `{{THEME}}` +- `accentColor` — `{{ACCENT_COLOR}}` + +You need a running Claudius worker for the chat to respond. See the +[worker setup guide](https://claudius-docs.pages.dev/deployment/worker/), or +re-run the scaffolder with `--worker` to generate one. diff --git a/create-claudius/templates/vanilla/_gitignore b/create-claudius/templates/vanilla/_gitignore new file mode 100644 index 0000000..a4d699a --- /dev/null +++ b/create-claudius/templates/vanilla/_gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.local +.DS_Store diff --git a/create-claudius/templates/vanilla/index.html b/create-claudius/templates/vanilla/index.html new file mode 100644 index 0000000..07fc1d7 --- /dev/null +++ b/create-claudius/templates/vanilla/index.html @@ -0,0 +1,60 @@ + + + + + + {{PROJECT_NAME}} + + + + + +
+

{{PROJECT_NAME}}

+

+ Your Claudius chat widget is live in the corner. Configure it by editing + window.ClaudiusConfig in index.html. +

+
+ + + + + + diff --git a/create-claudius/templates/vanilla/package.json b/create-claudius/templates/vanilla/package.json new file mode 100644 index 0000000..59703b9 --- /dev/null +++ b/create-claudius/templates/vanilla/package.json @@ -0,0 +1,14 @@ +{ + "name": "{{PROJECT_NAME}}", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^6.0.0" + } +} diff --git a/create-claudius/templates/worker/.dev.vars.example b/create-claudius/templates/worker/.dev.vars.example new file mode 100644 index 0000000..2f4312b --- /dev/null +++ b/create-claudius/templates/worker/.dev.vars.example @@ -0,0 +1,3 @@ +# Copy this file to .dev.vars and fill in your key for local development: +# cp .dev.vars.example .dev.vars +ANTHROPIC_API_KEY= diff --git a/create-claudius/templates/worker/README.md b/create-claudius/templates/worker/README.md new file mode 100644 index 0000000..2e311e3 --- /dev/null +++ b/create-claudius/templates/worker/README.md @@ -0,0 +1,34 @@ +# {{PROJECT_NAME}}-worker + +A minimal [Cloudflare Worker](https://workers.cloudflare.com) that keeps your +Anthropic API key server-side and powers the Claudius widget. It exposes +`POST /api/chat` and `GET /api/health`, with CORS and a small KV-backed rate limit. + +> This is a trimmed starter. For the full-featured worker (analytics, configurable +> rate limits, richer error handling), see the +> [Claudius worker](https://github.com/PMDevSolutions/Claudius/tree/main/worker). + +## Local development + +```bash +pnpm install +cp .dev.vars.example .dev.vars # then add your ANTHROPIC_API_KEY +pnpm dev # http://localhost:8787 +``` + +## Deploy + +```bash +# 1. Create the KV namespace and paste the id(s) into wrangler.toml +npx wrangler kv namespace create RATE_LIMIT + +# 2. Store your API key as a secret +npx wrangler secret put ANTHROPIC_API_KEY + +# 3. Deploy +npx wrangler deploy +``` + +After deploying, set `ALLOWED_ORIGIN` (in `wrangler.toml` or the Cloudflare +dashboard) to your site's origin, and point the widget's `apiUrl` at the worker +URL. diff --git a/create-claudius/templates/worker/_gitignore b/create-claudius/templates/worker/_gitignore new file mode 100644 index 0000000..4c0b608 --- /dev/null +++ b/create-claudius/templates/worker/_gitignore @@ -0,0 +1,5 @@ +node_modules +.wrangler +dist +.dev.vars +.DS_Store diff --git a/create-claudius/templates/worker/package.json b/create-claudius/templates/worker/package.json new file mode 100644 index 0000000..f3cbc02 --- /dev/null +++ b/create-claudius/templates/worker/package.json @@ -0,0 +1,18 @@ +{ + "name": "{{PROJECT_NAME}}-worker", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", + "hono": "^4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240909.0", + "typescript": "^5.6.0", + "wrangler": "^4.6.0" + } +} diff --git a/create-claudius/templates/worker/src/index.ts b/create-claudius/templates/worker/src/index.ts new file mode 100644 index 0000000..1db3f6a --- /dev/null +++ b/create-claudius/templates/worker/src/index.ts @@ -0,0 +1,92 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import Anthropic from "@anthropic-ai/sdk"; + +interface Env { + ANTHROPIC_API_KEY: string; + ALLOWED_ORIGIN: string; + RATE_LIMIT: KVNamespace; + CLAUDE_MODEL?: string; + MAX_TOKENS?: string; +} + +interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +const DEFAULT_MODEL = "claude-haiku-4-5-20251001"; +const MAX_MESSAGE_LENGTH = 2000; +const RATE_LIMIT_PER_MINUTE = 20; + +// Customize your assistant's personality and knowledge here. +const SYSTEM_PROMPT = + "You are a helpful, concise assistant embedded on a website. " + + "Answer clearly and politely. If you don't know something, say so."; + +const app = new Hono<{ Bindings: Env }>(); + +app.use( + "/api/*", + cors({ + origin: (origin, c) => { + const allowed = (c.env.ALLOWED_ORIGIN || "http://localhost:5173") + .split(",") + .map((o: string) => o.trim()) + .filter(Boolean); + if (origin?.startsWith("http://localhost:")) return origin; + return origin && allowed.includes(origin) ? origin : allowed[0]; + }, + allowMethods: ["POST", "OPTIONS"], + allowHeaders: ["Content-Type"], + maxAge: 86400, + }), +); + +app.get("/api/health", (c) => c.json({ ok: true })); + +app.post("/api/chat", async (c) => { + let body: { messages?: ChatMessage[] }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body." }, 400); + } + + const messages = body.messages; + if (!Array.isArray(messages) || messages.length === 0) { + return c.json({ error: "A non-empty messages array is required." }, 400); + } + + // Minimal per-IP, per-minute rate limit backed by the RATE_LIMIT KV namespace. + const ip = c.req.header("cf-connecting-ip") ?? "unknown"; + const bucket = `rl:${ip}:${Math.floor(Date.now() / 60000)}`; + const count = parseInt((await c.env.RATE_LIMIT.get(bucket)) ?? "0", 10); + if (count >= RATE_LIMIT_PER_MINUTE) { + return c.json({ error: "Too many requests. Please wait a minute." }, 429, { + "Retry-After": "60", + }); + } + await c.env.RATE_LIMIT.put(bucket, String(count + 1), { expirationTtl: 120 }); + + const sanitized = messages.map((m) => ({ + role: m.role, + content: String(m.content ?? "").slice(0, MAX_MESSAGE_LENGTH), + })); + + try { + const client = new Anthropic({ apiKey: c.env.ANTHROPIC_API_KEY }); + const response = await client.messages.create({ + model: c.env.CLAUDE_MODEL ?? DEFAULT_MODEL, + max_tokens: c.env.MAX_TOKENS ? parseInt(c.env.MAX_TOKENS, 10) : 1024, + system: SYSTEM_PROMPT, + messages: sanitized, + }); + const textBlock = response.content.find((block) => block.type === "text"); + return c.json({ reply: textBlock && textBlock.type === "text" ? textBlock.text : "" }); + } catch { + return c.json({ error: "AI service temporarily unavailable. Please try again." }, 502); + } +}); + +export default app; diff --git a/create-claudius/templates/worker/tsconfig.json b/create-claudius/templates/worker/tsconfig.json new file mode 100644 index 0000000..5a843c2 --- /dev/null +++ b/create-claudius/templates/worker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/create-claudius/templates/worker/wrangler.toml b/create-claudius/templates/worker/wrangler.toml new file mode 100644 index 0000000..4cc92cd --- /dev/null +++ b/create-claudius/templates/worker/wrangler.toml @@ -0,0 +1,18 @@ +name = "{{PROJECT_NAME}}-worker" +main = "src/index.ts" +compatibility_date = "2024-09-23" + +[vars] +# Comma-separated list of origins allowed to call this worker. +ALLOWED_ORIGIN = "http://localhost:5173" +# Optional overrides: +# CLAUDE_MODEL = "claude-haiku-4-5-20251001" +# MAX_TOKENS = "1024" + +# Per-IP rate limiting is backed by this KV namespace. Create it with: +# npx wrangler kv namespace create RATE_LIMIT +# then paste the returned id (and preview_id) below. +[[kv_namespaces]] +binding = "RATE_LIMIT" +id = "placeholder" +preview_id = "placeholder" diff --git a/create-claudius/tsconfig.json b/create-claudius/tsconfig.json new file mode 100644 index 0000000..5627a5d --- /dev/null +++ b/create-claudius/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": false, + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "templates"] +} diff --git a/create-claudius/tsup.config.ts b/create-claudius/tsup.config.ts new file mode 100644 index 0000000..c16b824 --- /dev/null +++ b/create-claudius/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + target: "node18", + clean: true, + // The published bin must be directly executable. + banner: { js: "#!/usr/bin/env node" }, +}); diff --git a/create-claudius/vitest.config.ts b/create-claudius/vitest.config.ts new file mode 100644 index 0000000..c497b8e --- /dev/null +++ b/create-claudius/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + globals: true, + }, +}); diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index fa13a46..a176f6f 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -35,6 +35,7 @@ export default defineConfig({ items: [ { slug: "getting-started/introduction" }, { slug: "getting-started/quick-start" }, + { slug: "getting-started/scaffolding" }, { slug: "getting-started/local-development" }, ], }, diff --git a/docs/src/content/docs/getting-started/scaffolding.md b/docs/src/content/docs/getting-started/scaffolding.md new file mode 100644 index 0000000..722a273 --- /dev/null +++ b/docs/src/content/docs/getting-started/scaffolding.md @@ -0,0 +1,74 @@ +--- +title: Scaffold a project +description: Create a ready-to-run Claudius project in one command with create-claudius. +--- + +`create-claudius` scaffolds a working Claudius project — frontend (and optionally a +Cloudflare Worker) — in under a minute, the same way `npm create vite` works. + +```bash +npm create claudius@latest +# or +pnpm create claudius +# or +yarn create claudius +``` + +## What it asks + +| Prompt | Choices | +| --- | --- | +| **Project name** | the new directory / package name | +| **Framework** | `vanilla` (CDN script embed) · `react` (Vite) · `next` (Next.js App Router) | +| **Theme** | `auto` · `light` · `dark` · `default` · `minimal` · `playful` · `corporate` | +| **Accent color** | a hex color, e.g. `#4f46e5` | +| **API URL** | your deployed worker's chat endpoint | +| **Worker?** | optionally scaffold a Cloudflare Worker alongside the app | + +## Run it + +```bash +cd my-app +pnpm install +pnpm dev +``` + +The chat needs a running [Claudius worker](/deployment/worker/) to respond. Point the +generated app's `apiUrl` at your worker, or scaffold one by answering **yes** to the +worker prompt (or passing `--worker`). + +## Non-interactive + +Every prompt has a flag, so the scaffolder works in CI or scripts: + +```bash +npm create claudius@latest my-app -- \ + --template react \ + --theme auto \ + --accent "#4f46e5" \ + --api-url https://my-worker.workers.dev \ + --worker \ + --yes +``` + +| Flag | Values | +| --- | --- | +| `--template` | `vanilla` · `react` · `next` | +| `--theme` | `auto` · `light` · `dark` · `default` · `minimal` · `playful` · `corporate` | +| `--accent` | `#rrggbb` | +| `--api-url` | URL | +| `--worker` | include a Cloudflare Worker | +| `--pm` | `npm` · `pnpm` · `yarn` · `bun` (shown in the next-steps hint) | +| `--yes`, `-y` | accept defaults for anything not provided | + +## What you get + +- **vanilla** — a static [Vite](https://vite.dev) site that loads the widget from the + jsDelivr CDN via a single `