@kud/gtv-cli 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/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # gtv-cli
2
+
3
+ Control your Google TV from the terminal with the Android TV Remote protocol.
4
+
5
+ ## Features
6
+
7
+ - **Pair-on-first-run** - run `gtv`, pair once, then jump straight into the remote.
8
+ - **Fullscreen remote** - Ink-powered alternate-screen interface centred in your terminal.
9
+ - **One-shot controls** - send common keys like `home`, `back`, `select`, volume, and media controls directly from the CLI.
10
+ - **Device discovery** - find Google TV devices on the local network via mDNS.
11
+ - **Health checks** - inspect saved pairing, discovery, and protocol connectivity with `gtv status` or `gtv doctor`.
12
+
13
+ ## Quick Start
14
+
15
+ ```console
16
+ npm install -g @kud/gtv-cli
17
+ gtv
18
+ ```
19
+
20
+ On first launch, `gtv` asks whether to pair a TV. Type the PIN shown on the TV into the terminal, press Enter, and the fullscreen remote opens automatically.
21
+
22
+ ## CLI Reference
23
+
24
+ ```console
25
+ gtv # open the fullscreen remote, pairing first if needed
26
+ gtv pair # pair or re-pair with a Google TV
27
+ gtv unpair # forget the saved local pairing
28
+ gtv status # check config, discovery, and remote protocol connectivity
29
+ gtv doctor # alias for status
30
+ gtv discover # scan the network for Google TV devices
31
+ gtv discover --select
32
+ ```
33
+
34
+ ### Remote Keys
35
+
36
+ ```console
37
+ gtv home
38
+ gtv back
39
+ gtv up
40
+ gtv down
41
+ gtv left
42
+ gtv right
43
+ gtv select
44
+ gtv power
45
+ gtv play
46
+ gtv stop
47
+ gtv next
48
+ gtv prev
49
+ gtv fwd
50
+ gtv rwd
51
+ gtv mute
52
+ gtv vol up
53
+ gtv vol down
54
+ gtv vol mute
55
+ gtv key settings
56
+ gtv app https://www.netflix.com/
57
+ ```
58
+
59
+ ## Development
60
+
61
+ ```console
62
+ npm install
63
+ npm run dev # run the CLI from source
64
+ npm run dev:pair # run the pairing flow
65
+ npm run dev:discover # scan for devices
66
+ npm run typecheck
67
+ npm run build
68
+ ```
69
+
70
+ ## Tech Stack
71
+
72
+ - TypeScript
73
+ - React + Ink
74
+ - Commander
75
+ - androidtv-remote
76
+ - tsup
77
+
78
+ ## Notes
79
+
80
+ `gtv unpair` removes the local CLI config and certificate from `~/.config/gtv/config.json`. If your TV keeps its own trusted-device entry, remove it from the TV's remote device settings as well.
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,698 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.tsx
4
+ import { Command } from "commander";
5
+ import { render } from "ink";
6
+ import chalk4 from "chalk";
7
+ import inquirer3 from "inquirer";
8
+
9
+ // src/app.tsx
10
+ import { useState as useState2 } from "react";
11
+ import { Box as Box3, useApp, useInput, useStdout } from "ink";
12
+
13
+ // src/hooks/use-remote.ts
14
+ import { useEffect, useRef, useState } from "react";
15
+ import { AndroidRemote, RemoteDirection } from "androidtv-remote";
16
+
17
+ // src/lib/config.ts
18
+ import fs from "fs";
19
+ import path from "path";
20
+ var CONFIG_PATH = path.join(
21
+ process.env["HOME"] ?? "~",
22
+ ".config",
23
+ "gtv",
24
+ "config.json"
25
+ );
26
+ var readConfig = () => {
27
+ if (!fs.existsSync(CONFIG_PATH)) return null;
28
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
29
+ };
30
+ var writeConfig = (config) => {
31
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
32
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
33
+ };
34
+ var deleteConfig = () => {
35
+ if (!fs.existsSync(CONFIG_PATH)) return false;
36
+ fs.unlinkSync(CONFIG_PATH);
37
+ return true;
38
+ };
39
+
40
+ // src/lib/remote.ts
41
+ var stopRemote = (remote) => {
42
+ const typedRemote = remote;
43
+ const clients = [
44
+ typedRemote.pairingManager?.client,
45
+ typedRemote.remoteManager?.client
46
+ ].filter((client) => Boolean(client));
47
+ try {
48
+ for (const client of clients) {
49
+ client.removeAllListeners("close");
50
+ client.destroy();
51
+ }
52
+ } catch {
53
+ try {
54
+ remote.stop();
55
+ } catch {
56
+ }
57
+ }
58
+ };
59
+
60
+ // src/hooks/use-remote.ts
61
+ var useRemote = () => {
62
+ const [state, setState] = useState({
63
+ connected: false,
64
+ powered: null,
65
+ volume: null,
66
+ currentApp: null,
67
+ error: null
68
+ });
69
+ const remoteRef = useRef(null);
70
+ useEffect(() => {
71
+ const config = readConfig();
72
+ if (!config) {
73
+ setState((s) => ({
74
+ ...s,
75
+ error: "No TV configured. Run `gtv pair` first."
76
+ }));
77
+ return;
78
+ }
79
+ const remote = new AndroidRemote(config.host, {
80
+ pairing_port: 6467,
81
+ remote_port: config.port ?? 6466,
82
+ service_name: config.name ?? "gtv-cli",
83
+ ...config.cert ? { cert: config.cert } : {}
84
+ });
85
+ remoteRef.current = remote;
86
+ remote.on(
87
+ "ready",
88
+ () => setState((s) => ({ ...s, connected: true, error: null }))
89
+ );
90
+ remote.on(
91
+ "powered",
92
+ (powered) => setState((s) => ({ ...s, powered }))
93
+ );
94
+ remote.on(
95
+ "volume",
96
+ (volume) => setState((s) => ({ ...s, volume }))
97
+ );
98
+ remote.on(
99
+ "current_app",
100
+ (app) => setState((s) => ({ ...s, currentApp: app }))
101
+ );
102
+ remote.on(
103
+ "error",
104
+ (err) => setState((s) => ({ ...s, connected: false, error: err.message }))
105
+ );
106
+ remote.on(
107
+ "unpaired",
108
+ () => setState((s) => ({
109
+ ...s,
110
+ connected: false,
111
+ error: "TV rejected the saved pairing. Run `gtv pair` again."
112
+ }))
113
+ );
114
+ remote.start().catch((err) => setState((s) => ({ ...s, error: err.message })));
115
+ return () => {
116
+ stopRemote(remote);
117
+ };
118
+ }, []);
119
+ const sendKey2 = (keyCode, direction = RemoteDirection.SHORT) => {
120
+ remoteRef.current?.sendKey(keyCode, direction);
121
+ };
122
+ return { state, sendKey: sendKey2 };
123
+ };
124
+
125
+ // src/components/status-bar.tsx
126
+ import { Box, Text } from "ink";
127
+ import { jsx, jsxs } from "react/jsx-runtime";
128
+ var APP_NAMES = {
129
+ "com.netflix.ninja": "Netflix",
130
+ "com.google.android.youtube.tv": "YouTube",
131
+ "com.spotify.tv.android": "Spotify",
132
+ "com.apple.atve.androidtv.appletv": "Apple TV+",
133
+ "com.disney.disneyplus": "Disney+",
134
+ "tv.twitch.android.app": "Twitch",
135
+ "com.amazon.amazonvideo.livingroom": "Prime Video",
136
+ "com.google.android.tvlauncher": "Home",
137
+ "com.google.android.leanbacklauncher": "Home",
138
+ "com.plexapp.android": "Plex",
139
+ "com.mubi": "MUBI"
140
+ };
141
+ var StatusBar = ({ state, lastKey }) => {
142
+ const appName = state.currentApp ? APP_NAMES[state.currentApp] ?? state.currentApp.split(".").pop() ?? "\u2013" : "\u2013";
143
+ const volDisplay = state.volume ? state.volume.muted ? "muted" : `${state.volume.level}/${state.volume.maximum}` : "\u2013";
144
+ return /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", marginBottom: 1, gap: 2, children: [
145
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "GTV" }),
146
+ /* @__PURE__ */ jsx(Text, { color: state.connected ? "green" : "yellow", children: state.connected ? "\u25CF connected" : "\u25CB connecting\u2026" }),
147
+ /* @__PURE__ */ jsxs(Text, { children: [
148
+ "app: ",
149
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: appName })
150
+ ] }),
151
+ /* @__PURE__ */ jsxs(Text, { children: [
152
+ "vol: ",
153
+ /* @__PURE__ */ jsx(Text, { color: state.volume?.muted ? "red" : "white", children: volDisplay })
154
+ ] }),
155
+ lastKey && /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
156
+ "\u21A9 ",
157
+ lastKey
158
+ ] }),
159
+ state.error && /* @__PURE__ */ jsx(Text, { color: "red", children: state.error })
160
+ ] });
161
+ };
162
+
163
+ // src/components/remote-layout.tsx
164
+ import { Box as Box2, Text as Text2 } from "ink";
165
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
166
+ var Btn = ({
167
+ label,
168
+ hint,
169
+ width = 9
170
+ }) => /* @__PURE__ */ jsxs2(
171
+ Box2,
172
+ {
173
+ borderStyle: "single",
174
+ width,
175
+ height: 3,
176
+ alignItems: "center",
177
+ justifyContent: "center",
178
+ children: [
179
+ /* @__PURE__ */ jsx2(Text2, { children: label }),
180
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", dimColor: true, children: ` ${hint}` })
181
+ ]
182
+ }
183
+ );
184
+ var Row = ({ children }) => /* @__PURE__ */ jsx2(Box2, { columnGap: 1, justifyContent: "center", children });
185
+ var RemoteLayout = () => /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", alignItems: "center", width: 58, children: [
186
+ /* @__PURE__ */ jsxs2(Row, { children: [
187
+ /* @__PURE__ */ jsx2(Btn, { label: "PWR", hint: "p" }),
188
+ /* @__PURE__ */ jsx2(Btn, { label: "HOME", hint: "h", width: 10 }),
189
+ /* @__PURE__ */ jsx2(Btn, { label: "BACK", hint: "esc", width: 10 }),
190
+ /* @__PURE__ */ jsx2(Btn, { label: "MENU", hint: "e", width: 10 }),
191
+ /* @__PURE__ */ jsx2(Btn, { label: "SEARCH", hint: "s", width: 12 })
192
+ ] }),
193
+ /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", alignItems: "center", children: [
194
+ /* @__PURE__ */ jsx2(Btn, { label: "UP", hint: "\u2191" }),
195
+ /* @__PURE__ */ jsxs2(Row, { children: [
196
+ /* @__PURE__ */ jsx2(Btn, { label: "LEFT", hint: "\u2190" }),
197
+ /* @__PURE__ */ jsx2(Btn, { label: "OK", hint: "\u21B5" }),
198
+ /* @__PURE__ */ jsx2(Btn, { label: "RIGHT", hint: "\u2192" })
199
+ ] }),
200
+ /* @__PURE__ */ jsx2(Btn, { label: "DOWN", hint: "\u2193" })
201
+ ] }),
202
+ /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", alignItems: "center", children: [
203
+ /* @__PURE__ */ jsxs2(Row, { children: [
204
+ /* @__PURE__ */ jsx2(Btn, { label: "PREV", hint: "," }),
205
+ /* @__PURE__ */ jsx2(Btn, { label: "PLAY", hint: "spc", width: 10 }),
206
+ /* @__PURE__ */ jsx2(Btn, { label: "STOP", hint: "." }),
207
+ /* @__PURE__ */ jsx2(Btn, { label: "NEXT", hint: "/" })
208
+ ] }),
209
+ /* @__PURE__ */ jsxs2(Row, { children: [
210
+ /* @__PURE__ */ jsx2(Btn, { label: "REW", hint: "[" }),
211
+ /* @__PURE__ */ jsx2(Btn, { label: "MUTE", hint: "m", width: 10 }),
212
+ /* @__PURE__ */ jsx2(Btn, { label: "FWD", hint: "]" })
213
+ ] })
214
+ ] }),
215
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Row, { children: [
216
+ /* @__PURE__ */ jsx2(Btn, { label: "VOL-", hint: "-", width: 11 }),
217
+ /* @__PURE__ */ jsx2(Btn, { label: "VOL+", hint: "=", width: 11 })
218
+ ] }) }),
219
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "gray", dimColor: true, children: "q quit" }) })
220
+ ] });
221
+
222
+ // src/lib/keycodes.ts
223
+ import { RemoteKeyCode } from "androidtv-remote";
224
+ var KEYS = {
225
+ home: RemoteKeyCode.KEYCODE_HOME,
226
+ back: RemoteKeyCode.KEYCODE_BACK,
227
+ power: RemoteKeyCode.KEYCODE_POWER,
228
+ up: RemoteKeyCode.KEYCODE_DPAD_UP,
229
+ down: RemoteKeyCode.KEYCODE_DPAD_DOWN,
230
+ left: RemoteKeyCode.KEYCODE_DPAD_LEFT,
231
+ right: RemoteKeyCode.KEYCODE_DPAD_RIGHT,
232
+ select: RemoteKeyCode.KEYCODE_DPAD_CENTER,
233
+ play: RemoteKeyCode.KEYCODE_MEDIA_PLAY_PAUSE,
234
+ stop: RemoteKeyCode.KEYCODE_MEDIA_STOP,
235
+ next: RemoteKeyCode.KEYCODE_MEDIA_NEXT,
236
+ prev: RemoteKeyCode.KEYCODE_MEDIA_PREVIOUS,
237
+ fwd: RemoteKeyCode.KEYCODE_MEDIA_FAST_FORWARD,
238
+ rwd: RemoteKeyCode.KEYCODE_MEDIA_REWIND,
239
+ "vol-up": RemoteKeyCode.KEYCODE_VOLUME_UP,
240
+ "vol-down": RemoteKeyCode.KEYCODE_VOLUME_DOWN,
241
+ mute: RemoteKeyCode.KEYCODE_VOLUME_MUTE,
242
+ menu: RemoteKeyCode.KEYCODE_MENU,
243
+ search: RemoteKeyCode.KEYCODE_SEARCH,
244
+ sleep: RemoteKeyCode.KEYCODE_SLEEP,
245
+ wakeup: RemoteKeyCode.KEYCODE_WAKEUP,
246
+ input: RemoteKeyCode.KEYCODE_TV_INPUT,
247
+ enter: RemoteKeyCode.KEYCODE_ENTER,
248
+ "channel-up": RemoteKeyCode.KEYCODE_CHANNEL_UP,
249
+ "channel-down": RemoteKeyCode.KEYCODE_CHANNEL_DOWN,
250
+ info: RemoteKeyCode.KEYCODE_INFO,
251
+ guide: RemoteKeyCode.KEYCODE_GUIDE,
252
+ settings: RemoteKeyCode.KEYCODE_SETTINGS
253
+ };
254
+
255
+ // src/app.tsx
256
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
257
+ var CHAR_MAP = {
258
+ " ": "play",
259
+ h: "home",
260
+ p: "power",
261
+ m: "mute",
262
+ e: "menu",
263
+ s: "search",
264
+ ".": "stop",
265
+ ",": "prev",
266
+ "/": "next",
267
+ "[": "rwd",
268
+ "]": "fwd",
269
+ "=": "vol-up",
270
+ "-": "vol-down"
271
+ };
272
+ var App = () => {
273
+ const [lastKey, setLastKey] = useState2("");
274
+ const { exit } = useApp();
275
+ const { stdout } = useStdout();
276
+ const { state, sendKey: sendKey2 } = useRemote();
277
+ useInput((input, key2) => {
278
+ if (input === "q" || key2.ctrl && input === "c") {
279
+ exit();
280
+ return;
281
+ }
282
+ const keyName = key2.upArrow ? "up" : key2.downArrow ? "down" : key2.leftArrow ? "left" : key2.rightArrow ? "right" : key2.return ? "select" : key2.escape || key2.backspace ? "back" : CHAR_MAP[input] ?? null;
283
+ if (!keyName) return;
284
+ const keyCode = KEYS[keyName];
285
+ if (!keyCode) return;
286
+ setLastKey(keyName);
287
+ sendKey2(keyCode);
288
+ });
289
+ return /* @__PURE__ */ jsxs3(
290
+ Box3,
291
+ {
292
+ flexDirection: "column",
293
+ borderStyle: "round",
294
+ borderColor: "blue",
295
+ width: stdout.columns,
296
+ height: stdout.rows,
297
+ paddingX: 2,
298
+ paddingY: 1,
299
+ children: [
300
+ /* @__PURE__ */ jsx3(StatusBar, { state, lastKey }),
301
+ /* @__PURE__ */ jsx3(Box3, { flexGrow: 1, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx3(RemoteLayout, {}) })
302
+ ]
303
+ }
304
+ );
305
+ };
306
+
307
+ // src/commands/pair.ts
308
+ import { AndroidRemote as AndroidRemote2 } from "androidtv-remote";
309
+ import inquirer2 from "inquirer";
310
+ import ora2 from "ora";
311
+ import chalk2 from "chalk";
312
+
313
+ // src/commands/discover.ts
314
+ import { spawn } from "child_process";
315
+ import dns from "dns/promises";
316
+ import ora from "ora";
317
+ import chalk from "chalk";
318
+ import inquirer from "inquirer";
319
+ var browseServices = (timeout) => new Promise((resolve) => {
320
+ const names = [];
321
+ const proc = spawn("dns-sd", ["-B", "_androidtvremote2._tcp", "local"]);
322
+ proc.stdout.on("data", (data) => {
323
+ for (const line of data.toString().split("\n")) {
324
+ const match = line.match(
325
+ /Add\s+\d+\s+\d+\s+\S+\s+_androidtvremote2\._tcp\.\s+(.+)$/
326
+ );
327
+ if (match) names.push(match[1].trim());
328
+ }
329
+ });
330
+ setTimeout(() => {
331
+ proc.kill();
332
+ resolve([...new Set(names)]);
333
+ }, timeout);
334
+ });
335
+ var resolveService = (name) => new Promise((resolve) => {
336
+ const proc = spawn("dns-sd", [
337
+ "-L",
338
+ name,
339
+ "_androidtvremote2._tcp",
340
+ "local"
341
+ ]);
342
+ let output = "";
343
+ proc.stdout.on("data", (data) => {
344
+ output += data.toString();
345
+ const match = output.match(/can be reached at ([^:]+):(\d+)/);
346
+ if (match) {
347
+ proc.kill();
348
+ resolve({ hostname: match[1], port: parseInt(match[2], 10) });
349
+ }
350
+ });
351
+ setTimeout(() => {
352
+ proc.kill();
353
+ resolve(null);
354
+ }, 3e3);
355
+ });
356
+ var discover = async (opts = {}) => {
357
+ const timeout = opts.timeout ?? 5e3;
358
+ const spinner = opts.quiet ? null : ora("Scanning for Google TV devices\u2026").start();
359
+ const names = await browseServices(timeout);
360
+ if (names.length === 0) {
361
+ spinner?.fail("No Google TV devices found on the network.");
362
+ return [];
363
+ }
364
+ if (spinner) spinner.text = `Resolving ${names.length} device(s)\u2026`;
365
+ const resolved = await Promise.all(
366
+ names.map(async (name) => {
367
+ const service = await resolveService(name);
368
+ if (!service) return null;
369
+ const { address } = await dns.lookup(service.hostname, { family: 4 });
370
+ return {
371
+ name,
372
+ host: address,
373
+ hostname: service.hostname,
374
+ port: service.port
375
+ };
376
+ })
377
+ );
378
+ const found = resolved.filter((d) => d !== null);
379
+ if (found.length === 0) {
380
+ spinner?.fail("Found devices but could not resolve their addresses.");
381
+ return [];
382
+ }
383
+ if (spinner) {
384
+ spinner.succeed(`Found ${found.length} device(s):`);
385
+ found.forEach((d) => {
386
+ process.stdout.write(
387
+ ` ${chalk.cyan(d.name)} ${chalk.gray(`${d.host}:${d.port}`)}
388
+ `
389
+ );
390
+ });
391
+ }
392
+ if (opts.select) {
393
+ const existing = readConfig();
394
+ const selected = found.length === 1 ? found[0] : await inquirer.prompt([
395
+ {
396
+ type: "list",
397
+ name: "device",
398
+ message: "Which TV do you want to use?",
399
+ choices: found.map((d) => ({
400
+ name: `${d.name} (${d.host})`,
401
+ value: d.host
402
+ })),
403
+ default: existing?.host
404
+ }
405
+ ]).then(({ device }) => found.find((d) => d.host === device));
406
+ writeConfig({ ...existing, host: selected.host, name: selected.name });
407
+ process.stdout.write(
408
+ chalk.green(`\u2714 TV set to ${selected.name} (${selected.host})
409
+ `)
410
+ );
411
+ }
412
+ return found;
413
+ };
414
+
415
+ // src/commands/pair.ts
416
+ var pairWithDevice = async (device) => {
417
+ const spinner = ora2("Connecting to TV\u2026").start();
418
+ const remote = new AndroidRemote2(device.hostname, {
419
+ pairing_port: 6467,
420
+ service_name: "gtv-cli"
421
+ });
422
+ await new Promise((resolve, reject) => {
423
+ const fail2 = (msg) => {
424
+ spinner.fail(msg);
425
+ try {
426
+ stopRemote(remote);
427
+ } catch {
428
+ }
429
+ reject(new Error(msg));
430
+ };
431
+ remote.on("secret", async () => {
432
+ spinner.stop();
433
+ process.stdout.write(
434
+ chalk2.cyan(
435
+ "A PIN is now shown on your TV. Type that PIN here in the terminal, then press Enter.\n"
436
+ )
437
+ );
438
+ const { pin } = await inquirer2.prompt([
439
+ { type: "input", name: "pin", message: "TV PIN:" }
440
+ ]);
441
+ remote.sendCode(pin);
442
+ spinner.start("Pairing\u2026");
443
+ });
444
+ remote.on("ready", () => {
445
+ const cert = remote.getCertificate();
446
+ writeConfig({
447
+ host: device.host,
448
+ port: device.port,
449
+ name: device.name ?? "gtv-cli",
450
+ cert
451
+ });
452
+ spinner.succeed("Paired! Config saved to ~/.config/gtv/config.json");
453
+ try {
454
+ stopRemote(remote);
455
+ } catch {
456
+ }
457
+ resolve();
458
+ });
459
+ remote.on("unpaired", () => fail2("TV rejected the pairing request."));
460
+ remote.on("error", (err) => fail2(err.message));
461
+ remote.start().then((paired) => {
462
+ if (!paired)
463
+ fail2(
464
+ "Could not connect to TV. Check that 'Remote Device Settings \u2192 Control remotely' is enabled."
465
+ );
466
+ }).catch((err) => fail2(err.message));
467
+ });
468
+ };
469
+ var pair = async () => {
470
+ const devices = await discover({ timeout: 4e3 });
471
+ if (devices.length === 0) {
472
+ process.stdout.write(
473
+ chalk2.yellow("No devices found via mDNS. Enter IP manually.\n")
474
+ );
475
+ const { host } = await inquirer2.prompt([
476
+ { type: "input", name: "host", message: "TV IP address:" }
477
+ ]);
478
+ await pairWithDevice({ host, hostname: host });
479
+ return;
480
+ }
481
+ if (devices.length === 1) {
482
+ await pairWithDevice(devices[0]);
483
+ return;
484
+ }
485
+ const choices = [
486
+ ...devices.map((d) => ({
487
+ name: `${d.name} (${d.host})`,
488
+ value: d
489
+ })),
490
+ { name: "Enter IP manually", value: null }
491
+ ];
492
+ const { device } = await inquirer2.prompt(
493
+ [
494
+ {
495
+ type: "list",
496
+ name: "device",
497
+ message: "Which TV to pair with?",
498
+ choices
499
+ }
500
+ ]
501
+ );
502
+ if (!device) {
503
+ const { host } = await inquirer2.prompt([
504
+ { type: "input", name: "host", message: "TV IP address:" }
505
+ ]);
506
+ await pairWithDevice({ host, hostname: host });
507
+ return;
508
+ }
509
+ await pairWithDevice(device);
510
+ };
511
+
512
+ // src/commands/status.ts
513
+ import chalk3 from "chalk";
514
+ import ora3 from "ora";
515
+
516
+ // src/lib/client.ts
517
+ import { AndroidRemote as AndroidRemote3, RemoteDirection as RemoteDirection2 } from "androidtv-remote";
518
+ var CONNECT_TIMEOUT_MS = 8e3;
519
+ var connect = () => {
520
+ const config = readConfig();
521
+ if (!config) throw new Error("No TV configured. Run `gtv pair` first.");
522
+ const remote = new AndroidRemote3(config.host, {
523
+ pairing_port: 6467,
524
+ remote_port: config.port ?? 6466,
525
+ service_name: config.name ?? "gtv-cli",
526
+ ...config.cert ? { cert: config.cert } : {}
527
+ });
528
+ return new Promise((resolve, reject) => {
529
+ const timeout = setTimeout(() => {
530
+ stopRemote(remote);
531
+ reject(
532
+ new Error(
533
+ `Timed out connecting to ${config.host}. Run \`gtv pair\` again if the TV is online.`
534
+ )
535
+ );
536
+ }, CONNECT_TIMEOUT_MS);
537
+ const fail2 = (error) => {
538
+ clearTimeout(timeout);
539
+ stopRemote(remote);
540
+ reject(error);
541
+ };
542
+ remote.once("ready", () => {
543
+ clearTimeout(timeout);
544
+ resolve(remote);
545
+ });
546
+ remote.once(
547
+ "unpaired",
548
+ () => fail2(new Error("TV rejected the saved pairing. Run `gtv pair` again."))
549
+ );
550
+ remote.once("error", fail2);
551
+ remote.start().then((started) => {
552
+ if (!started)
553
+ fail2(
554
+ new Error(
555
+ `Could not connect to ${config.host}. Check that remote control is enabled on the TV.`
556
+ )
557
+ );
558
+ }).catch(fail2);
559
+ });
560
+ };
561
+ var withRemote = async (fn) => {
562
+ const remote = await connect();
563
+ try {
564
+ fn(remote);
565
+ await new Promise((r) => setTimeout(r, 300));
566
+ } finally {
567
+ stopRemote(remote);
568
+ }
569
+ };
570
+ var sendKey = (keyCode) => withRemote((remote) => {
571
+ remote.sendKey(keyCode, RemoteDirection2.SHORT);
572
+ });
573
+ var launchApp = (deeplink) => withRemote((remote) => {
574
+ remote.sendAppLink(deeplink);
575
+ });
576
+
577
+ // src/commands/status.ts
578
+ var ok = (text) => `${chalk3.green("\u2714")} ${text}
579
+ `;
580
+ var warn = (text) => `${chalk3.yellow("!")} ${text}
581
+ `;
582
+ var fail = (text) => `${chalk3.red("\u2716")} ${text}
583
+ `;
584
+ var status = async () => {
585
+ process.stdout.write(chalk3.bold("Google TV status\n\n"));
586
+ const config = readConfig();
587
+ if (!config) {
588
+ process.stdout.write(fail(`No saved pairing at ${CONFIG_PATH}`));
589
+ process.stdout.write(warn("Run `gtv` to pair and open the remote."));
590
+ return;
591
+ }
592
+ process.stdout.write(ok(`Config: ${config.name ?? "Google TV"} at ${config.host}:${config.port ?? 6466}`));
593
+ process.stdout.write(
594
+ config.cert ? ok("Pairing certificate: present") : fail("Pairing certificate: missing")
595
+ );
596
+ const discoverySpinner = ora3("Discovering Google TV devices\u2026").start();
597
+ const devices = await discover({ timeout: 2500, quiet: true });
598
+ const matchingDevice = devices.find((device) => device.host === config.host);
599
+ if (matchingDevice) {
600
+ discoverySpinner.succeed(
601
+ `Discovery: found ${matchingDevice.name} at ${matchingDevice.host}:${matchingDevice.port}`
602
+ );
603
+ } else if (devices.length > 0) {
604
+ discoverySpinner.warn(
605
+ `Discovery: found ${devices.length} Google TV device(s), but not ${config.host}`
606
+ );
607
+ } else {
608
+ discoverySpinner.warn("Discovery: no Google TV devices found via mDNS");
609
+ }
610
+ const connectSpinner = ora3("Testing Android TV Remote protocol\u2026").start();
611
+ try {
612
+ const remote = await connect();
613
+ stopRemote(remote);
614
+ connectSpinner.succeed("Remote protocol: connected");
615
+ } catch (error) {
616
+ const message = error instanceof Error ? error.message : String(error);
617
+ connectSpinner.fail(`Remote protocol: ${message}`);
618
+ process.exitCode = 1;
619
+ }
620
+ };
621
+
622
+ // src/index.tsx
623
+ import { jsx as jsx4 } from "react/jsx-runtime";
624
+ var program = new Command().name("gtv").description("Control your Google TV").version("0.1.0");
625
+ program.command("pair").description("Pair or re-pair with your Google TV").action(pair);
626
+ program.command("unpair").description("Forget the saved Google TV pairing").action(() => {
627
+ const removed = deleteConfig();
628
+ process.stdout.write(
629
+ removed ? chalk4.green(`Unpaired. Removed ${CONFIG_PATH}
630
+ `) : chalk4.yellow("No saved Google TV pairing found.\n")
631
+ );
632
+ });
633
+ program.command("discover").description("Scan network for Google TV devices").option("-s, --select", "Select and save a TV as default").option("-t, --timeout <ms>", "Scan duration in ms", "5000").action(async (opts) => {
634
+ await discover({ select: opts.select, timeout: parseInt(opts.timeout, 10) });
635
+ });
636
+ program.command("status").description("Check Google TV pairing and connectivity").action(status);
637
+ program.command("doctor").description("Alias for status").action(status);
638
+ var key = (name) => async () => {
639
+ const k = KEYS[name];
640
+ if (!k) throw new Error(`Unknown key: ${name}`);
641
+ await sendKey(k);
642
+ };
643
+ program.command("home").description("Go home").action(key("home"));
644
+ program.command("back").description("Go back").action(key("back"));
645
+ program.command("up").description("D-pad up").action(key("up"));
646
+ program.command("down").description("D-pad down").action(key("down"));
647
+ program.command("left").description("D-pad left").action(key("left"));
648
+ program.command("right").description("D-pad right").action(key("right"));
649
+ program.command("select").description("Select / OK").action(key("select"));
650
+ program.command("power").description("Toggle power").action(key("power"));
651
+ program.command("play").description("Play / Pause").action(key("play"));
652
+ program.command("stop").description("Stop").action(key("stop"));
653
+ program.command("next").description("Next track").action(key("next"));
654
+ program.command("prev").description("Previous track").action(key("prev"));
655
+ program.command("fwd").description("Fast forward").action(key("fwd"));
656
+ program.command("rwd").description("Rewind").action(key("rwd"));
657
+ program.command("mute").description("Toggle mute").action(key("mute"));
658
+ program.command("menu").description("Open menu").action(key("menu"));
659
+ program.command("search").description("Open search").action(key("search"));
660
+ program.command("input").description("Switch input").action(key("input"));
661
+ program.command("sleep").description("Sleep").action(key("sleep"));
662
+ program.command("wakeup").description("Wake up").action(key("wakeup"));
663
+ program.command("vol").argument("<action>", "up, down, or mute").description("Volume control").action(async (action) => {
664
+ const keyName = action === "up" ? "vol-up" : action === "down" ? "vol-down" : "mute";
665
+ await key(keyName)();
666
+ });
667
+ program.command("app").argument("<deeplink>", "App deep link URL (e.g. https://www.netflix.com/)").description("Launch an app by deep link").action(launchApp);
668
+ program.command("key").argument("<name>", "Key name").description(`Send a key by name. Available: ${Object.keys(KEYS).join(", ")}`).action(async (name) => {
669
+ const keyCode = KEYS[name];
670
+ if (!keyCode) {
671
+ process.stderr.write(
672
+ `${chalk4.red("error:")} Unknown key "${name}". Available: ${Object.keys(KEYS).join(", ")}
673
+ `
674
+ );
675
+ process.exit(1);
676
+ }
677
+ await sendKey(keyCode);
678
+ });
679
+ var ensurePaired = async () => {
680
+ if (readConfig()) return true;
681
+ const { shouldPair } = await inquirer3.prompt([
682
+ {
683
+ type: "confirm",
684
+ name: "shouldPair",
685
+ message: "No Google TV is paired yet. Pair one now?",
686
+ default: true
687
+ }
688
+ ]);
689
+ if (!shouldPair) return false;
690
+ await pair();
691
+ return Boolean(readConfig());
692
+ };
693
+ if (process.argv.length <= 2) {
694
+ if (!await ensurePaired()) process.exit(0);
695
+ render(/* @__PURE__ */ jsx4(App, {}), { alternateScreen: true });
696
+ } else {
697
+ await program.parseAsync(process.argv);
698
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@kud/gtv-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Control your Google TV from the CLI",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kud/gtv-cli.git"
9
+ },
10
+ "bin": {
11
+ "gtv": "dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "dev": "tsx src/index.tsx",
18
+ "dev:pair": "tsx src/index.tsx pair",
19
+ "dev:discover": "tsx src/index.tsx discover --select",
20
+ "dev:tui": "tsx src/index.tsx",
21
+ "build": "tsup src/index.tsx --format esm --dts",
22
+ "build:watch": "tsup src/index.tsx --format esm --dts --watch",
23
+ "clean": "rm -rf dist",
24
+ "typecheck": "tsc --noEmit",
25
+ "link": "npm run build && npm link",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "dependencies": {
29
+ "androidtv-remote": "^1.0.10",
30
+ "chalk": "^5.6.2",
31
+ "commander": "^14.0.3",
32
+ "ink": "7.1.0",
33
+ "ink-spinner": "^5.0.0",
34
+ "inquirer": "^13.3.2",
35
+ "ora": "^9.3.0",
36
+ "react": "^19.2.4"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^25.5.0",
40
+ "@types/react": "^19.2.14",
41
+ "tsup": "^8.5.1",
42
+ "tsx": "^4.21.0",
43
+ "typescript": "^6.0.2"
44
+ },
45
+ "engines": {
46
+ "node": ">=22.0.0"
47
+ }
48
+ }