@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
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { applyEdits, modify, parse } from 'jsonc-parser';
|
|
6
|
+
import { JsonLibraryStore } from '../storage/store.js';
|
|
7
|
+
import { defaultAgentControlSettings } from '../types.js';
|
|
8
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
9
|
+
const formatting = { insertSpaces: true, tabSize: 2, eol: '\n' };
|
|
10
|
+
export async function configureMcpIntegrations(enabled, runtime, output = process.stdout) {
|
|
11
|
+
const store = new JsonLibraryStore();
|
|
12
|
+
const current = store.snapshot().settings;
|
|
13
|
+
if (!enabled)
|
|
14
|
+
store.updateSettings({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled: false } });
|
|
15
|
+
const results = [];
|
|
16
|
+
const command = mcpServerCommand(runtime);
|
|
17
|
+
if (enabled) {
|
|
18
|
+
const probe = await probeMcpServer(command);
|
|
19
|
+
results.push({ client: 'RadioCLI MCP server', status: probe.ok ? 'configured' : 'failed', detail: probe.detail });
|
|
20
|
+
if (!probe.ok) {
|
|
21
|
+
output?.write('\nAgent integration could not be enabled\n');
|
|
22
|
+
output?.write(` RadioCLI MCP server: failed · ${probe.detail}\n`);
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const codex = resolveCodexExecutable();
|
|
27
|
+
if (codex) {
|
|
28
|
+
results.push(await configureCliClient('Codex', codex, enabled
|
|
29
|
+
? ['mcp', 'add', 'radiocli', '--', ...command]
|
|
30
|
+
: ['mcp', 'remove', 'radiocli'], ['mcp', 'remove', 'radiocli']));
|
|
31
|
+
}
|
|
32
|
+
else
|
|
33
|
+
results.push(notFound('Codex'));
|
|
34
|
+
const claude = resolveCommandDetails('claude', { env: process.env }).path;
|
|
35
|
+
if (claude) {
|
|
36
|
+
results.push(await configureCliClient('Claude Code', claude, enabled
|
|
37
|
+
? ['mcp', 'add', '--scope', 'user', 'radiocli', '--', ...command]
|
|
38
|
+
: ['mcp', 'remove', '--scope', 'user', 'radiocli'], ['mcp', 'remove', '--scope', 'user', 'radiocli']));
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
results.push(notFound('Claude Code'));
|
|
42
|
+
const openCodePath = openCodeConfigPath();
|
|
43
|
+
if (resolveCommandDetails('opencode', { env: process.env }).path || existsSync(openCodePath)) {
|
|
44
|
+
try {
|
|
45
|
+
updateOpenCodeConfig(openCodePath, enabled, command);
|
|
46
|
+
results.push({ client: 'OpenCode', status: enabled ? 'configured' : 'removed', detail: openCodePath });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
results.push({ client: 'OpenCode', status: 'failed', detail: message(error) });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
results.push(notFound('OpenCode'));
|
|
54
|
+
configureJsonClient(results, 'Cursor', cursorDetected(), join(homedir(), '.cursor', 'mcp.json'), ['mcpServers', 'radiocli'], enabled, {
|
|
55
|
+
command: command[0], args: command.slice(1)
|
|
56
|
+
});
|
|
57
|
+
configureJsonClient(results, 'Gemini CLI', Boolean(resolveCommandDetails('gemini', { env: process.env }).path), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, {
|
|
58
|
+
command: command[0], args: command.slice(1)
|
|
59
|
+
});
|
|
60
|
+
configureJsonClient(results, 'VS Code / Copilot Agent Host', vsCodeDetected(), join(homedir(), '.copilot', 'mcp-config.json'), ['servers', 'radiocli'], enabled, {
|
|
61
|
+
type: 'stdio', command: command[0], args: command.slice(1)
|
|
62
|
+
});
|
|
63
|
+
if (resolveCommandDetails('orca', { env: process.env }).path) {
|
|
64
|
+
results.push({ client: 'Orca', status: 'inherited', detail: 'Orca exposes MCP integrations through its configured Codex/Claude agent runtimes.' });
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
results.push(notFound('Orca'));
|
|
68
|
+
const portablePath = writePortableConfig(command, enabled);
|
|
69
|
+
results.push({ client: 'Other MCP clients', status: enabled ? 'configured' : 'removed', detail: portablePath });
|
|
70
|
+
if (enabled) {
|
|
71
|
+
const latest = store.snapshot().settings;
|
|
72
|
+
store.updateSettings({ agentControl: { ...(latest.agentControl ?? defaultAgentControlSettings), enabled: true } });
|
|
73
|
+
}
|
|
74
|
+
output?.write(`\nAgent integration ${enabled ? 'enabled' : 'disabled'}\n`);
|
|
75
|
+
for (const result of results)
|
|
76
|
+
output?.write(` ${result.client}: ${result.status} · ${result.detail}\n`);
|
|
77
|
+
if (enabled) {
|
|
78
|
+
output?.write('\nIMPORTANT: Fully quit and reopen every running agent client before testing RadioCLI. New tasks opened before that restart will not have the RadioCLI tools and may incorrectly fall back to a browser.\n');
|
|
79
|
+
}
|
|
80
|
+
return results;
|
|
81
|
+
}
|
|
82
|
+
export function portableMcpConfig(runtime) {
|
|
83
|
+
const command = mcpServerCommand(runtime);
|
|
84
|
+
return { mcpServers: { radiocli: { type: 'stdio', command: command[0], args: command.slice(1) } } };
|
|
85
|
+
}
|
|
86
|
+
export async function mcpIntegrationReport(runtime) {
|
|
87
|
+
const settings = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
|
|
88
|
+
const command = mcpServerCommand(runtime);
|
|
89
|
+
const shimLauncher = command.length === 3;
|
|
90
|
+
const server = await probeMcpServer(command);
|
|
91
|
+
const clients = await mcpClientStates(command);
|
|
92
|
+
const needsRepair = Object.entries(clients)
|
|
93
|
+
.filter(([, client]) => client.detected && (client.state === 'missing' || client.state === 'stale'))
|
|
94
|
+
.map(([client]) => client);
|
|
95
|
+
const launcherExists = Boolean(command[0] && existsSync(command[0]) && (shimLauncher || (command[1] && existsSync(command[1]))));
|
|
96
|
+
return {
|
|
97
|
+
enabled: settings.enabled,
|
|
98
|
+
health: !settings.enabled ? 'disabled' : launcherExists && server.ok && needsRepair.length === 0 ? 'ready' : 'needs-repair',
|
|
99
|
+
nextStep: !settings.enabled
|
|
100
|
+
? 'Enable Agent control & MCP in the TUI, or run radiocli mcp enable.'
|
|
101
|
+
: !server.ok
|
|
102
|
+
? `The configured MCP server cannot start: ${server.detail} Run radiocli mcp repair after reinstalling or rebuilding RadioCLI.`
|
|
103
|
+
: needsRepair.length > 0
|
|
104
|
+
? `Run radiocli mcp repair, then restart open agent clients. Needs repair: ${needsRepair.join(', ')}.`
|
|
105
|
+
: 'RadioCLI is ready for local agents and Codex Voice.',
|
|
106
|
+
command,
|
|
107
|
+
server,
|
|
108
|
+
launcher: {
|
|
109
|
+
mode: shimLauncher ? 'radiocli-shim' : 'node-fallback',
|
|
110
|
+
path: command[0],
|
|
111
|
+
target: shimLauncher ? command[0] : command[1],
|
|
112
|
+
exists: launcherExists,
|
|
113
|
+
upgradeSafe: shimLauncher
|
|
114
|
+
},
|
|
115
|
+
clients,
|
|
116
|
+
portableConfigPath: portableConfigPath()
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Prefer the package-manager-owned shim because it survives package/Cellar version changes. */
|
|
120
|
+
export function mcpServerCommand(runtime, radioCliPath, platform = process.platform) {
|
|
121
|
+
const candidate = radioCliPath === undefined ? matchingRadioCliShim(runtime, platform) : radioCliPath;
|
|
122
|
+
const launcher = candidate && usableDirectLauncher(candidate, platform) ? candidate : undefined;
|
|
123
|
+
return launcher
|
|
124
|
+
? [launcher, 'mcp', 'serve']
|
|
125
|
+
: [runtime.nodePath, runtime.cliPath, 'mcp', 'serve'];
|
|
126
|
+
}
|
|
127
|
+
export function probeMcpServer(command, timeoutMs = 5_000) {
|
|
128
|
+
return new Promise(resolve => {
|
|
129
|
+
const [program, ...args] = command;
|
|
130
|
+
if (!program) {
|
|
131
|
+
resolve({ ok: false, detail: 'No MCP server command was resolved.' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const child = spawn(program, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
135
|
+
let stdout = '';
|
|
136
|
+
let stderr = '';
|
|
137
|
+
let settled = false;
|
|
138
|
+
const finish = (result) => {
|
|
139
|
+
if (settled)
|
|
140
|
+
return;
|
|
141
|
+
settled = true;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
child.kill();
|
|
144
|
+
resolve(result);
|
|
145
|
+
};
|
|
146
|
+
const timer = setTimeout(() => finish({ ok: false, detail: `Handshake timed out after ${timeoutMs} ms.${stderr ? ` ${stderr.trim()}` : ''}` }), timeoutMs);
|
|
147
|
+
timer.unref();
|
|
148
|
+
child.stderr.on('data', value => { stderr += String(value); });
|
|
149
|
+
child.stdout.on('data', value => {
|
|
150
|
+
stdout += String(value);
|
|
151
|
+
let newline = stdout.indexOf('\n');
|
|
152
|
+
while (newline >= 0) {
|
|
153
|
+
const line = stdout.slice(0, newline).trim();
|
|
154
|
+
stdout = stdout.slice(newline + 1);
|
|
155
|
+
if (line) {
|
|
156
|
+
try {
|
|
157
|
+
const response = JSON.parse(line);
|
|
158
|
+
if (response.id === 1 && response.result?.serverInfo?.name === 'radiocli') {
|
|
159
|
+
finish({ ok: true, detail: 'stdio handshake succeeded' });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (response.id === 1 && response.error) {
|
|
163
|
+
finish({ ok: false, detail: `Handshake rejected: ${String(response.error.message ?? 'unknown MCP error')}` });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Keep reading: a client may emit a non-protocol diagnostic line before startup.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
newline = stdout.indexOf('\n');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
child.once('error', error => finish({ ok: false, detail: error.message }));
|
|
175
|
+
child.once('close', code => finish({ ok: false, detail: `${stderr.trim() || stdout.trim() || `server exited with code ${code ?? 1}`}` }));
|
|
176
|
+
child.stdin.end(`${JSON.stringify({
|
|
177
|
+
jsonrpc: '2.0',
|
|
178
|
+
id: 1,
|
|
179
|
+
method: 'initialize',
|
|
180
|
+
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'radiocli-setup', version: '1.0.0' } }
|
|
181
|
+
})}\n`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function matchingRadioCliShim(runtime, platform) {
|
|
185
|
+
const candidate = resolveCommandDetails('radiocli', { env: process.env, platform }).path;
|
|
186
|
+
if (!candidate)
|
|
187
|
+
return undefined;
|
|
188
|
+
if (!usableDirectLauncher(candidate, platform))
|
|
189
|
+
return undefined;
|
|
190
|
+
try {
|
|
191
|
+
return realpathSync(candidate) === realpathSync(runtime.cliPath) ? candidate : undefined;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function usableDirectLauncher(path, platform) {
|
|
198
|
+
return platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(path);
|
|
199
|
+
}
|
|
200
|
+
async function mcpClientStates(command) {
|
|
201
|
+
const openCodePath = openCodeConfigPath();
|
|
202
|
+
const cursorPath = join(homedir(), '.cursor', 'mcp.json');
|
|
203
|
+
const geminiPath = join(homedir(), '.gemini', 'settings.json');
|
|
204
|
+
const vscodePath = join(homedir(), '.copilot', 'mcp-config.json');
|
|
205
|
+
return {
|
|
206
|
+
codex: await codexClientState(resolveCodexExecutable(), command),
|
|
207
|
+
claude: await claudeClientState(resolveCommandDetails('claude', { env: process.env }).path, command),
|
|
208
|
+
opencode: jsonClientState(Boolean(resolveCommandDetails('opencode', { env: process.env }).path || existsSync(openCodePath)), openCodePath, [
|
|
209
|
+
['mcp', 'servers', 'radiocli'], ['mcp', 'radiocli']
|
|
210
|
+
], command),
|
|
211
|
+
cursor: jsonClientState(cursorDetected(), cursorPath, [['mcpServers', 'radiocli']], command),
|
|
212
|
+
gemini: jsonClientState(Boolean(resolveCommandDetails('gemini', { env: process.env }).path), geminiPath, [['mcpServers', 'radiocli']], command),
|
|
213
|
+
vscode: jsonClientState(vsCodeDetected(), vscodePath, [['servers', 'radiocli']], command),
|
|
214
|
+
orca: {
|
|
215
|
+
detected: Boolean(resolveCommandDetails('orca', { env: process.env }).path),
|
|
216
|
+
state: resolveCommandDetails('orca', { env: process.env }).path ? 'managed-by-client' : 'not-found',
|
|
217
|
+
detail: 'inherits MCP configuration from its Codex or Claude runtime'
|
|
218
|
+
},
|
|
219
|
+
portable: jsonClientState(true, portableConfigPath(), [['mcpServers', 'radiocli']], command)
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function managedClientState(detected) {
|
|
223
|
+
return { detected, state: detected ? 'managed-by-client' : 'not-found', detail: detected ? 'inspect with the client MCP command' : 'client is not installed' };
|
|
224
|
+
}
|
|
225
|
+
export function resolveCodexExecutable(env = process.env, platform = process.platform, home = homedir()) {
|
|
226
|
+
const configured = env.CODEX_CLI_PATH;
|
|
227
|
+
if (configured && existsSync(configured))
|
|
228
|
+
return configured;
|
|
229
|
+
const pathExecutable = resolveCommandDetails('codex', { env, platform, home }).path;
|
|
230
|
+
if (pathExecutable)
|
|
231
|
+
return pathExecutable;
|
|
232
|
+
const candidates = platform === 'darwin'
|
|
233
|
+
? [
|
|
234
|
+
'/Applications/ChatGPT.app/Contents/Resources/codex',
|
|
235
|
+
join(home, 'Applications', 'ChatGPT.app', 'Contents', 'Resources', 'codex')
|
|
236
|
+
]
|
|
237
|
+
: platform === 'win32'
|
|
238
|
+
? [
|
|
239
|
+
join(env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'Programs', 'ChatGPT', 'resources', 'codex.exe'),
|
|
240
|
+
join(env.ProgramFiles ?? 'C:\\Program Files', 'ChatGPT', 'resources', 'codex.exe')
|
|
241
|
+
]
|
|
242
|
+
: [];
|
|
243
|
+
return candidates.find(candidate => existsSync(candidate));
|
|
244
|
+
}
|
|
245
|
+
function cursorDetected() {
|
|
246
|
+
return Boolean(resolveCommandDetails('cursor', { env: process.env }).path ||
|
|
247
|
+
resolveCommandDetails('cursor-agent', { env: process.env }).path ||
|
|
248
|
+
existsSync(join(homedir(), '.cursor')) ||
|
|
249
|
+
(process.platform === 'darwin' && existsSync('/Applications/Cursor.app')) ||
|
|
250
|
+
(process.platform === 'win32' && existsSync(join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Programs', 'cursor', 'Cursor.exe'))));
|
|
251
|
+
}
|
|
252
|
+
function vsCodeDetected() {
|
|
253
|
+
return Boolean(resolveCommandDetails('code', { env: process.env }).path ||
|
|
254
|
+
(process.platform === 'darwin' && existsSync('/Applications/Visual Studio Code.app')) ||
|
|
255
|
+
(process.platform === 'win32' && existsSync(join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Programs', 'Microsoft VS Code', 'Code.exe'))));
|
|
256
|
+
}
|
|
257
|
+
async function codexClientState(executable, expected) {
|
|
258
|
+
if (!executable)
|
|
259
|
+
return managedClientState(false);
|
|
260
|
+
const result = await run(executable, ['mcp', 'get', 'radiocli', '--json']);
|
|
261
|
+
if (result.code !== 0) {
|
|
262
|
+
const detail = `${result.stderr}\n${result.stdout}`.trim();
|
|
263
|
+
return missingRegistration(detail)
|
|
264
|
+
? { detected: true, state: 'missing', detail: 'RadioCLI is not registered' }
|
|
265
|
+
: { detected: true, state: 'stale', detail: detail || `Codex MCP inspection exited with code ${result.code}` };
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const parsed = JSON.parse(result.stdout);
|
|
269
|
+
const actual = parsed.transport?.type === 'stdio' && typeof parsed.transport.command === 'string' && Array.isArray(parsed.transport.args)
|
|
270
|
+
? [parsed.transport.command, ...parsed.transport.args.filter((value) => typeof value === 'string')]
|
|
271
|
+
: [];
|
|
272
|
+
if (parsed.enabled === false)
|
|
273
|
+
return { detected: true, state: 'stale', detail: 'RadioCLI is registered but disabled in Codex' };
|
|
274
|
+
return arraysEqual(actual, expected)
|
|
275
|
+
? { detected: true, state: 'configured', detail: 'shared ChatGPT desktop, Codex CLI, and IDE configuration' }
|
|
276
|
+
: { detected: true, state: 'stale', detail: `registered command: ${actual.join(' ') || 'unrecognized transport'}` };
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return { detected: true, state: 'stale', detail: 'Codex returned an invalid MCP status response' };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function claudeClientState(executable, expected) {
|
|
283
|
+
if (!executable)
|
|
284
|
+
return managedClientState(false);
|
|
285
|
+
const result = await run(executable, ['mcp', 'get', 'radiocli']);
|
|
286
|
+
const detail = `${result.stdout}\n${result.stderr}`.trim();
|
|
287
|
+
if (result.code !== 0) {
|
|
288
|
+
return missingRegistration(detail)
|
|
289
|
+
? { detected: true, state: 'missing', detail: 'RadioCLI is not registered' }
|
|
290
|
+
: { detected: true, state: 'stale', detail: detail || `Claude MCP inspection exited with code ${result.code}` };
|
|
291
|
+
}
|
|
292
|
+
const commandMatches = expected.every(value => detail.includes(value));
|
|
293
|
+
return commandMatches
|
|
294
|
+
? { detected: true, state: 'configured', detail: 'user-level MCP configuration' }
|
|
295
|
+
: { detected: true, state: 'stale', detail: 'RadioCLI is registered with a different command' };
|
|
296
|
+
}
|
|
297
|
+
function jsonClientState(detected, path, candidates, expected) {
|
|
298
|
+
if (!detected && !existsSync(path))
|
|
299
|
+
return { detected: false, state: 'not-found', detail: path };
|
|
300
|
+
if (!existsSync(path))
|
|
301
|
+
return { detected, state: 'missing', detail: path };
|
|
302
|
+
try {
|
|
303
|
+
const parsed = parse(readFileSync(path, 'utf8'));
|
|
304
|
+
const value = candidates.map(segments => valueAt(parsed, segments)).find(candidate => candidate !== undefined);
|
|
305
|
+
if (value === undefined)
|
|
306
|
+
return { detected, state: 'missing', detail: path };
|
|
307
|
+
const actual = commandFromConfig(value);
|
|
308
|
+
return { detected, state: arraysEqual(actual, expected) ? 'configured' : 'stale', detail: path };
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return { detected, state: 'stale', detail: `${path} (invalid configuration)` };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function valueAt(value, segments) {
|
|
315
|
+
let current = value;
|
|
316
|
+
for (const segment of segments) {
|
|
317
|
+
if (!current || typeof current !== 'object' || !(segment in current))
|
|
318
|
+
return undefined;
|
|
319
|
+
current = current[segment];
|
|
320
|
+
}
|
|
321
|
+
return current;
|
|
322
|
+
}
|
|
323
|
+
function commandFromConfig(value) {
|
|
324
|
+
if (!value || typeof value !== 'object')
|
|
325
|
+
return [];
|
|
326
|
+
const object = value;
|
|
327
|
+
if (Array.isArray(object.command) && object.command.every(item => typeof item === 'string'))
|
|
328
|
+
return object.command;
|
|
329
|
+
if (typeof object.command === 'string' && Array.isArray(object.args) && object.args.every(item => typeof item === 'string')) {
|
|
330
|
+
return [object.command, ...object.args];
|
|
331
|
+
}
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
function arraysEqual(left, right) {
|
|
335
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
336
|
+
}
|
|
337
|
+
function missingRegistration(detail) {
|
|
338
|
+
return /not found|does not exist|not configured|no mcp server(?:s)?.*found|no mcp servers are configured/i.test(detail);
|
|
339
|
+
}
|
|
340
|
+
async function configureCliClient(client, program, args, removeArgs) {
|
|
341
|
+
try {
|
|
342
|
+
let result = await run(program, args);
|
|
343
|
+
if (result.code !== 0 && args.includes('add') && /already exists|already configured|duplicate/i.test(`${result.stderr}\n${result.stdout}`)) {
|
|
344
|
+
const removed = await run(program, removeArgs);
|
|
345
|
+
if (removed.code !== 0)
|
|
346
|
+
throw new Error((removed.stderr || removed.stdout || `exit ${removed.code}`).trim());
|
|
347
|
+
result = await run(program, args);
|
|
348
|
+
}
|
|
349
|
+
if (result.code !== 0)
|
|
350
|
+
throw new Error((result.stderr || result.stdout || `exit ${result.code}`).trim());
|
|
351
|
+
return { client, status: args.includes('add') ? 'configured' : 'removed', detail: 'user-level MCP configuration' };
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (!args.includes('add') && /not found|does not exist|No MCP server/i.test(message(error)))
|
|
355
|
+
return { client, status: 'removed', detail: 'already absent' };
|
|
356
|
+
return { client, status: 'failed', detail: message(error) };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
export function updateOpenCodeConfig(path, enabled, command) {
|
|
360
|
+
const source = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
361
|
+
const parsed = source ? parse(source) : {};
|
|
362
|
+
const legacy = Boolean(parsed.mcp && !('servers' in parsed.mcp) && Object.values(parsed.mcp).some(isLegacyOpenCodeServer));
|
|
363
|
+
const value = enabled
|
|
364
|
+
? legacy ? { type: 'local', command, enabled: true } : { type: 'local', command, disabled: false }
|
|
365
|
+
: undefined;
|
|
366
|
+
updateJsoncValue(path, legacy ? ['mcp', 'radiocli'] : ['mcp', 'servers', 'radiocli'], value, '{\n "$schema": "https://opencode.ai/config.json"\n}\n', 'OpenCode');
|
|
367
|
+
}
|
|
368
|
+
function isLegacyOpenCodeServer(value) {
|
|
369
|
+
if (!value || typeof value !== 'object')
|
|
370
|
+
return false;
|
|
371
|
+
const candidate = value;
|
|
372
|
+
return (candidate.type === 'local' && Array.isArray(candidate.command)) || (candidate.type === 'remote' && typeof candidate.url === 'string');
|
|
373
|
+
}
|
|
374
|
+
function configureJsonClient(results, client, detected, path, segments, enabled, value) {
|
|
375
|
+
if (!detected && !existsSync(path)) {
|
|
376
|
+
results.push(notFound(client));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
updateJsoncValue(path, segments, enabled ? value : undefined, '{}\n', client);
|
|
381
|
+
results.push({ client, status: enabled ? 'configured' : 'removed', detail: path });
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
results.push({ client, status: 'failed', detail: message(error) });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function updateJsoncValue(path, segments, value, initial, client) {
|
|
388
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
389
|
+
const source = existsSync(path) ? readFileSync(path, 'utf8') : initial;
|
|
390
|
+
const errors = [];
|
|
391
|
+
parse(source, errors, { allowTrailingComma: true, disallowComments: false });
|
|
392
|
+
if (errors.length)
|
|
393
|
+
throw new Error(`Cannot safely update invalid ${client} config: ${path}`);
|
|
394
|
+
const updated = applyEdits(source, modify(source, segments, value, { formattingOptions: formatting }));
|
|
395
|
+
writeFileSync(path, updated.endsWith('\n') ? updated : `${updated}\n`, { mode: 0o600 });
|
|
396
|
+
}
|
|
397
|
+
function writePortableConfig(command, enabled) {
|
|
398
|
+
const path = portableConfigPath();
|
|
399
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
400
|
+
const config = enabled ? { mcpServers: { radiocli: { type: 'stdio', command: command[0], args: command.slice(1) } } } : { mcpServers: {} };
|
|
401
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
402
|
+
return path;
|
|
403
|
+
}
|
|
404
|
+
function portableConfigPath() {
|
|
405
|
+
if (process.env.RADIOCLI_HOME)
|
|
406
|
+
return join(process.env.RADIOCLI_HOME, 'mcp.json');
|
|
407
|
+
const base = process.platform === 'win32'
|
|
408
|
+
? process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming')
|
|
409
|
+
: process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
|
|
410
|
+
return join(base, 'radiocli', 'mcp.json');
|
|
411
|
+
}
|
|
412
|
+
function openCodeConfigPath() {
|
|
413
|
+
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
|
|
414
|
+
const directory = join(base, 'opencode');
|
|
415
|
+
const jsonc = join(directory, 'opencode.jsonc');
|
|
416
|
+
const json = join(directory, 'opencode.json');
|
|
417
|
+
return existsSync(jsonc) ? jsonc : json;
|
|
418
|
+
}
|
|
419
|
+
function run(program, args, timeoutMs = 15_000) {
|
|
420
|
+
return new Promise((resolve, reject) => {
|
|
421
|
+
const invocation = cliInvocation(program, args);
|
|
422
|
+
const child = spawn(invocation.program, invocation.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
423
|
+
let stdout = '';
|
|
424
|
+
let stderr = '';
|
|
425
|
+
let settled = false;
|
|
426
|
+
const finish = (result) => {
|
|
427
|
+
if (settled)
|
|
428
|
+
return;
|
|
429
|
+
settled = true;
|
|
430
|
+
clearTimeout(timer);
|
|
431
|
+
resolve(result);
|
|
432
|
+
};
|
|
433
|
+
const timer = setTimeout(() => {
|
|
434
|
+
child.kill();
|
|
435
|
+
finish({ code: 1, stdout, stderr: `${stderr}${stderr ? '\n' : ''}Timed out after ${timeoutMs} ms.` });
|
|
436
|
+
}, timeoutMs);
|
|
437
|
+
timer.unref();
|
|
438
|
+
child.stdout.on('data', value => { stdout += String(value); });
|
|
439
|
+
child.stderr.on('data', value => { stderr += String(value); });
|
|
440
|
+
child.once('error', error => {
|
|
441
|
+
if (settled)
|
|
442
|
+
return;
|
|
443
|
+
settled = true;
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
reject(error);
|
|
446
|
+
});
|
|
447
|
+
child.once('close', code => finish({ code: code ?? 1, stdout, stderr }));
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
export function cliInvocation(program, args, platform = process.platform, env = process.env) {
|
|
451
|
+
if (platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(program))
|
|
452
|
+
return { program, args };
|
|
453
|
+
const command = [program, ...args].map(value => `"${escapeCmdArgument(value)}"`).join(' ');
|
|
454
|
+
return { program: env.ComSpec || env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/v:off', '/c', command] };
|
|
455
|
+
}
|
|
456
|
+
function escapeCmdArgument(value) {
|
|
457
|
+
return value
|
|
458
|
+
.replaceAll('%', '%%')
|
|
459
|
+
.replaceAll('"', '""')
|
|
460
|
+
.replaceAll('^', '^^')
|
|
461
|
+
.replaceAll('&', '^&')
|
|
462
|
+
.replaceAll('|', '^|')
|
|
463
|
+
.replaceAll('<', '^<')
|
|
464
|
+
.replaceAll('>', '^>');
|
|
465
|
+
}
|
|
466
|
+
function notFound(client) { return { client, status: 'not-found', detail: 'client is not installed on this computer' }; }
|
|
467
|
+
function message(error) { return error instanceof Error ? error.message : String(error); }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { appVersion } from '../version.js';
|
|
5
|
+
import { receiverStyleNames, themeNames } from '../types.js';
|
|
6
|
+
import { AgentRadioService, stationForAgent } from './service.js';
|
|
7
|
+
export const radioCliMcpInstructions = `RadioCLI is the authoritative handler for requests about RadioCLI, “the radio”, “my radio”, recent stations, favorites, or stations from a place. Never substitute a web browser, web search, Computer Use, or another radio/music service. “Play my most recent radio station” means call radio_play with source="recent" and index=0. “Play a station from New York” means call radio_search with query="New York", then radio_play with one returned opaque station ID. Call these tools directly; do not delegate a radio-only operation or create a goal merely to monitor it.
|
|
8
|
+
|
|
9
|
+
RadioCLI controls internet radio, alarms, and macOS AirPlay locally. Discover unidentified stations with radio_search or radio_browse, then use only returned opaque IDs; never invent IDs or stream URLs. Ordinary stop never dismisses an alarm: list active alarms and use alarm controls, with an exact ID when several ring. Never remove an alarm without explicit user confirmation. List AirPlay receivers before selecting one. Never install an update without explicit approval.
|
|
10
|
+
|
|
11
|
+
For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is macOS-only. Submit a receiver code only when the user provides it; codes are never saved. Use radio_update_status for version or upgrade questions, and tell the user to restart their MCP client after an update.`;
|
|
12
|
+
const alarmScheduleSchema = z.discriminatedUnion('type', [
|
|
13
|
+
z.object({ type: z.literal('once'), at: z.string().min(1).describe('Absolute ISO-8601 minute including an offset or Z, with zero seconds.') }),
|
|
14
|
+
z.object({
|
|
15
|
+
type: z.literal('recurring'),
|
|
16
|
+
time: z.string().regex(/^\d{2}:\d{2}$/).describe('24-hour local civil time, HH:mm.'),
|
|
17
|
+
weekdays: z.array(z.number().int().min(1).max(7)).min(1).describe('ISO weekdays, Monday=1 through Sunday=7.'),
|
|
18
|
+
timezone: z.string().min(1).describe('IANA timezone such as America/Los_Angeles.')
|
|
19
|
+
})
|
|
20
|
+
]);
|
|
21
|
+
const alarmOptionsSchema = {
|
|
22
|
+
label: z.string().min(1).max(120).optional(),
|
|
23
|
+
enabled: z.boolean().optional(),
|
|
24
|
+
volume: z.number().int().min(0).max(100).optional(),
|
|
25
|
+
fade_seconds: z.number().int().min(0).max(3600).optional(),
|
|
26
|
+
stop_after_minutes: z.number().int().min(1).max(10080).optional(),
|
|
27
|
+
fallback_station_id: z.string().min(1).optional(),
|
|
28
|
+
missed_run_grace_minutes: z.number().int().min(0).max(10080).optional(),
|
|
29
|
+
wake_if_supported: z.boolean().optional(),
|
|
30
|
+
keep_awake_until_alarm: z.boolean().optional()
|
|
31
|
+
};
|
|
32
|
+
export async function runMcpServer(runtime) {
|
|
33
|
+
const service = new AgentRadioService(runtime);
|
|
34
|
+
serveStdio(() => createMcpServer(service), { onerror: error => console.error(`RadioCLI MCP: ${error.message}`) });
|
|
35
|
+
}
|
|
36
|
+
function createMcpServer(service) {
|
|
37
|
+
const server = new McpServer({ name: 'radiocli', version: appVersion() }, { instructions: radioCliMcpInstructions });
|
|
38
|
+
const tool = (name, description, schema, handler) => {
|
|
39
|
+
const config = schema ? { description, inputSchema: schema } : { description };
|
|
40
|
+
server.registerTool(name, config, async (raw) => {
|
|
41
|
+
try {
|
|
42
|
+
const value = await handler(raw);
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return { content: [{ type: 'text', text: error instanceof Error ? error.message : 'RadioCLI request failed.' }], isError: true };
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
tool('radio_status', 'Get current playback, active station, queue, agent preferences, and whether alarm playback is active.', undefined, () => service.status());
|
|
51
|
+
tool('radio_update_status', 'Check the installed and latest RadioCLI versions and return the appropriate update command. This is read-only and never installs an update.', undefined, () => service.updateStatus());
|
|
52
|
+
tool('radio_search', 'Search RadioCLI’s public station directory by name, genre, language, city, region, or country. Use this—not web search or a browser—for requests such as “play a station from New York”, then pass a returned opaque id to radio_play.', z.object({
|
|
53
|
+
query: z.string().min(1).max(200),
|
|
54
|
+
limit: z.number().int().min(1).max(30).default(10),
|
|
55
|
+
country_code: z.string().length(2).optional()
|
|
56
|
+
}), async ({ query, limit, country_code }) => (await service.search(query, limit, country_code)).map(stationForAgent));
|
|
57
|
+
tool('radio_browse', 'List saved favorites, recent stations, popular stations, nearby stations, countries, or recent track titles.', z.object({
|
|
58
|
+
kind: z.enum(['favorites', 'recent', 'popular', 'nearby', 'countries', 'track-history']),
|
|
59
|
+
limit: z.number().int().min(1).max(50).default(20)
|
|
60
|
+
}), async ({ kind, limit }) => {
|
|
61
|
+
const values = await service.browse(kind, limit);
|
|
62
|
+
return kind === 'favorites' || kind === 'recent' || kind === 'popular' || kind === 'nearby'
|
|
63
|
+
? values.map(stationForAgent)
|
|
64
|
+
: values;
|
|
65
|
+
});
|
|
66
|
+
tool('radio_play', 'Play through RadioCLI using a searched/saved station id, or choose from recent, favorites, popular, or a country. For “play my most recent radio station”, call this directly with source="recent" and index=0. By default this opens the normal RadioCLI TUI in a terminal window; set open_ui=false only when the user requests headless playback.', z.object({
|
|
67
|
+
station_id: z.string().min(1).optional(),
|
|
68
|
+
source: z.enum(['recent', 'favorite', 'popular', 'country']).default('recent'),
|
|
69
|
+
country_code: z.string().length(2).optional(),
|
|
70
|
+
index: z.number().int().min(0).optional(),
|
|
71
|
+
random: z.boolean().default(false),
|
|
72
|
+
open_ui: z.boolean().optional(),
|
|
73
|
+
if_playing: z.enum(['keep', 'replace']).default('replace')
|
|
74
|
+
}), ({ station_id, source, country_code, index, random, open_ui, if_playing }) => service.play({ stationId: station_id, source, countryCode: country_code, index, random, openUi: open_ui, ifPlaying: if_playing }));
|
|
75
|
+
tool('radio_pause', 'Pause playback. Safe to call repeatedly.', undefined, () => service.control({ type: 'pause' }));
|
|
76
|
+
tool('radio_resume', 'Resume paused playback without changing stations.', undefined, () => service.control({ type: 'resume' }));
|
|
77
|
+
tool('radio_stop', 'Stop ordinary interactive/headless playback. This never dismisses or modifies an alarm.', undefined, () => service.control({ type: 'stop' }));
|
|
78
|
+
tool('radio_next', 'Play the next station in the queue created by the last radio_play request.', undefined, () => service.control({ type: 'next' }));
|
|
79
|
+
tool('radio_previous', 'Play the previous station in the current queue.', undefined, () => service.control({ type: 'previous' }));
|
|
80
|
+
tool('radio_set_volume', 'Set playback volume from 0 through 100 and save it as the RadioCLI preference.', z.object({ volume: z.number().min(0).max(100) }), ({ volume }) => service.control({ type: 'set-volume', volume }));
|
|
81
|
+
tool('radio_set_muted', 'Explicitly mute or unmute playback; this is not a toggle.', z.object({ muted: z.boolean() }), ({ muted }) => service.control({ type: 'set-muted', muted }));
|
|
82
|
+
tool('radio_set_favorite', 'Explicitly add or remove a favorite. Omit station_id to act on the current station, as in “favorite this”.', z.object({ favorite: z.boolean().default(true), station_id: z.string().min(1).optional() }), ({ favorite, station_id }) => service.setFavorite(favorite, station_id));
|
|
83
|
+
tool('radio_stats', 'Get local listening time, stations heard, active days, and streak statistics.', undefined, () => service.stats());
|
|
84
|
+
tool('radio_get_appearance', 'List available display themes and receiver styles and show the current selections.', undefined, () => service.appearance());
|
|
85
|
+
tool('radio_set_appearance', 'Change the RadioCLI display color theme or visual receiver style. An open TUI updates immediately.', z.object({ theme: z.enum(themeNames).optional(), receiver_style: z.enum(receiverStyleNames).optional() }), ({ theme, receiver_style }) => service.updateAppearance({ theme, receiverStyle: receiver_style }));
|
|
86
|
+
tool('radio_set_agent_preferences', 'Choose whether agent-started playback opens the normal TUI and whether it switches an open TUI to Now Playing.', z.object({ open_ui_on_play: z.boolean().optional(), focus_now_playing: z.boolean().optional() }), ({ open_ui_on_play, focus_now_playing }) => service.updateAgentPreferences({ openUiOnPlay: open_ui_on_play, focusNowPlaying: focus_now_playing }));
|
|
87
|
+
tool('radio_run_completion_preset', 'Run the user-configured completion action immediately after the current task completes. This is a one-shot radio action, not a reason to create a goal, automation, reminder, task record, or monitoring task.', undefined, () => service.runCompletionPreset());
|
|
88
|
+
tool('radio_configure_completion_preset', 'Configure what radio_run_completion_preset does: play/stop/pause/resume, station source, conflict behavior, and optional UI override.', z.object({
|
|
89
|
+
action: z.enum(['play', 'pause', 'resume', 'stop']).optional(),
|
|
90
|
+
source: z.enum(['recent', 'favorite', 'popular', 'country']).optional(),
|
|
91
|
+
country_code: z.string().length(2).optional(),
|
|
92
|
+
if_playing: z.enum(['keep', 'replace']).optional(),
|
|
93
|
+
open_ui: z.boolean().optional()
|
|
94
|
+
}), ({ action, source, country_code, if_playing, open_ui }) => service.configureCompletionPreset({ action, source, countryCode: country_code, ifPlaying: if_playing, openUi: open_ui }));
|
|
95
|
+
tool('radio_alarm_list', 'List saved alarms with exact IDs, schedules, stations, next occurrences, playback settings, and last outcomes.', undefined, () => service.alarmList());
|
|
96
|
+
tool('radio_alarm_status', 'Get saved alarms, native scheduler health, and every currently ringing alarm occurrence.', undefined, () => service.alarmStatus());
|
|
97
|
+
tool('radio_alarm_create', 'Create and natively schedule a one-time or recurring radio alarm. Resolve the station with radio_search or radio_browse first.', z.object({
|
|
98
|
+
station_id: z.string().min(1),
|
|
99
|
+
schedule: alarmScheduleSchema,
|
|
100
|
+
...alarmOptionsSchema
|
|
101
|
+
}), ({ station_id, schedule, label, enabled, volume, fade_seconds, stop_after_minutes, fallback_station_id, missed_run_grace_minutes, wake_if_supported, keep_awake_until_alarm }) => service.alarmCreate({
|
|
102
|
+
stationId: station_id, schedule: schedule, label, enabled, volume,
|
|
103
|
+
fadeSeconds: fade_seconds, stopAfterMinutes: stop_after_minutes, fallbackStationId: fallback_station_id,
|
|
104
|
+
missedRunGraceMinutes: missed_run_grace_minutes, wakeIfSupported: wake_if_supported, keepAwakeUntilAlarm: keep_awake_until_alarm
|
|
105
|
+
}));
|
|
106
|
+
tool('radio_alarm_update', 'Edit an existing alarm by exact ID. Omitted fields remain unchanged; pass the complete replacement schedule when changing time or recurrence.', z.object({
|
|
107
|
+
alarm_id: z.string().min(1),
|
|
108
|
+
station_id: z.string().min(1).optional(),
|
|
109
|
+
schedule: alarmScheduleSchema.optional(),
|
|
110
|
+
clear_fallback: z.boolean().optional(),
|
|
111
|
+
...alarmOptionsSchema
|
|
112
|
+
}).refine(value => !(value.clear_fallback && value.fallback_station_id), { message: 'Choose either clear_fallback or fallback_station_id.' }), ({ alarm_id, station_id, schedule, clear_fallback, label, enabled, volume, fade_seconds, stop_after_minutes, fallback_station_id, missed_run_grace_minutes, wake_if_supported, keep_awake_until_alarm }) => service.alarmUpdate(alarm_id, {
|
|
113
|
+
stationId: station_id, schedule: schedule, clearFallback: clear_fallback,
|
|
114
|
+
label, enabled, volume, fadeSeconds: fade_seconds, stopAfterMinutes: stop_after_minutes, fallbackStationId: fallback_station_id,
|
|
115
|
+
missedRunGraceMinutes: missed_run_grace_minutes, wakeIfSupported: wake_if_supported, keepAwakeUntilAlarm: keep_awake_until_alarm
|
|
116
|
+
}));
|
|
117
|
+
tool('radio_alarm_set_enabled', 'Explicitly enable or disable an alarm by exact ID and reconcile its native scheduler job.', z.object({ alarm_id: z.string().min(1), enabled: z.boolean() }), ({ alarm_id, enabled }) => service.alarmSetEnabled(alarm_id, enabled));
|
|
118
|
+
tool('radio_alarm_remove', 'Permanently remove an alarm and its native scheduler job. First list alarms and obtain explicit user confirmation for the exact ID, then pass confirm=true.', z.object({ alarm_id: z.string().min(1), confirm: z.boolean() }), ({ alarm_id, confirm }) => service.alarmRemove(alarm_id, confirm));
|
|
119
|
+
tool('radio_alarm_sync', 'Reconcile every saved alarm with the operating system scheduler after setup, upgrades, timezone changes, or repair.', undefined, () => service.alarmSync());
|
|
120
|
+
tool('radio_alarm_control', 'Control one currently ringing alarm: dismiss it, snooze it, keep playing past its automatic stop, or hand it off to normal interactive playback. If multiple alarms ring, supply an exact alarm_id and optionally occurrence_at from radio_alarm_status.', z.object({
|
|
121
|
+
action: z.enum(['dismiss', 'snooze', 'keep-playing', 'handoff']),
|
|
122
|
+
alarm_id: z.string().min(1).optional(),
|
|
123
|
+
occurrence_at: z.string().min(1).optional(),
|
|
124
|
+
snooze_minutes: z.number().int().min(1).max(1440).optional()
|
|
125
|
+
}).refine(value => value.action === 'snooze' || value.snooze_minutes === undefined, { message: 'snooze_minutes is only valid for the snooze action.' }), ({ action, alarm_id, occurrence_at, snooze_minutes }) => service.alarmControl({ action, alarmId: alarm_id, occurrenceAt: occurrence_at, snoozeMinutes: snooze_minutes }));
|
|
126
|
+
tool('radio_airplay_list', 'Discover AirPlay receivers visible to this Mac. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({
|
|
127
|
+
id: device.id,
|
|
128
|
+
name: device.name,
|
|
129
|
+
host: device.host,
|
|
130
|
+
port: device.port,
|
|
131
|
+
requiresPassword: device.requiresPassword,
|
|
132
|
+
airplay2: device.airplay2,
|
|
133
|
+
local: device.local ?? false
|
|
134
|
+
})));
|
|
135
|
+
tool('radio_airplay_select', 'Switch current or future interactive playback to an exact AirPlay receiver ID returned by radio_airplay_list. Opens RadioCLI when needed unless open_ui=false.', z.object({ device_id: z.string().min(1), open_ui: z.boolean().optional() }), ({ device_id, open_ui }) => service.selectAirPlayDevice(device_id, open_ui));
|
|
136
|
+
tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this Mac using the best available local backend.', z.object({ open_ui: z.boolean().optional() }).optional(), args => service.useLocalOutput(args?.open_ui));
|
|
137
|
+
tool('radio_airplay_submit_code', 'Submit a receiver-displayed AirPlay code to the active AirPlay session. Use only a code the user explicitly provides; RadioCLI never saves it.', z.object({ code: z.string().min(1).max(64) }), ({ code }) => service.submitAirPlayPasscode(code));
|
|
138
|
+
return server;
|
|
139
|
+
}
|