lui-bridge 0.2.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/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # lui-bridge
2
+
3
+ Connect AI agents to LUI Android devices. 78 phone tools, MCP protocol, bidirectional communication.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g lui-bridge
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ ```bash
14
+ # Connect as an agent
15
+ lui bridge connect --url ws://PHONE_IP:8765 --token TOKEN --agent claude-code --mode claude-code
16
+ lui bridge connect --url ws://PHONE_IP:8765 --token TOKEN --agent hermes --mode hermes
17
+
18
+ # List tools
19
+ lui bridge tools --url ws://PHONE_IP:8765 --token TOKEN
20
+
21
+ # Call a single tool
22
+ lui bridge call --url ws://PHONE_IP:8765 --token TOKEN --tool battery
23
+ lui bridge call --url ws://PHONE_IP:8765 --token TOKEN --tool toggle_flashlight --args '{"state":"on"}'
24
+
25
+ # Check device
26
+ lui bridge status --url ws://PHONE_IP:8765 --token TOKEN
27
+ ```
28
+
29
+ ## JavaScript API
30
+
31
+ ```javascript
32
+ const { LuiBridge } = require('lui-bridge');
33
+
34
+ const bridge = new LuiBridge('ws://PHONE_IP:8765', 'YOUR_TOKEN');
35
+ await bridge.connect();
36
+
37
+ // Call tools
38
+ console.log(await bridge.callTool('battery'));
39
+ console.log(await bridge.callTool('get_location'));
40
+ console.log(await bridge.callTool('toggle_flashlight', { state: 'on' }));
41
+
42
+ // Vision
43
+ console.log(await bridge.callTool('take_photo')); // Camera2 capture
44
+ console.log(await bridge.callTool('analyze_image')); // Describe last photo
45
+
46
+ // Device info
47
+ console.log(await bridge.getDeviceState());
48
+ console.log(bridge.listTools());
49
+
50
+ bridge.disconnect();
51
+ ```
52
+
53
+ ## Bidirectional Agent
54
+
55
+ ```javascript
56
+ const { LuiBridge } = require('lui-bridge');
57
+
58
+ const bridge = new LuiBridge('ws://PHONE_IP:8765', 'YOUR_TOKEN', {
59
+ agentName: 'my-bot',
60
+ onInstruction: async (instruction) => {
61
+ console.log(`Got: ${instruction}`);
62
+ return `Executed: ${instruction}`;
63
+ },
64
+ onEvent: (type, data) => {
65
+ if (type === 'notification_2fa') console.log(`2FA: ${data.code}`);
66
+ if (type === 'notification') console.log(`Notif: ${data.title}`);
67
+ }
68
+ });
69
+
70
+ await bridge.connect();
71
+ // Phone user says: "patch me to my-bot" → direct chat
72
+ // "@my-bot deploy" → one-off instruction
73
+ ```
74
+
75
+ ## Requirements
76
+
77
+ - Node.js 16+
78
+ - Phone running LUI with bridge enabled
79
+ - Same Wi-Fi (or relay for remote)
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "lui-bridge",
3
+ "version": "0.2.0",
4
+ "description": "Connect AI agents to LUI Android devices — 78 tools, MCP protocol, bidirectional communication",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "lui": "src/cli.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node src/cli.js --help"
11
+ },
12
+ "keywords": ["android", "agent", "mcp", "bridge", "phone", "ai", "tool-use", "lui"],
13
+ "license": "GPL-3.0",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/obirije/LUI"
17
+ },
18
+ "engines": {
19
+ "node": ">=16.0.0"
20
+ },
21
+ "dependencies": {
22
+ "ws": "^8.16.0"
23
+ }
24
+ }
package/src/cli.js ADDED
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * LUI CLI — Connect agents to LUI devices.
5
+ *
6
+ * lui bridge connect --url ws://PHONE_IP:8765 --token TOKEN --agent NAME --mode MODE
7
+ * lui bridge tools --url ws://PHONE_IP:8765 --token TOKEN
8
+ * lui bridge call --url ws://PHONE_IP:8765 --token TOKEN --tool TOOL [--args JSON]
9
+ * lui bridge status --url ws://PHONE_IP:8765 --token TOKEN
10
+ * lui relay start [--port PORT]
11
+ */
12
+
13
+ const { LuiBridge } = require('./index');
14
+ const { execSync, spawn } = require('child_process');
15
+
16
+ const args = process.argv.slice(2);
17
+
18
+ function getArg(name, fallback = null) {
19
+ const idx = args.indexOf(`--${name}`);
20
+ if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
21
+ return fallback;
22
+ }
23
+
24
+ function hasArg(name) {
25
+ return args.includes(`--${name}`);
26
+ }
27
+
28
+ const command = args[0];
29
+ const subcommand = args[1];
30
+
31
+ async function main() {
32
+ if (hasArg('version')) {
33
+ console.log('lui-bridge 0.1.0');
34
+ return;
35
+ }
36
+
37
+ if (hasArg('help') || !command) {
38
+ console.log(`
39
+ lui — Android agent bridge and relay
40
+
41
+ Commands:
42
+ lui bridge connect --url URL --token TOKEN [--agent NAME] [--mode MODE]
43
+ lui bridge tools --url URL --token TOKEN
44
+ lui bridge call --url URL --token TOKEN --tool TOOL [--args JSON]
45
+ lui bridge status --url URL --token TOKEN
46
+ lui relay start [--port PORT]
47
+
48
+ Options:
49
+ --url Bridge WebSocket URL (ws://PHONE_IP:8765)
50
+ --token Auth token (from LUI notification)
51
+ --agent Agent name (for connect mode)
52
+ --mode Execution mode: echo, shell, hermes, claude-code (default: echo)
53
+ --tool Tool name (for call mode)
54
+ --args Tool arguments as JSON (for call mode)
55
+ --port Relay server port (default: 9000)
56
+ --help Show this help
57
+ --version Show version
58
+ `);
59
+ return;
60
+ }
61
+
62
+ if (command === 'bridge') {
63
+ const url = getArg('url');
64
+ const token = getArg('token');
65
+
66
+ if (!url || !token) {
67
+ console.error('Required: --url and --token');
68
+ process.exit(1);
69
+ }
70
+
71
+ if (subcommand === 'connect') {
72
+ await bridgeConnect(url, token);
73
+ } else if (subcommand === 'tools') {
74
+ await bridgeTools(url, token);
75
+ } else if (subcommand === 'call') {
76
+ await bridgeCall(url, token);
77
+ } else if (subcommand === 'status') {
78
+ await bridgeStatus(url, token);
79
+ } else {
80
+ console.error(`Unknown bridge command: ${subcommand}`);
81
+ process.exit(1);
82
+ }
83
+ } else if (command === 'relay') {
84
+ if (subcommand === 'start') {
85
+ await relayStart();
86
+ } else {
87
+ console.error(`Unknown relay command: ${subcommand}`);
88
+ process.exit(1);
89
+ }
90
+ } else {
91
+ console.error(`Unknown command: ${command}. Use --help`);
92
+ process.exit(1);
93
+ }
94
+ }
95
+
96
+ async function bridgeConnect(url, token) {
97
+ const agentName = getArg('agent', 'node-agent');
98
+ const mode = getArg('mode', 'echo');
99
+ const executor = getExecutor(mode);
100
+
101
+ const bridge = new LuiBridge(url, token, {
102
+ agentName,
103
+ onInstruction: async (instruction) => {
104
+ console.log(`\n[→ ${agentName}] ${instruction}`);
105
+ const result = await executor(instruction);
106
+ console.log(`[← ${agentName}] ${result.slice(0, 120)}`);
107
+ return result;
108
+ },
109
+ onEvent: (type, data) => {
110
+ if (type === 'notification_2fa') {
111
+ console.log(`\n[2FA] Code: ${data.code} from ${data.app}`);
112
+ } else if (type === 'call_incoming' || type === 'call_missed') {
113
+ console.log(`\n[${type.toUpperCase()}] ${data.caller || '?'}`);
114
+ } else if (type === 'notification') {
115
+ console.log(`\n[NOTIF] ${data.title || ''}: ${(data.text || '').slice(0, 50)}`);
116
+ }
117
+ }
118
+ });
119
+
120
+ const toolCount = await bridge.connect();
121
+ const state = await bridge.getDeviceState();
122
+ console.log(`Connected to LUI as '${agentName}' — ${toolCount} tools, mode=${mode}`);
123
+ console.log(`Device: ${state.split('\n')[0]}`);
124
+ console.log(`\nOn LUI say: 'patch me to ${agentName}' or '@${agentName} do something'`);
125
+ console.log('Listening... (Ctrl+C to exit)\n');
126
+
127
+ process.on('SIGINT', () => {
128
+ bridge.disconnect();
129
+ console.log('\nDisconnected.');
130
+ process.exit(0);
131
+ });
132
+
133
+ // Keep alive
134
+ await new Promise(() => {});
135
+ }
136
+
137
+ async function bridgeTools(url, token) {
138
+ const bridge = new LuiBridge(url, token);
139
+ const count = await bridge.connect();
140
+ const tools = bridge.listToolsDetailed();
141
+ bridge.disconnect();
142
+
143
+ console.log(`${count} tools available:\n`);
144
+ for (const t of tools) {
145
+ const params = Object.keys(t.inputSchema?.properties || {}).join(', ');
146
+ console.log(` ${t.name}(${params})`);
147
+ console.log(` ${t.description}\n`);
148
+ }
149
+ }
150
+
151
+ async function bridgeCall(url, token) {
152
+ const tool = getArg('tool');
153
+ if (!tool) {
154
+ console.error('Required: --tool');
155
+ process.exit(1);
156
+ }
157
+
158
+ const argsJson = getArg('args', '{}');
159
+ let toolArgs;
160
+ try {
161
+ toolArgs = JSON.parse(argsJson);
162
+ } catch {
163
+ console.error('Invalid --args JSON');
164
+ process.exit(1);
165
+ }
166
+
167
+ const bridge = new LuiBridge(url, token);
168
+ await bridge.connect();
169
+ const result = await bridge.callTool(tool, toolArgs);
170
+ bridge.disconnect();
171
+ console.log(result);
172
+ }
173
+
174
+ async function bridgeStatus(url, token) {
175
+ const bridge = new LuiBridge(url, token);
176
+ try {
177
+ const count = await bridge.connect();
178
+ const state = await bridge.getDeviceState();
179
+ bridge.disconnect();
180
+ console.log(`Connected — ${count} tools\n`);
181
+ console.log(state);
182
+ } catch (err) {
183
+ console.error(`Failed: ${err.message}`);
184
+ process.exit(1);
185
+ }
186
+ }
187
+
188
+ async function relayStart() {
189
+ const port = getArg('port', '9000');
190
+ console.log(`Starting LUI relay on port ${port}...`);
191
+ console.log('Note: The relay server requires Python. Use: lui relay start (Python CLI)');
192
+ console.log(`Or: python3 relay/relay_server.py (PORT=${port})`);
193
+ process.exit(1);
194
+ }
195
+
196
+ function getExecutor(mode) {
197
+ switch (mode) {
198
+ case 'shell':
199
+ return (instruction) => {
200
+ try {
201
+ const output = execSync(instruction, { timeout: 30000, encoding: 'utf-8', cwd: process.env.HOME });
202
+ return output.trim() || 'Done.';
203
+ } catch (err) {
204
+ return err.stderr?.trim() || err.message || 'Command failed.';
205
+ }
206
+ };
207
+ case 'hermes':
208
+ return (instruction) => {
209
+ try {
210
+ const output = execSync(`hermes chat -q "${instruction.replace(/"/g, '\\"')}" --yolo`, {
211
+ timeout: 120000, encoding: 'utf-8', env: { ...process.env, TERM: 'dumb', NO_COLOR: '1' }
212
+ });
213
+ // Strip ANSI and box drawing
214
+ const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
215
+ .replace(/[╭╮╰╯│─┃━┏┓┗┛┡┩┣┫╌╍⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
216
+ .split('\n')
217
+ .map(l => l.trim())
218
+ .filter(l => l && !l.startsWith('Hermes Agent') && !l.includes('──────'))
219
+ .slice(-15)
220
+ .join('\n');
221
+ return clean || 'Hermes returned no output.';
222
+ } catch (err) {
223
+ return `Hermes error: ${err.message}`;
224
+ }
225
+ };
226
+ case 'claude-code':
227
+ return (instruction) => {
228
+ try {
229
+ const output = execSync(`claude --print --dangerously-skip-permissions "${instruction.replace(/"/g, '\\"')}"`, {
230
+ timeout: 120000, encoding: 'utf-8'
231
+ });
232
+ return output.replace(/\x1b\[[0-9;]*m/g, '').trim().slice(0, 1000) || 'Claude Code returned no output.';
233
+ } catch (err) {
234
+ return `Claude Code error: ${err.message}`;
235
+ }
236
+ };
237
+ default:
238
+ return (instruction) => `Received: ${instruction}`;
239
+ }
240
+ }
241
+
242
+ main().catch(err => {
243
+ console.error(err.message);
244
+ process.exit(1);
245
+ });
package/src/index.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * LUI Bridge — Connect AI agents to Android devices.
3
+ *
4
+ * Usage:
5
+ * const { LuiBridge } = require('lui-bridge');
6
+ *
7
+ * const bridge = new LuiBridge('ws://PHONE_IP:8765', 'YOUR_TOKEN');
8
+ * await bridge.connect();
9
+ * console.log(await bridge.callTool('battery'));
10
+ * bridge.disconnect();
11
+ */
12
+
13
+ const WebSocket = require('ws');
14
+
15
+ class LuiBridge {
16
+ /**
17
+ * @param {string} url - WebSocket URL (e.g., ws://PHONE_IP:8765)
18
+ * @param {string} token - Bridge auth token
19
+ * @param {Object} [options]
20
+ * @param {string} [options.agentName] - Register as named agent for bidirectional comms
21
+ * @param {Function} [options.onInstruction] - async (instruction) => response string
22
+ * @param {Function} [options.onEvent] - (eventType, data) => void
23
+ */
24
+ constructor(url, token, options = {}) {
25
+ this.url = url;
26
+ this.token = token;
27
+ this.agentName = options.agentName || null;
28
+ this.onInstruction = options.onInstruction || null;
29
+ this.onEvent = options.onEvent || null;
30
+ this.ws = null;
31
+ this.connected = false;
32
+ this.tools = [];
33
+ this._requestId = 0;
34
+ this._pending = new Map();
35
+ }
36
+
37
+ /**
38
+ * Connect, authenticate, initialize MCP, optionally register as agent.
39
+ * @returns {Promise<number>} Number of available tools
40
+ */
41
+ async connect() {
42
+ return new Promise((resolve, reject) => {
43
+ this.ws = new WebSocket(this.url);
44
+
45
+ this.ws.on('error', (err) => {
46
+ if (!this.connected) reject(err);
47
+ });
48
+
49
+ this.ws.on('open', async () => {
50
+ try {
51
+ // Auth
52
+ this._send({ method: 'auth', params: { token: this.token } });
53
+ const auth = await this._readOne();
54
+ if (!auth?.result?.authenticated) {
55
+ throw new Error(`Auth failed: ${JSON.stringify(auth)}`);
56
+ }
57
+
58
+ // Initialize MCP
59
+ await this._call('initialize', {
60
+ protocolVersion: '2025-03-26',
61
+ clientInfo: { name: this.agentName || 'lui-node', version: '0.1.0' }
62
+ });
63
+
64
+ // Register as agent
65
+ if (this.agentName) {
66
+ this._send({
67
+ jsonrpc: '2.0', id: this._nextId(),
68
+ method: 'lui/register',
69
+ params: {
70
+ name: this.agentName,
71
+ description: `${this.agentName} agent`,
72
+ capabilities: []
73
+ }
74
+ });
75
+ await this._drain();
76
+ }
77
+
78
+ // Get tools
79
+ const toolsResp = await this._call('tools/list');
80
+ this.tools = toolsResp?.tools || [];
81
+
82
+ this.connected = true;
83
+ this._startListener();
84
+ resolve(this.tools.length);
85
+ } catch (err) {
86
+ reject(err);
87
+ }
88
+ });
89
+
90
+ this.ws.on('close', () => {
91
+ this.connected = false;
92
+ });
93
+ });
94
+ }
95
+
96
+ /** Disconnect from the bridge. */
97
+ disconnect() {
98
+ this.connected = false;
99
+ if (this.ws) {
100
+ this.ws.close();
101
+ this.ws = null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Call a LUI tool.
107
+ * @param {string} name - Tool name
108
+ * @param {Object} [args] - Tool arguments
109
+ * @returns {Promise<string>} Result text
110
+ */
111
+ async callTool(name, args = {}) {
112
+ const resp = await this._call('tools/call', { name, arguments: args });
113
+ const content = resp?.content || [];
114
+ if (content.length > 0) {
115
+ const text = content[0].text || '';
116
+ if (resp.isError) throw new ToolError(text);
117
+ return text;
118
+ }
119
+ return '';
120
+ }
121
+
122
+ /**
123
+ * Get current device state.
124
+ * @returns {Promise<string>}
125
+ */
126
+ async getDeviceState() {
127
+ const resp = await this._call('resources/read', { uri: 'lui://device/state' });
128
+ const contents = resp?.contents || [];
129
+ return contents.length > 0 ? contents[0].text || '' : '';
130
+ }
131
+
132
+ /** @returns {string[]} Available tool names */
133
+ listTools() {
134
+ return this.tools.map(t => t.name);
135
+ }
136
+
137
+ /** @returns {Object[]} Full tool definitions */
138
+ listToolsDetailed() {
139
+ return this.tools;
140
+ }
141
+
142
+ /** @returns {Promise<boolean>} */
143
+ async ping() {
144
+ try {
145
+ await this._call('ping');
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ // ── Internal ──
153
+
154
+ _nextId() {
155
+ return String(++this._requestId);
156
+ }
157
+
158
+ _send(msg) {
159
+ this.ws.send(JSON.stringify(msg));
160
+ }
161
+
162
+ _readOne() {
163
+ return new Promise((resolve, reject) => {
164
+ const timeout = setTimeout(() => reject(new Error('Timeout')), 10000);
165
+ this.ws.once('message', (data) => {
166
+ clearTimeout(timeout);
167
+ resolve(JSON.parse(data.toString()));
168
+ });
169
+ });
170
+ }
171
+
172
+ async _drain() {
173
+ await new Promise(r => setTimeout(r, 500));
174
+ // Read and discard buffered messages
175
+ return new Promise((resolve) => {
176
+ const handler = () => {};
177
+ this.ws.on('message', handler);
178
+ setTimeout(() => {
179
+ this.ws.removeListener('message', handler);
180
+ resolve();
181
+ }, 300);
182
+ });
183
+ }
184
+
185
+ _call(method, params) {
186
+ return new Promise((resolve, reject) => {
187
+ const id = this._nextId();
188
+ const msg = { jsonrpc: '2.0', id, method };
189
+ if (params) msg.params = params;
190
+
191
+ const timeout = setTimeout(() => {
192
+ this._pending.delete(id);
193
+ reject(new Error(`Timeout waiting for ${method}`));
194
+ }, 30000);
195
+
196
+ this._pending.set(id, { resolve, reject, timeout });
197
+ this._send(msg);
198
+
199
+ // If listener not running, read directly
200
+ if (!this._listening) {
201
+ const directHandler = (data) => {
202
+ const resp = JSON.parse(data.toString());
203
+ if (String(resp.id) === id) {
204
+ this.ws.removeListener('message', directHandler);
205
+ clearTimeout(timeout);
206
+ this._pending.delete(id);
207
+ resolve(resp.result || {});
208
+ }
209
+ };
210
+ this.ws.on('message', directHandler);
211
+ }
212
+ });
213
+ }
214
+
215
+ _startListener() {
216
+ this._listening = true;
217
+ this.ws.on('message', (data) => {
218
+ try {
219
+ const msg = JSON.parse(data.toString());
220
+ const id = String(msg.id || '');
221
+
222
+ // Pending response
223
+ if (this._pending.has(id)) {
224
+ const p = this._pending.get(id);
225
+ clearTimeout(p.timeout);
226
+ this._pending.delete(id);
227
+ p.resolve(msg.result || {});
228
+ return;
229
+ }
230
+
231
+ // Instruction from user
232
+ if (msg.method === 'lui/instruction' && this.onInstruction) {
233
+ const instruction = msg.params?.instruction || '';
234
+ const instrId = msg.id || '';
235
+
236
+ Promise.resolve(this.onInstruction(instruction)).then((response) => {
237
+ this._send({
238
+ jsonrpc: '2.0', id: 'resp',
239
+ method: 'lui/response',
240
+ params: { instruction_id: String(instrId), result: String(response).slice(0, 1000) }
241
+ });
242
+ }).catch(() => {});
243
+ }
244
+
245
+ // Event
246
+ if (msg.method === 'notifications/lui/event' && this.onEvent) {
247
+ const event = msg.params || {};
248
+ this.onEvent(event.type, event.data || {});
249
+ }
250
+ } catch {}
251
+ });
252
+ }
253
+ }
254
+
255
+ class ToolError extends Error {
256
+ constructor(message) {
257
+ super(message);
258
+ this.name = 'ToolError';
259
+ }
260
+ }
261
+
262
+ module.exports = { LuiBridge, ToolError };