@ciphore/radiocli 0.2.2 → 0.2.4
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 +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -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 +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +80 -56
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- 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 +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
|
@@ -1,81 +1,32 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
2
|
import { createServer } from 'node:net';
|
|
4
3
|
import { randomBytes } from 'node:crypto';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
}
|
|
4
|
+
import { listenLoopback } from '../platform/loopback.js';
|
|
5
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
6
|
+
import { detectGraphicalTerminal, launchTerminalCommand } from '../platform/terminals.js';
|
|
38
7
|
export async function openAlarmControls(options) {
|
|
39
8
|
if (options.hasLiveTui?.())
|
|
40
9
|
return { opened: false, terminal: 'existing-tui', message: 'An existing RadioCLI TUI will show the ringing controls.' };
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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]));
|
|
10
|
+
const terminal = await launchTerminalCommand({ ...options, args: [options.cliPath], title: 'RadioCLI Alarm' });
|
|
11
|
+
if (!options.hasLiveTui)
|
|
12
|
+
return { opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls; TUI startup is unverified.' };
|
|
13
|
+
const deadline = Date.now() + (options.timeoutMs ?? 8_000);
|
|
14
|
+
while (true) {
|
|
15
|
+
if (options.hasLiveTui())
|
|
16
|
+
return { opened: true, requested: true, terminal, message: 'RadioCLI alarm controls are ready in the saved terminal.' };
|
|
17
|
+
const remaining = deadline - Date.now();
|
|
18
|
+
if (remaining <= 0)
|
|
19
|
+
return { opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out. It may still be starting.' };
|
|
20
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
|
|
68
21
|
}
|
|
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
22
|
}
|
|
73
23
|
/** Ask for macOS Automation access while the user is configuring the alarm. */
|
|
74
24
|
export async function prepareAlarmTerminalAccess(options = {}) {
|
|
75
25
|
const platform = options.platform ?? process.platform;
|
|
76
|
-
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
if (nativeAdapters(identifyPlatform({ platform, env })).terminal !== 'macos')
|
|
77
28
|
return;
|
|
78
|
-
const terminal =
|
|
29
|
+
const terminal = detectGraphicalTerminal(platform, env, options.resolve);
|
|
79
30
|
const application = terminal === 'darwin:apple-terminal' ? 'Terminal' : terminal === 'darwin:iterm' ? 'iTerm' : undefined;
|
|
80
31
|
if (!application)
|
|
81
32
|
return;
|
|
@@ -94,88 +45,77 @@ export async function verifyAlarmTerminalLaunch(options = {}) {
|
|
|
94
45
|
const platform = options.platform ?? process.platform;
|
|
95
46
|
const env = options.env ?? process.env;
|
|
96
47
|
await prepareAlarmTerminalAccess(options);
|
|
97
|
-
const terminal =
|
|
48
|
+
const terminal = detectGraphicalTerminal(platform, env, options.resolve);
|
|
98
49
|
if (terminal.endsWith(':unsupported'))
|
|
99
50
|
throw new Error('No supported graphical terminal was found for automatic alarm controls.');
|
|
100
51
|
const token = randomBytes(24).toString('base64url');
|
|
101
52
|
const server = createServer();
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
53
|
+
const address = await listenLoopback(server);
|
|
54
|
+
const script = "const n=require('node:net');const s=n.connect(Number(process.argv[1]),process.argv[3],()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));";
|
|
55
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
56
|
+
const args = ['-e', script, String(address.port), token, address.host];
|
|
57
|
+
const sockets = new Set();
|
|
58
|
+
const received = new Promise((resolve, reject) => {
|
|
59
|
+
server.on('connection', socket => {
|
|
60
|
+
sockets.add(socket);
|
|
61
|
+
socket.once('close', () => sockets.delete(socket));
|
|
62
|
+
let value = '';
|
|
63
|
+
socket.setEncoding('utf8');
|
|
64
|
+
socket.on('data', chunk => {
|
|
65
|
+
value += chunk;
|
|
66
|
+
if (value.length > token.length) {
|
|
67
|
+
socket.destroy();
|
|
68
|
+
reject(new Error('The terminal verification response was not authentic.'));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
socket.on('end', () => value === token ? resolve() : reject(new Error('The terminal verification response was not authentic.')));
|
|
72
|
+
socket.on('error', reject);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
// A callback can fail while the launcher is still reporting its own result.
|
|
76
|
+
// Keep the rejection observed until it is awaited below.
|
|
77
|
+
void received.catch(() => undefined);
|
|
113
78
|
try {
|
|
114
|
-
|
|
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.');
|
|
79
|
+
await launchTerminalCommand({ ...options, nodePath, args, title: 'RadioCLI Alarm Verification', closeOnExit: true });
|
|
136
80
|
await withTimeout(received, options.timeoutMs ?? 8_000, 'The terminal opened but did not connect back to RadioCLI.');
|
|
137
81
|
return terminal;
|
|
138
82
|
}
|
|
139
83
|
finally {
|
|
84
|
+
for (const socket of sockets)
|
|
85
|
+
socket.destroy();
|
|
140
86
|
await new Promise(resolve => server.close(() => resolve()));
|
|
141
87
|
}
|
|
142
88
|
}
|
|
143
|
-
function
|
|
144
|
-
|
|
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;
|
|
89
|
+
function spawnAttached(command, args) {
|
|
90
|
+
return spawn(command, [...args], { stdio: 'ignore', windowsHide: true });
|
|
151
91
|
}
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
92
|
+
function completed(child, timeoutMs) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
const finish = (work) => {
|
|
96
|
+
if (settled)
|
|
97
|
+
return;
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
work();
|
|
101
|
+
};
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
child.kill('SIGTERM');
|
|
104
|
+
finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.')));
|
|
105
|
+
}, timeoutMs);
|
|
106
|
+
child.once('error', error => finish(() => reject(error)));
|
|
107
|
+
child.once('close', code => finish(() => resolve(code ?? 1)));
|
|
108
|
+
});
|
|
161
109
|
}
|
|
162
|
-
function
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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); })]);
|
|
110
|
+
async function withTimeout(promise, milliseconds, message) {
|
|
111
|
+
let timer;
|
|
112
|
+
try {
|
|
113
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
114
|
+
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
|
115
|
+
})]);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
if (timer)
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
177
121
|
}
|
|
178
|
-
finally {
|
|
179
|
-
if (timer)
|
|
180
|
-
clearTimeout(timer);
|
|
181
|
-
} }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { platformPaths } from '../platform/paths.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
export function registerTuiPresence(root = runtimeDirectory(), pid = process.pid) {
|
|
5
5
|
const directory = join(root, 'tui');
|
|
@@ -26,9 +26,7 @@ export function hasLiveTui(root = runtimeDirectory(), alive = processAlive) {
|
|
|
26
26
|
}
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
|
-
function runtimeDirectory() {
|
|
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'); }
|
|
29
|
+
function runtimeDirectory() { return platformPaths().runtime; }
|
|
32
30
|
function processAlive(pid) { try {
|
|
33
31
|
process.kill(pid, 0);
|
|
34
32
|
return true;
|
package/dist/cli.js
CHANGED
|
@@ -5,35 +5,71 @@ import { resolve } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { ProviderManager } from './providers/provider-manager.js';
|
|
7
7
|
import { PlayerController } from './player/player-controller.js';
|
|
8
|
-
import { JsonLibraryStore } from './storage/store.js';
|
|
9
|
-
import { parsePlaylistFile,
|
|
8
|
+
import { defaultLibraryPath, JsonLibraryStore } from './storage/store.js';
|
|
9
|
+
import { parsePlaylistFile, writeM3u } from './playlists/playlist.js';
|
|
10
|
+
import { importStreamUrl } from './streams/import-stream.js';
|
|
10
11
|
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
11
|
-
import { resolveCommand } from './
|
|
12
|
+
import { resolveCommand } from './platform/executables.js';
|
|
12
13
|
import { diagnoseCommand } from './player/command-diagnostics.js';
|
|
13
14
|
import { airPlaySenderHealth } from './player/airplay-sender-health.js';
|
|
14
15
|
import { appVersion } from './version.js';
|
|
15
|
-
import { checkForUpdate, updateCommandForInstall } from './update-check.js';
|
|
16
|
+
import { checkForUpdate, installUpdate, updateCommandForInstall } from './update-check.js';
|
|
16
17
|
import { runAlarmCommand } from './alarms/cli.js';
|
|
18
|
+
import { createSchedulerAdapter } from './alarms/scheduler.js';
|
|
17
19
|
import { runSetup } from './setup.js';
|
|
20
|
+
import { runAgentCliCommand, runMcpCommand } from './agent/cli.js';
|
|
21
|
+
import { decodeAgentCommand } from './agent/service.js';
|
|
22
|
+
import { runHeadlessAgentHost } from './agent/headless-host.js';
|
|
23
|
+
import { configureMcpIntegrations } from './agent/mcp-install.js';
|
|
24
|
+
import { defaultAgentControlSettings } from './types.js';
|
|
25
|
+
import { identifyPlatform } from './platform/runtime.js';
|
|
26
|
+
import { platformCapabilities } from './platform/capabilities.js';
|
|
27
|
+
import { hasGraphicalSession } from './platform/desktop.js';
|
|
28
|
+
import { detectPackageManager } from './platform/packages.js';
|
|
29
|
+
import { storageReadiness } from './platform/storage.js';
|
|
30
|
+
import { resolveTerminalCapabilities } from './platform/terminal.js';
|
|
31
|
+
import { networkDiagnostic } from './platform/network.js';
|
|
32
|
+
import { assessPlatformSupport } from './platform/support.js';
|
|
33
|
+
const runtime = { nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url) };
|
|
18
34
|
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
19
35
|
const args = process.argv.slice(2);
|
|
20
|
-
if (args
|
|
36
|
+
if (args[0] === 'agent-ui') {
|
|
37
|
+
const encoded = args[1];
|
|
38
|
+
if (!encoded)
|
|
39
|
+
throw new Error('Missing RadioCLI agent startup request.');
|
|
40
|
+
await renderTui(encoded);
|
|
41
|
+
}
|
|
42
|
+
else if (args[0] === 'agent-host') {
|
|
43
|
+
await runHeadlessAgentHost();
|
|
44
|
+
}
|
|
45
|
+
else if (args.length > 0) {
|
|
21
46
|
await runCommand(args).catch(error => {
|
|
22
47
|
console.error(error instanceof Error ? error.message : String(error));
|
|
23
48
|
process.exitCode = 1;
|
|
24
49
|
});
|
|
25
50
|
}
|
|
26
51
|
else {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
await renderTui();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function renderTui(encodedCommand) {
|
|
56
|
+
if (!process.stdin.isTTY) {
|
|
57
|
+
console.error('The interactive TUI needs terminal input. Use radiocli commands in a headless session, or connect with a terminal (for example, ssh -t).');
|
|
58
|
+
printHelp();
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
36
61
|
}
|
|
62
|
+
const { configureTerminalRenderer } = await import('./ui/terminal-renderer.js');
|
|
63
|
+
const terminal = await configureTerminalRenderer(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
|
|
64
|
+
const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
|
|
65
|
+
render(_jsx(App, { initialAgentCommand: encodedCommand ? decodeAgentCommand(encodedCommand) : undefined }), {
|
|
66
|
+
// App owns Ctrl+C so it can confirm before performing a clean shutdown.
|
|
67
|
+
exitOnCtrlC: false,
|
|
68
|
+
isScreenReaderEnabled: terminal.screenReader,
|
|
69
|
+
interactive: terminal.interactive ? undefined : false,
|
|
70
|
+
debug: !terminal.interactive,
|
|
71
|
+
kittyKeyboard: { mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
|
|
72
|
+
});
|
|
37
73
|
}
|
|
38
74
|
export async function runCommand(args) {
|
|
39
75
|
const [command, ...rest] = args;
|
|
@@ -49,6 +85,14 @@ export async function runCommand(args) {
|
|
|
49
85
|
await runAlarmCommand(rest);
|
|
50
86
|
return;
|
|
51
87
|
}
|
|
88
|
+
if (command === 'mcp') {
|
|
89
|
+
await runMcpCommand(rest, runtime);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (command === 'agent') {
|
|
93
|
+
await runAgentCliCommand(rest, runtime);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
52
96
|
if (command === 'setup') {
|
|
53
97
|
if (rest.includes('--help') || rest.includes('-h')) {
|
|
54
98
|
printSetupHelp();
|
|
@@ -63,16 +107,29 @@ export async function runCommand(args) {
|
|
|
63
107
|
if (command === 'doctor') {
|
|
64
108
|
const backends = detectPlaybackBackends();
|
|
65
109
|
const mpvDiagnostic = diagnoseCommand('mpv');
|
|
110
|
+
const report = await doctorReport(backends, mpvDiagnostic);
|
|
66
111
|
if (rest.includes('--json')) {
|
|
67
|
-
console.log(JSON.stringify(
|
|
112
|
+
console.log(JSON.stringify(report, null, 2));
|
|
68
113
|
return;
|
|
69
114
|
}
|
|
70
115
|
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
71
116
|
printMpvDiagnostic(mpvDiagnostic);
|
|
72
117
|
printPlaybackBackendStatus(backends);
|
|
118
|
+
for (const [name, capability] of Object.entries(report.capabilities)) {
|
|
119
|
+
console.log(`capability_${name}=${capability.status} ${capability.message}`);
|
|
120
|
+
}
|
|
121
|
+
console.log(`network=${report.network.status} ${report.network.message}`);
|
|
122
|
+
console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`);
|
|
123
|
+
console.log(`support_scope=${report.support.scope}`);
|
|
124
|
+
console.log('support_note=Doctor checks this installation without running packed-install or audio acceptance tests; reference CI results are documented in the platform matrix.');
|
|
125
|
+
for (const reason of report.support.reasons)
|
|
126
|
+
console.log(`support_note=${reason}`);
|
|
73
127
|
return;
|
|
74
128
|
}
|
|
75
129
|
if (command === 'update') {
|
|
130
|
+
const unknown = rest.filter(arg => arg !== '--install');
|
|
131
|
+
if (unknown.length > 0)
|
|
132
|
+
throw new Error('Usage: radiocli update [--install]');
|
|
76
133
|
const updateCheck = await checkForUpdate();
|
|
77
134
|
const updateCommand = updateCommandForInstall();
|
|
78
135
|
if (updateCheck.error) {
|
|
@@ -84,6 +141,26 @@ export async function runCommand(args) {
|
|
|
84
141
|
console.log(`available=${updateCheck.updateAvailable ? 'yes' : 'no'}`);
|
|
85
142
|
}
|
|
86
143
|
console.log(`command=${updateCommand.command}`);
|
|
144
|
+
if (rest.includes('--install')) {
|
|
145
|
+
const result = await installUpdate(updateCommand.command);
|
|
146
|
+
if (!result.ok)
|
|
147
|
+
throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
|
|
148
|
+
console.log('updated=yes');
|
|
149
|
+
const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
|
|
150
|
+
if (agentControl.enabled) {
|
|
151
|
+
try {
|
|
152
|
+
const repaired = await configureMcpIntegrations(true, runtime);
|
|
153
|
+
const failed = repaired.filter(item => item.status === 'failed');
|
|
154
|
+
console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
|
|
155
|
+
if (failed.length)
|
|
156
|
+
console.log(`mcp_failures=${failed.map(item => `${item.client}: ${item.detail}`).join('; ')}`);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
console.log(`mcp_repaired=failed ${error instanceof Error ? error.message : String(error)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
console.log('restart_required=yes');
|
|
163
|
+
}
|
|
87
164
|
return;
|
|
88
165
|
}
|
|
89
166
|
if (command === 'check') {
|
|
@@ -122,12 +199,20 @@ export async function runCommand(args) {
|
|
|
122
199
|
return;
|
|
123
200
|
}
|
|
124
201
|
if (command === 'import') {
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
127
|
-
throw new Error('Usage: radiocli import <playlist.m3u|playlist.pls|playlist.xspf>');
|
|
202
|
+
const target = rest[0];
|
|
203
|
+
if (!target) {
|
|
204
|
+
throw new Error('Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]');
|
|
128
205
|
}
|
|
129
|
-
const stations = parsePlaylistFile(file);
|
|
130
206
|
const store = new JsonLibraryStore();
|
|
207
|
+
if (/^https?:\/\//i.test(target)) {
|
|
208
|
+
const result = await importStreamUrl(target, rest.slice(1).join(' ') || undefined);
|
|
209
|
+
store.addImported([result.station]);
|
|
210
|
+
console.log(`imported=${result.station.name}`);
|
|
211
|
+
if (result.warning)
|
|
212
|
+
console.log(`warning=${result.warning}`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const stations = parsePlaylistFile(target);
|
|
131
216
|
store.addImported(stations);
|
|
132
217
|
console.log(`imported=${stations.length}`);
|
|
133
218
|
return;
|
|
@@ -140,17 +225,6 @@ export async function runCommand(args) {
|
|
|
140
225
|
console.log(`exported=${file}`);
|
|
141
226
|
return;
|
|
142
227
|
}
|
|
143
|
-
if (command === 'add-url') {
|
|
144
|
-
const url = rest[0];
|
|
145
|
-
if (!url || !/^https?:\/\//i.test(url)) {
|
|
146
|
-
throw new Error('Usage: radiocli add-url <stream-url> [station name]');
|
|
147
|
-
}
|
|
148
|
-
const station = stationFromUrl(url, rest.slice(1).join(' ') || url);
|
|
149
|
-
const store = new JsonLibraryStore();
|
|
150
|
-
store.addImported([station]);
|
|
151
|
-
console.log(`added=${station.name}`);
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
228
|
}
|
|
155
229
|
export function printHelp() {
|
|
156
230
|
console.log(`RadioCLI
|
|
@@ -161,12 +235,14 @@ Usage:
|
|
|
161
235
|
radiocli check Show provider/backend health
|
|
162
236
|
radiocli doctor [--json] Show local playback setup guidance
|
|
163
237
|
radiocli setup Install and verify native playback tools
|
|
238
|
+
radiocli mcp <command> Install, inspect, or run the MCP integration
|
|
239
|
+
radiocli agent <command> Scriptable radio controls for local agents
|
|
164
240
|
radiocli update Show update availability and install command
|
|
241
|
+
radiocli update --install Install the latest release and repair enabled MCP entries
|
|
165
242
|
radiocli countries Print top countries
|
|
166
243
|
radiocli search <query> Search public stations
|
|
167
|
-
radiocli import <
|
|
244
|
+
radiocli import <target> Import a stream URL or local playlist
|
|
168
245
|
radiocli export [file] Export favorites/imports as .m3u
|
|
169
|
-
radiocli add-url <url> [name]
|
|
170
246
|
radiocli alarm <command> Manage alarms and scheduled radio
|
|
171
247
|
`);
|
|
172
248
|
}
|
|
@@ -182,7 +258,7 @@ export function isDirectRun(entryPath, moduleUrl) {
|
|
|
182
258
|
}
|
|
183
259
|
}
|
|
184
260
|
function isKnownCommand(command) {
|
|
185
|
-
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', '
|
|
261
|
+
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'alarm', 'mcp', 'agent'].includes(command);
|
|
186
262
|
}
|
|
187
263
|
function printSetupHelp() {
|
|
188
264
|
console.log(`RadioCLI Setup
|
|
@@ -193,8 +269,14 @@ Usage:
|
|
|
193
269
|
radiocli setup --all --yes Install mpv, FFmpeg, and VLC
|
|
194
270
|
radiocli setup --only mpv,ffmpeg Select specific components
|
|
195
271
|
radiocli setup --dry-run Show commands without installing
|
|
272
|
+
radiocli setup --mcp Enable and configure agent MCP clients
|
|
273
|
+
radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
|
|
274
|
+
radiocli setup --mcp --headless-agent Opt out of external terminal windows
|
|
275
|
+
radiocli setup --no-mcp Disable and remove agent MCP entries
|
|
196
276
|
radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
|
|
197
|
-
apt, dnf, pacman, apk,
|
|
277
|
+
apt, dnf, pacman, apk, zypper,
|
|
278
|
+
pkg (FreeBSD), pkg_add, pkgin,
|
|
279
|
+
termux-pkg (Termux), or pkgman (Haiku)
|
|
198
280
|
`);
|
|
199
281
|
}
|
|
200
282
|
function printPlaybackBackendStatus(backends) {
|
|
@@ -202,14 +284,46 @@ function printPlaybackBackendStatus(backends) {
|
|
|
202
284
|
console.log(line);
|
|
203
285
|
}
|
|
204
286
|
}
|
|
205
|
-
function doctorReport(backends, mpvDiagnostic) {
|
|
287
|
+
async function doctorReport(backends, mpvDiagnostic) {
|
|
206
288
|
const commands = Object.fromEntries(['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]));
|
|
207
289
|
const airPlay = airPlaySenderHealth();
|
|
290
|
+
const host = identifyPlatform();
|
|
291
|
+
const terminal = resolveTerminalCapabilities(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
|
|
292
|
+
let scheduler;
|
|
293
|
+
try {
|
|
294
|
+
const adapter = createSchedulerAdapter();
|
|
295
|
+
scheduler = await adapter.probeCapabilities?.();
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
scheduler = { supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.' };
|
|
299
|
+
}
|
|
300
|
+
const integrationCommands = [
|
|
301
|
+
'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit',
|
|
302
|
+
'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer',
|
|
303
|
+
'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel',
|
|
304
|
+
'termux-open-url', 'termux-clipboard-set'
|
|
305
|
+
].filter(command => resolveCommand(command) !== null);
|
|
306
|
+
const capabilities = platformCapabilities(host, {
|
|
307
|
+
backends,
|
|
308
|
+
commands: integrationCommands,
|
|
309
|
+
graphicalSession: hasGraphicalSession(host),
|
|
310
|
+
packageManager: detectPackageManager(process.platform, host.osRelease),
|
|
311
|
+
storageWritable: libraryWritesAvailable(),
|
|
312
|
+
scheduler,
|
|
313
|
+
unicode: terminal.unicode,
|
|
314
|
+
color: terminal.colorLevel > 0,
|
|
315
|
+
screenReader: terminal.screenReader
|
|
316
|
+
});
|
|
208
317
|
return {
|
|
209
318
|
radioCliVersion: appVersion(),
|
|
210
319
|
nodeVersion: process.version,
|
|
211
320
|
platform: process.platform,
|
|
212
321
|
architecture: process.arch,
|
|
322
|
+
host: { id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl },
|
|
323
|
+
support: { scope: 'current-installation', ...assessPlatformSupport(host) },
|
|
324
|
+
capabilities,
|
|
325
|
+
terminal,
|
|
326
|
+
network: networkDiagnostic(),
|
|
213
327
|
backends,
|
|
214
328
|
commands,
|
|
215
329
|
mpv: redactDiagnostic(mpvDiagnostic),
|
|
@@ -224,6 +338,14 @@ function doctorReport(backends, mpvDiagnostic) {
|
|
|
224
338
|
guidance: playbackBackendStatusLines(backends)
|
|
225
339
|
};
|
|
226
340
|
}
|
|
341
|
+
function libraryWritesAvailable() {
|
|
342
|
+
try {
|
|
343
|
+
return storageReadiness(defaultLibraryPath()).status === 'available';
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
227
349
|
function printMpvDiagnostic(diagnostic) {
|
|
228
350
|
console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`);
|
|
229
351
|
console.log(`mpv_discovery=${diagnostic.discovery}`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { browserCommands, clipboardCandidates } from './desktop.js';
|
|
2
|
+
import { nativeAdapters } from './runtime.js';
|
|
3
|
+
const available = (message, adapter) => ({ status: 'available', message, ...(adapter ? { adapter } : {}) });
|
|
4
|
+
const unavailable = (message) => ({ status: 'unavailable', message });
|
|
5
|
+
const unverified = (message, adapter) => ({ status: 'unverified', message, ...(adapter ? { adapter } : {}) });
|
|
6
|
+
/**
|
|
7
|
+
* Availability is independent per feature. Tool discovery never substitutes for
|
|
8
|
+
* desktop, scheduler, audio-device, permission, or upstream runtime verification.
|
|
9
|
+
*/
|
|
10
|
+
export function platformCapabilities(host, evidence) {
|
|
11
|
+
const adapters = nativeAdapters(host);
|
|
12
|
+
const commands = new Set(evidence.commands);
|
|
13
|
+
const has = (name) => commands.has(name);
|
|
14
|
+
const backend = ['mpv', 'ffplay', 'vlc', 'airplay'].find(name => evidence.backends.includes(name));
|
|
15
|
+
const desktop = evidence.graphicalSession;
|
|
16
|
+
const opener = browserCommands(host).find(item => has(item.command));
|
|
17
|
+
const clipboard = clipboardCandidates(host).find(item => has(item.command));
|
|
18
|
+
const schedulerTool = adapters.scheduler === 'launchd' ? 'launchctl'
|
|
19
|
+
: adapters.scheduler === 'task-scheduler' ? 'schtasks.exe' : adapters.scheduler === 'systemd' ? 'systemctl' : null;
|
|
20
|
+
const backgroundScheduling = evidence.scheduler
|
|
21
|
+
? evidence.scheduler.supported ? available(evidence.scheduler.message, adapters.scheduler ?? undefined) : unavailable(evidence.scheduler.message)
|
|
22
|
+
: schedulerTool && has(schedulerTool) ? unverified('Native scheduler command found; verify the per-user session with radiocli alarm doctor.', adapters.scheduler ?? undefined)
|
|
23
|
+
: unavailable(adapters.scheduler ? `The ${schedulerTool} scheduler command is unavailable.` : `Reliable background scheduling has no verified adapter on ${host.id}.`);
|
|
24
|
+
const sleepTool = adapters.inhibitor === 'caffeinate' ? 'caffeinate' : adapters.inhibitor === 'logind' ? 'systemd-inhibit' : adapters.inhibitor === 'windows' ? 'powershell.exe' : null;
|
|
25
|
+
const volumeTool = adapters.volume === 'macos' ? has('osascript') ? 'osascript' : null
|
|
26
|
+
: adapters.volume === 'windows' ? ['powershell.exe', 'pwsh.exe'].find(has)
|
|
27
|
+
: adapters.volume === 'unix-audio' ? ['wpctl', 'pactl', 'amixer'].find(has) : null;
|
|
28
|
+
const storage = evidence.storageWritable === true ? available('The local data directory is writable; operations still verify each write.')
|
|
29
|
+
: evidence.storageWritable === false ? unavailable('Local data is not writable. Set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.')
|
|
30
|
+
: unverified('Local JSON data uses the existing private user paths; writability is checked when used.');
|
|
31
|
+
return {
|
|
32
|
+
playback: backend ? available(`${backend} executable detected; stream and audio-device readiness are checked when tuning.`, backend) : unavailable('No playback backend detected. Run radiocli setup or configure a native player path.'),
|
|
33
|
+
playbackControls: backend === 'mpv' ? available('mpv IPC provides pause, mute, volume, metadata, and media keys.', 'mpv')
|
|
34
|
+
: unavailable(backend === 'airplay' ? 'AirPlay provides volume and mute; pause and media-key playback controls require local mpv.' : 'Pause, mute, volume, metadata, and media-key playback controls require mpv.'),
|
|
35
|
+
packageInstallation: evidence.packageManager ? available('Explicit setup plans require confirmation or --yes; --dry-run never installs.', evidence.packageManager) : unavailable('No supported package manager detected; install playback prerequisites manually.'),
|
|
36
|
+
storage,
|
|
37
|
+
filePermissions: adapters.posixPermissions ? available('Private files use mode 0600 and private directories use mode 0700; the filesystem must enforce POSIX permissions.') : available('Private user directories inherit Windows ACLs; POSIX modes do not configure Windows ACLs.'),
|
|
38
|
+
atomicWrites: evidence.storageWritable === false ? unavailable('Atomic replacement requires a writable destination directory.') : available('Temporary files are renamed within the destination directory; write failures are reported.'),
|
|
39
|
+
externalUrl: desktop && opener ? available('A desktop URL handler is available; execution is checked when used.', opener.command) : unavailable('No usable desktop URL handler/session detected. Open the station homepage manually.'),
|
|
40
|
+
clipboard: desktop && clipboard ? available('A clipboard command is available; execution is checked when used.', clipboard.command) : unavailable('No usable clipboard command/session detected. RadioCLI can display the text for manual copying.'),
|
|
41
|
+
terminalReopening: desktop && adapters.terminal ? unverified('A supported installed terminal must be found and its launch verified.', adapters.terminal) : unavailable('No supported graphical terminal session. Use the current TUI or explicitly select headless agent playback.'),
|
|
42
|
+
backgroundScheduling,
|
|
43
|
+
catchUp: backgroundScheduling.status === 'available' && evidence.scheduler?.catchUpAfterWake ? available('The native scheduler can deliver a missed occurrence; RadioCLI still enforces the configured grace window.')
|
|
44
|
+
: backgroundScheduling.status === 'unverified' ? unverified('Catch-up depends on an accessible native user scheduler and its session.') : unavailable('Catch-up delivery is not verified in this session.'),
|
|
45
|
+
wakeRequests: adapters.scheduler === 'task-scheduler' ? unverified('Task Scheduler can request WakeToRun; hardware, login state, and power policy decide delivery.') : unavailable('No privileged or exact wake request is made on this platform. Catch-up and idle-sleep inhibition are separate capabilities.'),
|
|
46
|
+
sleepInhibition: sleepTool && has(sleepTool) ? unverified('A sleep-inhibitor tool is installed; its lease must remain alive to provide protection.', sleepTool) : unavailable('No usable per-process sleep inhibitor. Normal playback remains available; sleep may interrupt it.'),
|
|
47
|
+
systemVolume: volumeTool ? unverified('The system mixer must respond before alarm volume can be raised and restored.', volumeTool) : unavailable('System output volume cannot be adjusted. Set the device volume manually; player volume remains independent.'),
|
|
48
|
+
unicode: evidence.unicode === false ? unavailable('ASCII decoration mode is selected; international station data is preserved.') : available('Unicode decoration is enabled; terminal font coverage determines glyph appearance.'),
|
|
49
|
+
color: evidence.color === false ? unavailable('Color is disabled for the terminal or by user preference.') : available('Color is enabled at the terminal supported depth.'),
|
|
50
|
+
screenReader: available(evidence.screenReader ? 'Screen-reader mode is enabled; decorative animation is disabled.' : 'Screen-reader mode is available through INK_SCREEN_READER=true.'),
|
|
51
|
+
airPlay: adapters.airPlay && evidence.backends.includes('airplay') ? available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay') : unavailable(adapters.airPlay ? 'Experimental AirPlay requires FFmpeg, Bonjour, and the safe optional sender.' : 'AirPlay output is available only on macOS.')
|
|
52
|
+
};
|
|
53
|
+
}
|