OverviewFilesCommitsIssuesDocsReleasesPeers

keet-cli / index.js

968 lines · javascript ref:
import Pipe from 'bare-pipe'
import FramedStream from 'framed-stream'
import b4a from 'b4a'
import path from 'path'
import os from 'os'
import { getGenericEventName } from 'keet-daemon/lib/format.js'

// --- Parse Args ---
// Dual-mode: works under pear run (Pear.config.args) or bare (Bare.argv)
const rawArgs = (typeof Pear !== 'undefined' && Pear.config.args)
  ? Pear.config.args
  : Bare.argv.slice(2)

let customStorage = null
let customSocket = null
let debugMode = false
const args = []
for (let i = 0; i < rawArgs.length; i++) {
  if (rawArgs[i] === '--storage' && rawArgs[i + 1]) {
    customStorage = rawArgs[++i]
  } else if (rawArgs[i].startsWith('--storage=')) {
    customStorage = rawArgs[i].split('=')[1]
  } else if (rawArgs[i] === '--profile' && rawArgs[i + 1]) {
    const profileName = rawArgs[++i]
    customStorage = path.join(os.homedir(), '.config', 'keet-cli', profileName)
  } else if (rawArgs[i].startsWith('--profile=')) {
    const profileName = rawArgs[i].split('=')[1]
    customStorage = path.join(os.homedir(), '.config', 'keet-cli', profileName)
  } else if (rawArgs[i] === '--socket' && rawArgs[i + 1]) {
    customSocket = rawArgs[++i]
  } else if (rawArgs[i].startsWith('--socket=')) {
    customSocket = rawArgs[i].split('=')[1]
  } else if (rawArgs[i] === '--debug') {
    debugMode = true
  } else {
    args.push(rawArgs[i])
  }
}

const STORAGE = customStorage || path.join(os.homedir(), '.config', 'keet-cli')
const SOCKET = customSocket || '/tmp/keet-daemon.sock'
const command = args[0]

// --- Help ---
function printHelp () {
  console.log(`
keet-cli — Headless Keet P2P chat client (daemon + thin client)

Usage: bare index.js [options] <command> [args...]
       pear run --dev . [options] <command> [args...]

Options:
  --profile <name>          Use named profile (each profile = separate identity)
  --storage <path>          Use custom storage path
  --socket <path>           Daemon socket (default: /tmp/keet-daemon.sock)
  --debug                   Enable verbose discovery debug logging

Daemon Commands:
  daemon                    Start daemon (foreground, use tmux for background)
  daemon stop               Stop running daemon
  daemon status             Check if daemon is running

Identity:
  identity                  Show current identity
  identity recovery         Show recovery mnemonic phrase
  identity restore <phrase> Restore from mnemonic
  identity confirm          Confirm recovery phrase (onboarding step)
  identity store <phrase>   Store recovery phrase for HD key derivation
  identity register <name>  Register username (must include number: name0)
  identity check <name>     Check if username is available
  identity lookup <name>    Lookup user by username

Rooms:
  rooms                     List recent rooms
  room <roomId>             Show room info
  join <link|token>         Join room via invite link or raw token
  create <title>            Create a new room
  invite <roomId>           Create an invite for a room
  leave <roomId>            Leave a room

Room Config:
  config <roomId>           Show room config
  config <roomId> <key> <v> Set room config (title, description, etc.)

Messages:
  send <roomId> <message>   Send a chat message
  read <roomId> [count]     Read recent messages (default: 20)
  watch <roomId>            Watch for new messages (live stream)

Members:
  members <roomId>          List room members

DMs:
  dms                       List DM rooms
  dm accept <roomId>        Accept DM request
  dm reject <roomId>        Reject DM request

Files:
  files <roomId>            List local files in room
  download <roomId> <id>    Download a file

Metadata (Device Pairing):
  meta                      Show MetadataBase key info
  meta invite               Get invite key for device pairing
  meta join <key>           Pair with remote device's MetadataBase

Graph (MI Clustering):
  graph clusters            Show current clusters (default algo)
  graph edges               Show MI edges between rooms
  graph compute             Compute with default algo (members-only)
  graph compute --algo=<f>  Compute with algo file (JSON config)
  graph compute --features=topics --threshold=0.3
                            Compute with inline params

System:
  status                    Daemon status (uptime, rooms, identity)
  version                   Keet version info
  introspect [api]          List all methods on api (core|call|preferences|network|debug)
  help                      Show this help

Quick Start:
  # Terminal 1: start daemon
  bare index.js daemon

  # Terminal 2: use CLI
  bare index.js rooms
  bare index.js send abc123 "Hello!"
  bare index.js watch abc123
`)
  Bare.exit(0)
}

if (!command || command === 'help') printHelp()

// --- Daemon Mode ---
if (command === 'daemon') {
  const sub = args[1]

  if (sub === 'stop') {
    try {
      const result = await rpc('shutdown')
      if (result.ok) {
        console.log('[keet-cli] Daemon shutting down')
      } else {
        console.error('[keet-cli] Error:', result.error)
      }
    } catch (err) {
      if (err.code === 'ENOENT' || err.message.includes('ECONNREFUSED') || err.message.includes('ENOENT')) {
        console.log('[keet-cli] Daemon is not running')
      } else {
        console.error('[keet-cli] Error:', err.message)
      }
    }
    Bare.exit(0)
  }

  if (sub === 'status') {
    try {
      const result = await rpc('status')
      if (result.ok) {
        console.log('[keet-cli] Daemon is running')
        console.log(`  Uptime: ${result.data.uptime}s`)
        console.log(`  Rooms: ${result.data.rooms}`)
        if (result.data.identity) {
          console.log(`  Identity: ${result.data.identity.name || result.data.identity.memberId}`)
        }
      } else {
        console.log('[keet-cli] Daemon responded with error:', result.error)
      }
    } catch (err) {
      if (err.code === 'ENOENT' || err.message.includes('ENOENT')) {
        console.log('[keet-cli] Daemon is not running')
      } else {
        console.error('[keet-cli] Error:', err.message)
      }
    }
    Bare.exit(0)
  }

  // Start daemon (foreground)
  const { startDaemon } = await import('./daemon.js')
  await startDaemon({ storage: STORAGE, socketPath: SOCKET, debug: debugMode })
  // Keep alive
  await new Promise(() => {})
}

// ============================================================
// UDS client helpers (framed-stream JSON-RPC over Unix socket)
// ============================================================
// NOTE: Must be declared BEFORE the client switch to avoid TDZ errors

let _nextId = 1
const _pending = new Map()

function rpc (method, params) {
  return new Promise((resolve, reject) => {
    const id = _nextId++
    const msg = JSON.stringify({ id, method, params: params || {} })

    const pipe = new Pipe(SOCKET)
    const framed = new FramedStream(pipe)

    const timeout = setTimeout(() => {
      framed.destroy()
      reject(new Error('RPC timeout (5s)'))
    }, 5000)

    pipe.on('error', (err) => {
      clearTimeout(timeout)
      reject(err)
    })

    framed.on('data', (buf) => {
      try {
        const response = JSON.parse(b4a.toString(buf, 'utf8'))
        if (response.id === id) {
          clearTimeout(timeout)
          framed.destroy()
          resolve(response)
        }
      } catch {}
    })

    framed.on('error', (err) => {
      clearTimeout(timeout)
      reject(err)
    })

    framed.write(b4a.from(msg, 'utf8'))
  })
}

// --- Client Mode ---
try {
  switch (command) {
    case 'identity':
      await clientIdentity(args.slice(1))
      break
    case 'rooms':
      await clientRooms()
      break
    case 'room':
      await clientRoom(args[1])
      break
    case 'join':
      await clientJoin(args[1])
      break
    case 'create':
      await clientCreate(args.slice(1).join(' '))
      break
    case 'invite':
      await clientInvite(args[1])
      break
    case 'send':
      await clientSend(args[1], args.slice(2).join(' '))
      break
    case 'read':
      await clientRead(args[1], parseInt(args[2]) || 20)
      break
    case 'watch':
      await clientWatch(args[1])
      break
    case 'members':
      await clientMembers(args[1])
      break
    case 'leave':
      await clientLeave(args[1])
      break
    case 'status':
      await clientStatus()
      break
    case 'config':
      await clientConfig(args[1], args[2], args.slice(3).join(' '))
      break
    case 'dms':
      await clientDms()
      break
    case 'dm':
      await clientDm(args[1], args[2])
      break
    case 'files':
      await clientFiles(args[1])
      break
    case 'download':
      await clientDownload(args[1], args[2])
      break
    case 'version':
      await clientVersion()
      break
    case 'introspect':
      await clientIntrospect(args[1])
      break
    case 'meta':
      await clientMeta(args.slice(1))
      break
    case 'graph':
      await clientGraph(args.slice(1))
      break
    default:
      console.error(`Unknown command: ${command}`)
      printHelp()
  }
} catch (err) {
  if (err.code === 'ENOENT' || err.message.includes('ENOENT')) {
    console.error('[keet-cli] Daemon not running. Start with: bare index.js daemon')
  } else {
    console.error('[keet-cli] Error:', err.message || err)
  }
}

if (command !== 'watch') Bare.exit(0)

// ============================================================
// Client command handlers
// ============================================================

async function clientIdentity (subArgs) {
  const sub = subArgs[0]
  if (sub === 'recovery') {
    const result = await rpc('identity.recovery')
    if (result.ok) {
      console.log('[keet-cli] Recovery phrase:', result.data.phrase)
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }
  if (sub === 'restore') {
    const phrase = subArgs.slice(1).join(' ')
    if (!phrase) {
      console.error('Usage: identity restore <mnemonic phrase>')
      return
    }
    const result = await rpc('identity.restore', { phrase })
    if (result.ok) {
      console.log('[keet-cli] Identity restored!')
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }
  if (sub === 'confirm') {
    const result = await rpc('identity.confirm')
    if (result.ok) {
      console.log('[keet-cli] Recovery phrase confirmed.')
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }
  if (sub === 'store') {
    const phrase = subArgs.slice(1).join(' ')
    if (!phrase) {
      console.error('Usage: identity store <mnemonic phrase>')
      return
    }
    const result = await rpc('identity.store', { phrase })
    if (result.ok) {
      console.log('[keet-cli] Recovery phrase stored.')
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }
  if (sub === 'register') {
    const username = subArgs[1]
    if (!username) {
      console.error('Usage: identity register <username> (must include number: name0)')
      return
    }
    const result = await rpc('identity.register', { username })
    if (result.ok) {
      console.log('[keet-cli] Username registration initiated:', username)
      console.log('  (async — wait ~15s, then check with: identity)')
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }
  if (sub === 'check') {
    const username = subArgs[1]
    if (!username) {
      console.error('Usage: identity check <username>')
      return
    }
    const result = await rpc('identity.checkName', { username })
    if (result.ok) {
      console.log(`[keet-cli] "${username}": ${result.data.available ? 'available' : 'taken'}`)
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }
  if (sub === 'lookup') {
    const username = subArgs[1]
    if (!username) {
      console.error('Usage: identity lookup <username>')
      return
    }
    const result = await rpc('identity.lookup', { username })
    if (result.ok) {
      console.log('[keet-cli] Lookup result:')
      console.log(JSON.stringify(result.data, null, 2))
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }
  // Default: show identity
  const result = await rpc('identity')
  if (result.ok && result.data) {
    console.log('[keet-cli] Current identity:')
    console.log(JSON.stringify(result.data, null, 2))
  } else {
    console.log('[keet-cli] No identity found.')
  }
}

async function clientRooms () {
  const result = await rpc('rooms')
  if (!result.ok) {
    console.error('[keet-cli] Error:', result.error)
    return
  }
  const rooms = result.data
  if (!rooms || rooms.length === 0) {
    console.log('[keet-cli] No rooms. Use "join <key>" or "create <title>"')
    return
  }
  console.log(`[keet-cli] ${rooms.length} room(s):`)
  for (const room of rooms) {
    console.log(`  ${room.roomId}  ${room.title}`)
  }
}

async function clientRoom (roomId) {
  if (!roomId) {
    console.error('Usage: room <roomId>')
    return
  }
  const result = await rpc('room', { roomId })
  if (result.ok) {
    console.log('[keet-cli] Room info:')
    console.log(JSON.stringify(result.data, null, 2))
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientJoin (invite) {
  if (!invite) {
    console.error('Usage: join <inviteLink or rawToken>')
    return
  }
  console.log('[keet-cli] Joining room...')
  const result = await rpc('room.join', { invite })
  if (result.ok) {
    console.log('[keet-cli] Room joined!', result.data.status || '')
    console.log(JSON.stringify(result.data, null, 2))
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientCreate (title) {
  if (!title) {
    console.error('Usage: create <room title>')
    return
  }
  const result = await rpc('room.create', { title })
  if (result.ok) {
    console.log('[keet-cli] Room created!')
    console.log(JSON.stringify(result.data, null, 2))
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientInvite (roomId) {
  if (!roomId) {
    console.error('Usage: invite <roomId>')
    return
  }
  const result = await rpc('room.invite', { roomId })
  if (result.ok) {
    console.log('[keet-cli] Invitation created:')
    if (result.data.token) {
      console.log('  Token:', result.data.token)
      console.log('  Join with: keet-cli join', result.data.token)
    } else {
      console.log(JSON.stringify(result.data, null, 2))
    }
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientSend (roomId, text) {
  if (!roomId || !text) {
    console.error('Usage: send <roomId> <message text>')
    return
  }
  const result = await rpc('send', { roomId, text })
  if (result.ok) {
    console.log('[keet-cli] Message sent!')
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientRead (roomId, count) {
  if (!roomId) {
    console.error('Usage: read <roomId> [count]')
    return
  }
  const result = await rpc('read', { roomId, limit: count })
  if (!result.ok) {
    console.error('[keet-cli] Error:', result.error)
    return
  }
  const messages = result.data
  if (!messages || messages.length === 0) {
    console.log('[keet-cli] No messages yet.')
    return
  }
  for (const msg of messages) {
    const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '??:??'
    const deleted = msg.deleted ? ' [DELETED]' : ''
    const system = msg.system ? ` [${getGenericEventName(msg.system)}]` : ''
    console.log(`[${time}] ${msg.memberId}: ${msg.text}${deleted}${system}`)
  }
}

async function clientWatch (roomId) {
  if (!roomId) {
    console.error('Usage: watch <roomId>')
    return
  }
  console.log(`[keet-cli] Watching ${roomId}... (Ctrl+C to stop)`)

  // Connect to UDS and subscribe
  const pipe = Pipe.connect(SOCKET)
  const framed = new FramedStream(pipe)

  pipe.on('error', (err) => {
    if (err.code === 'ENOENT') {
      console.error('[keet-cli] Daemon not running. Start with: bare index.js daemon')
    } else {
      console.error('[keet-cli] Connection error:', err.message)
    }
    Bare.exit(1)
  })

  framed.on('data', (buf) => {
    try {
      const msg = JSON.parse(b4a.toString(buf, 'utf8'))

      // Response to our subscribe request
      if (msg.id === 1 && msg.ok) {
        console.log('--- live ---')
        return
      }

      // Push events
      if (msg.event === 'message' && msg.data) {
        const data = msg.data
        const time = data.timestamp ? new Date(data.timestamp).toLocaleTimeString() : '??:??'
        const deleted = data.deleted ? ' [DELETED]' : ''
        const system = data.system ? ` [${getGenericEventName(data.system)}]` : ''
        if (data.text || data.system) {
          console.log(`[${time}] ${data.name || data.memberId}: ${data.text}${deleted}${system}`)
        }
      }
    } catch {}
  })

  framed.on('close', () => {
    console.log('[keet-cli] Watch stream ended')
    Bare.exit(0)
  })

  // Send subscribe request
  const subscribeMsg = JSON.stringify({ id: 1, method: 'subscribe', params: { roomId } })
  framed.write(b4a.from(subscribeMsg, 'utf8'))

  await new Promise(() => {})
}

async function clientMembers (roomId) {
  if (!roomId) {
    console.error('Usage: members <roomId>')
    return
  }
  const result = await rpc('members', { roomId })
  if (!result.ok) {
    console.error('[keet-cli] Error:', result.error)
    return
  }
  const members = result.data
  if (!members || members.length === 0) {
    console.log('[keet-cli] No members found.')
    return
  }
  console.log(`[keet-cli] ${members.length} member(s):`)
  for (const m of members) {
    const online = m.online ? ' (online)' : ''
    console.log(`  ${m.name} (${m.role})${online}`)
  }
}

async function clientLeave (roomId) {
  if (!roomId) {
    console.error('Usage: leave <roomId>')
    return
  }
  const result = await rpc('room.leave', { roomId })
  if (result.ok) {
    console.log('[keet-cli] Left room.')
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientStatus () {
  const result = await rpc('status')
  if (result.ok) {
    console.log('[keet-cli] Daemon status:')
    console.log(`  Uptime: ${result.data.uptime}s`)
    console.log(`  Rooms: ${result.data.rooms}`)
    if (result.data.identity) {
      console.log(`  Identity: ${result.data.identity.name || result.data.identity.memberId}`)
    }
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientConfig (roomId, key, value) {
  if (!roomId) {
    console.error('Usage: config <roomId> [key] [value]')
    return
  }
  if (!key) {
    const result = await rpc('room.config', { roomId })
    if (result.ok) {
      console.log('[keet-cli] Room config:')
      console.log(JSON.stringify(result.data, null, 2))
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }
  if (!value) {
    console.error('Usage: config <roomId> <key> <value>')
    return
  }
  const result = await rpc('room.config.set', { roomId, key, value })
  if (result.ok) {
    console.log(`[keet-cli] Config "${key}" updated.`)
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientDms () {
  const result = await rpc('dm.list')
  if (!result.ok) {
    console.error('[keet-cli] Error:', result.error)
    return
  }
  const dms = Array.isArray(result.data) ? result.data : []
  if (dms.length === 0) {
    console.log('[keet-cli] No DMs.')
    return
  }
  console.log(`[keet-cli] ${dms.length} DM(s):`)
  for (const dm of dms) {
    console.log(`  ${dm.roomId || dm.id || '?'}  ${dm.title || dm.name || '(unnamed)'}`)
  }
}

async function clientDm (action, roomId) {
  if (!action) {
    console.error('Usage: dm accept|reject <roomId>')
    return
  }
  if (action === 'accept') {
    if (!roomId) { console.error('Usage: dm accept <roomId>'); return }
    const result = await rpc('dm.accept', { roomId })
    if (result.ok) {
      console.log('[keet-cli] DM request accepted.')
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
  } else if (action === 'reject') {
    if (!roomId) { console.error('Usage: dm reject <roomId>'); return }
    const result = await rpc('dm.reject', { roomId })
    if (result.ok) {
      console.log('[keet-cli] DM request rejected.')
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
  } else {
    console.error('Usage: dm accept|reject <roomId>')
  }
}

async function clientFiles (roomId) {
  if (!roomId) {
    console.error('Usage: files <roomId>')
    return
  }
  const result = await rpc('file.list', { roomId })
  if (!result.ok) {
    console.error('[keet-cli] Error:', result.error)
    return
  }
  const files = Array.isArray(result.data) ? result.data : []
  if (files.length === 0) {
    console.log('[keet-cli] No files.')
    return
  }
  console.log(`[keet-cli] ${files.length} file(s):`)
  for (const f of files) {
    console.log(`  ${f.id || f.fileId || '?'}  ${f.name || f.filename || '(unnamed)'}  ${f.size || '?'} bytes`)
  }
}

async function clientDownload (roomId, fileId) {
  if (!roomId || !fileId) {
    console.error('Usage: download <roomId> <fileId>')
    return
  }
  const result = await rpc('file.download', { roomId, fileId })
  if (result.ok) {
    console.log('[keet-cli] Download started.')
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientVersion () {
  const result = await rpc('version')
  if (result.ok) {
    console.log('[keet-cli] Version:', JSON.stringify(result.data, null, 2))
  } else {
    console.error('[keet-cli] Error:', result.error)
  }
}

async function clientIntrospect (api) {
  const result = await rpc('introspect', { api: api || 'core' })
  if (!result.ok) {
    console.error('[keet-cli] Error:', result.error)
    return
  }
  console.log(`[keet-cli] ${result.data.api} API — ${result.data.count} methods:`)
  for (const method of result.data.methods) {
    console.log(`  ${method}`)
  }
}

async function clientMeta (subArgs) {
  const sub = subArgs[0]

  if (!sub || sub === 'key') {
    const result = await rpc('meta.key')
    if (result.ok) {
      console.log('[keet-cli] MetadataBase key:', result.data.key)
      console.log('  writable:', result.data.writable)
      console.log('  localKey:', result.data.localKey)
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }

  if (sub === 'invite') {
    const result = await rpc('meta.invite')
    if (result.ok) {
      console.log(result.data.key)
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }

  if (sub === 'join') {
    const key = subArgs[1]
    if (!key || key.length !== 64) {
      console.error('Usage: meta join <64-char-hex-key>')
      return
    }
    const result = await rpc('meta.join', { key })
    if (result.ok) {
      console.log('[keet-cli] Paired:', result.data.ok ? 'yes' : 'no')
      console.log('  key:', result.data.key)
      console.log('  writable:', result.data.writable)
    } else {
      console.error('[keet-cli] Error:', result.error)
    }
    return
  }

  console.error('Usage: meta [key|invite|join <key>]')
}

async function clientGraph (subArgs) {
  // Parse --algo=<path>, --features=X, --threshold=N, --minSize=N from subArgs
  let sub = null
  let algoPath = null
  let inlineFeatures = null
  let inlineThreshold = null
  let inlineMinSize = null
  let force = false

  const positional = []
  for (const arg of subArgs) {
    if (arg.startsWith('--algo=')) {
      algoPath = arg.slice(7)
    } else if (arg.startsWith('--features=')) {
      inlineFeatures = arg.slice(11)
    } else if (arg.startsWith('--threshold=')) {
      inlineThreshold = parseFloat(arg.slice(12))
    } else if (arg.startsWith('--minSize=')) {
      inlineMinSize = parseInt(arg.slice(10))
    } else if (arg === '--force') {
      force = true
    } else {
      positional.push(arg)
    }
  }

  sub = positional[0] || 'clusters'

  if (sub === 'compute') {
    const params = { force }

    if (algoPath) {
      // Load algo file
      try {
        const fs = await import('fs')
        const raw = fs.readFileSync(algoPath, 'utf8')
        params.algo = JSON.parse(raw)
      } catch (err) {
        console.error('[keet-cli] Failed to load algo file:', algoPath, '-', err.message)
        return
      }
    } else if (inlineFeatures) {
      // Build inline algo from flags
      const features = inlineFeatures.split(',').map(t => ({ type: t.trim(), weight: 1.0 }))
      params.algo = {
        name: 'inline-' + inlineFeatures,
        features,
        threshold: inlineThreshold ?? 0.5,
        minSize: inlineMinSize ?? 2
      }
    }

    const result = await rpc('graph.compute', params)
    if (!result.ok) {
      console.error('[keet-cli] Error:', result.error)
      return
    }
    printGraphResult(result.data)
    return
  }

  if (sub === 'clusters') {
    const params = {}
    if (algoPath) {
      try {
        const fs = await import('fs')
        params.algo = JSON.parse(fs.readFileSync(algoPath, 'utf8'))
      } catch (err) {
        console.error('[keet-cli] Failed to load algo file:', err.message)
        return
      }
    }
    const result = await rpc('graph.clusters', params)
    if (!result.ok) {
      console.error('[keet-cli] Error:', result.error)
      return
    }
    const clusters = Array.isArray(result.data) ? result.data : []
    if (clusters.length === 0) {
      console.log('[keet-cli] No clusters found.')
      return
    }
    console.log(`[keet-cli] ${clusters.length} cluster(s):`)
    for (const c of clusters) {
      const label = c.label ? ` [${c.label}]` : ''
      const topics = c.topics?.length ? ` topics: ${c.topics.join(', ')}` : ''
      console.log(`  Cluster ${c.id}${label} — ${c.rooms.length} rooms${topics}`)
      for (const r of c.rooms) {
        console.log(`    ${r}`)
      }
    }
    return
  }

  if (sub === 'edges') {
    const params = {}
    if (algoPath) {
      try {
        const fs = await import('fs')
        params.algo = JSON.parse(fs.readFileSync(algoPath, 'utf8'))
      } catch (err) {
        console.error('[keet-cli] Failed to load algo file:', err.message)
        return
      }
    }
    const result = await rpc('graph.edges', params)
    if (!result.ok) {
      console.error('[keet-cli] Error:', result.error)
      return
    }
    const edges = Array.isArray(result.data) ? result.data : []
    if (edges.length === 0) {
      console.log('[keet-cli] No MI edges.')
      return
    }
    console.log(`[keet-cli] ${edges.length} edge(s):`)
    for (const e of edges) {
      console.log(`  ${e.from.slice(0, 8)}..${e.to.slice(0, 8)}  MI: ${e.mi}  shared: ${e.shared}`)
    }
    return
  }

  console.error('Usage: graph [clusters|edges|compute] [--algo=<file>] [--features=X] [--threshold=N]')
}

function printGraphResult (data) {
  const algo = data.algo || 'default'
  console.log(`[keet-cli] Graph computed (algo: ${algo})`)

  const clusters = data.clusters || []
  const edges = data.edges || []
  const suggestions = data.suggestions || []

  console.log(`  ${clusters.length} cluster(s), ${edges.length} edge(s), ${suggestions.length} suggestion(s)`)

  if (clusters.length > 0) {
    console.log('')
    console.log('  Clusters:')
    for (const c of clusters) {
      const label = c.label ? ` [${c.label}]` : ''
      const topics = c.topics?.length ? ` topics: ${c.topics.join(', ')}` : ''
      console.log(`    Cluster ${c.id}${label} — ${c.rooms.length} rooms${topics}`)
      for (const r of c.rooms) {
        console.log(`      ${r}`)
      }
    }
  }

  if (edges.length > 0) {
    console.log('')
    console.log('  Top edges:')
    const top = edges.slice(0, 10)
    for (const e of top) {
      console.log(`    ${e.from.slice(0, 8)}..${e.to.slice(0, 8)}  MI: ${e.mi}  shared: ${e.shared}`)
    }
    if (edges.length > 10) console.log(`    ... and ${edges.length - 10} more`)
  }
}

// (UDS client helpers moved above client switch to avoid TDZ)