From 68b54f6fb1fcad263d36fbaaa0745b20d3c5b4eb Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 14:56:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20`lisa=20pair`=20=E2=80=94=20sh?= =?UTF-8?q?ow=20a=20QR=20to=20pair=20a=20phone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mac-side counterpart to Lisa Pocket's QR scanner (docs/IOS_COMPANION_PLAN.md §5.3). Until now /api/pair/start minted a per-device token but nothing rendered the lisa-pair:// QR — so the phone scanner had nothing to scan and you had to paste the string by hand. `lisa pair [--host H] [--port N] [--name LABEL]` is a thin loopback client to a running serve: POSTs /api/pair/start (loopback-only), builds lisa-pair://v1?host=&port=&token=&name=, and renders it as a terminal QR (+ a pasteable URL fallback). --host defaults to the first non-internal LAN IPv4; pass a tailnet name to pair over Tailscale. Adds qrcode-terminal (zero runtime deps — bundles its own encoder). node-pty is kept in the lockfile (lock recomputed with --package-lock-only so the optional dep isn't pruned under Node 26; CI on Node 22 still builds it). Verified: typecheck + build clean; npm test -> 811 pass / 1 skip; pure helpers (parsePairArgs / detectLanHost / buildPairUrl) unit-tested; terminal QR render smoke-tested. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 18 +++++- package.json | 2 + src/cli.ts | 13 ++++- src/cli/pair.test.ts | 74 +++++++++++++++++++++++++ src/cli/pair.ts | 128 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 src/cli/pair.test.ts create mode 100644 src/cli/pair.ts diff --git a/package-lock.json b/package-lock.json index dd7505a..0c483c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "@anthropic-ai/sdk": "^0.92.0", "@google/genai": "^2.0.1", "@modelcontextprotocol/sdk": "^1.29.0", - "node-pty": "*", "openai": "^6.35.0", + "qrcode-terminal": "^0.12.0", "undici": "^8.2.0" }, "bin": { @@ -21,6 +21,7 @@ }, "devDependencies": { "@types/node": "^22.10.0", + "@types/qrcode-terminal": "^0.12.2", "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.7.0" @@ -1153,6 +1154,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -2340,6 +2348,14 @@ "node": ">= 0.10" } }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", diff --git a/package.json b/package.json index 83af806..29e6530 100644 --- a/package.json +++ b/package.json @@ -69,10 +69,12 @@ "@google/genai": "^2.0.1", "@modelcontextprotocol/sdk": "^1.29.0", "openai": "^6.35.0", + "qrcode-terminal": "^0.12.0", "undici": "^8.2.0" }, "devDependencies": { "@types/node": "^22.10.0", + "@types/qrcode-terminal": "^0.12.2", "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.7.0" diff --git a/src/cli.ts b/src/cli.ts index 10fd49f..eadc84d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -66,6 +66,8 @@ INSPECTION revoke-all. lisa sense [list] Recent ambient sense events + granted signals. lisa agents Snapshot of agent sessions across all observers. + lisa pair [--host H] Show a QR to pair a phone (Lisa Pocket) — mints a + per-device token via a running serve (localhost). LIFECYCLE lisa birth Run the birth ritual (auto-runs on first launch). @@ -172,7 +174,8 @@ interface ParsedArgs { | "model" | "consent" | "sense" - | "agents"; + | "agents" + | "pair"; subargs: string[]; serveWeb: boolean; serveImessage: boolean; @@ -294,7 +297,8 @@ function parseArgs(argv: string[]): ParsedArgs { first === "model" || first === "consent" || first === "sense" || - first === "agents" + first === "agents" || + first === "pair" ) { out.subcommand = first; out.subargs = positional.slice(1); @@ -490,6 +494,11 @@ async function main(): Promise { process.exit(await runAgentsCommand(args.subargs)); } + if (args.subcommand === "pair") { + const { runPairCommand } = await import("./cli/pair.js"); + process.exit(await runPairCommand(args.subargs)); + } + if (args.subcommand === "sessions") { const sessions = await listSessionsOnDisk(); for (const s of sessions) { diff --git a/src/cli/pair.test.ts b/src/cli/pair.test.ts new file mode 100644 index 0000000..379a22c --- /dev/null +++ b/src/cli/pair.test.ts @@ -0,0 +1,74 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { parsePairArgs, detectLanHost, buildPairUrl } from "./pair.js"; +import type os from "node:os"; + +describe("parsePairArgs", () => { + test("defaults: port 5757, name 'phone', host undefined", () => { + const prevPort = process.env.LISA_WEB_PORT; + delete process.env.LISA_WEB_PORT; + assert.deepEqual(parsePairArgs([]), { host: undefined, port: 5757, name: "phone" }); + if (prevPort !== undefined) process.env.LISA_WEB_PORT = prevPort; + }); + + test("--host / --port / --name (space and = forms)", () => { + assert.deepEqual(parsePairArgs(["--host", "mac.tailnet.ts.net", "--port", "6000", "--name", "iPhone"]), { + host: "mac.tailnet.ts.net", + port: 6000, + name: "iPhone", + }); + assert.deepEqual(parsePairArgs(["--host=10.0.0.5", "--port=8080", "--name=Pixel"]), { + host: "10.0.0.5", + port: 8080, + name: "Pixel", + }); + }); + + test("missing value / bad port / unknown arg → error", () => { + assert.ok("error" in parsePairArgs(["--host"])); + assert.ok("error" in parsePairArgs(["--port", "0"])); + assert.ok("error" in parsePairArgs(["--port", "abc"])); + assert.ok("error" in parsePairArgs(["--nope"])); + }); +}); + +describe("detectLanHost", () => { + test("returns the first non-internal IPv4", () => { + const ifaces = { + lo0: [{ family: "IPv4", internal: true, address: "127.0.0.1" }], + en0: [ + { family: "IPv6", internal: false, address: "fe80::1" }, + { family: "IPv4", internal: false, address: "192.168.1.42" }, + ], + } as unknown as NodeJS.Dict; + assert.equal(detectLanHost(ifaces), "192.168.1.42"); + }); + + test("only loopback → undefined", () => { + const ifaces = { + lo0: [{ family: "IPv4", internal: true, address: "127.0.0.1" }], + } as unknown as NodeJS.Dict; + assert.equal(detectLanHost(ifaces), undefined); + }); +}); + +describe("buildPairUrl", () => { + test("encodes host/port/token/name into a lisa-pair:// v1 URL", () => { + const url = buildPairUrl("192.168.1.42", 5757, "abc123", "my phone"); + const u = new URL(url); + assert.equal(u.protocol, "lisa-pair:"); + assert.equal(u.searchParams.get("host"), "192.168.1.42"); + assert.equal(u.searchParams.get("port"), "5757"); + assert.equal(u.searchParams.get("token"), "abc123"); + assert.equal(u.searchParams.get("name"), "my phone"); // spaces survive a round-trip + }); + + test("the app's pairing parser can read it back (host/token/port)", () => { + // Mirrors AppState.applyPairing: read query items, fall back to URL parts. + const url = buildPairUrl("10.0.0.5", 6000, "tok-XYZ", "iPad"); + const u = new URL(url); + assert.equal(u.searchParams.get("host"), "10.0.0.5"); + assert.equal(Number(u.searchParams.get("port")), 6000); + assert.equal(u.searchParams.get("token"), "tok-XYZ"); + }); +}); diff --git a/src/cli/pair.ts b/src/cli/pair.ts new file mode 100644 index 0000000..c87330d --- /dev/null +++ b/src/cli/pair.ts @@ -0,0 +1,128 @@ +/** + * `lisa pair` — mint a per-device token and show a scannable QR so a phone pairs + * without hand-typing host/token (docs/IOS_COMPANION_PLAN.md §5.3). This is the + * Mac-side counterpart to Lisa Pocket's QR scanner. + * + * A thin client to a running `lisa serve --web` (loopback, like `lisa agents + * pty`): it POSTs /api/pair/start — which is loopback-only and mints a per-device + * token, returned ONCE — then builds a `lisa-pair://` URL and renders it as a + * terminal QR. The token rides in the QR (and the printed URL fallback); the + * phone then authenticates with it like any device token. + */ +// qrcode-terminal is CommonJS — Node's ESM loader only exposes its default +// export, so import the module object and call .generate off it. +import qrcodeTerminal from "qrcode-terminal"; +import os from "node:os"; + +const DEFAULT_PORT = 5757; + +export interface PairArgs { + /** Host the phone will reach the Mac at (LAN IP or tailnet name). */ + host?: string; + /** Port of the running serve (loopback target + encoded for the phone). */ + port: number; + /** Device label stored alongside the minted token. */ + name: string; +} + +const USAGE = "usage: lisa pair [--host ] [--port N] [--name