@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,683 @@
1
+ /**
2
+ * Topic command — create / list / view / add-trail on web-ade topics.
3
+ *
4
+ * A **topic** is a curated collection of trails on a shared subject. The
5
+ * topic record itself is `{ title, description, trailIds, owner }` — trails
6
+ * are referenced by id and continue to enforce their own repo-access checks
7
+ * on read. See docs/topics.md in web-ade for the full design.
8
+ *
9
+ * Resolves a GitHub token locally (gh CLI → git credential helper) and calls
10
+ * the web-ade topic API with `Authorization: Bearer <token>`. The token is
11
+ * never echoed to argv, env, stdout, or stderr.
12
+ */
13
+ import { Command } from 'commander';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { handoffTopicToBridge } from '../lib/bridge-ipc.js';
16
+ import { fetchGitHubMe, fetchGitHubUserByLogin } from '../lib/github-user.js';
17
+ import { openInBrowser } from '../lib/open-url.js';
18
+ const BASE_URL = 'https://app.principal-ade.com';
19
+ // Matches the v1 uuid shape web-ade mints for both trails and topics.
20
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+ // ============================================================================
22
+ // Auth — same posture as `trail.ts`: gh CLI first, then git credential helper.
23
+ // Kept local so this command file is self-contained.
24
+ // ============================================================================
25
+ function resolveTokenViaGh() {
26
+ const result = spawnSync('gh', ['auth', 'token'], {
27
+ encoding: 'utf8',
28
+ stdio: ['ignore', 'pipe', 'ignore'],
29
+ });
30
+ if (result.status === 0 && result.stdout) {
31
+ const token = result.stdout.trim();
32
+ if (token)
33
+ return token;
34
+ }
35
+ return null;
36
+ }
37
+ function resolveTokenViaGitCredential() {
38
+ const result = spawnSync('git', ['credential', 'fill'], {
39
+ encoding: 'utf8',
40
+ input: 'protocol=https\nhost=github.com\n\n',
41
+ stdio: ['pipe', 'pipe', 'ignore'],
42
+ });
43
+ if (result.status !== 0 || !result.stdout)
44
+ return null;
45
+ for (const line of result.stdout.split('\n')) {
46
+ if (line.startsWith('password=')) {
47
+ const token = line.slice('password='.length).trim();
48
+ if (token)
49
+ return token;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ function resolveToken() {
55
+ return resolveTokenViaGh() ?? resolveTokenViaGitCredential();
56
+ }
57
+ function exitWithTokenError() {
58
+ process.stderr.write('Could not resolve a GitHub token. Run `gh auth login`, or configure a git credential helper for github.com.\n');
59
+ process.exit(2);
60
+ }
61
+ // ============================================================================
62
+ // HTTP helpers
63
+ // ============================================================================
64
+ async function describeHttpError(response) {
65
+ let serverMessage = '';
66
+ let code = '';
67
+ try {
68
+ const body = (await response.clone().json());
69
+ serverMessage = body.error ?? '';
70
+ code = body.code ?? '';
71
+ }
72
+ catch {
73
+ // body wasn't JSON — fall through to status-only message
74
+ }
75
+ const fallback = response.status === 404
76
+ ? 'Topic not found'
77
+ : response.status === 403
78
+ ? 'Not the topic owner (or no permission)'
79
+ : response.status === 401
80
+ ? 'GitHub token rejected'
81
+ : `HTTP ${response.status}`;
82
+ const human = serverMessage || fallback;
83
+ return `${human}${code ? ` [${code}]` : ''}`;
84
+ }
85
+ /**
86
+ * Comment-route error mapper — the comment endpoints surface a different
87
+ * vocabulary than the topic endpoints (COMMENT_FORBIDDEN vs NOT_OWNER,
88
+ * COMMENT_TOO_LONG with a dedicated 413, etc.). Map by `code` first so we
89
+ * stay accurate when the same status carries different meanings (e.g. 404
90
+ * for an unknown topic vs an unknown comment).
91
+ */
92
+ async function describeCommentHttpError(response) {
93
+ let serverMessage = '';
94
+ let code = '';
95
+ try {
96
+ const body = (await response.clone().json());
97
+ serverMessage = body.error ?? '';
98
+ code = body.code ?? '';
99
+ }
100
+ catch {
101
+ // body wasn't JSON — fall through to status-only message
102
+ }
103
+ const byCode = {
104
+ NOT_AUTHENTICATED: 'GitHub token required — run `gh auth login`',
105
+ NOT_FOUND: 'Topic not found',
106
+ COMMENT_NOT_FOUND: 'Comment not found',
107
+ COMMENT_FORBIDDEN: 'Not allowed to modify this comment',
108
+ COMMENT_TOO_LONG: 'Comment exceeds the 8000-character limit',
109
+ COMMENT_LIMIT_REACHED: 'This topic has reached its comment limit',
110
+ };
111
+ const fallback = byCode[code] ??
112
+ (response.status === 404
113
+ ? 'Topic or comment not found'
114
+ : response.status === 403
115
+ ? 'Not allowed to modify this comment'
116
+ : response.status === 401
117
+ ? 'GitHub token rejected'
118
+ : response.status === 413
119
+ ? 'Comment exceeds the 8000-character limit'
120
+ : `HTTP ${response.status}`);
121
+ const human = serverMessage || fallback;
122
+ return `${human}${code ? ` [${code}]` : ''}`;
123
+ }
124
+ /**
125
+ * Suggestion-route error mapper — the suggest endpoint has its own vocabulary
126
+ * (SUGGESTION_DUPLICATE, SUGGESTION_LIMIT_REACHED) plus a 404 that can mean
127
+ * either an unknown topic or an unknown trail. Map by `code` first so the
128
+ * user sees the precise reason rather than a generic "Topic not found".
129
+ */
130
+ async function describeSuggestionHttpError(response) {
131
+ let serverMessage = '';
132
+ let code = '';
133
+ try {
134
+ const body = (await response.clone().json());
135
+ serverMessage = body.error ?? '';
136
+ code = body.code ?? '';
137
+ }
138
+ catch {
139
+ // body wasn't JSON — fall through to status-only message
140
+ }
141
+ const byCode = {
142
+ NOT_AUTHENTICATED: 'GitHub token required — run `gh auth login`',
143
+ NOT_FOUND: 'Topic not found',
144
+ TRAIL_NOT_FOUND: 'Trail not found',
145
+ TRAIL_ALREADY_ADDED: 'That trail is already in this topic',
146
+ SUGGESTION_DUPLICATE: 'You already have a pending suggestion of that trail for this topic',
147
+ SUGGESTION_LIMIT_REACHED: 'This topic has reached its pending-suggestion limit',
148
+ };
149
+ const fallback = byCode[code] ??
150
+ (response.status === 404
151
+ ? 'Topic or trail not found'
152
+ : response.status === 401
153
+ ? 'GitHub token rejected'
154
+ : `HTTP ${response.status}`);
155
+ const human = serverMessage || fallback;
156
+ return `${human}${code ? ` [${code}]` : ''}`;
157
+ }
158
+ // ============================================================================
159
+ // Id / URL parsers
160
+ // ============================================================================
161
+ /** Extract a topic id from a bare uuid or a `…/topic/<id>` URL. */
162
+ function parseTopicId(input) {
163
+ const trimmed = input.trim();
164
+ if (UUID_PATTERN.test(trimmed))
165
+ return trimmed;
166
+ try {
167
+ const url = new URL(trimmed);
168
+ const match = url.pathname.match(/\/topic\/([^/]+)\/?$/);
169
+ if (match && UUID_PATTERN.test(match[1]))
170
+ return match[1];
171
+ }
172
+ catch {
173
+ // not a URL — fall through; the server will reject if not a uuid
174
+ }
175
+ // Last-ditch: a "/topic/<uuid>" fragment anywhere in the string.
176
+ const m = trimmed.match(/topic\/([0-9a-f-]+)/i);
177
+ if (m && UUID_PATTERN.test(m[1]))
178
+ return m[1];
179
+ return trimmed;
180
+ }
181
+ /** Extract a trail id from a bare uuid or a `…/trail/<id>` URL. */
182
+ function parseTrailId(input) {
183
+ const trimmed = input.trim();
184
+ if (UUID_PATTERN.test(trimmed))
185
+ return trimmed;
186
+ try {
187
+ const url = new URL(trimmed);
188
+ const match = url.pathname.match(/\/trail\/([^/]+)\/?$/);
189
+ if (match && UUID_PATTERN.test(match[1]))
190
+ return match[1];
191
+ }
192
+ catch {
193
+ // not a URL
194
+ }
195
+ const m = trimmed.match(/trail\/([0-9a-f-]+)/i);
196
+ if (m && UUID_PATTERN.test(m[1]))
197
+ return m[1];
198
+ return trimmed;
199
+ }
200
+ async function createTopic(options) {
201
+ if (!options.title || !options.title.trim()) {
202
+ process.stderr.write('--title is required\n');
203
+ process.exit(2);
204
+ }
205
+ const trailIds = (options.trail ?? []).map(parseTrailId);
206
+ for (const tid of trailIds) {
207
+ if (!UUID_PATTERN.test(tid)) {
208
+ process.stderr.write(`--trail value is not a valid trail id or URL: ${tid}\n`);
209
+ process.exit(2);
210
+ }
211
+ }
212
+ const token = resolveToken();
213
+ if (!token)
214
+ exitWithTokenError();
215
+ let response;
216
+ try {
217
+ response = await fetch(`${BASE_URL}/api/topics`, {
218
+ method: 'POST',
219
+ headers: {
220
+ Authorization: `Bearer ${token}`,
221
+ 'Content-Type': 'application/json',
222
+ Accept: 'application/json',
223
+ },
224
+ body: JSON.stringify({
225
+ title: options.title.trim(),
226
+ description: options.description ?? '',
227
+ trailIds,
228
+ }),
229
+ });
230
+ }
231
+ catch (err) {
232
+ process.stderr.write(`Network error creating topic: ${err.message}\n`);
233
+ process.exit(1);
234
+ }
235
+ if (!response.ok) {
236
+ process.stderr.write(`${await describeHttpError(response)}\n`);
237
+ process.exit(1);
238
+ }
239
+ const body = (await response.json());
240
+ if (!body.url) {
241
+ process.stderr.write('Server response missing topic URL\n');
242
+ process.exit(1);
243
+ }
244
+ const fullUrl = body.url.startsWith('http') ? body.url : `${BASE_URL}${body.url}`;
245
+ process.stdout.write(`${fullUrl}\n`);
246
+ }
247
+ async function addTrailToTopic(topicArg, trailArg) {
248
+ const topicId = parseTopicId(topicArg);
249
+ const trailId = parseTrailId(trailArg);
250
+ if (!UUID_PATTERN.test(topicId)) {
251
+ process.stderr.write(`Not a valid topic id or URL: ${topicArg}\n`);
252
+ process.exit(2);
253
+ }
254
+ if (!UUID_PATTERN.test(trailId)) {
255
+ process.stderr.write(`Not a valid trail id or URL: ${trailArg}\n`);
256
+ process.exit(2);
257
+ }
258
+ const token = resolveToken();
259
+ if (!token)
260
+ exitWithTokenError();
261
+ let response;
262
+ try {
263
+ response = await fetch(`${BASE_URL}/api/topics/by-id/${encodeURIComponent(topicId)}/trails`, {
264
+ method: 'POST',
265
+ headers: {
266
+ Authorization: `Bearer ${token}`,
267
+ 'Content-Type': 'application/json',
268
+ Accept: 'application/json',
269
+ },
270
+ body: JSON.stringify({ trailId }),
271
+ });
272
+ }
273
+ catch (err) {
274
+ process.stderr.write(`Network error adding trail: ${err.message}\n`);
275
+ process.exit(1);
276
+ }
277
+ if (!response.ok) {
278
+ process.stderr.write(`${await describeHttpError(response)}\n`);
279
+ process.exit(1);
280
+ }
281
+ process.stdout.write(`${BASE_URL}/topic/${topicId}\n`);
282
+ }
283
+ /**
284
+ * Suggest a trail for a topic. Any GitHub-authenticated user can suggest any
285
+ * resolvable trail (their own or someone else's) — the topic owner reviews
286
+ * and accepts or rejects via the web UI. Prints the suggestion uuid so the
287
+ * suggester can refer to it later (e.g. to withdraw).
288
+ */
289
+ async function suggestTrail(topicArg, trailArg, options) {
290
+ const topicId = parseTopicId(topicArg);
291
+ const trailId = parseTrailId(trailArg);
292
+ if (!UUID_PATTERN.test(topicId)) {
293
+ process.stderr.write(`Not a valid topic id or URL: ${topicArg}\n`);
294
+ process.exit(2);
295
+ }
296
+ if (!UUID_PATTERN.test(trailId)) {
297
+ process.stderr.write(`Not a valid trail id or URL: ${trailArg}\n`);
298
+ process.exit(2);
299
+ }
300
+ const reason = options.reason?.trim();
301
+ if (reason !== undefined && reason.length > 500) {
302
+ process.stderr.write('--reason exceeds the 500-character limit\n');
303
+ process.exit(2);
304
+ }
305
+ const token = resolveToken();
306
+ if (!token)
307
+ exitWithTokenError();
308
+ const payload = { trailId };
309
+ if (reason)
310
+ payload.reason = reason;
311
+ let response;
312
+ try {
313
+ response = await fetch(`${BASE_URL}/api/topics/by-id/${encodeURIComponent(topicId)}/suggestions`, {
314
+ method: 'POST',
315
+ headers: {
316
+ Authorization: `Bearer ${token}`,
317
+ 'Content-Type': 'application/json',
318
+ Accept: 'application/json',
319
+ },
320
+ body: JSON.stringify(payload),
321
+ });
322
+ }
323
+ catch (err) {
324
+ process.stderr.write(`Network error suggesting trail: ${err.message}\n`);
325
+ process.exit(1);
326
+ }
327
+ if (!response.ok) {
328
+ process.stderr.write(`${await describeSuggestionHttpError(response)}\n`);
329
+ process.exit(1);
330
+ }
331
+ const body = (await response.json());
332
+ const suggestionId = body.suggestion?.id;
333
+ if (suggestionId) {
334
+ process.stdout.write(`${suggestionId}\n`);
335
+ }
336
+ process.stdout.write(`${BASE_URL}/topic/${topicId}\n`);
337
+ }
338
+ async function updateTopic(topicArg, options) {
339
+ const topicId = parseTopicId(topicArg);
340
+ if (!UUID_PATTERN.test(topicId)) {
341
+ process.stderr.write(`Not a valid topic id or URL: ${topicArg}\n`);
342
+ process.exit(2);
343
+ }
344
+ const hasTitle = typeof options.title === 'string';
345
+ const hasDescription = typeof options.description === 'string';
346
+ if (!hasTitle && !hasDescription) {
347
+ process.stderr.write('Pass at least one of --title or --description\n');
348
+ process.exit(2);
349
+ }
350
+ const patch = {};
351
+ if (hasTitle) {
352
+ const trimmed = options.title.trim();
353
+ if (!trimmed) {
354
+ process.stderr.write('--title must not be empty (omit the flag to leave it unchanged)\n');
355
+ process.exit(2);
356
+ }
357
+ patch.title = trimmed;
358
+ }
359
+ if (hasDescription) {
360
+ patch.description = options.description;
361
+ }
362
+ const token = resolveToken();
363
+ if (!token)
364
+ exitWithTokenError();
365
+ let response;
366
+ try {
367
+ response = await fetch(`${BASE_URL}/api/topics/by-id/${encodeURIComponent(topicId)}`, {
368
+ method: 'PATCH',
369
+ headers: {
370
+ Authorization: `Bearer ${token}`,
371
+ 'Content-Type': 'application/json',
372
+ Accept: 'application/json',
373
+ },
374
+ body: JSON.stringify(patch),
375
+ });
376
+ }
377
+ catch (err) {
378
+ process.stderr.write(`Network error updating topic: ${err.message}\n`);
379
+ process.exit(1);
380
+ }
381
+ if (!response.ok) {
382
+ process.stderr.write(`${await describeHttpError(response)}\n`);
383
+ process.exit(1);
384
+ }
385
+ process.stdout.write(`${BASE_URL}/topic/${topicId}\n`);
386
+ }
387
+ async function viewTopic(input) {
388
+ const id = parseTopicId(input);
389
+ if (!UUID_PATTERN.test(id)) {
390
+ process.stderr.write(`Not a valid topic id or URL: ${input}\n`);
391
+ process.exit(2);
392
+ }
393
+ // GET /api/topics/by-id/{id} is public — token isn't required, but we
394
+ // attach one if available so the request is consistent with the other
395
+ // commands and so rate limits favor authenticated callers.
396
+ const token = resolveToken();
397
+ let response;
398
+ try {
399
+ response = await fetch(`${BASE_URL}/api/topics/by-id/${encodeURIComponent(id)}`, {
400
+ headers: {
401
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
402
+ Accept: 'application/json',
403
+ },
404
+ });
405
+ }
406
+ catch (err) {
407
+ process.stderr.write(`Network error fetching topic: ${err.message}\n`);
408
+ process.exit(1);
409
+ }
410
+ if (!response.ok) {
411
+ process.stderr.write(`${await describeHttpError(response)}\n`);
412
+ process.exit(1);
413
+ }
414
+ const body = await response.text();
415
+ process.stdout.write(body);
416
+ if (!body.endsWith('\n'))
417
+ process.stdout.write('\n');
418
+ }
419
+ async function listTopics(options) {
420
+ let githubId;
421
+ if (options.id) {
422
+ const parsed = Number(options.id);
423
+ if (!Number.isFinite(parsed) || parsed <= 0) {
424
+ process.stderr.write(`--id must be a positive number, got: ${options.id}\n`);
425
+ process.exit(2);
426
+ }
427
+ githubId = parsed;
428
+ }
429
+ else {
430
+ // `--user` resolves a login → numeric id via GitHub; the bare form resolves
431
+ // the authenticated user via /user. Both paths need a token.
432
+ const token = resolveToken();
433
+ if (!token)
434
+ exitWithTokenError();
435
+ const user = options.user
436
+ ? await fetchGitHubUserByLogin(options.user, token)
437
+ : await fetchGitHubMe(token);
438
+ githubId = user.id;
439
+ }
440
+ let response;
441
+ try {
442
+ response = await fetch(`${BASE_URL}/api/topics/by-user/${githubId}`, {
443
+ headers: { Accept: 'application/json' },
444
+ });
445
+ }
446
+ catch (err) {
447
+ process.stderr.write(`Network error listing topics: ${err.message}\n`);
448
+ process.exit(1);
449
+ }
450
+ if (!response.ok) {
451
+ process.stderr.write(`${await describeHttpError(response)}\n`);
452
+ process.exit(1);
453
+ }
454
+ const body = await response.text();
455
+ process.stdout.write(body);
456
+ if (!body.endsWith('\n'))
457
+ process.stdout.write('\n');
458
+ }
459
+ // ============================================================================
460
+ // open — launch the topic page in the default browser
461
+ // ============================================================================
462
+ async function openTopic(input) {
463
+ const id = parseTopicId(input);
464
+ const isLocalId = id.startsWith('topic-');
465
+ // Local topic-* ids bypass the UUID check (they're minted by the desktop app).
466
+ if (!isLocalId && !UUID_PATTERN.test(id)) {
467
+ process.stderr.write(`Not a valid topic id or URL: ${input}\n`);
468
+ process.exit(2);
469
+ }
470
+ // Prefer the running desktop app when available (local topic-* ids only).
471
+ if (isLocalId && (await handoffTopicToBridge(id))) {
472
+ process.stderr.write(`Topic opened in running desktop app: ${id}\n`);
473
+ process.exit(0);
474
+ }
475
+ // Fall back to web-ade in the browser.
476
+ const url = `${BASE_URL}/topic/${id}`;
477
+ // Print first so the URL is captured in scrollback / pipes even if the
478
+ // platform opener fails (headless / no DISPLAY / missing xdg-open).
479
+ process.stdout.write(`${url}\n`);
480
+ openInBrowser(url);
481
+ }
482
+ async function addComment(topicArg, options) {
483
+ const topicId = parseTopicId(topicArg);
484
+ if (!UUID_PATTERN.test(topicId)) {
485
+ process.stderr.write(`Not a valid topic id or URL: ${topicArg}\n`);
486
+ process.exit(2);
487
+ }
488
+ const body = (options.body ?? '').trim();
489
+ if (!body) {
490
+ process.stderr.write('--body is required and must not be empty\n');
491
+ process.exit(2);
492
+ }
493
+ const token = resolveToken();
494
+ if (!token)
495
+ exitWithTokenError();
496
+ let response;
497
+ try {
498
+ response = await fetch(`${BASE_URL}/api/topics/by-id/${encodeURIComponent(topicId)}/comments`, {
499
+ method: 'POST',
500
+ headers: {
501
+ Authorization: `Bearer ${token}`,
502
+ 'Content-Type': 'application/json',
503
+ Accept: 'application/json',
504
+ },
505
+ body: JSON.stringify({ body }),
506
+ });
507
+ }
508
+ catch (err) {
509
+ process.stderr.write(`Network error posting comment: ${err.message}\n`);
510
+ process.exit(1);
511
+ }
512
+ if (!response.ok) {
513
+ process.stderr.write(`${await describeCommentHttpError(response)}\n`);
514
+ process.exit(1);
515
+ }
516
+ const data = (await response.json());
517
+ if (!data.comment?.id) {
518
+ process.stderr.write('Server response missing comment id\n');
519
+ process.exit(1);
520
+ }
521
+ // Stdout is just the new id so it can be piped into `comment delete`.
522
+ process.stdout.write(`${data.comment.id}\n`);
523
+ }
524
+ async function listComments(topicArg) {
525
+ const topicId = parseTopicId(topicArg);
526
+ if (!UUID_PATTERN.test(topicId)) {
527
+ process.stderr.write(`Not a valid topic id or URL: ${topicArg}\n`);
528
+ process.exit(2);
529
+ }
530
+ // GET /comments is public — attach a token if present so rate limits favor
531
+ // authenticated callers, but don't error out if there isn't one.
532
+ const token = resolveToken();
533
+ let response;
534
+ try {
535
+ response = await fetch(`${BASE_URL}/api/topics/by-id/${encodeURIComponent(topicId)}/comments`, {
536
+ headers: {
537
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
538
+ Accept: 'application/json',
539
+ },
540
+ });
541
+ }
542
+ catch (err) {
543
+ process.stderr.write(`Network error fetching comments: ${err.message}\n`);
544
+ process.exit(1);
545
+ }
546
+ if (!response.ok) {
547
+ process.stderr.write(`${await describeCommentHttpError(response)}\n`);
548
+ process.exit(1);
549
+ }
550
+ const body = await response.text();
551
+ process.stdout.write(body);
552
+ if (!body.endsWith('\n'))
553
+ process.stdout.write('\n');
554
+ }
555
+ async function deleteComment(topicArg, commentIdArg) {
556
+ const topicId = parseTopicId(topicArg);
557
+ if (!UUID_PATTERN.test(topicId)) {
558
+ process.stderr.write(`Not a valid topic id or URL: ${topicArg}\n`);
559
+ process.exit(2);
560
+ }
561
+ const commentId = commentIdArg.trim();
562
+ if (!UUID_PATTERN.test(commentId)) {
563
+ process.stderr.write(`Not a valid comment id: ${commentIdArg}\n`);
564
+ process.exit(2);
565
+ }
566
+ const token = resolveToken();
567
+ if (!token)
568
+ exitWithTokenError();
569
+ let response;
570
+ try {
571
+ response = await fetch(`${BASE_URL}/api/topics/by-id/${encodeURIComponent(topicId)}/comments/${encodeURIComponent(commentId)}`, {
572
+ method: 'DELETE',
573
+ headers: {
574
+ Authorization: `Bearer ${token}`,
575
+ Accept: 'application/json',
576
+ },
577
+ });
578
+ }
579
+ catch (err) {
580
+ process.stderr.write(`Network error deleting comment: ${err.message}\n`);
581
+ process.exit(1);
582
+ }
583
+ if (!response.ok && response.status !== 204) {
584
+ process.stderr.write(`${await describeCommentHttpError(response)}\n`);
585
+ process.exit(1);
586
+ }
587
+ // 204 No Content is the happy path — keep stdout clean (scriptable) and
588
+ // mirror the topic URL on success so users see *what* they just mutated.
589
+ process.stdout.write(`${BASE_URL}/topic/${topicId}\n`);
590
+ }
591
+ // ============================================================================
592
+ // Command wiring
593
+ // ============================================================================
594
+ export function createTopicCommand() {
595
+ const command = new Command('topic');
596
+ command.description('Create or browse topics — curated collections of trails on web-ade');
597
+ command
598
+ .command('create')
599
+ .description('Create a new topic')
600
+ .requiredOption('--title <title>', 'Topic title (≤200 chars)')
601
+ .option('--description <text>', 'Topic description, markdown (≤8000 chars)')
602
+ .option('--trail <id-or-url>', 'Initial trail id or URL; pass repeatedly to seed multiple trails', (value, prev = []) => [...prev, value], [])
603
+ .action(async (options) => {
604
+ await createTopic(options);
605
+ });
606
+ command
607
+ .command('add-trail')
608
+ .description('Append a trail to a topic')
609
+ .argument('<topic-id-or-url>', 'Topic id or URL')
610
+ .argument('<trail-id-or-url>', 'Trail id or URL to add')
611
+ .action(async (topicArg, trailArg) => {
612
+ await addTrailToTopic(topicArg, trailArg);
613
+ });
614
+ command
615
+ .command('suggest')
616
+ .description('Suggest a trail for a topic — owner reviews and accepts/rejects. Any authenticated user can suggest any resolvable trail.')
617
+ .argument('<topic-id-or-url>', 'Topic id or URL')
618
+ .argument('<trail-id-or-url>', 'Trail id or URL to suggest')
619
+ .option('--reason <text>', 'Why this trail fits (≤500 chars)')
620
+ .action(async (topicArg, trailArg, options) => {
621
+ await suggestTrail(topicArg, trailArg, options);
622
+ });
623
+ command
624
+ .command('update')
625
+ .description("Update a topic's title and/or description (owner only)")
626
+ .argument('<id-or-url>', 'Topic id or URL')
627
+ .option('--title <title>', 'New title (≤200 chars); omit to leave unchanged')
628
+ .option('--description <text>', 'New description, markdown (≤8000 chars); omit to leave unchanged. Pass an empty string to clear.')
629
+ .action(async (input, options) => {
630
+ await updateTopic(input, options);
631
+ });
632
+ command
633
+ .command('view')
634
+ .description('Print a topic JSON to stdout')
635
+ .argument('<id-or-url>', 'Topic id or URL')
636
+ .action(async (input) => {
637
+ await viewTopic(input);
638
+ });
639
+ command
640
+ .command('open')
641
+ .description('Open the topic page in the default browser')
642
+ .argument('<id-or-url>', 'Topic id or URL')
643
+ .action(async (input) => {
644
+ await openTopic(input);
645
+ });
646
+ command
647
+ .command('list')
648
+ .description('List topics owned by a user (defaults to the authenticated user)')
649
+ .option('--user <login>', 'GitHub login to list topics for (resolves to numeric id)')
650
+ .option('--id <githubId>', 'GitHub numeric user id (skips the lookup)')
651
+ .action(async (options) => {
652
+ await listTopics(options);
653
+ });
654
+ // ---- comment subcommands -------------------------------------------------
655
+ // Flat thread attached to a topic. `add` and `delete` need a token; `list`
656
+ // is public and attaches one only when available.
657
+ const commentCommand = new Command('comment').description('Post or browse comments on a topic');
658
+ commentCommand
659
+ .command('add')
660
+ .description('Post a new comment on a topic')
661
+ .argument('<topic-id-or-url>', 'Topic id or URL')
662
+ .requiredOption('--body <text>', 'Comment body, markdown (≤8000 chars)')
663
+ .action(async (topicArg, options) => {
664
+ await addComment(topicArg, options);
665
+ });
666
+ commentCommand
667
+ .command('list')
668
+ .description('List the comments on a topic (public; token attached if present)')
669
+ .argument('<topic-id-or-url>', 'Topic id or URL')
670
+ .action(async (topicArg) => {
671
+ await listComments(topicArg);
672
+ });
673
+ commentCommand
674
+ .command('delete')
675
+ .description('Delete a comment (author or topic owner). Comment id is the uuid printed by `comment add`.')
676
+ .argument('<topic-id-or-url>', 'Topic id or URL')
677
+ .argument('<comment-id>', 'Comment uuid')
678
+ .action(async (topicArg, commentIdArg) => {
679
+ await deleteComment(topicArg, commentIdArg);
680
+ });
681
+ command.addCommand(commentCommand);
682
+ return command;
683
+ }