@ryopc/koshi 0.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/bin/cli.js ADDED
@@ -0,0 +1,827 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // koshi – Terminal-Native Decentralized SNS
4
+ // CLI/TUI Entry Point
5
+ // License: MIT
6
+ // ============================================================================
7
+ // The `kb` command-line interface. Provides all user-facing commands
8
+ // for interacting with the koshi board: register, login, post, feed,
9
+ // follow, dm, profile, search, and more.
10
+ //
11
+ // Usage:
12
+ // kb <command> [options]
13
+ // kb --help
14
+ // ============================================================================
15
+
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import chalk from 'chalk';
21
+ import ora from 'ora';
22
+
23
+ // ============================================================================
24
+ // Constants
25
+ // ============================================================================
26
+ const CONFIG_DIR = join(homedir(), '.config', 'koshi');
27
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
28
+ const SNSRC_FILE = join(homedir(), '.snsrc');
29
+ const API_BASE = process.env.KOSHI_API_URL || 'https://koshi-api.ryopc.f5.si';
30
+ const WS_URL = process.env.KOSHI_WS_URL || 'wss://koshi-api.ryopc.f5.si';
31
+
32
+ // ============================================================================
33
+ // Helper: load configuration
34
+ // ============================================================================
35
+ function loadConfig() {
36
+ try {
37
+ if (existsSync(CONFIG_FILE)) {
38
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
39
+ }
40
+ // Fallback to legacy .snsrc
41
+ if (existsSync(SNSRC_FILE)) {
42
+ const data = readFileSync(SNSRC_FILE, 'utf-8').trim();
43
+ if (data) {
44
+ try {
45
+ return JSON.parse(data);
46
+ } catch {
47
+ // Plain text format: first line is secretKey, second line is username
48
+ const lines = data.split('\n');
49
+ if (lines.length >= 2) {
50
+ return {
51
+ secretKey: lines[0].trim(),
52
+ username: lines[1].trim(),
53
+ };
54
+ }
55
+ }
56
+ }
57
+ }
58
+ } catch {
59
+ // Config file doesn't exist or is corrupt
60
+ }
61
+ return {};
62
+ }
63
+
64
+ // ============================================================================
65
+ // Helper: save configuration
66
+ // ============================================================================
67
+ async function saveConfig(config) {
68
+ if (!existsSync(CONFIG_DIR)) {
69
+ mkdirSync(CONFIG_DIR, { recursive: true });
70
+ }
71
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
72
+ // Also save to .snsrc for legacy compatibility
73
+ writeFileSync(SNSRC_FILE, `${config.secretKey}\n${config.username}\n`, 'utf-8');
74
+ // Set restrictive permissions
75
+ try {
76
+ const { chmod } = await import('node:fs/promises');
77
+ await chmod(CONFIG_FILE, 0o600);
78
+ await chmod(SNSRC_FILE, 0o600);
79
+ } catch {
80
+ // chmod not critical
81
+ }
82
+ }
83
+
84
+ // ============================================================================
85
+ // Helper: HTTP request with auth
86
+ // ============================================================================
87
+ async function apiRequest(method, path, body = null, token = null) {
88
+ const url = `${API_BASE}${path}`;
89
+ const headers = { 'Content-Type': 'application/json' };
90
+ if (token) {
91
+ headers['Authorization'] = `Bearer ${token}`;
92
+ }
93
+
94
+ const response = await fetch(url, {
95
+ method,
96
+ headers,
97
+ body: body ? JSON.stringify(body) : null,
98
+ });
99
+
100
+ const data = await response.json();
101
+
102
+ if (!response.ok) {
103
+ throw new Error(data.message || data.error || `HTTP ${response.status}`);
104
+ }
105
+
106
+ return data;
107
+ }
108
+
109
+ // ============================================================================
110
+ // Helper: resolve username to user ID
111
+ // ============================================================================
112
+ async function resolveUsername(username, token) {
113
+ const data = await apiRequest('GET', `/api/users/${username}`, null, token);
114
+ return data.id;
115
+ }
116
+
117
+ // ============================================================================
118
+ // Command: register
119
+ // ============================================================================
120
+ async function cmdRegister(args) {
121
+ const username = args[0];
122
+ if (!username) {
123
+ console.error(chalk.red('āœ– Error: Username is required. Usage: kb register <username>'));
124
+ process.exit(1);
125
+ }
126
+
127
+ const spinner = ora('Generating ed25519 keypair...').start();
128
+
129
+ try {
130
+ // Dynamically import crypto modules
131
+ const { generateKeypair, derivePublicKey } = await import('../src/auth/ed25519.js');
132
+ const keypair = generateKeypair();
133
+
134
+ spinner.text = 'Registering with server...';
135
+
136
+ const result = await apiRequest('POST', '/api/auth/register', {
137
+ username,
138
+ publicKey: keypair.publicKey,
139
+ });
140
+
141
+ // Store credentials
142
+ await saveConfig({
143
+ userId: result.userId,
144
+ username,
145
+ publicKey: keypair.publicKey,
146
+ secretKey: keypair.secretKey,
147
+ token: result.token,
148
+ });
149
+
150
+ spinner.succeed(chalk.green('Registration successful!'));
151
+
152
+ console.log(`\n ${chalk.bold('Username:')} ${chalk.cyan(username)}`);
153
+ console.log(` ${chalk.bold('User ID:')} ${chalk.dim(result.userId)}`);
154
+ console.log(` ${chalk.bold('Token:')} ${chalk.dim(result.token.substring(0, 40))}...`);
155
+ console.log(`\n ${chalk.dim('Keys stored in:')} ${chalk.italic(CONFIG_FILE)}`);
156
+ console.log(` ${chalk.green('āœ“')} You are now logged in.`);
157
+ } catch (err) {
158
+ spinner.fail(chalk.red(`Registration failed: ${err.message}`));
159
+ process.exit(1);
160
+ }
161
+ }
162
+
163
+ // ============================================================================
164
+ // Command: login
165
+ // ============================================================================
166
+ async function cmdLogin(args) {
167
+ const username = args[0];
168
+ if (!username) {
169
+ console.error(chalk.red('āœ– Error: Username is required. Usage: kb login <username>'));
170
+ process.exit(1);
171
+ }
172
+
173
+ const config = loadConfig();
174
+
175
+ if (!config.secretKey) {
176
+ console.error(chalk.red('āœ– Error: No secret key found.'));
177
+ console.error(chalk.dim(' Use "kb register <username>" to create a new account,'));
178
+ console.error(chalk.dim(' or import an existing key to ~/.snsrc.'));
179
+ process.exit(1);
180
+ }
181
+
182
+ const spinner = ora('Signing authentication challenge...').start();
183
+
184
+ try {
185
+ const { signMessage } = await import('../src/auth/ed25519.js');
186
+ const challenge = `koshi:login:${username}`;
187
+ const signature = await signMessage(challenge, config.secretKey);
188
+
189
+ spinner.text = 'Authenticating with server...';
190
+
191
+ const result = await apiRequest('POST', '/api/auth/login', {
192
+ username,
193
+ signature,
194
+ });
195
+
196
+ // Update stored config
197
+ config.username = username;
198
+ config.userId = result.userId;
199
+ config.token = result.token;
200
+ await saveConfig(config);
201
+
202
+ spinner.succeed(chalk.green('Login successful!'));
203
+
204
+ console.log(`\n ${chalk.bold('Username:')} ${chalk.cyan(username)}`);
205
+ console.log(` ${chalk.bold('Token:')} ${chalk.dim(result.token.substring(0, 40))}...`);
206
+ } catch (err) {
207
+ spinner.fail(chalk.red(`Login failed: ${err.message}`));
208
+ process.exit(1);
209
+ }
210
+ }
211
+
212
+ // ============================================================================
213
+ // Command: whoami
214
+ // ============================================================================
215
+ async function cmdWhoami() {
216
+ const config = loadConfig();
217
+
218
+ if (!config.token || !config.username) {
219
+ console.error(chalk.red('āœ– Not logged in. Use "kb login <username>" or "kb register <username>".'));
220
+ process.exit(1);
221
+ }
222
+
223
+ const spinner = ora('Fetching profile...').start();
224
+
225
+ try {
226
+ const data = await apiRequest('GET', `/api/users/${config.username}`, null, config.token);
227
+ spinner.stop();
228
+
229
+ console.log(`\n ${chalk.bold('Username:')} ${chalk.cyan(data.username)}`);
230
+ if (data.displayName) console.log(` ${chalk.bold('Display Name:')} ${data.displayName}`);
231
+ if (data.bio) console.log(` ${chalk.bold('Bio:')} ${data.bio}`);
232
+ console.log(` ${chalk.bold('Followers:')} ${data.followersCount}`);
233
+ console.log(` ${chalk.bold('Following:')} ${data.followingCount}`);
234
+ console.log(` ${chalk.bold('Joined:')} ${new Date(data.createdAt).toLocaleDateString()}`);
235
+ } catch (err) {
236
+ spinner.fail(chalk.red(`Failed to fetch profile: ${err.message}`));
237
+ process.exit(1);
238
+ }
239
+ }
240
+
241
+ // ============================================================================
242
+ // Command: post
243
+ // ============================================================================
244
+ async function cmdPost(args) {
245
+ const content = args.join(' ');
246
+ if (!content) {
247
+ console.error(chalk.red('āœ– Error: Content is required. Usage: kb post <message>'));
248
+ process.exit(1);
249
+ }
250
+
251
+ if (content.length > 2000) {
252
+ console.error(chalk.red(`āœ– Error: Content exceeds 2000 characters (${content.length}).`));
253
+ process.exit(1);
254
+ }
255
+
256
+ const config = loadConfig();
257
+
258
+ if (!config.token || !config.secretKey) {
259
+ console.error(chalk.red('āœ– Not logged in. Use "kb login <username>" or "kb register <username>".'));
260
+ process.exit(1);
261
+ }
262
+
263
+ const spinner = ora('Signing and posting...').start();
264
+
265
+ try {
266
+ const { signMessage } = await import('../src/auth/ed25519.js');
267
+ const signature = await signMessage(content, config.secretKey);
268
+
269
+ spinner.text = 'Submitting to koshi board...';
270
+
271
+ const result = await apiRequest('POST', '/api/posts', {
272
+ content,
273
+ signature,
274
+ }, config.token);
275
+
276
+ spinner.succeed(chalk.green('Post created!'));
277
+
278
+ console.log(`\n ${chalk.dim('ID:')} ${chalk.dim(result.id)}`);
279
+ console.log(` ${chalk.dim('Posted:')} ${new Date(result.createdAt).toLocaleString()}`);
280
+ } catch (err) {
281
+ spinner.fail(chalk.red(`Failed to post: ${err.message}`));
282
+ process.exit(1);
283
+ }
284
+ }
285
+
286
+ // ============================================================================
287
+ // Command: feed
288
+ // ============================================================================
289
+ async function cmdFeed(args) {
290
+ const config = loadConfig();
291
+ const limit = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1]) || 20;
292
+ const flags = args.filter((a) => !a.startsWith('--'));
293
+
294
+ const spinner = ora('Fetching feed...').start();
295
+
296
+ try {
297
+ const data = await apiRequest('GET', `/api/posts/feed?limit=${limit}`, null, config.token || null);
298
+ spinner.stop();
299
+
300
+ if (data.length === 0) {
301
+ console.log(chalk.dim('\n No posts in your feed. Follow some users or be the first to post!'));
302
+ console.log(chalk.dim(' Try: kb post "Hello, koshi!"'));
303
+ return;
304
+ }
305
+
306
+ console.log(`\n ${chalk.bold.cyan('šŸ“‹ Koshi Board Feed')} ${chalk.dim(`(${data.length} posts)`)}`);
307
+ console.log(` ${chalk.dim('─'.repeat(60))}\n`);
308
+
309
+ for (const post of data) {
310
+ const displayName = post.author.displayName || post.author.username;
311
+ const time = new Date(post.createdAt).toLocaleString();
312
+
313
+ console.log(` ${chalk.bold(displayName)} ${chalk.dim(`@${post.author.username}`)}`);
314
+ console.log(` ${chalk.dim(time)}`);
315
+ console.log(` ${post.content}`);
316
+ console.log(` ${chalk.dim('─'.repeat(60))}\n`);
317
+ }
318
+
319
+ if (data.length === limit) {
320
+ console.log(chalk.dim(` Use kb feed --limit=${limit + 20} to see more.`));
321
+ }
322
+ } catch (err) {
323
+ spinner.fail(chalk.red(`Failed to fetch feed: ${err.message}`));
324
+ process.exit(1);
325
+ }
326
+ }
327
+
328
+ // ============================================================================
329
+ // Command: follow
330
+ // ============================================================================
331
+ async function cmdFollow(args) {
332
+ const username = args[0];
333
+ if (!username) {
334
+ console.error(chalk.red('āœ– Error: Username is required. Usage: kb follow <username>'));
335
+ process.exit(1);
336
+ }
337
+
338
+ const config = loadConfig();
339
+ if (!config.token) {
340
+ console.error(chalk.red('āœ– Not logged in.'));
341
+ process.exit(1);
342
+ }
343
+
344
+ const spinner = ora(`Resolving @${username}...`).start();
345
+
346
+ try {
347
+ const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
348
+ spinner.text = `Following @${username}...`;
349
+
350
+ await apiRequest('POST', `/api/users/${userData.id}/follow`, {}, config.token);
351
+
352
+ spinner.succeed(chalk.green(`You are now following @${username}!`));
353
+ } catch (err) {
354
+ spinner.fail(chalk.red(`Failed to follow: ${err.message}`));
355
+ process.exit(1);
356
+ }
357
+ }
358
+
359
+ // ============================================================================
360
+ // Command: unfollow
361
+ // ============================================================================
362
+ async function cmdUnfollow(args) {
363
+ const username = args[0];
364
+ if (!username) {
365
+ console.error(chalk.red('āœ– Error: Username is required. Usage: kb unfollow <username>'));
366
+ process.exit(1);
367
+ }
368
+
369
+ const config = loadConfig();
370
+ if (!config.token) {
371
+ console.error(chalk.red('āœ– Not logged in.'));
372
+ process.exit(1);
373
+ }
374
+
375
+ const spinner = ora(`Resolving @${username}...`).start();
376
+
377
+ try {
378
+ const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
379
+ spinner.text = `Unfollowing @${username}...`;
380
+
381
+ await apiRequest('DELETE', `/api/users/${userData.id}/follow`, {}, config.token);
382
+
383
+ spinner.succeed(chalk.yellow(`Unfollowed @${username}.`));
384
+ } catch (err) {
385
+ spinner.fail(chalk.red(`Failed to unfollow: ${err.message}`));
386
+ process.exit(1);
387
+ }
388
+ }
389
+
390
+ // ============================================================================
391
+ // Command: dm
392
+ // ============================================================================
393
+ async function cmdDm(args) {
394
+ const username = args[0];
395
+ const message = args.slice(1).join(' ');
396
+
397
+ if (!username || !message) {
398
+ console.error(chalk.red('āœ– Error: Usage: kb dm <username> <message>'));
399
+ process.exit(1);
400
+ }
401
+
402
+ const config = loadConfig();
403
+ if (!config.token || !config.secretKey) {
404
+ console.error(chalk.red('āœ– Not logged in.'));
405
+ process.exit(1);
406
+ }
407
+
408
+ const spinner = ora(`Sending DM to @${username}...`).start();
409
+
410
+ try {
411
+ // Resolve recipient
412
+ const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
413
+
414
+ // Sign message
415
+ const { signMessage } = await import('../src/auth/ed25519.js');
416
+ const signature = await signMessage(message, config.secretKey);
417
+
418
+ spinner.text = 'Encrypting and sending...';
419
+
420
+ const result = await apiRequest('POST', `/api/dms/${userData.id}`, {
421
+ content: message,
422
+ signature,
423
+ }, config.token);
424
+
425
+ spinner.succeed(chalk.green(`DM sent to @${username}!`));
426
+ console.log(` ${chalk.dim('ID:')} ${chalk.dim(result.id)}`);
427
+ } catch (err) {
428
+ spinner.fail(chalk.red(`Failed to send DM: ${err.message}`));
429
+ process.exit(1);
430
+ }
431
+ }
432
+
433
+ // ============================================================================
434
+ // Command: dms
435
+ // ============================================================================
436
+ async function cmdDms(args) {
437
+ const config = loadConfig();
438
+ if (!config.token) {
439
+ console.error(chalk.red('āœ– Not logged in.'));
440
+ process.exit(1);
441
+ }
442
+
443
+ const unreadOnly = args.includes('--unread');
444
+ const limit = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1]) || 50;
445
+
446
+ const spinner = ora('Fetching DMs...').start();
447
+
448
+ try {
449
+ const data = await apiRequest(
450
+ 'GET',
451
+ `/api/dms?limit=${limit}${unreadOnly ? '&unread=true' : ''}`,
452
+ null,
453
+ config.token
454
+ );
455
+ spinner.stop();
456
+
457
+ if (data.length === 0) {
458
+ console.log(chalk.dim('\n No messages in your inbox.'));
459
+ if (!unreadOnly) {
460
+ console.log(chalk.dim(' To send a DM: kb dm <username> <message>'));
461
+ }
462
+ return;
463
+ }
464
+
465
+ const title = unreadOnly ? 'šŸ“Ø Unread Messages' : 'šŸ“Ø Direct Messages';
466
+ console.log(`\n ${chalk.bold.cyan(title)} ${chalk.dim(`(${data.length} messages)`)}`);
467
+ console.log(` ${chalk.dim('─'.repeat(60))}\n`);
468
+
469
+ for (const dm of data) {
470
+ const isFromMe = dm.from.id === config.userId;
471
+ const displayName = isFromMe ? dm.to.displayName || dm.to.username : dm.from.displayName || dm.from.username;
472
+ const handle = isFromMe ? `@${dm.to.username}` : `@${dm.from.username}`;
473
+ const time = new Date(dm.createdAt).toLocaleString();
474
+ const readStatus = dm.isRead ? '' : chalk.yellow(' ā—');
475
+
476
+ if (isFromMe) {
477
+ console.log(` ${chalk.dim('→ To:')} ${chalk.bold(displayName)} ${chalk.dim(handle)}${readStatus}`);
478
+ } else {
479
+ console.log(` ${chalk.dim('← From:')} ${chalk.bold(displayName)} ${chalk.dim(handle)}${readStatus}`);
480
+ }
481
+ console.log(` ${chalk.dim(time)}`);
482
+ console.log(` ${dm.content}`);
483
+ console.log(` ${chalk.dim('─'.repeat(60))}\n`);
484
+ }
485
+ } catch (err) {
486
+ spinner.fail(chalk.red(`Failed to fetch DMs: ${err.message}`));
487
+ process.exit(1);
488
+ }
489
+ }
490
+
491
+ // ============================================================================
492
+ // Command: profile
493
+ // ============================================================================
494
+ async function cmdProfile(args) {
495
+ const config = loadConfig();
496
+ const targetUsername = args[0] || config.username;
497
+
498
+ if (!targetUsername) {
499
+ console.error(chalk.red('āœ– Error: No username specified and not logged in.'));
500
+ process.exit(1);
501
+ }
502
+
503
+ const spinner = ora(`Fetching profile @${targetUsername}...`).start();
504
+
505
+ try {
506
+ const data = await apiRequest('GET', `/api/users/${targetUsername}`, null, config.token || null);
507
+ spinner.stop();
508
+
509
+ console.log(`\n ${chalk.bold.cyan('šŸ‘¤ Profile')}`);
510
+ console.log(` ${chalk.dim('─'.repeat(40))}`);
511
+ console.log(` ${chalk.bold('Username:')} ${chalk.cyan(data.username)}`);
512
+ if (data.displayName) console.log(` ${chalk.bold('Display Name:')} ${data.displayName}`);
513
+ if (data.bio) console.log(` ${chalk.bold('Bio:')} ${data.bio}`);
514
+ if (data.avatarUrl) console.log(` ${chalk.bold('Avatar:')} ${chalk.dim(data.avatarUrl)}`);
515
+ console.log(` ${chalk.bold('Followers:')} ${data.followersCount}`);
516
+ console.log(` ${chalk.bold('Following:')} ${data.followingCount}`);
517
+ console.log(` ${chalk.bold('Joined:')} ${new Date(data.createdAt).toLocaleDateString()}`);
518
+ } catch (err) {
519
+ spinner.fail(chalk.red(`Failed to fetch profile: ${err.message}`));
520
+ process.exit(1);
521
+ }
522
+ }
523
+
524
+ // ============================================================================
525
+ // Command: search
526
+ // ============================================================================
527
+ async function cmdSearch(args) {
528
+ const query = args.join(' ');
529
+ if (!query || query.length < 2) {
530
+ console.error(chalk.red('āœ– Error: Search query must be at least 2 characters. Usage: kb search <query>'));
531
+ process.exit(1);
532
+ }
533
+
534
+ const config = loadConfig();
535
+ const spinner = ora(`Searching for "${query}"...`).start();
536
+
537
+ try {
538
+ const data = await apiRequest('GET', `/api/users/search/${encodeURIComponent(query)}`, null, config.token || null);
539
+ spinner.stop();
540
+
541
+ if (data.length === 0) {
542
+ console.log(chalk.dim(`\n No users found matching "${query}".`));
543
+ return;
544
+ }
545
+
546
+ console.log(`\n ${chalk.bold.cyan('šŸ” Search Results')} ${chalk.dim(`for "${query}"`)}`);
547
+ console.log(` ${chalk.dim('─'.repeat(50))}\n`);
548
+
549
+ for (const user of data) {
550
+ const displayName = user.displayName || '(no display name)';
551
+ console.log(` ${chalk.bold(user.username)} ${chalk.dim(`— ${displayName}`)}`);
552
+ }
553
+ console.log();
554
+ } catch (err) {
555
+ spinner.fail(chalk.red(`Search failed: ${err.message}`));
556
+ process.exit(1);
557
+ }
558
+ }
559
+
560
+ // ============================================================================
561
+ // Command: help
562
+ // ============================================================================
563
+ function showHelp(command = null) {
564
+ const commands = {
565
+ register: {
566
+ usage: 'kb register <username>',
567
+ desc: 'Create a new account with ed25519 keypair',
568
+ },
569
+ login: {
570
+ usage: 'kb login <username>',
571
+ desc: 'Authenticate using existing keypair',
572
+ },
573
+ whoami: {
574
+ usage: 'kb whoami',
575
+ desc: 'Show your profile information',
576
+ },
577
+ post: {
578
+ usage: 'kb post <message>',
579
+ desc: 'Create a new post on the koshi board',
580
+ },
581
+ feed: {
582
+ usage: 'kb feed [--limit=20]',
583
+ desc: 'Display your post feed',
584
+ },
585
+ follow: {
586
+ usage: 'kb follow <username>',
587
+ desc: 'Follow a user',
588
+ },
589
+ unfollow: {
590
+ usage: 'kb unfollow <username>',
591
+ desc: 'Unfollow a user',
592
+ },
593
+ dm: {
594
+ usage: 'kb dm <username> <message>',
595
+ desc: 'Send a direct message',
596
+ },
597
+ dms: {
598
+ usage: 'kb dms [--unread] [--limit=50]',
599
+ desc: 'View your direct messages',
600
+ },
601
+ profile: {
602
+ usage: 'kb profile [username]',
603
+ desc: 'View a user profile (default: your own)',
604
+ },
605
+ search: {
606
+ usage: 'kb search <query>',
607
+ desc: 'Search users by username or display name',
608
+ },
609
+ help: {
610
+ usage: 'kb help [command]',
611
+ desc: 'Show this help message',
612
+ },
613
+ };
614
+
615
+ if (command && commands[command]) {
616
+ const cmd = commands[command];
617
+ console.log(`\n ${chalk.bold.cyan(`kb ${command}`)}`);
618
+ console.log(` ${chalk.dim('─'.repeat(40))}`);
619
+ console.log(` ${chalk.bold('Usage:')} ${cmd.usage}`);
620
+ console.log(` ${chalk.bold('Description:')} ${cmd.desc}`);
621
+ console.log();
622
+ return;
623
+ }
624
+
625
+ console.log(`\n ${chalk.bold.cyan('šŸ„ koshi — Terminal-Native Decentralized SNS')}`);
626
+ console.log(` ${chalk.dim('Version 0.2.0')}`);
627
+ console.log(`\n ${chalk.bold('Usage:')} kb <command> [options]\n`);
628
+ console.log(` ${chalk.bold('Commands:')}\n`);
629
+
630
+ const maxLen = Math.max(...Object.values(commands).map((c) => c.usage.length));
631
+
632
+ for (const [name, cmd] of Object.entries(commands)) {
633
+ const padding = ' '.repeat(maxLen - cmd.usage.length + 2);
634
+ console.log(` ${chalk.cyan(cmd.usage)}${padding}${cmd.desc}`);
635
+ }
636
+
637
+ console.log(`\n ${chalk.bold('Options:')}`);
638
+ console.log(` --help, -h Show help for a command`);
639
+ console.log(` --version, -v Show version`);
640
+ console.log(`\n ${chalk.dim('Environment:')}`);
641
+ console.log(` ${chalk.dim('KOSHI_API_URL API base URL (default: https://koshi-api.ryopc.f5.si)')}`);
642
+ console.log(` ${chalk.dim('KOSHI_WS_URL WebSocket URL (default: wss://koshi-api.ryopc.f5.si)')}`);
643
+ console.log();
644
+ }
645
+
646
+ // ============================================================================
647
+ // Command: version
648
+ // ============================================================================
649
+ function showVersion() {
650
+ console.log('koshi v0.2.0');
651
+ console.log('Terminal-native decentralized SNS');
652
+ console.log('License: MIT');
653
+ console.log('Author: game_ryo');
654
+ }
655
+
656
+ // ============================================================================
657
+ // Command: realtime feed (stream mode)
658
+ // ============================================================================
659
+ async function cmdRealtime() {
660
+ const config = loadConfig();
661
+ if (!config.token) {
662
+ console.error(chalk.red('āœ– Not logged in. Use "kb login" or "kb register" first.'));
663
+ process.exit(1);
664
+ }
665
+
666
+ console.log(`\n ${chalk.bold.cyan('šŸ“” Connecting to realtime feed...')}`);
667
+ console.log(` ${chalk.dim('Press Ctrl+C to disconnect')}\n`);
668
+
669
+ try {
670
+ const { default: WebSocket } = await import('ws');
671
+ const ws = new WebSocket(`${WS_URL}/ws?token=${config.token}`);
672
+
673
+ ws.on('open', () => {
674
+ console.log(chalk.green(' āœ“ Connected! Waiting for new posts...\n'));
675
+ });
676
+
677
+ ws.on('message', (data) => {
678
+ try {
679
+ const msg = JSON.parse(data.toString());
680
+
681
+ switch (msg.type) {
682
+ case 'connected':
683
+ // Initial connection confirmation
684
+ break;
685
+
686
+ case 'post_created':
687
+ console.log(` ${chalk.bold.cyan('šŸ“ New Post')}`);
688
+ console.log(` ${chalk.bold(msg.payload.author.username)} ${chalk.dim(new Date(msg.payload.timestamp).toLocaleTimeString())}`);
689
+ console.log(` ${msg.payload.content}`);
690
+ console.log(` ${chalk.dim('─'.repeat(50))}\n`);
691
+ break;
692
+
693
+ case 'dm_received':
694
+ console.log(` ${chalk.bold.yellow('āœ‰ New DM from')} ${chalk.bold(msg.payload.from.username)}`);
695
+ console.log(` ${msg.payload.content}`);
696
+ console.log(` ${chalk.dim('─'.repeat(50))}\n`);
697
+ break;
698
+
699
+ case 'user_online':
700
+ console.log(` ${chalk.green('🟢')} ${chalk.bold(msg.payload.username)} ${chalk.dim('is online')}`);
701
+ break;
702
+
703
+ case 'user_offline':
704
+ console.log(` ${chalk.red('šŸ”“')} ${chalk.dim(`${msg.payload.userId} went offline`)}`);
705
+ break;
706
+
707
+ case 'pong':
708
+ // Ignore pong
709
+ break;
710
+
711
+ default:
712
+ console.log(` ${chalk.dim(`[${msg.type}]`)}`, msg.payload);
713
+ }
714
+ } catch {
715
+ // Ignore parse errors
716
+ }
717
+ });
718
+
719
+ ws.on('close', () => {
720
+ console.log(chalk.yellow('\n Disconnected from realtime feed.'));
721
+ process.exit(0);
722
+ });
723
+
724
+ ws.on('error', (err) => {
725
+ console.error(chalk.red(`\n WebSocket error: ${err.message}`));
726
+ process.exit(1);
727
+ });
728
+
729
+ // Keep the process alive until Ctrl+C
730
+ process.on('SIGINT', () => {
731
+ console.log(chalk.dim('\n Closing connection...'));
732
+ ws.close();
733
+ process.exit(0);
734
+ });
735
+ } catch (err) {
736
+ console.error(chalk.red(`Failed to connect: ${err.message}`));
737
+ process.exit(1);
738
+ }
739
+ }
740
+
741
+ // ============================================================================
742
+ // Main CLI dispatcher
743
+ // ============================================================================
744
+ async function main() {
745
+ const args = process.argv.slice(2);
746
+ const command = args[0];
747
+
748
+ // No args
749
+ if (!command || command === '--help' || command === '-h') {
750
+ showHelp(args[1]);
751
+ return;
752
+ }
753
+
754
+ if (command === '--version' || command === '-v') {
755
+ showVersion();
756
+ return;
757
+ }
758
+
759
+ // Dispatch commands
760
+ switch (command) {
761
+ case 'register':
762
+ await cmdRegister(args.slice(1));
763
+ break;
764
+
765
+ case 'login':
766
+ await cmdLogin(args.slice(1));
767
+ break;
768
+
769
+ case 'whoami':
770
+ await cmdWhoami();
771
+ break;
772
+
773
+ case 'post':
774
+ await cmdPost(args.slice(1));
775
+ break;
776
+
777
+ case 'feed':
778
+ await cmdFeed(args.slice(1));
779
+ break;
780
+
781
+ case 'follow':
782
+ await cmdFollow(args.slice(1));
783
+ break;
784
+
785
+ case 'unfollow':
786
+ await cmdUnfollow(args.slice(1));
787
+ break;
788
+
789
+ case 'dm':
790
+ await cmdDm(args.slice(1));
791
+ break;
792
+
793
+ case 'dms':
794
+ await cmdDms(args.slice(1));
795
+ break;
796
+
797
+ case 'profile':
798
+ await cmdProfile(args.slice(1));
799
+ break;
800
+
801
+ case 'search':
802
+ await cmdSearch(args.slice(1));
803
+ break;
804
+
805
+ case 'realtime':
806
+ case 'stream':
807
+ await cmdRealtime();
808
+ break;
809
+
810
+ case 'help':
811
+ showHelp(args[1]);
812
+ break;
813
+
814
+ default:
815
+ console.error(chalk.red(`āœ– Unknown command: "${command}"`));
816
+ console.error(chalk.dim(' Run "kb help" to see available commands.'));
817
+ process.exit(1);
818
+ }
819
+ }
820
+
821
+ main().catch((err) => {
822
+ console.error(chalk.red(`\nāœ– Fatal error: ${err.message}`));
823
+ if (process.env.DEBUG) {
824
+ console.error(err.stack);
825
+ }
826
+ process.exit(1);
827
+ });