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)