@forgeailab/spark 0.1.3 → 0.2.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.
@@ -0,0 +1,233 @@
1
+ type FrontmatterBlockMap = Map<string, string[]>;
2
+
3
+ const rawBlocksSymbol = Symbol.for('@forgeailab/spark-skill-utils.rawBlocks');
4
+ const requiredFrontmatterKeys = ['name', 'description'] as const;
5
+ const codexFrontmatterKeys = ['name', 'description', 'model'] as const;
6
+
7
+ type FrontmatterWithBlocks = Record<string, unknown> & {
8
+ [rawBlocksSymbol]?: FrontmatterBlockMap;
9
+ };
10
+
11
+ export type ParsedSkillMarkdown = {
12
+ frontmatter: Record<string, unknown>;
13
+ body: string;
14
+ };
15
+
16
+ function hasOwn(record: Record<string, unknown>, key: string): boolean {
17
+ return Object.prototype.hasOwnProperty.call(record, key);
18
+ }
19
+
20
+ function attachRawBlocks<T extends Record<string, unknown>>(
21
+ frontmatter: T,
22
+ blocks: FrontmatterBlockMap,
23
+ ): T {
24
+ Object.defineProperty(frontmatter, rawBlocksSymbol, {
25
+ value: blocks,
26
+ enumerable: false,
27
+ });
28
+
29
+ return frontmatter;
30
+ }
31
+
32
+ function rawBlocks(frontmatter: Record<string, unknown>): FrontmatterBlockMap | undefined {
33
+ return (frontmatter as FrontmatterWithBlocks)[rawBlocksSymbol];
34
+ }
35
+
36
+ function splitFrontmatter(source: string): { frontmatter: string; body: string } {
37
+ const normalized = source.replace(/\r\n/g, '\n');
38
+ const lines = normalized.split('\n');
39
+
40
+ if (lines[0] !== '---') {
41
+ throw new Error('SKILL.md must start with YAML frontmatter');
42
+ }
43
+
44
+ const closingIndex = lines.findIndex((line, index) => index > 0 && line === '---');
45
+ if (closingIndex === -1) {
46
+ throw new Error('SKILL.md frontmatter is missing a closing --- delimiter');
47
+ }
48
+
49
+ return {
50
+ frontmatter: lines.slice(1, closingIndex).join('\n'),
51
+ body: lines.slice(closingIndex + 1).join('\n'),
52
+ };
53
+ }
54
+
55
+ function parseScalar(value: string): unknown {
56
+ const trimmed = value.trim();
57
+
58
+ if (trimmed === 'true') {
59
+ return true;
60
+ }
61
+
62
+ if (trimmed === 'false') {
63
+ return false;
64
+ }
65
+
66
+ return trimmed;
67
+ }
68
+
69
+ function parseBlockValue(lines: string[]): unknown {
70
+ const [firstLine, ...rest] = lines;
71
+ const valueStart = firstLine.indexOf(':') + 1;
72
+ const firstValue = firstLine.slice(valueStart);
73
+
74
+ if (rest.length === 0) {
75
+ return parseScalar(firstValue);
76
+ }
77
+
78
+ if (
79
+ firstValue.trim() === '' &&
80
+ rest.every((line) => line.trim() === '' || /^\s*-\s+/u.test(line))
81
+ ) {
82
+ return rest
83
+ .filter((line) => line.trim() !== '')
84
+ .map((line) => parseScalar(line.replace(/^\s*-\s+/u, '')));
85
+ }
86
+
87
+ return [firstValue.trim(), ...rest].join('\n');
88
+ }
89
+
90
+ function parseFrontmatterBlocks(frontmatter: string): Record<string, unknown> {
91
+ const blocks: FrontmatterBlockMap = new Map();
92
+ const parsed: Record<string, unknown> = {};
93
+ let currentKey: string | undefined;
94
+
95
+ for (const line of frontmatter.split('\n')) {
96
+ const match = /^([A-Za-z][A-Za-z0-9_-]*):(.*)$/u.exec(line);
97
+
98
+ if (match) {
99
+ currentKey = match[1];
100
+ if (blocks.has(currentKey)) {
101
+ throw new Error(`Duplicate frontmatter key: ${currentKey}`);
102
+ }
103
+ blocks.set(currentKey, [`${currentKey}:${match[2]}`]);
104
+ continue;
105
+ }
106
+
107
+ if (!currentKey) {
108
+ if (line.trim() === '' || line.trim().startsWith('#')) {
109
+ continue;
110
+ }
111
+ throw new Error(`Unexpected frontmatter line before a key: ${line}`);
112
+ }
113
+
114
+ blocks.get(currentKey)?.push(line);
115
+ }
116
+
117
+ for (const key of requiredFrontmatterKeys) {
118
+ if (!blocks.has(key)) {
119
+ throw new Error(`Missing required frontmatter key: ${key}`);
120
+ }
121
+ }
122
+
123
+ for (const [key, lines] of blocks) {
124
+ parsed[key] = parseBlockValue(lines);
125
+ }
126
+
127
+ return attachRawBlocks(parsed, blocks);
128
+ }
129
+
130
+ function copyRawBlock(
131
+ source: FrontmatterBlockMap | undefined,
132
+ target: FrontmatterBlockMap,
133
+ key: string,
134
+ ): void {
135
+ const block = source?.get(key);
136
+ if (block) {
137
+ target.set(key, [...block]);
138
+ }
139
+ }
140
+
141
+ function assertRequiredFrontmatter(frontmatter: Record<string, unknown>): void {
142
+ for (const key of requiredFrontmatterKeys) {
143
+ if (!hasOwn(frontmatter, key)) {
144
+ throw new Error(`Missing required frontmatter key: ${key}`);
145
+ }
146
+ }
147
+ }
148
+
149
+ function serializeScalar(value: unknown): string {
150
+ if (typeof value === 'string') {
151
+ return value;
152
+ }
153
+
154
+ return String(value);
155
+ }
156
+
157
+ function serializeKeyValue(key: string, value: unknown): string[] {
158
+ if (Array.isArray(value)) {
159
+ return [`${key}:`, ...value.map((item) => ` - ${serializeScalar(item)}`)];
160
+ }
161
+
162
+ if (typeof value === 'string' && value.includes('\n')) {
163
+ const [firstLine, ...rest] = value.split('\n');
164
+ return [`${key}: ${firstLine}`, ...rest];
165
+ }
166
+
167
+ return [`${key}: ${serializeScalar(value)}`];
168
+ }
169
+
170
+ export function parseSkillFrontmatter(source: string): ParsedSkillMarkdown {
171
+ const { frontmatter, body } = splitFrontmatter(source);
172
+
173
+ return {
174
+ frontmatter: parseFrontmatterBlocks(frontmatter),
175
+ body,
176
+ };
177
+ }
178
+
179
+ export function toCodexFrontmatter(claude: Record<string, unknown>): Record<string, unknown> {
180
+ assertRequiredFrontmatter(claude);
181
+
182
+ const sourceBlocks = rawBlocks(claude);
183
+ const codex: Record<string, unknown> = {};
184
+ const codexBlocks: FrontmatterBlockMap = new Map();
185
+
186
+ for (const key of codexFrontmatterKeys) {
187
+ if (!hasOwn(claude, key)) {
188
+ continue;
189
+ }
190
+
191
+ codex[key] = claude[key];
192
+ copyRawBlock(sourceBlocks, codexBlocks, key);
193
+ }
194
+
195
+ return attachRawBlocks(codex, codexBlocks);
196
+ }
197
+
198
+ export function toClaudeFrontmatter(codex: Record<string, unknown>): Record<string, unknown> {
199
+ assertRequiredFrontmatter(codex);
200
+
201
+ const sourceBlocks = rawBlocks(codex);
202
+ const claude: Record<string, unknown> = {};
203
+ const claudeBlocks: FrontmatterBlockMap = new Map();
204
+
205
+ for (const key of Object.keys(codex)) {
206
+ claude[key] = codex[key];
207
+ copyRawBlock(sourceBlocks, claudeBlocks, key);
208
+ }
209
+
210
+ return attachRawBlocks(claude, claudeBlocks);
211
+ }
212
+
213
+ export function serializeSkillFrontmatter(
214
+ frontmatter: Record<string, unknown>,
215
+ options: { trailingComments?: readonly string[] } = {},
216
+ ): string {
217
+ const blocks = rawBlocks(frontmatter);
218
+ const output: string[] = [];
219
+
220
+ for (const key of Object.keys(frontmatter)) {
221
+ const block = blocks?.get(key);
222
+ if (block) {
223
+ output.push(...block);
224
+ continue;
225
+ }
226
+
227
+ output.push(...serializeKeyValue(key, frontmatter[key]));
228
+ }
229
+
230
+ output.push(...(options.trailingComments ?? []));
231
+
232
+ return output.join('\n');
233
+ }
@@ -0,0 +1,89 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { StateFileSchema, type StateFile, type StateInstalledPack } from '@forgeailab/spark-schema';
4
+
5
+ function stateFilePath(projectRoot: string): string {
6
+ return join(projectRoot, '.spark', 'state.json');
7
+ }
8
+
9
+ function initialState(): StateFile {
10
+ return StateFileSchema.parse({
11
+ schema_version: 1,
12
+ });
13
+ }
14
+
15
+ function uniqueSorted(values: readonly string[]): string[] {
16
+ return [...new Set(values)].sort();
17
+ }
18
+
19
+ function normalizeInstalledPack(pack: StateInstalledPack): StateInstalledPack {
20
+ return {
21
+ ...pack,
22
+ files: uniqueSorted(pack.files),
23
+ appended_blocks: [...pack.appended_blocks].sort((left, right) =>
24
+ `${left.to}:${left.marker}`.localeCompare(`${right.to}:${right.marker}`),
25
+ ),
26
+ env: uniqueSorted(pack.env),
27
+ tasks: uniqueSorted(pack.tasks),
28
+ };
29
+ }
30
+
31
+ function normalizeState(state: StateFile): StateFile {
32
+ const parsed = StateFileSchema.parse(state);
33
+
34
+ return {
35
+ schema_version: 1,
36
+ installed_packs: [...parsed.installed_packs]
37
+ .map(normalizeInstalledPack)
38
+ .sort((left, right) => left.name.localeCompare(right.name)),
39
+ };
40
+ }
41
+
42
+ function errorMessage(error: unknown): string {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+
46
+ function parseStateFile(path: string, raw: string): StateFile {
47
+ try {
48
+ return normalizeState(StateFileSchema.parse(JSON.parse(raw)));
49
+ } catch (error) {
50
+ throw new Error(`Failed to parse state file at ${path}: ${errorMessage(error)}`, {
51
+ cause: error,
52
+ });
53
+ }
54
+ }
55
+
56
+ export async function readState(projectRoot: string): Promise<StateFile> {
57
+ const path = stateFilePath(projectRoot);
58
+ let raw: string;
59
+
60
+ try {
61
+ raw = await readFile(path, 'utf8');
62
+ } catch (error) {
63
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
64
+ return initialState();
65
+ }
66
+ throw error;
67
+ }
68
+
69
+ return parseStateFile(path, raw);
70
+ }
71
+
72
+ export async function writeState(projectRoot: string, state: StateFile): Promise<void> {
73
+ const path = stateFilePath(projectRoot);
74
+ const parsed = normalizeState(state);
75
+
76
+ await mkdir(dirname(path), { recursive: true });
77
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`);
78
+ }
79
+
80
+ export async function withState(
81
+ projectRoot: string,
82
+ mutator: (state: StateFile) => StateFile | Promise<StateFile>,
83
+ ): Promise<StateFile> {
84
+ const current = await readState(projectRoot);
85
+ const next = normalizeState(await mutator(current));
86
+
87
+ await writeState(projectRoot, next);
88
+ return next;
89
+ }
package/src/io/board.ts CHANGED
@@ -3,20 +3,20 @@ import { join, resolve, sep } from 'node:path';
3
3
  import {
4
4
  BoardTaskStatus,
5
5
  seedTasks as seedPackageTasks,
6
- } from '@forgeailab/spark-board';
6
+ } from '../internal/board';
7
7
 
8
8
  export {
9
9
  BoardTaskStatus,
10
10
  readBoard,
11
11
  seedTasks,
12
12
  updateStatus,
13
- } from '@forgeailab/spark-board';
13
+ } from '../internal/board';
14
14
  export type {
15
15
  Board,
16
16
  BoardEpic,
17
17
  BoardTask as ParsedBoardTask,
18
18
  SeedTask,
19
- } from '@forgeailab/spark-board';
19
+ } from '../internal/board';
20
20
 
21
21
  export type BoardTask = {
22
22
  id: string;
package/src/io/skills.ts CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  parseSkillFrontmatter,
5
5
  serializeSkillFrontmatter,
6
6
  toCodexFrontmatter,
7
- } from '@forgeailab/spark-skill-utils';
7
+ } from '../internal/skill-utils';
8
8
 
9
9
  type SkillCopyRecord = {
10
10
  claudeFiles: string[];
package/src/io/state.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { StateFileSchema, type StateFile, type StateInstalledPack } from '@forgeailab/spark-schema';
3
- export { readState, writeState, withState } from '@forgeailab/spark-state';
3
+ export { readState, writeState, withState } from '../internal/state';
4
4
 
5
5
  export function emptyState(): StateFile {
6
6
  return StateFileSchema.parse({
@@ -1,132 +0,0 @@
1
- import { readFile, stat } from 'node:fs/promises';
2
- import { join, resolve } from 'node:path';
3
- import type { PackManifest } from '@forgeailab/spark-schema';
4
-
5
- type RuntimePackage = NonNullable<PackManifest['runtime_package']>;
6
-
7
- type PackageJson = {
8
- dependencies?: Record<string, string>;
9
- devDependencies?: Record<string, string>;
10
- };
11
-
12
- function isRecord(value: unknown): value is Record<string, unknown> {
13
- return typeof value === 'object' && value !== null && !Array.isArray(value);
14
- }
15
-
16
- function stringRecord(value: unknown): Record<string, string> | undefined {
17
- if (!isRecord(value)) {
18
- return undefined;
19
- }
20
-
21
- const entries = Object.entries(value).filter(
22
- (entry): entry is [string, string] => typeof entry[1] === 'string',
23
- );
24
- return Object.fromEntries(entries);
25
- }
26
-
27
- async function fileExists(path: string): Promise<boolean> {
28
- try {
29
- const info = await stat(path);
30
- return info.isFile();
31
- } catch (error) {
32
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
33
- return false;
34
- }
35
- throw error;
36
- }
37
- }
38
-
39
- function helperDirectoryName(packageName: string): string {
40
- return packageName.split('/').at(-1) ?? packageName;
41
- }
42
-
43
- function packageNameFromSpecifier(specifier: string): string {
44
- const trimmed = specifier.trim();
45
- if (trimmed.startsWith('@')) {
46
- const slashIndex = trimmed.indexOf('/');
47
- if (slashIndex === -1) {
48
- return trimmed;
49
- }
50
-
51
- const versionIndex = trimmed.indexOf('@', slashIndex + 1);
52
- return versionIndex === -1 ? trimmed : trimmed.slice(0, versionIndex);
53
- }
54
-
55
- const versionIndex = trimmed.indexOf('@');
56
- return versionIndex === -1 ? trimmed : trimmed.slice(0, versionIndex);
57
- }
58
-
59
- async function readPackageJson(projectRoot: string): Promise<PackageJson> {
60
- let raw: string;
61
- try {
62
- raw = await readFile(join(projectRoot, 'package.json'), 'utf8');
63
- } catch (error) {
64
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
65
- return {};
66
- }
67
- throw error;
68
- }
69
-
70
- const parsed = JSON.parse(raw) as unknown;
71
- if (!isRecord(parsed)) {
72
- return {};
73
- }
74
-
75
- return {
76
- dependencies: stringRecord(parsed.dependencies),
77
- devDependencies: stringRecord(parsed.devDependencies),
78
- };
79
- }
80
-
81
- export function assertRuntimeHelperNotRedeclared(packName: string, manifest: PackManifest): void {
82
- const runtimePackage = manifest.runtime_package;
83
- if (!runtimePackage) {
84
- return;
85
- }
86
-
87
- const duplicate = (manifest.dependencies?.runtime ?? []).find(
88
- (specifier) => packageNameFromSpecifier(specifier) === runtimePackage.package,
89
- );
90
- if (!duplicate) {
91
- return;
92
- }
93
-
94
- throw new Error(
95
- `${packName} declares runtime helper ${runtimePackage.package} in both [runtime_package] and [dependencies].runtime (${duplicate}). Decision 6 requires declaring the helper only in [runtime_package].`,
96
- );
97
- }
98
-
99
- export async function resolveRuntimeHelper(manifest: PackManifest): Promise<string | undefined> {
100
- const runtimePackage = manifest.runtime_package;
101
- if (!runtimePackage) {
102
- return undefined;
103
- }
104
-
105
- const sparkRoot = process.env.SPARK_ROOT?.trim();
106
- if (sparkRoot) {
107
- const helperDir = resolve(sparkRoot, 'libs', helperDirectoryName(runtimePackage.package));
108
- if (await fileExists(join(helperDir, 'package.json'))) {
109
- return `file:${helperDir}`;
110
- }
111
- }
112
-
113
- return `${runtimePackage.package}@${runtimePackage.version}`;
114
- }
115
-
116
- export async function installedRuntimeHelperSpecifier(
117
- projectRoot: string,
118
- runtimePackage: RuntimePackage,
119
- ): Promise<string | undefined> {
120
- const packageJson = await readPackageJson(projectRoot);
121
- return (
122
- packageJson.dependencies?.[runtimePackage.package] ??
123
- packageJson.devDependencies?.[runtimePackage.package]
124
- );
125
- }
126
-
127
- export async function formatResolvedRuntimeHelper(
128
- projectRoot: string,
129
- runtimePackage: RuntimePackage,
130
- ): Promise<string> {
131
- return (await installedRuntimeHelperSpecifier(projectRoot, runtimePackage)) ?? 'not installed';
132
- }