@bdsqqq/lnr-cli 1.1.1 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bdsqqq/lnr-cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "cli for linear issue tracking",
5
5
  "type": "module",
6
6
  "private": false,
@@ -21,12 +21,16 @@
21
21
  "scripts": {
22
22
  "dev": "bun run src/cli.ts",
23
23
  "check": "tsc --noEmit",
24
- "build": "bun build ./src/cli.ts --compile --outfile lnr"
24
+ "build": "bun build ./src/cli.ts --compile --outfile lnr",
25
+ "test": "bun test"
25
26
  },
26
27
  "dependencies": {
27
28
  "@bdsqqq/lnr-core": "workspace:*",
29
+ "@trpc/server": "^11.8.1",
28
30
  "chalk": "^5.6.2",
29
- "commander": "^14.0.2"
31
+ "commander": "^14.0.2",
32
+ "trpc-cli": "^0.12.2",
33
+ "zod": "^4.3.5"
30
34
  },
31
35
  "peerDependencies": {
32
36
  "typescript": "^5"
package/src/cli.ts CHANGED
@@ -1,30 +1,28 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { Command } from "commander";
4
- import { registerAuthCommand } from "./commands/auth";
5
- import { registerIssuesCommand } from "./commands/issues";
6
- import { registerTeamsCommand } from "./commands/teams";
7
- import { registerProjectsCommand } from "./commands/projects";
8
- import { registerCyclesCommand } from "./commands/cycles";
9
- import { registerMeCommand } from "./commands/me";
10
- import { registerSearchCommand } from "./commands/search";
11
- import { registerConfigCommand } from "./commands/config";
3
+ import { createCli } from "trpc-cli";
4
+ import { appRouter } from "./router";
12
5
  import pkg from "../package.json";
13
6
 
14
- const program = new Command();
7
+ // parse global --api-key flag before trpc-cli
8
+ // precedence: --api-key > LINEAR_API_KEY env > config file
9
+ const apiKeyIndex = process.argv.findIndex((arg) => arg === "--api-key");
10
+ if (apiKeyIndex !== -1 && process.argv[apiKeyIndex + 1]) {
11
+ process.env.LINEAR_API_KEY = process.argv[apiKeyIndex + 1];
12
+ process.argv.splice(apiKeyIndex, 2);
13
+ } else {
14
+ const apiKeyEqMatch = process.argv.find((arg) => arg.startsWith("--api-key="));
15
+ if (apiKeyEqMatch) {
16
+ process.env.LINEAR_API_KEY = apiKeyEqMatch.slice("--api-key=".length);
17
+ process.argv = process.argv.filter((arg) => !arg.startsWith("--api-key="));
18
+ }
19
+ }
15
20
 
16
- program
17
- .name("lnr")
18
- .description("command-line interface for Linear")
19
- .version(pkg.version);
21
+ const cli = createCli({
22
+ router: appRouter,
23
+ name: "lnr",
24
+ version: pkg.version,
25
+ description: "command-line interface for Linear",
26
+ });
20
27
 
21
- registerAuthCommand(program);
22
- registerIssuesCommand(program);
23
- registerTeamsCommand(program);
24
- registerProjectsCommand(program);
25
- registerCyclesCommand(program);
26
- registerMeCommand(program);
27
- registerSearchCommand(program);
28
- registerConfigCommand(program);
29
-
30
- program.parse();
28
+ void cli.run();
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ truncate,
4
+ formatDate,
5
+ formatPriority,
6
+ formatRelativeTime,
7
+ shortcodeToEmoji,
8
+ formatReactions,
9
+ wrapText,
10
+ buildChildMap,
11
+ } from "./output";
12
+ import type { Comment } from "@bdsqqq/lnr-core";
13
+
14
+ describe("truncate", () => {
15
+ test("shorter than max → unchanged", () => {
16
+ expect(truncate("hello", 10)).toBe("hello");
17
+ });
18
+
19
+ test("at exact max → unchanged", () => {
20
+ expect(truncate("hello", 5)).toBe("hello");
21
+ });
22
+
23
+ test("longer → truncated with …", () => {
24
+ expect(truncate("hello world", 6)).toBe("hello…");
25
+ });
26
+ });
27
+
28
+ describe("formatDate", () => {
29
+ test("null → -", () => {
30
+ expect(formatDate(null)).toBe("-");
31
+ });
32
+
33
+ test("undefined → -", () => {
34
+ expect(formatDate(undefined)).toBe("-");
35
+ });
36
+
37
+ test("Date → YYYY-MM-DD", () => {
38
+ expect(formatDate(new Date("2024-03-15T12:00:00Z"))).toBe("2024-03-15");
39
+ });
40
+
41
+ test("ISO string → YYYY-MM-DD", () => {
42
+ expect(formatDate("2024-03-15T12:00:00Z")).toBe("2024-03-15");
43
+ });
44
+ });
45
+
46
+ describe("formatPriority", () => {
47
+ test("0 → -", () => expect(formatPriority(0)).toBe("-"));
48
+ test("1 → urgent", () => expect(formatPriority(1)).toBe("urgent"));
49
+ test("2 → high", () => expect(formatPriority(2)).toBe("high"));
50
+ test("3 → medium", () => expect(formatPriority(3)).toBe("medium"));
51
+ test("4 → low", () => expect(formatPriority(4)).toBe("low"));
52
+ test("undefined → -", () => expect(formatPriority(undefined)).toBe("-"));
53
+ });
54
+
55
+ describe("formatRelativeTime", () => {
56
+ test("< 1 min → just now", () => {
57
+ const now = new Date();
58
+ expect(formatRelativeTime(now)).toBe("just now");
59
+ });
60
+
61
+ test("minutes ago", () => {
62
+ const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
63
+ expect(formatRelativeTime(fiveMinAgo)).toBe("5m ago");
64
+ });
65
+
66
+ test("hours ago", () => {
67
+ const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
68
+ expect(formatRelativeTime(threeHoursAgo)).toBe("3h ago");
69
+ });
70
+
71
+ test("days ago", () => {
72
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
73
+ expect(formatRelativeTime(twoDaysAgo)).toBe("2d ago");
74
+ });
75
+
76
+ test("weeks ago", () => {
77
+ const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
78
+ expect(formatRelativeTime(twoWeeksAgo)).toBe("2w ago");
79
+ });
80
+ });
81
+
82
+ describe("shortcodeToEmoji", () => {
83
+ test("known → emoji", () => {
84
+ expect(shortcodeToEmoji("+1")).toBe("👍");
85
+ expect(shortcodeToEmoji("fire")).toBe("🔥");
86
+ expect(shortcodeToEmoji("heart")).toBe("❤️");
87
+ });
88
+
89
+ test("unknown → :shortcode:", () => {
90
+ expect(shortcodeToEmoji("not_a_real_emoji")).toBe(":not_a_real_emoji:");
91
+ });
92
+ });
93
+
94
+ describe("formatReactions", () => {
95
+ test("empty → empty string", () => {
96
+ expect(formatReactions([])).toBe("");
97
+ });
98
+
99
+ test("count 1 → just emoji", () => {
100
+ expect(formatReactions([{ emoji: "+1", count: 1 }])).toBe("👍");
101
+ });
102
+
103
+ test("count > 1 → emoji + count", () => {
104
+ expect(formatReactions([{ emoji: "+1", count: 3 }])).toBe("👍3");
105
+ });
106
+
107
+ test("multiple reactions", () => {
108
+ expect(
109
+ formatReactions([
110
+ { emoji: "+1", count: 2 },
111
+ { emoji: "fire", count: 1 },
112
+ ])
113
+ ).toBe("👍2 🔥");
114
+ });
115
+ });
116
+
117
+ describe("wrapText", () => {
118
+ test("short text → single line", () => {
119
+ expect(wrapText("hello", 20, "")).toEqual(["hello"]);
120
+ });
121
+
122
+ test("long text wraps", () => {
123
+ const result = wrapText("hello world foo bar", 12, "");
124
+ expect(result).toEqual(["hello world", "foo bar"]);
125
+ });
126
+
127
+ test("preserves paragraph breaks", () => {
128
+ const result = wrapText("hello\n\nworld", 20, "");
129
+ expect(result).toEqual(["hello", "", "world"]);
130
+ });
131
+
132
+ test("applies indent", () => {
133
+ const result = wrapText("hello", 20, " ");
134
+ expect(result).toEqual([" hello"]);
135
+ });
136
+ });
137
+
138
+ describe("buildChildMap", () => {
139
+ const makeComment = (
140
+ id: string,
141
+ parentId: string | null,
142
+ createdAt: Date
143
+ ): Comment => ({
144
+ id,
145
+ body: "test",
146
+ user: "user",
147
+ externalUser: null,
148
+ botActor: null,
149
+ url: "https://example.com",
150
+ createdAt,
151
+ updatedAt: createdAt,
152
+ parentId,
153
+ reactions: [],
154
+ syncedWith: [],
155
+ });
156
+
157
+ test("roots go under null key", () => {
158
+ const comments = [makeComment("a", null, new Date("2024-01-01"))];
159
+ const map = buildChildMap(comments);
160
+ expect(map.get(null)?.map((c) => c.id)).toEqual(["a"]);
161
+ });
162
+
163
+ test("children under parent id", () => {
164
+ const comments = [
165
+ makeComment("a", null, new Date("2024-01-01")),
166
+ makeComment("b", "a", new Date("2024-01-02")),
167
+ ];
168
+ const map = buildChildMap(comments);
169
+ expect(map.get("a")?.map((c) => c.id)).toEqual(["b"]);
170
+ });
171
+
172
+ test("sorted by createdAt", () => {
173
+ const comments = [
174
+ makeComment("c", null, new Date("2024-01-03")),
175
+ makeComment("a", null, new Date("2024-01-01")),
176
+ makeComment("b", null, new Date("2024-01-02")),
177
+ ];
178
+ const map = buildChildMap(comments);
179
+ expect(map.get(null)?.map((c) => c.id)).toEqual(["a", "b", "c"]);
180
+ });
181
+ });
package/src/lib/output.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { getConfigValue } from "@bdsqqq/lnr-core";
2
+ import { getConfigValue, type Comment } from "@bdsqqq/lnr-core";
3
3
 
4
4
  export type OutputFormat = "table" | "json" | "quiet";
5
5
 
@@ -97,3 +97,319 @@ export function formatPriority(priority: number | undefined): string {
97
97
  return "-";
98
98
  }
99
99
  }
100
+
101
+ export function formatRelativeTime(date: Date): string {
102
+ const now = new Date();
103
+ const diffMs = now.getTime() - date.getTime();
104
+ const diffMins = Math.floor(diffMs / (1000 * 60));
105
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
106
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
107
+ const diffWeeks = Math.floor(diffDays / 7);
108
+ const diffMonths = Math.floor(diffDays / 30);
109
+ const diffYears = Math.floor(diffDays / 365);
110
+
111
+ if (diffMins < 1) return "just now";
112
+ if (diffMins < 60) return `${diffMins}m ago`;
113
+ if (diffHours < 24) return `${diffHours}h ago`;
114
+ if (diffDays < 7) return `${diffDays}d ago`;
115
+ if (diffWeeks < 4) return `${diffWeeks}w ago`;
116
+ if (diffMonths < 12) return `${diffMonths}mo ago`;
117
+ return `${diffYears}y ago`;
118
+ }
119
+
120
+ export function buildChildMap(comments: Comment[]): Map<string | null, Comment[]> {
121
+ const childMap = new Map<string | null, Comment[]>();
122
+
123
+ for (const c of comments) {
124
+ const parentKey = c.parentId ?? null;
125
+ const existing = childMap.get(parentKey) ?? [];
126
+ existing.push(c);
127
+ childMap.set(parentKey, existing);
128
+ }
129
+
130
+ for (const children of Array.from(childMap.values())) {
131
+ children.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
132
+ }
133
+
134
+ return childMap;
135
+ }
136
+
137
+ const EMOJI_MAP: Record<string, string> = {
138
+ // gestures
139
+ "+1": "👍", thumbsup: "👍", "-1": "👎", thumbsdown: "👎",
140
+ wave: "👋", raised_back_of_hand: "🤚", raised_hand: "✋", hand: "✋",
141
+ vulcan_salute: "🖖", ok_hand: "👌", pinched_fingers: "🤌", pinching_hand: "🤏",
142
+ v: "✌️", crossed_fingers: "🤞", love_you_gesture: "🤟", metal: "🤘",
143
+ call_me_hand: "🤙", point_left: "👈", point_right: "👉", point_up_2: "👆",
144
+ point_down: "👇", point_up: "☝️", fist: "✊", fist_raised: "✊",
145
+ facepunch: "👊", punch: "👊", fist_oncoming: "👊", fist_left: "🤛", fist_right: "🤜",
146
+ clap: "👏", raised_hands: "🙌", open_hands: "👐", palms_up_together: "🤲",
147
+ handshake: "🤝", pray: "🙏", writing_hand: "✍️", nail_care: "💅",
148
+ selfie: "🤳", muscle: "💪", mechanical_arm: "🦾",
149
+ eyes: "👀", eye: "👁️", tongue: "👅", lips: "👄",
150
+
151
+ // smileys
152
+ grinning: "😀", smile: "😊", grin: "😁", joy: "😂", rofl: "🤣",
153
+ smiley: "😃", sweat_smile: "😅", laughing: "😆", laugh: "😄",
154
+ wink: "😉", blush: "😊", yum: "😋", sunglasses: "😎", heart_eyes: "😍",
155
+ kissing_heart: "💋", kissing: "😗", relaxed: "☺️",
156
+ stuck_out_tongue: "😛", stuck_out_tongue_winking_eye: "😜", stuck_out_tongue_closed_eyes: "😝",
157
+ disappointed: "😞", worried: "😟", angry: "😠", rage: "😡", pout: "😡",
158
+ cry: "😢", persevere: "😣", triumph: "😤", disappointed_relieved: "😥",
159
+ frowning: "😦", anguished: "😧", fearful: "😨", weary: "😩",
160
+ sleepy: "😪", tired_face: "😫", grimacing: "😬", sob: "😭",
161
+ open_mouth: "😮", hushed: "😯", cold_sweat: "😰", scream: "😱",
162
+ astonished: "😲", flushed: "😳", sleeping: "😴", dizzy_face: "😵",
163
+ no_mouth: "😶", mask: "😷", neutral_face: "😐", expressionless: "😑",
164
+ unamused: "😒", sweat: "😓", pensive: "😔", confused: "😕", confounded: "😖",
165
+ upside_down_face: "🙃", money_mouth_face: "🤑", thinking_face: "🤔", thinking: "🤔",
166
+ zipper_mouth_face: "🤐", nerd_face: "🤓", hugs: "🤗", rolling_eyes: "🙄",
167
+ smirk: "😏", drooling_face: "🤤", lying_face: "🤥",
168
+ face_with_raised_eyebrow: "🤨", shushing_face: "🤫", face_with_hand_over_mouth: "🤭",
169
+ face_vomiting: "🤮", exploding_head: "🤯", cowboy_hat_face: "🤠",
170
+ partying_face: "🥳", disguised_face: "🥸", pleading_face: "🥺",
171
+ skull: "💀", skull_and_crossbones: "☠️", ghost: "👻", alien: "👽",
172
+ robot: "🤖", poop: "💩", hankey: "💩", clown_face: "🤡",
173
+
174
+ // hearts
175
+ heart: "❤️", red_heart: "❤️", orange_heart: "🧡", yellow_heart: "💛",
176
+ green_heart: "💚", blue_heart: "💙", purple_heart: "💜", black_heart: "🖤",
177
+ white_heart: "🤍", brown_heart: "🤎", broken_heart: "💔", heart_exclamation: "❣️",
178
+ two_hearts: "💕", revolving_hearts: "💞", heartbeat: "💓", heartpulse: "💗",
179
+ sparkling_heart: "💖", cupid: "💘", gift_heart: "💝", heart_decoration: "💟",
180
+
181
+ // celebration
182
+ tada: "🎉", confetti_ball: "🎊", balloon: "🎈", birthday: "🎂", gift: "🎁",
183
+ trophy: "🏆", medal_military: "🎖️", medal_sports: "🏅",
184
+ first_place_medal: "🥇", second_place_medal: "🥈", third_place_medal: "🥉",
185
+
186
+ // symbols
187
+ fire: "🔥", sparkles: "✨", star: "⭐", star2: "🌟", dizzy: "💫",
188
+ boom: "💥", collision: "💥", zap: "⚡", lightning: "⚡",
189
+ snowflake: "❄️", cloud: "☁️", sunny: "☀️", rainbow: "🌈",
190
+ rocket: "🚀", airplane: "✈️", 100: "💯",
191
+ check: "✅", white_check_mark: "✅", ballot_box_with_check: "☑️", heavy_check_mark: "✔️",
192
+ x: "❌", cross_mark: "❌", negative_squared_cross_mark: "❎",
193
+ warning: "⚠️", exclamation: "❗", question: "❓",
194
+ grey_exclamation: "❕", grey_question: "❔", bangbang: "‼️", interrobang: "⁉️",
195
+ bulb: "💡", memo: "📝", pencil: "✏️", pencil2: "✏️", pen: "🖊️",
196
+ lock: "🔒", unlock: "🔓", key: "🔑", bell: "🔔", no_bell: "🔕",
197
+ bookmark: "🔖", link: "🔗", paperclip: "📎", pushpin: "📌", scissors: "✂️",
198
+ file_folder: "📁", open_file_folder: "📂", page_facing_up: "📄", page_with_curl: "📃",
199
+ calendar: "📅", date: "📅", clipboard: "📋",
200
+ chart_with_upwards_trend: "📈", chart_with_downwards_trend: "📉", bar_chart: "📊",
201
+ email: "📧", envelope: "✉️", inbox_tray: "📥", outbox_tray: "📤",
202
+ package: "📦", mailbox: "📫", speech_balloon: "💬", thought_balloon: "💭",
203
+ mag: "🔍", mag_right: "🔎", gear: "⚙️", wrench: "🔧", hammer: "🔨",
204
+ hammer_and_wrench: "🛠️", tools: "🛠️", nut_and_bolt: "🔩", shield: "🛡️",
205
+ hourglass: "⌛", hourglass_flowing_sand: "⏳", watch: "⌚", alarm_clock: "⏰",
206
+ stopwatch: "⏱️", timer_clock: "⏲️",
207
+
208
+ // animals
209
+ see_no_evil: "🙈", hear_no_evil: "🙉", speak_no_evil: "🙊",
210
+ monkey: "🐒", monkey_face: "🐵", dog: "🐕", dog2: "🐕", cat: "🐈", cat2: "🐈",
211
+ tiger: "🐅", tiger2: "🐅", lion: "🦁", horse: "🐴", unicorn: "🦄",
212
+ cow: "🐄", cow2: "🐄", pig: "🐷", pig2: "🐷", chicken: "🐔", penguin: "🐧",
213
+ bird: "🐦", eagle: "🦅", duck: "🦆", owl: "🦉", bat: "🦇", wolf: "🐺",
214
+ fox_face: "🦊", bear: "🐻", panda_face: "🐼", koala: "🐨",
215
+ rabbit: "🐰", rabbit2: "🐇", mouse: "🐭", mouse2: "🐁", rat: "🐀", hamster: "🐹",
216
+ frog: "🐸", snake: "🐍", turtle: "🐢", lizard: "🦎", dragon: "🐉", dragon_face: "🐲",
217
+ whale: "🐋", whale2: "🐋", dolphin: "🐬", fish: "🐟", tropical_fish: "🐠",
218
+ blowfish: "🐡", shark: "🦈", octopus: "🐙", crab: "🦀", lobster: "🦞",
219
+ shrimp: "🦐", squid: "🦑", snail: "🐌", butterfly: "🦋", bug: "🐛",
220
+ ant: "🐜", bee: "🐝", honeybee: "🐝", spider: "🕷️", spider_web: "🕸️",
221
+
222
+ // food & drink
223
+ apple: "🍎", green_apple: "🍏", pear: "🍐", tangerine: "🍊", orange: "🍊",
224
+ lemon: "🍋", banana: "🍌", watermelon: "🍉", grapes: "🍇", strawberry: "🍓",
225
+ peach: "🍑", cherries: "🍒", pizza: "🍕", hamburger: "🍔", fries: "🍟",
226
+ hotdog: "🌭", sandwich: "🥪", taco: "🌮", burrito: "🌯", egg: "🥚", cooking: "🍳",
227
+ cake: "🍰", cookie: "🍪", chocolate_bar: "🍫", candy: "🍬", lollipop: "🍭",
228
+ ice_cream: "🍨", icecream: "🍦", doughnut: "🍩",
229
+ coffee: "☕", tea: "🍵", beer: "🍺", beers: "🍻", wine_glass: "🍷",
230
+ cocktail: "🍸", tropical_drink: "🍹", champagne: "🍾",
231
+
232
+ // objects
233
+ computer: "💻", keyboard: "⌨️", desktop_computer: "🖥️", printer: "🖨️",
234
+ mouse_three_button: "🖱️", trackball: "🖲️", joystick: "🕹️", video_game: "🎮",
235
+ phone: "📱", iphone: "📱", telephone: "☎️", telephone_receiver: "📞",
236
+ battery: "🔋", electric_plug: "🔌", camera: "📷", camera_flash: "📸",
237
+ video_camera: "📹", movie_camera: "🎥", film_projector: "📽️", tv: "📺",
238
+ radio: "📻", microphone: "🎤", headphones: "🎧", musical_note: "🎵", notes: "🎶",
239
+ art: "🎨", performing_arts: "🎭", tickets: "🎟️", clapper: "🎬",
240
+ books: "📚", book: "📖", notebook: "📓", newspaper: "📰", scroll: "📜",
241
+ moneybag: "💰", dollar: "💵", credit_card: "💳", gem: "💎", ring: "💍",
242
+ crown: "👑", tophat: "🎩", necktie: "👔", shirt: "👕", jeans: "👖",
243
+ dress: "👗", lipstick: "💄", kiss: "💋", footprints: "👣",
244
+
245
+ // arrows
246
+ arrow_up: "⬆️", arrow_down: "⬇️", arrow_left: "⬅️", arrow_right: "➡️",
247
+ arrow_upper_left: "↖️", arrow_upper_right: "↗️", arrow_lower_left: "↙️", arrow_lower_right: "↘️",
248
+ left_right_arrow: "↔️", arrow_up_down: "↕️",
249
+ arrows_counterclockwise: "🔄", arrows_clockwise: "🔃",
250
+ rewind: "⏪", fast_forward: "⏩", play_or_pause_button: "⏯️",
251
+ pause_button: "⏸️", stop_button: "⏹️", record_button: "⏺️",
252
+
253
+ // numbers
254
+ zero: "0️⃣", one: "1️⃣", two: "2️⃣", three: "3️⃣", four: "4️⃣",
255
+ five: "5️⃣", six: "6️⃣", seven: "7️⃣", eight: "8️⃣", nine: "9️⃣", keycap_ten: "🔟",
256
+ };
257
+
258
+ export function shortcodeToEmoji(shortcode: string): string {
259
+ return EMOJI_MAP[shortcode] ?? `:${shortcode}:`;
260
+ }
261
+
262
+ export function formatReactions(reactions: { emoji: string; count: number }[]): string {
263
+ if (reactions.length === 0) return "";
264
+ return reactions.map((r) => {
265
+ const emoji = shortcodeToEmoji(r.emoji);
266
+ return `${emoji}${r.count > 1 ? r.count : ""}`;
267
+ }).join(" ");
268
+ }
269
+
270
+ export function wrapText(text: string, width: number, indent: string): string[] {
271
+ const lines: string[] = [];
272
+ const paragraphs = text.split(/\n/);
273
+
274
+ for (const paragraph of paragraphs) {
275
+ if (paragraph.trim() === "") {
276
+ lines.push("");
277
+ continue;
278
+ }
279
+
280
+ const words = paragraph.split(/\s+/);
281
+ let currentLine = "";
282
+
283
+ for (const word of words) {
284
+ if (currentLine.length + word.length + 1 <= width) {
285
+ currentLine += (currentLine ? " " : "") + word;
286
+ } else {
287
+ if (currentLine) lines.push(indent + currentLine);
288
+ currentLine = word;
289
+ }
290
+ }
291
+ if (currentLine) lines.push(indent + currentLine);
292
+ }
293
+
294
+ return lines;
295
+ }
296
+
297
+ function getActorName(comment: Comment): string {
298
+ return comment.externalUser ?? comment.user ?? comment.botActor ?? "unknown";
299
+ }
300
+
301
+ function getSourceLabel(comment: Comment): string {
302
+ const sync = comment.syncedWith[0];
303
+ if (!sync) return "";
304
+ const serviceName = sync.service.charAt(0).toUpperCase() + sync.service.slice(1).toLowerCase();
305
+ return ` via ${serviceName}`;
306
+ }
307
+
308
+ function getSyncChannelName(comment: Comment): string | undefined {
309
+ const sync = comment.syncedWith[0];
310
+ if (!sync) return undefined;
311
+
312
+ if (sync.meta.type === "slack") {
313
+ return sync.meta.channelName;
314
+ }
315
+ if (sync.meta.type === "github" && sync.meta.repo) {
316
+ return sync.meta.owner ? `${sync.meta.owner}/${sync.meta.repo}` : sync.meta.repo;
317
+ }
318
+ if (sync.meta.type === "jira" && sync.meta.issueKey) {
319
+ return sync.meta.issueKey;
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+ function formatCommentHeader(
325
+ comment: Comment,
326
+ isThreadRoot: boolean,
327
+ replyCount?: number,
328
+ threadUrl?: string
329
+ ): string {
330
+ const sync = comment.syncedWith[0];
331
+ const time = formatRelativeTime(comment.createdAt);
332
+
333
+ if (isThreadRoot && sync) {
334
+ const channelName = getSyncChannelName(comment);
335
+ const channelPart = channelName ? ` in #${chalk.white(channelName)}` : "";
336
+ const serviceName = sync.service.charAt(0).toUpperCase() + sync.service.slice(1).toLowerCase();
337
+ let header = `${chalk.white(serviceName)} thread connected${channelPart} ${chalk.dim(time)}`;
338
+
339
+ if (replyCount && replyCount > 3 && threadUrl) {
340
+ header += `\n└ ${chalk.dim(`${replyCount - 3} previous replies,`)} [view all](${threadUrl})`;
341
+ }
342
+ return header;
343
+ }
344
+
345
+ const actor = chalk.white(`@${getActorName(comment)}`);
346
+ const source = chalk.dim(getSourceLabel(comment));
347
+ return `${actor} ${chalk.dim(time)}${source}`;
348
+ }
349
+
350
+ function outputSingleComment(comment: Comment, indent: string): void {
351
+ const bodyLines = wrapText(comment.body.trim(), 60, indent + "└ ");
352
+ for (const line of bodyLines) {
353
+ console.log(line);
354
+ }
355
+
356
+ const reactions = formatReactions(comment.reactions);
357
+ if (reactions) {
358
+ console.log(`${indent}└ ${chalk.dim(`[${reactions}]`)}`);
359
+ }
360
+ }
361
+
362
+ function renderCommentRecursive(
363
+ comment: Comment,
364
+ childMap: Map<string | null, Comment[]>,
365
+ depth: number,
366
+ maxRepliesPerLevel: number
367
+ ): void {
368
+ const indent = " ".repeat(depth);
369
+ const header = formatCommentHeader(comment, false);
370
+ console.log(`${indent}└ ${header}`);
371
+ outputSingleComment(comment, indent + " ");
372
+
373
+ const children = childMap.get(comment.id) ?? [];
374
+ const recentChildren = children.slice(-maxRepliesPerLevel);
375
+
376
+ for (const child of recentChildren) {
377
+ renderCommentRecursive(child, childMap, depth + 1, maxRepliesPerLevel);
378
+ }
379
+ }
380
+
381
+ export function outputCommentThreads(comments: Comment[], maxThreads = 3): void {
382
+ if (comments.length === 0) {
383
+ console.log(chalk.dim("no comments"));
384
+ return;
385
+ }
386
+
387
+ const childMap = buildChildMap(comments);
388
+ const rootComments = childMap.get(null) ?? [];
389
+ const recentRoots = rootComments.slice(-maxThreads);
390
+
391
+ for (let i = 0; i < recentRoots.length; i++) {
392
+ const root = recentRoots[i];
393
+ if (!root) continue;
394
+
395
+ const children = childMap.get(root.id) ?? [];
396
+ const totalReplies = children.length;
397
+ const threadUrl = root.url;
398
+ const hasSync = root.syncedWith.length > 0;
399
+
400
+ console.log(formatCommentHeader(root, true, totalReplies, threadUrl));
401
+
402
+ if (!hasSync) {
403
+ outputSingleComment(root, "");
404
+ }
405
+
406
+ const recentChildren = children.slice(-3);
407
+ for (const child of recentChildren) {
408
+ renderCommentRecursive(child, childMap, 0, 3);
409
+ }
410
+
411
+ if (i < recentRoots.length - 1) {
412
+ console.log();
413
+ }
414
+ }
415
+ }
@@ -1,4 +1,4 @@
1
- import type { Command } from "commander";
1
+ import { z } from "zod";
2
2
  import {
3
3
  setApiKey,
4
4
  clearApiKey,
@@ -6,22 +6,29 @@ import {
6
6
  createClientWithKey,
7
7
  getViewer,
8
8
  } from "@bdsqqq/lnr-core";
9
+ import { router, procedure } from "./trpc";
9
10
  import { exitWithError, EXIT_CODES } from "../lib/error";
10
11
 
11
- export function registerAuthCommand(program: Command): void {
12
- program
13
- .command("auth [api-key]")
14
- .description("authenticate with Linear API")
15
- .option("--whoami", "show current authenticated user")
16
- .option("--logout", "clear stored credentials")
17
- .action(async (apiKey: string | undefined, options: { whoami?: boolean; logout?: boolean }) => {
18
- if (options.logout) {
12
+ const authInput = z.object({
13
+ apiKey: z.string().optional().meta({ positional: true }).describe("Linear API key"),
14
+ whoami: z.boolean().optional().describe("show current authenticated user"),
15
+ logout: z.boolean().optional().describe("clear stored credentials"),
16
+ });
17
+
18
+ export const authRouter = router({
19
+ auth: procedure
20
+ .meta({
21
+ description: "authenticate with Linear API",
22
+ })
23
+ .input(authInput)
24
+ .mutation(async ({ input }) => {
25
+ if (input.logout) {
19
26
  clearApiKey();
20
27
  console.log("logged out");
21
28
  return;
22
29
  }
23
30
 
24
- if (options.whoami) {
31
+ if (input.whoami) {
25
32
  const storedKey = getApiKey();
26
33
  if (!storedKey) {
27
34
  exitWithError("not authenticated", "run: lnr auth <api-key>", EXIT_CODES.AUTH_ERROR);
@@ -37,17 +44,17 @@ export function registerAuthCommand(program: Command): void {
37
44
  return;
38
45
  }
39
46
 
40
- if (!apiKey) {
47
+ if (!input.apiKey) {
41
48
  exitWithError("api key required", "usage: lnr auth <api-key>");
42
49
  }
43
50
 
44
51
  try {
45
- const client = createClientWithKey(apiKey);
52
+ const client = createClientWithKey(input.apiKey);
46
53
  const viewer = await getViewer(client);
47
- setApiKey(apiKey);
54
+ setApiKey(input.apiKey);
48
55
  console.log(`authenticated as ${viewer.name}`);
49
56
  } catch {
50
57
  exitWithError("invalid api key", "get one from: https://linear.app/settings/account/security", EXIT_CODES.AUTH_ERROR);
51
58
  }
52
- });
53
- }
59
+ }),
60
+ });