@ciphore/radiocli 0.2.1 → 0.2.3
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/CHANGELOG.md +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { basename, delimiter, join } from 'node:path';
|
|
6
|
+
const linuxTerminals = new Set(['alacritty', 'foot', 'ghostty', 'gnome-terminal', 'kitty', 'konsole', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'wezterm', 'xfce4-terminal', 'x-terminal-emulator']);
|
|
7
|
+
export function detectAlarmTerminal(platform = process.platform, env = process.env) {
|
|
8
|
+
const configured = env.RADIOCLI_ALARM_TERMINAL?.trim();
|
|
9
|
+
if (configured && validDescriptor(configured, platform))
|
|
10
|
+
return configured;
|
|
11
|
+
if (platform === 'darwin') {
|
|
12
|
+
const program = `${env.TERM_PROGRAM ?? ''} ${env.__CFBundleIdentifier ?? ''}`.toLowerCase();
|
|
13
|
+
if (program.includes('iterm'))
|
|
14
|
+
return 'darwin:iterm';
|
|
15
|
+
if (program.includes('wezterm'))
|
|
16
|
+
return 'darwin:wezterm';
|
|
17
|
+
if (program.includes('ghostty'))
|
|
18
|
+
return 'darwin:ghostty';
|
|
19
|
+
if (program.includes('kitty'))
|
|
20
|
+
return 'darwin:kitty';
|
|
21
|
+
return 'darwin:apple-terminal';
|
|
22
|
+
}
|
|
23
|
+
if (platform === 'win32')
|
|
24
|
+
return env.WT_SESSION ? 'win32:windows-terminal' : 'win32:console';
|
|
25
|
+
if (platform === 'linux') {
|
|
26
|
+
const requested = env.TERMINAL?.trim() || env.TERM_PROGRAM?.trim();
|
|
27
|
+
const resolved = requested ? resolveExecutable(requested, env) : undefined;
|
|
28
|
+
if (resolved && linuxTerminals.has(basename(resolved)))
|
|
29
|
+
return `linux:${resolved}`;
|
|
30
|
+
for (const command of ['ghostty', 'wezterm', 'kitty', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'x-terminal-emulator']) {
|
|
31
|
+
const path = resolveExecutable(command, env);
|
|
32
|
+
if (path)
|
|
33
|
+
return `linux:${path}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return `${platform}:unsupported`;
|
|
37
|
+
}
|
|
38
|
+
export async function openAlarmControls(options) {
|
|
39
|
+
if (options.hasLiveTui?.())
|
|
40
|
+
return { opened: false, terminal: 'existing-tui', message: 'An existing RadioCLI TUI will show the ringing controls.' };
|
|
41
|
+
const platform = options.platform ?? process.platform;
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
const terminal = detectAlarmTerminal(platform, env);
|
|
44
|
+
const command = shellCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME);
|
|
45
|
+
const directArgs = environmentCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME);
|
|
46
|
+
const launch = options.spawn ?? spawnDetached;
|
|
47
|
+
if (terminal === 'darwin:apple-terminal')
|
|
48
|
+
await launched(launch('/usr/bin/osascript', appleTerminalScript(command)));
|
|
49
|
+
else if (terminal === 'darwin:iterm')
|
|
50
|
+
await launched(launch('/usr/bin/osascript', iTermScript(command)));
|
|
51
|
+
else if (terminal === 'darwin:wezterm')
|
|
52
|
+
await launched(launch('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...directArgs]));
|
|
53
|
+
else if (terminal === 'darwin:ghostty')
|
|
54
|
+
await launched(launch('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...directArgs]));
|
|
55
|
+
else if (terminal === 'darwin:kitty')
|
|
56
|
+
await launched(launch('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...directArgs]));
|
|
57
|
+
else if (terminal === 'win32:windows-terminal')
|
|
58
|
+
await launched(launch('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI Alarm', options.nodePath, options.cliPath]));
|
|
59
|
+
else if (terminal === 'win32:console')
|
|
60
|
+
await launched(launch('cmd.exe', ['/d', '/c', 'start', 'RadioCLI Alarm', 'cmd.exe', '/k', windowsCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME)]));
|
|
61
|
+
else if (terminal.startsWith('linux:')) {
|
|
62
|
+
const executable = terminal.slice('linux:'.length);
|
|
63
|
+
if (!linuxTerminals.has(basename(executable)))
|
|
64
|
+
throw new Error('Saved Linux terminal is not supported.');
|
|
65
|
+
const name = basename(executable);
|
|
66
|
+
const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
|
|
67
|
+
await launched(launch(executable, [...prefix, ...directArgs]));
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
throw new Error('No supported graphical terminal was found. Open radiocli manually and press ! for alarm controls.');
|
|
71
|
+
return { opened: true, terminal, message: 'Opened RadioCLI alarm controls in the saved terminal.' };
|
|
72
|
+
}
|
|
73
|
+
/** Ask for macOS Automation access while the user is configuring the alarm. */
|
|
74
|
+
export async function prepareAlarmTerminalAccess(options = {}) {
|
|
75
|
+
const platform = options.platform ?? process.platform;
|
|
76
|
+
if (platform !== 'darwin')
|
|
77
|
+
return;
|
|
78
|
+
const terminal = detectAlarmTerminal(platform, options.env ?? process.env);
|
|
79
|
+
const application = terminal === 'darwin:apple-terminal' ? 'Terminal' : terminal === 'darwin:iterm' ? 'iTerm' : undefined;
|
|
80
|
+
if (!application)
|
|
81
|
+
return;
|
|
82
|
+
const launch = options.spawn ?? spawnAttached;
|
|
83
|
+
const child = launch('/usr/bin/osascript', ['-e', `tell application "${application}" to count windows`]);
|
|
84
|
+
const code = await completed(child, options.permissionTimeoutMs ?? 60_000);
|
|
85
|
+
if (code !== 0)
|
|
86
|
+
throw new Error(`macOS did not grant RadioCLI permission to control ${application}. Audio can still play, but automatic ringing controls cannot open. Enable Node under System Settings > Privacy & Security > Automation, then press Repair.`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Opens the saved terminal with a short-lived authenticated loopback probe. The
|
|
90
|
+
* terminal exits immediately after proving that a native background job can
|
|
91
|
+
* expose the ringing TUI on this desktop session.
|
|
92
|
+
*/
|
|
93
|
+
export async function verifyAlarmTerminalLaunch(options = {}) {
|
|
94
|
+
const platform = options.platform ?? process.platform;
|
|
95
|
+
const env = options.env ?? process.env;
|
|
96
|
+
await prepareAlarmTerminalAccess(options);
|
|
97
|
+
const terminal = detectAlarmTerminal(platform, env);
|
|
98
|
+
if (terminal.endsWith(':unsupported'))
|
|
99
|
+
throw new Error('No supported graphical terminal was found for automatic alarm controls.');
|
|
100
|
+
const token = randomBytes(24).toString('base64url');
|
|
101
|
+
const server = createServer();
|
|
102
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolve()); });
|
|
103
|
+
const address = server.address();
|
|
104
|
+
if (!address || typeof address === 'string') {
|
|
105
|
+
server.close();
|
|
106
|
+
throw new Error('Unable to create the terminal verification channel.');
|
|
107
|
+
}
|
|
108
|
+
const script = "const n=require('node:net');const s=n.connect(Number(process.argv[1]),'127.0.0.1',()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));";
|
|
109
|
+
const direct = [options.nodePath ?? process.execPath, '-e', script, String(address.port), token];
|
|
110
|
+
const command = `${direct.map(shellQuote).join(' ')}; exit`;
|
|
111
|
+
const launch = options.spawn ?? spawnDetached;
|
|
112
|
+
const received = new Promise((resolve, reject) => { server.once('connection', socket => { let value = ''; socket.setEncoding('utf8'); socket.on('data', chunk => value += chunk); socket.on('end', () => value === token ? resolve() : reject(new Error('The terminal verification response was not authentic.'))); socket.on('error', reject); }); });
|
|
113
|
+
try {
|
|
114
|
+
if (terminal === 'darwin:apple-terminal')
|
|
115
|
+
await launched(launch('/usr/bin/osascript', appleTerminalScript(command)));
|
|
116
|
+
else if (terminal === 'darwin:iterm')
|
|
117
|
+
await launched(launch('/usr/bin/osascript', iTermScript(command)));
|
|
118
|
+
else if (terminal === 'darwin:wezterm')
|
|
119
|
+
await launched(launch('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct]));
|
|
120
|
+
else if (terminal === 'darwin:ghostty')
|
|
121
|
+
await launched(launch('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct]));
|
|
122
|
+
else if (terminal === 'darwin:kitty')
|
|
123
|
+
await launched(launch('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct]));
|
|
124
|
+
else if (terminal === 'win32:windows-terminal')
|
|
125
|
+
await launched(launch('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI Alarm Verification', ...direct]));
|
|
126
|
+
else if (terminal === 'win32:console')
|
|
127
|
+
await launched(launch('cmd.exe', ['/d', '/c', 'start', 'RadioCLI Alarm Verification', 'cmd.exe', '/c', windowsCommandArgs(direct)]));
|
|
128
|
+
else if (terminal.startsWith('linux:')) {
|
|
129
|
+
const executable = terminal.slice(6);
|
|
130
|
+
const name = basename(executable);
|
|
131
|
+
const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
|
|
132
|
+
await launched(launch(executable, [...prefix, ...direct]));
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
throw new Error('The saved terminal is not supported for alarm controls.');
|
|
136
|
+
await withTimeout(received, options.timeoutMs ?? 8_000, 'The terminal opened but did not connect back to RadioCLI.');
|
|
137
|
+
return terminal;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function validDescriptor(value, platform) {
|
|
144
|
+
if (platform === 'darwin')
|
|
145
|
+
return ['darwin:apple-terminal', 'darwin:iterm', 'darwin:wezterm', 'darwin:ghostty', 'darwin:kitty'].includes(value);
|
|
146
|
+
if (platform === 'win32')
|
|
147
|
+
return value === 'win32:windows-terminal' || value === 'win32:console';
|
|
148
|
+
if (platform === 'linux' && value.startsWith('linux:'))
|
|
149
|
+
return linuxTerminals.has(basename(value.slice(6)));
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
function resolveExecutable(input, env) {
|
|
153
|
+
if (input.includes('/') && existsSync(input))
|
|
154
|
+
return input;
|
|
155
|
+
for (const directory of (env.PATH ?? '').split(delimiter)) {
|
|
156
|
+
const path = join(directory, input);
|
|
157
|
+
if (existsSync(path))
|
|
158
|
+
return path;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; }
|
|
163
|
+
function shellCommand(nodePath, cliPath, home) { return `${home ? `RADIOCLI_HOME=${shellQuote(home)} ` : ''}${shellQuote(nodePath)} ${shellQuote(cliPath)}`; }
|
|
164
|
+
function environmentCommand(nodePath, cliPath, home) { return home ? ['/usr/bin/env', `RADIOCLI_HOME=${home}`, nodePath, cliPath] : [nodePath, cliPath]; }
|
|
165
|
+
function windowsCommand(nodePath, cliPath, home) { return `${home ? `set "RADIOCLI_HOME=${cmdEscape(home)}" && ` : ''}"${nodePath.replaceAll('"', '""')}" "${cliPath.replaceAll('"', '""')}"`; }
|
|
166
|
+
function windowsCommandArgs(values) { return values.map(value => `"${value.replaceAll('"', '""')}"`).join(' '); }
|
|
167
|
+
function cmdEscape(value) { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); }
|
|
168
|
+
function appleTerminalScript(command) { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
|
|
169
|
+
function iTermScript(command) { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
|
|
170
|
+
function spawnDetached(command, args) { return spawn(command, [...args], { detached: true, stdio: 'ignore', windowsHide: false }); }
|
|
171
|
+
function spawnAttached(command, args) { return spawn(command, [...args], { stdio: 'ignore', windowsHide: true }); }
|
|
172
|
+
function launched(child) { return new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); }
|
|
173
|
+
function completed(child, timeoutMs) { return new Promise((resolve, reject) => { let settled = false; const finish = (work) => { if (settled)
|
|
174
|
+
return; settled = true; clearTimeout(timer); work(); }; const timer = setTimeout(() => { child.kill('SIGTERM'); finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.'))); }, timeoutMs); child.once('error', error => finish(() => reject(error))); child.once('close', code => finish(() => resolve(code ?? 1))); }); }
|
|
175
|
+
async function withTimeout(promise, milliseconds, message) { let timer; try {
|
|
176
|
+
return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
if (timer)
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
} }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export function registerTuiPresence(root = runtimeDirectory(), pid = process.pid) {
|
|
5
|
+
const directory = join(root, 'tui');
|
|
6
|
+
const path = join(directory, `${pid}.json`);
|
|
7
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
8
|
+
writeFileSync(path, JSON.stringify({ pid, startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
9
|
+
return () => rmSync(path, { force: true });
|
|
10
|
+
}
|
|
11
|
+
export function hasLiveTui(root = runtimeDirectory(), alive = processAlive) {
|
|
12
|
+
const directory = join(root, 'tui');
|
|
13
|
+
if (!existsSync(directory))
|
|
14
|
+
return false;
|
|
15
|
+
for (const name of readdirSync(directory)) {
|
|
16
|
+
const path = join(directory, name);
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
19
|
+
if (typeof value.pid === 'number' && Number.isInteger(value.pid) && alive(value.pid))
|
|
20
|
+
return true;
|
|
21
|
+
rmSync(path, { force: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
rmSync(path, { force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
function runtimeDirectory() { if (process.env.RADIOCLI_HOME)
|
|
30
|
+
return join(process.env.RADIOCLI_HOME, 'runtime'); if (process.platform === 'win32')
|
|
31
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); }
|
|
32
|
+
function processAlive(pid) { try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
} }
|
package/dist/cli.js
CHANGED
|
@@ -9,12 +9,34 @@ import { JsonLibraryStore } from './storage/store.js';
|
|
|
9
9
|
import { parsePlaylistFile, stationFromUrl, writeM3u } from './playlists/playlist.js';
|
|
10
10
|
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
11
11
|
import { resolveCommand } from './player/command.js';
|
|
12
|
+
import { diagnoseCommand } from './player/command-diagnostics.js';
|
|
12
13
|
import { airPlaySenderHealth } from './player/airplay-sender-health.js';
|
|
13
14
|
import { appVersion } from './version.js';
|
|
14
|
-
import { checkForUpdate, updateCommandForInstall } from './update-check.js';
|
|
15
|
+
import { checkForUpdate, installUpdate, updateCommandForInstall } from './update-check.js';
|
|
16
|
+
import { runAlarmCommand } from './alarms/cli.js';
|
|
17
|
+
import { runSetup } from './setup.js';
|
|
18
|
+
import { runAgentCliCommand, runMcpCommand } from './agent/cli.js';
|
|
19
|
+
import { decodeAgentCommand } from './agent/service.js';
|
|
20
|
+
import { runHeadlessAgentHost } from './agent/headless-host.js';
|
|
21
|
+
import { configureMcpIntegrations } from './agent/mcp-install.js';
|
|
22
|
+
import { defaultAgentControlSettings } from './types.js';
|
|
23
|
+
const runtime = { nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url) };
|
|
15
24
|
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
16
25
|
const args = process.argv.slice(2);
|
|
17
|
-
if (args
|
|
26
|
+
if (args[0] === 'agent-ui') {
|
|
27
|
+
const encoded = args[1];
|
|
28
|
+
if (!encoded)
|
|
29
|
+
throw new Error('Missing RadioCLI agent startup request.');
|
|
30
|
+
const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
|
|
31
|
+
render(_jsx(App, { initialAgentCommand: decodeAgentCommand(encoded) }), {
|
|
32
|
+
exitOnCtrlC: false,
|
|
33
|
+
kittyKeyboard: { mode: 'auto', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
else if (args[0] === 'agent-host') {
|
|
37
|
+
await runHeadlessAgentHost();
|
|
38
|
+
}
|
|
39
|
+
else if (args.length > 0) {
|
|
18
40
|
await runCommand(args).catch(error => {
|
|
19
41
|
console.error(error instanceof Error ? error.message : String(error));
|
|
20
42
|
process.exitCode = 1;
|
|
@@ -42,20 +64,45 @@ export async function runCommand(args) {
|
|
|
42
64
|
console.log(appVersion());
|
|
43
65
|
return;
|
|
44
66
|
}
|
|
67
|
+
if (command === 'alarm') {
|
|
68
|
+
await runAlarmCommand(rest);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (command === 'mcp') {
|
|
72
|
+
await runMcpCommand(rest, runtime);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (command === 'agent') {
|
|
76
|
+
await runAgentCliCommand(rest, runtime);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (command === 'setup') {
|
|
80
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
81
|
+
printSetupHelp();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await runSetup({ args: rest });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
45
87
|
if (!isKnownCommand(command)) {
|
|
46
88
|
throw new Error(`Unknown command: ${command}\nRun radiocli help.`);
|
|
47
89
|
}
|
|
48
90
|
if (command === 'doctor') {
|
|
49
91
|
const backends = detectPlaybackBackends();
|
|
92
|
+
const mpvDiagnostic = diagnoseCommand('mpv');
|
|
50
93
|
if (rest.includes('--json')) {
|
|
51
|
-
console.log(JSON.stringify(doctorReport(backends), null, 2));
|
|
94
|
+
console.log(JSON.stringify(doctorReport(backends, mpvDiagnostic), null, 2));
|
|
52
95
|
return;
|
|
53
96
|
}
|
|
54
97
|
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
98
|
+
printMpvDiagnostic(mpvDiagnostic);
|
|
55
99
|
printPlaybackBackendStatus(backends);
|
|
56
100
|
return;
|
|
57
101
|
}
|
|
58
102
|
if (command === 'update') {
|
|
103
|
+
const unknown = rest.filter(arg => arg !== '--install');
|
|
104
|
+
if (unknown.length > 0)
|
|
105
|
+
throw new Error('Usage: radiocli update [--install]');
|
|
59
106
|
const updateCheck = await checkForUpdate();
|
|
60
107
|
const updateCommand = updateCommandForInstall();
|
|
61
108
|
if (updateCheck.error) {
|
|
@@ -67,6 +114,26 @@ export async function runCommand(args) {
|
|
|
67
114
|
console.log(`available=${updateCheck.updateAvailable ? 'yes' : 'no'}`);
|
|
68
115
|
}
|
|
69
116
|
console.log(`command=${updateCommand.command}`);
|
|
117
|
+
if (rest.includes('--install')) {
|
|
118
|
+
const result = await installUpdate(updateCommand.command);
|
|
119
|
+
if (!result.ok)
|
|
120
|
+
throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
|
|
121
|
+
console.log('updated=yes');
|
|
122
|
+
const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
|
|
123
|
+
if (agentControl.enabled) {
|
|
124
|
+
try {
|
|
125
|
+
const repaired = await configureMcpIntegrations(true, runtime);
|
|
126
|
+
const failed = repaired.filter(item => item.status === 'failed');
|
|
127
|
+
console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
|
|
128
|
+
if (failed.length)
|
|
129
|
+
console.log(`mcp_failures=${failed.map(item => `${item.client}: ${item.detail}`).join('; ')}`);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.log(`mcp_repaired=failed ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
console.log('restart_required=yes');
|
|
136
|
+
}
|
|
70
137
|
return;
|
|
71
138
|
}
|
|
72
139
|
if (command === 'check') {
|
|
@@ -143,12 +210,17 @@ Usage:
|
|
|
143
210
|
radiocli version Print the installed version
|
|
144
211
|
radiocli check Show provider/backend health
|
|
145
212
|
radiocli doctor [--json] Show local playback setup guidance
|
|
213
|
+
radiocli setup Install and verify native playback tools
|
|
214
|
+
radiocli mcp <command> Install, inspect, or run the MCP integration
|
|
215
|
+
radiocli agent <command> Scriptable radio controls for local agents
|
|
146
216
|
radiocli update Show update availability and install command
|
|
217
|
+
radiocli update --install Install the latest release and repair enabled MCP entries
|
|
147
218
|
radiocli countries Print top countries
|
|
148
219
|
radiocli search <query> Search public stations
|
|
149
220
|
radiocli import <file> Import .m3u, .pls, or .xspf streams
|
|
150
221
|
radiocli export [file] Export favorites/imports as .m3u
|
|
151
222
|
radiocli add-url <url> [name]
|
|
223
|
+
radiocli alarm <command> Manage alarms and scheduled radio
|
|
152
224
|
`);
|
|
153
225
|
}
|
|
154
226
|
export function isDirectRun(entryPath, moduleUrl) {
|
|
@@ -163,14 +235,31 @@ export function isDirectRun(entryPath, moduleUrl) {
|
|
|
163
235
|
}
|
|
164
236
|
}
|
|
165
237
|
function isKnownCommand(command) {
|
|
166
|
-
return ['check', 'doctor', 'update', 'countries', 'search', 'import', 'export', 'add-url'].includes(command);
|
|
238
|
+
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'add-url', 'alarm', 'mcp', 'agent'].includes(command);
|
|
239
|
+
}
|
|
240
|
+
function printSetupHelp() {
|
|
241
|
+
console.log(`RadioCLI Setup
|
|
242
|
+
|
|
243
|
+
Usage:
|
|
244
|
+
radiocli setup Interactive guided setup
|
|
245
|
+
radiocli setup --yes Install recommended defaults
|
|
246
|
+
radiocli setup --all --yes Install mpv, FFmpeg, and VLC
|
|
247
|
+
radiocli setup --only mpv,ffmpeg Select specific components
|
|
248
|
+
radiocli setup --dry-run Show commands without installing
|
|
249
|
+
radiocli setup --mcp Enable and configure agent MCP clients
|
|
250
|
+
radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
|
|
251
|
+
radiocli setup --mcp --headless-agent Opt out of external terminal windows
|
|
252
|
+
radiocli setup --no-mcp Disable and remove agent MCP entries
|
|
253
|
+
radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
|
|
254
|
+
apt, dnf, pacman, apk, or zypper
|
|
255
|
+
`);
|
|
167
256
|
}
|
|
168
257
|
function printPlaybackBackendStatus(backends) {
|
|
169
258
|
for (const line of playbackBackendStatusLines(backends)) {
|
|
170
259
|
console.log(line);
|
|
171
260
|
}
|
|
172
261
|
}
|
|
173
|
-
function doctorReport(backends) {
|
|
262
|
+
function doctorReport(backends, mpvDiagnostic) {
|
|
174
263
|
const commands = Object.fromEntries(['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]));
|
|
175
264
|
const airPlay = airPlaySenderHealth();
|
|
176
265
|
return {
|
|
@@ -180,6 +269,7 @@ function doctorReport(backends) {
|
|
|
180
269
|
architecture: process.arch,
|
|
181
270
|
backends,
|
|
182
271
|
commands,
|
|
272
|
+
mpv: redactDiagnostic(mpvDiagnostic),
|
|
183
273
|
airPlay: {
|
|
184
274
|
available: airPlay.available,
|
|
185
275
|
safe: airPlay.safe,
|
|
@@ -191,6 +281,24 @@ function doctorReport(backends) {
|
|
|
191
281
|
guidance: playbackBackendStatusLines(backends)
|
|
192
282
|
};
|
|
193
283
|
}
|
|
284
|
+
function printMpvDiagnostic(diagnostic) {
|
|
285
|
+
console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`);
|
|
286
|
+
console.log(`mpv_discovery=${diagnostic.discovery}`);
|
|
287
|
+
console.log(`mpv_launch=${diagnostic.launchable ? 'ready' : 'failed'}`);
|
|
288
|
+
if (diagnostic.version)
|
|
289
|
+
console.log(`mpv_version=${diagnostic.version}`);
|
|
290
|
+
if (diagnostic.error)
|
|
291
|
+
console.log(`mpv_error=${diagnostic.error}`);
|
|
292
|
+
if (diagnostic.launchable && diagnostic.discovery !== 'path') {
|
|
293
|
+
console.log('mpv_hint=RadioCLI found mpv outside PATH and can use it directly; no PATH changes are required.');
|
|
294
|
+
}
|
|
295
|
+
else if (!diagnostic.path && process.platform === 'win32') {
|
|
296
|
+
console.log('mpv_hint=Install with winget, then rerun Doctor; RadioCLI also checks the standard MPV Player install directory.');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function redactDiagnostic(diagnostic) {
|
|
300
|
+
return { ...diagnostic, path: redactHome(diagnostic.path) };
|
|
301
|
+
}
|
|
194
302
|
function redactHome(path) {
|
|
195
303
|
if (!path)
|
|
196
304
|
return null;
|
|
@@ -70,12 +70,13 @@ export function playbackBackendLabel(backend) {
|
|
|
70
70
|
return playbackBackendCapabilities(backend).label;
|
|
71
71
|
}
|
|
72
72
|
export function playbackBackendInstallHint(platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
73
|
-
return `
|
|
73
|
+
return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`;
|
|
74
74
|
}
|
|
75
75
|
export function playbackBackendStatusLines(backends, platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
76
76
|
const backendSet = new Set(backends);
|
|
77
77
|
const lines = [
|
|
78
78
|
'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg',
|
|
79
|
+
'guided_setup=radiocli setup',
|
|
79
80
|
`install_mpv=${mpvInstallCommand(platform, osRelease)}`,
|
|
80
81
|
`optional_ffplay=${ffplayInstallCommand(platform, osRelease)}`
|
|
81
82
|
];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { resolveCommandDetails } from './command.js';
|
|
3
|
+
export function diagnoseCommand(command, overrides = {}) {
|
|
4
|
+
const resolution = (overrides.resolve ?? resolveCommandDetails)(command);
|
|
5
|
+
if (!resolution.path) {
|
|
6
|
+
return { ...resolution, launchable: false, version: null, error: 'not found' };
|
|
7
|
+
}
|
|
8
|
+
const execute = overrides.execute ?? ((path, args, options) => spawnSync(path, args, options));
|
|
9
|
+
const result = execute(resolution.path, ['--version'], {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
timeout: 3000,
|
|
12
|
+
windowsHide: true
|
|
13
|
+
});
|
|
14
|
+
if (result.error) {
|
|
15
|
+
return { ...resolution, launchable: false, version: null, error: result.error.message };
|
|
16
|
+
}
|
|
17
|
+
if (result.status !== 0) {
|
|
18
|
+
return { ...resolution, launchable: false, version: null, error: `exited with status ${result.status ?? 'unknown'}` };
|
|
19
|
+
}
|
|
20
|
+
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
21
|
+
return {
|
|
22
|
+
...resolution,
|
|
23
|
+
launchable: true,
|
|
24
|
+
version: output.split(/\r?\n/, 1)[0]?.trim() || null,
|
|
25
|
+
error: null
|
|
26
|
+
};
|
|
27
|
+
}
|