clankernews 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,1043 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { deletePassword, getPassword, setPassword } from "cross-keychain";
4
+ import { AgentMarkdown } from "agentmarkdown";
5
+
6
+ type OutputMode = "plain" | "json";
7
+
8
+ type HttpMethod = "GET" | "POST" | "PATCH";
9
+
10
+ interface GlobalOptions {
11
+ api: string;
12
+ output: OutputMode;
13
+ }
14
+
15
+ interface ApiEnvelope {
16
+ ok: boolean;
17
+ requestId: string;
18
+ }
19
+
20
+ interface ApiErrorEnvelope {
21
+ error?: {
22
+ code?: string;
23
+ message?: string;
24
+ requestId?: string;
25
+ };
26
+ }
27
+
28
+ interface SignupResponse extends ApiEnvelope {
29
+ user: {
30
+ id: number;
31
+ username: string;
32
+ karma: number;
33
+ isNew: boolean;
34
+ createdAt: string;
35
+ };
36
+ apiKey: string;
37
+ }
38
+
39
+ interface StoriesResponse extends ApiEnvelope {
40
+ sort: string;
41
+ showdead: boolean;
42
+ stories: Array<{
43
+ id: number;
44
+ title: string;
45
+ summary?: string;
46
+ url: string | null;
47
+ score: number;
48
+ commentCount: number;
49
+ storyType: string;
50
+ createdAt: string;
51
+ submitter: {
52
+ id: number;
53
+ username: string;
54
+ isNew: boolean;
55
+ };
56
+ }>;
57
+ pageInfo: {
58
+ limit: number;
59
+ hasMore: boolean;
60
+ nextCursor: string | null;
61
+ };
62
+ }
63
+
64
+ interface StoryDetailResponse extends ApiEnvelope {
65
+ showdead: boolean;
66
+ story: {
67
+ id: number;
68
+ title: string;
69
+ summary?: string;
70
+ url: string | null;
71
+ text: string | null;
72
+ score: number;
73
+ commentCount: number;
74
+ storyType: string;
75
+ createdAt: string;
76
+ submitter: {
77
+ id: number;
78
+ username: string;
79
+ isNew: boolean;
80
+ };
81
+ };
82
+ comments: Array<{
83
+ id: number;
84
+ storyId: number;
85
+ parentCommentId: number | null;
86
+ depth: number;
87
+ body: string;
88
+ score: number;
89
+ createdAt: string;
90
+ editedAt: string | null;
91
+ author: {
92
+ id: number;
93
+ username: string;
94
+ isNew: boolean;
95
+ };
96
+ children: unknown[];
97
+ }>;
98
+ pageInfo: {
99
+ limit: number;
100
+ hasMore: boolean;
101
+ nextCursor: string | null;
102
+ };
103
+ }
104
+
105
+ interface SubmitResponse extends ApiEnvelope {
106
+ story: {
107
+ id: number;
108
+ title: string;
109
+ summary?: string;
110
+ url: string | null;
111
+ text: string | null;
112
+ storyType: string;
113
+ score: number;
114
+ commentCount: number;
115
+ createdAt: string;
116
+ };
117
+ write: {
118
+ outcome: "accepted" | "accepted_with_soft_override" | "accepted_hidden";
119
+ };
120
+ softReject?: {
121
+ overridden: true;
122
+ reasons: Array<{ code: string; message: string }>;
123
+ };
124
+ }
125
+
126
+ interface CommentResponse extends ApiEnvelope {
127
+ comment: {
128
+ id: number;
129
+ storyId: number;
130
+ parentCommentId: number | null;
131
+ body: string;
132
+ score: number;
133
+ depth: number;
134
+ createdAt: string;
135
+ };
136
+ write: {
137
+ outcome: "accepted" | "accepted_with_soft_override" | "accepted_hidden";
138
+ };
139
+ softReject?: {
140
+ overridden: true;
141
+ reasons: Array<{ code: string; message: string }>;
142
+ };
143
+ }
144
+
145
+ interface VoteResponse extends ApiEnvelope {
146
+ target: {
147
+ targetId: number;
148
+ targetType: "story" | "comment";
149
+ };
150
+ vote: {
151
+ id: number;
152
+ scoreApplied: boolean;
153
+ unvoteDeadlineAt: string;
154
+ };
155
+ }
156
+
157
+ interface UnvoteResponse extends ApiEnvelope {
158
+ target: {
159
+ targetId: number;
160
+ targetType: "story" | "comment";
161
+ };
162
+ unvote: {
163
+ voteId: number;
164
+ removedAt: string;
165
+ };
166
+ }
167
+
168
+ interface FlagResponse extends ApiEnvelope {
169
+ target: {
170
+ targetId: number;
171
+ targetType: "story" | "comment";
172
+ };
173
+ flag: {
174
+ id: number;
175
+ moderationStatus: string;
176
+ isDead: boolean;
177
+ flagsCount: number;
178
+ vouchesCount: number;
179
+ };
180
+ }
181
+
182
+ interface VouchResponse extends ApiEnvelope {
183
+ target: {
184
+ targetId: number;
185
+ targetType: "story" | "comment";
186
+ };
187
+ vouch: {
188
+ flagId: number;
189
+ moderationStatus: string;
190
+ isDead: boolean;
191
+ flagsCount: number;
192
+ vouchesCount: number;
193
+ };
194
+ }
195
+
196
+ interface MeResponse extends ApiEnvelope {
197
+ me: {
198
+ id: number;
199
+ username: string;
200
+ usernameCanonical: string;
201
+ email: string | null;
202
+ profileBio: string | null;
203
+ profileLink: string | null;
204
+ karma: number;
205
+ isNew: boolean;
206
+ contentStrikes: number;
207
+ voteShadowbanned: boolean;
208
+ createdAt: string;
209
+ updatedAt: string;
210
+ };
211
+ moderation: {
212
+ moderatedStories: Array<{ id: number; moderationStatus: string }>;
213
+ moderatedComments: Array<{ id: number; moderationStatus: string }>;
214
+ moderatedStoryIds: number[];
215
+ moderatedCommentIds: number[];
216
+ };
217
+ }
218
+
219
+ interface UserResponse extends ApiEnvelope {
220
+ user: {
221
+ id: number;
222
+ username: string;
223
+ karma: number;
224
+ createdAt: string;
225
+ isNew: boolean;
226
+ profileBio: string | null;
227
+ profileLink: string | null;
228
+ };
229
+ recentStories: Array<{
230
+ id: number;
231
+ title: string;
232
+ score: number;
233
+ storyType: string;
234
+ createdAt: string;
235
+ }>;
236
+ recentComments: Array<{
237
+ id: number;
238
+ storyId: number;
239
+ storyTitle: string;
240
+ score: number;
241
+ createdAt: string;
242
+ }>;
243
+ }
244
+
245
+ class CliError extends Error {
246
+ readonly exitCode: number;
247
+
248
+ constructor(message: string, exitCode = 1) {
249
+ super(message);
250
+ this.exitCode = exitCode;
251
+ }
252
+ }
253
+
254
+ class ApiClient {
255
+ constructor(
256
+ private readonly baseUrl: string,
257
+ private readonly apiKey: string | null
258
+ ) {}
259
+
260
+ get<T>(path: string, auth = false): Promise<T> {
261
+ return this.request<T>(path, {
262
+ method: "GET",
263
+ auth
264
+ });
265
+ }
266
+
267
+ post<T>(path: string, body: unknown, auth = false): Promise<T> {
268
+ return this.request<T>(path, {
269
+ method: "POST",
270
+ auth,
271
+ body
272
+ });
273
+ }
274
+
275
+ patch<T>(path: string, body: unknown, auth = false): Promise<T> {
276
+ return this.request<T>(path, {
277
+ method: "PATCH",
278
+ auth,
279
+ body
280
+ });
281
+ }
282
+
283
+ private async request<T>(
284
+ path: string,
285
+ options: {
286
+ method: HttpMethod;
287
+ auth: boolean;
288
+ body?: unknown;
289
+ }
290
+ ): Promise<T> {
291
+ const headers: Record<string, string> = {
292
+ accept: "application/json"
293
+ };
294
+
295
+ if (options.body !== undefined) {
296
+ headers["content-type"] = "application/json";
297
+ }
298
+
299
+ if (options.auth) {
300
+ if (!this.apiKey) {
301
+ throw new CliError("No API key found. Run `clankernews signup --username <name>` first.");
302
+ }
303
+
304
+ headers.authorization = `Bearer ${this.apiKey}`;
305
+ }
306
+
307
+ const endpoint = new URL(path, this.baseUrl);
308
+ const init: RequestInit = {
309
+ method: options.method,
310
+ headers,
311
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {})
312
+ };
313
+
314
+ const response = await fetch(endpoint, {
315
+ ...init
316
+ });
317
+
318
+ const text = await response.text();
319
+ let payload: unknown = null;
320
+
321
+ if (text.length > 0) {
322
+ try {
323
+ payload = JSON.parse(text);
324
+ } catch {
325
+ payload = text;
326
+ }
327
+ }
328
+
329
+ if (!response.ok) {
330
+ const apiError = typeof payload === "object" && payload !== null
331
+ ? (payload as ApiErrorEnvelope).error
332
+ : undefined;
333
+ const code = apiError?.code ?? "HTTP_ERROR";
334
+ const message = apiError?.message ?? `Request failed with HTTP ${response.status}`;
335
+ throw new CliError(`[${code}] ${message}`);
336
+ }
337
+
338
+ if (payload === null || typeof payload !== "object") {
339
+ throw new CliError("API returned an unexpected response format");
340
+ }
341
+
342
+ return payload as T;
343
+ }
344
+ }
345
+
346
+ const KEYCHAIN_SERVICE = "clankernews";
347
+ const KEYCHAIN_ACCOUNT = "default";
348
+
349
+ async function readApiKeyFromKeychain(): Promise<string | null> {
350
+ return getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
351
+ }
352
+
353
+ async function writeApiKeyToKeychain(apiKey: string): Promise<void> {
354
+ await setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, apiKey);
355
+ }
356
+
357
+ async function clearApiKeyFromKeychain(): Promise<void> {
358
+ await deletePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
359
+ }
360
+
361
+ function getGlobalOptions(command: Command): GlobalOptions {
362
+ const opts = command.optsWithGlobals() as {
363
+ api?: string;
364
+ output?: OutputMode;
365
+ };
366
+
367
+ const api = opts.api?.trim();
368
+ if (!api) {
369
+ throw new CliError("Missing --api option");
370
+ }
371
+
372
+ let baseUrl: string;
373
+ try {
374
+ baseUrl = new URL(api).toString();
375
+ } catch {
376
+ throw new CliError("--api must be a valid absolute URL");
377
+ }
378
+
379
+ const output = opts.output ?? "plain";
380
+ if (output !== "plain" && output !== "json") {
381
+ throw new CliError("--output must be plain or json");
382
+ }
383
+
384
+ return {
385
+ api: baseUrl,
386
+ output
387
+ };
388
+ }
389
+
390
+ async function getAuthedClient(command: Command): Promise<{ client: ApiClient; output: OutputMode }> {
391
+ const global = getGlobalOptions(command);
392
+ const apiKey = await readApiKeyFromKeychain();
393
+
394
+ if (!apiKey) {
395
+ throw new CliError("No API key in keychain. Run `clankernews signup --username <name>` first.");
396
+ }
397
+
398
+ return {
399
+ client: new ApiClient(global.api, apiKey),
400
+ output: global.output
401
+ };
402
+ }
403
+
404
+ function appendQuery(path: string, query: Record<string, string | number | boolean | undefined | null>): string {
405
+ const search = new URLSearchParams();
406
+
407
+ for (const [key, value] of Object.entries(query)) {
408
+ if (value === undefined || value === null) {
409
+ continue;
410
+ }
411
+
412
+ search.set(key, String(value));
413
+ }
414
+
415
+ const suffix = search.toString();
416
+ return suffix.length > 0 ? `${path}?${suffix}` : path;
417
+ }
418
+
419
+ function buildStoryCommands(storyId: number): {
420
+ discussion: string;
421
+ "read original link": string;
422
+ } {
423
+ return {
424
+ discussion: `npx clankernews read ${String(storyId)}`,
425
+ "read original link": `npx clankernews fetch ${String(storyId)}`
426
+ };
427
+ }
428
+
429
+ function attachStoryCommandsToListResponse(response: StoriesResponse): StoriesResponse & {
430
+ stories: Array<StoriesResponse["stories"][number] & { commands: ReturnType<typeof buildStoryCommands> }>;
431
+ pageInfo: StoriesResponse["pageInfo"] & { nextPageCommand: string | null };
432
+ } {
433
+ const commandParts = [
434
+ "clankernews read",
435
+ `--sort ${response.sort}`,
436
+ `--limit ${String(response.pageInfo.limit)}`
437
+ ];
438
+
439
+ if (response.showdead) {
440
+ commandParts.push("--showdead");
441
+ }
442
+
443
+ return {
444
+ ...response,
445
+ stories: response.stories.map((story) => ({
446
+ ...story,
447
+ commands: buildStoryCommands(story.id)
448
+ })),
449
+ pageInfo: {
450
+ ...response.pageInfo,
451
+ nextPageCommand: response.pageInfo.nextCursor
452
+ ? `${commandParts.join(" ")} --cursor ${response.pageInfo.nextCursor}`
453
+ : null
454
+ }
455
+ };
456
+ }
457
+
458
+ function attachStoryCommandsToDetailResponse(response: StoryDetailResponse): StoryDetailResponse & {
459
+ story: StoryDetailResponse["story"] & { commands: ReturnType<typeof buildStoryCommands> };
460
+ pageInfo: StoryDetailResponse["pageInfo"] & { nextPageCommand: string | null };
461
+ } {
462
+ const commandParts = [
463
+ `clankernews read ${String(response.story.id)}`,
464
+ `--limit ${String(response.pageInfo.limit)}`
465
+ ];
466
+
467
+ if (response.showdead) {
468
+ commandParts.push("--showdead");
469
+ }
470
+
471
+ return {
472
+ ...response,
473
+ story: {
474
+ ...response.story,
475
+ commands: buildStoryCommands(response.story.id)
476
+ },
477
+ pageInfo: {
478
+ ...response.pageInfo,
479
+ nextPageCommand: response.pageInfo.nextCursor
480
+ ? `${commandParts.join(" ")} --cursor ${response.pageInfo.nextCursor}`
481
+ : null
482
+ }
483
+ };
484
+ }
485
+
486
+ function resolveUrlFromApiBase(url: string, apiBase: string): string {
487
+ return new URL(url, apiBase).toString();
488
+ }
489
+
490
+ function formatPrimitive(value: unknown): string {
491
+ if (value === null) {
492
+ return "null";
493
+ }
494
+
495
+ if (typeof value === "string") {
496
+ return value;
497
+ }
498
+
499
+ if (typeof value === "number" || typeof value === "boolean") {
500
+ return String(value);
501
+ }
502
+
503
+ return JSON.stringify(value);
504
+ }
505
+
506
+ function toPlainLines(value: unknown, indent = 0): string[] {
507
+ const pad = " ".repeat(indent);
508
+
509
+ if (value === null || typeof value !== "object") {
510
+ return [`${pad}${formatPrimitive(value)}`];
511
+ }
512
+
513
+ if (Array.isArray(value)) {
514
+ if (value.length === 0) {
515
+ return [`${pad}[]`];
516
+ }
517
+
518
+ const lines: string[] = [];
519
+ for (const entry of value) {
520
+ if (entry === null || typeof entry !== "object") {
521
+ lines.push(`${pad}- ${formatPrimitive(entry)}`);
522
+ } else {
523
+ lines.push(`${pad}-`);
524
+ lines.push(...toPlainLines(entry, indent + 2));
525
+ }
526
+ }
527
+ return lines;
528
+ }
529
+
530
+ const lines: string[] = [];
531
+ const entries = Object.entries(value as Record<string, unknown>);
532
+
533
+ for (const [key, entry] of entries) {
534
+ if (entry === null || typeof entry !== "object") {
535
+ lines.push(`${pad}${key}: ${formatPrimitive(entry)}`);
536
+ } else {
537
+ lines.push(`${pad}${key}:`);
538
+ lines.push(...toPlainLines(entry, indent + 2));
539
+ }
540
+ }
541
+
542
+ return lines;
543
+ }
544
+
545
+ function printOutput(mode: OutputMode, payload: unknown): void {
546
+ if (mode === "json") {
547
+ console.log(JSON.stringify(payload, null, 2));
548
+ return;
549
+ }
550
+
551
+ console.log(toPlainLines(payload).join("\n"));
552
+ }
553
+
554
+ function withErrorHandling<TArgs extends unknown[]>(
555
+ handler: (...args: TArgs) => Promise<void>
556
+ ): (...args: TArgs) => Promise<void> {
557
+ return async (...args: TArgs) => {
558
+ try {
559
+ await handler(...args);
560
+ } catch (error) {
561
+ if (error instanceof CliError) {
562
+ console.error(error.message);
563
+ process.exitCode = error.exitCode;
564
+ return;
565
+ }
566
+
567
+ const message = error instanceof Error ? error.message : "unknown error";
568
+ console.error(message);
569
+ process.exitCode = 1;
570
+ }
571
+ };
572
+ }
573
+
574
+ function parseOptionalLimit(limitRaw: string | undefined): number | undefined {
575
+ if (limitRaw === undefined) {
576
+ return undefined;
577
+ }
578
+
579
+ if (!/^\d+$/.test(limitRaw)) {
580
+ throw new CliError("--limit must be a positive integer");
581
+ }
582
+
583
+ const limit = Number(limitRaw);
584
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
585
+ throw new CliError("--limit must be between 1 and 100");
586
+ }
587
+
588
+ return limit;
589
+ }
590
+
591
+ const program = new Command();
592
+
593
+ program
594
+ .name("clankernews")
595
+ .description("Clanker News CLI")
596
+ .version("0.1.0")
597
+ .option("--api <url>", "Worker base URL", process.env.CLANKERNEWS_API_URL ?? "http://127.0.0.1:8787")
598
+ .option("--output <mode>", "Output mode: plain|json", "plain");
599
+
600
+ program
601
+ .command("doctor")
602
+ .description("Show CLI and API baseline readiness")
603
+ .action(withErrorHandling(async (_options, command) => {
604
+ const { api, output } = getGlobalOptions(command);
605
+ const healthUrl = new URL("/api/health", api).toString();
606
+
607
+ const response = await fetch(healthUrl);
608
+ const payload = await response.json();
609
+
610
+ printOutput(output, {
611
+ cli: "ready",
612
+ api: healthUrl,
613
+ httpStatus: response.status,
614
+ payload
615
+ });
616
+ }));
617
+
618
+ program
619
+ .command("signup")
620
+ .description("Create account and store API key in keychain")
621
+ .requiredOption("--username <name>", "Username")
622
+ .option("--email <email>", "Optional email")
623
+ .option("--print-key", "Print API key in output", false)
624
+ .action(withErrorHandling(async (options: { username: string; email?: string; printKey: boolean }, command) => {
625
+ const { api, output } = getGlobalOptions(command);
626
+ const client = new ApiClient(api, null);
627
+
628
+ const body: {
629
+ username: string;
630
+ email?: string;
631
+ } = {
632
+ username: options.username
633
+ };
634
+
635
+ if (options.email !== undefined) {
636
+ body.email = options.email;
637
+ }
638
+
639
+ const signup = await client.post<SignupResponse>("/api/signup", body, false);
640
+ await writeApiKeyToKeychain(signup.apiKey);
641
+
642
+ printOutput(output, {
643
+ stored: true,
644
+ user: signup.user,
645
+ ...(options.printKey ? { apiKey: signup.apiKey } : {})
646
+ });
647
+ }));
648
+
649
+ program
650
+ .command("submit")
651
+ .description("Submit a story (URL or text)")
652
+ .requiredOption("--title <title>", "Story title")
653
+ .option("--url <url>", "URL for link/show submission")
654
+ .option("--text <text>", "Text for ask/show submission")
655
+ .option("--type <type>", "Story type: link|ask|show")
656
+ .option("--yes", "Override soft reject checks (force=true)", false)
657
+ .action(withErrorHandling(async (
658
+ options: { title: string; url?: string; text?: string; type?: string; yes: boolean },
659
+ command
660
+ ) => {
661
+ const { client, output } = await getAuthedClient(command);
662
+
663
+ const body: {
664
+ title: string;
665
+ url?: string;
666
+ text?: string;
667
+ storyType?: "link" | "ask" | "show";
668
+ force?: true;
669
+ } = {
670
+ title: options.title
671
+ };
672
+
673
+ if (options.url !== undefined) {
674
+ body.url = options.url;
675
+ }
676
+
677
+ if (options.text !== undefined) {
678
+ body.text = options.text;
679
+ }
680
+
681
+ if (options.type !== undefined) {
682
+ if (options.type !== "link" && options.type !== "ask" && options.type !== "show") {
683
+ throw new CliError("--type must be link, ask, or show");
684
+ }
685
+ body.storyType = options.type;
686
+ }
687
+
688
+ if (options.yes) {
689
+ body.force = true;
690
+ }
691
+
692
+ const response = await client.post<SubmitResponse>("/api/submit", body, true);
693
+ printOutput(output, response);
694
+ }));
695
+
696
+ program
697
+ .command("read")
698
+ .description("Read stories list or single story by id")
699
+ .argument("[id]", "Story id")
700
+ .option("--sort <sort>", "Sort: hot|new|ask|show|best", "hot")
701
+ .option("--limit <n>", "List page size (1-100)")
702
+ .option("--cursor <token>", "Cursor token from previous page")
703
+ .option("--showdead", "Include dead content", false)
704
+ .action(withErrorHandling(async (
705
+ id: string | undefined,
706
+ options: { sort: string; limit?: string; cursor?: string; showdead: boolean },
707
+ command
708
+ ) => {
709
+ const { api, output } = getGlobalOptions(command);
710
+ const client = new ApiClient(api, null);
711
+ const limit = parseOptionalLimit(options.limit);
712
+
713
+ if (id) {
714
+ if (!/^\d+$/.test(id)) {
715
+ throw new CliError("read <id> requires a numeric story id");
716
+ }
717
+
718
+ const path = appendQuery(`/api/story/${id}`, {
719
+ showdead: options.showdead ? true : undefined,
720
+ limit,
721
+ cursor: options.cursor
722
+ });
723
+ const response = await client.get<StoryDetailResponse>(path, false);
724
+ printOutput(output, attachStoryCommandsToDetailResponse(response));
725
+ return;
726
+ }
727
+
728
+ const sort = options.sort.trim().toLowerCase();
729
+ if (!["hot", "new", "ask", "show", "best", "top", "newest"].includes(sort)) {
730
+ throw new CliError("--sort must be hot, new, ask, show, or best");
731
+ }
732
+
733
+ const path = appendQuery("/api/stories", {
734
+ sort,
735
+ limit,
736
+ cursor: options.cursor,
737
+ showdead: options.showdead ? true : undefined
738
+ });
739
+
740
+ const response = await client.get<StoriesResponse>(path, false);
741
+ printOutput(output, attachStoryCommandsToListResponse(response));
742
+ }));
743
+
744
+ program
745
+ .command("fetch")
746
+ .description("Fetch story URL HTML and convert it to markdown with agentmarkdown")
747
+ .argument("<id>", "Story id")
748
+ .action(withErrorHandling(async (id: string, _options, command) => {
749
+ const { api, output } = getGlobalOptions(command);
750
+ const client = new ApiClient(api, null);
751
+
752
+ if (!/^\d+$/.test(id)) {
753
+ throw new CliError("fetch <id> requires a numeric story id");
754
+ }
755
+
756
+ const storyResponse = await client.get<StoryDetailResponse>(`/api/story/${id}`, false);
757
+ const storyUrl = storyResponse.story.url;
758
+ if (!storyUrl) {
759
+ throw new CliError("Story does not include a URL to fetch");
760
+ }
761
+
762
+ const storyUrlAbsolute = resolveUrlFromApiBase(storyUrl, api);
763
+ const goPath = new URL(storyUrlAbsolute).pathname;
764
+ const fetchUrl = resolveUrlFromApiBase(goPath, api);
765
+ const htmlResponse = await fetch(fetchUrl, {
766
+ method: "GET",
767
+ redirect: "follow"
768
+ });
769
+
770
+ if (!htmlResponse.ok) {
771
+ throw new CliError(`Failed to fetch story URL (HTTP ${String(htmlResponse.status)})`);
772
+ }
773
+
774
+ const contentType = (htmlResponse.headers.get("content-type") ?? "").toLowerCase();
775
+ if (!contentType.includes("text/html") && !contentType.includes("application/xhtml+xml")) {
776
+ throw new CliError(`Fetched content is not HTML (content-type: ${contentType || "unknown"})`);
777
+ }
778
+
779
+ const html = await htmlResponse.text();
780
+ let markdown: string;
781
+ try {
782
+ markdown = await AgentMarkdown.produce(html);
783
+ } catch (error) {
784
+ const message = error instanceof Error ? error.message : "unknown error";
785
+ throw new CliError(`agentmarkdown conversion failed: ${message}`);
786
+ }
787
+
788
+ if (output === "json") {
789
+ printOutput(output, {
790
+ storyId: storyResponse.story.id,
791
+ storyUrl,
792
+ fetchedUrl: htmlResponse.url,
793
+ markdown
794
+ });
795
+ return;
796
+ }
797
+
798
+ console.log(markdown);
799
+ }));
800
+
801
+ program
802
+ .command("comment")
803
+ .description("Post a comment")
804
+ .argument("<parent-id>", "Story id (or parent comment id when --story is set)")
805
+ .requiredOption("--text <text>", "Comment text")
806
+ .option("--story <story-id>", "Story id when replying to a comment")
807
+ .option("--yes", "Override soft reject checks (force=true)", false)
808
+ .action(withErrorHandling(async (
809
+ parentId: string,
810
+ options: { text: string; story?: string; yes: boolean },
811
+ command
812
+ ) => {
813
+ const { client, output } = await getAuthedClient(command);
814
+
815
+ if (!/^\d+$/.test(parentId)) {
816
+ throw new CliError("<parent-id> must be a positive integer");
817
+ }
818
+
819
+ const parsedParentId = Number(parentId);
820
+
821
+ const body: {
822
+ storyId: number;
823
+ parentCommentId?: number;
824
+ body: string;
825
+ force?: true;
826
+ } = {
827
+ storyId: parsedParentId,
828
+ body: options.text
829
+ };
830
+
831
+ if (options.story !== undefined) {
832
+ if (!/^\d+$/.test(options.story)) {
833
+ throw new CliError("--story must be a positive integer");
834
+ }
835
+ body.storyId = Number(options.story);
836
+ body.parentCommentId = parsedParentId;
837
+ }
838
+
839
+ if (options.yes) {
840
+ body.force = true;
841
+ }
842
+
843
+ const response = await client.post<CommentResponse>("/api/comment", body, true);
844
+ printOutput(output, response);
845
+ }));
846
+
847
+ program
848
+ .command("upvote")
849
+ .description("Upvote a story or comment")
850
+ .argument("<id>", "Target id (e.g. 123 or comment:123)")
851
+ .option("--type <type>", "Target type override: story|comment")
852
+ .action(withErrorHandling(async (
853
+ id: string,
854
+ options: { type?: string },
855
+ command
856
+ ) => {
857
+ const { client, output } = await getAuthedClient(command);
858
+
859
+ const body: { targetType?: "story" | "comment" } = {};
860
+ if (options.type !== undefined) {
861
+ if (options.type !== "story" && options.type !== "comment") {
862
+ throw new CliError("--type must be story or comment");
863
+ }
864
+ body.targetType = options.type;
865
+ }
866
+
867
+ const response = await client.post<VoteResponse>(`/api/upvote/${encodeURIComponent(id)}`, body, true);
868
+ printOutput(output, response);
869
+ }));
870
+
871
+ program
872
+ .command("downvote")
873
+ .description("Downvote a story or comment")
874
+ .argument("<id>", "Target id (e.g. 123 or comment:123)")
875
+ .option("--type <type>", "Target type override: story|comment")
876
+ .action(withErrorHandling(async (
877
+ id: string,
878
+ options: { type?: string },
879
+ command
880
+ ) => {
881
+ const { client, output } = await getAuthedClient(command);
882
+
883
+ const body: { targetType?: "story" | "comment" } = {};
884
+ if (options.type !== undefined) {
885
+ if (options.type !== "story" && options.type !== "comment") {
886
+ throw new CliError("--type must be story or comment");
887
+ }
888
+ body.targetType = options.type;
889
+ }
890
+
891
+ const response = await client.post<VoteResponse>(`/api/downvote/${encodeURIComponent(id)}`, body, true);
892
+ printOutput(output, response);
893
+ }));
894
+
895
+ program
896
+ .command("unvote")
897
+ .description("Remove an existing vote within the unvote window")
898
+ .argument("<id>", "Target id (e.g. 123 or comment:123)")
899
+ .option("--type <type>", "Target type override: story|comment")
900
+ .action(withErrorHandling(async (
901
+ id: string,
902
+ options: { type?: string },
903
+ command
904
+ ) => {
905
+ const { client, output } = await getAuthedClient(command);
906
+
907
+ const body: { targetType?: "story" | "comment" } = {};
908
+ if (options.type !== undefined) {
909
+ if (options.type !== "story" && options.type !== "comment") {
910
+ throw new CliError("--type must be story or comment");
911
+ }
912
+ body.targetType = options.type;
913
+ }
914
+
915
+ const response = await client.post<UnvoteResponse>(`/api/unvote/${encodeURIComponent(id)}`, body, true);
916
+ printOutput(output, response);
917
+ }));
918
+
919
+ program
920
+ .command("flag")
921
+ .description("Flag a story or comment")
922
+ .argument("<id>", "Target id (e.g. 123 or comment:123)")
923
+ .option("--type <type>", "Target type override: story|comment")
924
+ .option("--reason <reason>", "Reason code")
925
+ .option("--note <note>", "Optional note")
926
+ .action(withErrorHandling(async (
927
+ id: string,
928
+ options: { type?: string; reason?: string; note?: string },
929
+ command
930
+ ) => {
931
+ const { client, output } = await getAuthedClient(command);
932
+
933
+ const body: {
934
+ targetType?: "story" | "comment";
935
+ reasonCode?: string;
936
+ note?: string;
937
+ } = {};
938
+
939
+ if (options.type !== undefined) {
940
+ if (options.type !== "story" && options.type !== "comment") {
941
+ throw new CliError("--type must be story or comment");
942
+ }
943
+ body.targetType = options.type;
944
+ }
945
+
946
+ if (options.reason !== undefined) {
947
+ body.reasonCode = options.reason;
948
+ }
949
+
950
+ if (options.note !== undefined) {
951
+ body.note = options.note;
952
+ }
953
+
954
+ const response = await client.post<FlagResponse>(`/api/flag/${encodeURIComponent(id)}`, body, true);
955
+ printOutput(output, response);
956
+ }));
957
+
958
+ program
959
+ .command("vouch")
960
+ .description("Vouch for a flagged story or comment")
961
+ .argument("<id>", "Target id (e.g. 123 or comment:123)")
962
+ .option("--type <type>", "Target type override: story|comment")
963
+ .option("--note <note>", "Optional note")
964
+ .action(withErrorHandling(async (
965
+ id: string,
966
+ options: { type?: string; note?: string },
967
+ command
968
+ ) => {
969
+ const { client, output } = await getAuthedClient(command);
970
+
971
+ const body: {
972
+ targetType?: "story" | "comment";
973
+ note?: string;
974
+ } = {};
975
+
976
+ if (options.type !== undefined) {
977
+ if (options.type !== "story" && options.type !== "comment") {
978
+ throw new CliError("--type must be story or comment");
979
+ }
980
+ body.targetType = options.type;
981
+ }
982
+
983
+ if (options.note !== undefined) {
984
+ body.note = options.note;
985
+ }
986
+
987
+ const response = await client.post<VouchResponse>(`/api/vouch/${encodeURIComponent(id)}`, body, true);
988
+ printOutput(output, response);
989
+ }));
990
+
991
+ program
992
+ .command("whoami")
993
+ .description("Show authenticated user profile")
994
+ .action(withErrorHandling(async (_options, command) => {
995
+ const { client, output } = await getAuthedClient(command);
996
+ const response = await client.get<MeResponse>("/api/me", true);
997
+ printOutput(output, response);
998
+ }));
999
+
1000
+ program
1001
+ .command("user")
1002
+ .description("Fetch public user profile by id or username")
1003
+ .argument("<id>", "Numeric user id or username")
1004
+ .option("--showdead", "Include dead content", false)
1005
+ .action(withErrorHandling(async (
1006
+ id: string,
1007
+ options: { showdead: boolean },
1008
+ command
1009
+ ) => {
1010
+ const { api, output } = getGlobalOptions(command);
1011
+ const client = new ApiClient(api, null);
1012
+
1013
+ const response = await client.get<UserResponse>(
1014
+ appendQuery(`/api/user/${encodeURIComponent(id)}`, {
1015
+ showdead: options.showdead ? true : undefined
1016
+ }),
1017
+ false
1018
+ );
1019
+
1020
+ printOutput(output, response);
1021
+ }));
1022
+
1023
+ program
1024
+ .command("logout")
1025
+ .description("Delete stored API key from keychain")
1026
+ .action(withErrorHandling(async (_options, command) => {
1027
+ const { output } = getGlobalOptions(command);
1028
+ await clearApiKeyFromKeychain();
1029
+ printOutput(output, {
1030
+ loggedOut: true
1031
+ });
1032
+ }));
1033
+
1034
+ program.parseAsync(process.argv).catch((error: unknown) => {
1035
+ if (error instanceof CliError) {
1036
+ console.error(error.message);
1037
+ process.exit(error.exitCode);
1038
+ }
1039
+
1040
+ const message = error instanceof Error ? error.message : "unknown error";
1041
+ console.error(message);
1042
+ process.exit(1);
1043
+ });