@forgeailab/spark 0.2.0 → 0.4.0
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 +8 -6
- package/src/commands/add.ts +3 -1
- package/src/commands/preset.ts +1 -0
- package/src/internal/board.ts +266 -304
- package/src/io/board.ts +29 -65
- package/src/io/env.ts +8 -3
- package/src/io/files.ts +22 -7
- package/packs/README.md +0 -130
- package/packs/ai-anthropic/files/app/api/ai/route.ts +0 -57
- package/packs/ai-anthropic/files/lib/anthropic.ts +0 -58
- package/packs/ai-anthropic/pack.toml +0 -31
- package/packs/ai-anthropic/skills/ai-feature-patterns/SKILL.md +0 -87
- package/packs/ai-anthropic/tasks.yaml +0 -9
- package/packs/ai-openai/files/app/api/ai-openai/route.ts +0 -55
- package/packs/ai-openai/files/lib/openai.ts +0 -21
- package/packs/ai-openai/pack.toml +0 -30
- package/packs/ai-openai/tasks.yaml +0 -9
- package/packs/analytics-posthog/files/components/PostHogProvider.tsx +0 -19
- package/packs/analytics-posthog/files/lib/posthog/client.ts +0 -20
- package/packs/analytics-posthog/files/lib/posthog/server.ts +0 -24
- package/packs/analytics-posthog/pack.toml +0 -35
- package/packs/analytics-posthog/tasks.yaml +0 -15
- package/packs/auth-better-auth/files/app/(auth)/login/page.tsx +0 -58
- package/packs/auth-better-auth/files/app/api/auth/[...all]/route.ts +0 -4
- package/packs/auth-better-auth/files/lib/auth.ts +0 -60
- package/packs/auth-better-auth/pack.toml +0 -28
- package/packs/auth-better-auth/tasks.yaml +0 -10
- package/packs/auth-better-auth-pg/files/app/api/auth/[...all]/route.ts +0 -4
- package/packs/auth-better-auth-pg/files/lib/auth.ts +0 -125
- package/packs/auth-better-auth-pg/pack.toml +0 -28
- package/packs/auth-better-auth-pg/tasks.yaml +0 -17
- package/packs/auth-supabase/files/app/(auth)/login/page.tsx +0 -64
- package/packs/auth-supabase/files/app/auth/callback/route.ts +0 -15
- package/packs/auth-supabase/files/middleware.ts +0 -41
- package/packs/auth-supabase/pack.toml +0 -34
- package/packs/auth-supabase/tasks.yaml +0 -10
- package/packs/db-postgres/files/compose/postgres.yml +0 -28
- package/packs/db-postgres/files/docker-compose.include.yml +0 -1
- package/packs/db-postgres/files/docker-compose.yml +0 -6
- package/packs/db-postgres/files/drizzle.config.ts +0 -10
- package/packs/db-postgres/files/lib/db/index.ts +0 -10
- package/packs/db-postgres/files/lib/db/schema.ts +0 -11
- package/packs/db-postgres/pack.toml +0 -53
- package/packs/db-postgres/tasks.yaml +0 -11
- package/packs/db-sqlite/files/drizzle.config.ts +0 -10
- package/packs/db-sqlite/files/lib/db.ts +0 -8
- package/packs/db-sqlite/files/lib/schema.ts +0 -13
- package/packs/db-sqlite/pack.toml +0 -34
- package/packs/db-sqlite/tasks.yaml +0 -6
- package/packs/db-supabase/files/lib/supabase/client.ts +0 -8
- package/packs/db-supabase/files/lib/supabase/server.ts +0 -27
- package/packs/db-supabase/pack.toml +0 -32
- package/packs/db-supabase/skills/supabase-patterns/SKILL.md +0 -82
- package/packs/db-supabase/tasks.yaml +0 -6
- package/packs/deploy-vercel/files/docs/deploy.md +0 -21
- package/packs/deploy-vercel/files/vercel.json +0 -4
- package/packs/deploy-vercel/pack.toml +0 -30
- package/packs/deploy-vercel/tasks.yaml +0 -14
- package/packs/docker-compose-dev/files/.env.docker.example +0 -2
- package/packs/docker-compose-dev/files/compose/redis.yml +0 -17
- package/packs/docker-compose-dev/files/docker-compose.include.yml +0 -1
- package/packs/docker-compose-dev/files/docker-compose.yml +0 -6
- package/packs/docker-compose-dev/pack.toml +0 -38
- package/packs/docker-compose-dev/tasks.yaml +0 -9
- package/packs/email-resend/files/app/api/email/test/route.ts +0 -38
- package/packs/email-resend/files/emails/welcome.tsx +0 -66
- package/packs/email-resend/files/lib/email.ts +0 -40
- package/packs/email-resend/pack.toml +0 -34
- package/packs/email-resend/tasks.yaml +0 -9
- package/packs/example/pack.toml +0 -69
- package/packs/payments-stripe/files/app/api/billing-portal/route.ts +0 -24
- package/packs/payments-stripe/files/app/api/checkout/route.ts +0 -58
- package/packs/payments-stripe/files/app/api/webhooks/stripe/route.ts +0 -84
- package/packs/payments-stripe/files/lib/stripe.ts +0 -158
- package/packs/payments-stripe/pack.toml +0 -45
- package/packs/payments-stripe/skills/stripe-patterns/SKILL.md +0 -93
- package/packs/payments-stripe/tasks.yaml +0 -16
- package/packs/sync-zero/files/components/ZeroProvider.tsx +0 -13
- package/packs/sync-zero/files/compose/zero-cache.yml +0 -26
- package/packs/sync-zero/files/docker-compose.include.yml +0 -1
- package/packs/sync-zero/files/docker-compose.yml +0 -6
- package/packs/sync-zero/files/lib/zero/client.ts +0 -18
- package/packs/sync-zero/files/lib/zero/schema.ts +0 -30
- package/packs/sync-zero/files/zero.config.ts +0 -26
- package/packs/sync-zero/pack.toml +0 -57
- package/packs/sync-zero/skills/zero-patterns/SKILL.md +0 -69
- package/packs/sync-zero/tasks.yaml +0 -16
- package/packs/testing-playwright/files/e2e/example.spec.ts +0 -7
- package/packs/testing-playwright/files/playwright.config.ts +0 -33
- package/packs/testing-playwright/pack.toml +0 -25
- package/packs/testing-playwright/tasks.yaml +0 -9
- package/packs/ui-shadcn/files/app/globals.css +0 -56
- package/packs/ui-shadcn/files/components/ui/button.tsx +0 -47
- package/packs/ui-shadcn/files/components/ui/card.tsx +0 -33
- package/packs/ui-shadcn/files/lib/utils.ts +0 -6
- package/packs/ui-shadcn/files/postcss.config.mjs +0 -7
- package/packs/ui-shadcn/files/tailwind.config.ts +0 -57
- package/packs/ui-shadcn/pack.toml +0 -44
- package/packs/ui-shadcn/skills/shadcn-dashboard-patterns/SKILL.md +0 -85
- package/packs/ui-shadcn/tasks.yaml +0 -6
- package/presets/docs-site.toml +0 -4
- package/presets/internal-tool.toml +0 -4
- package/presets/lean-saas.toml +0 -4
- package/presets/local-ai-mvp.toml +0 -4
- package/presets/saas-classic.toml +0 -4
- package/templates/README.md +0 -43
- package/templates/astro/README.md +0 -3
- package/templates/astro/template.toml +0 -4
- package/templates/astro-starlight/README.md +0 -3
- package/templates/astro-starlight/template.toml +0 -4
- package/templates/nextjs/.ai/architecture.md +0 -13
- package/templates/nextjs/.ai/board.md +0 -7
- package/templates/nextjs/.ai/product-spec.md +0 -11
- package/templates/nextjs/.claude/skills/.gitkeep +0 -0
- package/templates/nextjs/.codex/skills/.gitkeep +0 -0
- package/templates/nextjs/AGENTS.md +0 -95
- package/templates/nextjs/CLAUDE.md +0 -3
- package/templates/nextjs/README.md +0 -20
- package/templates/nextjs/app/(app)/home/page.tsx +0 -43
- package/templates/nextjs/app/(app)/home/posts-panel.tsx +0 -83
- package/templates/nextjs/app/(app)/layout.tsx +0 -12
- package/templates/nextjs/app/(auth)/login/page.tsx +0 -97
- package/templates/nextjs/app/globals.css +0 -23
- package/templates/nextjs/app/layout.tsx +0 -20
- package/templates/nextjs/app/page.tsx +0 -39
- package/templates/nextjs/lib/auth-placeholder.ts +0 -21
- package/templates/nextjs/lib/posts-placeholder.ts +0 -30
- package/templates/nextjs/next.config.ts +0 -5
- package/templates/nextjs/package.json +0 -26
- package/templates/nextjs/postcss.config.mjs +0 -7
- package/templates/nextjs/spark.config.json +0 -4
- package/templates/nextjs/template.toml +0 -4
- package/templates/nextjs/tsconfig.json +0 -27
- package/templates/nextjs/types/post.ts +0 -13
- package/templates/one/README.md +0 -5
- package/templates/one/template.toml +0 -4
- package/templates/vite-react/README.md +0 -3
- package/templates/vite-react/template.toml +0 -4
package/src/io/board.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { resolve, sep } from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
BoardTaskStatus,
|
|
5
|
+
readAllChangeTasks,
|
|
5
6
|
seedTasks as seedPackageTasks,
|
|
6
7
|
} from '../internal/board';
|
|
7
8
|
|
|
8
9
|
export {
|
|
9
10
|
BoardTaskStatus,
|
|
11
|
+
parseBoardMarkdown,
|
|
12
|
+
parseTasksMarkdown,
|
|
13
|
+
readAllChangeTasks,
|
|
10
14
|
readBoard,
|
|
15
|
+
renderBuildStatus,
|
|
11
16
|
seedTasks,
|
|
12
17
|
updateStatus,
|
|
13
18
|
} from '../internal/board';
|
|
14
19
|
export type {
|
|
20
|
+
AggregatedTask,
|
|
15
21
|
Board,
|
|
16
22
|
BoardEpic,
|
|
17
23
|
BoardTask as ParsedBoardTask,
|
|
@@ -33,16 +39,12 @@ function stripYamlValue(value: string): string {
|
|
|
33
39
|
) {
|
|
34
40
|
return trimmed.slice(1, -1);
|
|
35
41
|
}
|
|
36
|
-
|
|
37
42
|
return trimmed;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
function parseKeyValue(line: string): { key: string; value: string } | undefined {
|
|
41
46
|
const index = line.indexOf(':');
|
|
42
|
-
if (index === -1)
|
|
43
|
-
return undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
47
|
+
if (index === -1) return undefined;
|
|
46
48
|
return {
|
|
47
49
|
key: line.slice(0, index).trim(),
|
|
48
50
|
value: stripYamlValue(line.slice(index + 1)),
|
|
@@ -57,9 +59,7 @@ export function parseTasksYaml(raw: string): BoardTask[] {
|
|
|
57
59
|
|
|
58
60
|
for (const line of raw.split(/\r?\n/u)) {
|
|
59
61
|
const trimmed = line.trim();
|
|
60
|
-
if (trimmed.length === 0 || trimmed.startsWith('#'))
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
62
|
+
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
|
63
63
|
|
|
64
64
|
if (!trimmed.startsWith('- ') && parseKeyValue(trimmed)?.key === 'epic') {
|
|
65
65
|
defaultEpic = parseKeyValue(trimmed)?.value || defaultEpic;
|
|
@@ -68,20 +68,13 @@ export function parseTasksYaml(raw: string): BoardTask[] {
|
|
|
68
68
|
|
|
69
69
|
if (trimmed.startsWith('- id:')) {
|
|
70
70
|
const id = stripYamlValue(trimmed.slice('- id:'.length));
|
|
71
|
-
current = {
|
|
72
|
-
id,
|
|
73
|
-
title: id,
|
|
74
|
-
epic: defaultEpic,
|
|
75
|
-
acceptance: [],
|
|
76
|
-
};
|
|
71
|
+
current = { id, title: id, epic: defaultEpic, acceptance: [] };
|
|
77
72
|
tasks.push(current);
|
|
78
73
|
inAcceptance = false;
|
|
79
74
|
continue;
|
|
80
75
|
}
|
|
81
76
|
|
|
82
|
-
if (!current)
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
77
|
+
if (!current) continue;
|
|
85
78
|
|
|
86
79
|
if (trimmed === 'acceptance:' || trimmed === 'acceptance_criteria:') {
|
|
87
80
|
inAcceptance = true;
|
|
@@ -94,9 +87,7 @@ export function parseTasksYaml(raw: string): BoardTask[] {
|
|
|
94
87
|
}
|
|
95
88
|
|
|
96
89
|
const kv = parseKeyValue(trimmed);
|
|
97
|
-
if (!kv)
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
90
|
+
if (!kv) continue;
|
|
100
91
|
|
|
101
92
|
if (kv.key === 'title') {
|
|
102
93
|
current.title = kv.value;
|
|
@@ -112,66 +103,40 @@ export function parseTasksYaml(raw: string): BoardTask[] {
|
|
|
112
103
|
return tasks.filter((task) => task.id.length > 0);
|
|
113
104
|
}
|
|
114
105
|
|
|
115
|
-
async function readExisting(path: string): Promise<string> {
|
|
116
|
-
try {
|
|
117
|
-
return await readFile(path, 'utf8');
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
120
|
-
return '# Board\n';
|
|
121
|
-
}
|
|
122
|
-
throw error;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function escapeRegex(value: string): string {
|
|
127
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function boardHasTask(board: string, taskId: string): boolean {
|
|
131
|
-
return new RegExp(`\\b${escapeRegex(taskId)}\\b`).test(board);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function formatTaskDescription(task: BoardTask, packName: string): string {
|
|
135
|
-
const acceptance =
|
|
136
|
-
task.acceptance.length > 0
|
|
137
|
-
? task.acceptance.map((item) => ` - ${item}`)
|
|
138
|
-
: [' - Confirm acceptance criteria for this pack task.'];
|
|
139
|
-
|
|
140
|
-
return [`requires_pack: ${packName}`, 'acceptance:', ...acceptance].join('\n');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
106
|
function assertInsidePack(packRoot: string, path: string): string {
|
|
144
107
|
const resolvedRoot = resolve(packRoot);
|
|
145
108
|
const resolvedPath = resolve(packRoot, path);
|
|
146
|
-
|
|
147
109
|
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) {
|
|
148
110
|
throw new Error(`Refusing to read tasks file outside pack root: ${path}`);
|
|
149
111
|
}
|
|
150
|
-
|
|
151
112
|
return resolvedPath;
|
|
152
113
|
}
|
|
153
114
|
|
|
115
|
+
// The pack-install change's task lines carry "Status: Clarifying" + "requires_pack:"
|
|
116
|
+
// (added by internal/board.ts); here we only contribute the acceptance sub-bullets.
|
|
117
|
+
function formatTaskDescription(task: BoardTask): string {
|
|
118
|
+
const acceptance =
|
|
119
|
+
task.acceptance.length > 0
|
|
120
|
+
? task.acceptance.map((item) => ` - ${item}`)
|
|
121
|
+
: [' - Confirm acceptance criteria for this pack task.'];
|
|
122
|
+
return ['acceptance:', ...acceptance].join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
154
125
|
export async function seedBoardTasks(
|
|
155
126
|
projectRoot: string,
|
|
156
127
|
packName: string,
|
|
157
128
|
packRoot: string,
|
|
158
129
|
tasksFile?: string,
|
|
159
130
|
): Promise<string[]> {
|
|
160
|
-
if (!tasksFile)
|
|
161
|
-
return [];
|
|
162
|
-
}
|
|
131
|
+
if (!tasksFile) return [];
|
|
163
132
|
|
|
164
133
|
const rawTasks = await readFile(assertInsidePack(packRoot, tasksFile), 'utf8');
|
|
165
134
|
const tasks = parseTasksYaml(rawTasks);
|
|
166
|
-
if (tasks.length === 0)
|
|
167
|
-
return [];
|
|
168
|
-
}
|
|
135
|
+
if (tasks.length === 0) return [];
|
|
169
136
|
|
|
170
137
|
const taskIds = tasks.map((task) => task.id);
|
|
171
138
|
const missingBefore = await missingBoardTasks(projectRoot, taskIds);
|
|
172
|
-
if (missingBefore.length === 0)
|
|
173
|
-
return [];
|
|
174
|
-
}
|
|
139
|
+
if (missingBefore.length === 0) return [];
|
|
175
140
|
|
|
176
141
|
const missing = new Set(missingBefore);
|
|
177
142
|
await seedPackageTasks(
|
|
@@ -184,7 +149,7 @@ export async function seedBoardTasks(
|
|
|
184
149
|
id: task.id,
|
|
185
150
|
title: task.title,
|
|
186
151
|
status: BoardTaskStatus.Todo,
|
|
187
|
-
description: formatTaskDescription(task
|
|
152
|
+
description: formatTaskDescription(task),
|
|
188
153
|
})),
|
|
189
154
|
);
|
|
190
155
|
|
|
@@ -196,8 +161,7 @@ export async function missingBoardTasks(
|
|
|
196
161
|
projectRoot: string,
|
|
197
162
|
taskIds: readonly string[],
|
|
198
163
|
): Promise<string[]> {
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
return taskIds.filter((taskId) => !boardHasTask(board, taskId)).sort();
|
|
164
|
+
if (taskIds.length === 0) return [];
|
|
165
|
+
const existingIds = new Set((await readAllChangeTasks(projectRoot)).map((task) => task.id));
|
|
166
|
+
return taskIds.filter((id) => !existingIds.has(id)).sort();
|
|
203
167
|
}
|
package/src/io/env.ts
CHANGED
|
@@ -25,8 +25,13 @@ function hasEnvVar(content: string, key: string): boolean {
|
|
|
25
25
|
return new RegExp(`^\\s*(?:export\\s+)?${escapeRegex(key)}\\s*=`, 'm').test(content);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
async function appendEnvVarsToFile(
|
|
29
|
-
|
|
28
|
+
async function appendEnvVarsToFile(
|
|
29
|
+
path: string,
|
|
30
|
+
vars: readonly string[],
|
|
31
|
+
createIfMissing = false,
|
|
32
|
+
): Promise<string[]> {
|
|
33
|
+
const existing = await readExisting(path);
|
|
34
|
+
const current = existing === undefined && createIfMissing ? '' : existing;
|
|
30
35
|
if (current === undefined) {
|
|
31
36
|
return [];
|
|
32
37
|
}
|
|
@@ -50,7 +55,7 @@ export async function appendEnvVars(
|
|
|
50
55
|
const results: EnvApplyResult[] = [];
|
|
51
56
|
|
|
52
57
|
for (const file of ['.env.example', '.env.local']) {
|
|
53
|
-
const added = await appendEnvVarsToFile(join(projectRoot, file), uniqueVars);
|
|
58
|
+
const added = await appendEnvVarsToFile(join(projectRoot, file), uniqueVars, file === '.env.example');
|
|
54
59
|
results.push({ file, added });
|
|
55
60
|
}
|
|
56
61
|
|
package/src/io/files.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { dirname, resolve, sep } from 'node:path';
|
|
3
|
+
import { dirname, extname, resolve, sep } from 'node:path';
|
|
4
4
|
import type { PackManifest } from '@forgeailab/spark-schema';
|
|
5
5
|
import type { AppSkillsConfig } from '../config.ts';
|
|
6
6
|
|
|
@@ -60,6 +60,20 @@ function hashContent(content: string): string {
|
|
|
60
60
|
return createHash('sha256').update(content).digest('hex');
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function commentSyntax(path: string): { start: string; end: string } {
|
|
64
|
+
const extension = extname(path).slice(1).toLowerCase();
|
|
65
|
+
|
|
66
|
+
if (['css', 'scss', 'less', 'js', 'cjs', 'mjs', 'ts', 'tsx', 'jsx', 'json5'].includes(extension)) {
|
|
67
|
+
return { start: '/* ', end: ' */' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (['html', 'htm', 'xml', 'svg', 'vue'].includes(extension)) {
|
|
71
|
+
return { start: '<!-- ', end: ' -->' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { start: '# ', end: '' };
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
64
78
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
79
|
}
|
|
@@ -114,15 +128,16 @@ export function renderTemplate(template: string, config: AppSkillsConfig): strin
|
|
|
114
128
|
);
|
|
115
129
|
}
|
|
116
130
|
|
|
117
|
-
function appendBlock(packName: string, content: string): { marker: string; block: string } {
|
|
131
|
+
function appendBlock(packName: string, content: string, destinationPath: string): { marker: string; block: string } {
|
|
118
132
|
const marker = `spark:${packName}`;
|
|
119
|
-
const
|
|
120
|
-
const
|
|
133
|
+
const { start, end } = commentSyntax(destinationPath);
|
|
134
|
+
const begin = `${start}>>> ${marker} >>>${end}`.trim();
|
|
135
|
+
const finish = `${start}<<< ${marker} <<<${end}`.trim();
|
|
121
136
|
const trimmedContent = content.replace(/\s+$/u, '');
|
|
122
137
|
|
|
123
138
|
return {
|
|
124
139
|
marker,
|
|
125
|
-
block: `${begin}\n${trimmedContent}\n${
|
|
140
|
+
block: `${begin}\n${trimmedContent}\n${finish}\n`,
|
|
126
141
|
};
|
|
127
142
|
}
|
|
128
143
|
|
|
@@ -189,10 +204,10 @@ export async function applyFileOperation(
|
|
|
189
204
|
}
|
|
190
205
|
|
|
191
206
|
if (operation.mode === 'append') {
|
|
192
|
-
const { marker, block } = appendBlock(options.packName, sourceContent);
|
|
207
|
+
const { marker, block } = appendBlock(options.packName, sourceContent, destination);
|
|
193
208
|
const current = (await fileExists(destination)) ? await readFile(destination, 'utf8') : '';
|
|
194
209
|
|
|
195
|
-
if (current.includes(
|
|
210
|
+
if (current.includes(marker)) {
|
|
196
211
|
return {
|
|
197
212
|
to: operation.to,
|
|
198
213
|
mode: operation.mode,
|
package/packs/README.md
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
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
|
-
| Pack | Category |
|
|
95
|
-
|---|---|
|
|
96
|
-
| `auth-better-auth` | auth |
|
|
97
|
-
| `auth-better-auth-pg` | auth |
|
|
98
|
-
| `auth-supabase` | auth |
|
|
99
|
-
| `db-sqlite` | db |
|
|
100
|
-
| `db-postgres` | db |
|
|
101
|
-
| `db-supabase` | db |
|
|
102
|
-
| `sync-zero` | infra |
|
|
103
|
-
| `payments-stripe` | payments |
|
|
104
|
-
| `ai-anthropic` | ai |
|
|
105
|
-
| `ai-openai` | ai |
|
|
106
|
-
| `ui-shadcn` | ui |
|
|
107
|
-
| `email-resend` | email |
|
|
108
|
-
| `analytics-posthog` | analytics |
|
|
109
|
-
| `docker-compose-dev` | infra |
|
|
110
|
-
| `testing-playwright` | testing |
|
|
111
|
-
| `deploy-vercel` | deploy |
|
|
112
|
-
|
|
113
|
-
### Picking a db + auth pair
|
|
114
|
-
|
|
115
|
-
`auth-better-auth` and `auth-better-auth-pg` share the same Better Auth factory
|
|
116
|
-
code in their generated `lib/auth.ts` templates — they differ only in the
|
|
117
|
-
`provider:` handed to `drizzleAdapter`. Pair them:
|
|
118
|
-
|
|
119
|
-
- `db-sqlite` + `auth-better-auth` — fastest path, single file db, no infra.
|
|
120
|
-
- `db-postgres` + `auth-better-auth-pg` — production-shaped, **required for `sync-zero`** (Zero needs Postgres logical replication).
|
|
121
|
-
- `db-supabase` + `auth-better-auth-pg` — Supabase-hosted Postgres + Better Auth on top.
|
|
122
|
-
|
|
123
|
-
The two auth packs both `conflicts = ["auth"]`, so the resolver prevents
|
|
124
|
-
installing both. Mixing wrong pairs (e.g. `db-sqlite` + `auth-better-auth-pg`)
|
|
125
|
-
typechecks but fails at runtime — the drizzle adapter will reject sqlite tables
|
|
126
|
-
with `provider: 'pg'`.
|
|
127
|
-
|
|
128
|
-
The copy-mode packs were authored against [`reference/full-stack-saas/`](../reference/full-stack-saas/) — the canonical integration showing the copied templates working together. When debugging one of these packs, start there.
|
|
129
|
-
|
|
130
|
-
See the root `README.md` for the catalog summary.
|
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
-
|
|
3
|
-
export function createAnthropicClient(
|
|
4
|
-
apiKey: string,
|
|
5
|
-
options?: ConstructorParameters<typeof Anthropic>[0],
|
|
6
|
-
): Anthropic {
|
|
7
|
-
return new Anthropic({ apiKey, ...options });
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const encoder = new TextEncoder();
|
|
11
|
-
|
|
12
|
-
function isContentBlockDeltaEvent(event: unknown): event is { type: 'content_block_delta' } {
|
|
13
|
-
return (
|
|
14
|
-
typeof event === 'object' &&
|
|
15
|
-
event !== null &&
|
|
16
|
-
(event as { type?: unknown }).type === 'content_block_delta'
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function encodeSse(payload: unknown) {
|
|
21
|
-
return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function streamResponse(
|
|
25
|
-
client: Anthropic,
|
|
26
|
-
params: Parameters<Anthropic['messages']['stream']>[0],
|
|
27
|
-
): ReadableStream<Uint8Array> {
|
|
28
|
-
return new ReadableStream<Uint8Array>({
|
|
29
|
-
async start(controller) {
|
|
30
|
-
try {
|
|
31
|
-
const stream = client.messages.stream(params);
|
|
32
|
-
|
|
33
|
-
for await (const event of stream) {
|
|
34
|
-
if (isContentBlockDeltaEvent(event)) {
|
|
35
|
-
controller.enqueue(encodeSse(event));
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
|
40
|
-
controller.close();
|
|
41
|
-
} catch (error) {
|
|
42
|
-
controller.error(error);
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function requireEnv(name: string): string {
|
|
49
|
-
const value = process.env[name];
|
|
50
|
-
if (!value) {
|
|
51
|
-
throw new Error(`Missing required environment variable: ${name}`);
|
|
52
|
-
}
|
|
53
|
-
return value;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export const anthropic = createAnthropicClient(requireEnv('ANTHROPIC_API_KEY'));
|
|
57
|
-
|
|
58
|
-
export type AnthropicChatMessage = Anthropic.Messages.MessageParam;
|
|
@@ -1,31 +0,0 @@
|
|
|
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
|
-
[dependencies]
|
|
12
|
-
runtime = ["@anthropic-ai/sdk"]
|
|
13
|
-
|
|
14
|
-
[env]
|
|
15
|
-
required = ["ANTHROPIC_API_KEY"]
|
|
16
|
-
|
|
17
|
-
[[files]]
|
|
18
|
-
mode = "create"
|
|
19
|
-
from = "lib/anthropic.ts"
|
|
20
|
-
to = "lib/anthropic.ts"
|
|
21
|
-
|
|
22
|
-
[[files]]
|
|
23
|
-
mode = "create"
|
|
24
|
-
from = "app/api/ai/route.ts"
|
|
25
|
-
to = "app/api/ai/route.ts"
|
|
26
|
-
|
|
27
|
-
[skills]
|
|
28
|
-
copy = ["skills/ai-feature-patterns"]
|
|
29
|
-
|
|
30
|
-
[tasks]
|
|
31
|
-
file = "tasks.yaml"
|
|
@@ -1,87 +0,0 @@
|
|
|
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.
|