@principal-ai/principal-studio-cli 0.35.3

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 (184) hide show
  1. package/README.md +172 -0
  2. package/dist/commands/agent-session.d.ts +3 -0
  3. package/dist/commands/agent-session.d.ts.map +1 -0
  4. package/dist/commands/agent-session.js +67 -0
  5. package/dist/commands/agent-sessions.d.ts +11 -0
  6. package/dist/commands/agent-sessions.d.ts.map +1 -0
  7. package/dist/commands/agent-sessions.js +39 -0
  8. package/dist/commands/auxiliary/index.d.ts +3 -0
  9. package/dist/commands/auxiliary/index.d.ts.map +1 -0
  10. package/dist/commands/auxiliary/index.js +9 -0
  11. package/dist/commands/auxiliary/validate.d.ts +3 -0
  12. package/dist/commands/auxiliary/validate.d.ts.map +1 -0
  13. package/dist/commands/auxiliary/validate.js +123 -0
  14. package/dist/commands/collector/check.d.ts +6 -0
  15. package/dist/commands/collector/check.d.ts.map +1 -0
  16. package/dist/commands/collector/check.js +153 -0
  17. package/dist/commands/collector/diagnose.d.ts +6 -0
  18. package/dist/commands/collector/diagnose.d.ts.map +1 -0
  19. package/dist/commands/collector/diagnose.js +229 -0
  20. package/dist/commands/collector/index.d.ts +6 -0
  21. package/dist/commands/collector/index.d.ts.map +1 -0
  22. package/dist/commands/collector/index.js +16 -0
  23. package/dist/commands/collector/status.d.ts +6 -0
  24. package/dist/commands/collector/status.d.ts.map +1 -0
  25. package/dist/commands/collector/status.js +77 -0
  26. package/dist/commands/collector/utils.d.ts +92 -0
  27. package/dist/commands/collector/utils.d.ts.map +1 -0
  28. package/dist/commands/collector/utils.js +154 -0
  29. package/dist/commands/coverage.d.ts +9 -0
  30. package/dist/commands/coverage.d.ts.map +1 -0
  31. package/dist/commands/coverage.js +252 -0
  32. package/dist/commands/create.d.ts +6 -0
  33. package/dist/commands/create.d.ts.map +1 -0
  34. package/dist/commands/create.js +50 -0
  35. package/dist/commands/events/index.d.ts +3 -0
  36. package/dist/commands/events/index.d.ts.map +1 -0
  37. package/dist/commands/events/index.js +9 -0
  38. package/dist/commands/events/validate.d.ts +3 -0
  39. package/dist/commands/events/validate.d.ts.map +1 -0
  40. package/dist/commands/events/validate.js +124 -0
  41. package/dist/commands/formats.d.ts +6 -0
  42. package/dist/commands/formats.d.ts.map +1 -0
  43. package/dist/commands/formats.js +1118 -0
  44. package/dist/commands/hooks.d.ts +9 -0
  45. package/dist/commands/hooks.d.ts.map +1 -0
  46. package/dist/commands/hooks.js +295 -0
  47. package/dist/commands/inbox.d.ts +13 -0
  48. package/dist/commands/inbox.d.ts.map +1 -0
  49. package/dist/commands/inbox.js +179 -0
  50. package/dist/commands/init.d.ts +6 -0
  51. package/dist/commands/init.d.ts.map +1 -0
  52. package/dist/commands/init.js +220 -0
  53. package/dist/commands/lint.d.ts +11 -0
  54. package/dist/commands/lint.d.ts.map +1 -0
  55. package/dist/commands/lint.js +71 -0
  56. package/dist/commands/list.d.ts +6 -0
  57. package/dist/commands/list.d.ts.map +1 -0
  58. package/dist/commands/list.js +78 -0
  59. package/dist/commands/migrate-nodes.d.ts +9 -0
  60. package/dist/commands/migrate-nodes.d.ts.map +1 -0
  61. package/dist/commands/migrate-nodes.js +363 -0
  62. package/dist/commands/migrate-scopes-to-canvas.d.ts +3 -0
  63. package/dist/commands/migrate-scopes-to-canvas.d.ts.map +1 -0
  64. package/dist/commands/migrate-scopes-to-canvas.js +143 -0
  65. package/dist/commands/migration.d.ts +6 -0
  66. package/dist/commands/migration.d.ts.map +1 -0
  67. package/dist/commands/migration.js +247 -0
  68. package/dist/commands/open-studio.d.ts +10 -0
  69. package/dist/commands/open-studio.d.ts.map +1 -0
  70. package/dist/commands/open-studio.js +32 -0
  71. package/dist/commands/opencode/fetch.d.ts +3 -0
  72. package/dist/commands/opencode/fetch.d.ts.map +1 -0
  73. package/dist/commands/opencode/fetch.js +25 -0
  74. package/dist/commands/opencode/index.d.ts +3 -0
  75. package/dist/commands/opencode/index.d.ts.map +1 -0
  76. package/dist/commands/opencode/index.js +11 -0
  77. package/dist/commands/opencode/list-sessions.d.ts +3 -0
  78. package/dist/commands/opencode/list-sessions.d.ts.map +1 -0
  79. package/dist/commands/opencode/list-sessions.js +20 -0
  80. package/dist/commands/repo.d.ts +18 -0
  81. package/dist/commands/repo.d.ts.map +1 -0
  82. package/dist/commands/repo.js +233 -0
  83. package/dist/commands/schema.d.ts +6 -0
  84. package/dist/commands/schema.d.ts.map +1 -0
  85. package/dist/commands/schema.js +367 -0
  86. package/dist/commands/scopes/index.d.ts +3 -0
  87. package/dist/commands/scopes/index.d.ts.map +1 -0
  88. package/dist/commands/scopes/index.js +9 -0
  89. package/dist/commands/scopes/validate.d.ts +3 -0
  90. package/dist/commands/scopes/validate.d.ts.map +1 -0
  91. package/dist/commands/scopes/validate.js +187 -0
  92. package/dist/commands/setup.d.ts +6 -0
  93. package/dist/commands/setup.d.ts.map +1 -0
  94. package/dist/commands/setup.js +406 -0
  95. package/dist/commands/starred-collections.d.ts +12 -0
  96. package/dist/commands/starred-collections.d.ts.map +1 -0
  97. package/dist/commands/starred-collections.js +310 -0
  98. package/dist/commands/subsystem-model.d.ts +11 -0
  99. package/dist/commands/subsystem-model.d.ts.map +1 -0
  100. package/dist/commands/subsystem-model.js +249 -0
  101. package/dist/commands/topic.d.ts +15 -0
  102. package/dist/commands/topic.d.ts.map +1 -0
  103. package/dist/commands/topic.js +683 -0
  104. package/dist/commands/tour.d.ts +23 -0
  105. package/dist/commands/tour.d.ts.map +1 -0
  106. package/dist/commands/tour.js +783 -0
  107. package/dist/commands/trace/flow.d.ts +6 -0
  108. package/dist/commands/trace/flow.d.ts.map +1 -0
  109. package/dist/commands/trace/flow.js +158 -0
  110. package/dist/commands/trace/index.d.ts +6 -0
  111. package/dist/commands/trace/index.d.ts.map +1 -0
  112. package/dist/commands/trace/index.js +20 -0
  113. package/dist/commands/trace/inspect.d.ts +6 -0
  114. package/dist/commands/trace/inspect.d.ts.map +1 -0
  115. package/dist/commands/trace/inspect.js +187 -0
  116. package/dist/commands/trace/list.d.ts +6 -0
  117. package/dist/commands/trace/list.d.ts.map +1 -0
  118. package/dist/commands/trace/list.js +145 -0
  119. package/dist/commands/trace/registrations.d.ts +6 -0
  120. package/dist/commands/trace/registrations.d.ts.map +1 -0
  121. package/dist/commands/trace/registrations.js +81 -0
  122. package/dist/commands/trace/validate.d.ts +6 -0
  123. package/dist/commands/trace/validate.d.ts.map +1 -0
  124. package/dist/commands/trace/validate.js +276 -0
  125. package/dist/commands/trail.d.ts +36 -0
  126. package/dist/commands/trail.d.ts.map +1 -0
  127. package/dist/commands/trail.js +622 -0
  128. package/dist/commands/validate.d.ts +12 -0
  129. package/dist/commands/validate.d.ts.map +1 -0
  130. package/dist/commands/validate.js +3334 -0
  131. package/dist/commands/workflow/implementation.d.ts +3 -0
  132. package/dist/commands/workflow/implementation.d.ts.map +1 -0
  133. package/dist/commands/workflow/implementation.js +51 -0
  134. package/dist/commands/workflow/index.d.ts +3 -0
  135. package/dist/commands/workflow/index.d.ts.map +1 -0
  136. package/dist/commands/workflow/index.js +19 -0
  137. package/dist/commands/workflow/inspect.d.ts +3 -0
  138. package/dist/commands/workflow/inspect.d.ts.map +1 -0
  139. package/dist/commands/workflow/inspect.js +123 -0
  140. package/dist/commands/workflow/list.d.ts +3 -0
  141. package/dist/commands/workflow/list.d.ts.map +1 -0
  142. package/dist/commands/workflow/list.js +95 -0
  143. package/dist/commands/workflow/render.d.ts +3 -0
  144. package/dist/commands/workflow/render.d.ts.map +1 -0
  145. package/dist/commands/workflow/render.js +88 -0
  146. package/dist/commands/workflow/test.d.ts +3 -0
  147. package/dist/commands/workflow/test.d.ts.map +1 -0
  148. package/dist/commands/workflow/test.js +141 -0
  149. package/dist/commands/workflow/utils.d.ts +49 -0
  150. package/dist/commands/workflow/utils.d.ts.map +1 -0
  151. package/dist/commands/workflow/utils.js +164 -0
  152. package/dist/commands/workflow/validate.d.ts +3 -0
  153. package/dist/commands/workflow/validate.d.ts.map +1 -0
  154. package/dist/commands/workflow/validate.js +344 -0
  155. package/dist/file-utils.d.ts +15 -0
  156. package/dist/file-utils.d.ts.map +1 -0
  157. package/dist/file-utils.js +29 -0
  158. package/dist/index.cjs +285718 -0
  159. package/dist/index.cjs.map +7 -0
  160. package/dist/index.d.ts +8 -0
  161. package/dist/index.d.ts.map +1 -0
  162. package/dist/index.js +81 -0
  163. package/dist/lib/bridge-ipc.d.ts +29 -0
  164. package/dist/lib/bridge-ipc.d.ts.map +1 -0
  165. package/dist/lib/bridge-ipc.js +128 -0
  166. package/dist/lib/github-user.d.ts +17 -0
  167. package/dist/lib/github-user.d.ts.map +1 -0
  168. package/dist/lib/github-user.js +53 -0
  169. package/dist/lib/open-url.d.ts +11 -0
  170. package/dist/lib/open-url.d.ts.map +1 -0
  171. package/dist/lib/open-url.js +45 -0
  172. package/dist/lib/subsystem-model-store.d.ts +74 -0
  173. package/dist/lib/subsystem-model-store.d.ts.map +1 -0
  174. package/dist/lib/subsystem-model-store.js +292 -0
  175. package/dist/lib/tour-cache.d.ts +26 -0
  176. package/dist/lib/tour-cache.d.ts.map +1 -0
  177. package/dist/lib/tour-cache.js +46 -0
  178. package/dist/lib/trail-cache.d.ts +40 -0
  179. package/dist/lib/trail-cache.d.ts.map +1 -0
  180. package/dist/lib/trail-cache.js +97 -0
  181. package/dist/lib/viewer-ipc.d.ts +44 -0
  182. package/dist/lib/viewer-ipc.d.ts.map +1 -0
  183. package/dist/lib/viewer-ipc.js +68 -0
  184. package/package.json +68 -0
@@ -0,0 +1,783 @@
1
+ /**
2
+ * Tour command — author, validate, analyze, and open File City introduction
3
+ * tours (`*.tour.json`).
4
+ *
5
+ * Tours are the sibling of trails: a trail pins markers to `file:line`, a tour
6
+ * scopes steps to a `focusDirectory` + highlight layers. This command bundles
7
+ * the full tour lifecycle:
8
+ *
9
+ * principal-ai tour init scaffold a tour from a template
10
+ * principal-ai tour validate validate a tour against the spec
11
+ * principal-ai tour stats timing/length analysis vs. the guidelines
12
+ * principal-ai tour view open a tour in the standalone viewer
13
+ *
14
+ * `init`/`validate`/`stats` were folded in from the deprecated
15
+ * `@principal-ai/file-city-cli`; they delegate all schema work to
16
+ * `@principal-ai/file-city-builder` (`parseTour`, `IntroductionTour`). `view`
17
+ * is the lighter cousin of `trail view --file`, reusing the same viewer-launch
18
+ * + IPC handoff plumbing — and because steps address whole directories, tours
19
+ * are local-mode only (no fetch, no remote slice resolution, no token).
20
+ */
21
+ import { Command } from 'commander';
22
+ import { spawn } from 'node:child_process';
23
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { basename, join, resolve } from 'node:path';
26
+ import chalk from 'chalk';
27
+ import { parseTour } from '@principal-ai/file-city-builder';
28
+ import { handoffToRunning } from '../lib/viewer-ipc.js';
29
+ import * as tourCache from '../lib/tour-cache.js';
30
+ import { BASE_URL, describeHttpError, exitWithTokenError, gitRemoteUrl, ownerRepoFromGitRemote, resolveToken, resolveViewerLaunch, } from './trail.js';
31
+ const ONE_HOUR_MS = 60 * 60 * 1000;
32
+ /**
33
+ * Extract a tour id from a bare id or a web-ade `/tour/<id>` URL. Falls back to
34
+ * the input unchanged when it isn't a URL.
35
+ */
36
+ function parseTourId(input) {
37
+ try {
38
+ const url = new URL(input);
39
+ const match = url.pathname.match(/\/tour\/([^/]+)\/?$/);
40
+ if (match)
41
+ return match[1];
42
+ const segments = url.pathname.split('/').filter(Boolean);
43
+ if (segments.length)
44
+ return segments[segments.length - 1];
45
+ }
46
+ catch {
47
+ // not a URL — treat as a bare id
48
+ }
49
+ return input;
50
+ }
51
+ /**
52
+ * Resolve the `{ owner, repo }` a tour publishes under. Unlike trails, a tour
53
+ * document carries no `repos[]`, so we look at explicit flags first, then fall
54
+ * back to the cwd's `origin` git remote. Exits with guidance when neither
55
+ * yields an owner/repo.
56
+ */
57
+ function resolveTourOwnerRepo(options) {
58
+ if (options.purl) {
59
+ const match = options.purl.match(/^pkg:(?:github|gitlab|bitbucket)\/([^/]+)\/([^/@]+)/);
60
+ if (!match) {
61
+ process.stderr.write(`--purl is not a valid github/gitlab/bitbucket Purl: ${options.purl}\n`);
62
+ process.exit(2);
63
+ }
64
+ return { owner: options.owner ?? match[1], repo: options.repo ?? match[2] };
65
+ }
66
+ if (options.owner && options.repo) {
67
+ return { owner: options.owner, repo: options.repo };
68
+ }
69
+ const remote = gitRemoteUrl(process.cwd());
70
+ const fromRemote = remote ? ownerRepoFromGitRemote(remote) : null;
71
+ const owner = options.owner ?? fromRemote?.owner;
72
+ const repo = options.repo ?? fromRemote?.name;
73
+ if (!owner || !repo) {
74
+ process.stderr.write('Could not determine owner/repo. Pass --owner and --repo (or --purl), or run inside a clone with an `origin` remote.\n');
75
+ process.exit(2);
76
+ }
77
+ return { owner, repo };
78
+ }
79
+ /** Build a tour's primary repo ref from resolved owner/repo. */
80
+ function tourRepoRefFor(owner, repo) {
81
+ return {
82
+ id: `pkg:github/${owner.toLowerCase()}/${repo}`,
83
+ name: repo,
84
+ remote: { host: 'github', owner, name: repo },
85
+ };
86
+ }
87
+ /** Fetch + cache a tour by id. Exits the process on any error. */
88
+ async function fetchAndCacheTour(id) {
89
+ const token = resolveToken();
90
+ if (!token)
91
+ exitWithTokenError();
92
+ const url = `${BASE_URL}/api/tours/by-id/${encodeURIComponent(id)}`;
93
+ let response;
94
+ try {
95
+ response = await fetch(url, {
96
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
97
+ });
98
+ }
99
+ catch (err) {
100
+ process.stderr.write(`Network error fetching tour: ${err.message}\n`);
101
+ process.exit(1);
102
+ }
103
+ if (!response.ok) {
104
+ process.stderr.write(`${await describeHttpError(response)}\n`);
105
+ process.exit(1);
106
+ }
107
+ const body = await response.text();
108
+ let cachePath;
109
+ try {
110
+ cachePath = tourCache.write(id, body).path;
111
+ }
112
+ catch {
113
+ // Cache write failed — continue without a cache hit for this call.
114
+ cachePath = '';
115
+ }
116
+ return { body, cachePath };
117
+ }
118
+ async function fetchTour(input) {
119
+ const id = parseTourId(input);
120
+ if (!id) {
121
+ process.stderr.write('Invalid tour id\n');
122
+ process.exit(2);
123
+ }
124
+ const { body } = await fetchAndCacheTour(id);
125
+ process.stdout.write(body);
126
+ if (!body.endsWith('\n'))
127
+ process.stdout.write('\n');
128
+ }
129
+ /**
130
+ * Publish a tour document to web-ade. The server mints the share id and pins
131
+ * audio coordinates; we print the resulting share URL to stdout.
132
+ */
133
+ async function publishTour(file, options) {
134
+ const path = file && file !== '-' ? resolve(process.cwd(), file) : undefined;
135
+ if (!path) {
136
+ process.stderr.write('Pass the path to a *.tour.json file to publish.\n');
137
+ process.exit(2);
138
+ }
139
+ if (!existsSync(path)) {
140
+ process.stderr.write(`Tour file not found: ${path}\n`);
141
+ process.exit(2);
142
+ }
143
+ let tour;
144
+ try {
145
+ tour = JSON.parse(readFileSync(path, 'utf8'));
146
+ }
147
+ catch (err) {
148
+ process.stderr.write(`Tour file is not valid JSON: ${err.message}\n`);
149
+ process.exit(1);
150
+ }
151
+ // Resolve the publish target and stamp it as the tour's primary repo. Tours
152
+ // require `repos[]`, but an author's file usually omits it — so we derive it
153
+ // from flags / the git remote and inject it (preserving any extra repos the
154
+ // author declared for a multi-repo tour) before validating. The server
155
+ // re-stamps repos[0] authoritatively; this keeps local validation honest and
156
+ // the published artifact consistent.
157
+ const { owner, repo } = resolveTourOwnerRepo(options);
158
+ const existingRepos = tour && typeof tour === 'object' && Array.isArray(tour.repos)
159
+ ? tour.repos.slice(1)
160
+ : [];
161
+ const stampedTour = {
162
+ ...tour,
163
+ repos: [tourRepoRefFor(owner, repo), ...existingRepos],
164
+ };
165
+ // Validate locally before the round-trip so authors get the spec errors here
166
+ // rather than as a 400 from the server.
167
+ const parsed = parseTour(JSON.stringify(stampedTour));
168
+ if (!parsed.success || !parsed.tour) {
169
+ const detail = parsed.errors?.map((e) => e.message).join(', ') || 'unknown error';
170
+ process.stderr.write(`Invalid tour: ${detail}\n`);
171
+ process.exit(2);
172
+ }
173
+ const token = resolveToken();
174
+ if (!token)
175
+ exitWithTokenError();
176
+ let response;
177
+ try {
178
+ response = await fetch(`${BASE_URL}/api/tours/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
179
+ method: 'POST',
180
+ headers: {
181
+ Authorization: `Bearer ${token}`,
182
+ 'Content-Type': 'application/json',
183
+ Accept: 'application/json',
184
+ },
185
+ body: JSON.stringify({ owner, repo, tour: parsed.tour }),
186
+ });
187
+ }
188
+ catch (err) {
189
+ process.stderr.write(`Network error publishing tour: ${err.message}\n`);
190
+ process.exit(1);
191
+ }
192
+ if (!response.ok) {
193
+ process.stderr.write(`${await describeHttpError(response)}\n`);
194
+ process.exit(1);
195
+ }
196
+ const resBody = (await response.json());
197
+ if (!resBody.url) {
198
+ process.stderr.write('Server response missing share URL\n');
199
+ process.exit(1);
200
+ }
201
+ const shareUrl = resBody.url.startsWith('http')
202
+ ? resBody.url
203
+ : `${BASE_URL}${resBody.url}`;
204
+ process.stdout.write(`${shareUrl}\n`);
205
+ }
206
+ /**
207
+ * Cheap pre-flight so an obviously-broken tour fails here with a clear message
208
+ * instead of opening the viewer to an idle, empty city. The viewer host runs
209
+ * the full `parseTourOrThrow` validation on load — this only catches the
210
+ * coarse "not JSON / not a tour" cases without pulling in the builder package.
211
+ */
212
+ function assertLooksLikeTour(absolute) {
213
+ let body;
214
+ try {
215
+ body = readFileSync(absolute, 'utf8');
216
+ }
217
+ catch (err) {
218
+ process.stderr.write(`Failed to read ${absolute}: ${err.message}\n`);
219
+ process.exit(1);
220
+ }
221
+ let payload;
222
+ try {
223
+ payload = JSON.parse(body);
224
+ }
225
+ catch (err) {
226
+ process.stderr.write(`Tour file is not valid JSON: ${err.message}\n`);
227
+ process.exit(1);
228
+ }
229
+ const steps = typeof payload === 'object' && payload !== null
230
+ ? payload.steps
231
+ : undefined;
232
+ if (!Array.isArray(steps) || steps.length === 0) {
233
+ process.stderr.write(`${absolute} does not look like a tour (expected a non-empty \`steps\` array).\n`);
234
+ process.exit(1);
235
+ }
236
+ }
237
+ /**
238
+ * Materialize a viewer-ready *.tour.json from a store id. The by-id response is
239
+ * a `{ owner, repo, entry, payload }` wrapper where `payload` is the tour; we
240
+ * pull it out and write it to a temp `*.tour.json` the viewer can load. The
241
+ * fetched wrapper is still cached verbatim by `fetchAndCacheTour` for `fetch`.
242
+ */
243
+ async function resolveTourFileFromId(input, refresh) {
244
+ const id = parseTourId(input);
245
+ if (!id) {
246
+ process.stderr.write('Invalid tour id\n');
247
+ process.exit(2);
248
+ }
249
+ const cached = refresh ? null : tourCache.read(id, ONE_HOUR_MS);
250
+ const body = cached ? cached.body : (await fetchAndCacheTour(id)).body;
251
+ let wrapper;
252
+ try {
253
+ wrapper = JSON.parse(body);
254
+ }
255
+ catch (err) {
256
+ process.stderr.write(`Tour body is not valid JSON: ${err.message}\n`);
257
+ process.exit(1);
258
+ }
259
+ // The by-id response wraps the tour as `{ owner, repo, entry, payload }`,
260
+ // where `payload` IS the tour. A bare local file is already the tour. Accept
261
+ // either shape.
262
+ const tour = typeof wrapper === 'object' && wrapper !== null
263
+ ? (wrapper.payload ?? wrapper)
264
+ : wrapper;
265
+ const viewerPath = join(tmpdir(), `principal-tour-${id.replace(/[^A-Za-z0-9._-]/g, '_')}.tour.json`);
266
+ writeFileSync(viewerPath, JSON.stringify(tour), 'utf8');
267
+ assertLooksLikeTour(viewerPath);
268
+ return viewerPath;
269
+ }
270
+ async function viewTour(input, options) {
271
+ if (!input && !options.file) {
272
+ process.stderr.write('Pass a *.tour.json path/id-or-url, or --file <path>.\n');
273
+ process.exit(2);
274
+ }
275
+ if (input && options.file) {
276
+ process.stderr.write('Pass either a path/id-or-url OR --file, not both.\n');
277
+ process.exit(2);
278
+ }
279
+ let absolute;
280
+ if (options.file) {
281
+ absolute = resolve(process.cwd(), options.file);
282
+ if (!existsSync(absolute)) {
283
+ process.stderr.write(`Tour file not found: ${absolute}\n`);
284
+ process.exit(2);
285
+ }
286
+ assertLooksLikeTour(absolute);
287
+ }
288
+ else {
289
+ // A positional arg that resolves to an existing file is a local tour; any
290
+ // other value is treated as a store id/url and fetched.
291
+ const maybePath = resolve(process.cwd(), input);
292
+ if (existsSync(maybePath)) {
293
+ absolute = maybePath;
294
+ assertLooksLikeTour(absolute);
295
+ }
296
+ else {
297
+ absolute = await resolveTourFileFromId(input, options.refresh ?? false);
298
+ }
299
+ }
300
+ // Default the repo root to cwd. The user sees "no directory matched" framing
301
+ // in the viewer if cwd isn't actually the repo the tour was authored against,
302
+ // which is the right signal to re-run with --repo-root.
303
+ const repoRoot = options.repoRoot
304
+ ? resolve(process.cwd(), options.repoRoot)
305
+ : process.cwd();
306
+ const env = {
307
+ ...process.env,
308
+ TRAIL_FILE: absolute,
309
+ TRAIL_MODE: 'local',
310
+ TRAIL_REPO_ROOT: repoRoot,
311
+ };
312
+ // The viewer host auto-detects tour vs trail from the filename/shape, so the
313
+ // `LOAD_TRAIL` message carries the tour file just like a trail would.
314
+ const ipcMessage = {
315
+ kind: 'LOAD_TRAIL',
316
+ trailFile: absolute,
317
+ mode: 'local',
318
+ repoRoot,
319
+ };
320
+ if (await handoffToRunning(ipcMessage)) {
321
+ process.stderr.write(`Tour handed off to running viewer: ${absolute}\n`);
322
+ process.exit(0);
323
+ }
324
+ const launch = resolveViewerLaunch(options.viewerDir);
325
+ process.stderr.write(`Launching tour viewer for ${absolute}\n`);
326
+ const child = launch.kind === 'installed'
327
+ ? spawn(launch.bin, [], { env, stdio: 'inherit' })
328
+ : spawn('bun', ['start'], { cwd: launch.dir, env, stdio: 'inherit' });
329
+ child.on('error', (err) => {
330
+ process.stderr.write(`Failed to launch viewer: ${err.message}\n`);
331
+ process.exit(1);
332
+ });
333
+ child.on('exit', (code) => {
334
+ process.exit(code ?? 0);
335
+ });
336
+ }
337
+ function getMinimalTemplate() {
338
+ return {
339
+ id: 'quick-start',
340
+ title: 'Quick Start Guide',
341
+ description: 'Get started with the codebase in 5 minutes',
342
+ version: '1.0.0',
343
+ audience: 'New Users & AI Assistants',
344
+ steps: [
345
+ {
346
+ id: 'step-1-welcome',
347
+ title: 'Welcome!',
348
+ description: 'This is a simple introduction to the project structure.',
349
+ estimatedTime: 30,
350
+ // Last step must focus on repository root ("") for a complete overview.
351
+ focusDirectory: '',
352
+ colorMode: 'fileTypes',
353
+ },
354
+ ],
355
+ };
356
+ }
357
+ function getOnboardingTemplate() {
358
+ return {
359
+ id: 'codebase-onboarding',
360
+ title: 'Codebase Onboarding Tour',
361
+ description: 'Learn the structure and key components of this codebase',
362
+ version: '1.0.0',
363
+ audience: 'New Developers',
364
+ prerequisites: ['Basic understanding of the technology stack'],
365
+ steps: [
366
+ {
367
+ id: 'step-1-overview',
368
+ title: 'Project Overview',
369
+ description: 'Welcome to the codebase! This tour will guide you through the main areas.',
370
+ estimatedTime: 60,
371
+ colorMode: 'fileTypes',
372
+ },
373
+ {
374
+ id: 'step-2-core',
375
+ title: 'Core Components',
376
+ description: 'These are the main building blocks of the application.',
377
+ estimatedTime: 120,
378
+ focusDirectory: 'src',
379
+ highlightLayers: [
380
+ {
381
+ id: 'core-layer',
382
+ name: 'Core Files',
383
+ color: '#3b82f6',
384
+ items: [
385
+ { path: 'src/index.ts', type: 'file' },
386
+ { path: 'src/components', type: 'directory' },
387
+ ],
388
+ opacity: 0.7,
389
+ borderWidth: 2,
390
+ },
391
+ ],
392
+ colorMode: 'fileTypes',
393
+ },
394
+ {
395
+ id: 'step-3-configuration',
396
+ title: 'Configuration',
397
+ description: 'Configuration files that control the application behavior.',
398
+ estimatedTime: 60,
399
+ highlightFiles: ['package.json', 'tsconfig.json'],
400
+ // Last step focuses on repository root ("") for a complete overview.
401
+ focusDirectory: '',
402
+ colorMode: 'fileTypes',
403
+ },
404
+ ],
405
+ metadata: {
406
+ author: 'Your Name',
407
+ createdAt: new Date().toISOString(),
408
+ tags: ['onboarding', 'tutorial'],
409
+ },
410
+ };
411
+ }
412
+ function getArchitectureTemplate() {
413
+ return {
414
+ id: 'architecture-overview',
415
+ title: 'Architecture Overview',
416
+ description: 'Understand the architectural decisions and patterns in this codebase',
417
+ version: '1.0.0',
418
+ audience: 'Engineers & Architects',
419
+ steps: [
420
+ {
421
+ id: 'step-1-layered-architecture',
422
+ title: 'Layered Architecture',
423
+ description: 'The application follows a layered architecture pattern.',
424
+ estimatedTime: 120,
425
+ // Steps with highlightLayers must set focusDirectory; "" frames the
426
+ // whole tree so all three layers are visible at once.
427
+ focusDirectory: '',
428
+ highlightLayers: [
429
+ {
430
+ id: 'presentation-layer',
431
+ name: 'Presentation Layer',
432
+ color: '#10b981',
433
+ items: [{ path: 'src/components', type: 'directory' }],
434
+ opacity: 0.6,
435
+ },
436
+ {
437
+ id: 'business-layer',
438
+ name: 'Business Logic',
439
+ color: '#f59e0b',
440
+ items: [{ path: 'src/services', type: 'directory' }],
441
+ opacity: 0.6,
442
+ },
443
+ {
444
+ id: 'data-layer',
445
+ name: 'Data Layer',
446
+ color: '#ef4444',
447
+ items: [{ path: 'src/models', type: 'directory' }],
448
+ opacity: 0.6,
449
+ },
450
+ ],
451
+ colorMode: 'fileTypes',
452
+ },
453
+ {
454
+ id: 'step-2-patterns',
455
+ title: 'Design Patterns',
456
+ description: 'Key design patterns used throughout the codebase.',
457
+ estimatedTime: 180,
458
+ // Last step focuses on repository root ("") for a complete overview.
459
+ focusDirectory: '',
460
+ resources: [
461
+ {
462
+ title: 'Design Patterns Documentation',
463
+ url: 'https://refactoring.guru/design-patterns',
464
+ type: 'documentation',
465
+ },
466
+ ],
467
+ colorMode: 'fileTypes',
468
+ },
469
+ ],
470
+ metadata: {
471
+ author: 'Architecture Team',
472
+ createdAt: new Date().toISOString(),
473
+ tags: ['architecture', 'patterns', 'advanced'],
474
+ },
475
+ };
476
+ }
477
+ function getTemplate(templateType) {
478
+ switch (templateType) {
479
+ case 'onboarding':
480
+ return getOnboardingTemplate();
481
+ case 'architecture':
482
+ return getArchitectureTemplate();
483
+ case 'minimal':
484
+ default:
485
+ return getMinimalTemplate();
486
+ }
487
+ }
488
+ function initTour(options) {
489
+ const template = options.template || 'minimal';
490
+ const base = getTemplate(template);
491
+ // Tours require `repos[]`. Anchor to the cwd git remote when there is one;
492
+ // otherwise scaffold an obvious placeholder for the author to edit.
493
+ const remote = gitRemoteUrl(process.cwd());
494
+ const fromRemote = remote ? ownerRepoFromGitRemote(remote) : null;
495
+ const repos = fromRemote
496
+ ? [tourRepoRefFor(fromRemote.owner, fromRemote.name)]
497
+ : [
498
+ {
499
+ id: 'pkg:github/OWNER/REPO',
500
+ name: 'REPO',
501
+ remote: { host: 'github', owner: 'OWNER', name: 'REPO' },
502
+ },
503
+ ];
504
+ const tour = { ...base, repos };
505
+ const outputFile = options.output || `${tour.id}.tour.json`;
506
+ const absolutePath = resolve(process.cwd(), outputFile);
507
+ try {
508
+ writeFileSync(absolutePath, JSON.stringify(tour, null, 2), 'utf-8');
509
+ }
510
+ catch (error) {
511
+ console.error(chalk.red(`\n✗ ${error instanceof Error ? error.message : 'Unknown error'}\n`));
512
+ process.exit(1);
513
+ }
514
+ console.log(chalk.green(`✓ Tour file created: ${outputFile}`));
515
+ console.log(chalk.dim(` Template: ${template}`));
516
+ console.log(chalk.dim(` Tour ID: ${tour.id}`));
517
+ console.log(chalk.dim(` Steps: ${tour.steps.length}`));
518
+ console.log(chalk.dim(` Repo: ${repos[0].id}`));
519
+ if (!fromRemote) {
520
+ console.log(chalk.yellow(' ⚠ No git remote found — edit `repos[0]` to point at the real repository.'));
521
+ }
522
+ console.log('\nNext steps:');
523
+ console.log(' 1. Edit the tour file to customize it for your codebase');
524
+ console.log(` 2. Validate the tour: principal-ai tour validate ${outputFile}`);
525
+ console.log(` 3. Publish it: principal-ai tour publish ${outputFile}\n`);
526
+ }
527
+ function validateTourFile(file, options) {
528
+ const absolutePath = resolve(process.cwd(), file);
529
+ const fileName = basename(absolutePath);
530
+ let content;
531
+ try {
532
+ content = readFileSync(absolutePath, 'utf-8');
533
+ }
534
+ catch (error) {
535
+ const message = error instanceof Error ? error.message : 'Unknown error';
536
+ if (options.json) {
537
+ console.log(JSON.stringify({ valid: false, error: message }, null, 2));
538
+ }
539
+ else {
540
+ console.error(chalk.red(`\n✗ Error: ${message}\n`));
541
+ }
542
+ process.exit(1);
543
+ }
544
+ const result = parseTour(content);
545
+ if (options.json) {
546
+ console.log(JSON.stringify({
547
+ file: fileName,
548
+ valid: result.success,
549
+ errors: result.errors?.map((e) => ({
550
+ message: e.message,
551
+ field: e.field,
552
+ value: e.value,
553
+ })),
554
+ tour: result.tour
555
+ ? {
556
+ id: result.tour.id,
557
+ title: result.tour.title,
558
+ version: result.tour.version,
559
+ stepCount: result.tour.steps.length,
560
+ }
561
+ : undefined,
562
+ }, null, 2));
563
+ if (!result.success)
564
+ process.exit(1);
565
+ return;
566
+ }
567
+ if (result.success && result.tour) {
568
+ console.log(chalk.green(`✓ Tour "${fileName}" is valid!`));
569
+ console.log(chalk.dim(` Tour ID: ${result.tour.id}`));
570
+ console.log(chalk.dim(` Title: ${result.tour.title}`));
571
+ console.log(chalk.dim(` Version: ${result.tour.version}`));
572
+ console.log(chalk.dim(` Steps: ${result.tour.steps.length}`));
573
+ if (result.tour.audience) {
574
+ console.log(chalk.dim(` Audience: ${result.tour.audience}`));
575
+ }
576
+ return;
577
+ }
578
+ console.error(chalk.red(`\n✗ Tour "${fileName}" is invalid:\n`));
579
+ for (const e of result.errors ?? []) {
580
+ const where = e.field ? chalk.dim(` (${e.field})`) : '';
581
+ console.error(chalk.red(` • ${e.message}`) + where);
582
+ }
583
+ console.error('');
584
+ process.exit(1);
585
+ }
586
+ /**
587
+ * Estimate time for a step from character count. Guideline: 200-250 chars take
588
+ * ~20-30s — roughly 10 chars/second covering reading + viewing + interaction.
589
+ */
590
+ function estimateStepTime(charCount) {
591
+ return Math.round(charCount / 10 / 5) * 5;
592
+ }
593
+ function analyzeTour(tour) {
594
+ const steps = tour.steps.map((step) => {
595
+ const characterCount = step.description.length;
596
+ const hasTime = step.estimatedTime !== undefined;
597
+ const estimatedTime = hasTime ? step.estimatedTime : estimateStepTime(characterCount);
598
+ return { id: step.id, title: step.title, estimatedTime, characterCount, hasTime };
599
+ });
600
+ const totalCharacters = steps.reduce((sum, s) => sum + s.characterCount, 0);
601
+ const totalTime = steps.reduce((sum, s) => sum + s.estimatedTime, 0);
602
+ const hasAllTimes = steps.every((s) => s.hasTime);
603
+ const stepCount = steps.length;
604
+ const recommendations = [];
605
+ for (const step of steps.filter((s) => s.characterCount > 300)) {
606
+ const excess = step.characterCount - 300;
607
+ recommendations.push(`Reduce "${step.title}" by ${excess} character${excess > 1 ? 's' : ''}`);
608
+ }
609
+ if (stepCount > 8) {
610
+ recommendations.push(`Reduce step count from ${stepCount} to 6-8 steps (consolidate related concepts)`);
611
+ }
612
+ else if (stepCount > 6) {
613
+ recommendations.push(`Consider reducing from ${stepCount} to 4-6 steps for ideal 2-minute duration`);
614
+ }
615
+ if (totalTime > 180) {
616
+ recommendations.push(`Reduce total duration by ~${Math.round((totalTime - 180) / 60)}m to meet 3-minute maximum`);
617
+ }
618
+ else if (totalTime > 120) {
619
+ recommendations.push(`Consider reducing duration by ~${Math.round((totalTime - 120) / 60)}m to meet 2-minute ideal`);
620
+ }
621
+ if (totalCharacters > 2000) {
622
+ recommendations.push(`Reduce total text by ${totalCharacters - 2000} characters to meet 2,000 char limit`);
623
+ }
624
+ else if (totalCharacters > 1500) {
625
+ recommendations.push(`Consider reducing text by ${totalCharacters - 1500} characters to meet ideal range`);
626
+ }
627
+ if (!hasAllTimes) {
628
+ recommendations.push('Add `estimatedTime` field to all steps for accurate tracking');
629
+ }
630
+ return { stepCount, totalTime, hasAllTimes, totalCharacters, steps, recommendations };
631
+ }
632
+ function formatTime(seconds) {
633
+ const mins = Math.floor(seconds / 60);
634
+ const secs = seconds % 60;
635
+ return mins === 0 ? `${secs}s` : `${mins}m ${secs}s`;
636
+ }
637
+ function statusIndicator(value, idealMax, acceptableMax) {
638
+ if (value <= idealMax)
639
+ return chalk.green('✓');
640
+ if (value <= acceptableMax)
641
+ return chalk.yellow('⚠');
642
+ return chalk.red('✗');
643
+ }
644
+ function printStats(tour, stats) {
645
+ console.log('');
646
+ console.log(chalk.bold.cyan(`Tour Statistics: "${tour.title}"`));
647
+ console.log(chalk.cyan('━'.repeat(60)));
648
+ console.log('');
649
+ console.log(chalk.bold('Steps: ') + `${stats.stepCount} step${stats.stepCount !== 1 ? 's' : ''}`);
650
+ const timeStr = stats.hasAllTimes ? formatTime(stats.totalTime) : `${formatTime(stats.totalTime)} (estimated)`;
651
+ console.log(chalk.bold('Total duration: ') + timeStr);
652
+ console.log(chalk.bold('Total description: ') + `${stats.totalCharacters} characters`);
653
+ console.log('');
654
+ console.log(chalk.bold('Target Guidelines:'));
655
+ console.log(` ${statusIndicator(stats.stepCount, 6, 8)} Steps: ${stats.stepCount} (4-6 ideal, 6-8 acceptable, >8 over)`);
656
+ console.log(` ${statusIndicator(stats.totalTime, 120, 180)} Duration: ${formatTime(stats.totalTime)} (2min ideal, 3min max)`);
657
+ console.log(` ${statusIndicator(stats.totalCharacters, 1500, 2000)} Characters: ${stats.totalCharacters} (800-1,500 ideal, 2,000 max)`);
658
+ console.log('');
659
+ console.log(chalk.bold('Per-Step Breakdown:'));
660
+ stats.steps.forEach((step, index) => {
661
+ const timeStr = step.hasTime ? formatTime(step.estimatedTime) : `${formatTime(step.estimatedTime)} est`;
662
+ const charStatus = step.characterCount > 300
663
+ ? chalk.red('✗ Exceeds 300 char limit')
664
+ : step.characterCount >= 280
665
+ ? chalk.yellow('⚠ Approaching limit')
666
+ : chalk.green('✓');
667
+ console.log(` ${(index + 1).toString().padStart(2)}. ${step.title.padEnd(30)} (${timeStr.padEnd(7)}, ${step.characterCount} chars) ${charStatus}`);
668
+ });
669
+ console.log('');
670
+ if (!stats.hasAllTimes) {
671
+ console.log(chalk.yellow('⚠ Missing `estimatedTime` field on some steps (values estimated)'));
672
+ console.log('');
673
+ }
674
+ if (stats.recommendations.length > 0) {
675
+ console.log(chalk.bold('Recommendations:'));
676
+ for (const rec of stats.recommendations)
677
+ console.log(` • ${rec}`);
678
+ console.log('');
679
+ }
680
+ else {
681
+ console.log(chalk.green.bold('✓ Tour meets all guidelines!'));
682
+ console.log('');
683
+ }
684
+ }
685
+ function statsTourFile(file, options) {
686
+ const absolutePath = resolve(process.cwd(), file);
687
+ const fileName = basename(absolutePath);
688
+ let content;
689
+ try {
690
+ content = readFileSync(absolutePath, 'utf-8');
691
+ }
692
+ catch (error) {
693
+ const message = error instanceof Error ? error.message : 'Unknown error';
694
+ if (options.json)
695
+ console.log(JSON.stringify({ error: message }, null, 2));
696
+ else
697
+ console.error(chalk.red(`\n✗ Error: ${message}\n`));
698
+ process.exit(1);
699
+ }
700
+ const result = parseTour(content);
701
+ if (!result.success || !result.tour) {
702
+ console.error(chalk.red('\n✗ Cannot analyze tour — validation failed. Run `tour validate` first.\n'));
703
+ process.exit(1);
704
+ }
705
+ const stats = analyzeTour(result.tour);
706
+ if (options.json) {
707
+ console.log(JSON.stringify({
708
+ file: fileName,
709
+ tour: { id: result.tour.id, title: result.tour.title, version: result.tour.version },
710
+ stats: {
711
+ stepCount: stats.stepCount,
712
+ totalTime: stats.totalTime,
713
+ totalCharacters: stats.totalCharacters,
714
+ hasAllTimes: stats.hasAllTimes,
715
+ steps: stats.steps,
716
+ recommendations: stats.recommendations,
717
+ },
718
+ meetsGuidelines: stats.recommendations.length === 0,
719
+ }, null, 2));
720
+ return;
721
+ }
722
+ printStats(result.tour, stats);
723
+ }
724
+ // ---------------------------------------------------------------------------
725
+ // command wiring
726
+ // ---------------------------------------------------------------------------
727
+ export function createTourCommand() {
728
+ const command = new Command('tour');
729
+ command.description('Author, validate, analyze, and open File City introduction tours');
730
+ command
731
+ .command('init')
732
+ .description('Scaffold a new *.tour.json from a template')
733
+ .option('-t, --template <type>', 'Template type (minimal, onboarding, architecture)', 'minimal')
734
+ .option('-o, --output <file>', 'Output filename')
735
+ .action((options) => {
736
+ initTour(options);
737
+ });
738
+ command
739
+ .command('validate')
740
+ .description('Validate a *.tour.json against the spec')
741
+ .argument('<file>', 'Path to a *.tour.json file')
742
+ .option('-j, --json', 'Output results as JSON')
743
+ .action((file, options) => {
744
+ validateTourFile(file, options);
745
+ });
746
+ command
747
+ .command('stats')
748
+ .description('Show tour timing/length analysis vs. the authoring guidelines')
749
+ .argument('<file>', 'Path to a *.tour.json file')
750
+ .option('-j, --json', 'Output results as JSON')
751
+ .action((file, options) => {
752
+ statsTourFile(file, options);
753
+ });
754
+ command
755
+ .command('view')
756
+ .description('Open a tour in the standalone viewer (local mode): a *.tour.json path, or a store id/url')
757
+ .argument('[file-or-id]', 'Path to a *.tour.json file, a tour id, or a /tour/<id> URL (omit when using --file)')
758
+ .option('--file <path>', 'Open a local *.tour.json file directly (skips fetch + cache)')
759
+ .option('--repo-root <path>', 'Working tree the tour is authored against (default: cwd)')
760
+ .option('--refresh', 'Bypass the tour JSON cache and re-fetch (id/url only)')
761
+ .option('--viewer-dir <path>', 'Path to the @principal-ai/subsystems-studio package (overrides PRINCIPAL_STUDIO_DIR)')
762
+ .action(async (input, options) => {
763
+ await viewTour(input, options);
764
+ });
765
+ command
766
+ .command('fetch')
767
+ .description('Fetch a tour by id/url from web-ade and print its JSON (also caches locally)')
768
+ .argument('<id-or-url>', 'Tour id, or full https://app.principal-ade.com/tour/<id> URL')
769
+ .action(async (input) => {
770
+ await fetchTour(input);
771
+ });
772
+ command
773
+ .command('publish')
774
+ .description('Publish a *.tour.json to web-ade')
775
+ .argument('<file>', 'Path to a *.tour.json file')
776
+ .option('--owner <owner>', 'GitHub owner to publish under (overrides the git remote)')
777
+ .option('--repo <repo>', 'GitHub repo to publish under (overrides the git remote)')
778
+ .option('--purl <purl>', 'Anchor by Purl (e.g. pkg:github/owner/repo); overrides --owner/--repo derivation')
779
+ .action(async (file, options) => {
780
+ await publishTour(file, options);
781
+ });
782
+ return command;
783
+ }