lunel-cli 0.1.15 → 0.1.17

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.
Files changed (2) hide show
  1. package/dist/index.js +320 -57
  2. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { WebSocket } from "ws";
3
3
  import qrcode from "qrcode-terminal";
4
+ import { createOpencode } from "@opencode-ai/sdk";
4
5
  import Ignore from "ignore";
5
6
  const ignore = Ignore.default;
6
7
  import * as fs from "fs/promises";
@@ -8,16 +9,24 @@ import * as path from "path";
8
9
  import * as os from "os";
9
10
  import { spawn, execSync } from "child_process";
10
11
  import { createServer, createConnection } from "net";
12
+ import { createInterface } from "readline";
11
13
  const PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
12
- const VERSION = "0.1.3";
14
+ import { createRequire } from "module";
15
+ const __require = createRequire(import.meta.url);
16
+ const VERSION = __require("../package.json").version;
13
17
  // Root directory - sandbox all file operations to this
14
18
  const ROOT_DIR = process.cwd();
15
- // Terminal sessions
16
- const terminals = new Map();
19
+ // Terminal sessions (managed by Rust PTY binary)
20
+ const terminals = new Set();
21
+ // PTY binary process
22
+ let ptyProcess = null;
23
+ const ptyPendingSpawns = new Map();
17
24
  const processes = new Map();
18
25
  const processOutputBuffers = new Map();
19
26
  // CPU usage tracking
20
27
  let lastCpuInfo = null;
28
+ // OpenCode client
29
+ let opencodeClient = null;
21
30
  // Proxy tunnel management
22
31
  let currentSessionCode = null;
23
32
  const activeTunnels = new Map();
@@ -529,53 +538,113 @@ async function handleGitDiscard(payload) {
529
538
  return {};
530
539
  }
531
540
  // ============================================================================
532
- // Terminal Handlers
541
+ // Terminal Handlers (delegates to Rust PTY binary)
533
542
  // ============================================================================
534
543
  let dataChannel = null;
535
- function handleTerminalSpawn(payload) {
536
- const shell = payload.shell || process.env.SHELL || "/bin/sh";
537
- const cols = payload.cols || 80;
538
- const rows = payload.rows || 24;
539
- const terminalId = `term-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
540
- const proc = spawn(shell, [], {
544
+ function getPtyBinaryPath() {
545
+ // TODO: Switch to GitHub releases download for production
546
+ return path.join(os.homedir(), "lunel-pty");
547
+ }
548
+ function ensurePtyProcess() {
549
+ if (ptyProcess && ptyProcess.exitCode === null)
550
+ return;
551
+ const binPath = getPtyBinaryPath();
552
+ ptyProcess = spawn(binPath, [], {
541
553
  cwd: ROOT_DIR,
542
- env: {
543
- ...process.env,
544
- TERM: "xterm-256color",
545
- COLUMNS: cols.toString(),
546
- LINES: rows.toString(),
547
- },
548
554
  stdio: ["pipe", "pipe", "pipe"],
549
555
  });
550
- terminals.set(terminalId, proc);
551
- // Stream output to app via data channel
552
- const sendOutput = (data) => {
553
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
554
- const msg = {
555
- v: 1,
556
- id: `evt-${Date.now()}`,
557
- ns: "terminal",
558
- action: "output",
559
- payload: { terminalId, data: data.toString() },
560
- };
561
- dataChannel.send(JSON.stringify(msg));
556
+ ptyProcess.stderr?.on("data", (data) => {
557
+ console.error("[pty]", data.toString().trim());
558
+ });
559
+ ptyProcess.on("exit", (code) => {
560
+ console.log(`[pty] PTY process exited with code ${code}`);
561
+ ptyProcess = null;
562
+ // Reject all pending spawns
563
+ for (const [id, pending] of ptyPendingSpawns) {
564
+ pending.reject(new Error("PTY process exited"));
562
565
  }
563
- };
564
- proc.stdout?.on("data", sendOutput);
565
- proc.stderr?.on("data", sendOutput);
566
- proc.on("close", (code) => {
567
- terminals.delete(terminalId);
568
- if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
569
- const msg = {
570
- v: 1,
571
- id: `evt-${Date.now()}`,
572
- ns: "terminal",
573
- action: "exit",
574
- payload: { terminalId, code },
575
- };
576
- dataChannel.send(JSON.stringify(msg));
566
+ ptyPendingSpawns.clear();
567
+ });
568
+ // Parse stdout line by line for events from the Rust binary
569
+ const rl = createInterface({ input: ptyProcess.stdout });
570
+ rl.on("line", (line) => {
571
+ let event;
572
+ try {
573
+ event = JSON.parse(line);
574
+ }
575
+ catch {
576
+ return;
577
+ }
578
+ if (event.event === "spawned") {
579
+ const pending = ptyPendingSpawns.get(event.id);
580
+ if (pending) {
581
+ pending.resolve();
582
+ ptyPendingSpawns.delete(event.id);
583
+ }
584
+ }
585
+ else if (event.event === "state") {
586
+ // Forward screen state to app via data channel
587
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
588
+ const msg = {
589
+ v: 1,
590
+ id: `evt-${Date.now()}`,
591
+ ns: "terminal",
592
+ action: "state",
593
+ payload: {
594
+ terminalId: event.id,
595
+ buffer: event.buffer,
596
+ cursorX: event.cursorX,
597
+ cursorY: event.cursorY,
598
+ cols: event.cols,
599
+ rows: event.rows,
600
+ },
601
+ };
602
+ dataChannel.send(JSON.stringify(msg));
603
+ }
604
+ }
605
+ else if (event.event === "exit") {
606
+ terminals.delete(event.id);
607
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
608
+ const msg = {
609
+ v: 1,
610
+ id: `evt-${Date.now()}`,
611
+ ns: "terminal",
612
+ action: "exit",
613
+ payload: { terminalId: event.id, code: event.code },
614
+ };
615
+ dataChannel.send(JSON.stringify(msg));
616
+ }
617
+ }
618
+ else if (event.event === "error") {
619
+ console.error(`[pty] Error for ${event.id}: ${event.message}`);
577
620
  }
578
621
  });
622
+ }
623
+ function sendToPty(cmd) {
624
+ if (!ptyProcess || !ptyProcess.stdin) {
625
+ throw Object.assign(new Error("PTY process not running"), { code: "ENOPTY" });
626
+ }
627
+ ptyProcess.stdin.write(JSON.stringify(cmd) + "\n");
628
+ }
629
+ async function handleTerminalSpawn(payload) {
630
+ ensurePtyProcess();
631
+ const shell = payload.shell || process.env.SHELL || "/bin/sh";
632
+ const cols = payload.cols || 80;
633
+ const rows = payload.rows || 24;
634
+ const terminalId = `term-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
635
+ // Wait for the Rust binary to confirm spawn
636
+ const spawnPromise = new Promise((resolve, reject) => {
637
+ ptyPendingSpawns.set(terminalId, { resolve, reject });
638
+ setTimeout(() => {
639
+ if (ptyPendingSpawns.has(terminalId)) {
640
+ ptyPendingSpawns.delete(terminalId);
641
+ reject(new Error("Spawn timed out"));
642
+ }
643
+ }, 10000);
644
+ });
645
+ sendToPty({ cmd: "spawn", id: terminalId, shell, cols, rows });
646
+ await spawnPromise;
647
+ terminals.add(terminalId);
579
648
  return { terminalId };
580
649
  }
581
650
  function handleTerminalWrite(payload) {
@@ -585,10 +654,9 @@ function handleTerminalWrite(payload) {
585
654
  throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
586
655
  if (typeof data !== "string")
587
656
  throw Object.assign(new Error("data is required"), { code: "EINVAL" });
588
- const proc = terminals.get(terminalId);
589
- if (!proc)
657
+ if (!terminals.has(terminalId))
590
658
  throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
591
- proc.stdin?.write(data);
659
+ sendToPty({ cmd: "write", id: terminalId, data });
592
660
  return {};
593
661
  }
594
662
  function handleTerminalResize(payload) {
@@ -597,21 +665,18 @@ function handleTerminalResize(payload) {
597
665
  const rows = payload.rows;
598
666
  if (!terminalId)
599
667
  throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
600
- const proc = terminals.get(terminalId);
601
- if (!proc)
668
+ if (!terminals.has(terminalId))
602
669
  throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
603
- // Note: For proper PTY resize, you'd need node-pty
604
- // This is a simplified version
670
+ sendToPty({ cmd: "resize", id: terminalId, cols, rows });
605
671
  return {};
606
672
  }
607
673
  function handleTerminalKill(payload) {
608
674
  const terminalId = payload.terminalId;
609
675
  if (!terminalId)
610
676
  throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
611
- const proc = terminals.get(terminalId);
612
- if (!proc)
677
+ if (!terminals.has(terminalId))
613
678
  throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
614
- proc.kill();
679
+ sendToPty({ cmd: "kill", id: terminalId });
615
680
  terminals.delete(terminalId);
616
681
  return {};
617
682
  }
@@ -621,7 +686,7 @@ function handleTerminalKill(payload) {
621
686
  function handleSystemCapabilities() {
622
687
  return {
623
688
  version: VERSION,
624
- namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http", "proxy"],
689
+ namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http", "ai", "proxy"],
625
690
  platform: os.platform(),
626
691
  rootDir: ROOT_DIR,
627
692
  hostname: os.hostname(),
@@ -1085,6 +1150,145 @@ async function handleHttpRequest(payload) {
1085
1150
  }
1086
1151
  }
1087
1152
  // ============================================================================
1153
+ // AI Handlers (OpenCode SDK)
1154
+ // ============================================================================
1155
+ async function handleAiCreateSession(payload) {
1156
+ const title = payload.title || undefined;
1157
+ const response = await opencodeClient.session.create({ body: { title } });
1158
+ return { session: response.data };
1159
+ }
1160
+ async function handleAiListSessions() {
1161
+ const response = await opencodeClient.session.list();
1162
+ return { sessions: response.data };
1163
+ }
1164
+ async function handleAiGetSession(payload) {
1165
+ const id = payload.id;
1166
+ const response = await opencodeClient.session.get({ path: { id } });
1167
+ return { session: response.data };
1168
+ }
1169
+ async function handleAiDeleteSession(payload) {
1170
+ const id = payload.id;
1171
+ await opencodeClient.session.delete({ path: { id } });
1172
+ return {};
1173
+ }
1174
+ async function handleAiGetMessages(payload) {
1175
+ const id = payload.id;
1176
+ const response = await opencodeClient.session.messages({ path: { id } });
1177
+ return { messages: response.data };
1178
+ }
1179
+ async function handleAiPrompt(payload) {
1180
+ const sessionId = payload.sessionId;
1181
+ const text = payload.text;
1182
+ const model = payload.model;
1183
+ // Fire and forget — results stream via SSE events forwarded on data channel
1184
+ opencodeClient.session.prompt({
1185
+ path: { id: sessionId },
1186
+ body: {
1187
+ parts: [{ type: "text", text }],
1188
+ ...(model ? { model } : {}),
1189
+ },
1190
+ }).catch((err) => {
1191
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1192
+ dataChannel.send(JSON.stringify({
1193
+ v: 1,
1194
+ id: `evt-${Date.now()}`,
1195
+ ns: "ai",
1196
+ action: "event",
1197
+ payload: {
1198
+ type: "prompt_error",
1199
+ properties: { sessionId, error: err.message },
1200
+ },
1201
+ }));
1202
+ }
1203
+ });
1204
+ return { ack: true };
1205
+ }
1206
+ async function handleAiAbort(payload) {
1207
+ const id = payload.sessionId;
1208
+ await opencodeClient.session.abort({ path: { id } });
1209
+ return {};
1210
+ }
1211
+ async function handleAiAgents() {
1212
+ const response = await opencodeClient.app.agents();
1213
+ return { agents: response.data };
1214
+ }
1215
+ async function handleAiProviders() {
1216
+ const response = await opencodeClient.config.providers();
1217
+ return { providers: response.data };
1218
+ }
1219
+ async function handleAiSetAuth(payload) {
1220
+ const providerId = payload.providerId;
1221
+ const key = payload.key;
1222
+ await opencodeClient.auth.set({
1223
+ path: { id: providerId },
1224
+ body: { type: "api", key },
1225
+ });
1226
+ return {};
1227
+ }
1228
+ async function handleAiCommand(payload) {
1229
+ const sessionId = payload.sessionId;
1230
+ const command = payload.command;
1231
+ const args = payload.arguments || "";
1232
+ const response = await opencodeClient.session.command({
1233
+ path: { id: sessionId },
1234
+ body: { command, arguments: args },
1235
+ });
1236
+ return { result: response.data };
1237
+ }
1238
+ async function handleAiRevert(payload) {
1239
+ const sessionId = payload.sessionId;
1240
+ const messageId = payload.messageId;
1241
+ await opencodeClient.session.revert({
1242
+ path: { id: sessionId },
1243
+ body: { messageID: messageId },
1244
+ });
1245
+ return {};
1246
+ }
1247
+ async function handleAiUnrevert(payload) {
1248
+ const sessionId = payload.sessionId;
1249
+ await opencodeClient.session.unrevert({ path: { id: sessionId } });
1250
+ return {};
1251
+ }
1252
+ async function handleAiShare(payload) {
1253
+ const sessionId = payload.sessionId;
1254
+ const response = await opencodeClient.session.share({ path: { id: sessionId } });
1255
+ return { share: response.data };
1256
+ }
1257
+ async function handleAiPermissionReply(payload) {
1258
+ const permissionId = payload.permissionId;
1259
+ const sessionId = payload.sessionId;
1260
+ const approved = payload.approved;
1261
+ await opencodeClient.postSessionIdPermissionsPermissionId({
1262
+ path: { id: sessionId, permissionID: permissionId },
1263
+ body: { response: approved ? "once" : "reject" },
1264
+ });
1265
+ return {};
1266
+ }
1267
+ // SSE event forwarding from OpenCode to mobile app
1268
+ async function subscribeToOpenCodeEvents(client) {
1269
+ try {
1270
+ const events = await client.event.subscribe();
1271
+ for await (const event of events.stream) {
1272
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1273
+ const msg = {
1274
+ v: 1,
1275
+ id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
1276
+ ns: "ai",
1277
+ action: "event",
1278
+ payload: {
1279
+ type: event.type,
1280
+ properties: event.properties,
1281
+ },
1282
+ };
1283
+ dataChannel.send(JSON.stringify(msg));
1284
+ }
1285
+ }
1286
+ }
1287
+ catch (err) {
1288
+ console.error("OpenCode event stream error:", err);
1289
+ setTimeout(() => subscribeToOpenCodeEvents(client), 3000);
1290
+ }
1291
+ }
1088
1292
  // Proxy Handlers
1089
1293
  // ============================================================================
1090
1294
  async function scanDevPorts() {
@@ -1313,7 +1517,7 @@ async function processMessage(message) {
1313
1517
  case "terminal":
1314
1518
  switch (action) {
1315
1519
  case "spawn":
1316
- result = handleTerminalSpawn(payload);
1520
+ result = await handleTerminalSpawn(payload);
1317
1521
  break;
1318
1522
  case "write":
1319
1523
  result = handleTerminalWrite(payload);
@@ -1394,6 +1598,57 @@ async function processMessage(message) {
1394
1598
  throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
1395
1599
  }
1396
1600
  break;
1601
+ case "ai":
1602
+ switch (action) {
1603
+ case "prompt":
1604
+ result = await handleAiPrompt(payload);
1605
+ break;
1606
+ case "createSession":
1607
+ result = await handleAiCreateSession(payload);
1608
+ break;
1609
+ case "listSessions":
1610
+ result = await handleAiListSessions();
1611
+ break;
1612
+ case "getSession":
1613
+ result = await handleAiGetSession(payload);
1614
+ break;
1615
+ case "deleteSession":
1616
+ result = await handleAiDeleteSession(payload);
1617
+ break;
1618
+ case "getMessages":
1619
+ result = await handleAiGetMessages(payload);
1620
+ break;
1621
+ case "abort":
1622
+ result = await handleAiAbort(payload);
1623
+ break;
1624
+ case "agents":
1625
+ result = await handleAiAgents();
1626
+ break;
1627
+ case "providers":
1628
+ result = await handleAiProviders();
1629
+ break;
1630
+ case "setAuth":
1631
+ result = await handleAiSetAuth(payload);
1632
+ break;
1633
+ case "command":
1634
+ result = await handleAiCommand(payload);
1635
+ break;
1636
+ case "revert":
1637
+ result = await handleAiRevert(payload);
1638
+ break;
1639
+ case "unrevert":
1640
+ result = await handleAiUnrevert(payload);
1641
+ break;
1642
+ case "share":
1643
+ result = await handleAiShare(payload);
1644
+ break;
1645
+ case "permission":
1646
+ result = await handleAiPermissionReply(payload);
1647
+ break;
1648
+ default:
1649
+ throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
1650
+ }
1651
+ break;
1397
1652
  case "proxy":
1398
1653
  switch (action) {
1399
1654
  case "connect":
@@ -1557,9 +1812,10 @@ function connectWebSocket(code) {
1557
1812
  // Handle graceful shutdown
1558
1813
  process.on("SIGINT", () => {
1559
1814
  console.log("\nShutting down...");
1560
- // Kill all terminals
1561
- for (const [id, proc] of terminals) {
1562
- proc.kill();
1815
+ // Kill PTY process (kills all terminals)
1816
+ if (ptyProcess) {
1817
+ ptyProcess.kill();
1818
+ ptyProcess = null;
1563
1819
  }
1564
1820
  terminals.clear();
1565
1821
  // Kill all managed processes
@@ -1578,6 +1834,13 @@ async function main() {
1578
1834
  console.log("Lunel CLI v" + VERSION);
1579
1835
  console.log("=".repeat(20) + "\n");
1580
1836
  try {
1837
+ // Start OpenCode server + client
1838
+ console.log("Starting OpenCode...");
1839
+ const { client } = await createOpencode();
1840
+ opencodeClient = client;
1841
+ console.log("OpenCode ready.\n");
1842
+ // Subscribe to OpenCode events
1843
+ subscribeToOpenCodeEvents(client);
1581
1844
  const code = await createSession();
1582
1845
  displayQR(code);
1583
1846
  connectWebSocket(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",
@@ -17,7 +17,8 @@
17
17
  "lunel-cli": "./dist/index.js"
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "bin"
21
22
  ],
22
23
  "scripts": {
23
24
  "build": "tsc",
@@ -25,6 +26,7 @@
25
26
  "prepublishOnly": "npm run build"
26
27
  },
27
28
  "dependencies": {
29
+ "@opencode-ai/sdk": "^1.1.56",
28
30
  "ignore": "^6.0.2",
29
31
  "qrcode-terminal": "^0.12.0",
30
32
  "ws": "^8.18.0"