@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +76 -6
  3. package/dist/agent/alarm-service.js +210 -0
  4. package/dist/agent/cli.js +193 -0
  5. package/dist/agent/headless-host.js +143 -0
  6. package/dist/agent/launcher.js +71 -0
  7. package/dist/agent/mcp-install.js +467 -0
  8. package/dist/agent/mcp-server.js +139 -0
  9. package/dist/agent/service.js +347 -0
  10. package/dist/agent/session.js +248 -0
  11. package/dist/alarms/active-session.js +183 -0
  12. package/dist/alarms/cli.js +312 -0
  13. package/dist/alarms/guard.js +343 -0
  14. package/dist/alarms/inhibitor.js +48 -0
  15. package/dist/alarms/power-guard-store.js +169 -0
  16. package/dist/alarms/runner.js +342 -0
  17. package/dist/alarms/runtime-health.js +79 -0
  18. package/dist/alarms/schedule.js +149 -0
  19. package/dist/alarms/scheduler.js +250 -0
  20. package/dist/alarms/setup-verification.js +187 -0
  21. package/dist/alarms/system-volume.js +43 -0
  22. package/dist/alarms/terminal-launcher.js +181 -0
  23. package/dist/alarms/tui-presence.js +38 -0
  24. package/dist/cli.js +113 -5
  25. package/dist/player/backend-install.js +2 -1
  26. package/dist/player/command-diagnostics.js +27 -0
  27. package/dist/player/command.js +123 -62
  28. package/dist/player/player-controller.js +32 -2
  29. package/dist/providers/provider-manager.js +5 -0
  30. package/dist/providers/radio-browser.js +36 -6
  31. package/dist/setup.js +462 -0
  32. package/dist/storage/store.js +262 -2
  33. package/dist/types.js +6 -0
  34. package/dist/ui/AdaptiveContent.js +111 -26
  35. package/dist/ui/App.js +401 -67
  36. package/dist/ui/AppContent.js +24 -7
  37. package/dist/ui/adaptive-explore-layout.js +47 -0
  38. package/dist/ui/alarm-editor.js +174 -0
  39. package/dist/ui/alarm-tui-service.js +84 -0
  40. package/dist/ui/app-state.js +3 -0
  41. package/dist/ui/ascii.js +8 -0
  42. package/dist/ui/components/AdaptiveMarquee.js +28 -0
  43. package/dist/ui/components/StationList.js +10 -5
  44. package/dist/ui/components/VersionIndicator.js +19 -0
  45. package/dist/ui/cosmo-world-map.js +5 -2
  46. package/dist/ui/explore-map-layout.js +18 -6
  47. package/dist/ui/format.js +21 -0
  48. package/dist/ui/help-content.js +14 -2
  49. package/dist/ui/layout.js +1 -1
  50. package/dist/ui/page-footer.js +120 -2
  51. package/dist/ui/receiver-animation.js +68 -0
  52. package/dist/ui/screen-items.js +41 -9
  53. package/dist/ui/screen-meta.js +4 -0
  54. package/dist/ui/screens/AlarmsScreen.js +202 -0
  55. package/dist/ui/screens/CountriesScreen.js +8 -5
  56. package/dist/ui/screens/ExploreScreen.js +8 -3
  57. package/dist/ui/screens/HomeScreen.js +3 -1
  58. package/dist/ui/screens/NowPlayingScreen.js +6 -2
  59. package/dist/ui/screens/SettingsScreen.js +70 -53
  60. package/dist/ui/screens/StationScreen.js +3 -2
  61. package/dist/ui/selection-state.js +10 -0
  62. package/dist/ui/terminal-mouse.js +18 -3
  63. package/dist/ui/use-alarm-tui.js +727 -0
  64. package/dist/ui/use-app-input.js +107 -46
  65. package/dist/ui/visualizers/gallop.js +118 -0
  66. package/dist/ui/visualizers/horse-stride.js +20 -0
  67. package/dist/ui/visualizers/receiver-style-registry.js +14 -7
  68. package/dist/ui/visualizers/receiver-visualizers.js +233 -128
  69. package/dist/ui/visualizers/retro-receivers.js +4 -0
  70. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  71. package/dist/update-check.js +26 -7
  72. 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.length > 0) {
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 `Install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`;
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
+ }