@crewhaus/context-bundle 0.1.3 → 0.1.5

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.
@@ -0,0 +1,27 @@
1
+ export type BuildContextBundleOpts = {
2
+ readonly docsRoot: string;
3
+ readonly demosRoot: string;
4
+ readonly factoryRoot: string;
5
+ readonly bundledAt?: string;
6
+ };
7
+ export type ContextBundleSource = {
8
+ readonly path: string;
9
+ readonly bytes: number;
10
+ };
11
+ export type ContextBundle = {
12
+ readonly markdown: string;
13
+ readonly sources: readonly ContextBundleSource[];
14
+ };
15
+ export declare function buildContextBundle(opts: BuildContextBundleOpts): ContextBundle;
16
+ export declare function sliceDecisionTree(indexMarkdown: string): string;
17
+ export declare function sliceSpecSchemaExcerpt(specSource: string): string;
18
+ export type DiscoverRootsOpts = {
19
+ readonly startDir?: string;
20
+ readonly env?: NodeJS.ProcessEnv;
21
+ };
22
+ export type DiscoveredRoots = {
23
+ readonly factoryRoot: string;
24
+ readonly docsRoot: string;
25
+ readonly demosRoot: string;
26
+ };
27
+ export declare function discoverRoots(opts?: DiscoverRootsOpts): DiscoveredRoots;
package/dist/index.js ADDED
@@ -0,0 +1,148 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ const HEADER = `# CrewHaus Context Bundle
4
+
5
+ > A single-file orientation manifest for AI agents working with CrewHaus.
6
+ > CrewHaus is a meta-harness compiler: write one spec, emit many runtime shapes.
7
+ > Live docs: https://crewhaus.ai/docs · Source of truth for schema: \`packages/spec/src/index.ts\`
8
+ `;
9
+ const QUICKSTART = `## Quickstart
10
+
11
+ \`\`\`bash
12
+ crewhaus init my-agent
13
+ crewhaus compile my-agent/crewhaus.yaml -o ./dist
14
+ crewhaus run ./dist
15
+ \`\`\`
16
+
17
+ The \`target:\` field in \`crewhaus.yaml\` selects the runtime shape. Currently supported:
18
+
19
+ | target | shape |
20
+ |-------------|----------------------------------------|
21
+ | \`cli\` | interactive coding-agent loop |
22
+ | \`workflow\` | sequence of steps |
23
+ | \`channel\` | Slack/Discord/Telegram/iMessage daemon|
24
+ | \`graph\` | stateful graph with HITL checkpoints |
25
+ | \`managed\` | Anthropic Managed Agents |
26
+ | \`pipeline\` | RAG / extract-transform pipeline |
27
+ | \`crew\` | multi-agent role-based crew |
28
+ | \`research\` | autonomous long-horizon research |
29
+ | \`batch\` | queue worker |
30
+ | \`voice\` | voice agent |
31
+ | \`browser\` | browser agent |
32
+ | \`eval\` | eval harness bundle |
33
+ `;
34
+ export function buildContextBundle(opts) {
35
+ const bundledAt = opts.bundledAt ?? new Date().toISOString();
36
+ const sources = [];
37
+ const sections = [];
38
+ sections.push(HEADER);
39
+ sections.push(`> Bundled at: ${bundledAt}`);
40
+ sections.push(QUICKSTART);
41
+ sections.push(readSection("Spec schema (source of truth)", "ts", () => readTracked(sources, resolve(opts.factoryRoot, "packages/spec/src/index.ts"))));
42
+ sections.push(readSection("Picking a recipe — diagnostic decision tree", "md", () => sliceDecisionTree(readTracked(sources, resolve(opts.demosRoot, "recipes/INDEX.md")))));
43
+ sections.push(`## Recipe index\n\n${listRecipes(opts.demosRoot, sources)}`);
44
+ sections.push(readSection("Module catalog — layer index", "md", () => extractHeadings(readTracked(sources, resolve(opts.docsRoot, "MODULE-CATALOG.md")))));
45
+ sections.push(readSection("Getting started", "md", () => readTracked(sources, resolve(opts.docsRoot, "GETTING-STARTED.md"))));
46
+ const markdown = `${sections.join("\n\n")}\n`;
47
+ return { markdown, sources };
48
+ }
49
+ function readTracked(sources, path) {
50
+ const text = readFileSync(path, "utf-8");
51
+ sources.push({ path, bytes: Buffer.byteLength(text, "utf-8") });
52
+ return text;
53
+ }
54
+ function readSection(title, fence, body) {
55
+ const content = body();
56
+ if (fence === "ts") {
57
+ return `## ${title}\n\n\`\`\`ts\n${content}\n\`\`\``;
58
+ }
59
+ return `## ${title}\n\n${content}`;
60
+ }
61
+ export function sliceDecisionTree(indexMarkdown) {
62
+ const startMarker = "## Pick a recipe";
63
+ const endMarker = "## Recipes by part";
64
+ const start = indexMarkdown.indexOf(startMarker);
65
+ if (start === -1)
66
+ return indexMarkdown;
67
+ const end = indexMarkdown.indexOf(endMarker, start);
68
+ if (end === -1)
69
+ return indexMarkdown.slice(start);
70
+ return indexMarkdown.slice(start, end).trim();
71
+ }
72
+ export function sliceSpecSchemaExcerpt(specSource) {
73
+ const marker = specSource.indexOf("discriminatedUnion");
74
+ if (marker === -1)
75
+ return specSource.slice(0, 8000);
76
+ const start = Math.max(0, marker - 800);
77
+ const end = Math.min(specSource.length, marker + 5000);
78
+ return specSource.slice(start, end);
79
+ }
80
+ function extractHeadings(catalog) {
81
+ const lines = catalog.split("\n");
82
+ const headings = lines.filter((line) => /^#{1,3}\s/.test(line));
83
+ return headings.join("\n");
84
+ }
85
+ function listRecipes(demosRoot, sources) {
86
+ const recipesDir = resolve(demosRoot, "recipes");
87
+ if (!existsSync(recipesDir))
88
+ return "_(recipes directory not found)_";
89
+ const files = readdirSync(recipesDir)
90
+ .filter((name) => name.endsWith(".md") && name !== "INDEX.md")
91
+ .sort();
92
+ const rows = [];
93
+ for (const file of files) {
94
+ const path = join(recipesDir, file);
95
+ const text = readFileSync(path, "utf-8");
96
+ sources.push({ path, bytes: Buffer.byteLength(text, "utf-8") });
97
+ const title = firstHeading(text) ?? basename(file, ".md");
98
+ rows.push(`- [${file}](demos/walkthroughs/${file}) — ${title}`);
99
+ }
100
+ return rows.join("\n");
101
+ }
102
+ function firstHeading(markdown) {
103
+ for (const rawLine of markdown.split("\n")) {
104
+ const line = rawLine.trim();
105
+ if (line.startsWith("# "))
106
+ return line.slice(2).trim();
107
+ }
108
+ return undefined;
109
+ }
110
+ export function discoverRoots(opts = {}) {
111
+ const env = opts.env ?? process.env;
112
+ const factoryRoot = resolveRoot(env["CREWHAUS_FACTORY_ROOT"], "factory", ["packages/spec/src/index.ts"], opts.startDir);
113
+ const docsRoot = resolveRoot(env["CREWHAUS_DOCS_ROOT"], "docs", ["GETTING-STARTED.md", "MODULE-CATALOG.md"], opts.startDir);
114
+ const demosRoot = resolveRoot(env["CREWHAUS_DEMOS_ROOT"], "demos", ["recipes/INDEX.md"], opts.startDir);
115
+ return { factoryRoot, docsRoot, demosRoot };
116
+ }
117
+ function resolveRoot(envValue, siblingName, markers, startDir) {
118
+ if (envValue !== undefined && envValue !== "") {
119
+ const abs = resolve(envValue);
120
+ assertMarkers(abs, markers, `${envValue} (from env)`);
121
+ return abs;
122
+ }
123
+ const found = walkUpForSibling(startDir ?? process.cwd(), siblingName, markers);
124
+ if (found !== undefined)
125
+ return found;
126
+ throw new Error(`Could not locate ${siblingName}/ with markers ${markers.join(", ")}. ` +
127
+ `Set CREWHAUS_${siblingName.toUpperCase()}_ROOT or run from inside a CrewHaus checkout.`);
128
+ }
129
+ function assertMarkers(root, markers, label) {
130
+ for (const marker of markers) {
131
+ if (!existsSync(resolve(root, marker))) {
132
+ throw new Error(`${label} is missing required file: ${marker}`);
133
+ }
134
+ }
135
+ }
136
+ function walkUpForSibling(from, siblingName, markers) {
137
+ let current = resolve(from);
138
+ let parent = resolve(current, "..");
139
+ for (;; current = parent, parent = resolve(current, "..")) {
140
+ const candidate = resolve(current, siblingName);
141
+ if (existsSync(candidate) && markers.every((m) => existsSync(resolve(candidate, m)))) {
142
+ return candidate;
143
+ }
144
+ if (parent === current)
145
+ break;
146
+ }
147
+ return undefined;
148
+ }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@crewhaus/context-bundle",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Builds a single-markdown manifest of CrewHaus docs + recipes + schema. Consumed by `crewhaus context --bundle` and the cloud demo prompt builder.",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
@@ -30,5 +33,5 @@
30
33
  "publishConfig": {
31
34
  "access": "public"
32
35
  },
33
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
36
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
34
37
  }
package/src/index.test.ts DELETED
@@ -1,225 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
5
- import { buildContextBundle, discoverRoots, sliceSpecSchemaExcerpt } from "./index";
6
-
7
- function makeFixtureTree(): {
8
- factoryRoot: string;
9
- docsRoot: string;
10
- demosRoot: string;
11
- monorepoRoot: string;
12
- } {
13
- const monorepoRoot = join(
14
- tmpdir(),
15
- `crewhaus-context-bundle-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
16
- );
17
- const factoryRoot = join(monorepoRoot, "factory");
18
- const docsRoot = join(monorepoRoot, "docs");
19
- const demosRoot = join(monorepoRoot, "demos");
20
-
21
- mkdirSync(join(factoryRoot, "packages/spec/src"), { recursive: true });
22
- mkdirSync(docsRoot, { recursive: true });
23
- mkdirSync(join(demosRoot, "recipes"), { recursive: true });
24
-
25
- writeFileSync(
26
- join(factoryRoot, "packages/spec/src/index.ts"),
27
- "export const SPEC_VERSION = 0;\nconst _x = z.discriminatedUnion('target', []);\n",
28
- );
29
- writeFileSync(
30
- join(docsRoot, "GETTING-STARTED.md"),
31
- "# Getting started\n\nWelcome to the fixture.\n",
32
- );
33
- writeFileSync(
34
- join(docsRoot, "MODULE-CATALOG.md"),
35
- "# Module catalog\n\n## Layer A — Foundations\n\nbody\n\n### A1 — IR\n\nbody\n",
36
- );
37
- writeFileSync(
38
- join(demosRoot, "recipes/INDEX.md"),
39
- [
40
- "# Recipes",
41
- "",
42
- "preamble paragraph",
43
- "",
44
- "## Pick a recipe — diagnostic decision tree",
45
- "",
46
- "1. First option",
47
- "2. Second option",
48
- "",
49
- "## Recipes by part",
50
- "",
51
- "more content here",
52
- ].join("\n"),
53
- );
54
- writeFileSync(join(demosRoot, "recipes/01-cli-coding-agent.md"), "# CLI Coding Agent\n\nbody\n");
55
- writeFileSync(
56
- join(demosRoot, "recipes/02-sequential-workflow.md"),
57
- "# Sequential Workflow\n\nbody\n",
58
- );
59
-
60
- return { factoryRoot, docsRoot, demosRoot, monorepoRoot };
61
- }
62
-
63
- describe("buildContextBundle", () => {
64
- test("produces a bundle with the major sections", () => {
65
- const { factoryRoot, docsRoot, demosRoot } = makeFixtureTree();
66
- const { markdown, sources } = buildContextBundle({
67
- factoryRoot,
68
- docsRoot,
69
- demosRoot,
70
- bundledAt: "2026-05-21T00:00:00Z",
71
- });
72
-
73
- expect(markdown).toContain("# CrewHaus Context Bundle");
74
- expect(markdown).toContain("Bundled at: 2026-05-21T00:00:00Z");
75
- expect(markdown).toContain("## Quickstart");
76
- expect(markdown).toContain("## Spec schema");
77
- expect(markdown).toContain("z.discriminatedUnion('target', [])");
78
- expect(markdown).toContain("## Picking a recipe");
79
- expect(markdown).toContain("1. First option");
80
- expect(markdown).not.toContain("more content here");
81
- expect(markdown).toContain("## Recipe index");
82
- expect(markdown).toContain("01-cli-coding-agent.md");
83
- expect(markdown).toContain("CLI Coding Agent");
84
- expect(markdown).toContain("## Module catalog — layer index");
85
- expect(markdown).toContain("## Layer A — Foundations");
86
- expect(markdown).not.toContain("body\n");
87
- expect(markdown).toContain("## Getting started");
88
- expect(markdown).toContain("Welcome to the fixture");
89
-
90
- const paths = sources.map((s) => s.path);
91
- expect(paths.some((p) => p.endsWith("packages/spec/src/index.ts"))).toBe(true);
92
- expect(paths.some((p) => p.endsWith("recipes/INDEX.md"))).toBe(true);
93
- expect(paths.some((p) => p.endsWith("GETTING-STARTED.md"))).toBe(true);
94
- expect(paths.some((p) => p.endsWith("MODULE-CATALOG.md"))).toBe(true);
95
- });
96
-
97
- test("is deterministic for a fixed bundledAt and tree", () => {
98
- const { factoryRoot, docsRoot, demosRoot } = makeFixtureTree();
99
- const first = buildContextBundle({
100
- factoryRoot,
101
- docsRoot,
102
- demosRoot,
103
- bundledAt: "2026-05-21T00:00:00Z",
104
- });
105
- const second = buildContextBundle({
106
- factoryRoot,
107
- docsRoot,
108
- demosRoot,
109
- bundledAt: "2026-05-21T00:00:00Z",
110
- });
111
- expect(first.markdown).toBe(second.markdown);
112
- });
113
- });
114
-
115
- describe("discoverRoots", () => {
116
- test("walks up to find sibling factory/docs/demos directories", () => {
117
- const { factoryRoot, docsRoot, demosRoot, monorepoRoot } = makeFixtureTree();
118
- const nested = join(monorepoRoot, "factory/packages/spec/src");
119
-
120
- const roots = discoverRoots({ startDir: nested, env: {} });
121
-
122
- expect(roots.factoryRoot).toBe(resolve(factoryRoot));
123
- expect(roots.docsRoot).toBe(resolve(docsRoot));
124
- expect(roots.demosRoot).toBe(resolve(demosRoot));
125
- });
126
-
127
- test("env overrides take precedence", () => {
128
- const { factoryRoot, docsRoot, demosRoot } = makeFixtureTree();
129
-
130
- const roots = discoverRoots({
131
- startDir: "/tmp",
132
- env: {
133
- CREWHAUS_FACTORY_ROOT: factoryRoot,
134
- CREWHAUS_DOCS_ROOT: docsRoot,
135
- CREWHAUS_DEMOS_ROOT: demosRoot,
136
- },
137
- });
138
-
139
- expect(roots.factoryRoot).toBe(resolve(factoryRoot));
140
- expect(roots.docsRoot).toBe(resolve(docsRoot));
141
- expect(roots.demosRoot).toBe(resolve(demosRoot));
142
- });
143
-
144
- test("throws when roots cannot be located", () => {
145
- expect(() => discoverRoots({ startDir: tmpdir(), env: {} })).toThrow(/Could not locate/);
146
- });
147
-
148
- test("throws when an env-provided root is missing a required marker", () => {
149
- const { factoryRoot, docsRoot } = makeFixtureTree();
150
- // Point the demos root at a directory that exists but lacks recipes/INDEX.md.
151
- const emptyDemos = join(
152
- tmpdir(),
153
- `crewhaus-context-bundle-empty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
154
- );
155
- mkdirSync(emptyDemos, { recursive: true });
156
-
157
- expect(() =>
158
- discoverRoots({
159
- startDir: "/tmp",
160
- env: {
161
- CREWHAUS_FACTORY_ROOT: factoryRoot,
162
- CREWHAUS_DOCS_ROOT: docsRoot,
163
- CREWHAUS_DEMOS_ROOT: emptyDemos,
164
- },
165
- }),
166
- ).toThrow(/missing required file: recipes\/INDEX\.md/);
167
- });
168
- });
169
-
170
- describe("recipe heading fallback", () => {
171
- test("falls back to the file basename when a recipe has no top-level heading", () => {
172
- const { factoryRoot, docsRoot, demosRoot } = makeFixtureTree();
173
- // A recipe whose only headings are deeper than h1 (no "# " line) must
174
- // fall back to its basename in the recipe index.
175
- writeFileSync(
176
- join(demosRoot, "recipes/99-no-top-heading.md"),
177
- "## Subheading only\n\nbody without an h1\n",
178
- );
179
-
180
- const { markdown } = buildContextBundle({
181
- factoryRoot,
182
- docsRoot,
183
- demosRoot,
184
- bundledAt: "2026-05-21T00:00:00Z",
185
- });
186
-
187
- expect(markdown).toContain(
188
- "- [99-no-top-heading.md](demos/walkthroughs/99-no-top-heading.md) — 99-no-top-heading",
189
- );
190
- });
191
- });
192
-
193
- describe("sliceSpecSchemaExcerpt", () => {
194
- test("centers the excerpt on the discriminatedUnion marker", () => {
195
- const before = "A".repeat(2000);
196
- const after = "B".repeat(6000);
197
- const source = `${before}discriminatedUnion${after}`;
198
-
199
- const excerpt = sliceSpecSchemaExcerpt(source);
200
-
201
- // Starts 800 chars before the marker and runs up to 5000 chars past it.
202
- const markerIndex = source.indexOf("discriminatedUnion");
203
- expect(excerpt).toBe(source.slice(markerIndex - 800, markerIndex + 5000));
204
- expect(excerpt).toContain("discriminatedUnion");
205
- expect(excerpt.length).toBe(800 + 5000);
206
- });
207
-
208
- test("clamps the window to the source bounds when the marker is near the edges", () => {
209
- const source = "discriminatedUnion then a short tail";
210
-
211
- const excerpt = sliceSpecSchemaExcerpt(source);
212
-
213
- // marker - 800 clamps to 0 and marker + 5000 clamps to source.length.
214
- expect(excerpt).toBe(source);
215
- });
216
-
217
- test("falls back to the first 8000 chars when no marker is present", () => {
218
- const source = "C".repeat(10000);
219
-
220
- const excerpt = sliceSpecSchemaExcerpt(source);
221
-
222
- expect(excerpt.length).toBe(8000);
223
- expect(excerpt).toBe(source.slice(0, 8000));
224
- });
225
- });
package/src/index.ts DELETED
@@ -1,232 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync } from "node:fs";
2
- import { basename, join, resolve } from "node:path";
3
-
4
- export type BuildContextBundleOpts = {
5
- readonly docsRoot: string;
6
- readonly demosRoot: string;
7
- readonly factoryRoot: string;
8
- readonly bundledAt?: string;
9
- };
10
-
11
- export type ContextBundleSource = {
12
- readonly path: string;
13
- readonly bytes: number;
14
- };
15
-
16
- export type ContextBundle = {
17
- readonly markdown: string;
18
- readonly sources: readonly ContextBundleSource[];
19
- };
20
-
21
- const HEADER = `# CrewHaus Context Bundle
22
-
23
- > A single-file orientation manifest for AI agents working with CrewHaus.
24
- > CrewHaus is a meta-harness compiler: write one spec, emit many runtime shapes.
25
- > Live docs: https://crewhaus.ai/docs · Source of truth for schema: \`packages/spec/src/index.ts\`
26
- `;
27
-
28
- const QUICKSTART = `## Quickstart
29
-
30
- \`\`\`bash
31
- crewhaus init my-agent
32
- crewhaus compile my-agent/crewhaus.yaml -o ./dist
33
- crewhaus run ./dist
34
- \`\`\`
35
-
36
- The \`target:\` field in \`crewhaus.yaml\` selects the runtime shape. Currently supported:
37
-
38
- | target | shape |
39
- |-------------|----------------------------------------|
40
- | \`cli\` | interactive coding-agent loop |
41
- | \`workflow\` | sequence of steps |
42
- | \`channel\` | Slack/Discord/Telegram/iMessage daemon|
43
- | \`graph\` | stateful graph with HITL checkpoints |
44
- | \`managed\` | Anthropic Managed Agents |
45
- | \`pipeline\` | RAG / extract-transform pipeline |
46
- | \`crew\` | multi-agent role-based crew |
47
- | \`research\` | autonomous long-horizon research |
48
- | \`batch\` | queue worker |
49
- | \`voice\` | voice agent |
50
- | \`browser\` | browser agent |
51
- | \`eval\` | eval harness bundle |
52
- `;
53
-
54
- export function buildContextBundle(opts: BuildContextBundleOpts): ContextBundle {
55
- const bundledAt = opts.bundledAt ?? new Date().toISOString();
56
- const sources: ContextBundleSource[] = [];
57
-
58
- const sections: string[] = [];
59
- sections.push(HEADER);
60
- sections.push(`> Bundled at: ${bundledAt}`);
61
- sections.push(QUICKSTART);
62
-
63
- sections.push(
64
- readSection("Spec schema (source of truth)", "ts", () =>
65
- readTracked(sources, resolve(opts.factoryRoot, "packages/spec/src/index.ts")),
66
- ),
67
- );
68
-
69
- sections.push(
70
- readSection("Picking a recipe — diagnostic decision tree", "md", () =>
71
- sliceDecisionTree(readTracked(sources, resolve(opts.demosRoot, "recipes/INDEX.md"))),
72
- ),
73
- );
74
-
75
- sections.push(`## Recipe index\n\n${listRecipes(opts.demosRoot, sources)}`);
76
-
77
- sections.push(
78
- readSection("Module catalog — layer index", "md", () =>
79
- extractHeadings(readTracked(sources, resolve(opts.docsRoot, "MODULE-CATALOG.md"))),
80
- ),
81
- );
82
-
83
- sections.push(
84
- readSection("Getting started", "md", () =>
85
- readTracked(sources, resolve(opts.docsRoot, "GETTING-STARTED.md")),
86
- ),
87
- );
88
-
89
- const markdown = `${sections.join("\n\n")}\n`;
90
- return { markdown, sources };
91
- }
92
-
93
- function readTracked(sources: ContextBundleSource[], path: string): string {
94
- const text = readFileSync(path, "utf-8");
95
- sources.push({ path, bytes: Buffer.byteLength(text, "utf-8") });
96
- return text;
97
- }
98
-
99
- function readSection(title: string, fence: "ts" | "md", body: () => string): string {
100
- const content = body();
101
- if (fence === "ts") {
102
- return `## ${title}\n\n\`\`\`ts\n${content}\n\`\`\``;
103
- }
104
- return `## ${title}\n\n${content}`;
105
- }
106
-
107
- export function sliceDecisionTree(indexMarkdown: string): string {
108
- const startMarker = "## Pick a recipe";
109
- const endMarker = "## Recipes by part";
110
- const start = indexMarkdown.indexOf(startMarker);
111
- if (start === -1) return indexMarkdown;
112
- const end = indexMarkdown.indexOf(endMarker, start);
113
- if (end === -1) return indexMarkdown.slice(start);
114
- return indexMarkdown.slice(start, end).trim();
115
- }
116
-
117
- export function sliceSpecSchemaExcerpt(specSource: string): string {
118
- const marker = specSource.indexOf("discriminatedUnion");
119
- if (marker === -1) return specSource.slice(0, 8000);
120
- const start = Math.max(0, marker - 800);
121
- const end = Math.min(specSource.length, marker + 5000);
122
- return specSource.slice(start, end);
123
- }
124
-
125
- function extractHeadings(catalog: string): string {
126
- const lines = catalog.split("\n");
127
- const headings = lines.filter((line) => /^#{1,3}\s/.test(line));
128
- return headings.join("\n");
129
- }
130
-
131
- function listRecipes(demosRoot: string, sources: ContextBundleSource[]): string {
132
- const recipesDir = resolve(demosRoot, "recipes");
133
- if (!existsSync(recipesDir)) return "_(recipes directory not found)_";
134
- const files = readdirSync(recipesDir)
135
- .filter((name) => name.endsWith(".md") && name !== "INDEX.md")
136
- .sort();
137
- const rows: string[] = [];
138
- for (const file of files) {
139
- const path = join(recipesDir, file);
140
- const text = readFileSync(path, "utf-8");
141
- sources.push({ path, bytes: Buffer.byteLength(text, "utf-8") });
142
- const title = firstHeading(text) ?? basename(file, ".md");
143
- rows.push(`- [${file}](demos/walkthroughs/${file}) — ${title}`);
144
- }
145
- return rows.join("\n");
146
- }
147
-
148
- function firstHeading(markdown: string): string | undefined {
149
- for (const rawLine of markdown.split("\n")) {
150
- const line = rawLine.trim();
151
- if (line.startsWith("# ")) return line.slice(2).trim();
152
- }
153
- return undefined;
154
- }
155
-
156
- export type DiscoverRootsOpts = {
157
- readonly startDir?: string;
158
- readonly env?: NodeJS.ProcessEnv;
159
- };
160
-
161
- export type DiscoveredRoots = {
162
- readonly factoryRoot: string;
163
- readonly docsRoot: string;
164
- readonly demosRoot: string;
165
- };
166
-
167
- export function discoverRoots(opts: DiscoverRootsOpts = {}): DiscoveredRoots {
168
- const env = opts.env ?? process.env;
169
- const factoryRoot = resolveRoot(
170
- env["CREWHAUS_FACTORY_ROOT"],
171
- "factory",
172
- ["packages/spec/src/index.ts"],
173
- opts.startDir,
174
- );
175
- const docsRoot = resolveRoot(
176
- env["CREWHAUS_DOCS_ROOT"],
177
- "docs",
178
- ["GETTING-STARTED.md", "MODULE-CATALOG.md"],
179
- opts.startDir,
180
- );
181
- const demosRoot = resolveRoot(
182
- env["CREWHAUS_DEMOS_ROOT"],
183
- "demos",
184
- ["recipes/INDEX.md"],
185
- opts.startDir,
186
- );
187
- return { factoryRoot, docsRoot, demosRoot };
188
- }
189
-
190
- function resolveRoot(
191
- envValue: string | undefined,
192
- siblingName: string,
193
- markers: readonly string[],
194
- startDir: string | undefined,
195
- ): string {
196
- if (envValue !== undefined && envValue !== "") {
197
- const abs = resolve(envValue);
198
- assertMarkers(abs, markers, `${envValue} (from env)`);
199
- return abs;
200
- }
201
- const found = walkUpForSibling(startDir ?? process.cwd(), siblingName, markers);
202
- if (found !== undefined) return found;
203
- throw new Error(
204
- `Could not locate ${siblingName}/ with markers ${markers.join(", ")}. ` +
205
- `Set CREWHAUS_${siblingName.toUpperCase()}_ROOT or run from inside a CrewHaus checkout.`,
206
- );
207
- }
208
-
209
- function assertMarkers(root: string, markers: readonly string[], label: string): void {
210
- for (const marker of markers) {
211
- if (!existsSync(resolve(root, marker))) {
212
- throw new Error(`${label} is missing required file: ${marker}`);
213
- }
214
- }
215
- }
216
-
217
- function walkUpForSibling(
218
- from: string,
219
- siblingName: string,
220
- markers: readonly string[],
221
- ): string | undefined {
222
- let current = resolve(from);
223
- let parent = resolve(current, "..");
224
- for (; ; current = parent, parent = resolve(current, "..")) {
225
- const candidate = resolve(current, siblingName);
226
- if (existsSync(candidate) && markers.every((m) => existsSync(resolve(candidate, m)))) {
227
- return candidate;
228
- }
229
- if (parent === current) break;
230
- }
231
- return undefined;
232
- }