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,433 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ciWorkflowFiles, ciWorkflowRelativePath, renderedCiWorkflow } from "./ci-workflows.mjs";
4
+ import {
5
+ dependencyInstallOperation,
6
+ planDependencyInstall,
7
+ preflightDependencyInstaller,
8
+ runDependencyInstall,
9
+ } from "./dependency-installer.mjs";
10
+ import { renderClaudeHookSettings } from "./claude-hooks.mjs";
11
+ import { installStatePath, writeInstallState } from "./install-state.mjs";
12
+ import { DEFAULT_PACKAGE_MANAGER, detectPackageManager, validatePackageManager } from "./package-manager.mjs";
13
+ import { getProfileDocFiles } from "./profile-docs.mjs";
14
+ import { parseProfiles } from "./profile.mjs";
15
+ import { getProfileScripts, getProfileSupportScripts } from "./profile-scripts.mjs";
16
+ import {
17
+ CliError,
18
+ copyFileSafe,
19
+ fromTemplates,
20
+ pathExists,
21
+ readJson,
22
+ resolveTarget,
23
+ writeJson,
24
+ writeLine,
25
+ } from "./utils.mjs";
26
+
27
+ const INIT_USAGE = `ai-check-template init
28
+
29
+ Usage:
30
+ ai-check-template init --target <dir> --profile <name> --yes [options]
31
+
32
+ Options:
33
+ --target <dir> Target project directory. Defaults to the current directory.
34
+ --profile <name> Base profile, optionally with +supabase-rls. Defaults to react-nextjs.
35
+ --package-manager <name> Package manager: pnpm, npm, yarn, or bun. Defaults to target detection.
36
+ --ci <mode> CI mode: direct, reusable, or none. Defaults to direct.
37
+ --claude-hooks Copy Claude hook rule and merge hook settings.
38
+ --install-deps Install missing dev dependencies for generated package scripts.
39
+ --dry-run Print planned operations without writing files.
40
+ --yes Confirm non-interactive writes.
41
+ --overwrite Replace conflicting files/scripts.`;
42
+
43
+ export async function runInit(argv, io = {}) {
44
+ const options = parseInitArgs(argv, io.cwd ?? process.cwd());
45
+
46
+ if (options.help) {
47
+ writeLine(io.stdout, INIT_USAGE);
48
+ return;
49
+ }
50
+
51
+ if (!options.yes && !options.dryRun) {
52
+ throw new CliError("Refusing to write without --yes. Use --dry-run to preview.");
53
+ }
54
+
55
+ const profile = parseProfiles(options.profile);
56
+ const targetDir = await normalizeTargetDir(options.target);
57
+ const packageJsonPath = path.join(targetDir, "package.json");
58
+ const packageManager = options.explicit.packageManager
59
+ ? options.packageManager
60
+ : await detectPackageManager(targetDir);
61
+ const writeOptions = { ...options, packageManager };
62
+
63
+ if (!(await pathExists(packageJsonPath))) {
64
+ throw new CliError(`Target project must contain package.json: ${packageJsonPath}`);
65
+ }
66
+
67
+ const dependencyInstallPlan = writeOptions.installDeps
68
+ ? await planDependencyInstall(packageJsonPath, profile, writeOptions.packageManager)
69
+ : null;
70
+
71
+ if (dependencyInstallPlan && !writeOptions.dryRun) {
72
+ preflightDependencyInstaller(dependencyInstallPlan, targetDir);
73
+ }
74
+
75
+ const operations = [];
76
+
77
+ await mergePackageScripts(packageJsonPath, profile, writeOptions, operations);
78
+ await copyScripts(targetDir, writeOptions, operations);
79
+ await copyProfileDocs(targetDir, profile, writeOptions, operations);
80
+ await copyCiFiles(targetDir, writeOptions, operations);
81
+
82
+ if (writeOptions.claudeHooks) {
83
+ await copyClaudeHooks(targetDir, writeOptions, operations);
84
+ }
85
+
86
+ await writeInitInstallState(targetDir, profile, writeOptions, operations);
87
+ await maybeInstallDependencies(targetDir, dependencyInstallPlan, writeOptions, operations);
88
+
89
+ writeLine(io.stdout, `ai-check-template init ${writeOptions.dryRun ? "dry-run" : "completed"}`);
90
+ writeLine(io.stdout, `target: ${targetDir}`);
91
+ writeLine(io.stdout, `profile: ${profile.all.join("+")}`);
92
+ writeLine(io.stdout, `package-manager: ${writeOptions.packageManager}`);
93
+ for (const operation of operations) {
94
+ writeLine(
95
+ io.stdout,
96
+ `${operation.action}: ${relativeTarget(targetDir, operation.targetPath)}${operation.reason ? ` (${operation.reason})` : ""}${operation.command ? ` [${operation.command}]` : ""}`,
97
+ );
98
+ }
99
+ }
100
+
101
+ function parseInitArgs(argv, cwd) {
102
+ const options = {
103
+ target: cwd,
104
+ profile: "react-nextjs",
105
+ packageManager: DEFAULT_PACKAGE_MANAGER,
106
+ ci: "direct",
107
+ claudeHooks: false,
108
+ installDeps: false,
109
+ dryRun: false,
110
+ yes: false,
111
+ overwrite: false,
112
+ help: false,
113
+ explicit: {
114
+ packageManager: false,
115
+ },
116
+ };
117
+
118
+ for (let index = 0; index < argv.length; index += 1) {
119
+ const arg = argv[index];
120
+
121
+ if (arg === "--help" || arg === "-h") {
122
+ options.help = true;
123
+ continue;
124
+ }
125
+
126
+ if (arg === "--claude-hooks") {
127
+ options.claudeHooks = true;
128
+ continue;
129
+ }
130
+
131
+ if (arg === "--install-deps") {
132
+ options.installDeps = true;
133
+ continue;
134
+ }
135
+
136
+ if (arg === "--dry-run") {
137
+ options.dryRun = true;
138
+ continue;
139
+ }
140
+
141
+ if (arg === "--yes") {
142
+ options.yes = true;
143
+ continue;
144
+ }
145
+
146
+ if (arg === "--overwrite") {
147
+ options.overwrite = true;
148
+ continue;
149
+ }
150
+
151
+ if (arg.startsWith("--target=")) {
152
+ options.target = resolveTarget(arg.slice("--target=".length), cwd);
153
+ continue;
154
+ }
155
+
156
+ if (arg === "--target") {
157
+ options.target = resolveTarget(readFlagValue(argv, (index += 1), arg), cwd);
158
+ continue;
159
+ }
160
+
161
+ if (arg.startsWith("--profile=")) {
162
+ options.profile = arg.slice("--profile=".length);
163
+ continue;
164
+ }
165
+
166
+ if (arg === "--profile") {
167
+ options.profile = readFlagValue(argv, (index += 1), arg);
168
+ continue;
169
+ }
170
+
171
+ if (arg.startsWith("--package-manager=")) {
172
+ options.packageManager = validatePackageManager(arg.slice("--package-manager=".length));
173
+ options.explicit.packageManager = true;
174
+ continue;
175
+ }
176
+
177
+ if (arg === "--package-manager") {
178
+ options.packageManager = validatePackageManager(readFlagValue(argv, (index += 1), arg));
179
+ options.explicit.packageManager = true;
180
+ continue;
181
+ }
182
+
183
+ if (arg.startsWith("--ci=")) {
184
+ options.ci = arg.slice("--ci=".length);
185
+ continue;
186
+ }
187
+
188
+ if (arg === "--ci") {
189
+ options.ci = readFlagValue(argv, (index += 1), arg);
190
+ continue;
191
+ }
192
+
193
+ throw new CliError(`Unknown init option: ${arg}\n\n${INIT_USAGE}`);
194
+ }
195
+
196
+ if (!["direct", "reusable", "none"].includes(options.ci)) {
197
+ throw new CliError("--ci must be one of: direct, reusable, none");
198
+ }
199
+
200
+ return options;
201
+ }
202
+
203
+ function readFlagValue(argv, index, flagName) {
204
+ const value = argv[index];
205
+
206
+ if (!value || value.startsWith("--")) {
207
+ throw new CliError(`Missing value for ${flagName}`);
208
+ }
209
+
210
+ return value;
211
+ }
212
+
213
+ async function normalizeTargetDir(target) {
214
+ const resolved = path.resolve(target);
215
+
216
+ try {
217
+ return await fs.realpath(resolved);
218
+ } catch (error) {
219
+ throw new CliError(`Target directory does not exist: ${resolved}\n${error.message}`);
220
+ }
221
+ }
222
+
223
+ async function mergePackageScripts(packageJsonPath, profile, options, operations) {
224
+ const packageJson = await readJson(packageJsonPath);
225
+ const existingScripts = packageJson.scripts ?? {};
226
+ const expectedScripts = getProfileScripts(profile, { packageManager: options.packageManager });
227
+ const supportScripts = getProfileSupportScripts(profile);
228
+ const nextScripts = { ...existingScripts };
229
+ let changed = false;
230
+
231
+ for (const [name, command] of Object.entries(expectedScripts)) {
232
+ if (existingScripts[name] === command) {
233
+ operations.push({ action: "keep", reason: "same script", targetPath: packageJsonPath });
234
+ continue;
235
+ }
236
+
237
+ if (existingScripts[name] && !options.overwrite) {
238
+ operations.push({ action: "skip", reason: `script ${name} exists`, targetPath: packageJsonPath });
239
+ continue;
240
+ }
241
+
242
+ nextScripts[name] = command;
243
+ changed = true;
244
+ operations.push({
245
+ action: existingScripts[name]
246
+ ? options.dryRun
247
+ ? "would-overwrite"
248
+ : "overwrite"
249
+ : options.dryRun
250
+ ? "would-merge"
251
+ : "merge",
252
+ reason: `script ${name}`,
253
+ targetPath: packageJsonPath,
254
+ });
255
+ }
256
+
257
+ for (const [name, command] of Object.entries(supportScripts)) {
258
+ if (nextScripts[name]) {
259
+ operations.push({ action: "keep", reason: `support script ${name}`, targetPath: packageJsonPath });
260
+ continue;
261
+ }
262
+
263
+ nextScripts[name] = command;
264
+ changed = true;
265
+ operations.push({
266
+ action: options.dryRun ? "would-merge" : "merge",
267
+ reason: `support script ${name}`,
268
+ targetPath: packageJsonPath,
269
+ });
270
+ }
271
+
272
+ if (changed) {
273
+ packageJson.scripts = nextScripts;
274
+ await writeJson(packageJsonPath, packageJson, { dryRun: options.dryRun });
275
+ }
276
+ }
277
+
278
+ async function copyScripts(targetDir, options, operations) {
279
+ for (const fileName of ["ai-check.sh", "ai-check-fast.sh"]) {
280
+ operations.push(
281
+ await copyFileSafe(
282
+ fromTemplates("scripts", fileName),
283
+ path.join(targetDir, "scripts", fileName),
284
+ options,
285
+ ),
286
+ );
287
+ }
288
+ }
289
+
290
+ async function copyCiFiles(targetDir, options, operations) {
291
+ for (const fileName of ciWorkflowFiles(options.ci)) {
292
+ const relativePath = ciWorkflowRelativePath(fileName);
293
+ operations.push(await copyTextFileSafe(
294
+ await renderedCiWorkflow(fileName, options.packageManager),
295
+ path.join(targetDir, relativePath),
296
+ options,
297
+ ));
298
+ }
299
+ }
300
+
301
+ async function copyTextFileSafe(content, targetPath, options = {}) {
302
+ const { dryRun = false, overwrite = false } = options;
303
+ const exists = await pathExists(targetPath);
304
+
305
+ if (exists && !overwrite) {
306
+ return { action: "skip", reason: "exists", targetPath };
307
+ }
308
+
309
+ if (!dryRun) {
310
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
311
+ await fs.writeFile(targetPath, content);
312
+ }
313
+
314
+ if (exists && overwrite) {
315
+ return { action: dryRun ? "would-overwrite" : "overwrite", targetPath };
316
+ }
317
+
318
+ return { action: dryRun ? "would-copy" : "copy", targetPath };
319
+ }
320
+
321
+ async function copyProfileDocs(targetDir, profile, options, operations) {
322
+ for (const file of getProfileDocFiles(profile)) {
323
+ const operation = await copyFileSafe(
324
+ file.sourcePath,
325
+ path.join(targetDir, file.relativePath),
326
+ options,
327
+ );
328
+ operations.push({
329
+ ...operation,
330
+ reason: operation.reason === "exists" ? "profile doc exists" : "profile doc",
331
+ });
332
+ }
333
+ }
334
+
335
+ async function copyClaudeHooks(targetDir, options, operations) {
336
+ operations.push(
337
+ await copyFileSafe(
338
+ fromTemplates(".claude", "rules", "test-rules.md"),
339
+ path.join(targetDir, ".claude", "rules", "test-rules.md"),
340
+ options,
341
+ ),
342
+ );
343
+
344
+ await mergeClaudeSettings(targetDir, options, operations);
345
+ }
346
+
347
+ async function mergeClaudeSettings(targetDir, options, operations) {
348
+ const targetPath = path.join(targetDir, ".claude", "settings.json");
349
+ const fragment = renderClaudeHookSettings(
350
+ await readJson(fromTemplates(".claude", "settings.hook-fragment.json")),
351
+ options.packageManager,
352
+ );
353
+ const settings = (await pathExists(targetPath)) ? await readJson(targetPath) : {};
354
+ const nextSettings = { ...settings, hooks: { ...(settings.hooks ?? {}) } };
355
+ let changed = false;
356
+
357
+ for (const [name, hooks] of Object.entries(fragment.hooks ?? {})) {
358
+ if (nextSettings.hooks[name] && !options.overwrite) {
359
+ operations.push({ action: "skip", reason: `Claude hook ${name} exists`, targetPath });
360
+ continue;
361
+ }
362
+
363
+ nextSettings.hooks[name] = hooks;
364
+ changed = true;
365
+ operations.push({
366
+ action: nextSettings.hooks[name] && settings.hooks?.[name]
367
+ ? options.dryRun
368
+ ? "would-overwrite"
369
+ : "overwrite"
370
+ : options.dryRun
371
+ ? "would-merge"
372
+ : "merge",
373
+ reason: `Claude hook ${name}`,
374
+ targetPath,
375
+ });
376
+ }
377
+
378
+ if (changed) {
379
+ await writeJson(targetPath, nextSettings, { dryRun: options.dryRun });
380
+ }
381
+ }
382
+
383
+ async function writeInitInstallState(targetDir, profile, options, operations) {
384
+ const targetPath = installStatePath(targetDir);
385
+ const exists = await pathExists(targetPath);
386
+ operations.push({
387
+ action: exists
388
+ ? options.dryRun
389
+ ? "would-update"
390
+ : "update"
391
+ : options.dryRun
392
+ ? "would-create"
393
+ : "create",
394
+ reason: "install state",
395
+ targetPath,
396
+ });
397
+
398
+ await writeInstallState(
399
+ targetDir,
400
+ {
401
+ profile,
402
+ packageManager: options.packageManager,
403
+ ci: options.ci,
404
+ claudeHooks: options.claudeHooks,
405
+ },
406
+ { dryRun: options.dryRun },
407
+ );
408
+ }
409
+
410
+ async function maybeInstallDependencies(targetDir, dependencyInstallPlan, options, operations) {
411
+ if (!dependencyInstallPlan) {
412
+ return;
413
+ }
414
+
415
+ const dependencyOperation = dependencyInstallOperation(dependencyInstallPlan, {
416
+ dryRun: options.dryRun,
417
+ path: "package.json",
418
+ });
419
+ operations.push({
420
+ action: dependencyOperation.action,
421
+ reason: dependencyOperation.detail,
422
+ targetPath: path.join(targetDir, dependencyOperation.path),
423
+ ...(dependencyOperation.command ? { command: dependencyOperation.command } : {}),
424
+ });
425
+
426
+ if (!options.dryRun) {
427
+ runDependencyInstall(dependencyInstallPlan, targetDir);
428
+ }
429
+ }
430
+
431
+ function relativeTarget(targetDir, targetPath) {
432
+ return path.relative(targetDir, targetPath) || ".";
433
+ }
@@ -0,0 +1,242 @@
1
+ import path from "node:path";
2
+ import { DEFAULT_PACKAGE_MANAGER, validatePackageManager } from "./package-manager.mjs";
3
+ import { parseProfiles } from "./profile.mjs";
4
+ import {
5
+ CliError,
6
+ pathExists,
7
+ readJson,
8
+ repoRoot,
9
+ writeJson,
10
+ } from "./utils.mjs";
11
+
12
+ export const INSTALL_STATE_FILE = ".ai-check-template.json";
13
+ export const INSTALL_STATE_SCHEMA_VERSION = 1;
14
+
15
+ const PACKAGE_NAME = "ai-check-template";
16
+ const VALID_CI_MODES = new Set(["direct", "reusable", "none"]);
17
+
18
+ export function installStatePath(targetDir) {
19
+ return path.join(targetDir, INSTALL_STATE_FILE);
20
+ }
21
+
22
+ export async function buildInstallState({ profile, ci, claudeHooks, packageManager = DEFAULT_PACKAGE_MANAGER }) {
23
+ const packageJson = await readJson(path.join(repoRoot, "package.json"));
24
+ const parsedProfile = normalizeProfile(profile);
25
+ const normalizedPackageManager = validatePackageManager(packageManager);
26
+
27
+ return {
28
+ schemaVersion: INSTALL_STATE_SCHEMA_VERSION,
29
+ packageName: packageJson.name ?? PACKAGE_NAME,
30
+ packageVersion: packageJson.version ?? "0.0.0",
31
+ profile: serializeProfile(parsedProfile),
32
+ packageManager: normalizedPackageManager,
33
+ ci,
34
+ claudeHooks: Boolean(claudeHooks),
35
+ managedBy: PACKAGE_NAME,
36
+ };
37
+ }
38
+
39
+ export async function writeInstallState(targetDir, input, { dryRun = false } = {}) {
40
+ const state = await buildInstallState(input);
41
+ await writeJson(installStatePath(targetDir), state, { dryRun });
42
+ return state;
43
+ }
44
+
45
+ export async function loadInstallState(targetDir) {
46
+ const targetPath = installStatePath(targetDir);
47
+
48
+ if (!(await pathExists(targetPath))) {
49
+ return { source: "defaults", state: null, error: null };
50
+ }
51
+
52
+ let state;
53
+ try {
54
+ state = await readJson(targetPath);
55
+ } catch (error) {
56
+ return invalidState("invalid-install-state", error.message);
57
+ }
58
+
59
+ return validateInstallState(state);
60
+ }
61
+
62
+ export function resolveEffectiveOptions(options, installState) {
63
+ const state = installState?.source === "state" ? installState.state : null;
64
+ const stateProfile = state ? serializeProfile(state.profile) : null;
65
+ const profileInput = options.explicit.profile
66
+ ? options.profile
67
+ : stateProfile ?? parseProfiles(options.profile);
68
+ const profile = normalizeProfile(profileInput);
69
+
70
+ return {
71
+ profile,
72
+ packageManager: options.explicit.packageManager
73
+ ? options.packageManager
74
+ : state?.packageManager ?? options.packageManager ?? DEFAULT_PACKAGE_MANAGER,
75
+ ci: options.explicit.ci ? options.ci : state?.ci ?? options.ci,
76
+ claudeHooks: options.explicit.claudeHooks
77
+ ? options.claudeHooks
78
+ : state?.claudeHooks ?? options.claudeHooks,
79
+ };
80
+ }
81
+
82
+ export function installStateIssue(installState) {
83
+ if (!installState?.error) {
84
+ return null;
85
+ }
86
+
87
+ return {
88
+ code: installState.error.code,
89
+ path: INSTALL_STATE_FILE,
90
+ message: installState.error.message,
91
+ };
92
+ }
93
+
94
+ export function installationSummary(installState) {
95
+ const summary = {
96
+ source: installState?.source ?? "defaults",
97
+ path: INSTALL_STATE_FILE,
98
+ };
99
+
100
+ if (installState?.state) {
101
+ return {
102
+ ...summary,
103
+ schemaVersion: installState.state.schemaVersion,
104
+ packageVersion: installState.state.packageVersion,
105
+ profile: installState.state.profile,
106
+ packageManager: installState.state.packageManager,
107
+ ci: installState.state.ci,
108
+ claudeHooks: installState.state.claudeHooks,
109
+ };
110
+ }
111
+
112
+ if (installState?.error) {
113
+ return {
114
+ ...summary,
115
+ error: installState.error,
116
+ };
117
+ }
118
+
119
+ return summary;
120
+ }
121
+
122
+ export function effectiveOptionsSummary(effectiveOptions) {
123
+ return {
124
+ profile: effectiveOptions.profile.all.join("+"),
125
+ profiles: serializeProfile(effectiveOptions.profile),
126
+ packageManager: effectiveOptions.packageManager,
127
+ ci: effectiveOptions.ci,
128
+ claudeHooks: effectiveOptions.claudeHooks,
129
+ };
130
+ }
131
+
132
+ export function assertWritableInstallState(installState) {
133
+ if (installState?.error) {
134
+ throw new CliError(`Invalid install state: ${installState.error.message}`);
135
+ }
136
+ }
137
+
138
+ export function validateCiMode(ci) {
139
+ if (!VALID_CI_MODES.has(ci)) {
140
+ throw new CliError("--ci must be one of: direct, reusable, none");
141
+ }
142
+ }
143
+
144
+ function validateInstallState(state) {
145
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
146
+ return invalidState("invalid-install-state", "Install state must be a JSON object");
147
+ }
148
+
149
+ if (state.schemaVersion !== INSTALL_STATE_SCHEMA_VERSION) {
150
+ return invalidState(
151
+ "unsupported-install-state",
152
+ `Unsupported install state schemaVersion: ${String(state.schemaVersion)}`,
153
+ );
154
+ }
155
+
156
+ if (state.packageName !== PACKAGE_NAME || state.managedBy !== PACKAGE_NAME) {
157
+ return invalidState("invalid-install-state", "Install state is not managed by ai-check-template");
158
+ }
159
+
160
+ if (typeof state.packageVersion !== "string" || state.packageVersion.length === 0) {
161
+ return invalidState("invalid-install-state", "Install state packageVersion must be a string");
162
+ }
163
+
164
+ let packageManager;
165
+ try {
166
+ packageManager = state.packageManager === undefined
167
+ ? DEFAULT_PACKAGE_MANAGER
168
+ : validatePackageManager(state.packageManager);
169
+ } catch (error) {
170
+ return invalidState("invalid-install-state", error.message);
171
+ }
172
+
173
+ let profile;
174
+ try {
175
+ profile = normalizeProfile(state.profile);
176
+ } catch (error) {
177
+ return invalidState("invalid-install-state", error.message);
178
+ }
179
+
180
+ if (!VALID_CI_MODES.has(state.ci)) {
181
+ return invalidState("invalid-install-state", "Install state ci must be direct, reusable, or none");
182
+ }
183
+
184
+ if (typeof state.claudeHooks !== "boolean") {
185
+ return invalidState("invalid-install-state", "Install state claudeHooks must be a boolean");
186
+ }
187
+
188
+ return {
189
+ source: "state",
190
+ state: {
191
+ schemaVersion: state.schemaVersion,
192
+ packageName: state.packageName,
193
+ packageVersion: state.packageVersion,
194
+ profile: serializeProfile(profile),
195
+ packageManager,
196
+ ci: state.ci,
197
+ claudeHooks: state.claudeHooks,
198
+ managedBy: state.managedBy,
199
+ },
200
+ error: null,
201
+ };
202
+ }
203
+
204
+ function normalizeProfile(profile) {
205
+ if (typeof profile === "string") {
206
+ return parseProfiles(profile);
207
+ }
208
+
209
+ if (profile?.base && Array.isArray(profile.addons) && Array.isArray(profile.all)) {
210
+ const parsed = parseProfiles(profile.all.join("+"));
211
+ if (parsed.base !== profile.base || parsed.addons.join("+") !== profile.addons.join("+")) {
212
+ throw new CliError("Install state profile fields are inconsistent");
213
+ }
214
+ return parsed;
215
+ }
216
+
217
+ if (profile?.base && Array.isArray(profile.addons)) {
218
+ return parseProfiles([profile.base, ...profile.addons].join("+"));
219
+ }
220
+
221
+ if (profile?.all && Array.isArray(profile.all)) {
222
+ return parseProfiles(profile.all.join("+"));
223
+ }
224
+
225
+ throw new CliError("Install state profile is invalid");
226
+ }
227
+
228
+ function serializeProfile(profile) {
229
+ return {
230
+ base: profile.base,
231
+ addons: [...profile.addons],
232
+ all: [...profile.all],
233
+ };
234
+ }
235
+
236
+ function invalidState(code, message) {
237
+ return {
238
+ source: code === "unsupported-install-state" ? "unsupported" : "invalid",
239
+ state: null,
240
+ error: { code, message },
241
+ };
242
+ }