@structor-dev/cli 0.1.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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +405 -0
  3. package/bin/structor.mjs +576 -0
  4. package/docs/INIT.md +109 -0
  5. package/docs/adr/0001-default-generated-repo-name.md +9 -0
  6. package/docs/issues/0001-structor-doctor.md +39 -0
  7. package/examples/frontend-backend/harness.config.json +35 -0
  8. package/examples/openai-and-anthropic/harness.config.json +28 -0
  9. package/examples/single-repo/harness.config.json +26 -0
  10. package/harness.config.example.json +38 -0
  11. package/package.json +58 -0
  12. package/schemas/contract-manifest.schema.json +18 -0
  13. package/schemas/harness-config.schema.json +85 -0
  14. package/schemas/task-brief.schema.json +37 -0
  15. package/scripts/check-config.mjs +76 -0
  16. package/scripts/check-contract-manifests.mjs +85 -0
  17. package/scripts/check-model-overlays.mjs +30 -0
  18. package/scripts/check-placeholders.mjs +48 -0
  19. package/scripts/check-task-template.mjs +53 -0
  20. package/scripts/check-template-files.mjs +110 -0
  21. package/scripts/init-harness.mjs +270 -0
  22. package/scripts/lib.mjs +190 -0
  23. package/scripts/smoke-template.mjs +309 -0
  24. package/scripts/validate-governance.mjs +3 -0
  25. package/scripts/validate-template.mjs +16 -0
  26. package/template/.claude/CLAUDE.md.tpl +12 -0
  27. package/template/.claude/rules/harness-client-surfaces.md.tpl +20 -0
  28. package/template/.claude/settings.json.tpl +10 -0
  29. package/template/.codex/hooks.json.tpl +77 -0
  30. package/template/AGENTS.md.tpl +22 -0
  31. package/template/CLAUDE.md.tpl +16 -0
  32. package/template/README.md.tpl +109 -0
  33. package/template/ai/AGENT-GARBAGE-COLLECTION.md.tpl +18 -0
  34. package/template/ai/AGENTS.md.tpl +36 -0
  35. package/template/ai/ARCHITECTURE.md.tpl +35 -0
  36. package/template/ai/CODEX-HOOKS.md.tpl +23 -0
  37. package/template/ai/DECISIONS.md.tpl +22 -0
  38. package/template/ai/DESIGN.md.tpl +22 -0
  39. package/template/ai/HARNESS-ENGINEERING.md.tpl +107 -0
  40. package/template/ai/HARNESS.md.tpl +53 -0
  41. package/template/ai/HUB.md.tpl +53 -0
  42. package/template/ai/PRODUCT-SUMMARY.md.tpl +28 -0
  43. package/template/ai/PRODUCT.md.tpl +32 -0
  44. package/template/ai/QUALITY.md.tpl +37 -0
  45. package/template/ai/READINESS.md.tpl +39 -0
  46. package/template/ai/RUNNER-READINESS.md.tpl +14 -0
  47. package/template/ai/RUNNER-SAFETY.md.tpl +21 -0
  48. package/template/ai/VERSIONING.md.tpl +16 -0
  49. package/template/ai/WORKFLOW.md.tpl +42 -0
  50. package/template/ai/context.md.tpl +17 -0
  51. package/template/ai/contracts/README.md.tpl +23 -0
  52. package/template/ai/contracts/api-boundary.contract.json.tpl +11 -0
  53. package/template/ai/contracts/api-boundary.md.tpl +17 -0
  54. package/template/ai/contracts/app-legibility.contract.json.tpl +11 -0
  55. package/template/ai/contracts/app-legibility.md.tpl +24 -0
  56. package/template/ai/contracts/codex-hooks.contract.json.tpl +15 -0
  57. package/template/ai/contracts/codex-hooks.md.tpl +18 -0
  58. package/template/ai/contracts/github-safety.contract.json.tpl +11 -0
  59. package/template/ai/contracts/github-safety.md.tpl +15 -0
  60. package/template/ai/contracts/release-flow.contract.json.tpl +12 -0
  61. package/template/ai/contracts/release-flow.md.tpl +15 -0
  62. package/template/ai/contracts/repo-boundaries.contract.json.tpl +12 -0
  63. package/template/ai/contracts/repo-boundaries.md.tpl +18 -0
  64. package/template/ai/contracts/security-boundary.contract.json.tpl +11 -0
  65. package/template/ai/contracts/security-boundary.md.tpl +19 -0
  66. package/template/ai/knowledge-manifest.json.tpl +149 -0
  67. package/template/ai/model-overlays/anthropic/CLAUDE.md.tpl +14 -0
  68. package/template/ai/model-overlays/openai/AGENTS.md.tpl +13 -0
  69. package/template/ai/plans/README.md.tpl +10 -0
  70. package/template/ai/plans/tech-debt.md.tpl +7 -0
  71. package/template/ai/skills/README.md.tpl +15 -0
  72. package/template/ai/skills/review-architecture.md.tpl +41 -0
  73. package/template/ai/skills/review-contract-drift.md.tpl +41 -0
  74. package/template/ai/skills/review-governance-drift.md.tpl +42 -0
  75. package/template/ai/skills/review-security.md.tpl +40 -0
  76. package/template/ai/specs/README.md.tpl +14 -0
  77. package/template/ai/templates/README.md.tpl +13 -0
  78. package/template/ai/templates/fixtures/issues/invalid-placeholder.md.tpl +20 -0
  79. package/template/ai/templates/fixtures/issues/invalid-protected-surface.md.tpl +21 -0
  80. package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +105 -0
  81. package/template/ai/templates/issue-template.md.tpl +107 -0
  82. package/template/ai/templates/task-brief-template.md.tpl +185 -0
  83. package/template/ai/workspace/LOCAL-STACK.md.tpl +21 -0
  84. package/template/ai/workspace/REPOS.md.tpl +19 -0
  85. package/template/ai/workspace/SESSION-BOOTSTRAP.md.tpl +27 -0
  86. package/template/ai/workspace/SYSTEM-MAP.md.tpl +19 -0
  87. package/template/ai/workspace/TEST-STRATEGY.md.tpl +22 -0
  88. package/template/consumer/.claude/CLAUDE.md.tpl +14 -0
  89. package/template/consumer/AGENTS.md.tpl +23 -0
  90. package/template/consumer/CLAUDE.md.tpl +15 -0
  91. package/template/scripts/bootstrap-codex-worktree.mjs.tpl +52 -0
  92. package/template/scripts/bootstrap-workspace.mjs.tpl +100 -0
  93. package/template/scripts/check-claude-compatibility.mjs.tpl +120 -0
  94. package/template/scripts/check-codex-hooks.mjs.tpl +190 -0
  95. package/template/scripts/check-contract-manifests.mjs.tpl +81 -0
  96. package/template/scripts/check-garbage-collection.mjs.tpl +25 -0
  97. package/template/scripts/check-html-views.mjs.tpl +60 -0
  98. package/template/scripts/check-issue-template.mjs.tpl +167 -0
  99. package/template/scripts/check-knowledge-manifest.mjs.tpl +82 -0
  100. package/template/scripts/check-overlay-drift.mjs.tpl +49 -0
  101. package/template/scripts/check-plans.mjs.tpl +70 -0
  102. package/template/scripts/check-readiness.mjs.tpl +130 -0
  103. package/template/scripts/check-review-skills.mjs.tpl +48 -0
  104. package/template/scripts/check-task-template.mjs.tpl +63 -0
  105. package/template/scripts/check-template-governance.mjs.tpl +161 -0
  106. package/template/scripts/check-workspace.mjs.tpl +212 -0
  107. package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +122 -0
  108. package/template/scripts/check-worktrees.mjs.tpl +69 -0
  109. package/template/scripts/fixtures/worktrees/README.md.tpl +4 -0
  110. package/template/scripts/generate-html-views.mjs.tpl +189 -0
  111. package/template/scripts/hooks/codex-hook.mjs.tpl +21 -0
  112. package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +114 -0
  113. package/template/scripts/lib/worktree-bootstrap.mjs.tpl +388 -0
  114. package/template/scripts/validate-governance.mjs.tpl +78 -0
  115. package/template/workspace/.claude/CLAUDE.md.tpl +9 -0
  116. package/template/workspace/.claude/rules/harness-client-surfaces.md.tpl +15 -0
  117. package/template/workspace/.claude/settings.json.tpl +10 -0
  118. package/template/workspace/AGENTS.md.tpl +17 -0
  119. package/template/workspace/CLAUDE.md.tpl +18 -0
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from "node:path";
4
+ import { exists, failIfErrors, repoRoot } from "./lib.mjs";
5
+
6
+ const requiredFiles = [
7
+ "template/AGENTS.md.tpl",
8
+ "template/CLAUDE.md.tpl",
9
+ "template/.claude/CLAUDE.md.tpl",
10
+ "template/.claude/rules/harness-client-surfaces.md.tpl",
11
+ "template/.claude/settings.json.tpl",
12
+ "template/.codex/hooks.json.tpl",
13
+ "template/README.md.tpl",
14
+ "template/ai/AGENTS.md.tpl",
15
+ "template/ai/HUB.md.tpl",
16
+ "template/ai/context.md.tpl",
17
+ "template/ai/HARNESS.md.tpl",
18
+ "template/ai/HARNESS-ENGINEERING.md.tpl",
19
+ "template/ai/READINESS.md.tpl",
20
+ "template/ai/QUALITY.md.tpl",
21
+ "template/ai/DECISIONS.md.tpl",
22
+ "template/ai/PRODUCT-SUMMARY.md.tpl",
23
+ "template/ai/PRODUCT.md.tpl",
24
+ "template/ai/ARCHITECTURE.md.tpl",
25
+ "template/ai/DESIGN.md.tpl",
26
+ "template/ai/WORKFLOW.md.tpl",
27
+ "template/ai/VERSIONING.md.tpl",
28
+ "template/ai/CODEX-HOOKS.md.tpl",
29
+ "template/ai/RUNNER-SAFETY.md.tpl",
30
+ "template/ai/RUNNER-READINESS.md.tpl",
31
+ "template/ai/AGENT-GARBAGE-COLLECTION.md.tpl",
32
+ "template/ai/knowledge-manifest.json.tpl",
33
+ "template/ai/workspace/REPOS.md.tpl",
34
+ "template/ai/workspace/SYSTEM-MAP.md.tpl",
35
+ "template/ai/workspace/SESSION-BOOTSTRAP.md.tpl",
36
+ "template/ai/workspace/LOCAL-STACK.md.tpl",
37
+ "template/ai/workspace/TEST-STRATEGY.md.tpl",
38
+ "template/ai/model-overlays/openai/AGENTS.md.tpl",
39
+ "template/ai/model-overlays/anthropic/CLAUDE.md.tpl",
40
+ "template/consumer/AGENTS.md.tpl",
41
+ "template/consumer/CLAUDE.md.tpl",
42
+ "template/consumer/.claude/CLAUDE.md.tpl",
43
+ "template/workspace/AGENTS.md.tpl",
44
+ "template/workspace/CLAUDE.md.tpl",
45
+ "template/workspace/.claude/CLAUDE.md.tpl",
46
+ "template/workspace/.claude/rules/harness-client-surfaces.md.tpl",
47
+ "template/workspace/.claude/settings.json.tpl",
48
+ "template/ai/contracts/README.md.tpl",
49
+ "template/ai/contracts/repo-boundaries.md.tpl",
50
+ "template/ai/contracts/app-legibility.md.tpl",
51
+ "template/ai/contracts/api-boundary.md.tpl",
52
+ "template/ai/contracts/security-boundary.md.tpl",
53
+ "template/ai/contracts/repo-boundaries.contract.json.tpl",
54
+ "template/ai/contracts/app-legibility.contract.json.tpl",
55
+ "template/ai/contracts/api-boundary.contract.json.tpl",
56
+ "template/ai/contracts/security-boundary.contract.json.tpl",
57
+ "template/ai/contracts/codex-hooks.md.tpl",
58
+ "template/ai/contracts/codex-hooks.contract.json.tpl",
59
+ "template/ai/contracts/release-flow.md.tpl",
60
+ "template/ai/contracts/release-flow.contract.json.tpl",
61
+ "template/ai/contracts/github-safety.md.tpl",
62
+ "template/ai/contracts/github-safety.contract.json.tpl",
63
+ "template/ai/templates/README.md.tpl",
64
+ "template/ai/templates/task-brief-template.md.tpl",
65
+ "template/ai/templates/issue-template.md.tpl",
66
+ "template/ai/templates/fixtures/issues/valid-ready.md.tpl",
67
+ "template/ai/templates/fixtures/issues/invalid-placeholder.md.tpl",
68
+ "template/ai/templates/fixtures/issues/invalid-protected-surface.md.tpl",
69
+ "template/ai/specs/README.md.tpl",
70
+ "template/ai/skills/README.md.tpl",
71
+ "template/ai/skills/review-architecture.md.tpl",
72
+ "template/ai/skills/review-security.md.tpl",
73
+ "template/ai/skills/review-contract-drift.md.tpl",
74
+ "template/ai/skills/review-governance-drift.md.tpl",
75
+ "template/ai/plans/README.md.tpl",
76
+ "template/ai/plans/tech-debt.md.tpl",
77
+ "template/scripts/validate-governance.mjs.tpl",
78
+ "template/scripts/check-template-governance.mjs.tpl",
79
+ "template/scripts/check-readiness.mjs.tpl",
80
+ "template/scripts/check-task-template.mjs.tpl",
81
+ "template/scripts/check-issue-template.mjs.tpl",
82
+ "template/scripts/check-knowledge-manifest.mjs.tpl",
83
+ "template/scripts/check-plans.mjs.tpl",
84
+ "template/scripts/check-review-skills.mjs.tpl",
85
+ "template/scripts/check-garbage-collection.mjs.tpl",
86
+ "template/scripts/check-contract-manifests.mjs.tpl",
87
+ "template/scripts/generate-html-views.mjs.tpl",
88
+ "template/scripts/check-html-views.mjs.tpl",
89
+ "template/scripts/check-codex-hooks.mjs.tpl",
90
+ "template/scripts/check-claude-compatibility.mjs.tpl",
91
+ "template/scripts/check-overlay-drift.mjs.tpl",
92
+ "template/scripts/bootstrap-codex-worktree.mjs.tpl",
93
+ "template/scripts/check-worktrees.mjs.tpl",
94
+ "template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl",
95
+ "template/scripts/lib/worktree-bootstrap.mjs.tpl",
96
+ "template/scripts/fixtures/worktrees/README.md.tpl",
97
+ "template/scripts/bootstrap-workspace.mjs.tpl",
98
+ "template/scripts/check-workspace.mjs.tpl",
99
+ "template/scripts/hooks/codex-hook.mjs.tpl",
100
+ "template/scripts/hooks/lib/codex-hooks-core.mjs.tpl"
101
+ ];
102
+
103
+ const errors = [];
104
+ for (const relativePath of requiredFiles) {
105
+ if (!(await exists(path.join(repoRoot, relativePath)))) {
106
+ errors.push(`missing ${relativePath}`);
107
+ }
108
+ }
109
+
110
+ failIfErrors("Template file check", errors);
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { execFileSync } from "node:child_process";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { assertSafeOutputRoot, exists, validateConfigShape } from "./lib.mjs";
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+
11
+ const configFileDefault = "harness.config.json";
12
+ const configArg = "--config";
13
+ const outputArg = "--output";
14
+ const dryRunArg = "--dry-run";
15
+ const forceArg = "--force";
16
+ const installConsumerEntrypointsArg = "--install-consumer-entrypoints";
17
+ const allowAbsoluteOutputArg = "--allow-absolute-output";
18
+
19
+ const consumerPathPrefix = "consumer/";
20
+ const anthropicPathPrefix = ".claude/";
21
+ const codexHookPathPrefix = ".codex/";
22
+ const scriptRulesPath = "scripts/check-claude-compatibility.mjs.tpl";
23
+ const scriptCodexPath = "scripts/check-codex-hooks.mjs.tpl";
24
+ const scriptHooksPath = "scripts/hooks/";
25
+
26
+ function parseArgs(argv) {
27
+ const options = {
28
+ config: configFileDefault,
29
+ output: null,
30
+ dryRun: false,
31
+ force: false,
32
+ installConsumerEntrypoints: false,
33
+ allowAbsoluteOutput: false,
34
+ };
35
+
36
+ for (let index = 0; index < argv.length; index += 1) {
37
+ const arg = argv[index];
38
+ if (arg === configArg) options.config = argv[++index];
39
+ else if (arg === outputArg) options.output = argv[++index];
40
+ else if (arg === dryRunArg) options.dryRun = true;
41
+ else if (arg === forceArg) options.force = true;
42
+ else if (arg === installConsumerEntrypointsArg) options.installConsumerEntrypoints = true;
43
+ else if (arg === allowAbsoluteOutputArg) options.allowAbsoluteOutput = true;
44
+ else throw new Error(`Unknown argument: ${arg}`);
45
+ }
46
+
47
+ return options;
48
+ }
49
+
50
+ function render(content, values) {
51
+ return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => {
52
+ if (!(key in values)) {
53
+ throw new Error(`No value provided for template placeholder {{${key}}}`);
54
+ }
55
+ return values[key];
56
+ });
57
+ }
58
+
59
+ function consumerList(consumers) {
60
+ return consumers.map((consumer) => `- ${consumer.name}: ${consumer.purpose}`).join("\n");
61
+ }
62
+
63
+ function validationList(validation) {
64
+ const entries = Object.entries(validation ?? {});
65
+ if (entries.length === 0) return "- No local validation commands documented yet.";
66
+ return entries.map(([name, command]) => `- ${name}: \`${command}\``).join("\n");
67
+ }
68
+
69
+ function consumerNames(consumers) {
70
+ return JSON.stringify(consumers.map((consumer) => consumer.name));
71
+ }
72
+
73
+ function consumerConfig(consumers, configDir, outputRoot) {
74
+ const workspaceRoot = path.dirname(outputRoot);
75
+ const normalizedConsumers = consumers.map((consumer) => {
76
+ const consumerRoot = path.resolve(configDir, consumer.path);
77
+ return {
78
+ ...consumer,
79
+ workspacePath: path.relative(workspaceRoot, consumerRoot).replaceAll(path.sep, "/") || ".",
80
+ };
81
+ });
82
+ return JSON.stringify(normalizedConsumers, null, 2);
83
+ }
84
+
85
+ function booleanLiteral(value) {
86
+ return value ? "true" : "false";
87
+ }
88
+
89
+ function clientSupport(config) {
90
+ return {
91
+ codexHooks: config.models.openai && (config.clientSupport?.codex?.hooks ?? true),
92
+ claudeRules: config.models.anthropic && (config.clientSupport?.claude?.rules ?? true),
93
+ claudeHooks: config.models.anthropic && (config.clientSupport?.claude?.hooks ?? false),
94
+ claudeSkills: config.models.anthropic && (config.clientSupport?.claude?.skills ?? false),
95
+ };
96
+ }
97
+
98
+ function shouldRenderTemplate(sourceRelative, config) {
99
+ const support = clientSupport(config);
100
+ const claudePath = "workspace/.claude/";
101
+ const openaiWorkspacePath = "workspace/AGENTS.md.tpl";
102
+ const claudeWorkspacePath = "workspace/CLAUDE.md.tpl";
103
+
104
+ if (sourceRelative.startsWith(consumerPathPrefix)) return false;
105
+ if (!config.models.anthropic && sourceRelative.startsWith(anthropicPathPrefix)) return false;
106
+ if (!support.claudeRules && sourceRelative.startsWith(`${anthropicPathPrefix}rules/`)) return false;
107
+ if (!support.claudeHooks && sourceRelative.startsWith(`${anthropicPathPrefix}hooks/`)) return false;
108
+ if (!support.claudeSkills && sourceRelative.startsWith(`${anthropicPathPrefix}skills/`)) return false;
109
+ if (!support.codexHooks && sourceRelative.startsWith(codexHookPathPrefix)) return false;
110
+ if (!support.codexHooks && sourceRelative.startsWith(scriptHooksPath)) return false;
111
+ if (!support.codexHooks && sourceRelative === scriptCodexPath) return false;
112
+ if (!support.codexHooks && sourceRelative === "ai/contracts/codex-hooks.contract.json.tpl") return false;
113
+ if (!config.models.anthropic && sourceRelative === scriptRulesPath) return false;
114
+ if (!config.models.openai && sourceRelative === openaiWorkspacePath) return false;
115
+ if (!config.models.anthropic && sourceRelative === claudeWorkspacePath) return false;
116
+ if (!config.models.anthropic && sourceRelative.startsWith(claudePath)) return false;
117
+ if (!support.claudeRules && sourceRelative.startsWith(`${claudePath}rules/`)) return false;
118
+ if (!config.models.openai && sourceRelative === "AGENTS.md.tpl") return false;
119
+ if (!config.models.anthropic && sourceRelative === "CLAUDE.md.tpl") return false;
120
+ if (!config.models.openai && sourceRelative.startsWith("ai/model-overlays/openai/")) return false;
121
+ if (!config.models.anthropic && sourceRelative.startsWith("ai/model-overlays/anthropic/")) return false;
122
+ return true;
123
+ }
124
+
125
+ async function collectTemplateFiles() {
126
+ const basePath = path.join(repoRoot, "template");
127
+ const files = [];
128
+
129
+ async function walk(currentPath) {
130
+ const entries = await readdir(currentPath, { withFileTypes: true });
131
+ for (const entry of entries) {
132
+ const absolute = path.join(currentPath, entry.name);
133
+ const relative = path.relative(basePath, absolute).replaceAll(path.sep, "/");
134
+ if (entry.isDirectory()) {
135
+ await walk(absolute);
136
+ } else if (entry.isFile() && entry.name.endsWith(".tpl")) {
137
+ files.push(relative);
138
+ }
139
+ }
140
+ }
141
+
142
+ await walk(basePath);
143
+ return files.sort();
144
+ }
145
+
146
+ async function writeRenderedFile(sourceRelative, targetRoot, values, options) {
147
+ const sourcePath = path.join(repoRoot, "template", sourceRelative);
148
+ const targetRelative = sourceRelative.replace(/\.tpl$/, "");
149
+ const targetPath = path.join(targetRoot, targetRelative);
150
+ const content = render(await readFile(sourcePath, "utf8"), values);
151
+
152
+ if (options.dryRun) {
153
+ console.log(`would create ${targetPath}`);
154
+ return;
155
+ }
156
+
157
+ if ((await exists(targetPath)) && !options.force) {
158
+ console.log(`skipped existing ${targetPath}`);
159
+ return;
160
+ }
161
+
162
+ const existed = await exists(targetPath);
163
+ await mkdir(path.dirname(targetPath), { recursive: true });
164
+ await writeFile(targetPath, content);
165
+ console.log(`${existed ? "wrote" : "created"} ${targetPath}`);
166
+ }
167
+
168
+ async function installConsumerEntrypoints(config, harnessRoot, options) {
169
+ for (const consumer of config.consumers) {
170
+ const consumerRoot = path.resolve(path.dirname(path.resolve(options.config)), consumer.path);
171
+ if (!(await exists(consumerRoot))) {
172
+ throw new Error(`Consumer repo path does not exist: ${consumerRoot}`);
173
+ }
174
+
175
+ const harnessRelativePath = path.relative(consumerRoot, harnessRoot).replaceAll(path.sep, "/") || ".";
176
+ const values = {
177
+ PROJECT_NAME: config.project.name,
178
+ CONSUMER_NAME: consumer.name,
179
+ CONSUMER_PURPOSE: consumer.purpose,
180
+ CONSUMER_VALIDATION_LIST: validationList(consumer.validation),
181
+ HARNESS_RELATIVE_PATH: harnessRelativePath,
182
+ };
183
+
184
+ const entrypoints = [];
185
+ if (config.models.openai) entrypoints.push(["AGENTS.md", "AGENTS.md.tpl"]);
186
+ if (config.models.anthropic) {
187
+ entrypoints.push(["CLAUDE.md", "CLAUDE.md.tpl"]);
188
+ entrypoints.push([path.join(".claude", "CLAUDE.md"), path.join(".claude", "CLAUDE.md.tpl")]);
189
+ }
190
+
191
+ for (const [targetRelative, sourceRelative] of entrypoints) {
192
+ const sourcePath = path.join(repoRoot, "template", "consumer", sourceRelative);
193
+ const targetPath = path.join(consumerRoot, targetRelative);
194
+ const content = render(await readFile(sourcePath, "utf8"), values);
195
+
196
+ if (options.dryRun) {
197
+ console.log(`would create consumer entrypoint ${targetPath}`);
198
+ continue;
199
+ }
200
+ if ((await exists(targetPath)) && !options.force) {
201
+ console.log(`skipped existing consumer entrypoint ${targetPath}`);
202
+ continue;
203
+ }
204
+
205
+ await mkdir(path.dirname(targetPath), { recursive: true });
206
+ await writeFile(targetPath, content);
207
+ console.log(`wrote consumer entrypoint ${targetPath}`);
208
+ }
209
+ }
210
+ }
211
+
212
+ async function main() {
213
+ const options = parseArgs(process.argv.slice(2));
214
+ const configPath = path.resolve(options.config);
215
+ const config = JSON.parse(await readFile(configPath, "utf8"));
216
+ const errors = await validateConfigShape(config, options.config);
217
+ if (errors.length > 0) {
218
+ throw new Error(errors.join("\n"));
219
+ }
220
+
221
+ const configDir = path.dirname(configPath);
222
+ const outputPath = options.output ?? config.output.path;
223
+ const outputRoot = path.resolve(configDir, outputPath);
224
+ const consumerRepos = config.consumers.map((consumer) => path.resolve(configDir, consumer.path));
225
+ assertSafeOutputRoot({
226
+ outputPath,
227
+ outputRoot,
228
+ repoRoot,
229
+ workspaceRoot: configDir,
230
+ consumerRepos,
231
+ allowAbsoluteOutput: options.allowAbsoluteOutput,
232
+ });
233
+ const support = clientSupport(config);
234
+ const values = {
235
+ PROJECT_NAME: config.project.name,
236
+ PROJECT_SLUG: config.project.slug,
237
+ HARNESS_REPO_NAME: config.project.harnessRepoName,
238
+ CONSUMER_REPOS_LIST: consumerList(config.consumers),
239
+ CONSUMER_REPO_NAMES_JSON: consumerNames(config.consumers),
240
+ CONSUMER_CONFIG_JSON: consumerConfig(config.consumers, configDir, outputRoot),
241
+ PRIMARY_CONSUMER_NAME: config.consumers[0].name,
242
+ MODEL_OPENAI_ENABLED: booleanLiteral(config.models.openai),
243
+ MODEL_ANTHROPIC_ENABLED: booleanLiteral(config.models.anthropic),
244
+ CLIENT_CODEX_HOOKS_ENABLED: booleanLiteral(support.codexHooks),
245
+ CLIENT_CLAUDE_RULES_ENABLED: booleanLiteral(support.claudeRules),
246
+ CLIENT_CLAUDE_HOOKS_ENABLED: booleanLiteral(support.claudeHooks),
247
+ CLIENT_CLAUDE_SKILLS_ENABLED: booleanLiteral(support.claudeSkills),
248
+ };
249
+
250
+ for (const sourceRelative of await collectTemplateFiles()) {
251
+ if (!shouldRenderTemplate(sourceRelative, config)) continue;
252
+ await writeRenderedFile(sourceRelative, outputRoot, values, options);
253
+ }
254
+
255
+ if (!options.dryRun) {
256
+ execFileSync(process.execPath, [path.join(outputRoot, "scripts/generate-html-views.mjs")], {
257
+ cwd: outputRoot,
258
+ stdio: "inherit",
259
+ });
260
+ }
261
+
262
+ if (options.installConsumerEntrypoints) {
263
+ await installConsumerEntrypoints(config, outputRoot, { ...options, config: configPath });
264
+ }
265
+ }
266
+
267
+ main().catch((error) => {
268
+ console.error(error instanceof Error ? error.message : String(error));
269
+ process.exit(1);
270
+ });
@@ -0,0 +1,190 @@
1
+ import { access, readdir, readFile, stat } from "node:fs/promises";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+
8
+ export async function exists(filePath) {
9
+ try {
10
+ await access(filePath, fsConstants.F_OK);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function readJson(relativePath) {
18
+ return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
19
+ }
20
+
21
+ export async function collectFiles(baseRelativePath, predicate = () => true) {
22
+ const basePath = path.join(repoRoot, baseRelativePath);
23
+ const files = [];
24
+
25
+ async function walk(currentPath) {
26
+ const entries = await readdir(currentPath, { withFileTypes: true });
27
+ for (const entry of entries) {
28
+ const absolute = path.join(currentPath, entry.name);
29
+ const relative = path.relative(repoRoot, absolute).replaceAll(path.sep, "/");
30
+ if (entry.isDirectory()) {
31
+ await walk(absolute);
32
+ } else if (entry.isFile() && predicate(relative)) {
33
+ files.push(relative);
34
+ }
35
+ }
36
+ }
37
+
38
+ if (await exists(basePath)) {
39
+ const info = await stat(basePath);
40
+ if (info.isDirectory()) {
41
+ await walk(basePath);
42
+ }
43
+ }
44
+
45
+ return files.sort();
46
+ }
47
+
48
+ export function pathContainsSegment(targetPath, segment) {
49
+ return path.resolve(targetPath).split(path.sep).includes(segment);
50
+ }
51
+
52
+ export function isSameOrInsidePath(candidate, root) {
53
+ const resolvedCandidate = path.resolve(candidate);
54
+ const resolvedRoot = path.resolve(root);
55
+ const relative = path.relative(resolvedRoot, resolvedCandidate);
56
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
57
+ }
58
+
59
+ export function assertSafeOutputRoot({
60
+ outputPath,
61
+ outputRoot,
62
+ repoRoot: templateRepoRoot,
63
+ workspaceRoot,
64
+ consumerRepos,
65
+ allowAbsoluteOutput = false,
66
+ }) {
67
+ const rejectedPath = path.resolve(outputRoot);
68
+ if (path.isAbsolute(outputPath) && !allowAbsoluteOutput) {
69
+ throw new Error(`Unsafe output path ${rejectedPath}: absolute output paths require --allow-absolute-output.`);
70
+ }
71
+ if (isSameOrInsidePath(outputRoot, templateRepoRoot)) {
72
+ throw new Error(`Unsafe output path ${rejectedPath}: output must not equal or be inside the template repo ${templateRepoRoot}.`);
73
+ }
74
+ if (path.resolve(outputRoot) === path.resolve(workspaceRoot)) {
75
+ throw new Error(`Unsafe output path ${rejectedPath}: output must not equal the workspace root ${workspaceRoot}.`);
76
+ }
77
+ for (const consumerRoot of consumerRepos) {
78
+ if (isSameOrInsidePath(outputRoot, consumerRoot)) {
79
+ throw new Error(
80
+ `Unsafe output path ${rejectedPath}: output must not equal or be inside configured consumer repo ${consumerRoot}.`,
81
+ );
82
+ }
83
+ }
84
+ if (pathContainsSegment(outputRoot, ".git")) {
85
+ throw new Error(`Unsafe output path ${rejectedPath}: output path must not contain a .git path segment.`);
86
+ }
87
+ }
88
+
89
+ export function failIfErrors(title, errors) {
90
+ if (errors.length === 0) {
91
+ console.log(`${title} passed.`);
92
+ return;
93
+ }
94
+
95
+ console.error(`${title} failed.`);
96
+ for (const error of errors) {
97
+ console.error(`- ${error}`);
98
+ }
99
+ process.exit(1);
100
+ }
101
+
102
+ function isPlainObject(value) {
103
+ return typeof value === "object" && value !== null && !Array.isArray(value);
104
+ }
105
+
106
+ function typeName(value) {
107
+ if (Array.isArray(value)) return "array";
108
+ if (value === null) return "null";
109
+ return typeof value;
110
+ }
111
+
112
+ function validateJsonSchema(value, schema, label, errors) {
113
+ const expectedType = schema.type;
114
+ if (expectedType) {
115
+ const validType =
116
+ expectedType === "object" ? isPlainObject(value) :
117
+ expectedType === "array" ? Array.isArray(value) :
118
+ typeof value === expectedType;
119
+ if (!validType) {
120
+ errors.push(`${label} must be ${expectedType}; got ${typeName(value)}.`);
121
+ return;
122
+ }
123
+ }
124
+
125
+ if (Object.hasOwn(schema, "const") && value !== schema.const) {
126
+ errors.push(`${label} must be ${JSON.stringify(schema.const)}.`);
127
+ }
128
+
129
+ if (typeof value === "string") {
130
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
131
+ errors.push(`${label} must be at least ${schema.minLength} character(s).`);
132
+ }
133
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
134
+ errors.push(`${label} must match pattern ${schema.pattern}.`);
135
+ }
136
+ }
137
+
138
+ if (Array.isArray(value)) {
139
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
140
+ errors.push(`${label} must contain at least ${schema.minItems} item(s).`);
141
+ }
142
+ if (schema.items) {
143
+ for (const [index, item] of value.entries()) {
144
+ validateJsonSchema(item, schema.items, `${label}[${index}]`, errors);
145
+ }
146
+ }
147
+ }
148
+
149
+ if (isPlainObject(value)) {
150
+ const properties = schema.properties ?? {};
151
+ for (const requiredKey of schema.required ?? []) {
152
+ if (!Object.hasOwn(value, requiredKey)) {
153
+ errors.push(`${label}.${requiredKey} is required.`);
154
+ }
155
+ }
156
+ if (schema.additionalProperties === false) {
157
+ for (const key of Object.keys(value)) {
158
+ if (!Object.hasOwn(properties, key)) {
159
+ errors.push(`${label}.${key} is not allowed.`);
160
+ }
161
+ }
162
+ }
163
+ for (const [key, propertySchema] of Object.entries(properties)) {
164
+ if (Object.hasOwn(value, key)) {
165
+ validateJsonSchema(value[key], propertySchema, `${label}.${key}`, errors);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ export async function validateConfigShape(config, label) {
172
+ const errors = [];
173
+ const schema = await readJson("schemas/harness-config.schema.json");
174
+ validateJsonSchema(config, schema, label, errors);
175
+
176
+ if (isPlainObject(config.models) && config.models.openai === false && config.models.anthropic === false) {
177
+ errors.push("Invalid harness config: at least one model provider must be enabled.");
178
+ }
179
+
180
+ const names = new Set();
181
+ if (Array.isArray(config.consumers)) {
182
+ for (const [index, consumer] of config.consumers.entries()) {
183
+ const prefix = `${label}.consumers[${index}]`;
184
+ if (names.has(consumer.name)) errors.push(`${prefix}.name is duplicated.`);
185
+ names.add(consumer.name);
186
+ }
187
+ }
188
+
189
+ return errors;
190
+ }