dev-harness-cli 1.0.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +299 -0
  3. package/adapters/amazon-q/README.md +23 -0
  4. package/adapters/antigravity/README.md +22 -0
  5. package/adapters/claude-code/README.md +30 -0
  6. package/adapters/cline/README.md +23 -0
  7. package/adapters/codex/README.md +31 -0
  8. package/adapters/copilot/README.md +23 -0
  9. package/adapters/cursor/README.md +29 -0
  10. package/adapters/gemini/README.md +23 -0
  11. package/adapters/generic/README.md +40 -0
  12. package/adapters/hermes/README.md +31 -0
  13. package/adapters/hermes/SKILL.md +89 -0
  14. package/adapters/hermes/scripts/init.mjs +27 -0
  15. package/adapters/hermes/scripts/phase.mjs +27 -0
  16. package/adapters/hermes/scripts/validate.mjs +27 -0
  17. package/adapters/kilo-code/README.md +23 -0
  18. package/adapters/openclaw/README.md +22 -0
  19. package/adapters/pi/README.md +22 -0
  20. package/adapters/roo/README.md +23 -0
  21. package/adapters/windsurf/README.md +23 -0
  22. package/cli/commands/checkpoint.mjs +94 -0
  23. package/cli/commands/config.mjs +268 -0
  24. package/cli/commands/contract.mjs +155 -0
  25. package/cli/commands/detect-tool.mjs +112 -0
  26. package/cli/commands/init.mjs +351 -0
  27. package/cli/commands/learn.mjs +47 -0
  28. package/cli/commands/pause.mjs +34 -0
  29. package/cli/commands/phase.mjs +182 -0
  30. package/cli/commands/resume.mjs +33 -0
  31. package/cli/commands/rollback.mjs +261 -0
  32. package/cli/commands/set-mode.mjs +75 -0
  33. package/cli/commands/status.mjs +168 -0
  34. package/cli/commands/validate.mjs +118 -0
  35. package/cli/commands/worktree.mjs +298 -0
  36. package/cli/harness-dev.mjs +88 -0
  37. package/cli/lib/args.mjs +111 -0
  38. package/cli/lib/command-helpers.mjs +50 -0
  39. package/cli/lib/config-registry.mjs +329 -0
  40. package/cli/lib/constants.mjs +30 -0
  41. package/cli/lib/contract.mjs +306 -0
  42. package/cli/lib/detect-stack.mjs +235 -0
  43. package/cli/lib/errors.mjs +71 -0
  44. package/cli/lib/file-io.mjs +90 -0
  45. package/cli/lib/gates.mjs +492 -0
  46. package/cli/lib/git.mjs +144 -0
  47. package/cli/lib/help.mjs +246 -0
  48. package/cli/lib/modes.mjs +92 -0
  49. package/cli/lib/output.mjs +49 -0
  50. package/cli/lib/paths.mjs +75 -0
  51. package/cli/lib/phases.mjs +58 -0
  52. package/cli/lib/platform.mjs +78 -0
  53. package/cli/lib/progress.mjs +357 -0
  54. package/cli/lib/ralph-inner.mjs +314 -0
  55. package/cli/lib/ralph-outer.mjs +249 -0
  56. package/cli/lib/ralph-output.mjs +178 -0
  57. package/cli/lib/scaffold.mjs +431 -0
  58. package/cli/lib/schemas/stacks.json +477 -0
  59. package/cli/lib/state.mjs +333 -0
  60. package/cli/lib/templates.mjs +264 -0
  61. package/cli/lib/tool-registry.mjs +218 -0
  62. package/cli/lib/validate-schema.mjs +131 -0
  63. package/cli/lib/vars.mjs +114 -0
  64. package/package.json +50 -0
  65. package/schema/harness-config.schema.json +127 -0
  66. package/templates/AGENTS.md +63 -0
  67. package/templates/ci/github-actions.yml +78 -0
  68. package/templates/ci/gitlab-ci.yml +59 -0
  69. package/templates/docs/agents/evaluator.md +14 -0
  70. package/templates/docs/agents/generator.md +13 -0
  71. package/templates/docs/agents/planner.md +13 -0
  72. package/templates/docs/agents/simplifier.md +13 -0
  73. package/templates/docs/phases/build.md +41 -0
  74. package/templates/docs/phases/define.md +51 -0
  75. package/templates/docs/phases/plan.md +36 -0
  76. package/templates/docs/phases/review.md +42 -0
  77. package/templates/docs/phases/ship.md +43 -0
  78. package/templates/docs/phases/simplify.md +40 -0
  79. package/templates/docs/phases/verify.md +38 -0
  80. package/templates/evaluator-rubric.md +28 -0
  81. package/templates/init.ps1 +97 -0
  82. package/templates/init.sh +102 -0
  83. package/templates/sprint-contract.md +31 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * state — Harness config & state machine.
3
+ *
4
+ * Reads/writes harness-config.json, manages phase transitions,
5
+ * provides dot-notation access for get/set operations.
6
+ *
7
+ * Usage:
8
+ * import { loadConfig, saveConfig, get, set, transitionPhase } from './state.mjs';
9
+ * const cfg = loadConfig('/path/to/project');
10
+ * set('/path/to/project', 'gates.enabled', true);
11
+ * transitionPhase('/path/to/project', 'build');
12
+ */
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ import { validateAgainstSchema } from './validate-schema.mjs';
16
+ import { getGitBranch, isGitClean, getLastCommitMessage, hasGitUpstream } from './git.mjs';
17
+ import { CONFIG_SCHEMA_PATH, CONFIG_PATH } from './paths.mjs';
18
+ import { PHASE_ORDER, getPhaseOrder, isValidTransition } from './phases.mjs';
19
+ import { DEFAULT_MAX_RETRIES, COVERAGE_THRESHOLD_DEFAULT } from './constants.mjs';
20
+
21
+ // Re-export phase logic for backward compatibility (callers import from state.mjs).
22
+ export { PHASE_ORDER, getPhaseOrder, isValidTransition };
23
+
24
+ // ── Default config ───────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Canonical default harness-config.json.
28
+ * @returns {object}
29
+ */
30
+ export function getDefaultConfig() {
31
+ return {
32
+ version: '1.0',
33
+ stack: null,
34
+ stackMeta: null,
35
+ agentTool: null,
36
+ mode: 'copilot',
37
+ currentPhase: null,
38
+ paused: false,
39
+ features: {
40
+ remaining: 0,
41
+ passing: 0,
42
+ total: 0,
43
+ },
44
+ gates: {
45
+ enabled: false,
46
+ checks: ['all'],
47
+ coverage: {
48
+ enabled: false,
49
+ threshold: COVERAGE_THRESHOLD_DEFAULT,
50
+ },
51
+ },
52
+ git: {
53
+ autoCommit: false,
54
+ autoTag: false,
55
+ resetOnRetry: false,
56
+ branch: null,
57
+ clean: true,
58
+ hasUpstream: false,
59
+ lastCommitMessage: null,
60
+ },
61
+ phases: {
62
+ enabled: ['define', 'plan', 'build', 'verify', 'review', 'ship'],
63
+ },
64
+ agents: {
65
+ tone: {
66
+ planner: 'Analytical and precise. Define clear boundaries.',
67
+ generator: 'Focused and practical. Build what\'s specified, nothing more.',
68
+ evaluator: 'Skeptical and thorough. Accept only compelling evidence.',
69
+ simplifier: 'Relentless about clarity. Delete more than you add.',
70
+ },
71
+ },
72
+ maxRetries: DEFAULT_MAX_RETRIES,
73
+ retryCount: 0,
74
+ pipelineIteration: 0,
75
+ gateHistory: [],
76
+ };
77
+ }
78
+
79
+ // ── File I/O ─────────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Get the path to harness-config.json for a given project directory.
83
+ * @param {string} targetDir
84
+ * @returns {string}
85
+ */
86
+ export function getConfigPath(targetDir) {
87
+ return CONFIG_PATH(targetDir);
88
+ }
89
+
90
+ /**
91
+ * Deep-merge a partial config into defaults.
92
+ * Missing keys get default values; extra keys preserved.
93
+ * @param {object} defaults
94
+ * @param {object} partial
95
+ * @returns {object}
96
+ */
97
+ function deepMerge(defaults, partial) {
98
+ const result = { ...defaults };
99
+ for (const key of Object.keys(partial)) {
100
+ if (
101
+ defaults[key] && typeof defaults[key] === 'object' && !Array.isArray(defaults[key]) &&
102
+ partial[key] && typeof partial[key] === 'object' && !Array.isArray(partial[key])
103
+ ) {
104
+ result[key] = deepMerge(defaults[key], partial[key]);
105
+ } else {
106
+ result[key] = partial[key];
107
+ }
108
+ }
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * Load harness-config.json, merging with defaults.
114
+ * Returns defaults if file doesn't exist or is invalid.
115
+ * @param {string} targetDir
116
+ * @returns {{ config: object, path: string, ok: boolean, error: string|null }}
117
+ */
118
+ export function loadConfig(targetDir) {
119
+ const cfgPath = getConfigPath(targetDir);
120
+ const defaults = getDefaultConfig();
121
+
122
+ if (!existsSync(cfgPath)) {
123
+ return {
124
+ config: defaults,
125
+ path: cfgPath,
126
+ ok: false,
127
+ error: `Not found: ${cfgPath}. Run: harness-dev init`,
128
+ };
129
+ }
130
+
131
+ try {
132
+ const raw = readFileSync(cfgPath, 'utf-8');
133
+ const parsed = JSON.parse(raw);
134
+ const config = deepMerge(defaults, parsed);
135
+ // Schema validation — non-blocking: return errors in result so callers
136
+ // (e.g. status command) can surface them. Library does NOT write to stderr
137
+ // to keep the error contract clean (stderr reserved for real errors).
138
+ const schemaResult = validateAgainstSchema(config, CONFIG_SCHEMA_PATH);
139
+ return { config, path: cfgPath, ok: true, error: null, schemaErrors: schemaResult.ok ? [] : schemaResult.errors };
140
+ } catch (err) {
141
+ return {
142
+ config: defaults,
143
+ path: cfgPath,
144
+ ok: false,
145
+ error: `Invalid config: ${err.message}`,
146
+ };
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Save config to harness-config.json.
152
+ * @param {string} targetDir
153
+ * @param {object} cfg
154
+ * @returns {{ ok: boolean, error: string|null }}
155
+ */
156
+ export function saveConfig(targetDir, cfg) {
157
+ try {
158
+ const cfgPath = getConfigPath(targetDir);
159
+ mkdirSync(dirname(cfgPath), { recursive: true });
160
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
161
+ return { ok: true, error: null };
162
+ } catch (err) {
163
+ return { ok: false, error: err.message };
164
+ }
165
+ }
166
+
167
+ // ── Dot-notation access ──────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Resolve a dot-notation key against an object.
171
+ * @param {object} obj
172
+ * @param {string} key — e.g. "gates.enabled"
173
+ * @returns {any}
174
+ */
175
+ function resolveKey(obj, key) {
176
+ const parts = key.split('.');
177
+ let current = obj;
178
+ for (const part of parts) {
179
+ if (current === null || current === undefined || typeof current !== 'object') {
180
+ return undefined;
181
+ }
182
+ current = current[part];
183
+ }
184
+ return current;
185
+ }
186
+
187
+ /**
188
+ * Set a dot-notation key on an object (mutates in-place).
189
+ * @param {object} obj
190
+ * @param {string} key
191
+ * @param {any} value
192
+ */
193
+ function setKey(obj, key, value) {
194
+ const parts = key.split('.');
195
+ let current = obj;
196
+ for (let i = 0; i < parts.length - 1; i++) {
197
+ if (!(parts[i] in current) || typeof current[parts[i]] !== 'object') {
198
+ current[parts[i]] = {};
199
+ }
200
+ current = current[parts[i]];
201
+ }
202
+ current[parts[parts.length - 1]] = value;
203
+ }
204
+
205
+ /**
206
+ * Get a config value by dot-notation key.
207
+ * Loads from disk on each call (always fresh).
208
+ * @param {string} targetDir
209
+ * @param {string} key — dot-notation key
210
+ * @returns {{ value: any, ok: boolean, error: string|null }}
211
+ */
212
+ export function get(targetDir, key) {
213
+ const { config, ok, error } = loadConfig(targetDir);
214
+ if (key) {
215
+ const value = resolveKey(config, key);
216
+ return { value: value !== undefined ? value : null, ok, error };
217
+ }
218
+ return { value: config, ok, error };
219
+ }
220
+
221
+ /**
222
+ * Set a config value by dot-notation key and save to disk.
223
+ * @param {string} targetDir
224
+ * @param {string} key — dot-notation key
225
+ * @param {any} value
226
+ * @returns {{ ok: boolean, error: string|null }}
227
+ */
228
+ export function set(targetDir, key, value) {
229
+ const { config } = loadConfig(targetDir);
230
+ setKey(config, key, value);
231
+ return saveConfig(targetDir, config);
232
+ }
233
+
234
+ // ── Phase transitions ────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Record a gate result in gateHistory.
238
+ * @param {object} config
239
+ * @param {string} phase
240
+ * @param {string} result — "pass" | "fail"
241
+ */
242
+ function recordGate(config, phase, result) {
243
+ if (!config.gateHistory) {config.gateHistory = [];}
244
+ config.gateHistory.push({
245
+ phase,
246
+ result,
247
+ timestamp: new Date().toISOString(),
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Transition to the next phase.
253
+ *
254
+ * Steps:
255
+ * 1. Validate transition
256
+ * 2. Record old phase gate to history
257
+ * 3. Update currentPhase
258
+ * 4. Update git.branch, git.clean, git.lastCommitMessage
259
+ * 5. Save config
260
+ *
261
+ * @param {string} targetDir
262
+ * @param {string} toPhase
263
+ * @returns {{ ok: boolean, error: string|null, config: object|null }}
264
+ */
265
+ export function transitionPhase(targetDir, toPhase) {
266
+ const { config, ok, error } = loadConfig(targetDir);
267
+ if (!ok) {
268
+ return { ok: false, error: error || 'Cannot load config', config: null };
269
+ }
270
+
271
+ const enabled = config.phases?.enabled;
272
+ if (!isValidTransition(config.currentPhase, toPhase, enabled)) {
273
+ const order = getPhaseOrder(enabled).join(' → ');
274
+ return {
275
+ ok: false,
276
+ error: `Invalid transition: "${config.currentPhase || 'start'}" → "${toPhase}". Valid order: ${order}`,
277
+ config: null,
278
+ };
279
+ }
280
+
281
+ // Record old phase gate if leaving a phase (skip for same-phase re-run)
282
+ if (config.currentPhase && config.currentPhase !== toPhase) {
283
+ recordGate(config, config.currentPhase, 'pass');
284
+ }
285
+
286
+ // Retry tracking: increment on same-phase re-run, reset on new phase
287
+ const isNewPhase = config.currentPhase !== toPhase;
288
+ if (config.retryCount === undefined) {config.retryCount = 0;}
289
+ if (isNewPhase) {
290
+ config.retryCount = 0;
291
+ } else {
292
+ config.retryCount = (config.retryCount || 0) + 1;
293
+ }
294
+
295
+ // Update phase
296
+ config.currentPhase = toPhase;
297
+
298
+ // Update git metadata
299
+ config.git = config.git || {};
300
+ config.git.branch = getGitBranch(targetDir);
301
+ config.git.clean = isGitClean(targetDir);
302
+ config.git.hasUpstream = hasGitUpstream(targetDir);
303
+ config.git.lastCommitMessage = getLastCommitMessage(targetDir);
304
+
305
+ // Clear pause on transition
306
+ config.paused = false;
307
+
308
+ const saveResult = saveConfig(targetDir, config);
309
+ if (!saveResult.ok) {
310
+ return { ok: false, error: saveResult.error, config: null };
311
+ }
312
+
313
+ return { ok: true, error: null, config };
314
+ }
315
+
316
+ /**
317
+ * Validate config against the JSON schema.
318
+ * Returns list of missing required fields.
319
+ * @param {object} cfg
320
+ * @returns {string[]}
321
+ */
322
+ export function validateConfig(cfg) {
323
+ const required = ['version', 'mode', 'currentPhase', 'gates', 'git', 'phases', 'maxRetries'];
324
+ // Fields where `null` is a valid value (must not report as missing)
325
+ const nullable = new Set(['currentPhase']);
326
+ const missing = [];
327
+ for (const field of required) {
328
+ if (cfg[field] === undefined || (!nullable.has(field) && cfg[field] === null)) {
329
+ missing.push(field);
330
+ }
331
+ }
332
+ return missing;
333
+ }
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * templates — Stack-aware template engine CLI.
4
+ *
5
+ * Processes templates/ files with {{VAR}} substitution using
6
+ * stack-specific variables from stacks.json.
7
+ *
8
+ * Usage:
9
+ * node cli/lib/templates.mjs --stack python --target /tmp/out
10
+ * node cli/lib/templates.mjs --stack node --target /tmp/out --override version=0.1.0
11
+ * node cli/lib/templates.mjs --stack go --target /tmp/out --json
12
+ *
13
+ * Flags:
14
+ * --stack <name> Required. One of: python, node, go, rust, c, cpp, vhdl, verilog
15
+ * --target <dir> Required. Output directory (created if missing)
16
+ * --override k=v Repeatable. Extra template variables
17
+ * --json Machine-parseable JSON output
18
+ * --help Show this message
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from 'node:fs';
22
+ import { resolve, join, basename, dirname } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { getStackVars, listStacks } from './vars.mjs';
25
+ import { TEMPLATES_DIR, PROJECT_ROOT } from './paths.mjs';
26
+ import { EXECUTABLE_MODE } from './constants.mjs';
27
+
28
+ const PACKAGE_PATH = resolve(PROJECT_ROOT, 'package.json');
29
+
30
+ // ── helpers ──────────────────────────────────────────────────────────────────
31
+
32
+ export function loadPackageVersion() {
33
+ try {
34
+ const raw = readFileSync(PACKAGE_PATH, 'utf-8');
35
+ return JSON.parse(raw).version || '0.1.0';
36
+ } catch {
37
+ return '0.1.0';
38
+ }
39
+ }
40
+
41
+ function parseOverrides(args) {
42
+ const overrides = {};
43
+ for (let i = 0; i < args.length; i++) {
44
+ const arg = args[i];
45
+ if (arg.startsWith('--override=')) {
46
+ // --override=k=v (equals between flag & value)
47
+ const val = arg.slice('--override='.length);
48
+ const eqIdx = val.indexOf('=');
49
+ if (eqIdx !== -1) {
50
+ overrides[val.slice(0, eqIdx)] = val.slice(eqIdx + 1);
51
+ }
52
+ } else if (arg === '--override' && i + 1 < args.length) {
53
+ // --override key=value or --override key value
54
+ const key = args[i + 1];
55
+ const eqIdx = key.indexOf('=');
56
+ if (eqIdx !== -1) {
57
+ // --override key=value (one arg with =)
58
+ overrides[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
59
+ i++;
60
+ } else if (i + 2 < args.length) {
61
+ // --override key value (two separate args, no =)
62
+ overrides[key] = args[i + 2];
63
+ i += 2;
64
+ }
65
+ }
66
+ }
67
+ return overrides;
68
+ }
69
+
70
+ /**
71
+ * Substitute {{VAR}} placeholders in text with actual values.
72
+ * Unknown variables are left as-is (optional variables in templates).
73
+ * @param {string} text
74
+ * @param {Record<string, string>} vars
75
+ * @returns {string}
76
+ */
77
+ export function substitute(text, vars) {
78
+ return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
79
+ return vars[key] !== undefined ? vars[key] : match;
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Discover template files in the templates directory.
85
+ * Returns sorted list of absolute paths, excluding dotfiles.
86
+ * @returns {string[]}
87
+ */
88
+ export function discoverTemplates() {
89
+ try {
90
+ const result = [];
91
+ const walk = (dir) => {
92
+ let entries;
93
+ try {
94
+ entries = readdirSync(dir, { withFileTypes: true });
95
+ } catch {
96
+ return;
97
+ }
98
+ for (const e of entries) {
99
+ const fullPath = join(dir, e.name);
100
+ if (e.isDirectory() && !e.name.startsWith('.')) {
101
+ walk(fullPath);
102
+ } else if (e.isFile() && !e.name.startsWith('.')) {
103
+ result.push(fullPath);
104
+ }
105
+ }
106
+ };
107
+ walk(TEMPLATES_DIR);
108
+ return result.sort();
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Run the template engine.
116
+ *
117
+ * @param {object} opts
118
+ * @param {string} opts.stack — stack name
119
+ * @param {string} opts.target — output directory
120
+ * @param {object} [opts.overrides] — extra template variables
121
+ * @param {boolean} [opts.json] — JSON output mode
122
+ * @returns {{ files: string[], errors: string[] }}
123
+ */
124
+ export function generateTemplates(opts) {
125
+ const { stack, target, overrides = {} } = opts;
126
+
127
+ // Load stack variables
128
+ const stackVars = getStackVars(stack, {
129
+ harnessVersion: loadPackageVersion(),
130
+ maxRetries: '3',
131
+ ...overrides,
132
+ });
133
+
134
+ // Discover templates
135
+ const templatePaths = discoverTemplates();
136
+ if (templatePaths.length === 0) {
137
+ return { files: [], errors: ['No templates found in ' + TEMPLATES_DIR] };
138
+ }
139
+
140
+ // Ensure target directory exists
141
+ mkdirSync(target, { recursive: true });
142
+
143
+ const created = [];
144
+ const errors = [];
145
+
146
+ for (const tmplPath of templatePaths) {
147
+ const relativePath = tmplPath.startsWith(TEMPLATES_DIR + '/')
148
+ ? tmplPath.slice(TEMPLATES_DIR.length + 1)
149
+ : basename(tmplPath);
150
+ const outPath = join(target, relativePath);
151
+ const outDir = dirname(outPath);
152
+
153
+ try {
154
+ if (outDir !== target) {
155
+ mkdirSync(outDir, { recursive: true });
156
+ }
157
+ const raw = readFileSync(tmplPath, 'utf-8');
158
+ const rendered = substitute(raw, stackVars);
159
+ writeFileSync(outPath, rendered, 'utf-8');
160
+
161
+ // Make .sh files executable
162
+ if (outPath.endsWith('.sh')) {
163
+ chmodSync(outPath, EXECUTABLE_MODE);
164
+ }
165
+
166
+ created.push(outPath);
167
+ } catch (err) {
168
+ errors.push(`${tmplPath}: ${err.message}`);
169
+ }
170
+ }
171
+
172
+ return { files: created, errors };
173
+ }
174
+
175
+ // ── CLI entry ────────────────────────────────────────────────────────────────
176
+
177
+ function main() {
178
+ const args = process.argv.slice(2);
179
+
180
+ if (args.includes('--help') || args.includes('-h')) {
181
+ process.stdout.write(`Usage: node cli/lib/templates.mjs --stack <name> --target <dir> [options]
182
+
183
+ Flags:
184
+ --stack <name> Required. Project stack (${listStacks().join(', ')})
185
+ --target <dir> Required. Output directory
186
+ --override k=v Repeatable. Extra variables
187
+ --json Machine-parseable output
188
+ --help Show this help
189
+ `);
190
+ return;
191
+ }
192
+
193
+ const json = args.includes('--json');
194
+ const stackIdx = args.indexOf('--stack');
195
+ const targetIdx = args.indexOf('--target');
196
+
197
+ if (stackIdx === -1 || stackIdx + 1 >= args.length) {
198
+ const msg = '--stack is required';
199
+ if (json) {
200
+ process.stderr.write(JSON.stringify({ command: 'generate-templates', status: 'error', message: msg }) + '\n');
201
+ } else {
202
+ process.stderr.write(`Error: ${msg}\n`);
203
+ }
204
+ process.exit(2);
205
+ }
206
+
207
+ if (targetIdx === -1 || targetIdx + 1 >= args.length) {
208
+ const msg = '--target is required';
209
+ if (json) {
210
+ process.stderr.write(JSON.stringify({ command: 'generate-templates', status: 'error', message: msg }) + '\n');
211
+ } else {
212
+ process.stderr.write(`Error: ${msg}\n`);
213
+ }
214
+ process.exit(2);
215
+ }
216
+
217
+ const stack = args[stackIdx + 1];
218
+ const target = resolve(args[targetIdx + 1]);
219
+
220
+ const valid = listStacks();
221
+ if (!valid.includes(stack)) {
222
+ const msg = `Unknown stack "${stack}". Valid: ${valid.join(', ')}`;
223
+ if (json) {
224
+ process.stderr.write(JSON.stringify({ command: 'generate-templates', status: 'error', message: msg }) + '\n');
225
+ } else {
226
+ process.stderr.write(`Error: ${msg}\n`);
227
+ }
228
+ process.exit(2);
229
+ }
230
+
231
+ const overrides = parseOverrides(args);
232
+ const result = generateTemplates({ stack, target, overrides, json });
233
+
234
+ if (json) {
235
+ const successMsg = result.errors.length > 0
236
+ ? `Generated ${result.files.length} file(s) with ${result.errors.length} error(s)`
237
+ : `Generated ${result.files.length} file(s) for stack "${stack}"`;
238
+ process.stdout.write(JSON.stringify({
239
+ command: 'generate-templates',
240
+ status: result.errors.length > 0 ? 'partial' : 'ok',
241
+ message: successMsg,
242
+ stack,
243
+ target,
244
+ filesCreated: result.files.length,
245
+ files: result.files,
246
+ errors: result.errors,
247
+ }) + '\n');
248
+ return;
249
+ }
250
+
251
+ // Human output
252
+ for (const f of result.files) {
253
+ process.stdout.write(` ✓ ${f}\n`);
254
+ }
255
+ for (const e of result.errors) {
256
+ process.stderr.write(` ✗ ${e}\n`);
257
+ }
258
+ process.stdout.write(`\nCreated ${result.files.length} file(s) for stack "${stack}"\n`);
259
+ }
260
+
261
+ // Only run as CLI when called directly (not when imported as module)
262
+ if (process.argv[1] && basename(process.argv[1]) === basename(fileURLToPath(import.meta.url))) {
263
+ main();
264
+ }