@forgeailab/create-spark 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/packs/README.md +132 -0
- package/packs/ai-anthropic/files/app/api/ai/route.ts +57 -0
- package/packs/ai-anthropic/files/lib/anthropic.ts +15 -0
- package/packs/ai-anthropic/pack.toml +32 -0
- package/packs/ai-anthropic/skills/ai-feature-patterns/SKILL.md +87 -0
- package/packs/ai-anthropic/tasks.yaml +9 -0
- package/packs/ai-openai/files/app/api/ai-openai/route.ts +55 -0
- package/packs/ai-openai/files/lib/openai.ts +21 -0
- package/packs/ai-openai/pack.toml +30 -0
- package/packs/ai-openai/tasks.yaml +9 -0
- package/packs/analytics-posthog/files/components/PostHogProvider.tsx +19 -0
- package/packs/analytics-posthog/files/lib/posthog/client.ts +20 -0
- package/packs/analytics-posthog/files/lib/posthog/server.ts +24 -0
- package/packs/analytics-posthog/pack.toml +35 -0
- package/packs/analytics-posthog/tasks.yaml +15 -0
- package/packs/auth-better-auth/files/app/(auth)/login/page.tsx +58 -0
- package/packs/auth-better-auth/files/app/api/auth/[...all]/route.ts +4 -0
- package/packs/auth-better-auth/files/lib/auth.ts +21 -0
- package/packs/auth-better-auth/pack.toml +32 -0
- package/packs/auth-better-auth/tasks.yaml +10 -0
- package/packs/auth-better-auth-pg/files/app/api/auth/[...all]/route.ts +4 -0
- package/packs/auth-better-auth-pg/files/lib/auth.ts +86 -0
- package/packs/auth-better-auth-pg/pack.toml +32 -0
- package/packs/auth-better-auth-pg/tasks.yaml +17 -0
- package/packs/auth-supabase/files/app/(auth)/login/page.tsx +64 -0
- package/packs/auth-supabase/files/app/auth/callback/route.ts +15 -0
- package/packs/auth-supabase/files/middleware.ts +41 -0
- package/packs/auth-supabase/pack.toml +34 -0
- package/packs/auth-supabase/tasks.yaml +10 -0
- package/packs/db-postgres/files/compose/postgres.yml +28 -0
- package/packs/db-postgres/files/docker-compose.include.yml +1 -0
- package/packs/db-postgres/files/docker-compose.yml +6 -0
- package/packs/db-postgres/files/drizzle.config.ts +10 -0
- package/packs/db-postgres/files/lib/db/index.ts +10 -0
- package/packs/db-postgres/files/lib/db/schema.ts +11 -0
- package/packs/db-postgres/pack.toml +53 -0
- package/packs/db-postgres/tasks.yaml +11 -0
- package/packs/db-sqlite/files/drizzle.config.ts +10 -0
- package/packs/db-sqlite/files/lib/db.ts +8 -0
- package/packs/db-sqlite/files/lib/schema.ts +13 -0
- package/packs/db-sqlite/pack.toml +34 -0
- package/packs/db-sqlite/tasks.yaml +6 -0
- package/packs/db-supabase/files/lib/supabase/client.ts +8 -0
- package/packs/db-supabase/files/lib/supabase/server.ts +27 -0
- package/packs/db-supabase/pack.toml +32 -0
- package/packs/db-supabase/skills/supabase-patterns/SKILL.md +82 -0
- package/packs/db-supabase/tasks.yaml +6 -0
- package/packs/deploy-vercel/files/docs/deploy.md +21 -0
- package/packs/deploy-vercel/files/vercel.json +4 -0
- package/packs/deploy-vercel/pack.toml +30 -0
- package/packs/deploy-vercel/tasks.yaml +14 -0
- package/packs/docker-compose-dev/files/.env.docker.example +2 -0
- package/packs/docker-compose-dev/files/compose/redis.yml +17 -0
- package/packs/docker-compose-dev/files/docker-compose.include.yml +1 -0
- package/packs/docker-compose-dev/files/docker-compose.yml +6 -0
- package/packs/docker-compose-dev/pack.toml +38 -0
- package/packs/docker-compose-dev/tasks.yaml +9 -0
- package/packs/email-resend/files/app/api/email/test/route.ts +38 -0
- package/packs/email-resend/files/emails/welcome.tsx +66 -0
- package/packs/email-resend/files/lib/email.ts +40 -0
- package/packs/email-resend/pack.toml +34 -0
- package/packs/email-resend/tasks.yaml +9 -0
- package/packs/example/pack.toml +69 -0
- package/packs/payments-stripe/files/app/api/billing-portal/route.ts +24 -0
- package/packs/payments-stripe/files/app/api/checkout/route.ts +58 -0
- package/packs/payments-stripe/files/app/api/webhooks/stripe/route.ts +84 -0
- package/packs/payments-stripe/files/lib/stripe.ts +60 -0
- package/packs/payments-stripe/pack.toml +49 -0
- package/packs/payments-stripe/skills/stripe-patterns/SKILL.md +93 -0
- package/packs/payments-stripe/tasks.yaml +16 -0
- package/packs/sync-zero/files/components/ZeroProvider.tsx +3 -0
- package/packs/sync-zero/files/compose/zero-cache.yml +26 -0
- package/packs/sync-zero/files/docker-compose.include.yml +1 -0
- package/packs/sync-zero/files/docker-compose.yml +6 -0
- package/packs/sync-zero/files/lib/zero/client.ts +18 -0
- package/packs/sync-zero/files/lib/zero/schema.ts +17 -0
- package/packs/sync-zero/files/zero.config.ts +26 -0
- package/packs/sync-zero/pack.toml +61 -0
- package/packs/sync-zero/skills/zero-patterns/SKILL.md +69 -0
- package/packs/sync-zero/tasks.yaml +16 -0
- package/packs/testing-playwright/files/e2e/example.spec.ts +7 -0
- package/packs/testing-playwright/files/playwright.config.ts +33 -0
- package/packs/testing-playwright/pack.toml +25 -0
- package/packs/testing-playwright/tasks.yaml +9 -0
- package/packs/ui-shadcn/files/app/globals.css +56 -0
- package/packs/ui-shadcn/files/components/ui/button.tsx +47 -0
- package/packs/ui-shadcn/files/components/ui/card.tsx +33 -0
- package/packs/ui-shadcn/files/lib/utils.ts +6 -0
- package/packs/ui-shadcn/files/postcss.config.mjs +7 -0
- package/packs/ui-shadcn/files/tailwind.config.ts +57 -0
- package/packs/ui-shadcn/pack.toml +44 -0
- package/packs/ui-shadcn/skills/shadcn-dashboard-patterns/SKILL.md +85 -0
- package/packs/ui-shadcn/tasks.yaml +6 -0
- package/presets/docs-site.toml +4 -0
- package/presets/internal-tool.toml +4 -0
- package/presets/lean-saas.toml +4 -0
- package/presets/local-ai-mvp.toml +4 -0
- package/presets/saas-classic.toml +4 -0
- package/src/paths.ts +22 -4
- package/templates/README.md +43 -0
- package/templates/astro/README.md +3 -0
- package/templates/astro/template.toml +4 -0
- package/templates/astro-starlight/README.md +3 -0
- package/templates/astro-starlight/template.toml +4 -0
- package/templates/nextjs/.ai/architecture.md +13 -0
- package/templates/nextjs/.ai/board.md +7 -0
- package/templates/nextjs/.ai/product-spec.md +11 -0
- package/templates/nextjs/.claude/skills/.gitkeep +0 -0
- package/templates/nextjs/.codex/skills/.gitkeep +0 -0
- package/templates/nextjs/AGENTS.md +95 -0
- package/templates/nextjs/CLAUDE.md +3 -0
- package/templates/nextjs/README.md +20 -0
- package/templates/nextjs/anvil.config.json +4 -0
- package/templates/nextjs/app/(app)/home/page.tsx +43 -0
- package/templates/nextjs/app/(app)/home/posts-panel.tsx +83 -0
- package/templates/nextjs/app/(app)/layout.tsx +12 -0
- package/templates/nextjs/app/(auth)/login/page.tsx +97 -0
- package/templates/nextjs/app/globals.css +23 -0
- package/templates/nextjs/app/layout.tsx +20 -0
- package/templates/nextjs/app/page.tsx +39 -0
- package/templates/nextjs/lib/auth-placeholder.ts +21 -0
- package/templates/nextjs/lib/posts-placeholder.ts +30 -0
- package/templates/nextjs/next.config.ts +5 -0
- package/templates/nextjs/package.json +26 -0
- package/templates/nextjs/postcss.config.mjs +7 -0
- package/templates/nextjs/template.toml +4 -0
- package/templates/nextjs/tsconfig.json +27 -0
- package/templates/nextjs/types/post.ts +13 -0
- package/templates/one/README.md +5 -0
- package/templates/one/template.toml +4 -0
- package/templates/vite-react/README.md +3 -0
- package/templates/vite-react/template.toml +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeailab/create-spark",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Interactive scaffolder for spark projects with guided pack picker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -8,14 +8,17 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"src",
|
|
11
|
-
"README.md"
|
|
11
|
+
"README.md",
|
|
12
|
+
"templates",
|
|
13
|
+
"packs",
|
|
14
|
+
"presets"
|
|
12
15
|
],
|
|
13
16
|
"bin": {
|
|
14
17
|
"create-spark": "./src/cli.ts"
|
|
15
18
|
},
|
|
16
19
|
"dependencies": {
|
|
17
|
-
"@forgeailab/spark": "
|
|
18
|
-
"@forgeailab/spark-schema": "
|
|
20
|
+
"@forgeailab/spark": "^0.1.2",
|
|
21
|
+
"@forgeailab/spark-schema": "^0.1.2",
|
|
19
22
|
"@clack/prompts": "latest",
|
|
20
23
|
"citty": "latest",
|
|
21
24
|
"picocolors": "latest"
|
package/packs/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Packs
|
|
2
|
+
|
|
3
|
+
A **pack** is a self-contained unit of capability — auth, db, payments, UI, AI SDK, email, deploy target, … — that the `spark` CLI can install into a scaffolded project. Packs are TOML-manifested, declarative (no shell hooks), and capability-resolved.
|
|
4
|
+
|
|
5
|
+
## Directory layout
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
packs/<name>/
|
|
9
|
+
├── pack.toml # manifest (REQUIRED)
|
|
10
|
+
├── files/ # tree of files to copy into the project (optional)
|
|
11
|
+
├── skills/ # SKILL.md folders shipped with this pack (optional)
|
|
12
|
+
└── tasks.yaml # board tasks seeded into .ai/board.md on install (optional)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## `pack.toml`
|
|
16
|
+
|
|
17
|
+
See [`docs/pack-spec.md`](../docs/pack-spec.md) for the full schema. The Zod source of truth is `packages/spark-schema/src/pack.ts`.
|
|
18
|
+
|
|
19
|
+
A minimum-viable pack:
|
|
20
|
+
|
|
21
|
+
```toml
|
|
22
|
+
name = "db-sqlite"
|
|
23
|
+
version = "0.1.0"
|
|
24
|
+
category = "db"
|
|
25
|
+
description = "Local SQLite database via bun:sqlite + drizzle-orm."
|
|
26
|
+
|
|
27
|
+
provides = ["db"]
|
|
28
|
+
requires = []
|
|
29
|
+
conflicts = ["db"]
|
|
30
|
+
requires_runtime = ["server"]
|
|
31
|
+
compatible_scaffolds = ["nextjs"]
|
|
32
|
+
|
|
33
|
+
[dependencies]
|
|
34
|
+
runtime = ["drizzle-orm"]
|
|
35
|
+
dev = ["drizzle-kit"]
|
|
36
|
+
|
|
37
|
+
[env]
|
|
38
|
+
required = ["DATABASE_URL"]
|
|
39
|
+
|
|
40
|
+
[[files]]
|
|
41
|
+
mode = "create"
|
|
42
|
+
from = "files/lib/db.ts"
|
|
43
|
+
to = "lib/db.ts"
|
|
44
|
+
|
|
45
|
+
[tasks]
|
|
46
|
+
file = "tasks.yaml"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## File modes
|
|
50
|
+
|
|
51
|
+
| Mode | Behavior |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `create` | Fails if the destination already exists |
|
|
54
|
+
| `append` | Idempotent; uses `# >>> spark:<pack> >>>` / `# <<<` markers |
|
|
55
|
+
| `merge-json` | Deep-merges into an existing JSON file with deterministic key order |
|
|
56
|
+
| `template` | Handlebars-style substitution from `spark.config.json` (e.g. `{{appName}}`) |
|
|
57
|
+
|
|
58
|
+
## Capability enums (closed)
|
|
59
|
+
|
|
60
|
+
**Pack capabilities** (`provides` / `requires` / `conflicts`):
|
|
61
|
+
`db, auth, payments, email, ui-kit, local-runtime, deploy-target, e2e, ai-sdk, blob-storage, analytics, sync`
|
|
62
|
+
|
|
63
|
+
Exclusive (one provider per project): `db, auth, payments, ui-kit, sync`
|
|
64
|
+
Non-exclusive (multiple providers OK): `ai-sdk, analytics, email, blob-storage, e2e, deploy-target, local-runtime`
|
|
65
|
+
|
|
66
|
+
**Template capabilities** (`requires_runtime`):
|
|
67
|
+
`static, server, react, native, vue, svelte, mdx-content, edge-runtime`
|
|
68
|
+
|
|
69
|
+
The two enums are separate and never overlap. Adding a new value requires a registry-wide change.
|
|
70
|
+
|
|
71
|
+
## What is NOT allowed
|
|
72
|
+
|
|
73
|
+
- `post_install`, `hooks`, `pre_add`, `scripts` — packs MUST be declarative. If your pack needs a setup step that can't be expressed in `[[files]]`, ship it as a seeded board task the user runs manually.
|
|
74
|
+
- Pack-name conflicts (`conflicts = ["other-pack-name"]`). Use capability tags only.
|
|
75
|
+
- Cross-pack file ownership. Two packs MUST NOT write the same `to` path with `create` mode.
|
|
76
|
+
|
|
77
|
+
## Adding a new pack
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# In Claude Code:
|
|
81
|
+
/new-pack realtime-supabase category=db
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then fill in the generated `packs/realtime-supabase/pack.toml`. Validate with:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
bun -e "import {parsePackToml} from './packages/spark-schema/src/parse.ts'; import {readFileSync} from 'node:fs'; const r = parsePackToml(readFileSync('packs/realtime-supabase/pack.toml','utf8')); console.log(r.ok ? 'OK' : r.error);"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
See `packs/example/pack.toml` for a manifest that exercises every field.
|
|
91
|
+
|
|
92
|
+
## v1 catalog
|
|
93
|
+
|
|
94
|
+
Each pack is either **copy** (ships file trees the user owns) or **hybrid** (ships thin wiring + imports runtime logic from a versioned `@forgeailab/spark-*` helper under `libs/`). The mode is inferred from the presence of a `[runtime_package]` block in the manifest.
|
|
95
|
+
|
|
96
|
+
| Pack | Category | Mode | Runtime helper |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| `auth-better-auth` | auth | **hybrid** | `@forgeailab/spark-auth-better-auth` (SQLite) |
|
|
99
|
+
| `auth-better-auth-pg` | auth | **hybrid** | `@forgeailab/spark-auth-better-auth` (Postgres) |
|
|
100
|
+
| `auth-supabase` | auth | copy | — |
|
|
101
|
+
| `db-sqlite` | db | copy | — |
|
|
102
|
+
| `db-postgres` | db | copy | — |
|
|
103
|
+
| `db-supabase` | db | copy | — |
|
|
104
|
+
| `sync-zero` | infra | **hybrid** | `@forgeailab/spark-sync-zero` |
|
|
105
|
+
| `payments-stripe` | payments | **hybrid** | `@forgeailab/spark-stripe-helpers` |
|
|
106
|
+
| `ai-anthropic` | ai | **hybrid** | `@forgeailab/spark-anthropic` |
|
|
107
|
+
| `ai-openai` | ai | copy | — |
|
|
108
|
+
| `ui-shadcn` | ui | copy | — |
|
|
109
|
+
| `email-resend` | email | copy | — |
|
|
110
|
+
| `analytics-posthog` | analytics | copy | — |
|
|
111
|
+
| `docker-compose-dev` | infra | copy | — |
|
|
112
|
+
| `testing-playwright` | testing | copy | — |
|
|
113
|
+
| `deploy-vercel` | deploy | copy | — |
|
|
114
|
+
|
|
115
|
+
### Picking a db + auth pair
|
|
116
|
+
|
|
117
|
+
`auth-better-auth` and `auth-better-auth-pg` share the same runtime helper
|
|
118
|
+
(`@forgeailab/spark-auth-better-auth`) — they differ only in the `provider:`
|
|
119
|
+
their generated `lib/auth.ts` template hands to `drizzleAdapter`. Pair them:
|
|
120
|
+
|
|
121
|
+
- `db-sqlite` + `auth-better-auth` — fastest path, single file db, no infra.
|
|
122
|
+
- `db-postgres` + `auth-better-auth-pg` — production-shaped, **required for `sync-zero`** (Zero needs Postgres logical replication).
|
|
123
|
+
- `db-supabase` + `auth-better-auth-pg` — Supabase-hosted Postgres + Better Auth on top.
|
|
124
|
+
|
|
125
|
+
The two auth packs both `conflicts = ["auth"]`, so the resolver prevents
|
|
126
|
+
installing both. Mixing wrong pairs (e.g. `db-sqlite` + `auth-better-auth-pg`)
|
|
127
|
+
typechecks but fails at runtime — the drizzle adapter will reject sqlite tables
|
|
128
|
+
with `provider: 'pg'`.
|
|
129
|
+
|
|
130
|
+
The hybrid packs were authored against [`reference/full-stack-saas/`](../reference/full-stack-saas/) — the canonical integration showing all four helpers working together. When debugging a hybrid pack, start there.
|
|
131
|
+
|
|
132
|
+
See the root `README.md` for the catalog summary.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { anthropic, streamResponse, type AnthropicChatMessage } from '@/lib/anthropic';
|
|
2
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
export const runtime = 'nodejs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MODEL = 'claude-sonnet-4-5';
|
|
7
|
+
const DEFAULT_MAX_TOKENS = 1_024;
|
|
8
|
+
const HARD_MAX_TOKENS = 4_096;
|
|
9
|
+
|
|
10
|
+
type ChatRequest = {
|
|
11
|
+
messages?: unknown;
|
|
12
|
+
system?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function normalizeMessages(value: unknown): AnthropicChatMessage[] | undefined {
|
|
18
|
+
if (!Array.isArray(value)) return undefined;
|
|
19
|
+
const messages: AnthropicChatMessage[] = [];
|
|
20
|
+
for (const entry of value) {
|
|
21
|
+
if (typeof entry !== 'object' || entry === null) return undefined;
|
|
22
|
+
const role = 'role' in entry ? entry.role : undefined;
|
|
23
|
+
const content = 'content' in entry ? entry.content : undefined;
|
|
24
|
+
if ((role !== 'user' && role !== 'assistant') || typeof content !== 'string') return undefined;
|
|
25
|
+
messages.push({ role, content });
|
|
26
|
+
}
|
|
27
|
+
return messages.length > 0 ? messages : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function POST(request: NextRequest) {
|
|
31
|
+
const body = (await request.json().catch(() => ({}))) as ChatRequest;
|
|
32
|
+
const messages = normalizeMessages(body.messages);
|
|
33
|
+
|
|
34
|
+
if (!messages) {
|
|
35
|
+
return NextResponse.json(
|
|
36
|
+
{ error: 'messages must be a non-empty array of user/assistant strings' },
|
|
37
|
+
{ status: 400 },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const maxTokens = Math.min(Math.max(1, body.maxTokens ?? DEFAULT_MAX_TOKENS), HARD_MAX_TOKENS);
|
|
42
|
+
|
|
43
|
+
const stream = streamResponse(anthropic, {
|
|
44
|
+
model: body.model ?? DEFAULT_MODEL,
|
|
45
|
+
max_tokens: maxTokens,
|
|
46
|
+
system: body.system,
|
|
47
|
+
messages,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return new Response(stream, {
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
53
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
54
|
+
Connection: 'keep-alive',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { createAnthropicClient, streamResponse } from '@forgeailab/spark-anthropic';
|
|
3
|
+
|
|
4
|
+
function requireEnv(name: string): string {
|
|
5
|
+
const value = process.env[name];
|
|
6
|
+
if (!value) {
|
|
7
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const anthropic = createAnthropicClient(requireEnv('ANTHROPIC_API_KEY'));
|
|
13
|
+
|
|
14
|
+
export { streamResponse };
|
|
15
|
+
export type AnthropicChatMessage = Anthropic.Messages.MessageParam;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name = "ai-anthropic"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "ai"
|
|
4
|
+
description = "Anthropic SDK client and streaming chat endpoint."
|
|
5
|
+
provides = ["ai-sdk"]
|
|
6
|
+
requires = []
|
|
7
|
+
conflicts = []
|
|
8
|
+
requires_runtime = ["server"]
|
|
9
|
+
compatible_scaffolds = []
|
|
10
|
+
|
|
11
|
+
[runtime_package]
|
|
12
|
+
package = "@forgeailab/spark-anthropic"
|
|
13
|
+
version = "^0.1"
|
|
14
|
+
|
|
15
|
+
[env]
|
|
16
|
+
required = ["ANTHROPIC_API_KEY"]
|
|
17
|
+
|
|
18
|
+
[[files]]
|
|
19
|
+
mode = "create"
|
|
20
|
+
from = "lib/anthropic.ts"
|
|
21
|
+
to = "lib/anthropic.ts"
|
|
22
|
+
|
|
23
|
+
[[files]]
|
|
24
|
+
mode = "create"
|
|
25
|
+
from = "app/api/ai/route.ts"
|
|
26
|
+
to = "app/api/ai/route.ts"
|
|
27
|
+
|
|
28
|
+
[skills]
|
|
29
|
+
copy = ["skills/ai-feature-patterns"]
|
|
30
|
+
|
|
31
|
+
[tasks]
|
|
32
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai-feature-patterns
|
|
3
|
+
description: Build Anthropic-backed AI features with streaming UX, prompt discipline, and cost controls. Use when implementing or reviewing features after the ai-anthropic pack is installed.
|
|
4
|
+
allowed-tools:
|
|
5
|
+
- Read
|
|
6
|
+
- Write
|
|
7
|
+
- Edit
|
|
8
|
+
- Bash
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Skill: ai-feature-patterns
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
Add one focused AI capability that helps the product journey without turning the
|
|
16
|
+
MVP into a generic chat app. Keep prompts inspectable, streams responsive, and
|
|
17
|
+
cost bounded by server-side controls.
|
|
18
|
+
|
|
19
|
+
## Recommended model
|
|
20
|
+
|
|
21
|
+
Opus 4.7 or GPT-5.5 for prompt architecture and evaluation. Sonnet 4.6 or GPT-5
|
|
22
|
+
family executor for endpoint, UI, and test wiring.
|
|
23
|
+
|
|
24
|
+
## Inputs
|
|
25
|
+
|
|
26
|
+
Read these before changing AI behavior:
|
|
27
|
+
|
|
28
|
+
- `.ai/product-spec.md` for the user workflow and non-goals
|
|
29
|
+
- `.ai/architecture.md` for persistence, auth, and runtime boundaries
|
|
30
|
+
- `.ai/board.md` for the exact AI task and acceptance criteria
|
|
31
|
+
- `lib/anthropic.ts` for shared client setup
|
|
32
|
+
- `app/api/ai/route.ts` for request, stream, and guardrail behavior
|
|
33
|
+
|
|
34
|
+
If the spec does not name the decision or artifact the AI should improve, stop
|
|
35
|
+
and ask. Do not add chat just because an AI SDK is installed.
|
|
36
|
+
|
|
37
|
+
## Prompt Patterns
|
|
38
|
+
|
|
39
|
+
- Put durable behavior in a server-owned system prompt.
|
|
40
|
+
- Put user-specific state in structured context, not prose pasted from the UI.
|
|
41
|
+
- Keep task prompts narrow: role, input facts, output format, refusal boundary.
|
|
42
|
+
- Prefer JSON or Markdown output contracts only when the UI actually needs them.
|
|
43
|
+
- Do not ask the model to enforce authorization, billing, or data access rules.
|
|
44
|
+
- Include the smallest useful context window; retrieve or summarize before
|
|
45
|
+
sending long histories.
|
|
46
|
+
|
|
47
|
+
## Streaming UX
|
|
48
|
+
|
|
49
|
+
- Stream text when the user is waiting on generation or analysis.
|
|
50
|
+
- Show partial output in the destination surface, not a separate debug pane.
|
|
51
|
+
- Preserve a cancel path when requests can take more than a few seconds.
|
|
52
|
+
- Persist the final answer only after the stream completes successfully.
|
|
53
|
+
- Treat stream errors as recoverable UI state with a retry action.
|
|
54
|
+
|
|
55
|
+
## Cost Controls
|
|
56
|
+
|
|
57
|
+
- Cap `max_tokens` on the server, even when the client sends a smaller hint.
|
|
58
|
+
- Rate limit by user, organization, or IP before calling Anthropic.
|
|
59
|
+
- Add a cheap preflight check for empty prompts and unsupported file sizes.
|
|
60
|
+
- Log model, token caps, latency, and user/account id for cost review.
|
|
61
|
+
- Use smaller models or cached summaries for low-stakes transformations.
|
|
62
|
+
- Keep generated artifacts small enough to review and edit.
|
|
63
|
+
|
|
64
|
+
## Safety and Privacy
|
|
65
|
+
|
|
66
|
+
- Send only the data needed for the requested feature.
|
|
67
|
+
- Redact secrets and credentials from context before model calls.
|
|
68
|
+
- Avoid storing raw prompts if they may contain sensitive customer data.
|
|
69
|
+
- Make model output advisory unless the product spec explicitly automates action.
|
|
70
|
+
- Require human confirmation before sending emails, charging users, or deleting data.
|
|
71
|
+
|
|
72
|
+
## Common Pitfalls
|
|
73
|
+
|
|
74
|
+
- Do not stream from the browser directly with the API key.
|
|
75
|
+
- Do not let the client choose unlimited tokens or arbitrary expensive models.
|
|
76
|
+
- Do not hide prompt changes in component code; keep prompts close to API routes.
|
|
77
|
+
- Do not make acceptance criteria depend on subjective model quality alone.
|
|
78
|
+
- Do not add vector search, agents, or tool calls unless the board asks for them.
|
|
79
|
+
|
|
80
|
+
## Verification
|
|
81
|
+
|
|
82
|
+
Use a small real prompt and confirm all of these:
|
|
83
|
+
|
|
84
|
+
- The endpoint streams incremental `text` events.
|
|
85
|
+
- The final event is `done`.
|
|
86
|
+
- Invalid input returns a 400 before calling Anthropic.
|
|
87
|
+
- Token caps and rate limits are enforced server-side.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { getOpenAIClient, OPENAI_CHAT_MODEL, type OpenAIChatMessage } from "../../../lib/openai";
|
|
2
|
+
|
|
3
|
+
export const runtime = "nodejs";
|
|
4
|
+
|
|
5
|
+
type ChatRequest = {
|
|
6
|
+
messages?: OpenAIChatMessage[];
|
|
7
|
+
prompt?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function normalizeMessages(body: ChatRequest): OpenAIChatMessage[] {
|
|
11
|
+
if (Array.isArray(body.messages) && body.messages.length > 0) {
|
|
12
|
+
return body.messages;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (body.prompt) {
|
|
16
|
+
return [{ role: "user", content: body.prompt }];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
throw new Error("Request body must include messages or prompt.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function POST(request: Request): Promise<Response> {
|
|
23
|
+
const body = (await request.json()) as ChatRequest;
|
|
24
|
+
const messages = normalizeMessages(body);
|
|
25
|
+
|
|
26
|
+
const stream = await getOpenAIClient().chat.completions.create({
|
|
27
|
+
model: OPENAI_CHAT_MODEL,
|
|
28
|
+
messages,
|
|
29
|
+
stream: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
34
|
+
async start(controller) {
|
|
35
|
+
try {
|
|
36
|
+
for await (const chunk of stream) {
|
|
37
|
+
const content = chunk.choices[0]?.delta?.content;
|
|
38
|
+
if (content) {
|
|
39
|
+
controller.enqueue(encoder.encode(content));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
controller.close();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
controller.error(error);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return new Response(readable, {
|
|
50
|
+
headers: {
|
|
51
|
+
"Cache-Control": "no-cache",
|
|
52
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
|
|
3
|
+
let client: OpenAI | undefined;
|
|
4
|
+
|
|
5
|
+
export type OpenAIChatMessage = {
|
|
6
|
+
role: "developer" | "system" | "user" | "assistant";
|
|
7
|
+
content: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const OPENAI_CHAT_MODEL = "gpt-5.2";
|
|
11
|
+
|
|
12
|
+
export function getOpenAIClient(): OpenAI {
|
|
13
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
14
|
+
|
|
15
|
+
if (!apiKey) {
|
|
16
|
+
throw new Error("OPENAI_API_KEY is required to use the OpenAI client.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
client ??= new OpenAI({ apiKey });
|
|
20
|
+
return client;
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name = "ai-openai"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "ai"
|
|
4
|
+
description = "OpenAI SDK client wrapper and streaming chat endpoint."
|
|
5
|
+
provides = ["ai-sdk"]
|
|
6
|
+
requires = []
|
|
7
|
+
conflicts = []
|
|
8
|
+
requires_runtime = ["server"]
|
|
9
|
+
compatible_scaffolds = []
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
runtime = ["openai"]
|
|
13
|
+
dev = []
|
|
14
|
+
|
|
15
|
+
[env]
|
|
16
|
+
required = ["OPENAI_API_KEY"]
|
|
17
|
+
optional = []
|
|
18
|
+
|
|
19
|
+
[[files]]
|
|
20
|
+
mode = "create"
|
|
21
|
+
from = "lib/openai.ts"
|
|
22
|
+
to = "lib/openai.ts"
|
|
23
|
+
|
|
24
|
+
[[files]]
|
|
25
|
+
mode = "create"
|
|
26
|
+
from = "app/api/ai-openai/route.ts"
|
|
27
|
+
to = "app/api/ai-openai/route.ts"
|
|
28
|
+
|
|
29
|
+
[tasks]
|
|
30
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
epic: AI
|
|
2
|
+
tasks:
|
|
3
|
+
- id: AI-101
|
|
4
|
+
title: Add usage tracking + cost guardrails
|
|
5
|
+
status: Clarifying
|
|
6
|
+
acceptance:
|
|
7
|
+
- OpenAI requests record model, token usage, and user or workspace context.
|
|
8
|
+
- The endpoint enforces a documented per-user or per-workspace usage limit.
|
|
9
|
+
- Over-limit requests return a clear non-200 response without calling OpenAI.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import posthog from "posthog-js";
|
|
6
|
+
import { PostHogProvider as Provider } from "posthog-js/react";
|
|
7
|
+
import { getPostHogClient } from "../lib/posthog/client";
|
|
8
|
+
|
|
9
|
+
type PostHogProviderProps = {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function PostHogProvider({ children }: PostHogProviderProps) {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
getPostHogClient();
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return <Provider client={posthog}>{children}</Provider>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import posthog from "posthog-js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
|
|
6
|
+
|
|
7
|
+
export function getPostHogClient(): typeof posthog {
|
|
8
|
+
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
|
9
|
+
const host = process.env.NEXT_PUBLIC_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST;
|
|
10
|
+
const loaded = (posthog as typeof posthog & { __loaded?: boolean }).__loaded;
|
|
11
|
+
|
|
12
|
+
if (typeof window !== "undefined" && key && !loaded) {
|
|
13
|
+
posthog.init(key, {
|
|
14
|
+
api_host: host,
|
|
15
|
+
capture_pageview: false,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return posthog;
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PostHog } from "posthog-node";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
|
|
4
|
+
|
|
5
|
+
let client: PostHog | undefined;
|
|
6
|
+
|
|
7
|
+
export function getPostHogServerClient(): PostHog | undefined {
|
|
8
|
+
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
|
9
|
+
|
|
10
|
+
if (!key) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
client ??= new PostHog(key, {
|
|
15
|
+
host: process.env.NEXT_PUBLIC_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return client;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function shutdownPostHogServerClient(): Promise<void> {
|
|
22
|
+
await client?.shutdown();
|
|
23
|
+
client = undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name = "analytics-posthog"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "analytics"
|
|
4
|
+
description = "PostHog browser and server analytics helpers."
|
|
5
|
+
provides = ["analytics"]
|
|
6
|
+
requires = []
|
|
7
|
+
conflicts = []
|
|
8
|
+
requires_runtime = ["server"]
|
|
9
|
+
compatible_scaffolds = []
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
runtime = ["posthog-js", "posthog-node"]
|
|
13
|
+
dev = []
|
|
14
|
+
|
|
15
|
+
[env]
|
|
16
|
+
required = ["NEXT_PUBLIC_POSTHOG_KEY"]
|
|
17
|
+
optional = ["NEXT_PUBLIC_POSTHOG_HOST"]
|
|
18
|
+
|
|
19
|
+
[[files]]
|
|
20
|
+
mode = "create"
|
|
21
|
+
from = "lib/posthog/client.ts"
|
|
22
|
+
to = "lib/posthog/client.ts"
|
|
23
|
+
|
|
24
|
+
[[files]]
|
|
25
|
+
mode = "create"
|
|
26
|
+
from = "lib/posthog/server.ts"
|
|
27
|
+
to = "lib/posthog/server.ts"
|
|
28
|
+
|
|
29
|
+
[[files]]
|
|
30
|
+
mode = "create"
|
|
31
|
+
from = "components/PostHogProvider.tsx"
|
|
32
|
+
to = "components/PostHogProvider.tsx"
|
|
33
|
+
|
|
34
|
+
[tasks]
|
|
35
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
epic: Analytics
|
|
2
|
+
tasks:
|
|
3
|
+
- id: ANALYTICS-001
|
|
4
|
+
title: Wire posthog provider into app root
|
|
5
|
+
status: Clarifying
|
|
6
|
+
acceptance:
|
|
7
|
+
- The app root wraps client routes with PostHogProvider.
|
|
8
|
+
- Page views are captured intentionally, either by router events or a documented manual call.
|
|
9
|
+
- id: ANALYTICS-002
|
|
10
|
+
title: Add consent banner
|
|
11
|
+
status: Clarifying
|
|
12
|
+
acceptance:
|
|
13
|
+
- Users can accept or decline analytics before tracking starts.
|
|
14
|
+
- The consent choice persists across reloads.
|
|
15
|
+
- PostHog capture is disabled when consent is declined.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from 'react';
|
|
4
|
+
import { createAuthClient } from 'better-auth/react';
|
|
5
|
+
|
|
6
|
+
const authClient = createAuthClient();
|
|
7
|
+
type SocialProvider = 'github' | 'google';
|
|
8
|
+
|
|
9
|
+
export default function LoginPage() {
|
|
10
|
+
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
const [isPending, startTransition] = useTransition();
|
|
12
|
+
|
|
13
|
+
function signIn(provider: SocialProvider) {
|
|
14
|
+
setError(null);
|
|
15
|
+
startTransition(() => {
|
|
16
|
+
void authClient.signIn
|
|
17
|
+
.social({
|
|
18
|
+
provider,
|
|
19
|
+
callbackURL: '/',
|
|
20
|
+
})
|
|
21
|
+
.then((result) => {
|
|
22
|
+
if (result.error) {
|
|
23
|
+
setError(result.error.message ?? 'Unable to sign in.');
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<main className="mx-auto flex min-h-screen w-full max-w-sm flex-col justify-center px-6">
|
|
31
|
+
<div className="space-y-2">
|
|
32
|
+
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
|
33
|
+
<p className="text-sm text-muted-foreground">Continue with a configured OAuth provider.</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div className="mt-8 grid gap-3">
|
|
37
|
+
<button
|
|
38
|
+
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
|
39
|
+
disabled={isPending}
|
|
40
|
+
onClick={() => signIn('github')}
|
|
41
|
+
type="button"
|
|
42
|
+
>
|
|
43
|
+
Continue with GitHub
|
|
44
|
+
</button>
|
|
45
|
+
<button
|
|
46
|
+
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
|
47
|
+
disabled={isPending}
|
|
48
|
+
onClick={() => signIn('google')}
|
|
49
|
+
type="button"
|
|
50
|
+
>
|
|
51
|
+
Continue with Google
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
|
|
56
|
+
</main>
|
|
57
|
+
);
|
|
58
|
+
}
|