livetap 0.1.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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * livetap stop — Stop the daemon.
3
+ */
4
+
5
+ import { resolve } from 'path'
6
+ import { homedir } from 'os'
7
+ import { existsSync, readFileSync, unlinkSync } from 'fs'
8
+ import { isDaemonRunning, getDaemonUrl } from './daemon-client.js'
9
+
10
+ const STATE_PATH = resolve(homedir(), '.livetap', 'state.json')
11
+
12
+ export async function run(_args: string[]) {
13
+ if (!(await isDaemonRunning())) {
14
+ // Try reading PID from state file
15
+ if (existsSync(STATE_PATH)) {
16
+ try {
17
+ const state = JSON.parse(readFileSync(STATE_PATH, 'utf-8'))
18
+ try { process.kill(state.pid, 'SIGTERM') } catch { /* already dead */ }
19
+ unlinkSync(STATE_PATH)
20
+ console.log('livetap daemon stopped (cleaned up stale state)')
21
+ } catch {
22
+ unlinkSync(STATE_PATH)
23
+ }
24
+ } else {
25
+ console.log('livetap is not running.')
26
+ }
27
+ return
28
+ }
29
+
30
+ // Read state for PID
31
+ let pid: number | undefined
32
+ if (existsSync(STATE_PATH)) {
33
+ try {
34
+ const state = JSON.parse(readFileSync(STATE_PATH, 'utf-8'))
35
+ pid = state.pid
36
+ } catch { /* malformed */ }
37
+ }
38
+
39
+ // Send SIGTERM
40
+ if (pid) {
41
+ try {
42
+ process.kill(pid, 'SIGTERM')
43
+ } catch { /* already gone */ }
44
+ }
45
+
46
+ // Wait for shutdown
47
+ const deadline = Date.now() + 5000
48
+ while (Date.now() < deadline) {
49
+ if (!(await isDaemonRunning())) break
50
+ await new Promise((r) => setTimeout(r, 300))
51
+ }
52
+
53
+ // Force kill if still running
54
+ if (await isDaemonRunning()) {
55
+ if (pid) try { process.kill(pid, 'SIGKILL') } catch { /* ok */ }
56
+ }
57
+
58
+ // Clean up state
59
+ if (existsSync(STATE_PATH)) {
60
+ try { unlinkSync(STATE_PATH) } catch { /* ok */ }
61
+ }
62
+
63
+ console.log('livetap daemon stopped')
64
+ }
package/src/cli/tap.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * livetap tap <uri|file.json> — Connect to a data source.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from 'fs'
6
+ import { daemonFetch } from './daemon-client.js'
7
+
8
+ export async function run(args: string[]) {
9
+ const source = args[0]
10
+ if (!source) {
11
+ console.error('Usage: livetap tap <uri|file.json|webhook>')
12
+ console.error(' livetap tap mqtt://broker.emqx.io:1883/sensors/#')
13
+ console.error(' livetap tap wss://stream.example.com/prices')
14
+ console.error(' livetap tap webhook')
15
+ console.error(' livetap tap connection.json')
16
+ process.exit(1)
17
+ }
18
+
19
+ const nameIdx = args.indexOf('--name')
20
+ const name = nameIdx !== -1 ? args[nameIdx + 1] : undefined
21
+
22
+ let config: any
23
+
24
+ if (source === 'webhook') {
25
+ config = { type: 'webhook' }
26
+ } else if (source.endsWith('.json') && existsSync(source)) {
27
+ config = JSON.parse(readFileSync(source, 'utf-8'))
28
+ } else if (source.startsWith('mqtt://') || source.startsWith('mqtts://')) {
29
+ config = parseMqttUri(source)
30
+ } else if (source.startsWith('ws://') || source.startsWith('wss://')) {
31
+ config = { type: 'websocket', url: source }
32
+ } else if (source.startsWith('file://')) {
33
+ const path = source.slice(7) // strip file://
34
+ if (!path.startsWith('/')) {
35
+ console.error('file:// path must be absolute (e.g. file:///var/log/app.log)')
36
+ process.exit(1)
37
+ }
38
+ config = { type: 'file', path }
39
+ } else {
40
+ console.error(`Unknown source format: ${source}`)
41
+ console.error('Expected: mqtt://..., wss://..., file:///path, webhook, or a .json file')
42
+ process.exit(1)
43
+ }
44
+
45
+ const body: any = { ...config }
46
+ if (name) body.name = name
47
+
48
+ const res = await daemonFetch('/connections', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify(body),
52
+ })
53
+
54
+ const data = await res.json()
55
+
56
+ if (!res.ok) {
57
+ console.error(`Error: ${data.error}`)
58
+ process.exit(1)
59
+ }
60
+
61
+ console.log(`Tapped: ${data.connectionId} (${config.type} → ${summarize(config)})`)
62
+ if (data.ingestUrl) {
63
+ console.log(`Ingest URL: ${data.ingestUrl}`)
64
+ }
65
+ }
66
+
67
+ function parseMqttUri(uri: string): any {
68
+ // URL() mangles MQTT wildcards (# and +), so parse manually for the path
69
+ const url = new URL(uri)
70
+ const tls = url.protocol === 'mqtts:'
71
+
72
+ // Extract topic from the raw URI (after host:port/)
73
+ const hostEnd = uri.indexOf('/', uri.indexOf('//') + 2)
74
+ const rawTopic = hostEnd !== -1 ? uri.slice(hostEnd + 1) : ''
75
+
76
+ return {
77
+ type: 'mqtt',
78
+ broker: url.hostname,
79
+ port: parseInt(url.port) || (tls ? 8883 : 1883),
80
+ tls,
81
+ credentials: {
82
+ username: decodeURIComponent(url.username || ''),
83
+ password: decodeURIComponent(url.password || ''),
84
+ },
85
+ topics: rawTopic ? [rawTopic] : [],
86
+ }
87
+ }
88
+
89
+ function summarize(config: any): string {
90
+ if (config.type === 'mqtt') return `${config.broker}/${config.topics?.[0] ?? ''}`
91
+ if (config.type === 'websocket') return config.url
92
+ if (config.type === 'file') return config.path
93
+ return 'webhook ingest'
94
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * livetap taps — List active taps.
3
+ */
4
+
5
+ import { isDaemonRunning, daemonJson } from './daemon-client.js'
6
+
7
+ export async function run(args: string[]) {
8
+ if (!(await isDaemonRunning())) {
9
+ console.log('livetap is not running. Use "livetap start" first.')
10
+ return
11
+ }
12
+
13
+ const jsonMode = args.includes('--json')
14
+ const data = await daemonJson('/connections')
15
+
16
+ if (jsonMode) {
17
+ console.log(JSON.stringify(data, null, 2))
18
+ return
19
+ }
20
+
21
+ if (data.length === 0) {
22
+ console.log('No active taps. Use "livetap tap <uri>" to connect.')
23
+ return
24
+ }
25
+
26
+ console.log(`Active taps (${data.length}):\n`)
27
+ for (const c of data) {
28
+ const rate = `${c.msgPerSec} msg/s`
29
+ const buf = `${c.bufferedCount} buffered`
30
+ console.log(` ${c.connectionId} ${c.type.padEnd(9)} ${(c.summary || '').slice(0, 40).padEnd(42)} ${rate.padStart(10)} ${buf}`)
31
+ }
32
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * livetap untap <connectionId> — Remove a tap.
3
+ */
4
+
5
+ import { daemonFetch } from './daemon-client.js'
6
+
7
+ export async function run(args: string[]) {
8
+ const id = args[0]
9
+ if (!id) {
10
+ console.error('Usage: livetap untap <connectionId>')
11
+ process.exit(1)
12
+ }
13
+
14
+ const res = await daemonFetch(`/connections/${id}`, { method: 'DELETE' })
15
+ const data = await res.json()
16
+
17
+ if (!res.ok) {
18
+ console.error(`Error: ${data.error}`)
19
+ process.exit(1)
20
+ }
21
+
22
+ console.log(`Untapped: ${id}`)
23
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * livetap unwatch <watcherId> — Remove a watcher.
3
+ */
4
+
5
+ import { daemonFetch } from './daemon-client.js'
6
+
7
+ export async function run(args: string[]) {
8
+ const id = args[0]
9
+ if (!id) {
10
+ console.error('Usage: livetap unwatch <watcherId>')
11
+ process.exit(1)
12
+ }
13
+
14
+ const res = await daemonFetch(`/watchers/${id}`, { method: 'DELETE' })
15
+ const data = await res.json()
16
+
17
+ if (!res.ok) {
18
+ console.error(`Error: ${data.error}`)
19
+ process.exit(1)
20
+ }
21
+
22
+ console.log(`Unwatched: ${id}`)
23
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * livetap watch <connectionId> "expression" — Create a watcher.
3
+ */
4
+
5
+ import { daemonFetch } from './daemon-client.js'
6
+
7
+ export async function run(args: string[]) {
8
+ const connId = args[0]
9
+ const exprStr = args[1]
10
+
11
+ if (!connId || !exprStr) {
12
+ console.error('Usage: livetap watch <connectionId> "field > value"')
13
+ console.error(' livetap watch conn_abc "temperature > 50"')
14
+ console.error(' livetap watch conn_abc "temp > 50 AND humidity > 90"')
15
+ console.error(' livetap watch conn_abc "temp > 50 OR smoke > 0.05"')
16
+ process.exit(1)
17
+ }
18
+
19
+ const cooldownIdx = args.indexOf('--cooldown')
20
+ const cooldown = cooldownIdx !== -1 ? parseInt(args[cooldownIdx + 1]) : 60
21
+
22
+ const { conditions, match } = parseExpression(exprStr)
23
+
24
+ // Parse action flag
25
+ const actionIdx = args.indexOf('--action')
26
+ let action: any = 'channel_alert'
27
+ if (actionIdx !== -1) {
28
+ const actionStr = args[actionIdx + 1]
29
+ if (actionStr.startsWith('webhook:')) {
30
+ action = { webhook: actionStr.slice(8) }
31
+ } else if (actionStr.startsWith('shell:')) {
32
+ action = { shell: actionStr.slice(6) }
33
+ } else {
34
+ action = actionStr
35
+ }
36
+ }
37
+
38
+ const res = await daemonFetch('/watchers', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ connectionId: connId, conditions, match, action, cooldown }),
42
+ })
43
+
44
+ const data = await res.json()
45
+
46
+ if (!res.ok) {
47
+ console.error(`Error: ${data.error}`)
48
+ process.exit(1)
49
+ }
50
+
51
+ console.log(`Watcher created: ${data.id} (${data.expression}, cooldown ${cooldown}s)`)
52
+ }
53
+
54
+ function parseExpression(expr: string): { conditions: any[]; match: 'all' | 'any' } {
55
+ let match: 'all' | 'any' = 'all'
56
+ let parts: string[]
57
+
58
+ if (expr.includes(' AND ')) {
59
+ parts = expr.split(' AND ').map((s) => s.trim())
60
+ match = 'all'
61
+ } else if (expr.includes(' OR ')) {
62
+ parts = expr.split(' OR ').map((s) => s.trim())
63
+ match = 'any'
64
+ } else {
65
+ parts = [expr.trim()]
66
+ }
67
+
68
+ const conditions = parts.map((part) => {
69
+ // Match: field op value
70
+ const m = part.match(/^(.+?)\s*(>=|<=|!=|>|<|==|contains|matches)\s*(.+)$/)
71
+ if (!m) {
72
+ console.error(`Cannot parse condition: "${part}"`)
73
+ console.error('Expected format: "field op value" (e.g. "temperature > 50")')
74
+ process.exit(1)
75
+ }
76
+
77
+ const field = m[1].trim()
78
+ const op = m[2].trim()
79
+ let value: any = m[3].trim()
80
+
81
+ // Parse value type
82
+ if (value === 'true') value = true
83
+ else if (value === 'false') value = false
84
+ else if (!isNaN(Number(value))) value = Number(value)
85
+ else value = value.replace(/^["']|["']$/g, '') // strip quotes
86
+
87
+ return { field, op, value }
88
+ })
89
+
90
+ return { conditions, match }
91
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * livetap watchers [connectionId] — List watchers.
3
+ */
4
+
5
+ import { isDaemonRunning, daemonJson } from './daemon-client.js'
6
+
7
+ export async function run(args: string[]) {
8
+ if (!(await isDaemonRunning())) {
9
+ console.log('livetap is not running. Use "livetap start" first.')
10
+ return
11
+ }
12
+
13
+ const jsonMode = args.includes('--json')
14
+
15
+ // --logs <watcherId> — show watcher logs
16
+ const logsIdx = args.indexOf('--logs')
17
+ if (logsIdx !== -1) {
18
+ const watcherId = args[logsIdx + 1]
19
+ if (!watcherId) {
20
+ console.error('Usage: livetap watchers --logs <watcherId>')
21
+ process.exit(1)
22
+ }
23
+ const data = await daemonJson(`/watchers/${watcherId}/logs`)
24
+ if (jsonMode) {
25
+ console.log(JSON.stringify(data, null, 2))
26
+ } else {
27
+ console.log(`Logs for ${watcherId}:\n`)
28
+ for (const line of data.logs || []) {
29
+ console.log(` ${line}`)
30
+ }
31
+ if (!data.logs?.length) console.log(' (no logs yet)')
32
+ }
33
+ return
34
+ }
35
+
36
+ // Filter args: anything that doesn't start with - and isn't after a flag
37
+ const positional = args.filter((a) => !a.startsWith('-'))
38
+ const arg = positional[0]
39
+
40
+ // If it looks like a watcher ID, show that watcher's details
41
+ if (arg?.startsWith('w_')) {
42
+ const info = await daemonJson(`/watchers/${arg}`)
43
+ if (info.error) {
44
+ console.error(`Error: ${info.error}`)
45
+ return
46
+ }
47
+ if (jsonMode) {
48
+ console.log(JSON.stringify(info, null, 2))
49
+ } else {
50
+ const expr = info.conditions
51
+ ?.map((c: any) => `${c.field} ${c.op} ${c.value}`)
52
+ .join(info.match === 'all' ? ' AND ' : ' OR ') || '?'
53
+ console.log(`Watcher ${info.id}:`)
54
+ console.log(` Connection: ${info.connectionId}`)
55
+ console.log(` Expression: ${expr}`)
56
+ console.log(` Status: ${info.status} Matches: ${info.matchCount ?? 0} Cooldown: ${info.cooldown}s`)
57
+ if (info.lastMatch) console.log(` Last match: ${info.lastMatch}`)
58
+ console.log(` Created: ${info.createdAt}`)
59
+ }
60
+ return
61
+ }
62
+
63
+ const connId = arg
64
+
65
+ const params = connId ? `?connectionId=${connId}` : ''
66
+ const data = await daemonJson(`/watchers${params}`)
67
+
68
+ // Handle error response (e.g. old daemon that requires connectionId)
69
+ if (data.error) {
70
+ console.error(`Error: ${data.error}`)
71
+ return
72
+ }
73
+
74
+ const list = Array.isArray(data) ? data : []
75
+
76
+ if (jsonMode) {
77
+ console.log(JSON.stringify(list, null, 2))
78
+ return
79
+ }
80
+
81
+ if (list.length === 0) {
82
+ console.log(connId ? `No watchers for ${connId}.` : 'No watchers.')
83
+ return
84
+ }
85
+
86
+ // Group by connectionId for display
87
+ const grouped = new Map<string, any[]>()
88
+ for (const w of list) {
89
+ const key = w.connectionId
90
+ if (!grouped.has(key)) grouped.set(key, [])
91
+ grouped.get(key)!.push(w)
92
+ }
93
+
94
+ for (const [cid, ws] of grouped) {
95
+ console.log(`Watchers for ${cid}:\n`)
96
+ printWatchers(ws)
97
+ }
98
+ }
99
+
100
+ function printWatchers(watchers: any[]) {
101
+ for (const w of watchers) {
102
+ const expr = w.conditions
103
+ ?.map((c: any) => `${c.field} ${c.op} ${c.value}`)
104
+ .join(w.match === 'all' ? ' AND ' : ' OR ') || '?'
105
+ const last = w.lastMatch ? timeSince(w.lastMatch) : 'never'
106
+ console.log(` ${w.id} ${expr}`)
107
+ console.log(` ${w.status} ${w.matchCount ?? 0} matches cooldown ${w.cooldown}s last: ${last}\n`)
108
+ }
109
+ }
110
+
111
+ function timeSince(iso: string): string {
112
+ const ms = Date.now() - new Date(iso).getTime()
113
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s ago`
114
+ if (ms < 3600_000) return `${Math.round(ms / 60_000)}m ago`
115
+ return `${Math.round(ms / 3600_000)}h ago`
116
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * livetap MCP Channel Proxy
4
+ *
5
+ * Thin MCP server spawned by Claude Code as a subprocess (stdio transport).
6
+ * - Declares claude/channel capability for push notifications
7
+ * - Proxies all tool calls to the livetap daemon on :8788
8
+ * - Holds SSE connection to daemon /events for alert delivery
9
+ */
10
+
11
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
13
+ import { registerTools } from './tools.js'
14
+ import { generateInstructions } from '../shared/catalog-generators.js'
15
+
16
+ const DAEMON_PORT = parseInt(process.env.LIVETAP_PORT || '8788')
17
+ const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`
18
+
19
+ const INSTRUCTIONS = generateInstructions()
20
+
21
+ async function waitForDaemon(maxWaitMs = 10_000): Promise<boolean> {
22
+ const deadline = Date.now() + maxWaitMs
23
+ while (Date.now() < deadline) {
24
+ try {
25
+ const res = await fetch(`${DAEMON_URL}/status`)
26
+ if (res.ok) return true
27
+ } catch { /* not ready */ }
28
+ await new Promise((r) => setTimeout(r, 500))
29
+ }
30
+ return false
31
+ }
32
+
33
+ async function autoStartDaemon(): Promise<boolean> {
34
+ // Check if already running
35
+ try {
36
+ const res = await fetch(`${DAEMON_URL}/status`)
37
+ if (res.ok) return true
38
+ } catch { /* not running */ }
39
+
40
+ // Auto-start the daemon
41
+ console.error('[livetap-mcp] Daemon not running, auto-starting...')
42
+ Bun.spawn(['bun', 'src/server/index.ts'], {
43
+ env: { ...process.env, LIVETAP_PORT: String(DAEMON_PORT) },
44
+ stdout: 'ignore',
45
+ stderr: 'ignore',
46
+ })
47
+
48
+ return waitForDaemon()
49
+ }
50
+
51
+ async function connectToSSE(mcp: Server) {
52
+ try {
53
+ const res = await fetch(`${DAEMON_URL}/events`)
54
+ if (!res.ok || !res.body) return
55
+
56
+ const reader = res.body.getReader()
57
+ const decoder = new TextDecoder()
58
+ let buffer = ''
59
+
60
+ while (true) {
61
+ const { done, value } = await reader.read()
62
+ if (done) break
63
+
64
+ buffer += decoder.decode(value, { stream: true })
65
+ const lines = buffer.split('\n')
66
+ buffer = lines.pop() ?? ''
67
+
68
+ for (const line of lines) {
69
+ if (line.startsWith('data: ')) {
70
+ const data = line.slice(6)
71
+ try {
72
+ const event = JSON.parse(data)
73
+ await mcp.notification({
74
+ method: 'notifications/claude/channel',
75
+ params: {
76
+ content: JSON.stringify(event.payload ?? event),
77
+ meta: {
78
+ type: event.type ?? 'alert',
79
+ ...(event.connectionId && { connection: event.connectionId }),
80
+ ...(event.watcherId && { watcher: event.watcherId }),
81
+ },
82
+ },
83
+ })
84
+ } catch { /* malformed event */ }
85
+ }
86
+ }
87
+ }
88
+ } catch {
89
+ // SSE connection failed — daemon may not support /events yet (Phase 3)
90
+ // Silently retry after delay
91
+ setTimeout(() => connectToSSE(mcp), 5000)
92
+ }
93
+ }
94
+
95
+ async function main() {
96
+ const daemonReady = await autoStartDaemon()
97
+ if (!daemonReady) {
98
+ console.error('[livetap-mcp] WARNING: Could not connect to daemon. Tools may fail.')
99
+ }
100
+
101
+ const mcp = new Server(
102
+ { name: 'livetap', version: '0.1.0' },
103
+ {
104
+ capabilities: {
105
+ experimental: { 'claude/channel': {} },
106
+ tools: {},
107
+ },
108
+ instructions: INSTRUCTIONS,
109
+ },
110
+ )
111
+
112
+ registerTools(mcp, DAEMON_URL)
113
+
114
+ await mcp.connect(new StdioServerTransport())
115
+ console.error('[livetap-mcp] MCP channel proxy connected')
116
+
117
+ // Start SSE listener for alert delivery (non-blocking)
118
+ connectToSSE(mcp)
119
+ }
120
+
121
+ main()