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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/livetap.ts +33 -0
- package/package.json +43 -0
- package/scripts/postinstall.ts +75 -0
- package/src/cli/daemon-client.ts +43 -0
- package/src/cli/help.ts +9 -0
- package/src/cli/sip.ts +63 -0
- package/src/cli/start.ts +75 -0
- package/src/cli/status.ts +45 -0
- package/src/cli/stop.ts +64 -0
- package/src/cli/tap.ts +94 -0
- package/src/cli/taps.ts +32 -0
- package/src/cli/untap.ts +23 -0
- package/src/cli/unwatch.ts +23 -0
- package/src/cli/watch.ts +91 -0
- package/src/cli/watchers.ts +116 -0
- package/src/mcp/channel.ts +121 -0
- package/src/mcp/tools.ts +314 -0
- package/src/server/connection-manager.ts +171 -0
- package/src/server/connections/file.ts +123 -0
- package/src/server/connections/mqtt.ts +104 -0
- package/src/server/connections/webhook.ts +54 -0
- package/src/server/connections/websocket.ts +154 -0
- package/src/server/index.ts +255 -0
- package/src/server/redis.ts +62 -0
- package/src/server/types.ts +94 -0
- package/src/server/watchers/engine.ts +70 -0
- package/src/server/watchers/manager.ts +354 -0
- package/src/server/watchers/types.ts +44 -0
- package/src/shared/catalog-generators.ts +125 -0
- package/src/shared/command-catalog.ts +143 -0
package/src/cli/stop.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/taps.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/untap.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/watch.ts
ADDED
|
@@ -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()
|