/ keet-cli / index.js
index.js
  1  #!/usr/bin/env node
  2  /**
  3   * Keet CLI - P2P Room Messaging
  4   *
  5   * A command-line interface for P2P chat rooms using Hyperswarm.
  6   *
  7   * Usage:
  8   *   keet-cli                     # Create new room
  9   *   keet-cli <invite>            # Join existing room
 10   *   keet-cli --room <name>       # Create/join named room
 11   *
 12   * Commands (in chat):
 13   *   /invite                      # Show invite code
 14   *   /peers                       # List connected peers
 15   *   /name <name>                 # Set your display name
 16   *   /bridge <url>                # Bridge messages to HTTP endpoint
 17   *   /quit                        # Exit
 18   */
 19  
 20  import Hyperswarm from 'hyperswarm'
 21  import b4a from 'b4a'
 22  import crypto from 'crypto'
 23  import readline from 'readline'
 24  import path from 'path'
 25  import os from 'os'
 26  import fs from 'fs'
 27  import { NostrBridge } from './nostr-bridge.js'
 28  
 29  // Configuration
 30  const STORAGE_BASE = path.join(os.homedir(), '.keet-cli')
 31  const DEFAULT_NAME = os.userInfo().username || 'anon'
 32  
 33  class KeetRoom {
 34    constructor(options = {}) {
 35      this.roomName = options.roomName || null
 36      this.inviteCode = options.inviteCode || null
 37      this.displayName = options.displayName || DEFAULT_NAME
 38      this.bridgeUrl = options.bridgeUrl || null
 39      this.useNostr = options.useNostr || false
 40  
 41      this.swarm = null
 42      this.topic = null
 43      this.peers = new Map()
 44      this.rl = null
 45      this.messageHistory = []
 46      this.nostr = null
 47  
 48      // Message handlers
 49      this.onMessage = options.onMessage || null
 50    }
 51  
 52    async start() {
 53      // Determine the topic for this room
 54      if (this.inviteCode) {
 55        // Decode invite code to get topic
 56        this.topic = Buffer.from(this.inviteCode, 'hex')
 57        if (this.topic.length !== 32) {
 58          throw new Error('Invalid invite code')
 59        }
 60      } else if (this.roomName) {
 61        // Derive topic from room name
 62        this.topic = crypto.createHash('sha256')
 63          .update(`keet-cli:room:${this.roomName}`)
 64          .digest()
 65      } else {
 66        // Create random topic
 67        this.topic = crypto.randomBytes(32)
 68      }
 69  
 70      // Generate invite code
 71      this.inviteCode = this.topic.toString('hex')
 72  
 73      // Initialize Nostr if enabled
 74      if (this.useNostr) {
 75        try {
 76          this.nostr = new NostrBridge({
 77            onInvite: (invite) => {
 78              console.log(`\r[nostr] Room invite received: ${invite.inviteCode.slice(0, 16)}...`)
 79              console.log(`  Join with: keet-cli ${invite.inviteCode}`)
 80              if (this.rl) this.rl.prompt(true)
 81            }
 82          })
 83          await this.nostr.init()
 84          await this.nostr.connect()
 85  
 86          // Publish our room invite
 87          await this.nostr.publishRoomInvite(this.inviteCode, this.roomName)
 88        } catch (e) {
 89          console.log(`[nostr] Failed to initialize: ${e.message}`)
 90        }
 91      }
 92  
 93      // Start swarm
 94      await this._startSwarm()
 95  
 96      // Print welcome
 97      console.log('')
 98      console.log('╔══════════════════════════════════════════════════════════╗')
 99      console.log('║                    KEET CLI - P2P Chat                   ║')
100      console.log('╚══════════════════════════════════════════════════════════╝')
101      console.log('')
102      console.log(`Room: ${this.roomName || 'Anonymous Room'}`)
103      console.log(`Your name: ${this.displayName}`)
104      console.log('')
105      console.log(`Invite code:`)
106      console.log(`  ${this.inviteCode}`)
107      console.log('')
108      console.log('Commands: /invite /peers /name <n> /bridge <url> /quit')
109      console.log('─'.repeat(60))
110      console.log('')
111  
112      // Start CLI
113      this._startCLI()
114  
115      return this
116    }
117  
118    async _startSwarm() {
119      this.swarm = new Hyperswarm()
120  
121      this.swarm.on('connection', (conn, info) => {
122        const peerId = info.publicKey.toString('hex').slice(0, 8)
123        console.log(`\r[+] Peer connected: ${peerId}`)
124        this.peers.set(peerId, { conn, info, buffer: '' })
125  
126        // Send hello
127        this._sendToPeer(conn, {
128          type: 'hello',
129          name: this.displayName
130        })
131  
132        // Handle incoming data
133        conn.on('data', (data) => {
134          this._handlePeerData(peerId, conn, data)
135        })
136  
137        conn.on('close', () => {
138          console.log(`\r[-] Peer disconnected: ${peerId}`)
139          this.peers.delete(peerId)
140          if (this.rl) this.rl.prompt(true)
141        })
142  
143        conn.on('error', (err) => {
144          // Silent - peer errors are common
145        })
146  
147        if (this.rl) this.rl.prompt(true)
148      })
149  
150      // Join the topic
151      const discovery = this.swarm.join(this.topic, { server: true, client: true })
152      await discovery.flushed()
153    }
154  
155    _handlePeerData(peerId, conn, data) {
156      const peer = this.peers.get(peerId)
157      if (!peer) return
158  
159      // Buffer incoming data (messages may be split)
160      peer.buffer += data.toString()
161  
162      // Process complete JSON messages (newline-delimited)
163      const lines = peer.buffer.split('\n')
164      peer.buffer = lines.pop() // Keep incomplete line in buffer
165  
166      for (const line of lines) {
167        if (!line.trim()) continue
168  
169        try {
170          const msg = JSON.parse(line)
171          this._handleMessage(peerId, msg)
172        } catch (e) {
173          // Ignore malformed messages
174        }
175      }
176    }
177  
178    _handleMessage(peerId, msg) {
179      switch (msg.type) {
180        case 'hello':
181          const peer = this.peers.get(peerId)
182          if (peer) {
183            peer.name = msg.name
184          }
185          console.log(`\r* ${msg.name || peerId} joined`)
186          break
187  
188        case 'message':
189          const time = new Date(msg.ts).toLocaleTimeString()
190          const sender = msg.name || peerId
191          console.log(`\r[${time}] <${sender}> ${msg.text}`)
192  
193          // Store in history
194          this.messageHistory.push(msg)
195  
196          // Bridge if configured
197          if (this.bridgeUrl) {
198            this._bridgeMessage(msg)
199          }
200  
201          // Callback if set
202          if (this.onMessage) {
203            this.onMessage(msg)
204          }
205          break
206  
207        case 'history-request':
208          // Send recent history to new peer
209          const conn = this.peers.get(peerId)?.conn
210          if (conn) {
211            for (const m of this.messageHistory.slice(-20)) {
212              this._sendToPeer(conn, { type: 'history', ...m })
213            }
214          }
215          break
216  
217        case 'history':
218          // Received history message
219          const htime = new Date(msg.ts).toLocaleTimeString()
220          const hsender = msg.name || 'anon'
221          console.log(`\r[${htime}] <${hsender}> ${msg.text} (history)`)
222          break
223      }
224  
225      if (this.rl) this.rl.prompt(true)
226    }
227  
228    _sendToPeer(conn, msg) {
229      try {
230        conn.write(JSON.stringify(msg) + '\n')
231      } catch (e) {
232        // Ignore send errors
233      }
234    }
235  
236    async _bridgeMessage(msg) {
237      try {
238        const response = await fetch(this.bridgeUrl, {
239          method: 'POST',
240          headers: { 'Content-Type': 'application/json' },
241          body: JSON.stringify({
242            type: 'keet_message',
243            room: this.roomName || this.inviteCode?.slice(0, 16),
244            sender: msg.name,
245            text: msg.text,
246            ts: msg.ts
247          })
248        })
249        if (!response.ok) {
250          console.log(`\r[bridge] Failed: ${response.status}`)
251        }
252      } catch (e) {
253        console.log(`\r[bridge] Error: ${e.message}`)
254      }
255    }
256  
257    broadcast(msg) {
258      const data = JSON.stringify(msg) + '\n'
259      for (const [peerId, peer] of this.peers) {
260        try {
261          peer.conn.write(data)
262        } catch (e) {
263          // Ignore
264        }
265      }
266    }
267  
268    async sendMessage(text) {
269      const msg = {
270        type: 'message',
271        name: this.displayName,
272        text: text,
273        ts: Date.now()
274      }
275  
276      // Store locally
277      this.messageHistory.push(msg)
278  
279      // Broadcast to peers
280      this.broadcast(msg)
281  
282      // Show locally
283      const time = new Date(msg.ts).toLocaleTimeString()
284      console.log(`\r[${time}] <${this.displayName}> ${text}`)
285  
286      // Bridge if configured
287      if (this.bridgeUrl) {
288        this._bridgeMessage(msg)
289      }
290    }
291  
292    _startCLI() {
293      this.rl = readline.createInterface({
294        input: process.stdin,
295        output: process.stdout,
296        prompt: '> '
297      })
298  
299      this.rl.prompt()
300  
301      this.rl.on('line', async (line) => {
302        const trimmed = line.trim()
303  
304        if (!trimmed) {
305          this.rl.prompt()
306          return
307        }
308  
309        // Handle commands
310        if (trimmed.startsWith('/')) {
311          await this._handleCommand(trimmed)
312        } else {
313          // Send message
314          await this.sendMessage(trimmed)
315        }
316  
317        this.rl.prompt()
318      })
319  
320      this.rl.on('close', async () => {
321        await this.stop()
322        process.exit(0)
323      })
324    }
325  
326    async _handleCommand(cmd) {
327      const parts = cmd.split(' ')
328      const command = parts[0].toLowerCase()
329  
330      switch (command) {
331        case '/invite':
332          console.log('')
333          console.log(`Invite code:`)
334          console.log(`  ${this.inviteCode}`)
335          console.log('')
336          console.log('Share this with others to join the room.')
337          console.log('They can join with: keet-cli <invite-code>')
338          console.log('')
339          break
340  
341        case '/peers':
342          console.log('')
343          console.log(`Connected peers: ${this.peers.size}`)
344          for (const [id, peer] of this.peers) {
345            const name = peer.name || id
346            console.log(`  - ${name} (${id})`)
347          }
348          console.log('')
349          break
350  
351        case '/name':
352          if (parts[1]) {
353            this.displayName = parts.slice(1).join(' ')
354            console.log(`\rName set to: ${this.displayName}`)
355  
356            // Announce to peers
357            this.broadcast({
358              type: 'hello',
359              name: this.displayName
360            })
361          } else {
362            console.log(`Current name: ${this.displayName}`)
363            console.log('Usage: /name <your-name>')
364          }
365          break
366  
367        case '/bridge':
368          if (parts[1] === 'off') {
369            this.bridgeUrl = null
370            console.log('\rBridge disabled.')
371          } else if (parts[1]) {
372            this.bridgeUrl = parts[1]
373            console.log(`\rBridge URL set: ${this.bridgeUrl}`)
374            console.log('Messages will be forwarded to this endpoint.')
375          } else if (this.bridgeUrl) {
376            console.log(`Current bridge: ${this.bridgeUrl}`)
377            console.log('Use /bridge <url> to change, or /bridge off to disable')
378          } else {
379            console.log('No bridge configured.')
380            console.log('Usage: /bridge http://localhost:7777/event')
381          }
382          break
383  
384        case '/history':
385          // Request history from peers
386          this.broadcast({ type: 'history-request' })
387          console.log('Requested history from peers...')
388          break
389  
390        case '/nostr':
391          // Share room via Nostr
392          if (!this.nostr) {
393            console.log('Initializing Nostr...')
394            try {
395              this.nostr = new NostrBridge()
396              await this.nostr.init()
397              await this.nostr.connect()
398            } catch (e) {
399              console.log(`Failed: ${e.message}`)
400              break
401            }
402          }
403          await this.nostr.publishRoomInvite(this.inviteCode, this.roomName)
404          console.log('')
405          console.log('Room invite published to Nostr!')
406          console.log(`Your npub: ${this.nostr.getNpub()}`)
407          console.log('Others can receive it by following your npub.')
408          console.log('')
409          break
410  
411        case '/quit':
412        case '/exit':
413          console.log('\rGoodbye!')
414          await this.stop()
415          process.exit(0)
416          break
417  
418        case '/help':
419          console.log('')
420          console.log('Commands:')
421          console.log('  /invite          Show invite code for this room')
422          console.log('  /peers           List connected peers')
423          console.log('  /name <name>     Set your display name')
424          console.log('  /bridge <url>    Forward messages to HTTP endpoint')
425          console.log('  /bridge off      Disable bridge')
426          console.log('  /nostr           Share room invite via Nostr (encrypted DM)')
427          console.log('  /history         Request message history from peers')
428          console.log('  /quit            Exit')
429          console.log('')
430          break
431  
432        default:
433          console.log(`Unknown command: ${command}. Try /help`)
434      }
435    }
436  
437    async stop() {
438      if (this.rl) {
439        this.rl.close()
440      }
441      if (this.swarm) {
442        await this.swarm.destroy()
443      }
444    }
445  }
446  
447  // Headless mode for scripting
448  class HeadlessKeetRoom extends KeetRoom {
449    _startCLI() {
450      // Read from stdin line by line
451      const rl = readline.createInterface({
452        input: process.stdin,
453        output: process.stdout,
454        terminal: false
455      })
456  
457      rl.on('line', async (line) => {
458        const trimmed = line.trim()
459        if (trimmed && !trimmed.startsWith('/')) {
460          await this.sendMessage(trimmed)
461        } else if (trimmed.startsWith('/')) {
462          await this._handleCommand(trimmed)
463        }
464      })
465  
466      // Keep process alive
467      process.stdin.resume()
468    }
469  }
470  
471  // Daemon mode - run in background and bridge to Hypercore
472  class DaemonKeetRoom extends KeetRoom {
473    _startCLI() {
474      // No CLI - just run as daemon
475      console.log('Running in daemon mode...')
476      console.log('Messages will be bridged to:', this.bridgeUrl || '(no bridge set)')
477      console.log('Press Ctrl+C to stop.')
478  
479      // Keep process alive
480      process.stdin.resume()
481    }
482  }
483  
484  // CLI entry point
485  async function main() {
486    const args = process.argv.slice(2)
487  
488    let inviteCode = null
489    let roomName = null
490    let displayName = DEFAULT_NAME
491    let bridgeUrl = null
492    let useNostr = false
493    let mode = 'interactive'
494  
495    for (let i = 0; i < args.length; i++) {
496      const arg = args[i]
497  
498      if (arg === '--room' || arg === '-r') {
499        roomName = args[++i]
500      } else if (arg === '--name' || arg === '-n') {
501        displayName = args[++i]
502      } else if (arg === '--bridge' || arg === '-b') {
503        bridgeUrl = args[++i]
504      } else if (arg === '--nostr') {
505        useNostr = true
506      } else if (arg === '--headless') {
507        mode = 'headless'
508      } else if (arg === '--daemon' || arg === '-d') {
509        mode = 'daemon'
510      } else if (arg === '--help' || arg === '-h') {
511        console.log(`
512  Keet CLI - P2P Room Messaging
513  
514  Usage:
515    keet-cli                      Create new room
516    keet-cli <invite>             Join room by invite code
517    keet-cli --room <name>        Create/join named room
518  
519  Options:
520    -r, --room <name>     Room name (creates deterministic room)
521    -n, --name <name>     Your display name (default: ${DEFAULT_NAME})
522    -b, --bridge <url>    Bridge messages to HTTP endpoint
523    --nostr               Auto-publish room invite via Nostr encrypted DM
524    -d, --daemon          Run as daemon (no interactive CLI)
525    --headless            Run without interactive CLI (for scripting)
526    -h, --help            Show this help
527  
528  Commands (in chat):
529    /invite               Show invite code
530    /peers                List connected peers
531    /name <name>          Set display name
532    /bridge <url>         Set bridge endpoint
533    /bridge off           Disable bridge
534    /nostr                Share room via Nostr
535    /history              Request history from peers
536    /quit                 Exit
537  
538  Examples:
539    # Create a new room
540    keet-cli --name "Rick"
541  
542    # Join existing room
543    keet-cli <64-char-hex-invite> --name "Rick"
544  
545    # Create named room (deterministic - same name = same room)
546    keet-cli --room "sovereign-os" --name "Rick"
547  
548    # Bridge messages to Hypercore daemon
549    keet-cli --room "context" --bridge http://localhost:7777/event
550  
551    # Auto-publish room invite via Nostr
552    keet-cli --room "sovereign-os" --nostr --name "Rick"
553  
554    # Run as daemon (background bridge)
555    keet-cli --room "context" --daemon --bridge http://localhost:7777/event
556  
557    # Headless mode (for scripts)
558    echo "Hello from script" | keet-cli --room "test" --headless
559  
560  Nostr Integration:
561    Room invites are encrypted and sent as DMs to yourself.
562    Your phone's Nostr client (Damus, Primal) will receive them.
563    Use /nostr in chat to manually publish, or --nostr flag to auto-publish.
564  `)
565        process.exit(0)
566      } else if (!arg.startsWith('-')) {
567        // Assume it's an invite code
568        inviteCode = arg
569      }
570    }
571  
572    let RoomClass
573    switch (mode) {
574      case 'headless':
575        RoomClass = HeadlessKeetRoom
576        break
577      case 'daemon':
578        RoomClass = DaemonKeetRoom
579        break
580      default:
581        RoomClass = KeetRoom
582    }
583  
584    const room = new RoomClass({
585      inviteCode,
586      roomName,
587      displayName,
588      bridgeUrl,
589      useNostr
590    })
591  
592    process.on('SIGINT', async () => {
593      console.log('\nShutting down...')
594      await room.stop()
595      process.exit(0)
596    })
597  
598    await room.start()
599  }
600  
601  main().catch(err => {
602    console.error('Error:', err.message)
603    process.exit(1)
604  })