@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.
- package/LICENSE +21 -0
- package/README.md +405 -0
- package/bin/structor.mjs +576 -0
- package/docs/INIT.md +109 -0
- package/docs/adr/0001-default-generated-repo-name.md +9 -0
- package/docs/issues/0001-structor-doctor.md +39 -0
- package/examples/frontend-backend/harness.config.json +35 -0
- package/examples/openai-and-anthropic/harness.config.json +28 -0
- package/examples/single-repo/harness.config.json +26 -0
- package/harness.config.example.json +38 -0
- package/package.json +58 -0
- package/schemas/contract-manifest.schema.json +18 -0
- package/schemas/harness-config.schema.json +85 -0
- package/schemas/task-brief.schema.json +37 -0
- package/scripts/check-config.mjs +76 -0
- package/scripts/check-contract-manifests.mjs +85 -0
- package/scripts/check-model-overlays.mjs +30 -0
- package/scripts/check-placeholders.mjs +48 -0
- package/scripts/check-task-template.mjs +53 -0
- package/scripts/check-template-files.mjs +110 -0
- package/scripts/init-harness.mjs +270 -0
- package/scripts/lib.mjs +190 -0
- package/scripts/smoke-template.mjs +309 -0
- package/scripts/validate-governance.mjs +3 -0
- package/scripts/validate-template.mjs +16 -0
- package/template/.claude/CLAUDE.md.tpl +12 -0
- package/template/.claude/rules/harness-client-surfaces.md.tpl +20 -0
- package/template/.claude/settings.json.tpl +10 -0
- package/template/.codex/hooks.json.tpl +77 -0
- package/template/AGENTS.md.tpl +22 -0
- package/template/CLAUDE.md.tpl +16 -0
- package/template/README.md.tpl +109 -0
- package/template/ai/AGENT-GARBAGE-COLLECTION.md.tpl +18 -0
- package/template/ai/AGENTS.md.tpl +36 -0
- package/template/ai/ARCHITECTURE.md.tpl +35 -0
- package/template/ai/CODEX-HOOKS.md.tpl +23 -0
- package/template/ai/DECISIONS.md.tpl +22 -0
- package/template/ai/DESIGN.md.tpl +22 -0
- package/template/ai/HARNESS-ENGINEERING.md.tpl +107 -0
- package/template/ai/HARNESS.md.tpl +53 -0
- package/template/ai/HUB.md.tpl +53 -0
- package/template/ai/PRODUCT-SUMMARY.md.tpl +28 -0
- package/template/ai/PRODUCT.md.tpl +32 -0
- package/template/ai/QUALITY.md.tpl +37 -0
- package/template/ai/READINESS.md.tpl +39 -0
- package/template/ai/RUNNER-READINESS.md.tpl +14 -0
- package/template/ai/RUNNER-SAFETY.md.tpl +21 -0
- package/template/ai/VERSIONING.md.tpl +16 -0
- package/template/ai/WORKFLOW.md.tpl +42 -0
- package/template/ai/context.md.tpl +17 -0
- package/template/ai/contracts/README.md.tpl +23 -0
- package/template/ai/contracts/api-boundary.contract.json.tpl +11 -0
- package/template/ai/contracts/api-boundary.md.tpl +17 -0
- package/template/ai/contracts/app-legibility.contract.json.tpl +11 -0
- package/template/ai/contracts/app-legibility.md.tpl +24 -0
- package/template/ai/contracts/codex-hooks.contract.json.tpl +15 -0
- package/template/ai/contracts/codex-hooks.md.tpl +18 -0
- package/template/ai/contracts/github-safety.contract.json.tpl +11 -0
- package/template/ai/contracts/github-safety.md.tpl +15 -0
- package/template/ai/contracts/release-flow.contract.json.tpl +12 -0
- package/template/ai/contracts/release-flow.md.tpl +15 -0
- package/template/ai/contracts/repo-boundaries.contract.json.tpl +12 -0
- package/template/ai/contracts/repo-boundaries.md.tpl +18 -0
- package/template/ai/contracts/security-boundary.contract.json.tpl +11 -0
- package/template/ai/contracts/security-boundary.md.tpl +19 -0
- package/template/ai/knowledge-manifest.json.tpl +149 -0
- package/template/ai/model-overlays/anthropic/CLAUDE.md.tpl +14 -0
- package/template/ai/model-overlays/openai/AGENTS.md.tpl +13 -0
- package/template/ai/plans/README.md.tpl +10 -0
- package/template/ai/plans/tech-debt.md.tpl +7 -0
- package/template/ai/skills/README.md.tpl +15 -0
- package/template/ai/skills/review-architecture.md.tpl +41 -0
- package/template/ai/skills/review-contract-drift.md.tpl +41 -0
- package/template/ai/skills/review-governance-drift.md.tpl +42 -0
- package/template/ai/skills/review-security.md.tpl +40 -0
- package/template/ai/specs/README.md.tpl +14 -0
- package/template/ai/templates/README.md.tpl +13 -0
- package/template/ai/templates/fixtures/issues/invalid-placeholder.md.tpl +20 -0
- package/template/ai/templates/fixtures/issues/invalid-protected-surface.md.tpl +21 -0
- package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +105 -0
- package/template/ai/templates/issue-template.md.tpl +107 -0
- package/template/ai/templates/task-brief-template.md.tpl +185 -0
- package/template/ai/workspace/LOCAL-STACK.md.tpl +21 -0
- package/template/ai/workspace/REPOS.md.tpl +19 -0
- package/template/ai/workspace/SESSION-BOOTSTRAP.md.tpl +27 -0
- package/template/ai/workspace/SYSTEM-MAP.md.tpl +19 -0
- package/template/ai/workspace/TEST-STRATEGY.md.tpl +22 -0
- package/template/consumer/.claude/CLAUDE.md.tpl +14 -0
- package/template/consumer/AGENTS.md.tpl +23 -0
- package/template/consumer/CLAUDE.md.tpl +15 -0
- package/template/scripts/bootstrap-codex-worktree.mjs.tpl +52 -0
- package/template/scripts/bootstrap-workspace.mjs.tpl +100 -0
- package/template/scripts/check-claude-compatibility.mjs.tpl +120 -0
- package/template/scripts/check-codex-hooks.mjs.tpl +190 -0
- package/template/scripts/check-contract-manifests.mjs.tpl +81 -0
- package/template/scripts/check-garbage-collection.mjs.tpl +25 -0
- package/template/scripts/check-html-views.mjs.tpl +60 -0
- package/template/scripts/check-issue-template.mjs.tpl +167 -0
- package/template/scripts/check-knowledge-manifest.mjs.tpl +82 -0
- package/template/scripts/check-overlay-drift.mjs.tpl +49 -0
- package/template/scripts/check-plans.mjs.tpl +70 -0
- package/template/scripts/check-readiness.mjs.tpl +130 -0
- package/template/scripts/check-review-skills.mjs.tpl +48 -0
- package/template/scripts/check-task-template.mjs.tpl +63 -0
- package/template/scripts/check-template-governance.mjs.tpl +161 -0
- package/template/scripts/check-workspace.mjs.tpl +212 -0
- package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +122 -0
- package/template/scripts/check-worktrees.mjs.tpl +69 -0
- package/template/scripts/fixtures/worktrees/README.md.tpl +4 -0
- package/template/scripts/generate-html-views.mjs.tpl +189 -0
- package/template/scripts/hooks/codex-hook.mjs.tpl +21 -0
- package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +114 -0
- package/template/scripts/lib/worktree-bootstrap.mjs.tpl +388 -0
- package/template/scripts/validate-governance.mjs.tpl +78 -0
- package/template/workspace/.claude/CLAUDE.md.tpl +9 -0
- package/template/workspace/.claude/rules/harness-client-surfaces.md.tpl +15 -0
- package/template/workspace/.claude/settings.json.tpl +10 -0
- package/template/workspace/AGENTS.md.tpl +17 -0
- package/template/workspace/CLAUDE.md.tpl +18 -0
package/bin/structor.mjs
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { constants as fsConstants } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import readline from "node:readline/promises";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const generatorPath = path.join(packageRoot, "scripts/init-harness.mjs");
|
|
13
|
+
const configFileName = "harness.config.json";
|
|
14
|
+
const reset = "\x1b[0m";
|
|
15
|
+
const styles = {
|
|
16
|
+
bold: "\x1b[1m",
|
|
17
|
+
dim: "\x1b[2m",
|
|
18
|
+
cyan: "\x1b[36m",
|
|
19
|
+
green: "\x1b[32m",
|
|
20
|
+
yellow: "\x1b[33m",
|
|
21
|
+
red: "\x1b[31m",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const repoSignals = [
|
|
25
|
+
".git",
|
|
26
|
+
"package.json",
|
|
27
|
+
"pyproject.toml",
|
|
28
|
+
"go.mod",
|
|
29
|
+
"Cargo.toml",
|
|
30
|
+
"pom.xml",
|
|
31
|
+
"build.gradle",
|
|
32
|
+
"Gemfile",
|
|
33
|
+
"composer.json",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
async function readPackageMetadata() {
|
|
37
|
+
return await readJson(path.join(packageRoot, "package.json"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function color(style, value) {
|
|
41
|
+
return `${styles[style]}${value}${reset}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function section(title) {
|
|
45
|
+
console.log(`\n${color("cyan", color("bold", title))}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function note(message) {
|
|
49
|
+
console.log(color("dim", message));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function success(message) {
|
|
53
|
+
console.log(color("green", message));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function warn(message) {
|
|
57
|
+
console.log(color("yellow", message));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fail(message) {
|
|
61
|
+
console.error(color("red", message));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function exists(filePath) {
|
|
65
|
+
try {
|
|
66
|
+
await access(filePath, fsConstants.F_OK);
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readJson(filePath) {
|
|
74
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function maybeReadJson(filePath) {
|
|
78
|
+
if (!(await exists(filePath))) return null;
|
|
79
|
+
try {
|
|
80
|
+
return await readJson(filePath);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function slugify(value) {
|
|
87
|
+
return value
|
|
88
|
+
.trim()
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
91
|
+
.replace(/^-+|-+$/g, "")
|
|
92
|
+
|| "project";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function relativeFrom(basePath, targetPath) {
|
|
96
|
+
const relative = path.relative(basePath, targetPath).replaceAll(path.sep, "/");
|
|
97
|
+
return relative === "" ? "." : relative.startsWith(".") ? relative : `./${relative}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseArgs(argv) {
|
|
101
|
+
const [command = "help", ...rest] = argv;
|
|
102
|
+
const options = { _: [] };
|
|
103
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
104
|
+
const arg = rest[index];
|
|
105
|
+
if (!arg.startsWith("--")) {
|
|
106
|
+
options._.push(arg);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (arg === "--yes" || arg === "-y") options.yes = true;
|
|
110
|
+
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
111
|
+
else if (arg === "--install-consumer-entrypoints") options.installConsumerEntrypoints = true;
|
|
112
|
+
else if (arg === "--force") options.force = true;
|
|
113
|
+
else if (arg === "--workspace") options.workspace = rest[++index];
|
|
114
|
+
else if (arg === "--config") options.config = rest[++index];
|
|
115
|
+
else options._.push(arg);
|
|
116
|
+
}
|
|
117
|
+
return { command, options, rawArgs: rest };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function printHelp() {
|
|
121
|
+
console.log(`Structor\n\nUsage:\n structor init [--workspace <path>] [--config <path>] [--yes]\n structor generate --config <path> [generator options]\n structor doctor\n\nCommands:\n init Guided local setup for a Structor workspace.\n generate Render a generated harness from an existing config.\n doctor Planned diagnostic and repair command.\n`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function runGenerator(args, cwd = process.cwd()) {
|
|
125
|
+
const result = spawnSync(process.execPath, [generatorPath, ...args], {
|
|
126
|
+
cwd,
|
|
127
|
+
encoding: "utf8",
|
|
128
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
129
|
+
});
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function printCommandOutput(result) {
|
|
134
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
135
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function passthroughGenerate(args) {
|
|
139
|
+
const result = spawnSync(process.execPath, [generatorPath, ...args], {
|
|
140
|
+
cwd: process.cwd(),
|
|
141
|
+
stdio: "inherit",
|
|
142
|
+
});
|
|
143
|
+
process.exit(result.status ?? 1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function createPrompt() {
|
|
147
|
+
if (process.stdin.isTTY) {
|
|
148
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
149
|
+
}
|
|
150
|
+
const chunks = [];
|
|
151
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
152
|
+
const lines = Buffer.concat(chunks).toString("utf8").split(/\r?\n/);
|
|
153
|
+
return {
|
|
154
|
+
async question(query) {
|
|
155
|
+
process.stdout.write(query);
|
|
156
|
+
return lines.length > 0 ? lines.shift() : "";
|
|
157
|
+
},
|
|
158
|
+
close() {},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function askLine(rl, question, defaultValue = "") {
|
|
163
|
+
const suffix = defaultValue ? ` ${color("dim", `[${defaultValue}]`)}` : "";
|
|
164
|
+
const answer = (await rl.question(`${question}${suffix}: `)).trim();
|
|
165
|
+
return answer || defaultValue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function askYesNo(rl, question, defaultValue = true) {
|
|
169
|
+
const suffix = defaultValue ? "Y/n" : "y/N";
|
|
170
|
+
while (true) {
|
|
171
|
+
const answer = (await rl.question(`${question} ${color("dim", `[${suffix}]`)} `)).trim().toLowerCase();
|
|
172
|
+
if (!answer) return defaultValue;
|
|
173
|
+
if (["y", "yes"].includes(answer)) return true;
|
|
174
|
+
if (["n", "no"].includes(answer)) return false;
|
|
175
|
+
warn("Please answer yes or no.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function askChoice(rl, question, choices, defaultIndex = 0) {
|
|
180
|
+
console.log(question);
|
|
181
|
+
choices.forEach((choice, index) => {
|
|
182
|
+
const marker = index === defaultIndex ? "*" : " ";
|
|
183
|
+
console.log(` ${marker} ${index + 1}. ${choice.label}${choice.note ? color("dim", ` - ${choice.note}`) : ""}`);
|
|
184
|
+
});
|
|
185
|
+
while (true) {
|
|
186
|
+
const answer = (await rl.question(`Select ${color("dim", `[${defaultIndex + 1}]`)}: `)).trim();
|
|
187
|
+
const index = answer ? Number.parseInt(answer, 10) - 1 : defaultIndex;
|
|
188
|
+
if (Number.isInteger(index) && choices[index]) return choices[index].value;
|
|
189
|
+
warn("Please select a listed number.");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function askMultiSelect(rl, question, choices, defaultIndexes) {
|
|
194
|
+
console.log(question);
|
|
195
|
+
choices.forEach((choice, index) => {
|
|
196
|
+
const marker = defaultIndexes.includes(index) ? "*" : " ";
|
|
197
|
+
console.log(` ${marker} ${index + 1}. ${choice.label}${choice.note ? color("dim", ` - ${choice.note}`) : ""}`);
|
|
198
|
+
});
|
|
199
|
+
const defaultValue = defaultIndexes.map((index) => index + 1).join(",");
|
|
200
|
+
while (true) {
|
|
201
|
+
const answer = (await rl.question(`Select comma-separated numbers ${color("dim", `[${defaultValue}]`)}: `)).trim();
|
|
202
|
+
const raw = answer || defaultValue;
|
|
203
|
+
const indexes = raw.split(",").map((item) => Number.parseInt(item.trim(), 10) - 1);
|
|
204
|
+
if (indexes.length > 0 && indexes.every((index) => Number.isInteger(index) && choices[index])) {
|
|
205
|
+
return [...new Set(indexes)].map((index) => choices[index].value);
|
|
206
|
+
}
|
|
207
|
+
warn("Please enter one or more listed numbers, for example 1,2.");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function isDirectory(filePath) {
|
|
212
|
+
try {
|
|
213
|
+
return (await stat(filePath)).isDirectory();
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function shouldExcludeCandidate(name) {
|
|
220
|
+
return (
|
|
221
|
+
name.startsWith(".") ||
|
|
222
|
+
name === "node_modules" ||
|
|
223
|
+
name === "structor" ||
|
|
224
|
+
name.endsWith("-structor") ||
|
|
225
|
+
name.endsWith("-harness") ||
|
|
226
|
+
name.endsWith("-engineering-harness")
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function collectSignals(candidateRoot) {
|
|
231
|
+
const signals = [];
|
|
232
|
+
for (const signal of repoSignals) {
|
|
233
|
+
if (await exists(path.join(candidateRoot, signal))) signals.push(signal);
|
|
234
|
+
}
|
|
235
|
+
return signals;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function detectConsumerRepos(workspaceRoot) {
|
|
239
|
+
const entries = await readdir(workspaceRoot, { withFileTypes: true });
|
|
240
|
+
const candidates = [];
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (!entry.isDirectory() || shouldExcludeCandidate(entry.name)) continue;
|
|
243
|
+
const absolutePath = path.join(workspaceRoot, entry.name);
|
|
244
|
+
const signals = await collectSignals(absolutePath);
|
|
245
|
+
if (signals.length === 0) continue;
|
|
246
|
+
candidates.push({
|
|
247
|
+
name: slugify(entry.name),
|
|
248
|
+
path: absolutePath,
|
|
249
|
+
folderName: entry.name,
|
|
250
|
+
signals,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return candidates.sort((a, b) => a.folderName.localeCompare(b.folderName));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function detectPackageManager(repoRoot) {
|
|
257
|
+
if (await exists(path.join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
|
|
258
|
+
if (await exists(path.join(repoRoot, "yarn.lock"))) return "yarn";
|
|
259
|
+
if (await exists(path.join(repoRoot, "package-lock.json"))) return "npm";
|
|
260
|
+
if (await exists(path.join(repoRoot, "package.json"))) return "npm";
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function packageCommand(packageManager, scriptName) {
|
|
265
|
+
if (packageManager === "yarn") return `yarn ${scriptName}`;
|
|
266
|
+
if (scriptName === "test") return `${packageManager} test`;
|
|
267
|
+
return `${packageManager} run ${scriptName}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function inferValidation(repoRoot) {
|
|
271
|
+
const validation = {};
|
|
272
|
+
const packageJson = await maybeReadJson(path.join(repoRoot, "package.json"));
|
|
273
|
+
if (packageJson) {
|
|
274
|
+
const packageManager = await detectPackageManager(repoRoot);
|
|
275
|
+
if (packageManager === "pnpm") validation.install = "pnpm install";
|
|
276
|
+
else if (packageManager === "yarn") validation.install = "yarn install";
|
|
277
|
+
else validation.install = "npm install";
|
|
278
|
+
const scripts = packageJson.scripts ?? {};
|
|
279
|
+
for (const scriptName of ["lint", "test", "build"]) {
|
|
280
|
+
if (scripts[scriptName]) validation[scriptName] = packageCommand(packageManager, scriptName);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (await exists(path.join(repoRoot, "go.mod"))) {
|
|
284
|
+
validation.test ??= "go test ./...";
|
|
285
|
+
}
|
|
286
|
+
if (await exists(path.join(repoRoot, "Cargo.toml"))) {
|
|
287
|
+
validation.test ??= "cargo test";
|
|
288
|
+
validation.build ??= "cargo build";
|
|
289
|
+
}
|
|
290
|
+
if (await exists(path.join(repoRoot, "pyproject.toml"))) {
|
|
291
|
+
const hasPytest =
|
|
292
|
+
await exists(path.join(repoRoot, "pytest.ini")) ||
|
|
293
|
+
await exists(path.join(repoRoot, "tests")) ||
|
|
294
|
+
(await readFile(path.join(repoRoot, "pyproject.toml"), "utf8")).includes("pytest");
|
|
295
|
+
if (hasPytest) validation.test ??= "python -m pytest";
|
|
296
|
+
}
|
|
297
|
+
return validation;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function compactValidation(validation) {
|
|
301
|
+
return Object.fromEntries(Object.entries(validation).filter(([, value]) => value.trim() !== ""));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function collectConsumerDetails(rl, workspaceRoot, selectedCandidates) {
|
|
305
|
+
const consumers = [];
|
|
306
|
+
for (const candidate of selectedCandidates) {
|
|
307
|
+
section(`Consumer: ${candidate.folderName}`);
|
|
308
|
+
const name = await askLine(rl, "Consumer name", candidate.name);
|
|
309
|
+
const purpose = await askLine(rl, "Purpose", "Application repository");
|
|
310
|
+
const suggestions = await inferValidation(candidate.path);
|
|
311
|
+
note("Validation commands are stored in harness.config.json for agents to run later. Leave unknown commands blank.");
|
|
312
|
+
const validation = {};
|
|
313
|
+
for (const key of ["install", "lint", "test", "build", "health"]) {
|
|
314
|
+
validation[key] = await askLine(rl, `${key} command`, suggestions[key] ?? "");
|
|
315
|
+
}
|
|
316
|
+
consumers.push({
|
|
317
|
+
name: slugify(name),
|
|
318
|
+
path: relativeFrom(workspaceRoot, candidate.path),
|
|
319
|
+
purpose,
|
|
320
|
+
validation: compactValidation(validation),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return consumers;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function promptManualConsumers(rl, workspaceRoot) {
|
|
327
|
+
const consumers = [];
|
|
328
|
+
while (consumers.length === 0 || await askYesNo(rl, "Add another consumer repo?", false)) {
|
|
329
|
+
section(`Consumer ${consumers.length + 1}`);
|
|
330
|
+
const repoPath = await askLine(rl, "Path to consumer repo, relative to workspace", "./app");
|
|
331
|
+
const absolutePath = path.resolve(workspaceRoot, repoPath);
|
|
332
|
+
if (!(await isDirectory(absolutePath))) {
|
|
333
|
+
warn(`Path does not exist yet: ${absolutePath}`);
|
|
334
|
+
if (!(await askYesNo(rl, "Use this path anyway?", false))) continue;
|
|
335
|
+
}
|
|
336
|
+
const folderName = path.basename(absolutePath);
|
|
337
|
+
const [consumer] = await collectConsumerDetails(rl, workspaceRoot, [{
|
|
338
|
+
name: slugify(folderName),
|
|
339
|
+
path: absolutePath,
|
|
340
|
+
folderName,
|
|
341
|
+
signals: await collectSignals(absolutePath),
|
|
342
|
+
}]);
|
|
343
|
+
consumers.push(consumer);
|
|
344
|
+
}
|
|
345
|
+
return consumers;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function printConfigSummary(config, configPath) {
|
|
349
|
+
section("Config summary");
|
|
350
|
+
console.log(`Config: ${configPath}`);
|
|
351
|
+
console.log(`Project: ${config.project.name} (${config.project.slug})`);
|
|
352
|
+
console.log(`Generated repo: ${config.output.path}`);
|
|
353
|
+
console.log(`Models: ${config.models.openai ? "Codex" : ""}${config.models.openai && config.models.anthropic ? " + " : ""}${config.models.anthropic ? "Claude" : ""}`);
|
|
354
|
+
console.log("Consumer repos:");
|
|
355
|
+
for (const consumer of config.consumers) {
|
|
356
|
+
console.log(` - ${consumer.name}: ${consumer.path} (${consumer.purpose})`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function warnIfOutputIsNotWorkspaceChild(workspaceRoot, outputPath) {
|
|
361
|
+
const outputRoot = path.resolve(workspaceRoot, outputPath);
|
|
362
|
+
if (path.dirname(outputRoot) !== path.resolve(workspaceRoot)) {
|
|
363
|
+
warn("Generated Structor repo path is not a direct child of the workspace. The sibling layout is recommended.");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function loadExistingConfig(configPath) {
|
|
368
|
+
if (!(await exists(configPath))) return null;
|
|
369
|
+
try {
|
|
370
|
+
return await readJson(configPath);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
throw new Error(`Could not read existing config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function writeConfig(configPath, config) {
|
|
377
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
378
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function nextValidationCommands(config) {
|
|
382
|
+
const commands = [
|
|
383
|
+
`cd ${config.output.path}`,
|
|
384
|
+
"node scripts/validate-governance.mjs",
|
|
385
|
+
"node scripts/bootstrap-workspace.mjs --dry-run",
|
|
386
|
+
"node scripts/bootstrap-workspace.mjs",
|
|
387
|
+
"node scripts/check-workspace.mjs",
|
|
388
|
+
];
|
|
389
|
+
return commands;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function printNextSteps(config) {
|
|
393
|
+
section("Next validation commands");
|
|
394
|
+
note("Run these from the workspace after generation to prove harness policy and workspace routing are healthy.");
|
|
395
|
+
for (const command of nextValidationCommands(config)) {
|
|
396
|
+
console.log(` ${command}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function init(options) {
|
|
401
|
+
const rl = await createPrompt();
|
|
402
|
+
try {
|
|
403
|
+
console.log(color("bold", "Structor init"));
|
|
404
|
+
note("Local-only, deterministic setup. No network calls, no remote services, and no writes without confirmation.");
|
|
405
|
+
|
|
406
|
+
const workspaceDefault = options.workspace ? path.resolve(options.workspace) : process.cwd();
|
|
407
|
+
const workspaceRoot = path.resolve(await askLine(rl, "Workspace folder", workspaceDefault));
|
|
408
|
+
const configPath = path.resolve(workspaceRoot, options.config ?? configFileName);
|
|
409
|
+
const existingConfig = await loadExistingConfig(configPath);
|
|
410
|
+
let startingConfig = null;
|
|
411
|
+
if (existingConfig) {
|
|
412
|
+
printConfigSummary(existingConfig, configPath);
|
|
413
|
+
if (await askYesNo(rl, "Use this existing config as the starting point?", true)) {
|
|
414
|
+
startingConfig = existingConfig;
|
|
415
|
+
} else {
|
|
416
|
+
warn("Continuing will replace the config draft only after confirmation.");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
section("Project");
|
|
421
|
+
const projectName = await askLine(rl, "Project name", startingConfig?.project?.name ?? path.basename(workspaceRoot));
|
|
422
|
+
const projectSlug = slugify(await askLine(rl, "Project slug", startingConfig?.project?.slug ?? slugify(projectName)));
|
|
423
|
+
const harnessRepoName = await askLine(rl, "Generated Structor repo folder", startingConfig?.project?.harnessRepoName ?? `${projectSlug}-structor`);
|
|
424
|
+
const outputPath = await askLine(rl, "Generated Structor repo path", startingConfig?.output?.path ?? `./${harnessRepoName}`);
|
|
425
|
+
|
|
426
|
+
section("Agent clients");
|
|
427
|
+
const defaultModelIndex =
|
|
428
|
+
startingConfig?.models?.openai && !startingConfig?.models?.anthropic ? 1 :
|
|
429
|
+
!startingConfig?.models?.openai && startingConfig?.models?.anthropic ? 2 :
|
|
430
|
+
0;
|
|
431
|
+
const modelChoice = await askChoice(rl, "Which agent clients should this harness support?", [
|
|
432
|
+
{ label: "Codex and Claude", value: "both" },
|
|
433
|
+
{ label: "Codex only", value: "openai" },
|
|
434
|
+
{ label: "Claude only", value: "anthropic" },
|
|
435
|
+
], defaultModelIndex);
|
|
436
|
+
|
|
437
|
+
section("Customization");
|
|
438
|
+
await askChoice(rl, "How much should Structor customize from consumer repos?", [
|
|
439
|
+
{ label: "Starter only", value: "starter", note: "available now" },
|
|
440
|
+
{ label: "Light scan", value: "starter", note: "coming soon" },
|
|
441
|
+
{ label: "Deep scan", value: "starter", note: "coming soon" },
|
|
442
|
+
]);
|
|
443
|
+
note("Starter only creates generic harness content. It does not infer real contracts or coding conventions.");
|
|
444
|
+
|
|
445
|
+
section("Consumer repos");
|
|
446
|
+
note("For best results, run Structor from the workspace folder that contains your consumer repos as siblings.");
|
|
447
|
+
let consumers;
|
|
448
|
+
if (startingConfig?.consumers?.length > 0 && await askYesNo(rl, "Use configured consumer repos?", true)) {
|
|
449
|
+
consumers = startingConfig.consumers;
|
|
450
|
+
} else {
|
|
451
|
+
const candidates = await detectConsumerRepos(workspaceRoot);
|
|
452
|
+
if (candidates.length > 0) {
|
|
453
|
+
const selected = await askMultiSelect(
|
|
454
|
+
rl,
|
|
455
|
+
"Found likely consumer repos:",
|
|
456
|
+
candidates.map((candidate) => ({
|
|
457
|
+
label: candidate.folderName,
|
|
458
|
+
value: candidate,
|
|
459
|
+
note: candidate.signals.join(", "),
|
|
460
|
+
})),
|
|
461
|
+
candidates.map((_, index) => index),
|
|
462
|
+
);
|
|
463
|
+
consumers = await collectConsumerDetails(rl, workspaceRoot, selected);
|
|
464
|
+
} else {
|
|
465
|
+
warn("No obvious sibling consumer repos found.");
|
|
466
|
+
consumers = await promptManualConsumers(rl, workspaceRoot);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const config = {
|
|
471
|
+
project: {
|
|
472
|
+
name: projectName,
|
|
473
|
+
slug: projectSlug,
|
|
474
|
+
harnessRepoName,
|
|
475
|
+
},
|
|
476
|
+
output: {
|
|
477
|
+
path: outputPath,
|
|
478
|
+
},
|
|
479
|
+
models: {
|
|
480
|
+
openai: modelChoice === "both" || modelChoice === "openai",
|
|
481
|
+
anthropic: modelChoice === "both" || modelChoice === "anthropic",
|
|
482
|
+
},
|
|
483
|
+
clientSupport: {
|
|
484
|
+
codex: {
|
|
485
|
+
hooks: modelChoice === "both" || modelChoice === "openai",
|
|
486
|
+
},
|
|
487
|
+
claude: {
|
|
488
|
+
rules: modelChoice === "both" || modelChoice === "anthropic",
|
|
489
|
+
hooks: false,
|
|
490
|
+
skills: false,
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
consumers,
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
printConfigSummary(config, configPath);
|
|
497
|
+
warnIfOutputIsNotWorkspaceChild(workspaceRoot, config.output.path);
|
|
498
|
+
note("harness.config.json is Structor's project-specific input: project facts, output path, agent clients, consumer repos, and validation commands.");
|
|
499
|
+
const canWriteConfig = existingConfig
|
|
500
|
+
? await askYesNo(rl, "Replace existing harness.config.json with this config?", false)
|
|
501
|
+
: await askYesNo(rl, "Write harness.config.json?", true);
|
|
502
|
+
if (!canWriteConfig) {
|
|
503
|
+
warn("Stopped before writing config.");
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
await writeConfig(configPath, config);
|
|
507
|
+
success(`Wrote ${configPath}`);
|
|
508
|
+
|
|
509
|
+
section("Dry-run preview");
|
|
510
|
+
note("The initializer dry-run renders the plan without writing harness or consumer files.");
|
|
511
|
+
const dryRun = runGenerator(["--config", configPath, "--dry-run"], workspaceRoot);
|
|
512
|
+
printCommandOutput(dryRun);
|
|
513
|
+
if (dryRun.status !== 0) throw new Error("Generator dry-run failed.");
|
|
514
|
+
|
|
515
|
+
const apply = options.yes || await askYesNo(rl, "Generate harness now?", false);
|
|
516
|
+
if (!apply) {
|
|
517
|
+
warn("Stopped after dry-run preview.");
|
|
518
|
+
printNextSteps(config);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const generateArgs = ["--config", configPath];
|
|
523
|
+
if (options.force) generateArgs.push("--force");
|
|
524
|
+
const installEntrypoints = options.installConsumerEntrypoints || await askYesNo(
|
|
525
|
+
rl,
|
|
526
|
+
"Install consumer entrypoint pointer files? These are thin AGENTS.md/CLAUDE.md files that route agents to the generated Structor repo.",
|
|
527
|
+
true,
|
|
528
|
+
);
|
|
529
|
+
if (installEntrypoints) generateArgs.push("--install-consumer-entrypoints");
|
|
530
|
+
|
|
531
|
+
section("Generate");
|
|
532
|
+
const result = runGenerator(generateArgs, workspaceRoot);
|
|
533
|
+
printCommandOutput(result);
|
|
534
|
+
if (result.status !== 0) throw new Error("Generation failed.");
|
|
535
|
+
success("Structor setup complete.");
|
|
536
|
+
printNextSteps(config);
|
|
537
|
+
} finally {
|
|
538
|
+
rl.close();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function main() {
|
|
543
|
+
const { command, options, rawArgs } = parseArgs(process.argv.slice(2));
|
|
544
|
+
if (command === "--version" || command === "-v" || options.version) {
|
|
545
|
+
const metadata = await readPackageMetadata();
|
|
546
|
+
console.log(metadata.version);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (options.help || command === "help" || command === "--help" || command === "-h") {
|
|
550
|
+
printHelp();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (command === "init") {
|
|
554
|
+
await init(options);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (command === "generate") {
|
|
558
|
+
passthroughGenerate(rawArgs);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (command === "doctor") {
|
|
562
|
+
note("structor doctor is planned but not implemented yet.");
|
|
563
|
+
note("It will diagnose and repair drift in an existing Structor workspace:");
|
|
564
|
+
note(" - stale or missing consumer entrypoints");
|
|
565
|
+
note(" - moved harness or consumer folders");
|
|
566
|
+
note(" - unsafe output paths");
|
|
567
|
+
note("Track progress: docs/issues/0001-structor-doctor.md");
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
throw new Error(`Unknown command: ${command}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
main().catch((error) => {
|
|
574
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
575
|
+
process.exit(1);
|
|
576
|
+
});
|
package/docs/INIT.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Structor Init
|
|
2
|
+
|
|
3
|
+
`structor init` is the recommended first-run setup flow for a project workspace.
|
|
4
|
+
It is a local-only, deterministic terminal wizard. It does not call an LLM, make
|
|
5
|
+
API requests, install packages, create remotes, or modify external services.
|
|
6
|
+
|
|
7
|
+
## Recommended Command
|
|
8
|
+
|
|
9
|
+
Run from the workspace folder that contains your consumer repos:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx @structor-dev/cli init
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
During local development from a clone of this repo, use
|
|
16
|
+
`node ./structor/bin/structor.mjs init` from the parent workspace instead.
|
|
17
|
+
|
|
18
|
+
During local development from this repo, use:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm run init -- --workspace ..
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What It Reads
|
|
25
|
+
|
|
26
|
+
- The current workspace folder, or the folder passed with `--workspace`.
|
|
27
|
+
- Sibling folder names and local repo signals such as `.git`, `package.json`,
|
|
28
|
+
`pyproject.toml`, `go.mod`, `Cargo.toml`, `pom.xml`, `build.gradle`,
|
|
29
|
+
`Gemfile`, and `composer.json`.
|
|
30
|
+
- Existing `harness.config.json`, if present.
|
|
31
|
+
- Local package metadata needed to suggest validation commands.
|
|
32
|
+
|
|
33
|
+
## What It Writes
|
|
34
|
+
|
|
35
|
+
Only after confirmation, it can write:
|
|
36
|
+
|
|
37
|
+
- `harness.config.json` in the selected workspace.
|
|
38
|
+
- A generated Structor repo at the configured `output.path`.
|
|
39
|
+
- Optional consumer entrypoint pointer files: `AGENTS.md`, `CLAUDE.md`, and
|
|
40
|
+
`.claude/CLAUDE.md`.
|
|
41
|
+
|
|
42
|
+
Existing generated harness files and consumer entrypoints are skipped by the
|
|
43
|
+
underlying generator unless the user passes `--force`.
|
|
44
|
+
|
|
45
|
+
## What It Never Does
|
|
46
|
+
|
|
47
|
+
- No network calls.
|
|
48
|
+
- No LLM or API calls.
|
|
49
|
+
- No package installation in consumer repos.
|
|
50
|
+
- No `git init`, remote creation, branch publishing, or pull request work.
|
|
51
|
+
- No database, infrastructure, deployment, or external service mutation.
|
|
52
|
+
- No runner behavior such as polling, auto-repair loops, dashboards, or
|
|
53
|
+
auto-merge.
|
|
54
|
+
|
|
55
|
+
## Workspace Detection
|
|
56
|
+
|
|
57
|
+
Structor works best when run from a parent workspace folder:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
workspace/
|
|
61
|
+
project-frontend/
|
|
62
|
+
project-api/
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It suggests sibling folders as consumer repos only when it finds strong local
|
|
66
|
+
signals such as `.git`, `package.json`, `pyproject.toml`, or `go.mod`. It
|
|
67
|
+
excludes hidden folders, `node_modules`, `structor`, and likely generated
|
|
68
|
+
folders such as `*-structor`, `*-harness`, and `*-engineering-harness`.
|
|
69
|
+
|
|
70
|
+
The detected list is only a suggestion. The user confirms the selected repos
|
|
71
|
+
before any config is written.
|
|
72
|
+
|
|
73
|
+
## Generated Repo Name
|
|
74
|
+
|
|
75
|
+
The default generated repo folder is:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
<project-slug>-structor
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Harness remains the category. Structor is the productized local harness
|
|
82
|
+
implementation.
|
|
83
|
+
|
|
84
|
+
## Config File
|
|
85
|
+
|
|
86
|
+
`harness.config.json` is Structor's project-specific input file. It records:
|
|
87
|
+
|
|
88
|
+
- project name, slug, and generated repo name
|
|
89
|
+
- output path
|
|
90
|
+
- Codex and Claude support flags
|
|
91
|
+
- consumer repo paths, purposes, and validation commands
|
|
92
|
+
|
|
93
|
+
`structor generate --config harness.config.json` uses this file to render the
|
|
94
|
+
generated harness deterministically.
|
|
95
|
+
|
|
96
|
+
## Dry Run
|
|
97
|
+
|
|
98
|
+
Before generation, `structor init` runs the initializer in dry-run mode. This
|
|
99
|
+
prints the files that would be created or skipped without writing the generated
|
|
100
|
+
harness or consumer entrypoints. The user then confirms whether to apply the
|
|
101
|
+
plan.
|
|
102
|
+
|
|
103
|
+
## Customization Mode
|
|
104
|
+
|
|
105
|
+
The MVP supports `Starter only` content. It creates generic harness guidance and
|
|
106
|
+
does not infer real project contracts, coding conventions, or architecture from
|
|
107
|
+
consumer repo code.
|
|
108
|
+
|
|
109
|
+
`Light scan` and `Deep scan` are reserved for future opt-in features.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Default generated repository name uses Structor
|
|
2
|
+
|
|
3
|
+
Structor defaults generated harness repositories to `<project-slug>-structor`
|
|
4
|
+
instead of `<project-slug>-harness`, `<project-slug>-structor-harness`, or the
|
|
5
|
+
older `<project-slug>-engineering-harness` pattern. Harness remains the category,
|
|
6
|
+
while Structor is the productized implementation of that category; using the
|
|
7
|
+
product name in the default folder helps users associate Structor with the
|
|
8
|
+
improved local harness concept without reintroducing the earlier
|
|
9
|
+
engineering-harness naming ambiguity.
|