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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 livetap contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# livetap
|
|
2
|
+
|
|
3
|
+
> Push live data streams into your AI coding agent.
|
|
4
|
+
|
|
5
|
+
Connect MQTT brokers, WebSocket feeds, or tail log files. Your agent samples, watches, and acts on real-time data through natural language.
|
|
6
|
+
|
|
7
|
+
<!-- TODO: Add demo GIF here after recording -->
|
|
8
|
+
<!--  -->
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
**Requirements:** [Bun](https://bun.sh), [Redis](https://redis.io/) (`brew install redis`), Claude Code v2.1.80+
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add livetap
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Start the daemon and Claude Code:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
livetap start
|
|
22
|
+
claude --dangerously-load-development-channels server:livetap
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then ask your agent:
|
|
26
|
+
|
|
27
|
+
> "Connect to the IoT demo at mqtt://broker.emqx.io on topic justinx/demo/# and watch for temperature above 23 degrees"
|
|
28
|
+
|
|
29
|
+
## What it does
|
|
30
|
+
|
|
31
|
+
livetap runs a background daemon that connects to live data sources, buffers messages in embedded Redis, and pushes alerts into your Claude Code session via the [Channels API](https://code.claude.com/docs/en/channels). Your agent sees the data in real-time and can create expression-based watchers that fire when conditions match.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Source (MQTT/WS/File) ──> Subscriber ──> Redis Stream ──> Watcher Engine
|
|
35
|
+
| |
|
|
36
|
+
v v (on match)
|
|
37
|
+
read_stream Channel Alert
|
|
38
|
+
(agent samples) ──> Claude Code
|
|
39
|
+
──> agent acts
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
### IoT sensor monitoring
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
You: "Connect to mqtt://broker.emqx.io:1883/justinx/demo/# and watch for temperature above 25°C"
|
|
48
|
+
|
|
49
|
+
Agent: Creates connection, samples the data to learn the payload structure,
|
|
50
|
+
sets up watcher on sensors.environmental.temperature.value > 25.
|
|
51
|
+
When it fires, summarizes: "sensor-zone-c hit 25.4°C at 10:05:08Z"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Crypto price alerts
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
You: "Tap the Binance BTC/USDT trade stream and alert me if price drops below 60000"
|
|
58
|
+
|
|
59
|
+
Agent: Connects to wss://stream.binance.com:9443/ws/btcusdt@trade,
|
|
60
|
+
samples to see the price field is "p" (string),
|
|
61
|
+
sets up watcher with regex: p matches "^[1-5]" (prices starting with 1-5 = below 60k)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Log file monitoring
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
You: "Watch my nginx error log for 5xx errors and summarize each one"
|
|
68
|
+
|
|
69
|
+
Agent: Taps file:///var/log/nginx/error.log,
|
|
70
|
+
creates watcher: payload matches "5[0-9]{2}",
|
|
71
|
+
when an error appears, analyzes it:
|
|
72
|
+
"503 Service Unavailable on /api/data — upstream auth-service not responding"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### WiFi disconnect detection
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
You: "Monitor /var/log/wifi.log and alert me when WiFi drops"
|
|
79
|
+
|
|
80
|
+
Agent: Taps the file, samples to see log format, creates regex watcher
|
|
81
|
+
for power state changes. When you toggle WiFi:
|
|
82
|
+
"Wi-Fi powered OFF at 17:51:45, back ON at 17:51:47 (2s outage)"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## CLI
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Daemon
|
|
89
|
+
livetap start # Start (embedded Redis + API)
|
|
90
|
+
livetap stop # Stop
|
|
91
|
+
livetap status # Dashboard
|
|
92
|
+
|
|
93
|
+
# Tap into data sources
|
|
94
|
+
livetap tap mqtt://broker.emqx.io:1883/sensors/# # MQTT broker
|
|
95
|
+
livetap tap wss://stream.binance.com:9443/ws/btcusdt@trade # WebSocket
|
|
96
|
+
livetap tap file:///var/log/nginx/error.log # Log file
|
|
97
|
+
livetap taps # List active taps
|
|
98
|
+
livetap untap <connectionId> # Remove
|
|
99
|
+
|
|
100
|
+
# Sample data
|
|
101
|
+
livetap sip <connectionId> # Pretty JSON output
|
|
102
|
+
livetap sip <connectionId> --raw # Raw JSON
|
|
103
|
+
|
|
104
|
+
# Watchers
|
|
105
|
+
livetap watch <connId> "temperature > 50" # Numeric
|
|
106
|
+
livetap watch <connId> "payload matches 'ERROR|FATAL'" # Regex
|
|
107
|
+
livetap watch <connId> "temp > 50 AND humidity > 90" # AND
|
|
108
|
+
livetap watch <connId> "price > 70000" --cooldown 300 # Custom cooldown
|
|
109
|
+
livetap watchers # List all
|
|
110
|
+
livetap watchers --logs <watcherId> # View logs
|
|
111
|
+
livetap unwatch <watcherId> # Remove
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Supported sources
|
|
115
|
+
|
|
116
|
+
| Protocol | Example | Use case |
|
|
117
|
+
|----------|---------|----------|
|
|
118
|
+
| **MQTT** | `livetap tap mqtt://broker.emqx.io:1883/sensors/#` | IoT sensors, home automation |
|
|
119
|
+
| **WebSocket** | `livetap tap wss://stream.binance.com:9443/ws/btcusdt@trade` | Finance, real-time APIs |
|
|
120
|
+
| **File tailing** | `livetap tap file:///var/log/nginx/error.log` | Log monitoring, DevOps |
|
|
121
|
+
| Webhooks | Planned v0.1 | CI/CD, external services |
|
|
122
|
+
| Kafka | Planned v0.2 | Event sourcing, analytics |
|
|
123
|
+
|
|
124
|
+
## MCP tools
|
|
125
|
+
|
|
126
|
+
livetap exposes 12 MCP tools that your agent uses automatically:
|
|
127
|
+
|
|
128
|
+
| Tool | What it does |
|
|
129
|
+
|------|-------------|
|
|
130
|
+
| `create_connection` | Connect to MQTT, WebSocket, or file |
|
|
131
|
+
| `list_connections` | List active connections |
|
|
132
|
+
| `get_connection` | Connection details and stats |
|
|
133
|
+
| `destroy_connection` | Remove a connection |
|
|
134
|
+
| `read_stream` | Sample recent entries from a stream |
|
|
135
|
+
| `create_watcher` | Set up expression-based alerts |
|
|
136
|
+
| `list_watchers` | List watchers |
|
|
137
|
+
| `get_watcher` | Watcher details |
|
|
138
|
+
| `get_watcher_logs` | View MATCH/SUPPRESSED logs |
|
|
139
|
+
| `update_watcher` | Change conditions or cooldown |
|
|
140
|
+
| `delete_watcher` | Remove a watcher |
|
|
141
|
+
| `restart_watcher` | Restart a stopped watcher |
|
|
142
|
+
|
|
143
|
+
## Expression watchers
|
|
144
|
+
|
|
145
|
+
Watchers use structured conditions — not arbitrary code:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"conditions": [
|
|
150
|
+
{ "field": "sensors.temperature.value", "op": ">", "value": 50 },
|
|
151
|
+
{ "field": "sensors.humidity.value", "op": ">", "value": 90 }
|
|
152
|
+
],
|
|
153
|
+
"match": "all",
|
|
154
|
+
"cooldown": 60
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Operators:** `>`, `<`, `>=`, `<=`, `==`, `!=`, `contains`, `matches` (regex)
|
|
159
|
+
|
|
160
|
+
**Cooldown:** Seconds between repeated alerts. `0` for every match, `60` default.
|
|
161
|
+
|
|
162
|
+
When a watcher fires, the alert arrives as a `<channel>` tag in your Claude Code session. The agent reads it and acts — writing to a file, calling an API, or whatever you asked.
|
|
163
|
+
|
|
164
|
+
## How the agent uses livetap
|
|
165
|
+
|
|
166
|
+
The MCP instructions teach the agent this workflow:
|
|
167
|
+
|
|
168
|
+
1. **CONNECT** — `create_connection` to tap a source
|
|
169
|
+
2. **SAMPLE** — `read_stream` to see the data shape (always before creating watchers)
|
|
170
|
+
3. **WATCH** — `create_watcher` with the correct field paths
|
|
171
|
+
4. **ACT** — when `<channel>` alerts arrive, do what the user asked
|
|
172
|
+
|
|
173
|
+
The agent knows field paths differ by source:
|
|
174
|
+
- **MQTT/WebSocket:** JSON payload parsed — use dot-paths like `sensors.temperature.value`
|
|
175
|
+
- **File (text):** raw line in `payload` field — use `payload contains "ERROR"` or `payload matches "5[0-9]{2}"`
|
|
176
|
+
- **File (JSON lines):** parsed — use dot-paths like `level`, `msg`
|
|
177
|
+
|
|
178
|
+
## Configuration
|
|
179
|
+
|
|
180
|
+
**Daemon port:** Default `:8788`. Override with `--port` or `LIVETAP_PORT` env var.
|
|
181
|
+
|
|
182
|
+
**State directory:** `~/.livetap/` stores daemon PID, logs, and watcher evaluation logs.
|
|
183
|
+
|
|
184
|
+
**MCP config:** `.mcp.json` in your project root:
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"mcpServers": {
|
|
188
|
+
"livetap": {
|
|
189
|
+
"command": "bun",
|
|
190
|
+
"args": ["path/to/src/mcp/channel.ts"]
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Machine-readable help:** `livetap --llm-help` outputs structured JSON for AI agents.
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
git clone https://github.com/livetap/livetap.git
|
|
202
|
+
cd livetap && git checkout v0
|
|
203
|
+
bun install
|
|
204
|
+
bun test # 103 tests
|
|
205
|
+
bun test tests/phase1/ # Specific phase
|
|
206
|
+
SKIP_LIVE_MQTT=1 bun test # Skip tests needing broker.emqx.io
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
See [docs/PLAN.md](docs/PLAN.md) for the full build plan with phased architecture.
|
|
210
|
+
|
|
211
|
+
## Contributing
|
|
212
|
+
|
|
213
|
+
1. Fork and clone
|
|
214
|
+
2. `bun install && bun test`
|
|
215
|
+
3. Make changes, add tests
|
|
216
|
+
4. `bun test` must pass
|
|
217
|
+
5. PR to `v0` branch
|
|
218
|
+
|
|
219
|
+
See [docs/PLAN.md](docs/PLAN.md) for architecture and module layout.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT
|
package/bin/livetap.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* livetap CLI entry point.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const [cmd, ...args] = Bun.argv.slice(2)
|
|
7
|
+
|
|
8
|
+
const commands: Record<string, () => Promise<void>> = {
|
|
9
|
+
start: () => import('../src/cli/start.js').then((m) => m.run(args)),
|
|
10
|
+
stop: () => import('../src/cli/stop.js').then((m) => m.run(args)),
|
|
11
|
+
status: () => import('../src/cli/status.js').then((m) => m.run(args)),
|
|
12
|
+
tap: () => import('../src/cli/tap.js').then((m) => m.run(args)),
|
|
13
|
+
untap: () => import('../src/cli/untap.js').then((m) => m.run(args)),
|
|
14
|
+
taps: () => import('../src/cli/taps.js').then((m) => m.run(args)),
|
|
15
|
+
sip: () => import('../src/cli/sip.js').then((m) => m.run(args)),
|
|
16
|
+
watch: () => import('../src/cli/watch.js').then((m) => m.run(args)),
|
|
17
|
+
unwatch: () => import('../src/cli/unwatch.js').then((m) => m.run(args)),
|
|
18
|
+
watchers: () => import('../src/cli/watchers.js').then((m) => m.run(args)),
|
|
19
|
+
mcp: () => import('../src/mcp/channel.js'),
|
|
20
|
+
help: () => import('../src/cli/help.js').then((m) => m.run(args)),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
24
|
+
await commands.help()
|
|
25
|
+
} else if (cmd === '--llm-help') {
|
|
26
|
+
const { generateLlmHelp } = await import('../src/shared/catalog-generators.js')
|
|
27
|
+
console.log(JSON.stringify(generateLlmHelp(), null, 2))
|
|
28
|
+
} else if (commands[cmd]) {
|
|
29
|
+
await commands[cmd]()
|
|
30
|
+
} else {
|
|
31
|
+
console.error(`Unknown command: ${cmd}. Run 'livetap help' for usage.`)
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "livetap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Push live data streams into your AI coding agent. Connect MQTT, WebSocket, or webhooks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"livetap": "./bin/livetap.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"postinstall": "bun ./scripts/postinstall.ts",
|
|
11
|
+
"test": "bun test"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mqtt", "kafka", "websocket", "webhook", "streaming",
|
|
15
|
+
"real-time", "monitoring", "alerts", "mcp", "claude-code",
|
|
16
|
+
"ai-agent", "iot", "observability", "data-pipeline"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/livetap/livetap"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/livetap/livetap",
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/",
|
|
26
|
+
"src/",
|
|
27
|
+
"scripts/postinstall.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
33
|
+
"ioredis": "^5.10.1",
|
|
34
|
+
"mqtt": "^5.15.1",
|
|
35
|
+
"redis-server": "^1.2.2"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/bun": "latest"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"typescript": "^5"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall script — auto-configures .mcp.json for Claude Code.
|
|
4
|
+
* Runs after `bun add livetap` or `npm install livetap`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
8
|
+
import { resolve } from 'path'
|
|
9
|
+
|
|
10
|
+
const MCP_ENTRY = {
|
|
11
|
+
livetap: {
|
|
12
|
+
command: 'bun',
|
|
13
|
+
args: [resolve(import.meta.dir, '..', 'src', 'mcp', 'channel.ts')],
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findProjectRoot(): string {
|
|
18
|
+
// If we're inside node_modules, walk up past it to find the project root
|
|
19
|
+
const scriptDir = resolve(import.meta.dir)
|
|
20
|
+
const nmIdx = scriptDir.lastIndexOf('node_modules')
|
|
21
|
+
if (nmIdx !== -1) {
|
|
22
|
+
const root = scriptDir.slice(0, nmIdx).replace(/\/$/, '')
|
|
23
|
+
if (existsSync(resolve(root, 'package.json'))) return root
|
|
24
|
+
}
|
|
25
|
+
// Fallback to cwd (e.g. when running postinstall directly for testing)
|
|
26
|
+
return process.cwd()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function run() {
|
|
30
|
+
// Skip in CI or when explicitly disabled
|
|
31
|
+
if (process.env.CI || process.env.LIVETAP_SKIP_POSTINSTALL) return
|
|
32
|
+
|
|
33
|
+
const root = findProjectRoot()
|
|
34
|
+
const mcpPath = resolve(root, '.mcp.json')
|
|
35
|
+
|
|
36
|
+
let config: any = {}
|
|
37
|
+
if (existsSync(mcpPath)) {
|
|
38
|
+
try {
|
|
39
|
+
config = JSON.parse(readFileSync(mcpPath, 'utf-8'))
|
|
40
|
+
} catch {
|
|
41
|
+
console.warn('\n ⚠ .mcp.json exists but is malformed. Skipping auto-config.')
|
|
42
|
+
console.warn(' Add the livetap entry manually (see below).\n')
|
|
43
|
+
printManualInstructions()
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Don't overwrite if livetap entry already exists
|
|
49
|
+
if (config.mcpServers?.livetap) {
|
|
50
|
+
console.log('\n ✓ livetap already configured in .mcp.json\n')
|
|
51
|
+
printRestartInstructions()
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Add livetap entry
|
|
56
|
+
if (!config.mcpServers) config.mcpServers = {}
|
|
57
|
+
config.mcpServers.livetap = MCP_ENTRY.livetap
|
|
58
|
+
writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n')
|
|
59
|
+
|
|
60
|
+
console.log('\n ✓ livetap added to .mcp.json\n')
|
|
61
|
+
printRestartInstructions()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function printRestartInstructions() {
|
|
65
|
+
console.log(' To enable live data streaming in Claude Code, restart with:\n')
|
|
66
|
+
console.log(' claude --dangerously-load-development-channels server:livetap\n')
|
|
67
|
+
console.log(' Then ask Claude: "Connect to mqtt://broker.emqx.io:1883/sensors/#"\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function printManualInstructions() {
|
|
71
|
+
console.log(' Add to .mcp.json:\n')
|
|
72
|
+
console.log(' ' + JSON.stringify({ mcpServers: MCP_ENTRY }, null, 2).split('\n').join('\n ') + '\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
run()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for talking to the livetap daemon.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve } from 'path'
|
|
6
|
+
import { homedir } from 'os'
|
|
7
|
+
import { existsSync, readFileSync } from 'fs'
|
|
8
|
+
|
|
9
|
+
const STATE_PATH = resolve(homedir(), '.livetap', 'state.json')
|
|
10
|
+
|
|
11
|
+
export function getDaemonUrl(): string {
|
|
12
|
+
const port = process.env.LIVETAP_PORT || '8788'
|
|
13
|
+
return `http://127.0.0.1:${port}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function isDaemonRunning(): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`${getDaemonUrl()}/status`)
|
|
19
|
+
return res.ok
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function requireDaemon() {
|
|
26
|
+
// Called at start of commands that need the daemon
|
|
27
|
+
// Actual check is async, so this just prints the message format
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function daemonFetch(path: string, opts?: RequestInit): Promise<Response> {
|
|
31
|
+
const url = `${getDaemonUrl()}${path}`
|
|
32
|
+
try {
|
|
33
|
+
return await fetch(url, opts)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error('Error: livetap daemon is not running. Use "livetap start" first.')
|
|
36
|
+
process.exit(1)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function daemonJson(path: string, opts?: RequestInit): Promise<any> {
|
|
41
|
+
const res = await daemonFetch(path, opts)
|
|
42
|
+
return res.json()
|
|
43
|
+
}
|
package/src/cli/help.ts
ADDED
package/src/cli/sip.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* livetap sip <connectionId> — Sample recent stream entries.
|
|
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 sip <connectionId>')
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const rawMode = args.includes('--raw')
|
|
15
|
+
const maxIdx = args.indexOf('--max')
|
|
16
|
+
const maxEntries = maxIdx !== -1 ? parseInt(args[maxIdx + 1]) : 10
|
|
17
|
+
const backIdx = args.indexOf('--back')
|
|
18
|
+
const backfillSeconds = backIdx !== -1 ? parseInt(args[backIdx + 1]) : 60
|
|
19
|
+
|
|
20
|
+
const params = new URLSearchParams({
|
|
21
|
+
backfillSeconds: String(backfillSeconds),
|
|
22
|
+
maxEntries: String(maxEntries),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const res = await daemonFetch(`/connections/${id}/stream?${params}`)
|
|
26
|
+
const data = await res.json()
|
|
27
|
+
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
console.error(`Error: ${data.error}`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (rawMode) {
|
|
34
|
+
console.log(JSON.stringify(data, null, 2))
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entries = data.entries || []
|
|
39
|
+
if (entries.length === 0) {
|
|
40
|
+
console.log('No entries yet. Data may still be flowing in — try again in a few seconds.')
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log(`${entries.length} entries:\n`)
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const time = new Date(entry.ts).toISOString().slice(11, 19)
|
|
47
|
+
const topic = entry.fields.topic || ''
|
|
48
|
+
const payload = entry.fields.payload || ''
|
|
49
|
+
|
|
50
|
+
console.log(`[${time}]${topic ? ` topic=${topic}` : ''}`)
|
|
51
|
+
|
|
52
|
+
// Pretty-print JSON payload with indentation
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(payload)
|
|
55
|
+
const pretty = JSON.stringify(parsed, null, 2)
|
|
56
|
+
const indented = pretty.split('\n').map((l) => ` ${l}`).join('\n')
|
|
57
|
+
console.log(indented)
|
|
58
|
+
} catch {
|
|
59
|
+
console.log(` ${payload}`)
|
|
60
|
+
}
|
|
61
|
+
console.log()
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/cli/start.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* livetap start — Start the daemon in background or foreground.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve } from 'path'
|
|
6
|
+
import { homedir } from 'os'
|
|
7
|
+
import { mkdirSync, writeFileSync, existsSync } from 'fs'
|
|
8
|
+
import { isDaemonRunning, getDaemonUrl } from './daemon-client.js'
|
|
9
|
+
|
|
10
|
+
const STATE_DIR = resolve(homedir(), '.livetap')
|
|
11
|
+
const LOG_DIR = resolve(STATE_DIR, 'logs')
|
|
12
|
+
const STATE_PATH = resolve(STATE_DIR, 'state.json')
|
|
13
|
+
|
|
14
|
+
export async function run(args: string[]) {
|
|
15
|
+
const foreground = args.includes('--foreground') || args.includes('-f')
|
|
16
|
+
const portFlag = args.indexOf('--port')
|
|
17
|
+
if (portFlag !== -1 && args[portFlag + 1]) {
|
|
18
|
+
process.env.LIVETAP_PORT = args[portFlag + 1]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (await isDaemonRunning()) {
|
|
22
|
+
const res = await fetch(`${getDaemonUrl()}/status`)
|
|
23
|
+
const status = await res.json()
|
|
24
|
+
console.log(`livetap is already running on :${status.port} (${status.connections.length} connections)`)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
mkdirSync(LOG_DIR, { recursive: true })
|
|
29
|
+
|
|
30
|
+
if (foreground) {
|
|
31
|
+
console.log('Starting livetap in foreground...')
|
|
32
|
+
// Import and run directly
|
|
33
|
+
await import('../server/index.js')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Background: spawn detached
|
|
38
|
+
const port = process.env.LIVETAP_PORT || '8788'
|
|
39
|
+
const logFile = Bun.file(resolve(LOG_DIR, 'daemon.log'))
|
|
40
|
+
const logFd = logFile.writer()
|
|
41
|
+
|
|
42
|
+
const proc = Bun.spawn(['bun', resolve(import.meta.dir, '../server/index.ts')], {
|
|
43
|
+
env: { ...process.env, LIVETAP_PORT: port },
|
|
44
|
+
stdout: 'ignore',
|
|
45
|
+
stderr: 'ignore',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Wait for it to be ready
|
|
49
|
+
const deadline = Date.now() + 15_000
|
|
50
|
+
let ready = false
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`)
|
|
54
|
+
if (res.ok) {
|
|
55
|
+
const data = await res.json()
|
|
56
|
+
writeFileSync(STATE_PATH, JSON.stringify({
|
|
57
|
+
pid: proc.pid,
|
|
58
|
+
port: parseInt(port),
|
|
59
|
+
redisPort: data.redisPort,
|
|
60
|
+
startedAt: new Date().toISOString(),
|
|
61
|
+
}, null, 2))
|
|
62
|
+
ready = true
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
} catch { /* not ready */ }
|
|
66
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (ready) {
|
|
70
|
+
console.log(`livetap daemon started on :${port} (pid ${proc.pid})`)
|
|
71
|
+
} else {
|
|
72
|
+
console.error('Error: daemon failed to start within 15s. Check ~/.livetap/logs/daemon.log')
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* livetap status — Show daemon, connections, and 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" to begin.')
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const jsonMode = args.includes('--json')
|
|
14
|
+
const data = await daemonJson('/status')
|
|
15
|
+
|
|
16
|
+
if (jsonMode) {
|
|
17
|
+
console.log(JSON.stringify(data, null, 2))
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const uptime = formatUptime(data.uptime)
|
|
22
|
+
console.log(`livetap daemon running on :${data.port} (uptime ${uptime})`)
|
|
23
|
+
console.log(`Redis: localhost:${data.redisPort}\n`)
|
|
24
|
+
|
|
25
|
+
const conns = data.connections || []
|
|
26
|
+
if (conns.length === 0) {
|
|
27
|
+
console.log('No active connections. Use "livetap tap <uri>" to connect.\n')
|
|
28
|
+
} else {
|
|
29
|
+
console.log(`Connections (${conns.length}):`)
|
|
30
|
+
for (const c of conns) {
|
|
31
|
+
const rate = `${c.msgPerSec} msg/s`
|
|
32
|
+
const buf = `${c.bufferedCount} buffered`
|
|
33
|
+
console.log(` ${c.connectionId} ${c.type.padEnd(9)} ${c.summary.slice(0, 40).padEnd(42)} ${rate.padStart(10)} ${buf}`)
|
|
34
|
+
}
|
|
35
|
+
console.log()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatUptime(seconds: number): string {
|
|
40
|
+
if (seconds < 60) return `${Math.round(seconds)}s`
|
|
41
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m`
|
|
42
|
+
const h = Math.floor(seconds / 3600)
|
|
43
|
+
const m = Math.round((seconds % 3600) / 60)
|
|
44
|
+
return `${h}h ${m}m`
|
|
45
|
+
}
|