ai-check-template 0.2.0-alpha.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 (51) hide show
  1. package/LICENSE +201 -0
  2. package/README-ja.md +151 -0
  3. package/README.md +149 -0
  4. package/bin/ai-check-template.mjs +7 -0
  5. package/docs/cli.md +348 -0
  6. package/package-templates/.claude/README.md +83 -0
  7. package/package-templates/.claude/rules/test-rules.md +46 -0
  8. package/package-templates/.claude/settings.hook-fragment.json +25 -0
  9. package/package-templates/README.md +56 -0
  10. package/package-templates/ci-examples/README.md +134 -0
  11. package/package-templates/ci-examples/github-actions/ai-check-fast.yml +49 -0
  12. package/package-templates/ci-examples/github-actions/ai-check.yml +58 -0
  13. package/package-templates/ci-examples/github-actions/ai-quality-call.yml +26 -0
  14. package/package-templates/ci-examples/github-actions/ai-quality-reusable.yml +113 -0
  15. package/package-templates/docs/philosophy/formal-name-match.md +182 -0
  16. package/package-templates/docs/philosophy/given-when-then.md +206 -0
  17. package/package-templates/docs/philosophy/qa-techniques.md +235 -0
  18. package/package-templates/docs/philosophy/test-pyramid.md +171 -0
  19. package/package-templates/docs/test-design-template.md +173 -0
  20. package/package-templates/package.scripts.fragment.json +6 -0
  21. package/package-templates/profiles/README.md +89 -0
  22. package/package-templates/profiles/expo-rn/README.md +80 -0
  23. package/package-templates/profiles/node-cli/README.md +93 -0
  24. package/package-templates/profiles/react-nextjs/README.md +82 -0
  25. package/package-templates/profiles/react-vanilla/README.md +73 -0
  26. package/package-templates/profiles/supabase-rls/README.md +89 -0
  27. package/package-templates/prompts/README.md +94 -0
  28. package/package-templates/prompts/boundary-value.md +94 -0
  29. package/package-templates/prompts/decision-table.md +82 -0
  30. package/package-templates/prompts/diagnostic-repair.md +149 -0
  31. package/package-templates/prompts/plan-first.md +122 -0
  32. package/package-templates/prompts/rls-permission.md +94 -0
  33. package/package-templates/prompts/state-transition.md +81 -0
  34. package/package-templates/scripts/README.md +78 -0
  35. package/package-templates/scripts/ai-check-fast.sh +20 -0
  36. package/package-templates/scripts/ai-check.sh +22 -0
  37. package/package.json +47 -0
  38. package/src/cli/ci-workflows.mjs +104 -0
  39. package/src/cli/claude-hooks.mjs +94 -0
  40. package/src/cli/dependency-installer.mjs +164 -0
  41. package/src/cli/doctor.mjs +392 -0
  42. package/src/cli/index.mjs +80 -0
  43. package/src/cli/init.mjs +433 -0
  44. package/src/cli/install-state.mjs +242 -0
  45. package/src/cli/package-manager.mjs +78 -0
  46. package/src/cli/profile-diagnostics.mjs +160 -0
  47. package/src/cli/profile-docs.mjs +31 -0
  48. package/src/cli/profile-scripts.mjs +96 -0
  49. package/src/cli/profile.mjs +59 -0
  50. package/src/cli/update.mjs +537 -0
  51. package/src/cli/utils.mjs +75 -0
@@ -0,0 +1,537 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ ciWorkflowFiles,
5
+ ciWorkflowRelativePath,
6
+ inactiveCiWorkflowFiles,
7
+ isManagedCiWorkflowContent,
8
+ renderedCiWorkflow,
9
+ } from "./ci-workflows.mjs";
10
+ import {
11
+ dependencyInstallOperation,
12
+ planDependencyInstall,
13
+ preflightDependencyInstaller,
14
+ runDependencyInstall,
15
+ } from "./dependency-installer.mjs";
16
+ import { mergeRenderedClaudeHookEntries, renderClaudeHookSettings } from "./claude-hooks.mjs";
17
+ import {
18
+ assertWritableInstallState,
19
+ effectiveOptionsSummary,
20
+ installationSummary,
21
+ installStatePath,
22
+ loadInstallState,
23
+ resolveEffectiveOptions,
24
+ validateCiMode,
25
+ writeInstallState,
26
+ } from "./install-state.mjs";
27
+ import { DEFAULT_PACKAGE_MANAGER, detectPackageManager, validatePackageManager } from "./package-manager.mjs";
28
+ import { getProfileDocFiles } from "./profile-docs.mjs";
29
+ import { getProfileScripts, getProfileSupportScripts } from "./profile-scripts.mjs";
30
+ import {
31
+ CliError,
32
+ fromTemplates,
33
+ pathExists,
34
+ readJson,
35
+ resolveTarget,
36
+ writeJson,
37
+ writeLine,
38
+ } from "./utils.mjs";
39
+
40
+ const UPDATE_USAGE = `ai-check-template update
41
+
42
+ Usage:
43
+ ai-check-template update --target <dir> --yes [options]
44
+
45
+ Options:
46
+ --target <dir> Target project directory. Defaults to the current directory.
47
+ --profile <name> Profile to refresh in install state. Defaults to install state or react-nextjs.
48
+ --package-manager <name> Package manager: pnpm, npm, yarn, or bun. Defaults to install state or target detection.
49
+ --ci <mode> CI mode to update: direct, reusable, or none. Defaults to direct.
50
+ --claude-hooks Update Claude rule and hook settings.
51
+ --install-deps Install missing dev dependencies for generated package scripts.
52
+ --dry-run Print planned operations without writing files.
53
+ --yes Confirm non-interactive writes.
54
+ --json Print machine-readable JSON output.`;
55
+
56
+ export async function runUpdate(argv, io = {}) {
57
+ const options = parseUpdateArgs(argv, io.cwd ?? process.cwd());
58
+
59
+ if (options.help) {
60
+ writeLine(io.stdout, UPDATE_USAGE);
61
+ return;
62
+ }
63
+
64
+ if (!options.yes && !options.dryRun) {
65
+ throw new CliError("Refusing to write without --yes. Use --dry-run to preview.");
66
+ }
67
+
68
+ const targetDir = await normalizeTargetDir(options.target);
69
+ const packageJsonPath = path.join(targetDir, "package.json");
70
+
71
+ if (!(await pathExists(packageJsonPath))) {
72
+ throw new CliError(`Target project must contain package.json: ${packageJsonPath}`);
73
+ }
74
+
75
+ options.packageManager = options.explicit.packageManager
76
+ ? options.packageManager
77
+ : await detectPackageManager(targetDir);
78
+ const installState = await loadInstallState(targetDir);
79
+ assertWritableInstallState(installState);
80
+ const effectiveOptions = resolveEffectiveOptions(options, installState);
81
+ const writeOptions = {
82
+ ...options,
83
+ profile: effectiveOptions.profile,
84
+ packageManager: effectiveOptions.packageManager,
85
+ ci: effectiveOptions.ci,
86
+ claudeHooks: effectiveOptions.claudeHooks,
87
+ };
88
+ const dependencyInstallPlan = writeOptions.installDeps
89
+ ? await planDependencyInstall(packageJsonPath, writeOptions.profile, writeOptions.packageManager)
90
+ : null;
91
+
92
+ if (dependencyInstallPlan && !writeOptions.dryRun) {
93
+ preflightDependencyInstaller(dependencyInstallPlan, targetDir);
94
+ }
95
+
96
+ const operations = [];
97
+
98
+ await updatePackageScripts(targetDir, packageJsonPath, writeOptions, operations);
99
+ await updateTemplateFile(targetDir, fromTemplates("scripts", "ai-check.sh"), "scripts/ai-check.sh", writeOptions, operations);
100
+ await updateTemplateFile(targetDir, fromTemplates("scripts", "ai-check-fast.sh"), "scripts/ai-check-fast.sh", writeOptions, operations);
101
+ await createMissingProfileDocs(targetDir, writeOptions, operations);
102
+ await updateCi(targetDir, writeOptions, operations);
103
+ await cleanupInactiveCi(targetDir, writeOptions, operations);
104
+
105
+ if (writeOptions.claudeHooks) {
106
+ await updateTemplateFile(
107
+ targetDir,
108
+ fromTemplates(".claude", "rules", "test-rules.md"),
109
+ ".claude/rules/test-rules.md",
110
+ writeOptions,
111
+ operations,
112
+ );
113
+ await updateClaudeSettings(targetDir, writeOptions, operations);
114
+ }
115
+
116
+ await updateInstallState(targetDir, effectiveOptions, writeOptions, operations);
117
+ await maybeInstallDependencies(targetDir, dependencyInstallPlan, writeOptions, operations);
118
+
119
+ const output = {
120
+ status: options.dryRun ? "dry-run" : "updated",
121
+ target: targetDir,
122
+ installation: installationSummary(installState),
123
+ effectiveOptions: effectiveOptionsSummary(effectiveOptions),
124
+ operations,
125
+ };
126
+
127
+ if (options.json) {
128
+ writeLine(io.stdout, JSON.stringify(output, null, 2));
129
+ } else {
130
+ writeHumanOutput(io.stdout, output);
131
+ }
132
+ }
133
+
134
+ function parseUpdateArgs(argv, cwd) {
135
+ const options = {
136
+ target: cwd,
137
+ profile: "react-nextjs",
138
+ packageManager: DEFAULT_PACKAGE_MANAGER,
139
+ ci: "direct",
140
+ claudeHooks: false,
141
+ installDeps: false,
142
+ dryRun: false,
143
+ yes: false,
144
+ json: false,
145
+ help: false,
146
+ explicit: {
147
+ profile: false,
148
+ packageManager: false,
149
+ ci: false,
150
+ claudeHooks: false,
151
+ },
152
+ };
153
+
154
+ for (let index = 0; index < argv.length; index += 1) {
155
+ const arg = argv[index];
156
+
157
+ if (arg === "--help" || arg === "-h") {
158
+ options.help = true;
159
+ continue;
160
+ }
161
+
162
+ if (arg === "--claude-hooks") {
163
+ options.claudeHooks = true;
164
+ options.explicit.claudeHooks = true;
165
+ continue;
166
+ }
167
+
168
+ if (arg === "--install-deps") {
169
+ options.installDeps = true;
170
+ continue;
171
+ }
172
+
173
+ if (arg === "--dry-run") {
174
+ options.dryRun = true;
175
+ continue;
176
+ }
177
+
178
+ if (arg === "--yes") {
179
+ options.yes = true;
180
+ continue;
181
+ }
182
+
183
+ if (arg === "--json") {
184
+ options.json = true;
185
+ continue;
186
+ }
187
+
188
+ if (arg.startsWith("--target=")) {
189
+ options.target = resolveTarget(arg.slice("--target=".length), cwd);
190
+ continue;
191
+ }
192
+
193
+ if (arg === "--target") {
194
+ options.target = resolveTarget(readFlagValue(argv, (index += 1), arg), cwd);
195
+ continue;
196
+ }
197
+
198
+ if (arg.startsWith("--profile=")) {
199
+ options.profile = arg.slice("--profile=".length);
200
+ options.explicit.profile = true;
201
+ continue;
202
+ }
203
+
204
+ if (arg === "--profile") {
205
+ options.profile = readFlagValue(argv, (index += 1), arg);
206
+ options.explicit.profile = true;
207
+ continue;
208
+ }
209
+
210
+ if (arg.startsWith("--package-manager=")) {
211
+ options.packageManager = validatePackageManager(arg.slice("--package-manager=".length));
212
+ options.explicit.packageManager = true;
213
+ continue;
214
+ }
215
+
216
+ if (arg === "--package-manager") {
217
+ options.packageManager = validatePackageManager(readFlagValue(argv, (index += 1), arg));
218
+ options.explicit.packageManager = true;
219
+ continue;
220
+ }
221
+
222
+ if (arg.startsWith("--ci=")) {
223
+ options.ci = arg.slice("--ci=".length);
224
+ options.explicit.ci = true;
225
+ continue;
226
+ }
227
+
228
+ if (arg === "--ci") {
229
+ options.ci = readFlagValue(argv, (index += 1), arg);
230
+ options.explicit.ci = true;
231
+ continue;
232
+ }
233
+
234
+ throw new CliError(`Unknown update option: ${arg}\n\n${UPDATE_USAGE}`);
235
+ }
236
+
237
+ validateCiMode(options.ci);
238
+
239
+ return options;
240
+ }
241
+
242
+ function readFlagValue(argv, index, flagName) {
243
+ const value = argv[index];
244
+
245
+ if (!value || value.startsWith("--")) {
246
+ throw new CliError(`Missing value for ${flagName}`);
247
+ }
248
+
249
+ return value;
250
+ }
251
+
252
+ async function normalizeTargetDir(target) {
253
+ const resolved = path.resolve(target);
254
+
255
+ try {
256
+ return await fs.realpath(resolved);
257
+ } catch (error) {
258
+ throw new CliError(`Target directory does not exist: ${resolved}\n${error.message}`);
259
+ }
260
+ }
261
+
262
+ async function updatePackageScripts(targetDir, packageJsonPath, options, operations) {
263
+ const packageJson = await readJson(packageJsonPath);
264
+ const existingScripts = packageJson.scripts ?? {};
265
+ const expectedScripts = getProfileScripts(options.profile, { packageManager: options.packageManager });
266
+ const supportScripts = getProfileSupportScripts(options.profile);
267
+ const nextScripts = { ...existingScripts };
268
+ let changed = false;
269
+
270
+ for (const [name, expected] of Object.entries(expectedScripts)) {
271
+ const current = existingScripts[name];
272
+ const relativePath = "package.json";
273
+
274
+ if (current === expected) {
275
+ operations.push(operation("keep", relativePath, `script ${name}`));
276
+ continue;
277
+ }
278
+
279
+ nextScripts[name] = expected;
280
+ changed = true;
281
+ operations.push(
282
+ operation(
283
+ current ? (options.dryRun ? "would-update" : "update") : (options.dryRun ? "would-create" : "create"),
284
+ relativePath,
285
+ `script ${name}`,
286
+ ),
287
+ );
288
+ }
289
+
290
+ for (const [name, expected] of Object.entries(supportScripts)) {
291
+ const current = nextScripts[name];
292
+ const relativePath = "package.json";
293
+
294
+ if (current) {
295
+ operations.push(operation("keep", relativePath, `support script ${name}`));
296
+ continue;
297
+ }
298
+
299
+ nextScripts[name] = expected;
300
+ changed = true;
301
+ operations.push(
302
+ operation(options.dryRun ? "would-create" : "create", relativePath, `support script ${name}`),
303
+ );
304
+ }
305
+
306
+ if (changed && !options.dryRun) {
307
+ packageJson.scripts = nextScripts;
308
+ await writeJson(packageJsonPath, packageJson);
309
+ }
310
+ }
311
+
312
+ async function updateTemplateFile(targetDir, sourcePath, relativePath, options, operations) {
313
+ const targetPath = path.join(targetDir, relativePath);
314
+ const expected = await fs.readFile(sourcePath, "utf8");
315
+ const exists = await pathExists(targetPath);
316
+
317
+ if (exists) {
318
+ const actual = await fs.readFile(targetPath, "utf8");
319
+ if (actual === expected) {
320
+ operations.push(operation("keep", relativePath));
321
+ return;
322
+ }
323
+ }
324
+
325
+ operations.push(
326
+ operation(
327
+ exists ? (options.dryRun ? "would-update" : "update") : (options.dryRun ? "would-create" : "create"),
328
+ relativePath,
329
+ ),
330
+ );
331
+
332
+ if (!options.dryRun) {
333
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
334
+ await fs.writeFile(targetPath, expected);
335
+ }
336
+ }
337
+
338
+ async function createMissingProfileDocs(targetDir, options, operations) {
339
+ for (const file of getProfileDocFiles(options.profile)) {
340
+ await createMissingTemplateFile(targetDir, file.sourcePath, file.relativePath, options, operations, "profile doc");
341
+ }
342
+ }
343
+
344
+ async function createMissingTemplateFile(targetDir, sourcePath, relativePath, options, operations, detail) {
345
+ const targetPath = path.join(targetDir, relativePath);
346
+
347
+ if (await pathExists(targetPath)) {
348
+ operations.push(operation("keep", relativePath, detail));
349
+ return;
350
+ }
351
+
352
+ operations.push(operation(options.dryRun ? "would-create" : "create", relativePath, detail));
353
+
354
+ if (!options.dryRun) {
355
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
356
+ await fs.copyFile(sourcePath, targetPath);
357
+ }
358
+ }
359
+
360
+ async function updateCi(targetDir, options, operations) {
361
+ for (const fileName of ciWorkflowFiles(options.ci)) {
362
+ await updateRenderedTemplateFile(
363
+ targetDir,
364
+ await renderedCiWorkflow(fileName, options.packageManager),
365
+ ciWorkflowRelativePath(fileName),
366
+ options,
367
+ operations,
368
+ );
369
+ }
370
+ }
371
+
372
+ async function cleanupInactiveCi(targetDir, options, operations) {
373
+ for (const fileName of inactiveCiWorkflowFiles(options.ci)) {
374
+ await cleanupManagedFile(
375
+ targetDir,
376
+ fileName,
377
+ ciWorkflowRelativePath(fileName),
378
+ options,
379
+ operations,
380
+ );
381
+ }
382
+ }
383
+
384
+ async function updateRenderedTemplateFile(targetDir, expected, relativePath, options, operations) {
385
+ const targetPath = path.join(targetDir, relativePath);
386
+ const exists = await pathExists(targetPath);
387
+
388
+ if (exists) {
389
+ const actual = await fs.readFile(targetPath, "utf8");
390
+ if (actual === expected) {
391
+ operations.push(operation("keep", relativePath));
392
+ return;
393
+ }
394
+ }
395
+
396
+ operations.push(
397
+ operation(
398
+ exists ? (options.dryRun ? "would-update" : "update") : (options.dryRun ? "would-create" : "create"),
399
+ relativePath,
400
+ ),
401
+ );
402
+
403
+ if (!options.dryRun) {
404
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
405
+ await fs.writeFile(targetPath, expected);
406
+ }
407
+ }
408
+
409
+ async function cleanupManagedFile(targetDir, fileName, relativePath, options, operations) {
410
+ const targetPath = path.join(targetDir, relativePath);
411
+
412
+ if (!(await pathExists(targetPath))) {
413
+ return;
414
+ }
415
+
416
+ const actual = await fs.readFile(targetPath, "utf8");
417
+ if (!(await isManagedCiWorkflowContent(fileName, actual))) {
418
+ operations.push(operation("keep", relativePath, "custom workflow"));
419
+ return;
420
+ }
421
+
422
+ operations.push(operation(options.dryRun ? "would-delete" : "delete", relativePath, "inactive managed workflow"));
423
+
424
+ if (!options.dryRun) {
425
+ await fs.unlink(targetPath);
426
+ }
427
+ }
428
+
429
+ async function updateClaudeSettings(targetDir, options, operations) {
430
+ const relativePath = ".claude/settings.json";
431
+ const targetPath = path.join(targetDir, relativePath);
432
+ const fragment = renderClaudeHookSettings(
433
+ await readJson(fromTemplates(".claude", "settings.hook-fragment.json")),
434
+ options.packageManager,
435
+ );
436
+ const settings = (await pathExists(targetPath)) ? await readJson(targetPath) : {};
437
+ const nextSettings = { ...settings, hooks: { ...(settings.hooks ?? {}) } };
438
+ let changed = false;
439
+
440
+ for (const [name, hooks] of Object.entries(fragment.hooks ?? {})) {
441
+ const current = nextSettings.hooks[name];
442
+ const currentJson = current ? JSON.stringify(current) : "";
443
+ const expected = mergeRenderedClaudeHookEntries(current, hooks);
444
+ const expectedJson = JSON.stringify(expected);
445
+
446
+ if (currentJson === expectedJson) {
447
+ operations.push(operation("keep", relativePath, `Claude hook ${name}`));
448
+ continue;
449
+ }
450
+
451
+ nextSettings.hooks[name] = expected;
452
+ changed = true;
453
+ operations.push(
454
+ operation(
455
+ current ? (options.dryRun ? "would-update" : "update") : (options.dryRun ? "would-create" : "create"),
456
+ relativePath,
457
+ `Claude hook ${name}`,
458
+ ),
459
+ );
460
+ }
461
+
462
+ if (changed && !options.dryRun) {
463
+ await writeJson(targetPath, nextSettings);
464
+ }
465
+ }
466
+
467
+ async function updateInstallState(targetDir, effectiveOptions, options, operations) {
468
+ const relativePath = ".ai-check-template.json";
469
+ const targetPath = installStatePath(targetDir);
470
+ const exists = await pathExists(targetPath);
471
+
472
+ operations.push(
473
+ operation(
474
+ exists ? (options.dryRun ? "would-update" : "update") : (options.dryRun ? "would-create" : "create"),
475
+ relativePath,
476
+ "install state",
477
+ ),
478
+ );
479
+
480
+ await writeInstallState(
481
+ targetDir,
482
+ {
483
+ profile: effectiveOptions.profile,
484
+ packageManager: effectiveOptions.packageManager,
485
+ ci: effectiveOptions.ci,
486
+ claudeHooks: effectiveOptions.claudeHooks,
487
+ },
488
+ { dryRun: options.dryRun },
489
+ );
490
+ }
491
+
492
+ async function maybeInstallDependencies(targetDir, dependencyInstallPlan, options, operations) {
493
+ if (!dependencyInstallPlan) {
494
+ return;
495
+ }
496
+
497
+ operations.push(
498
+ dependencyInstallOperation(dependencyInstallPlan, {
499
+ dryRun: options.dryRun,
500
+ path: "package.json",
501
+ }),
502
+ );
503
+
504
+ if (!options.dryRun) {
505
+ runDependencyInstall(dependencyInstallPlan, targetDir);
506
+ }
507
+ }
508
+
509
+ function operation(action, filePath, detail = undefined) {
510
+ return {
511
+ action,
512
+ path: normalizeRelative(filePath),
513
+ ...(detail ? { detail } : {}),
514
+ };
515
+ }
516
+
517
+ function normalizeRelative(filePath) {
518
+ return filePath.split(path.sep).join("/");
519
+ }
520
+
521
+ function writeHumanOutput(stream, output) {
522
+ writeLine(stream, `ai-check-template update ${output.status}`);
523
+ writeLine(stream, `target: ${output.target}`);
524
+ writeLine(stream, `install-state: ${output.installation.source}`);
525
+ writeLine(stream, `profile: ${output.effectiveOptions.profile}`);
526
+ writeLine(stream, `package-manager: ${output.effectiveOptions.packageManager}`);
527
+ writeLine(stream, `ci: ${output.effectiveOptions.ci}`);
528
+ writeLine(stream, `claude-hooks: ${output.effectiveOptions.claudeHooks}`);
529
+ writeLine(stream, `operations: ${output.operations.length}`);
530
+
531
+ for (const currentOperation of output.operations) {
532
+ writeLine(
533
+ stream,
534
+ `- ${currentOperation.action}: ${currentOperation.path}${currentOperation.detail ? ` (${currentOperation.detail})` : ""}${currentOperation.command ? ` [${currentOperation.command}]` : ""}`,
535
+ );
536
+ }
537
+ }
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export class CliError extends Error {
6
+ constructor(message, exitCode = 1) {
7
+ super(message);
8
+ this.name = "CliError";
9
+ this.exitCode = exitCode;
10
+ }
11
+ }
12
+
13
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ export const repoRoot = path.resolve(cliDir, "../..");
16
+ export const packageTemplatesRoot = path.join(repoRoot, "package-templates");
17
+
18
+ export function writeLine(stream, message = "") {
19
+ const target = stream ?? process.stdout;
20
+ target.write(`${message}\n`);
21
+ }
22
+
23
+ export function resolveTarget(input, cwd = process.cwd()) {
24
+ return path.resolve(cwd, input ?? ".");
25
+ }
26
+
27
+ export function fromTemplates(...segments) {
28
+ return path.join(packageTemplatesRoot, ...segments);
29
+ }
30
+
31
+ export async function pathExists(targetPath) {
32
+ try {
33
+ await fs.access(targetPath);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export async function readJson(targetPath) {
41
+ try {
42
+ return JSON.parse(await fs.readFile(targetPath, "utf8"));
43
+ } catch (error) {
44
+ throw new CliError(`Failed to read JSON: ${targetPath}\n${error.message}`);
45
+ }
46
+ }
47
+
48
+ export async function writeJson(targetPath, value, { dryRun = false } = {}) {
49
+ if (dryRun) {
50
+ return;
51
+ }
52
+
53
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
54
+ await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`);
55
+ }
56
+
57
+ export async function copyFileSafe(sourcePath, targetPath, options = {}) {
58
+ const { dryRun = false, overwrite = false } = options;
59
+ const exists = await pathExists(targetPath);
60
+
61
+ if (exists && !overwrite) {
62
+ return { action: "skip", reason: "exists", targetPath };
63
+ }
64
+
65
+ if (!dryRun) {
66
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
67
+ await fs.copyFile(sourcePath, targetPath);
68
+ }
69
+
70
+ if (exists && overwrite) {
71
+ return { action: dryRun ? "would-overwrite" : "overwrite", targetPath };
72
+ }
73
+
74
+ return { action: dryRun ? "would-copy" : "copy", targetPath };
75
+ }