@forgeailab/spark 0.4.1 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeailab/spark",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "CLI for managing feature packs in an spark-scaffolded project.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,7 +23,7 @@
23
23
  "typecheck": "tsc --noEmit"
24
24
  },
25
25
  "dependencies": {
26
- "@forgeailab/spark-schema": "0.4.1",
26
+ "@forgeailab/spark-schema": "0.4.2",
27
27
  "@clack/prompts": "latest",
28
28
  "citty": "latest",
29
29
  "picocolors": "latest",
package/src/cli.ts CHANGED
@@ -5,6 +5,8 @@ import { checkCommand } from './commands/check.ts';
5
5
  import { infoCommand } from './commands/info.ts';
6
6
  import { listCommand } from './commands/list.ts';
7
7
  import { presetCommand } from './commands/preset.ts';
8
+ import { statusCommand } from './commands/status.ts';
9
+ import { validateCommand } from './commands/validate.ts';
8
10
 
9
11
  const subCommands = {
10
12
  list: listCommand,
@@ -12,6 +14,8 @@ const subCommands = {
12
14
  check: checkCommand,
13
15
  add: addCommand,
14
16
  preset: presetCommand,
17
+ status: statusCommand,
18
+ validate: validateCommand,
15
19
  };
16
20
 
17
21
  const mainCommand = defineCommand({
@@ -100,10 +100,10 @@ function renderPlan(
100
100
  }
101
101
 
102
102
  if (runtimeDependencies.length > 0) {
103
- lines.push(`runtime deps: ${[...new Set(runtimeDependencies)].sort().join(', ')}`);
103
+ lines.push(`runtime deps: ${[...new Set(runtimeDependencies)].toSorted().join(', ')}`);
104
104
  }
105
105
  if (devDependencies.length > 0) {
106
- lines.push(`dev deps: ${[...new Set(devDependencies)].sort().join(', ')}`);
106
+ lines.push(`dev deps: ${[...new Set(devDependencies)].toSorted().join(', ')}`);
107
107
  }
108
108
 
109
109
  return lines.join('\n');
@@ -181,7 +181,7 @@ function stateEntryForPack(
181
181
  return {
182
182
  name: packName,
183
183
  version,
184
- files: [...new Set(fileTargets)].sort(),
184
+ files: [...new Set(fileTargets)].toSorted(),
185
185
  appended_blocks: fileRecords
186
186
  .filter((record) => record.marker)
187
187
  .map((record) => ({
@@ -189,8 +189,8 @@ function stateEntryForPack(
189
189
  marker: record.marker ?? '',
190
190
  content_hash: record.contentHash,
191
191
  })),
192
- env: [...new Set(envVars)].sort(),
193
- tasks: [...new Set(taskIds)].sort(),
192
+ env: [...new Set(envVars)].toSorted(),
193
+ tasks: [...new Set(taskIds)].toSorted(),
194
194
  };
195
195
  }
196
196
 
@@ -33,7 +33,7 @@ async function fileExists(path: string): Promise<boolean> {
33
33
  }
34
34
 
35
35
  function escapeRegex(value: string): string {
36
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
37
  }
38
38
 
39
39
  function hasEnvVar(content: string, key: string): boolean {
@@ -59,9 +59,9 @@ export async function runCheck(
59
59
  const state = await readState(projectRoot);
60
60
  const recordedFiles = [
61
61
  ...new Set(state.installed_packs.flatMap((pack) => pack.files)),
62
- ].sort();
63
- const recordedEnv = [...new Set(state.installed_packs.flatMap((pack) => pack.env))].sort();
64
- const recordedTasks = [...new Set(state.installed_packs.flatMap((pack) => pack.tasks))].sort();
62
+ ].toSorted();
63
+ const recordedEnv = [...new Set(state.installed_packs.flatMap((pack) => pack.env))].toSorted();
64
+ const recordedTasks = [...new Set(state.installed_packs.flatMap((pack) => pack.tasks))].toSorted();
65
65
 
66
66
  const missingFiles: string[] = [];
67
67
  for (const file of recordedFiles) {
@@ -54,12 +54,12 @@ export async function runList(projectRoot = process.cwd(), output: ListOutput =
54
54
  return;
55
55
  }
56
56
 
57
- for (const [category, names] of [...byCategory.entries()].sort(([left], [right]) =>
57
+ for (const [category, names] of [...byCategory.entries()].toSorted(([left], [right]) =>
58
58
  left.localeCompare(right),
59
59
  )) {
60
60
  output.log(pc.bold(category));
61
61
  output.log('pack status scaffold');
62
- for (const name of names.sort()) {
62
+ for (const name of names.toSorted()) {
63
63
  const status = installed.has(name) ? pc.green('installed') : 'available';
64
64
  const annotation = scaffoldAnnotation(registry, config.template, name);
65
65
  output.log(`${name.padEnd(20)} ${status.padEnd(11)} ${annotation}`);
@@ -0,0 +1,32 @@
1
+ import { defineCommand } from 'citty';
2
+ import { readAllChangeTasks, renderBuildStatus } from '../io/board.ts';
3
+ import type { AggregatedTask } from '../io/board.ts';
4
+
5
+ export async function runStatus(
6
+ projectRoot = process.cwd(),
7
+ opts: { change?: string } = {},
8
+ ): Promise<string> {
9
+ const all: AggregatedTask[] = await readAllChangeTasks(projectRoot);
10
+ const filtered = opts.change ? all.filter((t) => t.changeId === opts.change) : all;
11
+ const view = renderBuildStatus(filtered);
12
+ console.log(view);
13
+ return view;
14
+ }
15
+
16
+ export const statusCommand = defineCommand({
17
+ meta: {
18
+ name: 'status',
19
+ description: 'Render the build-status view from tasks.md across active changes',
20
+ },
21
+ args: {
22
+ change: {
23
+ type: 'string',
24
+ description: 'Limit to one change id',
25
+ },
26
+ },
27
+ async run({ args }) {
28
+ await runStatus(process.cwd(), {
29
+ change: typeof args.change === 'string' ? args.change : undefined,
30
+ });
31
+ },
32
+ });
@@ -0,0 +1,281 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { defineCommand } from 'citty';
4
+ import pc from 'picocolors';
5
+ import { parseTasksMarkdown } from '../io/board.ts';
6
+
7
+ export type Violation = { file: string; line: number; message: string };
8
+
9
+ async function dirExists(p: string): Promise<boolean> {
10
+ try {
11
+ const s = await stat(p);
12
+ return s.isDirectory();
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ async function collectSpecFiles(dir: string): Promise<string[]> {
19
+ const results: string[] = [];
20
+ async function walk(current: string): Promise<void> {
21
+ let entries;
22
+ try {
23
+ entries = await readdir(current, { withFileTypes: true });
24
+ } catch {
25
+ return;
26
+ }
27
+ for (const entry of entries) {
28
+ const full = join(current, entry.name);
29
+ if (entry.isDirectory()) {
30
+ await walk(full);
31
+ } else if (entry.isFile() && entry.name === 'spec.md') {
32
+ results.push(full);
33
+ }
34
+ }
35
+ }
36
+ await walk(dir);
37
+ return results;
38
+ }
39
+
40
+ function isUnderArchive(filePath: string): boolean {
41
+ return filePath.split('/').includes('archive');
42
+ }
43
+
44
+ const DELTA_HEADERS = new Set([
45
+ '## ADDED Requirements',
46
+ '## MODIFIED Requirements',
47
+ '## REMOVED Requirements',
48
+ '## RENAMED Requirements',
49
+ ]);
50
+
51
+ function skipFrontmatterLines(lines: string[]): number {
52
+ if (lines.length === 0) return 0;
53
+ if (lines[0].trim() !== '---') return 0;
54
+ for (let i = 1; i < lines.length; i++) {
55
+ if (lines[i].trim() === '---') return i + 1;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ function checkDeltaHeader(relFile: string, lines: string[]): Violation | null {
61
+ const start = skipFrontmatterLines(lines);
62
+ for (let i = start; i < lines.length; i++) {
63
+ const trimmed = lines[i].trim();
64
+ if (trimmed.length === 0) continue;
65
+ if (!DELTA_HEADERS.has(trimmed)) {
66
+ return {
67
+ file: relFile,
68
+ line: i + 1,
69
+ message: `expected one of "## ADDED|MODIFIED|REMOVED|RENAMED Requirements" as first non-empty line, got: "${trimmed}"`,
70
+ };
71
+ }
72
+ return null;
73
+ }
74
+ return { file: relFile, line: 1, message: 'spec file is empty or has no content after frontmatter' };
75
+ }
76
+
77
+ function checkRequirementsAndScenarios(relFile: string, lines: string[]): Violation[] {
78
+ const violations: Violation[] = [];
79
+
80
+ let reqLine = -1;
81
+ let hasDescription = false;
82
+ let hasScenario = false;
83
+ let scenLine = -1;
84
+ let scenHasWhen = false;
85
+ let scenHasThen = false;
86
+
87
+ function finishScenario(): void {
88
+ if (scenLine === -1) return;
89
+ if (!scenHasWhen || !scenHasThen) {
90
+ violations.push({
91
+ file: relFile,
92
+ line: scenLine + 1,
93
+ message: 'scenario must have at least one **WHEN** and one **THEN** bullet',
94
+ });
95
+ }
96
+ scenLine = -1;
97
+ scenHasWhen = false;
98
+ scenHasThen = false;
99
+ }
100
+
101
+ function finishRequirement(): void {
102
+ if (reqLine === -1) return;
103
+ finishScenario();
104
+ if (!hasDescription || !hasScenario) {
105
+ violations.push({
106
+ file: relFile,
107
+ line: reqLine + 1,
108
+ message:
109
+ 'requirement must have at least one descriptive line and one "#### Scenario:" before the next requirement or EOF',
110
+ });
111
+ }
112
+ reqLine = -1;
113
+ hasDescription = false;
114
+ hasScenario = false;
115
+ }
116
+
117
+ for (let i = 0; i < lines.length; i++) {
118
+ const line = lines[i];
119
+ const trimmed = line.trim();
120
+
121
+ if (/^#{4}\s+/.test(trimmed)) {
122
+ if (/^#{4}\s+Scenario:/i.test(trimmed)) {
123
+ finishScenario();
124
+ if (reqLine !== -1) hasScenario = true;
125
+ scenLine = i;
126
+ scenHasWhen = false;
127
+ scenHasThen = false;
128
+ } else {
129
+ finishScenario();
130
+ }
131
+ continue;
132
+ }
133
+
134
+ if (/^#{3}\s+/.test(trimmed)) {
135
+ finishRequirement();
136
+ if (/^#{3}\s+Requirement:/i.test(trimmed)) {
137
+ reqLine = i;
138
+ hasDescription = false;
139
+ hasScenario = false;
140
+ }
141
+ continue;
142
+ }
143
+
144
+ if (/^#{2}\s+/.test(trimmed) || /^#\s+/.test(trimmed)) {
145
+ finishRequirement();
146
+ continue;
147
+ }
148
+
149
+ if (scenLine !== -1 && /^\s*[-*]\s+/.test(line)) {
150
+ if (line.includes('**WHEN**')) scenHasWhen = true;
151
+ if (line.includes('**THEN**')) scenHasThen = true;
152
+ continue;
153
+ }
154
+
155
+ if (reqLine !== -1 && scenLine === -1 && trimmed.length > 0) {
156
+ hasDescription = true;
157
+ }
158
+ }
159
+
160
+ finishRequirement();
161
+ return violations;
162
+ }
163
+
164
+ export async function runValidate(
165
+ targetPath = 'docs/spark',
166
+ projectRoot = process.cwd(),
167
+ ): Promise<Violation[]> {
168
+ const root = join(projectRoot, targetPath);
169
+
170
+ if (!(await dirExists(root))) {
171
+ return [{ file: targetPath, line: 1, message: `workspace path does not exist: ${root}` }];
172
+ }
173
+
174
+ const violations: Violation[] = [];
175
+ const changesDir = join(root, 'changes');
176
+
177
+ async function getChangeIds(): Promise<string[]> {
178
+ if (!(await dirExists(changesDir))) return [];
179
+ try {
180
+ const dirents = await readdir(changesDir, { withFileTypes: true });
181
+ return dirents.filter((d) => d.isDirectory() && d.name !== 'archive').map((d) => d.name);
182
+ } catch {
183
+ return [];
184
+ }
185
+ }
186
+
187
+ const changeIds = await getChangeIds();
188
+
189
+ const deltaSpecFiles: string[] = [];
190
+ for (const changeId of changeIds) {
191
+ const specsDir = join(changesDir, changeId, 'specs');
192
+ if (await dirExists(specsDir)) {
193
+ for (const f of await collectSpecFiles(specsDir)) {
194
+ if (!isUnderArchive(f)) deltaSpecFiles.push(f);
195
+ }
196
+ }
197
+ }
198
+
199
+ const truthSpecFiles: string[] = [];
200
+ const truthSpecDir = join(root, 'specs');
201
+ if (await dirExists(truthSpecDir)) {
202
+ for (const f of await collectSpecFiles(truthSpecDir)) {
203
+ if (!isUnderArchive(f)) truthSpecFiles.push(f);
204
+ }
205
+ }
206
+
207
+ const tasksFiles: string[] = [];
208
+ for (const changeId of changeIds) {
209
+ const tasksPath = join(changesDir, changeId, 'tasks.md');
210
+ try {
211
+ await stat(tasksPath);
212
+ tasksFiles.push(tasksPath);
213
+ } catch {
214
+ // no tasks.md — not an error
215
+ }
216
+ }
217
+
218
+ for (const file of deltaSpecFiles) {
219
+ const lines = (await readFile(file, 'utf8')).split('\n');
220
+ const v = checkDeltaHeader(relative(projectRoot, file), lines);
221
+ if (v) violations.push(v);
222
+ }
223
+
224
+ for (const file of [...deltaSpecFiles, ...truthSpecFiles]) {
225
+ const lines = (await readFile(file, 'utf8')).split('\n');
226
+ violations.push(...checkRequirementsAndScenarios(relative(projectRoot, file), lines));
227
+ }
228
+
229
+ for (const tasksPath of tasksFiles) {
230
+ let raw: string;
231
+ try {
232
+ raw = await readFile(tasksPath, 'utf8');
233
+ } catch (err) {
234
+ violations.push({
235
+ file: relative(projectRoot, tasksPath),
236
+ line: 1,
237
+ message: `could not read tasks file: ${String(err)}`,
238
+ });
239
+ continue;
240
+ }
241
+ try {
242
+ parseTasksMarkdown(tasksPath, raw);
243
+ } catch (err) {
244
+ const msg = String(err instanceof Error ? err.message : err);
245
+ const lineMatch = /:(\d+):/.exec(msg);
246
+ violations.push({
247
+ file: relative(projectRoot, tasksPath),
248
+ line: lineMatch ? parseInt(lineMatch[1], 10) : 1,
249
+ message: msg,
250
+ });
251
+ }
252
+ }
253
+
254
+ return violations;
255
+ }
256
+
257
+ export const validateCommand = defineCommand({
258
+ meta: {
259
+ name: 'validate',
260
+ description: 'Lint the docs/spark spec workspace; exit non-zero on any violation',
261
+ },
262
+ args: {
263
+ path: {
264
+ type: 'positional',
265
+ required: false,
266
+ default: 'docs/spark',
267
+ description: 'Workspace path to validate',
268
+ },
269
+ },
270
+ async run({ args }) {
271
+ const p = typeof args.path === 'string' && args.path.length > 0 ? args.path : 'docs/spark';
272
+ const v = await runValidate(p);
273
+ if (v.length > 0) {
274
+ for (const x of v) console.error(pc.red(`${x.file}:${x.line} ${x.message}`));
275
+ console.error(pc.red(`validate: ${v.length} problem(s) found`));
276
+ process.exitCode = 1;
277
+ } else {
278
+ console.log(pc.green('OK: spec workspace is well-formed.'));
279
+ }
280
+ },
281
+ });
package/src/config.ts CHANGED
@@ -19,7 +19,7 @@ export async function readConfig(projectRoot: string): Promise<AppSkillsConfig>
19
19
  raw = await readFile(configPath, 'utf8');
20
20
  } catch (error) {
21
21
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
22
- throw new Error('not in an spark project: missing spark.config.json');
22
+ throw new Error('not in an spark project: missing spark.config.json', { cause: error });
23
23
  }
24
24
  throw error;
25
25
  }
@@ -80,7 +80,7 @@ function assertStatus(status: BoardTaskStatus, path: string): BoardTaskStatus {
80
80
  }
81
81
 
82
82
  function escapeRegex(value: string): string {
83
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
84
84
  }
85
85
 
86
86
  /**
@@ -242,7 +242,7 @@ export async function readAllChangeTasks(projectRoot: string): Promise<Aggregate
242
242
  changeIds = dirents
243
243
  .filter((d) => d.isDirectory() && d.name !== 'archive')
244
244
  .map((d) => d.name)
245
- .sort();
245
+ .toSorted();
246
246
  } catch (error) {
247
247
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
248
248
  throw error;
@@ -288,7 +288,7 @@ export function renderBuildStatus(tasks: readonly AggregatedTask[]): string {
288
288
  function renderGroup(label: string, group: readonly AggregatedTask[]): void {
289
289
  if (group.length === 0) return;
290
290
  lines.push(`\n### ${label}`);
291
- const sorted = [...group].sort((a, b) =>
291
+ const sorted = [...group].toSorted((a, b) =>
292
292
  a.changeId === b.changeId ? a.id.localeCompare(b.id) : a.changeId.localeCompare(b.changeId),
293
293
  );
294
294
  for (const task of sorted) {
@@ -328,8 +328,8 @@ function buildFrontmatter(): string {
328
328
  function generatedTaskId(packName: string, index: number): string {
329
329
  const prefix = packName
330
330
  .toUpperCase()
331
- .replace(/[^A-Z0-9]+/g, '-')
332
- .replace(/^-+|-+$/g, '');
331
+ .replaceAll(/[^A-Z0-9]+/g, '-')
332
+ .replaceAll(/^-+|-+$/g, '');
333
333
  return `${prefix || 'PACK'}-${String(index + 1).padStart(3, '0')}`;
334
334
  }
335
335
 
@@ -354,7 +354,7 @@ function formatSeedTask(task: Required<SeedTask>, packName: string): string {
354
354
  const lines = [`- [${statusMarkers[task.status]}] ${task.id}: ${task.title}`];
355
355
  lines.push(' - Status: Clarifying');
356
356
  lines.push(` - requires_pack: ${packName}`);
357
- const description = task.description.replace(/\r\n/g, '\n').replace(/\n$/u, '');
357
+ const description = task.description.replaceAll(/\r\n/g, '\n').replace(/\n$/u, '');
358
358
  if (description.trim().length > 0) {
359
359
  lines.push(...description.split('\n').map((l) => ` ${l}`));
360
360
  }
@@ -34,7 +34,7 @@ function rawBlocks(frontmatter: Record<string, unknown>): FrontmatterBlockMap |
34
34
  }
35
35
 
36
36
  function splitFrontmatter(source: string): { frontmatter: string; body: string } {
37
- const normalized = source.replace(/\r\n/g, '\n');
37
+ const normalized = source.replaceAll(/\r\n/g, '\n');
38
38
  const lines = normalized.split('\n');
39
39
 
40
40
  if (lines[0] !== '---') {
@@ -13,14 +13,14 @@ function initialState(): StateFile {
13
13
  }
14
14
 
15
15
  function uniqueSorted(values: readonly string[]): string[] {
16
- return [...new Set(values)].sort();
16
+ return [...new Set(values)].toSorted();
17
17
  }
18
18
 
19
19
  function normalizeInstalledPack(pack: StateInstalledPack): StateInstalledPack {
20
20
  return {
21
21
  ...pack,
22
22
  files: uniqueSorted(pack.files),
23
- appended_blocks: [...pack.appended_blocks].sort((left, right) =>
23
+ appended_blocks: [...pack.appended_blocks].toSorted((left, right) =>
24
24
  `${left.to}:${left.marker}`.localeCompare(`${right.to}:${right.marker}`),
25
25
  ),
26
26
  env: uniqueSorted(pack.env),
@@ -35,7 +35,7 @@ function normalizeState(state: StateFile): StateFile {
35
35
  schema_version: 1,
36
36
  installed_packs: [...parsed.installed_packs]
37
37
  .map(normalizeInstalledPack)
38
- .sort((left, right) => left.name.localeCompare(right.name)),
38
+ .toSorted((left, right) => left.name.localeCompare(right.name)),
39
39
  };
40
40
  }
41
41
 
package/src/io/board.ts CHANGED
@@ -144,7 +144,7 @@ export async function seedBoardTasks(
144
144
  packName,
145
145
  tasks
146
146
  .filter((task) => missing.has(task.id))
147
- .sort((left, right) => left.id.localeCompare(right.id))
147
+ .toSorted((left, right) => left.id.localeCompare(right.id))
148
148
  .map((task) => ({
149
149
  id: task.id,
150
150
  title: task.title,
@@ -154,7 +154,7 @@ export async function seedBoardTasks(
154
154
  );
155
155
 
156
156
  const missingAfter = new Set(await missingBoardTasks(projectRoot, taskIds));
157
- return missingBefore.filter((taskId) => !missingAfter.has(taskId)).sort();
157
+ return missingBefore.filter((taskId) => !missingAfter.has(taskId)).toSorted();
158
158
  }
159
159
 
160
160
  export async function missingBoardTasks(
@@ -163,5 +163,5 @@ export async function missingBoardTasks(
163
163
  ): Promise<string[]> {
164
164
  if (taskIds.length === 0) return [];
165
165
  const existingIds = new Set((await readAllChangeTasks(projectRoot)).map((task) => task.id));
166
- return taskIds.filter((id) => !existingIds.has(id)).sort();
166
+ return taskIds.filter((id) => !existingIds.has(id)).toSorted();
167
167
  }
package/src/io/deps.ts CHANGED
@@ -6,7 +6,7 @@ export type DependencyCommand = {
6
6
  export type DependencyRunner = (command: DependencyCommand) => Promise<void>;
7
7
 
8
8
  function uniqueSorted(values: readonly string[]): string[] {
9
- return [...new Set(values)].sort();
9
+ return [...new Set(values)].toSorted();
10
10
  }
11
11
 
12
12
  export const defaultDependencyRunner: DependencyRunner = async ({ args, cwd }) => {
package/src/io/env.ts CHANGED
@@ -18,7 +18,7 @@ async function readExisting(path: string): Promise<string | undefined> {
18
18
  }
19
19
 
20
20
  function escapeRegex(value: string): string {
21
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
22
  }
23
23
 
24
24
  function hasEnvVar(content: string, key: string): boolean {
@@ -51,7 +51,7 @@ export async function appendEnvVars(
51
51
  projectRoot: string,
52
52
  vars: readonly string[],
53
53
  ): Promise<EnvApplyResult[]> {
54
- const uniqueVars = [...new Set(vars)].sort();
54
+ const uniqueVars = [...new Set(vars)].toSorted();
55
55
  const results: EnvApplyResult[] = [];
56
56
 
57
57
  for (const file of ['.env.example', '.env.local']) {
package/src/io/files.ts CHANGED
@@ -101,7 +101,7 @@ function sortJson(value: unknown): unknown {
101
101
  }
102
102
 
103
103
  const sorted: Record<string, unknown> = {};
104
- for (const key of Object.keys(value).sort()) {
104
+ for (const key of Object.keys(value).toSorted()) {
105
105
  sorted[key] = sortJson(value[key]);
106
106
  }
107
107
 
@@ -119,11 +119,25 @@ function lookupTemplateValue(config: AppSkillsConfig, key: string): string {
119
119
  current = current[segment];
120
120
  }
121
121
 
122
- return current === undefined || current === null ? '' : String(current);
122
+ if (current === undefined || current === null) {
123
+ return '';
124
+ }
125
+
126
+ switch (typeof current) {
127
+ case 'string':
128
+ return current;
129
+ case 'number':
130
+ case 'boolean':
131
+ case 'bigint':
132
+ case 'symbol':
133
+ return String(current);
134
+ default:
135
+ return JSON.stringify(current) ?? '';
136
+ }
123
137
  }
124
138
 
125
139
  export function renderTemplate(template: string, config: AppSkillsConfig): string {
126
- return template.replace(/{{\s*([A-Za-z0-9_.-]+)\s*}}/g, (_match, key: string) =>
140
+ return template.replaceAll(/{{\s*([A-Za-z0-9_.-]+)\s*}}/g, (_match, key: string) =>
127
141
  lookupTemplateValue(config, key),
128
142
  );
129
143
  }
@@ -43,7 +43,7 @@ async function readChildDirectories(root: string): Promise<string[]> {
43
43
  return entries
44
44
  .filter((entry) => entry.isDirectory())
45
45
  .map((entry) => entry.name)
46
- .sort();
46
+ .toSorted();
47
47
  } catch (error) {
48
48
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
49
49
  return [];
@@ -63,9 +63,15 @@ async function readToml(path: string): Promise<string | undefined> {
63
63
  }
64
64
  }
65
65
 
66
- function unwrapParseResult<T>(result: ReturnType<typeof parsePackToml> | ReturnType<typeof parsePresetToml> | ReturnType<typeof parseTemplateToml>, path: string): T {
66
+ function unwrapParseResult(result: ReturnType<typeof parsePackToml>, path: string): PackManifest;
67
+ function unwrapParseResult(result: ReturnType<typeof parsePresetToml>, path: string): PresetManifest;
68
+ function unwrapParseResult(result: ReturnType<typeof parseTemplateToml>, path: string): TemplateManifest;
69
+ function unwrapParseResult(
70
+ result: ReturnType<typeof parsePackToml> | ReturnType<typeof parsePresetToml> | ReturnType<typeof parseTemplateToml>,
71
+ path: string,
72
+ ): PackManifest | PresetManifest | TemplateManifest {
67
73
  if (result.ok) {
68
- return result.data as T;
74
+ return result.data;
69
75
  }
70
76
 
71
77
  const details = result.error.issues?.length ? `\n${result.error.issues.join('\n')}` : '';
@@ -84,7 +90,7 @@ async function readTemplates(root: string): Promise<Map<string, TemplateRegistry
84
90
  continue;
85
91
  }
86
92
 
87
- const manifest = unwrapParseResult<TemplateManifest>(parseTemplateToml(raw), manifestPath);
93
+ const manifest = unwrapParseResult(parseTemplateToml(raw), manifestPath);
88
94
  if (manifest.name !== dirName) {
89
95
  throw new Error(`${manifestPath}: template name "${manifest.name}" must match directory "${dirName}"`);
90
96
  }
@@ -119,7 +125,7 @@ async function readPacks(
119
125
  continue;
120
126
  }
121
127
 
122
- const manifest = unwrapParseResult<PackManifest>(parsePackToml(raw), manifestPath);
128
+ const manifest = unwrapParseResult(parsePackToml(raw), manifestPath);
123
129
  if (manifest.name !== dirName) {
124
130
  throw new Error(`${manifestPath}: pack name "${manifest.name}" must match directory "${dirName}"`);
125
131
  }
@@ -155,7 +161,7 @@ async function readPresets(root: string): Promise<Map<string, PresetRegistryEntr
155
161
 
156
162
  const file = join(presetsRoot, entry.name);
157
163
  const raw = await readFile(file, 'utf8');
158
- const manifest = unwrapParseResult<PresetManifest>(parsePresetToml(raw), file);
164
+ const manifest = unwrapParseResult(parsePresetToml(raw), file);
159
165
  const name = manifest.name ?? parsePath(entry.name).name;
160
166
 
161
167
  if (manifest.name && manifest.name !== parsePath(entry.name).name) {
package/src/io/skills.ts CHANGED
@@ -24,7 +24,7 @@ async function walkFiles(root: string): Promise<string[]> {
24
24
  }
25
25
  }
26
26
 
27
- return files.sort();
27
+ return files.toSorted();
28
28
  }
29
29
 
30
30
  async function directoryExists(path: string): Promise<boolean> {
@@ -124,5 +124,5 @@ export async function copyPackSkills(
124
124
  written.push(...record.claudeFiles, ...record.codexFiles);
125
125
  }
126
126
 
127
- return [...new Set(written)].sort();
127
+ return [...new Set(written)].toSorted();
128
128
  }
package/src/io/state.ts CHANGED
@@ -13,14 +13,14 @@ export function stateFilePath(projectRoot: string): string {
13
13
  }
14
14
 
15
15
  function uniqueSorted(values: readonly string[]): string[] {
16
- return [...new Set(values)].sort();
16
+ return [...new Set(values)].toSorted();
17
17
  }
18
18
 
19
19
  function normalizeInstalledPack(pack: StateInstalledPack): StateInstalledPack {
20
20
  return {
21
21
  ...pack,
22
22
  files: uniqueSorted(pack.files),
23
- appended_blocks: [...pack.appended_blocks].sort((left, right) =>
23
+ appended_blocks: [...pack.appended_blocks].toSorted((left, right) =>
24
24
  `${left.to}:${left.marker}`.localeCompare(`${right.to}:${right.marker}`),
25
25
  ),
26
26
  env: uniqueSorted(pack.env),
@@ -35,7 +35,7 @@ export function normalizeState(state: StateFile): StateFile {
35
35
  schema_version: 1,
36
36
  installed_packs: [...parsed.installed_packs]
37
37
  .map(normalizeInstalledPack)
38
- .sort((left, right) => left.name.localeCompare(right.name)),
38
+ .toSorted((left, right) => left.name.localeCompare(right.name)),
39
39
  };
40
40
  }
41
41
 
package/src/resolver.ts CHANGED
@@ -111,7 +111,7 @@ function findProviders(
111
111
  }
112
112
  }
113
113
 
114
- return providers.sort();
114
+ return providers.toSorted();
115
115
  }
116
116
 
117
117
  function firstScaffoldError(
@@ -232,7 +232,7 @@ function sortInstallNames(
232
232
  }
233
233
 
234
234
  state.set(name, 'visiting');
235
- const deps = [...(dependencies.get(name) ?? [])].sort();
235
+ const deps = [...(dependencies.get(name) ?? [])].toSorted();
236
236
  for (const dependency of deps) {
237
237
  const cycle = visit(dependency, [...path, dependency]);
238
238
  if (cycle) {