@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.
Files changed (138) hide show
  1. package/package.json +8 -6
  2. package/src/commands/add.ts +3 -1
  3. package/src/commands/preset.ts +1 -0
  4. package/src/internal/board.ts +266 -304
  5. package/src/io/board.ts +29 -65
  6. package/src/io/env.ts +8 -3
  7. package/src/io/files.ts +22 -7
  8. package/packs/README.md +0 -130
  9. package/packs/ai-anthropic/files/app/api/ai/route.ts +0 -57
  10. package/packs/ai-anthropic/files/lib/anthropic.ts +0 -58
  11. package/packs/ai-anthropic/pack.toml +0 -31
  12. package/packs/ai-anthropic/skills/ai-feature-patterns/SKILL.md +0 -87
  13. package/packs/ai-anthropic/tasks.yaml +0 -9
  14. package/packs/ai-openai/files/app/api/ai-openai/route.ts +0 -55
  15. package/packs/ai-openai/files/lib/openai.ts +0 -21
  16. package/packs/ai-openai/pack.toml +0 -30
  17. package/packs/ai-openai/tasks.yaml +0 -9
  18. package/packs/analytics-posthog/files/components/PostHogProvider.tsx +0 -19
  19. package/packs/analytics-posthog/files/lib/posthog/client.ts +0 -20
  20. package/packs/analytics-posthog/files/lib/posthog/server.ts +0 -24
  21. package/packs/analytics-posthog/pack.toml +0 -35
  22. package/packs/analytics-posthog/tasks.yaml +0 -15
  23. package/packs/auth-better-auth/files/app/(auth)/login/page.tsx +0 -58
  24. package/packs/auth-better-auth/files/app/api/auth/[...all]/route.ts +0 -4
  25. package/packs/auth-better-auth/files/lib/auth.ts +0 -60
  26. package/packs/auth-better-auth/pack.toml +0 -28
  27. package/packs/auth-better-auth/tasks.yaml +0 -10
  28. package/packs/auth-better-auth-pg/files/app/api/auth/[...all]/route.ts +0 -4
  29. package/packs/auth-better-auth-pg/files/lib/auth.ts +0 -125
  30. package/packs/auth-better-auth-pg/pack.toml +0 -28
  31. package/packs/auth-better-auth-pg/tasks.yaml +0 -17
  32. package/packs/auth-supabase/files/app/(auth)/login/page.tsx +0 -64
  33. package/packs/auth-supabase/files/app/auth/callback/route.ts +0 -15
  34. package/packs/auth-supabase/files/middleware.ts +0 -41
  35. package/packs/auth-supabase/pack.toml +0 -34
  36. package/packs/auth-supabase/tasks.yaml +0 -10
  37. package/packs/db-postgres/files/compose/postgres.yml +0 -28
  38. package/packs/db-postgres/files/docker-compose.include.yml +0 -1
  39. package/packs/db-postgres/files/docker-compose.yml +0 -6
  40. package/packs/db-postgres/files/drizzle.config.ts +0 -10
  41. package/packs/db-postgres/files/lib/db/index.ts +0 -10
  42. package/packs/db-postgres/files/lib/db/schema.ts +0 -11
  43. package/packs/db-postgres/pack.toml +0 -53
  44. package/packs/db-postgres/tasks.yaml +0 -11
  45. package/packs/db-sqlite/files/drizzle.config.ts +0 -10
  46. package/packs/db-sqlite/files/lib/db.ts +0 -8
  47. package/packs/db-sqlite/files/lib/schema.ts +0 -13
  48. package/packs/db-sqlite/pack.toml +0 -34
  49. package/packs/db-sqlite/tasks.yaml +0 -6
  50. package/packs/db-supabase/files/lib/supabase/client.ts +0 -8
  51. package/packs/db-supabase/files/lib/supabase/server.ts +0 -27
  52. package/packs/db-supabase/pack.toml +0 -32
  53. package/packs/db-supabase/skills/supabase-patterns/SKILL.md +0 -82
  54. package/packs/db-supabase/tasks.yaml +0 -6
  55. package/packs/deploy-vercel/files/docs/deploy.md +0 -21
  56. package/packs/deploy-vercel/files/vercel.json +0 -4
  57. package/packs/deploy-vercel/pack.toml +0 -30
  58. package/packs/deploy-vercel/tasks.yaml +0 -14
  59. package/packs/docker-compose-dev/files/.env.docker.example +0 -2
  60. package/packs/docker-compose-dev/files/compose/redis.yml +0 -17
  61. package/packs/docker-compose-dev/files/docker-compose.include.yml +0 -1
  62. package/packs/docker-compose-dev/files/docker-compose.yml +0 -6
  63. package/packs/docker-compose-dev/pack.toml +0 -38
  64. package/packs/docker-compose-dev/tasks.yaml +0 -9
  65. package/packs/email-resend/files/app/api/email/test/route.ts +0 -38
  66. package/packs/email-resend/files/emails/welcome.tsx +0 -66
  67. package/packs/email-resend/files/lib/email.ts +0 -40
  68. package/packs/email-resend/pack.toml +0 -34
  69. package/packs/email-resend/tasks.yaml +0 -9
  70. package/packs/example/pack.toml +0 -69
  71. package/packs/payments-stripe/files/app/api/billing-portal/route.ts +0 -24
  72. package/packs/payments-stripe/files/app/api/checkout/route.ts +0 -58
  73. package/packs/payments-stripe/files/app/api/webhooks/stripe/route.ts +0 -84
  74. package/packs/payments-stripe/files/lib/stripe.ts +0 -158
  75. package/packs/payments-stripe/pack.toml +0 -45
  76. package/packs/payments-stripe/skills/stripe-patterns/SKILL.md +0 -93
  77. package/packs/payments-stripe/tasks.yaml +0 -16
  78. package/packs/sync-zero/files/components/ZeroProvider.tsx +0 -13
  79. package/packs/sync-zero/files/compose/zero-cache.yml +0 -26
  80. package/packs/sync-zero/files/docker-compose.include.yml +0 -1
  81. package/packs/sync-zero/files/docker-compose.yml +0 -6
  82. package/packs/sync-zero/files/lib/zero/client.ts +0 -18
  83. package/packs/sync-zero/files/lib/zero/schema.ts +0 -30
  84. package/packs/sync-zero/files/zero.config.ts +0 -26
  85. package/packs/sync-zero/pack.toml +0 -57
  86. package/packs/sync-zero/skills/zero-patterns/SKILL.md +0 -69
  87. package/packs/sync-zero/tasks.yaml +0 -16
  88. package/packs/testing-playwright/files/e2e/example.spec.ts +0 -7
  89. package/packs/testing-playwright/files/playwright.config.ts +0 -33
  90. package/packs/testing-playwright/pack.toml +0 -25
  91. package/packs/testing-playwright/tasks.yaml +0 -9
  92. package/packs/ui-shadcn/files/app/globals.css +0 -56
  93. package/packs/ui-shadcn/files/components/ui/button.tsx +0 -47
  94. package/packs/ui-shadcn/files/components/ui/card.tsx +0 -33
  95. package/packs/ui-shadcn/files/lib/utils.ts +0 -6
  96. package/packs/ui-shadcn/files/postcss.config.mjs +0 -7
  97. package/packs/ui-shadcn/files/tailwind.config.ts +0 -57
  98. package/packs/ui-shadcn/pack.toml +0 -44
  99. package/packs/ui-shadcn/skills/shadcn-dashboard-patterns/SKILL.md +0 -85
  100. package/packs/ui-shadcn/tasks.yaml +0 -6
  101. package/presets/docs-site.toml +0 -4
  102. package/presets/internal-tool.toml +0 -4
  103. package/presets/lean-saas.toml +0 -4
  104. package/presets/local-ai-mvp.toml +0 -4
  105. package/presets/saas-classic.toml +0 -4
  106. package/templates/README.md +0 -43
  107. package/templates/astro/README.md +0 -3
  108. package/templates/astro/template.toml +0 -4
  109. package/templates/astro-starlight/README.md +0 -3
  110. package/templates/astro-starlight/template.toml +0 -4
  111. package/templates/nextjs/.ai/architecture.md +0 -13
  112. package/templates/nextjs/.ai/board.md +0 -7
  113. package/templates/nextjs/.ai/product-spec.md +0 -11
  114. package/templates/nextjs/.claude/skills/.gitkeep +0 -0
  115. package/templates/nextjs/.codex/skills/.gitkeep +0 -0
  116. package/templates/nextjs/AGENTS.md +0 -95
  117. package/templates/nextjs/CLAUDE.md +0 -3
  118. package/templates/nextjs/README.md +0 -20
  119. package/templates/nextjs/app/(app)/home/page.tsx +0 -43
  120. package/templates/nextjs/app/(app)/home/posts-panel.tsx +0 -83
  121. package/templates/nextjs/app/(app)/layout.tsx +0 -12
  122. package/templates/nextjs/app/(auth)/login/page.tsx +0 -97
  123. package/templates/nextjs/app/globals.css +0 -23
  124. package/templates/nextjs/app/layout.tsx +0 -20
  125. package/templates/nextjs/app/page.tsx +0 -39
  126. package/templates/nextjs/lib/auth-placeholder.ts +0 -21
  127. package/templates/nextjs/lib/posts-placeholder.ts +0 -30
  128. package/templates/nextjs/next.config.ts +0 -5
  129. package/templates/nextjs/package.json +0 -26
  130. package/templates/nextjs/postcss.config.mjs +0 -7
  131. package/templates/nextjs/spark.config.json +0 -4
  132. package/templates/nextjs/template.toml +0 -4
  133. package/templates/nextjs/tsconfig.json +0 -27
  134. package/templates/nextjs/types/post.ts +0 -13
  135. package/templates/one/README.md +0 -5
  136. package/templates/one/template.toml +0 -4
  137. package/templates/vite-react/README.md +0 -3
  138. 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 { join, resolve, sep } from 'node:path';
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, packName),
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
- const boardPath = join(projectRoot, '.ai', 'board.md');
200
- const board = await readExisting(boardPath);
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(path: string, vars: readonly string[]): Promise<string[]> {
29
- const current = await readExisting(path);
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 begin = `# >>> ${marker} >>>`;
120
- const end = `# <<< ${marker} <<<`;
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${end}\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(`# >>> ${marker} >>>`)) {
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.
@@ -1,9 +0,0 @@
1
- epic: AI
2
-
3
- tasks:
4
- - id: AI-001
5
- title: Add cost guardrails (token caps, rate limits)
6
- status: Clarifying
7
- acceptance:
8
- - AI requests have server-side max token caps.
9
- - Repeated requests are rate limited before calling Anthropic.