@ontrails/trails 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +12 -0
  5. package/__tests__/examples.test.ts +6 -0
  6. package/bin/trails.ts +3 -0
  7. package/dist/bin/trails.d.ts +3 -0
  8. package/dist/bin/trails.d.ts.map +1 -0
  9. package/dist/bin/trails.js +4 -0
  10. package/dist/bin/trails.js.map +1 -0
  11. package/dist/src/app.d.ts +2 -0
  12. package/dist/src/app.d.ts.map +1 -0
  13. package/dist/src/app.js +11 -0
  14. package/dist/src/app.js.map +1 -0
  15. package/dist/src/clack.d.ts +9 -0
  16. package/dist/src/clack.d.ts.map +1 -0
  17. package/dist/src/clack.js +62 -0
  18. package/dist/src/clack.js.map +1 -0
  19. package/dist/src/cli.d.ts +2 -0
  20. package/dist/src/cli.d.ts.map +1 -0
  21. package/dist/src/cli.js +13 -0
  22. package/dist/src/cli.js.map +1 -0
  23. package/dist/src/trails/add-surface.d.ts +13 -0
  24. package/dist/src/trails/add-surface.d.ts.map +1 -0
  25. package/dist/src/trails/add-surface.js +88 -0
  26. package/dist/src/trails/add-surface.js.map +1 -0
  27. package/dist/src/trails/add-trail.d.ts +11 -0
  28. package/dist/src/trails/add-trail.d.ts.map +1 -0
  29. package/dist/src/trails/add-trail.js +85 -0
  30. package/dist/src/trails/add-trail.js.map +1 -0
  31. package/dist/src/trails/add-verify.d.ts +10 -0
  32. package/dist/src/trails/add-verify.d.ts.map +1 -0
  33. package/dist/src/trails/add-verify.js +67 -0
  34. package/dist/src/trails/add-verify.js.map +1 -0
  35. package/dist/src/trails/create-scaffold.d.ts +15 -0
  36. package/dist/src/trails/create-scaffold.d.ts.map +1 -0
  37. package/dist/src/trails/create-scaffold.js +288 -0
  38. package/dist/src/trails/create-scaffold.js.map +1 -0
  39. package/dist/src/trails/create.d.ts +22 -0
  40. package/dist/src/trails/create.d.ts.map +1 -0
  41. package/dist/src/trails/create.js +121 -0
  42. package/dist/src/trails/create.js.map +1 -0
  43. package/dist/src/trails/guide.d.ts +11 -0
  44. package/dist/src/trails/guide.d.ts.map +1 -0
  45. package/dist/src/trails/guide.js +80 -0
  46. package/dist/src/trails/guide.js.map +1 -0
  47. package/dist/src/trails/load-app.d.ts +4 -0
  48. package/dist/src/trails/load-app.d.ts.map +1 -0
  49. package/dist/src/trails/load-app.js +24 -0
  50. package/dist/src/trails/load-app.js.map +1 -0
  51. package/dist/src/trails/project.d.ts +8 -0
  52. package/dist/src/trails/project.d.ts.map +1 -0
  53. package/dist/src/trails/project.js +43 -0
  54. package/dist/src/trails/project.js.map +1 -0
  55. package/dist/src/trails/survey.d.ts +33 -0
  56. package/dist/src/trails/survey.d.ts.map +1 -0
  57. package/dist/src/trails/survey.js +225 -0
  58. package/dist/src/trails/survey.js.map +1 -0
  59. package/dist/src/trails/warden.d.ts +19 -0
  60. package/dist/src/trails/warden.d.ts.map +1 -0
  61. package/dist/src/trails/warden.js +88 -0
  62. package/dist/src/trails/warden.js.map +1 -0
  63. package/dist/tsconfig.tsbuildinfo +1 -0
  64. package/package.json +28 -0
  65. package/src/__tests__/create.test.ts +349 -0
  66. package/src/__tests__/guide.test.ts +91 -0
  67. package/src/__tests__/load-app.test.ts +15 -0
  68. package/src/__tests__/survey.test.ts +161 -0
  69. package/src/__tests__/warden.test.ts +74 -0
  70. package/src/app.ts +22 -0
  71. package/src/clack.ts +89 -0
  72. package/src/cli.ts +14 -0
  73. package/src/trails/add-surface.ts +119 -0
  74. package/src/trails/add-trail.ts +103 -0
  75. package/src/trails/add-verify.ts +87 -0
  76. package/src/trails/create-scaffold.ts +352 -0
  77. package/src/trails/create.ts +203 -0
  78. package/src/trails/guide.ts +104 -0
  79. package/src/trails/load-app.ts +37 -0
  80. package/src/trails/project.ts +51 -0
  81. package/src/trails/survey.ts +307 -0
  82. package/src/trails/warden.ts +104 -0
  83. package/tsconfig.json +9 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * `create.scaffold` trail -- Creates base project structure.
3
+ *
4
+ * Generates package.json, tsconfig, app.ts, starter trails, and .trails/ directory.
5
+ */
6
+
7
+ import { mkdirSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
9
+
10
+ import { Result, trail } from '@ontrails/core';
11
+ import { z } from 'zod';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ type Starter = 'empty' | 'entity' | 'hello';
18
+
19
+ interface ScaffoldResult {
20
+ readonly created: string[];
21
+ readonly dir: string;
22
+ readonly name: string;
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Content generators
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const generatePackageJson = (name: string): string => {
30
+ const deps: Record<string, string> = {
31
+ '@ontrails/core': 'workspace:*',
32
+ zod: '^4.0.0',
33
+ };
34
+
35
+ const pkg: Record<string, unknown> = {
36
+ dependencies: Object.fromEntries(
37
+ Object.entries(deps).toSorted(([a], [b]) => a.localeCompare(b))
38
+ ),
39
+ name,
40
+ scripts: {
41
+ build: 'tsc -b',
42
+ lint: 'oxlint ./src',
43
+ test: 'bun test',
44
+ typecheck: 'tsc --noEmit',
45
+ },
46
+ type: 'module',
47
+ version: '0.1.0',
48
+ };
49
+
50
+ return JSON.stringify(pkg, null, 2);
51
+ };
52
+
53
+ const TSCONFIG_CONTENT = JSON.stringify(
54
+ {
55
+ compilerOptions: {
56
+ declaration: true,
57
+ module: 'ESNext',
58
+ moduleResolution: 'bundler',
59
+ noUncheckedIndexedAccess: true,
60
+ outDir: 'dist',
61
+ rootDir: 'src',
62
+ skipLibCheck: true,
63
+ strict: true,
64
+ target: 'ESNext',
65
+ verbatimModuleSyntax: true,
66
+ },
67
+ include: ['src'],
68
+ },
69
+ null,
70
+ 2
71
+ );
72
+
73
+ const GITIGNORE_CONTENT = `node_modules/
74
+ dist/
75
+ *.tsbuildinfo
76
+ .trails/_surface.json
77
+ `;
78
+
79
+ const OXLINTRC_CONTENT = JSON.stringify(
80
+ {
81
+ extends: ['ultracite'],
82
+ },
83
+ null,
84
+ 2
85
+ );
86
+
87
+ const OXFMTRC_CONTENT = `{
88
+ // ultracite defaults
89
+ }
90
+ `;
91
+
92
+ const generateHelloTrail = (): string =>
93
+ `import { Result, trail } from '@ontrails/core';
94
+ import { z } from 'zod';
95
+
96
+ export const hello = trail('hello', {
97
+ description: 'Say hello',
98
+ examples: [
99
+ {
100
+ expected: { message: 'Hello, world!' },
101
+ input: {},
102
+ name: 'Default greeting',
103
+ },
104
+ {
105
+ expected: { message: 'Hello, Trails!' },
106
+ input: { name: 'Trails' },
107
+ name: 'Named greeting',
108
+ },
109
+ ],
110
+ implementation: (input) => {
111
+ const name = input.name ?? 'world';
112
+ return Result.ok({ message: \`Hello, \${name}!\` });
113
+ },
114
+ input: z.object({
115
+ name: z.string().optional(),
116
+ }),
117
+ output: z.object({
118
+ message: z.string(),
119
+ }),
120
+ readOnly: true,
121
+ });
122
+ `;
123
+
124
+ const generateEntityTrails = (): string =>
125
+ `import { Result, trail } from '@ontrails/core';
126
+ import { z } from 'zod';
127
+
128
+ const entitySchema = z.object({
129
+ id: z.string(),
130
+ name: z.string(),
131
+ });
132
+
133
+ export const show = trail('entity.show', {
134
+ description: 'Show an entity by ID',
135
+ examples: [
136
+ {
137
+ expected: { id: '1', name: 'Example' },
138
+ input: { id: '1' },
139
+ name: 'Show entity',
140
+ },
141
+ ],
142
+ implementation: (input) => {
143
+ return Result.ok({ id: input.id, name: 'Example' });
144
+ },
145
+ input: z.object({ id: z.string() }),
146
+ output: entitySchema,
147
+ readOnly: true,
148
+ });
149
+
150
+ export const add = trail('entity.add', {
151
+ description: 'Add a new entity',
152
+ examples: [
153
+ {
154
+ expected: { id: '1', name: 'New' },
155
+ input: { name: 'New' },
156
+ name: 'Add entity',
157
+ },
158
+ ],
159
+ implementation: (input) => {
160
+ return Result.ok({ id: '1', name: input.name });
161
+ },
162
+ input: z.object({ name: z.string() }),
163
+ output: entitySchema,
164
+ });
165
+ `;
166
+
167
+ const generateSearchTrail = (): string =>
168
+ `import { Result, trail } from '@ontrails/core';
169
+ import { z } from 'zod';
170
+
171
+ export const search = trail('search', {
172
+ description: 'Search entities by query',
173
+ examples: [
174
+ {
175
+ expected: { results: [] },
176
+ input: { query: 'test' },
177
+ name: 'Search entities',
178
+ },
179
+ ],
180
+ implementation: () => {
181
+ return Result.ok({ results: [] });
182
+ },
183
+ input: z.object({ query: z.string() }),
184
+ output: z.object({
185
+ results: z.array(z.object({ id: z.string(), name: z.string() })),
186
+ }),
187
+ readOnly: true,
188
+ });
189
+ `;
190
+
191
+ const generateOnboardHike = (): string =>
192
+ `import { Result, hike } from '@ontrails/core';
193
+ import { z } from 'zod';
194
+
195
+ export const onboard = hike('entity.onboard', {
196
+ description: 'Onboard a new entity end-to-end',
197
+ follows: ['entity.add'],
198
+ implementation: async (input, ctx) => {
199
+ const result = await ctx.follow('entity.add', { name: input.name });
200
+ if (result.isErr()) {
201
+ return result;
202
+ }
203
+ return Result.ok({ onboarded: true });
204
+ },
205
+ input: z.object({ name: z.string() }),
206
+ output: z.object({ onboarded: z.boolean() }),
207
+ });
208
+ `;
209
+
210
+ const generateEntityEvents = (): string =>
211
+ `import { event } from '@ontrails/core';
212
+ import { z } from 'zod';
213
+
214
+ export const entityUpdated = event('entity.updated', {
215
+ description: 'Fired when an entity is updated',
216
+ payload: z.object({
217
+ entityId: z.string(),
218
+ updatedAt: z.string(),
219
+ }),
220
+ });
221
+ `;
222
+
223
+ const generateStore = (): string =>
224
+ `/** In-memory store for entities. */
225
+
226
+ interface Entity {
227
+ readonly id: string;
228
+ readonly name: string;
229
+ }
230
+
231
+ const store = new Map<string, Entity>();
232
+
233
+ export const getEntity = (id: string): Entity | undefined => store.get(id);
234
+ export const addEntity = (entity: Entity): void => {
235
+ store.set(entity.id, entity);
236
+ };
237
+ export const deleteEntity = (id: string): boolean => store.delete(id);
238
+ export const listEntities = (): Entity[] => Array.from(store.values());
239
+ `;
240
+
241
+ const starterImports: Record<
242
+ Starter,
243
+ { imports: string[]; modules: string[] }
244
+ > = {
245
+ empty: { imports: [], modules: [] },
246
+ entity: {
247
+ imports: [
248
+ "import * as entity from './trails/entity.js';",
249
+ "import * as search from './trails/search.js';",
250
+ "import * as onboard from './trails/onboard.js';",
251
+ "import * as entityEvents from './events/entity-events.js';",
252
+ ],
253
+ modules: ['entity', 'search', 'onboard', 'entityEvents'],
254
+ },
255
+ hello: {
256
+ imports: ["import * as hello from './trails/hello.js';"],
257
+ modules: ['hello'],
258
+ },
259
+ };
260
+
261
+ const generateAppTs = (name: string, starter: Starter): string => {
262
+ const { imports, modules } = starterImports[starter];
263
+ const topoArgs =
264
+ modules.length > 0 ? `'${name}', ${modules.join(', ')}` : `'${name}'`;
265
+
266
+ return [
267
+ "import { topo } from '@ontrails/core';",
268
+ ...imports,
269
+ '',
270
+ `export const app = topo(${topoArgs});`,
271
+ '',
272
+ ].join('\n');
273
+ };
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // File collection and writing
277
+ // ---------------------------------------------------------------------------
278
+
279
+ const starterFileGenerators: Record<Starter, () => [string, string][]> = {
280
+ empty: () => [['src/trails/.gitkeep', '']],
281
+ entity: () => [
282
+ ['src/trails/entity.ts', generateEntityTrails()],
283
+ ['src/trails/search.ts', generateSearchTrail()],
284
+ ['src/trails/onboard.ts', generateOnboardHike()],
285
+ ['src/events/entity-events.ts', generateEntityEvents()],
286
+ ['src/store.ts', generateStore()],
287
+ ],
288
+ hello: () => [['src/trails/hello.ts', generateHelloTrail()]],
289
+ };
290
+
291
+ const collectScaffoldFiles = (
292
+ name: string,
293
+ starter: Starter
294
+ ): Map<string, string> =>
295
+ new Map([
296
+ ['package.json', generatePackageJson(name)],
297
+ ['tsconfig.json', TSCONFIG_CONTENT],
298
+ ['.gitignore', GITIGNORE_CONTENT],
299
+ ['.oxlintrc.json', OXLINTRC_CONTENT],
300
+ ['.oxfmtrc.jsonc', OXFMTRC_CONTENT],
301
+ ['src/app.ts', generateAppTs(name, starter)],
302
+ ...starterFileGenerators[starter](),
303
+ ]);
304
+
305
+ const writeScaffoldFiles = async (
306
+ projectDir: string,
307
+ fileMap: Map<string, string>
308
+ ): Promise<string[]> => {
309
+ const files: string[] = [];
310
+ for (const [relativePath, content] of fileMap) {
311
+ const fullPath = join(projectDir, relativePath);
312
+ mkdirSync(dirname(fullPath), { recursive: true });
313
+ await Bun.write(fullPath, content);
314
+ files.push(relativePath);
315
+ }
316
+ return files;
317
+ };
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Trail definition
321
+ // ---------------------------------------------------------------------------
322
+
323
+ export const createScaffold = trail('create.scaffold', {
324
+ description: 'Scaffold a new Trails project',
325
+ implementation: async (input) => {
326
+ const projectDir = resolve(input.dir ?? '.', input.name);
327
+ const starter = (input.starter ?? 'hello') as Starter;
328
+ const fileMap = collectScaffoldFiles(input.name, starter);
329
+ const files = await writeScaffoldFiles(projectDir, fileMap);
330
+ mkdirSync(join(projectDir, '.trails'), { recursive: true });
331
+
332
+ return Result.ok({
333
+ created: files,
334
+ dir: projectDir,
335
+ name: input.name,
336
+ } satisfies ScaffoldResult);
337
+ },
338
+ input: z.object({
339
+ dir: z.string().optional().describe('Parent directory'),
340
+ name: z.string().describe('Project name'),
341
+ starter: z
342
+ .enum(['hello', 'entity', 'empty'])
343
+ .default('hello')
344
+ .describe('Starter trail'),
345
+ }),
346
+ markers: { internal: true },
347
+ output: z.object({
348
+ created: z.array(z.string()),
349
+ dir: z.string(),
350
+ name: z.string(),
351
+ }),
352
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `create` route -- Create a new Trails project.
3
+ *
4
+ * Composes create.scaffold, add.surface, and add.verify sub-trails
5
+ * via ctx.follow().
6
+ */
7
+
8
+ import type { FollowFn } from '@ontrails/core';
9
+ import { Result, hike } from '@ontrails/core';
10
+ import { z } from 'zod';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ type Starter = 'empty' | 'entity' | 'hello';
17
+ type Surface = 'cli' | 'mcp';
18
+
19
+ interface BlazeInput {
20
+ readonly dir?: string | undefined;
21
+ readonly name: string;
22
+ readonly starter: Starter;
23
+ readonly surfaces: readonly Surface[];
24
+ readonly verify: boolean;
25
+ }
26
+
27
+ interface ScaffoldRequest {
28
+ readonly dir?: string | undefined;
29
+ readonly name: string;
30
+ readonly starter: Starter;
31
+ }
32
+
33
+ interface VerifyRequest {
34
+ readonly dir?: string | undefined;
35
+ readonly name: string;
36
+ readonly verify: boolean;
37
+ }
38
+
39
+ interface ScaffoldedProject {
40
+ readonly created: string[];
41
+ readonly dir: string;
42
+ readonly name: string;
43
+ }
44
+
45
+ const buildScaffoldInput = (input: ScaffoldRequest) => ({
46
+ ...(input.dir === undefined ? {} : { dir: input.dir }),
47
+ name: input.name,
48
+ starter: input.starter,
49
+ });
50
+
51
+ const buildSurfaceInput = (dir: string, surface: string) => ({
52
+ dir,
53
+ surface,
54
+ });
55
+
56
+ const buildVerifyInput = (input: VerifyRequest) => ({
57
+ ...(input.dir === undefined ? {} : { dir: input.dir }),
58
+ name: input.name,
59
+ });
60
+
61
+ const scaffoldProject = (
62
+ follow: FollowFn,
63
+ input: ScaffoldRequest
64
+ ): Promise<Result<ScaffoldedProject, Error>> =>
65
+ follow('create.scaffold', buildScaffoldInput(input));
66
+
67
+ const addSurfaceFiles = async (
68
+ follow: FollowFn,
69
+ dir: string,
70
+ surfaces: readonly string[]
71
+ ): Promise<Result<string[], Error>> => {
72
+ const created: string[] = [];
73
+
74
+ for (const surface of surfaces) {
75
+ const result = await follow<{ created: string; dependency: string }>(
76
+ 'add.surface',
77
+ buildSurfaceInput(dir, surface)
78
+ );
79
+ if (result.isErr()) {
80
+ return Result.err(result.error);
81
+ }
82
+ created.push(result.value.created);
83
+ }
84
+
85
+ return Result.ok(created);
86
+ };
87
+
88
+ const collectVerifyFiles = async (
89
+ follow: FollowFn,
90
+ input: VerifyRequest
91
+ ): Promise<Result<string[], Error>> => {
92
+ if (!input.verify) {
93
+ return Result.ok([]);
94
+ }
95
+
96
+ const result = await follow<{ created: string[] }>(
97
+ 'add.verify',
98
+ buildVerifyInput(input)
99
+ );
100
+ return result.isErr()
101
+ ? Result.err(result.error)
102
+ : Result.ok(result.value.created);
103
+ };
104
+
105
+ const collectCreatedFiles = (
106
+ scaffolded: readonly string[],
107
+ surfaces: readonly string[],
108
+ verify: readonly string[]
109
+ ): string[] => [...scaffolded, ...surfaces, ...verify];
110
+
111
+ const runCreate = async (
112
+ follow: FollowFn,
113
+ input: BlazeInput
114
+ ): Promise<Result<{ created: string[]; dir: string; name: string }, Error>> => {
115
+ const scaffolded = await scaffoldProject(follow, input);
116
+ if (scaffolded.isErr()) {
117
+ return Result.err(scaffolded.error);
118
+ }
119
+
120
+ const surfaceResults = await addSurfaceFiles(
121
+ follow,
122
+ scaffolded.value.dir,
123
+ input.surfaces
124
+ );
125
+ if (surfaceResults.isErr()) {
126
+ return Result.err(surfaceResults.error);
127
+ }
128
+
129
+ const verifyFiles = await collectVerifyFiles(follow, input);
130
+ if (verifyFiles.isErr()) {
131
+ return Result.err(verifyFiles.error);
132
+ }
133
+
134
+ return Result.ok({
135
+ created: collectCreatedFiles(
136
+ scaffolded.value.created,
137
+ surfaceResults.value,
138
+ verifyFiles.value
139
+ ),
140
+ dir: scaffolded.value.dir,
141
+ name: input.name,
142
+ });
143
+ };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Route definition
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export const createRoute = hike('create', {
150
+ description: 'Create a new Trails project',
151
+ fields: {
152
+ starter: {
153
+ options: [
154
+ {
155
+ hint: 'One trail, one example',
156
+ label: 'Hello world',
157
+ value: 'hello',
158
+ },
159
+ {
160
+ hint: '4 trails, hike, event, store',
161
+ label: 'Entity CRUD',
162
+ value: 'entity',
163
+ },
164
+ { hint: 'Just the structure', label: 'Empty', value: 'empty' },
165
+ ],
166
+ },
167
+ surfaces: {
168
+ options: [
169
+ { hint: 'Commander-based command line', label: 'CLI', value: 'cli' },
170
+ {
171
+ hint: 'Model Context Protocol for agents',
172
+ label: 'MCP',
173
+ value: 'mcp',
174
+ },
175
+ ],
176
+ },
177
+ },
178
+ follows: ['create.scaffold', 'add.surface', 'add.verify'],
179
+ implementation: async (input: BlazeInput, ctx) => {
180
+ if (!ctx.follow) {
181
+ return Result.err(new Error('create route requires ctx.follow'));
182
+ }
183
+ return await runCreate(ctx.follow, input);
184
+ },
185
+ input: z.object({
186
+ dir: z.string().optional().describe('Parent directory'),
187
+ name: z.string().describe('Project name'),
188
+ starter: z
189
+ .enum(['hello', 'entity', 'empty'])
190
+ .default('hello')
191
+ .describe('Starter trail'),
192
+ surfaces: z
193
+ .array(z.enum(['cli', 'mcp']))
194
+ .default(['cli'])
195
+ .describe('Surfaces'),
196
+ verify: z.boolean().default(true).describe('Include testing + warden'),
197
+ }),
198
+ output: z.object({
199
+ created: z.array(z.string()),
200
+ dir: z.string(),
201
+ name: z.string(),
202
+ }),
203
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `guide` trail -- Runtime guidance.
3
+ *
4
+ * Lists trails with descriptions and examples. Detailed guidance is planned for post-v1.
5
+ */
6
+
7
+ import type { Topo, Trail } from '@ontrails/core';
8
+ import { Result, trail } from '@ontrails/core';
9
+ import { z } from 'zod';
10
+
11
+ import { loadApp } from './load-app.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ interface GuideEntry {
18
+ readonly description: string;
19
+ readonly exampleCount: number;
20
+ readonly id: string;
21
+ readonly kind: string;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const toGuideEntries = (app: Topo): GuideEntry[] => {
29
+ const entries: GuideEntry[] = [];
30
+
31
+ for (const item of app.list()) {
32
+ const raw = item as unknown as Record<string, unknown>;
33
+ entries.push({
34
+ description:
35
+ typeof raw['description'] === 'string'
36
+ ? raw['description']
37
+ : '(no description)',
38
+ exampleCount: Array.isArray(raw['examples'])
39
+ ? (raw['examples'] as unknown[]).length
40
+ : 0,
41
+ id: item.id,
42
+ kind: item.kind,
43
+ });
44
+ }
45
+
46
+ return entries;
47
+ };
48
+
49
+ const toGuideDetail = (item: Trail<unknown, unknown>): object => ({
50
+ description: item.description ?? null,
51
+ detours: item.detours ?? null,
52
+ examples: item.examples ?? [],
53
+ id: item.id,
54
+ kind: item.kind,
55
+ });
56
+
57
+ export const guideTrail = trail('guide', {
58
+ description: 'Runtime guidance for trails',
59
+ examples: [
60
+ {
61
+ description: 'Lists all trails with descriptions and example counts',
62
+ input: {},
63
+ name: 'List trail guidance',
64
+ },
65
+ ],
66
+ implementation: async (input, ctx) => {
67
+ const app = await loadApp(input.module, ctx.cwd ?? '.');
68
+
69
+ if (input.trailId) {
70
+ const item = app.get(input.trailId);
71
+ if (!item) {
72
+ return Result.err(new Error(`Trail not found: ${input.trailId}`));
73
+ }
74
+ return Result.ok(toGuideDetail(item as Trail<unknown, unknown>));
75
+ }
76
+
77
+ return Result.ok(toGuideEntries(app));
78
+ },
79
+ input: z.object({
80
+ module: z
81
+ .string()
82
+ .default('./src/app.ts')
83
+ .describe('Path to the app module'),
84
+ trailId: z.string().optional().describe('Trail ID for detailed guidance'),
85
+ }),
86
+ output: z.union([
87
+ z.array(
88
+ z.object({
89
+ description: z.string(),
90
+ exampleCount: z.number(),
91
+ id: z.string(),
92
+ kind: z.string(),
93
+ })
94
+ ),
95
+ z.object({
96
+ description: z.string().nullable(),
97
+ detours: z.unknown().nullable(),
98
+ examples: z.array(z.unknown()),
99
+ id: z.string(),
100
+ kind: z.string(),
101
+ }),
102
+ ]),
103
+ readOnly: true,
104
+ });
@@ -0,0 +1,37 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ import type { Topo } from '@ontrails/core';
5
+
6
+ const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
7
+
8
+ /** Resolve a module path from cwd so CLI defaults behave like shell paths. */
9
+ const resolveModuleSpecifier = (modulePath: string, cwd: string): string => {
10
+ if (URL_SCHEME.test(modulePath)) {
11
+ return modulePath;
12
+ }
13
+
14
+ const absolutePath = isAbsolute(modulePath)
15
+ ? modulePath
16
+ : resolve(cwd, modulePath);
17
+ return pathToFileURL(absolutePath).href;
18
+ };
19
+
20
+ /** Load a Topo export from a module path relative to cwd. */
21
+ export const loadApp = async (
22
+ modulePath: string,
23
+ cwd: string
24
+ ): Promise<Topo> => {
25
+ const mod = (await import(resolveModuleSpecifier(modulePath, cwd))) as Record<
26
+ string,
27
+ unknown
28
+ >;
29
+ const app = (mod['default'] ?? mod['app']) as Topo | undefined;
30
+ if (!app?.trails) {
31
+ throw new Error(
32
+ `Could not find a Topo export in "${modulePath}". ` +
33
+ "Expected a default or named 'app' export created with topo()."
34
+ );
35
+ }
36
+ return app;
37
+ };