@robotaccomplice/architext 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 (43) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +497 -0
  3. package/docs/architecture/AGENTS_APPENDIX.md +39 -0
  4. package/docs/architecture/ARCHITECTURE_PLAN.md +520 -0
  5. package/docs/architecture/LLM_ARCHITEXT.md +95 -0
  6. package/docs/architext/AGENTS_APPENDIX.md +39 -0
  7. package/docs/architext/LLM_ARCHITEXT.md +64 -0
  8. package/docs/architext/README.md +120 -0
  9. package/docs/architext/data/data-classification.json +34 -0
  10. package/docs/architext/data/decisions.json +54 -0
  11. package/docs/architext/data/flows.json +114 -0
  12. package/docs/architext/data/glossary.json +24 -0
  13. package/docs/architext/data/manifest.json +23 -0
  14. package/docs/architext/data/nodes.json +194 -0
  15. package/docs/architext/data/risks.json +59 -0
  16. package/docs/architext/data/views.json +91 -0
  17. package/docs/architext/dist/assets/index-BWZ6sEpA.js +51 -0
  18. package/docs/architext/dist/assets/index-iWLms0Pa.css +1 -0
  19. package/docs/architext/dist/compass.svg +9 -0
  20. package/docs/architext/dist/index.html +14 -0
  21. package/docs/architext/index.html +13 -0
  22. package/docs/architext/package-lock.json +1822 -0
  23. package/docs/architext/package.json +28 -0
  24. package/docs/architext/public/compass.svg +9 -0
  25. package/docs/architext/schema/data-classification.schema.json +28 -0
  26. package/docs/architext/schema/decisions.schema.json +33 -0
  27. package/docs/architext/schema/flows.schema.json +72 -0
  28. package/docs/architext/schema/glossary.schema.json +22 -0
  29. package/docs/architext/schema/manifest.schema.json +47 -0
  30. package/docs/architext/schema/nodes.schema.json +69 -0
  31. package/docs/architext/schema/risks.schema.json +34 -0
  32. package/docs/architext/schema/views.schema.json +48 -0
  33. package/docs/architext/src/main.tsx +2133 -0
  34. package/docs/architext/src/styles.css +1475 -0
  35. package/docs/architext/tools/validate-architext.mjs +163 -0
  36. package/docs/architext/tsconfig.json +21 -0
  37. package/docs/architext/vite.config.ts +47 -0
  38. package/docs/assets/screenshots/architext-c4.png +0 -0
  39. package/docs/assets/screenshots/architext-data-risks.png +0 -0
  40. package/docs/assets/screenshots/architext-flows.png +0 -0
  41. package/docs/assets/screenshots/architext-sequence.png +0 -0
  42. package/package.json +81 -0
  43. package/tools/architext-adopt.mjs +874 -0
@@ -0,0 +1,874 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { copyFile, cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:http";
6
+ import { createInterface } from "node:readline/promises";
7
+ import path from "node:path";
8
+ import { stdin as input, stdout as output } from "node:process";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const viewerDir = path.join(packageRoot, "docs", "architext");
13
+ const viewerDistDir = path.join(viewerDir, "dist");
14
+ const schemaDir = path.join(viewerDir, "schema");
15
+ const validatorPath = path.join(viewerDir, "tools", "validate-architext.mjs");
16
+ const appendixPath = path.join(viewerDir, "AGENTS_APPENDIX.md");
17
+ const metadataFile = ".architext.json";
18
+ const legacyMetadataFile = ".architext-install.json";
19
+ const instructionFiles = ["AGENTS.md", "CLAUDE.md"];
20
+ const generatedIgnores = ["docs/architext/dist/"];
21
+ const copiedInstallEntries = [
22
+ "AGENTS_APPENDIX.md",
23
+ "LLM_ARCHITEXT.md",
24
+ "README.md",
25
+ "index.html",
26
+ "dist",
27
+ "node_modules",
28
+ "package-lock.json",
29
+ "package.json",
30
+ "public",
31
+ "schema",
32
+ "src",
33
+ "tools",
34
+ "tsconfig.json",
35
+ "vite.config.ts"
36
+ ];
37
+ const rootScripts = {
38
+ architext: "architext serve .",
39
+ "architext:build": "architext build .",
40
+ "architext:clean": "architext clean .",
41
+ "architext:doctor": "architext doctor .",
42
+ "architext:prompt": "architext prompt .",
43
+ "architext:validate": "architext validate ."
44
+ };
45
+
46
+ function usage() {
47
+ return `Usage:
48
+ architext <command> [path] [options]
49
+
50
+ Path:
51
+ [path] is optional and defaults to the current directory.
52
+ Use it to manage another repository, for example:
53
+ architext serve ../roboticus
54
+
55
+ Commands:
56
+ sync | install | upgrade Install data-only Architext or migrate old copied installs.
57
+ migrate Alias for sync, intended for old copied installs.
58
+ doctor Print installation health and next actions.
59
+ status Print installation status. Use --json for machine output.
60
+ serve Run the package-owned local viewer for a target repo.
61
+ validate Validate target Architext JSON with package-owned schemas.
62
+ build Build a static viewer into docs/architext/dist by default.
63
+ prompt Print an LLM maintenance prompt.
64
+ clean Remove generated local artifacts.
65
+ explain [topic] Explain schemas and data contracts.
66
+
67
+ Options:
68
+ --target <repo> Target repository. Positional [path] is preferred.
69
+ --yes, -y Accept default prompts.
70
+ --json Machine-readable status/doctor output.
71
+ --dry-run Show intended changes without writing files.
72
+ --force Rerun lifecycle management even when current.
73
+ --overwrite-data Replace docs/architext/data/*.json with neutral starter data.
74
+ --append-agents Append or replace Architext sections in AGENTS.md and CLAUDE.md.
75
+ --no-agents Do not manage AGENTS.md or CLAUDE.md.
76
+ --root-scripts Add root package.json Architext convenience scripts.
77
+ --no-root-scripts Do not manage root package.json scripts.
78
+ --update-gitignore Add generated artifact ignores without prompting.
79
+ --no-gitignore Do not manage .gitignore.
80
+ --mode <name> Prompt mode: initial-buildout, architecture-change, repair-validation.
81
+ --out <path> Build output path. Defaults to docs/architext/dist.
82
+ --skip-validate Do not run validation after sync/migration.
83
+ --branch current|new|none Branch handling for mutating sync.
84
+ --branch-name <name> Branch name to use with --branch new.
85
+
86
+ Examples:
87
+ architext sync
88
+ architext serve
89
+ architext validate .
90
+ architext doctor ../roboticus
91
+ architext status ../roboticus --json
92
+ architext sync ../roboticus --dry-run
93
+ architext sync ../roboticus --yes --branch current
94
+ architext build . --out docs/architext/dist
95
+ architext prompt . --mode architecture-change
96
+
97
+ Target repository ownership:
98
+ Target repos should commit docs/architext/data/*.json,
99
+ docs/architext/.architext.json, and optional AGENTS.md or CLAUDE.md guidance.
100
+ Do not copy or edit package-owned viewer, schema, tool, package, or Vite files
101
+ inside target repositories.`;
102
+ }
103
+
104
+ function parseArgs(argv) {
105
+ const knownCommands = new Set(["install", "upgrade", "sync", "migrate", "doctor", "status", "serve", "validate", "build", "prompt", "clean", "explain", "help"]);
106
+ const first = argv[0];
107
+ const hasCommand = first && !first.startsWith("--") && knownCommands.has(first);
108
+ const command = hasCommand ? first : "sync";
109
+ const rest = hasCommand ? argv.slice(1) : argv;
110
+ const options = {
111
+ command,
112
+ target: "",
113
+ topic: "",
114
+ yes: false,
115
+ json: false,
116
+ dryRun: false,
117
+ force: false,
118
+ overwriteData: false,
119
+ appendAgents: false,
120
+ noAgents: false,
121
+ rootScripts: false,
122
+ noRootScripts: false,
123
+ updateGitignore: false,
124
+ noGitignore: false,
125
+ mode: "initial-buildout",
126
+ out: "",
127
+ skipValidate: false,
128
+ nodeModules: false,
129
+ branch: "",
130
+ branchName: ""
131
+ };
132
+
133
+ for (let index = 0; index < rest.length; index += 1) {
134
+ const arg = rest[index];
135
+ if (arg === "--target") options.target = rest[++index] ?? "";
136
+ else if (arg === "--yes" || arg === "-y") options.yes = true;
137
+ else if (arg === "--json") options.json = true;
138
+ else if (arg === "--dry-run") options.dryRun = true;
139
+ else if (arg === "--force") options.force = true;
140
+ else if (arg === "--overwrite-data") options.overwriteData = true;
141
+ else if (arg === "--append-agents") options.appendAgents = true;
142
+ else if (arg === "--no-agents") options.noAgents = true;
143
+ else if (arg === "--root-scripts") options.rootScripts = true;
144
+ else if (arg === "--no-root-scripts") options.noRootScripts = true;
145
+ else if (arg === "--update-gitignore") options.updateGitignore = true;
146
+ else if (arg === "--no-gitignore") options.noGitignore = true;
147
+ else if (arg === "--mode") options.mode = rest[++index] ?? "";
148
+ else if (arg === "--out") options.out = rest[++index] ?? "";
149
+ else if (arg === "--skip-validate") options.skipValidate = true;
150
+ else if (arg === "--node-modules") options.nodeModules = true;
151
+ else if (arg === "--branch") options.branch = rest[++index] ?? "";
152
+ else if (arg === "--branch-name") options.branchName = rest[++index] ?? "";
153
+ else if (arg === "--help" || arg === "-h") options.command = "help";
154
+ else if (options.command === "explain" && !options.topic) options.topic = arg;
155
+ else if (!options.target) options.target = arg;
156
+ else throw new Error(`Unknown argument: ${arg}`);
157
+ }
158
+
159
+ return options;
160
+ }
161
+
162
+ function run(command, args, cwd, extraEnv = {}) {
163
+ console.log(`Running: ${command} ${args.join(" ")}`);
164
+ execFileSync(command, args, {
165
+ cwd,
166
+ stdio: "inherit",
167
+ shell: process.platform === "win32",
168
+ env: { ...process.env, ...extraEnv }
169
+ });
170
+ }
171
+
172
+ function tryRun(command, args, cwd, extraEnv = {}) {
173
+ try {
174
+ return {
175
+ ok: true,
176
+ output: execFileSync(command, args, {
177
+ cwd,
178
+ encoding: "utf8",
179
+ stdio: ["ignore", "pipe", "pipe"],
180
+ shell: process.platform === "win32",
181
+ env: { ...process.env, ...extraEnv }
182
+ }).trim()
183
+ };
184
+ } catch (error) {
185
+ return {
186
+ ok: false,
187
+ output: `${error.stdout?.toString?.() ?? ""}${error.stderr?.toString?.() ?? ""}`.trim() || error.message
188
+ };
189
+ }
190
+ }
191
+
192
+ function git(target, args) {
193
+ return execFileSync("git", args, { cwd: target, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
194
+ }
195
+
196
+ function gitAvailable(target) {
197
+ try {
198
+ git(target, ["rev-parse", "--is-inside-work-tree"]);
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ async function readJson(file) {
206
+ return JSON.parse(await readFile(file, "utf8"));
207
+ }
208
+
209
+ async function writeJson(file, value) {
210
+ await mkdir(path.dirname(file), { recursive: true });
211
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
212
+ }
213
+
214
+ async function packageVersion() {
215
+ return (await readJson(path.join(packageRoot, "package.json"))).version;
216
+ }
217
+
218
+ function architextDir(target) {
219
+ return path.join(target, "docs", "architext");
220
+ }
221
+
222
+ function dataDir(target) {
223
+ return path.join(architextDir(target), "data");
224
+ }
225
+
226
+ function metadataPath(target) {
227
+ return path.join(architextDir(target), metadataFile);
228
+ }
229
+
230
+ function legacyMetadataPath(target) {
231
+ return path.join(architextDir(target), legacyMetadataFile);
232
+ }
233
+
234
+ async function assertTarget(target) {
235
+ const targetStat = await stat(target).catch(() => null);
236
+ if (!targetStat?.isDirectory()) throw new Error(`Target is not a directory: ${target}`);
237
+ }
238
+
239
+ function copiedInstallPaths(target) {
240
+ if (path.resolve(target) === packageRoot) return [];
241
+ return copiedInstallEntries
242
+ .map((entry) => path.join(architextDir(target), entry))
243
+ .filter((entryPath) => existsSync(entryPath));
244
+ }
245
+
246
+ async function readMetadata(target) {
247
+ const current = metadataPath(target);
248
+ const legacy = legacyMetadataPath(target);
249
+ if (existsSync(current)) return readJson(current).catch(() => null);
250
+ if (existsSync(legacy)) return readJson(legacy).catch(() => null);
251
+ return null;
252
+ }
253
+
254
+ async function validateTarget(target) {
255
+ if (!existsSync(path.join(dataDir(target), "manifest.json"))) {
256
+ return { ok: false, output: `Architext data is not installed at ${dataDir(target)}` };
257
+ }
258
+ return tryRun(process.execPath, [validatorPath, "--data-dir", dataDir(target), "--schema-dir", schemaDir], packageRoot);
259
+ }
260
+
261
+ function slugify(value) {
262
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
263
+ return slug || "target-project";
264
+ }
265
+
266
+ async function writeStarterData(target, version) {
267
+ const targetDataDir = dataDir(target);
268
+ const projectName = path.basename(target);
269
+ const projectId = slugify(projectName);
270
+ const systemId = `${projectId}-system`;
271
+ const actorId = "project-team";
272
+ const dataId = "architecture-knowledge";
273
+ const flowId = "architecture-buildout";
274
+
275
+ await writeJson(path.join(targetDataDir, "manifest.json"), {
276
+ schemaVersion: version,
277
+ project: {
278
+ id: projectId,
279
+ name: projectName,
280
+ summary: "Architext has been installed. Replace this starter model with the real project architecture."
281
+ },
282
+ generatedAt: new Date().toISOString(),
283
+ defaultViewId: "system-map",
284
+ files: {
285
+ nodes: "nodes.json",
286
+ flows: "flows.json",
287
+ views: "views.json",
288
+ dataClassification: "data-classification.json",
289
+ decisions: "decisions.json",
290
+ risks: "risks.json",
291
+ glossary: "glossary.json"
292
+ },
293
+ notes: [
294
+ "Starter data only. Ask an LLM to inspect the codebase and build out docs/architext/data/*.json.",
295
+ "Do not treat this starter model as architecture documentation for the target project."
296
+ ]
297
+ });
298
+
299
+ await writeJson(path.join(targetDataDir, "nodes.json"), {
300
+ nodes: [
301
+ {
302
+ id: actorId,
303
+ type: "actor",
304
+ name: "Project Team",
305
+ summary: "Placeholder actor for the team or user initiating the Architext build-out.",
306
+ responsibilities: ["Replace starter data with real architecture facts"],
307
+ owner: "Project maintainers",
308
+ sourcePaths: [],
309
+ runtime: "Repository workflow",
310
+ interfaces: ["Architext JSON"],
311
+ dependencies: [systemId],
312
+ dataHandled: [dataId],
313
+ security: ["Unknown until architecture build-out is complete"],
314
+ observability: ["Unknown until architecture build-out is complete"],
315
+ relatedFlows: [flowId],
316
+ relatedDecisions: [],
317
+ knownRisks: ["architext-starter-data"],
318
+ verification: ["architext validate"]
319
+ },
320
+ {
321
+ id: systemId,
322
+ type: "software-system",
323
+ name: projectName,
324
+ summary: "Placeholder system boundary. Replace with the real project systems, services, stores, flows, and dependencies.",
325
+ responsibilities: ["Pending architecture discovery"],
326
+ owner: "Project maintainers",
327
+ sourcePaths: [],
328
+ runtime: "Unknown until architecture build-out is complete",
329
+ interfaces: ["Unknown until architecture build-out is complete"],
330
+ dependencies: [],
331
+ dataHandled: [dataId],
332
+ security: ["Unknown until architecture build-out is complete"],
333
+ observability: ["Unknown until architecture build-out is complete"],
334
+ relatedFlows: [flowId],
335
+ relatedDecisions: ["architext-buildout-required"],
336
+ knownRisks: ["architext-starter-data"],
337
+ verification: ["architext validate"]
338
+ }
339
+ ]
340
+ });
341
+
342
+ await writeJson(path.join(targetDataDir, "flows.json"), {
343
+ flows: [
344
+ {
345
+ id: flowId,
346
+ name: "Architext build-out required",
347
+ status: "planned",
348
+ summary: "Starter flow showing that architecture data still needs to be generated from the target repository.",
349
+ trigger: "Architext installed into the project",
350
+ actors: [actorId],
351
+ steps: [
352
+ {
353
+ id: "inspect-project",
354
+ from: actorId,
355
+ to: systemId,
356
+ action: "inspectCodebaseAndReplaceStarterData",
357
+ summary: "An LLM should inspect the repository and replace every starter JSON file with real architecture data.",
358
+ data: [dataId]
359
+ }
360
+ ],
361
+ guarantees: ["Validation passes for starter data"],
362
+ failureBehavior: ["Rendered site is not useful until project-specific data replaces the starter model"],
363
+ observability: ["Validation output"],
364
+ verification: ["architext validate"],
365
+ knownGaps: ["All project architecture facts are pending discovery"]
366
+ }
367
+ ]
368
+ });
369
+
370
+ await writeJson(path.join(targetDataDir, "views.json"), {
371
+ views: [
372
+ { id: "system-map", name: "System Map", type: "system-map", summary: "Starter view. Replace with the real project system map.", lanes: [{ id: "people", name: "People", nodeIds: [actorId] }, { id: "system", name: "System", nodeIds: [systemId] }] },
373
+ { id: "dataflow", name: "Dataflow", type: "dataflow", summary: "Starter dataflow. Replace with real data movement.", lanes: [{ id: "source", name: "Source", nodeIds: [actorId] }, { id: "target", name: "Target", nodeIds: [systemId] }] },
374
+ { id: "sequence", name: "Sequence", type: "sequence", summary: "Starter sequence for the build-out flow.", lanes: [{ id: "participants", name: "Participants", nodeIds: [actorId, systemId] }] },
375
+ { id: "deployment", name: "Deployment", type: "deployment", summary: "Starter deployment view. Replace with real runtime placement.", lanes: [{ id: "unknown", name: "Unknown", nodeIds: [systemId] }] },
376
+ { id: "c4-context", name: "C4 Context", type: "c4-context", summary: "Starter C4 context. Replace with real actors, system boundary, and external systems.", lanes: [{ id: "people", name: "People", nodeIds: [actorId] }, { id: "system", name: "System", nodeIds: [systemId] }] },
377
+ { id: "c4-container", name: "C4 Container", type: "c4-container", summary: "Starter C4 container view. Replace with deployable units and dependencies.", lanes: [{ id: "containers", name: "Containers", nodeIds: [systemId] }] },
378
+ { id: "c4-component", name: "C4 Component", type: "c4-component", summary: "Starter C4 component view. Replace with components inside a selected container.", lanes: [{ id: "components", name: "Components", nodeIds: [systemId] }] }
379
+ ]
380
+ });
381
+
382
+ await writeJson(path.join(targetDataDir, "data-classification.json"), {
383
+ classes: [{ id: dataId, name: "Architecture Knowledge", sensitivity: "medium", handling: "Review generated architecture facts before treating them as project documentation." }]
384
+ });
385
+ await writeJson(path.join(targetDataDir, "decisions.json"), {
386
+ decisions: [{ id: "architext-buildout-required", status: "planned", title: "Replace starter Architext data", context: "Architext was installed with neutral starter data.", decision: "An LLM must inspect the target repository and replace docs/architext/data/*.json with project-specific architecture facts.", consequences: ["The site validates immediately", "The starter model is intentionally not useful as final documentation"], relatedNodes: [systemId], relatedFlows: [flowId] }]
387
+ });
388
+ await writeJson(path.join(targetDataDir, "risks.json"), {
389
+ risks: [{ id: "architext-starter-data", title: "Starter data is not project architecture", category: "technical", severity: "high", status: "open", summary: "The installed Architext data is a placeholder until an LLM builds out the real architecture model.", mitigations: ["Run the LLM JSON build-out workflow", "Review generated JSON diffs", "Run architext validate"], relatedNodes: [systemId], relatedFlows: [flowId] }]
390
+ });
391
+ await writeJson(path.join(targetDataDir, "glossary.json"), {
392
+ terms: [{ term: "Architext starter data", definition: "A neutral validating placeholder installed into new projects before real architecture data is generated." }]
393
+ });
394
+ }
395
+
396
+ async function appendixMarkdown() {
397
+ const appendix = await readFile(appendixPath, "utf8");
398
+ const start = appendix.indexOf("```markdown");
399
+ const end = appendix.lastIndexOf("\n```");
400
+ if (start === -1 || end === -1 || end <= start) return appendix.trim();
401
+ return appendix.slice(start + "```markdown".length, end).trim();
402
+ }
403
+
404
+ function replaceArchitextSection(existing, appendix) {
405
+ const heading = "## Architext Architecture Documentation";
406
+ const start = existing.indexOf(heading);
407
+ if (start === -1) {
408
+ const prefix = existing.trimEnd();
409
+ return `${prefix}${prefix ? "\n\n" : ""}${appendix}\n`;
410
+ }
411
+ const nextHeading = existing.slice(start + heading.length).search(/\n## (?!#)/);
412
+ const end = nextHeading === -1 ? existing.length : start + heading.length + nextHeading;
413
+ return `${existing.slice(0, start).trimEnd()}${existing.slice(0, start).trimEnd() ? "\n\n" : ""}${appendix}\n${existing.slice(end).replace(/^\n+/, "\n")}`.replace(/\n{3,}/g, "\n\n");
414
+ }
415
+
416
+ async function upsertInstructionFile({ target, fileName, dryRun }) {
417
+ const destination = path.join(target, fileName);
418
+ const appendix = await appendixMarkdown();
419
+ const existing = existsSync(destination) ? await readFile(destination, "utf8") : "";
420
+ const next = replaceArchitextSection(existing, appendix);
421
+ if (next === existing) return { destination, changed: false, reason: "already current" };
422
+ if (!dryRun) {
423
+ await mkdir(path.dirname(destination), { recursive: true });
424
+ await writeFile(destination, next, "utf8");
425
+ }
426
+ return { destination, changed: true, created: !existing };
427
+ }
428
+
429
+ async function packageJsonInfo(target) {
430
+ const file = path.join(target, "package.json");
431
+ if (!existsSync(file)) return { path: file, exists: false, packageJson: null };
432
+ return { path: file, exists: true, packageJson: await readJson(file) };
433
+ }
434
+
435
+ async function upsertRootScripts({ target, dryRun }) {
436
+ const info = await packageJsonInfo(target);
437
+ if (!info.exists) return { destination: info.path, changed: false, reason: "missing package.json", missing: [] };
438
+ const existingScripts = info.packageJson.scripts ?? {};
439
+ const missing = Object.entries(rootScripts).filter(([name, value]) => existingScripts[name] !== value);
440
+ if (missing.length === 0) return { destination: info.path, changed: false, reason: "already present", missing: [] };
441
+ if (!dryRun) {
442
+ await writeJson(info.path, { ...info.packageJson, scripts: { ...existingScripts, ...Object.fromEntries(missing) } });
443
+ }
444
+ return { destination: info.path, changed: true, missing: missing.map(([name]) => name) };
445
+ }
446
+
447
+ async function upsertGitignore({ target, dryRun }) {
448
+ const destination = path.join(target, ".gitignore");
449
+ const existing = existsSync(destination) ? await readFile(destination, "utf8") : "";
450
+ const lines = existing.split(/\r?\n/);
451
+ const missing = generatedIgnores.filter((entry) => !lines.includes(entry));
452
+ if (missing.length === 0) return { destination, changed: false, reason: "already present", missing: [] };
453
+ if (!dryRun) {
454
+ const prefix = existing.trimEnd();
455
+ await writeFile(destination, `${prefix}${prefix ? "\n\n" : ""}# Architext generated static builds.\n${missing.join("\n")}\n`, "utf8");
456
+ }
457
+ return { destination, changed: true, missing };
458
+ }
459
+
460
+ async function writeMetadata(target, patch) {
461
+ const existing = await readMetadata(target);
462
+ const next = {
463
+ schemaVersion: 2,
464
+ installedAt: existing?.installedAt ?? new Date().toISOString(),
465
+ updatedAt: new Date().toISOString(),
466
+ ...existing,
467
+ ...patch
468
+ };
469
+ await writeJson(metadataPath(target), next);
470
+ return next;
471
+ }
472
+
473
+ async function collectStatus(target, version, { runValidation = false } = {}) {
474
+ const targetDataDir = dataDir(target);
475
+ const manifestPath = path.join(targetDataDir, "manifest.json");
476
+ const copiedPaths = copiedInstallPaths(target);
477
+ const packageSelf = path.resolve(target) === packageRoot;
478
+ const metadata = await readMetadata(target);
479
+ const installed = existsSync(manifestPath);
480
+ const validation = runValidation ? await validateTarget(target) : null;
481
+ const gitignoreText = existsSync(path.join(target, ".gitignore")) ? await readFile(path.join(target, ".gitignore"), "utf8") : "";
482
+ const gitignoreMissing = generatedIgnores.filter((entry) => !gitignoreText.split(/\r?\n/).includes(entry));
483
+ const instructionStatus = {};
484
+ for (const fileName of instructionFiles) {
485
+ const filePath = path.join(target, fileName);
486
+ const text = existsSync(filePath) ? await readFile(filePath, "utf8") : "";
487
+ instructionStatus[fileName] = {
488
+ exists: existsSync(filePath),
489
+ hasArchitextSection: text.includes("## Architext Architecture Documentation"),
490
+ mentionsCopiedTemplate: /docs\/architext\/(src|schema|tools|package\.json|node_modules)|npm run validate|cd docs\/architext/.test(text)
491
+ };
492
+ }
493
+ const packageInfo = await packageJsonInfo(target);
494
+ const rootScriptStatus = {};
495
+ for (const [name, value] of Object.entries(rootScripts)) {
496
+ const actual = packageInfo.packageJson?.scripts?.[name] ?? "";
497
+ rootScriptStatus[name] = { present: Boolean(actual), recommended: actual === value, value: actual || null };
498
+ }
499
+ const trackedGenerated = gitAvailable(target)
500
+ ? tryRun("git", ["ls-files", "docs/architext/dist"], target).output.split(/\r?\n/).filter(Boolean)
501
+ : [];
502
+
503
+ return {
504
+ target,
505
+ cliVersion: version,
506
+ installed,
507
+ dataDir: targetDataDir,
508
+ metadata,
509
+ copiedInstallDetected: !packageSelf && (copiedPaths.length > 0 || existsSync(legacyMetadataPath(target))),
510
+ copiedInstallPaths: copiedPaths.map((item) => path.relative(target, item)),
511
+ needsMigration: !packageSelf && (copiedPaths.length > 0 || existsSync(legacyMetadataPath(target))),
512
+ gitignoreMissing,
513
+ instructionStatus,
514
+ rootPackageExists: packageInfo.exists,
515
+ rootScripts: rootScriptStatus,
516
+ trackedGenerated,
517
+ validation
518
+ };
519
+ }
520
+
521
+ function printStatus(status, { verbose = false } = {}) {
522
+ console.log(`Target: ${status.target}`);
523
+ console.log(`Architext data: ${status.installed ? "installed" : "missing"}`);
524
+ console.log(`CLI: ${status.cliVersion}`);
525
+ console.log(`Copied install: ${status.copiedInstallDetected ? "detected" : "no"}`);
526
+ console.log(`Gitignore: ${status.gitignoreMissing.length ? `missing ${status.gitignoreMissing.join(", ")}` : "ok"}`);
527
+ console.log(`Generated artifacts tracked: ${status.trackedGenerated.length ? status.trackedGenerated.length : "none"}`);
528
+ if (status.validation) {
529
+ console.log(`Validation: ${status.validation.ok ? "passed" : "failed"}`);
530
+ if (!status.validation.ok || verbose) console.log(status.validation.output);
531
+ }
532
+ if (verbose) {
533
+ console.log("Instruction files:");
534
+ for (const [fileName, fileStatus] of Object.entries(status.instructionStatus)) {
535
+ const state = fileStatus.hasArchitextSection ? fileStatus.mentionsCopiedTemplate ? "outdated Architext section" : "current Architext section" : fileStatus.exists ? "missing Architext section" : "missing";
536
+ console.log(`- ${fileName}: ${state}`);
537
+ }
538
+ console.log("Root scripts:");
539
+ for (const [name, script] of Object.entries(status.rootScripts)) {
540
+ console.log(`- ${name}: ${script.present ? script.recommended ? "ok" : "custom" : "missing"}`);
541
+ }
542
+ }
543
+ }
544
+
545
+ async function promptYesNo(rl, question, defaultValue) {
546
+ const suffix = defaultValue ? "Y/n" : "y/N";
547
+ const answer = (await rl.question(`${question} [${suffix}] `)).trim().toLowerCase();
548
+ if (!answer) return defaultValue;
549
+ return ["y", "yes"].includes(answer);
550
+ }
551
+
552
+ async function handleBranch({ target, options, version, rl }) {
553
+ if (options.dryRun || options.branch === "none" || !gitAvailable(target)) return;
554
+ let branchChoice = options.branch;
555
+ if (!branchChoice && !options.yes) {
556
+ branchChoice = await promptYesNo(rl, "Create a new git branch for Architext changes?", false) ? "new" : "current";
557
+ }
558
+ if (!branchChoice) branchChoice = "current";
559
+ if (branchChoice === "current") return;
560
+ if (branchChoice !== "new") throw new Error("--branch must be current, new, or none");
561
+ const branchName = options.branchName || `architext/data-only-${version.replaceAll(".", "-")}`;
562
+ git(target, ["checkout", "-b", branchName]);
563
+ console.log(`Created and switched to branch ${branchName}`);
564
+ }
565
+
566
+ async function chooseInstructionFiles(options, rl) {
567
+ if (options.noAgents) return [];
568
+ if (options.appendAgents || options.yes) return instructionFiles;
569
+ const selected = [];
570
+ for (const fileName of instructionFiles) {
571
+ if (await promptYesNo(rl, `Create/update ${fileName} Architext instructions?`, true)) selected.push(fileName);
572
+ }
573
+ return selected;
574
+ }
575
+
576
+ async function chooseGitignore(options, rl) {
577
+ if (options.noGitignore) return false;
578
+ if (options.updateGitignore || options.yes) return true;
579
+ return promptYesNo(rl, "Ensure .gitignore excludes Architext generated builds?", true);
580
+ }
581
+
582
+ async function chooseRootScripts(target, options, rl) {
583
+ if (options.noRootScripts) return false;
584
+ if (options.rootScripts) return true;
585
+ if (!(await packageJsonInfo(target)).exists) return false;
586
+ if (options.yes) return true;
587
+ return promptYesNo(rl, "Add root package.json Architext convenience scripts?", true);
588
+ }
589
+
590
+ async function removeCopiedInstallFiles(target, dryRun) {
591
+ const removed = [];
592
+ for (const entryPath of copiedInstallPaths(target)) {
593
+ removed.push(path.relative(target, entryPath));
594
+ if (!dryRun) await rm(entryPath, { recursive: true, force: true });
595
+ }
596
+ if (existsSync(legacyMetadataPath(target))) {
597
+ removed.push(path.relative(target, legacyMetadataPath(target)));
598
+ if (!dryRun) await rm(legacyMetadataPath(target), { force: true });
599
+ }
600
+ return removed;
601
+ }
602
+
603
+ async function syncTarget(target, options, version) {
604
+ const status = await collectStatus(target, version);
605
+ const installing = !status.installed || options.overwriteData;
606
+ const migrating = status.needsMigration;
607
+ const shouldWrite = installing || migrating || options.force || options.appendAgents || options.rootScripts || options.updateGitignore;
608
+
609
+ console.log(`Target: ${target}`);
610
+ console.log(`Architext CLI: ${version}`);
611
+ console.log(`Operation: ${installing ? "install" : migrating ? "migrate" : "sync"}${shouldWrite ? "" : " (current)"}`);
612
+ if (migrating) {
613
+ console.log(`Copied install detected: ${status.copiedInstallPaths.length} package-owned paths`);
614
+ }
615
+
616
+ const rl = createInterface({ input, output });
617
+ try {
618
+ const instructionFilesToManage = await chooseInstructionFiles(options, rl);
619
+ const manageGitignore = await chooseGitignore(options, rl);
620
+ const manageRootScripts = await chooseRootScripts(target, options, rl);
621
+
622
+ if (!shouldWrite && instructionFilesToManage.length === 0 && !manageGitignore && !manageRootScripts) {
623
+ console.log("No lifecycle changes needed.");
624
+ return;
625
+ }
626
+
627
+ await handleBranch({ target, options, version, rl });
628
+ if (!options.yes && !options.dryRun) {
629
+ const proceed = await promptYesNo(rl, "Proceed with selected Architext changes in this branch?", true);
630
+ if (!proceed) {
631
+ console.log("Aborted.");
632
+ return;
633
+ }
634
+ }
635
+
636
+ if (installing) {
637
+ console.log(`${options.dryRun ? "Would write" : "Writing"} starter data to ${dataDir(target)}`);
638
+ if (!options.dryRun) await writeStarterData(target, version);
639
+ } else {
640
+ console.log("Preserving target-owned docs/architext/data/*.json");
641
+ }
642
+
643
+ const removed = migrating ? await removeCopiedInstallFiles(target, options.dryRun) : [];
644
+ if (removed.length) {
645
+ console.log(`${options.dryRun ? "Would remove" : "Removed"} copied package-owned files:`);
646
+ removed.forEach((item) => console.log(`- ${item}`));
647
+ }
648
+
649
+ const managedInstructions = [];
650
+ for (const fileName of instructionFilesToManage) {
651
+ const result = await upsertInstructionFile({ target, fileName, dryRun: options.dryRun });
652
+ console.log(result.changed ? `${options.dryRun ? "Would update" : "Updated"} ${result.destination}` : `Skipped ${result.destination}: ${result.reason}`);
653
+ if (result.changed) managedInstructions.push(fileName);
654
+ }
655
+
656
+ let gitignoreManaged = false;
657
+ if (manageGitignore) {
658
+ const result = await upsertGitignore({ target, dryRun: options.dryRun });
659
+ console.log(result.changed ? `${options.dryRun ? "Would update" : "Updated"} ${result.destination}` : `Skipped ${result.destination}: ${result.reason}`);
660
+ gitignoreManaged = result.changed || result.reason === "already present";
661
+ }
662
+
663
+ let rootScriptsManaged = false;
664
+ if (manageRootScripts) {
665
+ const result = await upsertRootScripts({ target, dryRun: options.dryRun });
666
+ console.log(result.changed ? `${options.dryRun ? "Would update" : "Updated"} ${result.destination} with ${result.missing.length} scripts` : `Skipped ${result.destination}: ${result.reason}`);
667
+ rootScriptsManaged = result.changed || result.reason === "already present";
668
+ }
669
+
670
+ const validation = options.skipValidate || (options.dryRun && installing)
671
+ ? null
672
+ : await validateTarget(target);
673
+ if (validation) console.log(`Validation: ${validation.ok ? "passed" : "failed"}`);
674
+
675
+ if (!options.dryRun) {
676
+ await writeMetadata(target, {
677
+ source: "architext-cli",
678
+ cliVersion: version,
679
+ operation: installing ? "install" : migrating ? "migrate" : "sync",
680
+ dataPolicy: installing ? "starter-written" : "preserved",
681
+ copiedInstallMigrated: migrating,
682
+ instructionFiles: Object.fromEntries(instructionFiles.map((fileName) => [fileName, instructionFilesToManage.includes(fileName)])),
683
+ managedInstructions,
684
+ gitignoreManaged,
685
+ rootScriptsManaged,
686
+ lastValidation: validation ? { ok: validation.ok, at: new Date().toISOString() } : undefined
687
+ });
688
+ }
689
+ } finally {
690
+ rl.close();
691
+ }
692
+ }
693
+
694
+ async function printPrompt(target, mode) {
695
+ const manifestPath = path.join(dataDir(target), "manifest.json");
696
+ const manifest = existsSync(manifestPath) ? await readJson(manifestPath) : null;
697
+ const projectName = manifest?.project?.name ?? path.basename(target);
698
+ const modes = new Set(["initial-buildout", "architecture-change", "repair-validation"]);
699
+ const promptMode = modes.has(mode) ? mode : "initial-buildout";
700
+ const lead = {
701
+ "initial-buildout": `Build out Architext for ${projectName}. Replace neutral starter data with source-backed architecture facts.`,
702
+ "architecture-change": `Update Architext for the architecture changes just made in ${projectName}. Keep existing stable IDs where concepts already exist.`,
703
+ "repair-validation": `Repair Architext JSON validation failures for ${projectName}. Do not change application code for this task.`
704
+ }[promptMode];
705
+
706
+ console.log(`${lead}
707
+
708
+ First read AGENTS.md/CLAUDE.md if present, then docs/architext/data/*.json.
709
+
710
+ Rules:
711
+ - Update only docs/architext/data/*.json unless the Architext package itself is being changed.
712
+ - Reuse stable IDs, create nodes before references, keep flows ordered, and prefer source-path-backed claims.
713
+ - Mark uncertainty and known gaps explicitly.
714
+ - Do not edit copied viewer, schema, package, Vite, or local tool files in the target repository.
715
+ - Run architext validate ${target} before claiming completion.
716
+
717
+ Required finish:
718
+ - Summarize changed data files.
719
+ - Summarize covered architecture areas.
720
+ - Summarize remaining uncertainty.
721
+ - Report validation result.`);
722
+ }
723
+
724
+ async function cleanGenerated(target, options) {
725
+ const candidates = [path.join(architextDir(target), "dist")];
726
+ if (options.nodeModules) candidates.push(path.join(architextDir(target), "node_modules"));
727
+ const removed = [];
728
+ for (const candidate of candidates) {
729
+ if (existsSync(candidate)) {
730
+ removed.push(candidate);
731
+ if (!options.dryRun) await rm(candidate, { recursive: true, force: true });
732
+ }
733
+ }
734
+ console.log(removed.length ? `${options.dryRun ? "Would remove" : "Removed"}:\n${removed.map((item) => `- ${item}`).join("\n")}` : "No generated Architext artifacts found.");
735
+ }
736
+
737
+ async function explainTopic(topic) {
738
+ const normalized = (topic || "overview").toLowerCase();
739
+ const schemaMap = {
740
+ manifest: "manifest.schema.json",
741
+ nodes: "nodes.schema.json",
742
+ node: "nodes.schema.json",
743
+ flows: "flows.schema.json",
744
+ flow: "flows.schema.json",
745
+ views: "views.schema.json",
746
+ view: "views.schema.json",
747
+ data: "data-classification.schema.json",
748
+ risks: "risks.schema.json",
749
+ risk: "risks.schema.json",
750
+ decisions: "decisions.schema.json",
751
+ decision: "decisions.schema.json",
752
+ glossary: "glossary.schema.json"
753
+ };
754
+ const schemaFile = schemaMap[normalized];
755
+ if (!schemaFile) {
756
+ console.log("Architext data is split across manifest, nodes, flows, views, data classification, decisions, risks, and glossary JSON files.");
757
+ return;
758
+ }
759
+ const schema = await readJson(path.join(schemaDir, schemaFile));
760
+ console.log(`${normalized}: package schema ${schemaFile}`);
761
+ if (schema.required?.length) console.log(`Required fields: ${schema.required.join(", ")}`);
762
+ }
763
+
764
+ async function buildStatic(target, options) {
765
+ const outDir = path.resolve(target, options.out || path.join("docs", "architext", "dist"));
766
+ if (!existsSync(path.join(viewerDistDir, "index.html"))) {
767
+ throw new Error("Package viewer assets are missing. Run npm run build before packing Architext.");
768
+ }
769
+ await rm(outDir, { recursive: true, force: true });
770
+ await cp(viewerDistDir, outDir, { recursive: true });
771
+ await mkdir(path.join(outDir, "data"), { recursive: true });
772
+ await cp(dataDir(target), path.join(outDir, "data"), { recursive: true });
773
+ console.log(`Copied target data to ${path.join(outDir, "data")}`);
774
+ }
775
+
776
+ const contentTypes = {
777
+ ".css": "text/css; charset=utf-8",
778
+ ".html": "text/html; charset=utf-8",
779
+ ".js": "text/javascript; charset=utf-8",
780
+ ".json": "application/json; charset=utf-8",
781
+ ".svg": "image/svg+xml; charset=utf-8"
782
+ };
783
+
784
+ function safeJoin(root, requestPath) {
785
+ const decoded = decodeURIComponent(requestPath);
786
+ const resolved = path.resolve(root, decoded.replace(/^\/+/, ""));
787
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return "";
788
+ return resolved;
789
+ }
790
+
791
+ async function sendFile(response, file) {
792
+ const body = await readFile(file);
793
+ response.writeHead(200, { "content-type": contentTypes[path.extname(file)] || "application/octet-stream" });
794
+ response.end(body);
795
+ }
796
+
797
+ async function serveTarget(target) {
798
+ if (!existsSync(path.join(viewerDistDir, "index.html"))) {
799
+ throw new Error("Package viewer assets are missing. Run npm run build before serving Architext.");
800
+ }
801
+ const targetDataDir = dataDir(target);
802
+ const server = createServer(async (request, response) => {
803
+ try {
804
+ const url = new URL(request.url || "/", "http://127.0.0.1");
805
+ if (url.pathname.startsWith("/data/")) {
806
+ const dataFile = safeJoin(targetDataDir, url.pathname.slice("/data/".length));
807
+ if (!dataFile || !(await stat(dataFile).catch(() => null))?.isFile()) {
808
+ response.writeHead(404);
809
+ response.end("Not found");
810
+ return;
811
+ }
812
+ await sendFile(response, dataFile);
813
+ return;
814
+ }
815
+
816
+ const assetPath = url.pathname === "/" ? "index.html" : url.pathname;
817
+ const assetFile = safeJoin(viewerDistDir, assetPath);
818
+ const assetStat = assetFile ? await stat(assetFile).catch(() => null) : null;
819
+ await sendFile(response, assetStat?.isFile() ? assetFile : path.join(viewerDistDir, "index.html"));
820
+ } catch (error) {
821
+ response.writeHead(500);
822
+ response.end(error.message);
823
+ }
824
+ });
825
+
826
+ await new Promise((resolve, reject) => {
827
+ server.once("error", reject);
828
+ server.listen(4317, "127.0.0.1", resolve);
829
+ });
830
+ console.log(`Serving Architext for ${target}`);
831
+ console.log("Open http://127.0.0.1:4317");
832
+ }
833
+
834
+ async function main() {
835
+ const options = parseArgs(process.argv.slice(2));
836
+ if (options.command === "help") {
837
+ console.log(usage());
838
+ return;
839
+ }
840
+
841
+ const version = await packageVersion();
842
+ const target = path.resolve(options.target || process.cwd());
843
+ if (options.command !== "explain") await assertTarget(target);
844
+
845
+ if (["install", "upgrade", "sync", "migrate"].includes(options.command)) return syncTarget(target, options, version);
846
+ if (options.command === "serve") return serveTarget(target);
847
+ if (options.command === "validate") {
848
+ const validation = await validateTarget(target);
849
+ console.log(validation.output);
850
+ if (!validation.ok) process.exit(1);
851
+ return;
852
+ }
853
+ if (options.command === "build") return buildStatic(target, options);
854
+ if (options.command === "prompt") return printPrompt(target, options.mode);
855
+ if (options.command === "clean") return cleanGenerated(target, options);
856
+ if (options.command === "explain") return explainTopic(options.topic);
857
+ if (options.command === "status" || options.command === "doctor") {
858
+ const status = await collectStatus(target, version, { runValidation: options.command === "doctor" });
859
+ if (options.json) console.log(JSON.stringify(status, null, 2));
860
+ else {
861
+ printStatus(status, { verbose: options.command === "doctor" });
862
+ if (!status.installed || status.needsMigration) console.log("Next: architext sync");
863
+ else if (status.validation && !status.validation.ok) console.log("Next: architext prompt --mode repair-validation");
864
+ else console.log("Next: architext serve");
865
+ }
866
+ return;
867
+ }
868
+ throw new Error(`Unknown command: ${options.command}`);
869
+ }
870
+
871
+ main().catch((error) => {
872
+ console.error(error.message);
873
+ process.exit(1);
874
+ });