@ciphore/radiocli 0.2.2 → 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 (40) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +30 -0
  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/cli.js +4 -1
  12. package/dist/alarms/runner.js +43 -26
  13. package/dist/cli.js +60 -3
  14. package/dist/player/player-controller.js +19 -0
  15. package/dist/providers/provider-manager.js +5 -0
  16. package/dist/providers/radio-browser.js +4 -0
  17. package/dist/setup.js +71 -2
  18. package/dist/storage/store.js +33 -1
  19. package/dist/types.js +6 -0
  20. package/dist/ui/AdaptiveContent.js +24 -9
  21. package/dist/ui/App.js +299 -18
  22. package/dist/ui/AppContent.js +4 -4
  23. package/dist/ui/components/StationList.js +3 -5
  24. package/dist/ui/components/VersionIndicator.js +19 -0
  25. package/dist/ui/page-footer.js +4 -2
  26. package/dist/ui/screen-items.js +40 -9
  27. package/dist/ui/screens/AlarmsScreen.js +2 -1
  28. package/dist/ui/screens/CountriesScreen.js +8 -5
  29. package/dist/ui/screens/HomeScreen.js +3 -1
  30. package/dist/ui/screens/SettingsScreen.js +70 -53
  31. package/dist/ui/use-alarm-tui.js +4 -0
  32. package/dist/ui/use-app-input.js +29 -3
  33. package/dist/ui/visualizers/gallop.js +118 -0
  34. package/dist/ui/visualizers/horse-stride.js +20 -0
  35. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  36. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  37. package/dist/ui/visualizers/retro-receivers.js +4 -0
  38. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  39. package/dist/update-check.js +26 -7
  40. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -5,7 +5,52 @@ All notable changes to RadioCLI are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.2.3] - 2026-09-07
9
+
10
+ ### Added
11
+
12
+ - Added opt-in local coding-agent control through both a typed stdio MCP server
13
+ and `radiocli agent` shell commands, covering discovery, playback, favorites,
14
+ listening stats, appearance, and configurable task-completion actions.
15
+ - Added automatic MCP setup for detected Codex, Claude Code, OpenCode, Cursor,
16
+ Gemini CLI, VS Code/Copilot Agent Host, and Orca-backed runtimes, plus a
17
+ portable configuration for other MCP clients.
18
+ - Added typed MCP and voice-accessible alarm management, active ringing
19
+ controls, and macOS AirPlay discovery, receiver selection, local-output
20
+ switching, and ephemeral passcode submission.
21
+ - Added `radiocli update --install`, package-manager-aware upgrade commands,
22
+ an in-app update indicator, read-only MCP update status, and
23
+ `radiocli mcp repair`.
24
+ - Added Agent settings for installing or repairing MCP integrations and choosing
25
+ between a visible terminal player and headless playback.
26
+ - Added the animated Gallop receiver as the new default and a color-field
27
+ Crossfade receiver.
28
+
29
+ ### Fixed
30
+
31
+ - Agent playback continues to open the terminal TUI by default on every platform,
32
+ while interactive setup now explains the macOS Automation prompt and offers an
33
+ explicit headless opt-out. MCP guidance prevents ordinary task-completion radio
34
+ actions from creating implicit goals or monitors.
35
+ - MCP registrations now use upgrade-stable, directly executable launchers,
36
+ including a shell-free Node command on Windows, and enabled registrations are
37
+ repaired after an in-app or CLI update.
38
+ - Active interactive or agent playback now yields cleanly when an alarm begins,
39
+ preventing overlapping streams while preserving the alarm if TUI handoff fails.
40
+ - Kept the Countries station-count column fixed while scrolling, added focused
41
+ marquee reading for overlong country and station names across Explore,
42
+ Library, and Nearby, and aligned Overview descriptions behind a compact
43
+ separator-free title column.
44
+
45
+ ### Security
46
+
47
+ - Agent playback uses a single-owner, authenticated loopback session with
48
+ serialized commands, private discovery files, and strict isolation from
49
+ active alarm sessions. Agent tools accept saved or RadioCLI-discovered station
50
+ IDs rather than arbitrary stream URLs.
51
+ - Alarm removal requires an exact ID and explicit confirmation. AirPlay receiver
52
+ IDs must come from local discovery, and receiver passcodes are never persisted
53
+ or echoed by agent responses.
9
54
 
10
55
  ## [0.2.2] - 2026-09-04
11
56
 
@@ -351,6 +396,7 @@ Initial public release.
351
396
  [0.2.0]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.0
352
397
  [0.2.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.1
353
398
  [0.2.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.2
399
+ [0.2.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.3
354
400
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
355
401
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
356
402
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/README.md CHANGED
@@ -94,6 +94,24 @@ covers filters, playback, media-key learning, AirPlay, and every command.
94
94
 
95
95
  ## CLI
96
96
 
97
+ RadioCLI can also be controlled by local coding agents. Run `radiocli setup`
98
+ and opt into MCP, or use `radiocli setup --mcp`, to configure detected Codex,
99
+ Claude, OpenCode, Cursor, Gemini, VS Code/Copilot, and Orca-backed runtimes
100
+ automatically. From the TUI, open **Settings → Agent control & MCP** and turn on
101
+ **Allow local agent control** for the same one-step setup—no terminal command is
102
+ required. The same actions are
103
+ available to non-MCP harnesses through `radiocli agent` and `radiocli alarm`:
104
+ search and play stations, browse recents/favorites/nearby stations, favorite the
105
+ current station, inspect listening stats, create and control alarms, control
106
+ macOS AirPlay, change the receiver look, or run a user-configured completion
107
+ preset. Codex Voice can invoke those same typed MCP tools. Agent playback opens
108
+ the normal RadioCLI TUI in a terminal window by default on macOS, Windows, and
109
+ Linux. Interactive setup explains that macOS may require the agent host to approve
110
+ a one-time Automation prompt; users can explicitly opt into headless playback.
111
+ Scheduled alarms remain isolated and always use local output rather than
112
+ unattended AirPlay. See the
113
+ [CLI guide](apps/docs/content/docs/getting-started/cli.mdx#local-agent-and-mcp-control).
114
+
97
115
  ```bash
98
116
  radiocli # Start the TUI
99
117
  radiocli check # Check providers, playback tools, and the local store
@@ -105,8 +123,20 @@ radiocli export favorites.m3u
105
123
  radiocli add-url <stream-url> [station name]
106
124
  radiocli alarm list
107
125
  radiocli alarm doctor
126
+ radiocli update --install # Upgrade and repair enabled MCP registrations
108
127
  ```
109
128
 
129
+ Existing installations keep their library and preferences when upgraded. Use
130
+ `radiocli update` to inspect the detected Homebrew, npm, pnpm, or Bun command,
131
+ or `radiocli update --install` to run it. MCP registrations use an
132
+ upgrade-stable launcher so versioned package paths do not break them. On
133
+ Windows this is a shell-free Node command; on Unix it uses the `radiocli` shim.
134
+ Run `radiocli mcp status` to inspect registrations and `radiocli mcp repair`
135
+ after moving an installation or when an agent client reports a stale server;
136
+ fully quit and reopen agent clients after enabling, repairing, or upgrading.
137
+ Until that restart, even a newly created task in an already-running client will
138
+ not have RadioCLI's tools and may incorrectly fall back to browser playback.
139
+
110
140
  RadioCLI imports `.m3u`, `.pls`, and `.xspf` playlists. It exports favorites
111
141
  and imported streams as `.m3u`.
112
142
 
@@ -0,0 +1,210 @@
1
+ import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone, nextOccurrenceForAlarm } from '../alarms/schedule.js';
2
+ import { connectActiveAlarms } from '../alarms/active-session.js';
3
+ import { createSchedulerService } from '../alarms/scheduler.js';
4
+ export class AgentAlarmService {
5
+ store;
6
+ resolveStation;
7
+ scheduler;
8
+ handoffToInteractive;
9
+ constructor(store, resolveStation, scheduler = createSchedulerService(), handoffToInteractive) {
10
+ this.store = store;
11
+ this.resolveStation = resolveStation;
12
+ this.scheduler = scheduler;
13
+ this.handoffToInteractive = handoffToInteractive;
14
+ }
15
+ async list() {
16
+ return Promise.all(this.store.listAlarms().map(alarm => this.describe(alarm)));
17
+ }
18
+ async status() {
19
+ const alarms = this.store.listAlarms();
20
+ return {
21
+ active: await this.activeStatuses(),
22
+ scheduler: await this.scheduler.runtimeStatus(alarms),
23
+ alarms: await Promise.all(alarms.map(alarm => this.describe(alarm)))
24
+ };
25
+ }
26
+ async create(input) {
27
+ const station = await this.requiredStation(input.stationId);
28
+ const fallbackStation = input.fallbackStationId
29
+ ? await this.requiredStation(input.fallbackStationId)
30
+ : undefined;
31
+ const alarmInput = {
32
+ label: cleanLabel(input.label ?? station.name),
33
+ enabled: input.enabled ?? true,
34
+ station,
35
+ schedule: normalizeSchedule(input.schedule),
36
+ playback: {
37
+ volume: boundedInteger(input.volume ?? 40, 'volume', 0, 100),
38
+ fadeSeconds: boundedInteger(input.fadeSeconds ?? 0, 'fadeSeconds', 0, 3600),
39
+ stopAfterMinutes: boundedInteger(input.stopAfterMinutes ?? 60, 'stopAfterMinutes', 1, 10080),
40
+ ...(fallbackStation ? { fallbackStation } : {})
41
+ },
42
+ reliability: {
43
+ missedRunGraceMinutes: boundedInteger(input.missedRunGraceMinutes ?? 10, 'missedRunGraceMinutes', 0, 10080),
44
+ wakeIfSupported: input.wakeIfSupported ?? false,
45
+ keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? false
46
+ }
47
+ };
48
+ const alarm = this.store.addAlarm(alarmInput);
49
+ await this.syncSaved(alarm);
50
+ return this.describe(alarm);
51
+ }
52
+ async update(id, input) {
53
+ const alarm = this.requiredAlarm(id);
54
+ const station = input.stationId ? await this.requiredStation(input.stationId) : alarm.station;
55
+ const fallbackStation = input.clearFallback
56
+ ? undefined
57
+ : input.fallbackStationId ? await this.requiredStation(input.fallbackStationId) : alarm.playback.fallbackStation;
58
+ const updated = this.store.updateAlarm(alarm.id, {
59
+ label: input.label === undefined ? alarm.label : cleanLabel(input.label),
60
+ enabled: input.enabled ?? alarm.enabled,
61
+ station,
62
+ schedule: input.schedule ? normalizeSchedule(input.schedule) : alarm.schedule,
63
+ playback: {
64
+ volume: boundedInteger(input.volume ?? alarm.playback.volume, 'volume', 0, 100),
65
+ fadeSeconds: boundedInteger(input.fadeSeconds ?? alarm.playback.fadeSeconds, 'fadeSeconds', 0, 3600),
66
+ stopAfterMinutes: boundedInteger(input.stopAfterMinutes ?? alarm.playback.stopAfterMinutes, 'stopAfterMinutes', 1, 10080),
67
+ ...(fallbackStation ? { fallbackStation } : {})
68
+ },
69
+ reliability: {
70
+ missedRunGraceMinutes: boundedInteger(input.missedRunGraceMinutes ?? alarm.reliability.missedRunGraceMinutes, 'missedRunGraceMinutes', 0, 10080),
71
+ wakeIfSupported: input.wakeIfSupported ?? alarm.reliability.wakeIfSupported,
72
+ keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? alarm.reliability.keepAwakeUntilAlarm
73
+ }
74
+ });
75
+ await this.syncSaved(updated);
76
+ return this.describe(updated);
77
+ }
78
+ async setEnabled(id, enabled) {
79
+ const alarm = this.store.toggleAlarm(this.requiredAlarm(id).id, enabled);
80
+ await this.syncSaved(alarm);
81
+ return this.describe(alarm);
82
+ }
83
+ async remove(id, confirm) {
84
+ if (!confirm)
85
+ throw new Error('Alarm removal requires confirm=true. List alarms first and confirm the exact alarm ID with the user.');
86
+ const alarm = this.requiredAlarm(id);
87
+ await this.scheduler.remove(alarm.id);
88
+ if (!this.store.removeAlarm(alarm.id))
89
+ throw new Error(`Alarm not found: ${id}`);
90
+ return { ok: true, removed: alarm.id };
91
+ }
92
+ async sync() {
93
+ return this.scheduler.syncAll(this.store.listAlarms());
94
+ }
95
+ async controlActive(input) {
96
+ const { client, status } = await this.selectActive(input.alarmId, input.occurrenceAt);
97
+ if (input.action === 'dismiss')
98
+ await client.dismiss();
99
+ else if (input.action === 'keep-playing')
100
+ await client.keepPlaying();
101
+ else if (input.action === 'handoff') {
102
+ if (status.state !== 'playing')
103
+ throw new Error('Alarm playback is still starting.');
104
+ if (!this.handoffToInteractive)
105
+ throw new Error('Interactive playback handoff is unavailable.');
106
+ await this.handoffToInteractive(status);
107
+ await client.handoff();
108
+ }
109
+ else
110
+ await client.snooze(boundedInteger(input.snoozeMinutes ?? 10, 'snoozeMinutes', 1, 1440));
111
+ return { ok: true, action: input.action, alarm: activeStatusForAgent(status) };
112
+ }
113
+ async describe(alarm) {
114
+ return {
115
+ id: alarm.id,
116
+ label: alarm.label,
117
+ enabled: alarm.enabled,
118
+ station: { id: `${alarm.station.provider}:${alarm.station.id}`, name: alarm.station.name },
119
+ schedule: alarm.schedule,
120
+ nextOccurrence: nextOccurrenceForAlarm(alarm, new Date())?.toISOString() ?? null,
121
+ playback: {
122
+ volume: alarm.playback.volume,
123
+ fadeSeconds: alarm.playback.fadeSeconds,
124
+ stopAfterMinutes: alarm.playback.stopAfterMinutes,
125
+ ...(alarm.playback.fallbackStation ? { fallbackStation: {
126
+ id: `${alarm.playback.fallbackStation.provider}:${alarm.playback.fallbackStation.id}`,
127
+ name: alarm.playback.fallbackStation.name
128
+ } } : {})
129
+ },
130
+ reliability: alarm.reliability,
131
+ lastRun: alarm.lastRun,
132
+ snoozedUntil: alarm.nextOverride?.at
133
+ };
134
+ }
135
+ async activeStatuses() {
136
+ return (await Promise.all((await connectActiveAlarms()).map(client => client.status()))).map(activeStatusForAgent);
137
+ }
138
+ async selectActive(alarmId, occurrenceAt) {
139
+ const matches = [];
140
+ for (const client of await connectActiveAlarms()) {
141
+ const status = await client.status();
142
+ if ((!alarmId || status.alarmId === alarmId) && (!occurrenceAt || status.scheduledAt === occurrenceAt))
143
+ matches.push({ client, status });
144
+ }
145
+ if (!matches.length)
146
+ throw new Error('No matching alarm is currently ringing.');
147
+ if (matches.length > 1)
148
+ throw new Error('Multiple alarms are ringing. List alarm status, then provide alarm_id and optionally occurrence_at.');
149
+ return matches[0];
150
+ }
151
+ requiredAlarm(id) {
152
+ const alarm = this.store.getAlarm(id);
153
+ if (!alarm)
154
+ throw new Error(`Alarm not found: ${id}`);
155
+ return alarm;
156
+ }
157
+ async requiredStation(id) {
158
+ const station = await this.resolveStation(id);
159
+ if (!station)
160
+ throw new Error(`Unknown station ID: ${id}. Search or browse first.`);
161
+ return station;
162
+ }
163
+ async syncSaved(alarm) {
164
+ try {
165
+ await this.scheduler.sync(alarm);
166
+ }
167
+ catch (error) {
168
+ throw new Error(`Alarm ${alarm.id} was saved, but scheduler setup is degraded: ${error instanceof Error ? error.message : String(error)}`);
169
+ }
170
+ }
171
+ }
172
+ function normalizeSchedule(schedule) {
173
+ if (schedule.type === 'once') {
174
+ const instant = new Date(schedule.at);
175
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(schedule.at) || !Number.isFinite(instant.getTime()) || instant.getUTCSeconds() !== 0 || instant.getUTCMilliseconds() !== 0) {
176
+ throw new Error('One-time alarms require an absolute ISO-8601 minute with an offset or Z and zero seconds.');
177
+ }
178
+ if (instant.getTime() <= Date.now())
179
+ throw new Error('One-time alarms must be scheduled in the future.');
180
+ return { type: 'once', at: instant.toISOString() };
181
+ }
182
+ return {
183
+ type: 'recurring',
184
+ time: canonicalizeAlarmTime(schedule.time),
185
+ weekdays: canonicalizeIsoWeekdays(schedule.weekdays),
186
+ timezone: canonicalizeTimeZone(schedule.timezone)
187
+ };
188
+ }
189
+ function boundedInteger(value, label, min, max) {
190
+ if (!Number.isInteger(value) || value < min || value > max)
191
+ throw new Error(`${label} must be an integer from ${min} to ${max}.`);
192
+ return value;
193
+ }
194
+ function cleanLabel(value) {
195
+ const label = value.trim();
196
+ if (!label || label.length > 120 || /[\u0000-\u001F\u007F-\u009F]/.test(label))
197
+ throw new Error('Alarm label must be 1-120 printable characters.');
198
+ return label;
199
+ }
200
+ function activeStatusForAgent(status) {
201
+ return {
202
+ alarmId: status.alarmId,
203
+ scheduledAt: status.scheduledAt,
204
+ stationName: status.stationName,
205
+ ...(status.station ? { station: { id: `${status.station.provider}:${status.station.id}`, name: status.station.name } } : {}),
206
+ startedAt: status.startedAt,
207
+ state: status.state,
208
+ keepPlaying: status.keepPlaying
209
+ };
210
+ }
@@ -0,0 +1,193 @@
1
+ import { AgentRadioService, stationForAgent } from './service.js';
2
+ import { configureMcpIntegrations, mcpIntegrationReport, portableMcpConfig } from './mcp-install.js';
3
+ import { runMcpServer } from './mcp-server.js';
4
+ export async function runMcpCommand(args, runtime) {
5
+ const command = args[0] ?? 'status';
6
+ if (command === 'serve')
7
+ return runMcpServer(runtime);
8
+ if (command === 'enable' || command === 'install' || command === 'repair') {
9
+ assertMcpInstallSucceeded(await configureMcpIntegrations(true, runtime), 'configured');
10
+ return;
11
+ }
12
+ if (command === 'disable' || command === 'remove') {
13
+ assertMcpInstallSucceeded(await configureMcpIntegrations(false, runtime), 'removed');
14
+ return;
15
+ }
16
+ if (command === 'config') {
17
+ console.log(JSON.stringify(portableMcpConfig(runtime), null, 2));
18
+ return;
19
+ }
20
+ if (command === 'status' || command === 'doctor') {
21
+ console.log(JSON.stringify(await mcpIntegrationReport(runtime), null, 2));
22
+ return;
23
+ }
24
+ throw new Error('Usage: radiocli mcp <enable|install|repair|disable|status|config|serve>');
25
+ }
26
+ export async function runAgentCliCommand(args, runtime) {
27
+ const service = new AgentRadioService(runtime);
28
+ const [command = 'status', ...rest] = args;
29
+ let value;
30
+ if (command === 'status')
31
+ value = await service.status();
32
+ else if (command === 'search')
33
+ value = (await service.search(requiredText(rest, 'Usage: radiocli agent search <query>'))).map(stationForAgent);
34
+ else if (command === 'browse') {
35
+ const kind = parseBrowseKind(rest[0]);
36
+ if (rest.length > 2)
37
+ throw new Error('Usage: radiocli agent browse [favorites|recent|popular|nearby|countries|track-history] [1-50]');
38
+ const items = await service.browse(kind, rest[1] === undefined ? 20 : integerInRange(rest[1], 1, 50, 'Browse limit'));
39
+ value = ['favorites', 'recent', 'popular', 'nearby'].includes(kind)
40
+ ? items.map(stationForAgent)
41
+ : items;
42
+ }
43
+ else if (command === 'play')
44
+ value = await service.play(parsePlayArgs(rest));
45
+ else if (command === 'pause' || command === 'resume' || command === 'stop' || command === 'next' || command === 'previous')
46
+ value = await service.control({ type: command });
47
+ else if (command === 'volume') {
48
+ if (rest.length !== 1)
49
+ throw new Error('Usage: radiocli agent volume <0-100>');
50
+ value = await service.control({ type: 'set-volume', volume: numberInRange(rest[0], 0, 100, 'Volume') });
51
+ }
52
+ else if (command === 'mute' || command === 'unmute')
53
+ value = await service.control({ type: 'set-muted', muted: command === 'mute' });
54
+ else if (command === 'favorite' || command === 'unfavorite')
55
+ value = await service.setFavorite(command === 'favorite', rest[0]);
56
+ else if (command === 'stats')
57
+ value = service.stats();
58
+ else if (command === 'appearance')
59
+ value = await parseAppearance(rest, service);
60
+ else if (command === 'airplay')
61
+ value = await runAirPlayCommand(rest, service);
62
+ else if (command === 'preset')
63
+ value = service.configureCompletionPreset(parsePreset(rest));
64
+ else if (command === 'done')
65
+ value = await service.runCompletionPreset();
66
+ else
67
+ throw new Error('Usage: radiocli agent <status|search|browse|play|pause|resume|stop|next|previous|volume|mute|unmute|favorite|unfavorite|stats|appearance|airplay|preset|done>');
68
+ console.log(JSON.stringify(value, null, 2));
69
+ }
70
+ function parseBrowseKind(value) {
71
+ const kind = value ?? 'recent';
72
+ if (!['favorites', 'recent', 'popular', 'nearby', 'countries', 'track-history'].includes(kind))
73
+ throw new Error('Browse kind must be favorites, recent, popular, nearby, countries, or track-history.');
74
+ return kind;
75
+ }
76
+ function parsePlayArgs(args) {
77
+ const result = {};
78
+ for (let index = 0; index < args.length; index += 1) {
79
+ const arg = args[index];
80
+ if (arg === '--source')
81
+ result.source = stationSource(required(args[++index], '--source requires recent, favorite, popular, or country.'));
82
+ else if (arg === '--country') {
83
+ result.source = 'country';
84
+ result.countryCode = countryCode(required(args[++index], '--country requires a two-letter code.'));
85
+ }
86
+ else if (arg === '--random')
87
+ result.random = true;
88
+ else if (arg === '--no-ui')
89
+ result.openUi = false;
90
+ else if (arg === '--keep-playing')
91
+ result.ifPlaying = 'keep';
92
+ else if (!result.stationId)
93
+ result.stationId = arg;
94
+ else
95
+ throw new Error(`Unknown play option: ${arg}`);
96
+ }
97
+ return result;
98
+ }
99
+ function parseAppearance(args, service) {
100
+ if (args.length === 0)
101
+ return service.appearance();
102
+ const input = {};
103
+ for (let index = 0; index < args.length; index += 1) {
104
+ const arg = args[index];
105
+ if (arg === '--theme')
106
+ input.theme = required(args[++index], '--theme requires a theme name.');
107
+ else if (arg === '--receiver-style')
108
+ input.receiverStyle = required(args[++index], '--receiver-style requires a style name.');
109
+ else
110
+ throw new Error(`Unknown appearance option: ${arg}`);
111
+ }
112
+ return service.updateAppearance(input);
113
+ }
114
+ function parsePreset(args) {
115
+ const input = {};
116
+ for (let index = 0; index < args.length; index += 1) {
117
+ const arg = args[index];
118
+ if (arg === '--action')
119
+ input.action = presetAction(required(args[++index], '--action requires play, pause, resume, or stop.'));
120
+ else if (arg === '--source')
121
+ input.source = stationSource(required(args[++index], '--source requires recent, favorite, popular, or country.'));
122
+ else if (arg === '--country') {
123
+ input.source = 'country';
124
+ input.countryCode = countryCode(required(args[++index], '--country requires a two-letter code.'));
125
+ }
126
+ else if (arg === '--keep-playing')
127
+ input.ifPlaying = 'keep';
128
+ else if (arg === '--replace')
129
+ input.ifPlaying = 'replace';
130
+ else if (arg === '--open-ui')
131
+ input.openUi = true;
132
+ else if (arg === '--no-ui')
133
+ input.openUi = false;
134
+ else
135
+ throw new Error(`Unknown preset option: ${arg}`);
136
+ }
137
+ return input;
138
+ }
139
+ async function runAirPlayCommand(args, service) {
140
+ const [action = 'list', ...rest] = args;
141
+ if (action === 'list')
142
+ return service.listAirPlayDevices();
143
+ if (action === 'select')
144
+ return service.selectAirPlayDevice(required(rest[0], 'Usage: radiocli agent airplay select <receiver-id>'), !rest.includes('--no-ui'));
145
+ if (action === 'local')
146
+ return service.useLocalOutput();
147
+ if (action === 'code')
148
+ return service.submitAirPlayPasscode(required(rest[0], 'Usage: radiocli agent airplay code <receiver-code>'));
149
+ throw new Error('Usage: radiocli agent airplay <list|select|local|code>');
150
+ }
151
+ function required(value, error) { if (!value)
152
+ throw new Error(error); return value; }
153
+ function requiredText(values, error) {
154
+ const value = values.join(' ').trim();
155
+ if (!value)
156
+ throw new Error(error);
157
+ return value;
158
+ }
159
+ function numberInRange(value, minimum, maximum, label) {
160
+ const parsed = Number(value);
161
+ if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
162
+ throw new Error(`${label} must be a number from ${minimum} through ${maximum}.`);
163
+ }
164
+ return parsed;
165
+ }
166
+ function integerInRange(value, minimum, maximum, label) {
167
+ const parsed = numberInRange(value, minimum, maximum, label);
168
+ if (!Number.isInteger(parsed))
169
+ throw new Error(`${label} must be a whole number from ${minimum} through ${maximum}.`);
170
+ return parsed;
171
+ }
172
+ function stationSource(value) {
173
+ if (!['recent', 'favorite', 'popular', 'country'].includes(value)) {
174
+ throw new Error('Source must be recent, favorite, popular, or country.');
175
+ }
176
+ return value;
177
+ }
178
+ function presetAction(value) {
179
+ if (!['play', 'pause', 'resume', 'stop'].includes(value))
180
+ throw new Error('Action must be play, pause, resume, or stop.');
181
+ return value;
182
+ }
183
+ function countryCode(value) {
184
+ if (!/^[a-z]{2}$/i.test(value))
185
+ throw new Error('Country must be a two-letter ISO country code.');
186
+ return value.toUpperCase();
187
+ }
188
+ function assertMcpInstallSucceeded(results, action) {
189
+ const failed = results.filter(result => result.status === 'failed');
190
+ if (failed.length === 0)
191
+ return;
192
+ throw new Error(`RadioCLI was ${action} where possible, but ${failed.length} integration${failed.length === 1 ? '' : 's'} failed: ${failed.map(result => `${result.client} (${result.detail})`).join('; ')}`);
193
+ }
@@ -0,0 +1,143 @@
1
+ import { PlayerController } from '../player/player-controller.js';
2
+ import { ProviderManager } from '../providers/provider-manager.js';
3
+ import { JsonLibraryStore, stationKey } from '../storage/store.js';
4
+ import { startRadioSession } from './session.js';
5
+ import { detectPlaybackBackends } from '../player/backend-install.js';
6
+ export async function runHeadlessAgentHost() {
7
+ const store = new JsonLibraryStore();
8
+ const providers = new ProviderManager();
9
+ const player = new PlayerController(() => store.snapshot().settings);
10
+ player.refreshDetectedBackends();
11
+ let station = null;
12
+ let queue = [];
13
+ const status = () => ({ owner: 'headless', playback: player.getState(), station, queue, output: {
14
+ preferredBackend: store.snapshot().settings.preferredBackend,
15
+ preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice
16
+ } });
17
+ const result = (message, ok = true, data) => ({ ok, message, status: status(), ...(data ? { data } : {}) });
18
+ const play = async (next, nextQueue = [next]) => {
19
+ store.finishActiveListeningSession();
20
+ await player.stop();
21
+ const resolved = await providers.resolve(next);
22
+ await player.play(next, resolved.url);
23
+ station = next;
24
+ queue = nextQueue.length ? nextQueue : [next];
25
+ store.addRecent(next);
26
+ store.startListeningSession(next);
27
+ return result(`Playing ${next.name}.`);
28
+ };
29
+ const handle = async (command) => {
30
+ if (command.type === 'status')
31
+ return result(station ? `${player.getState().state}: ${station.name}` : 'RadioCLI is idle.');
32
+ if (command.type === 'play') {
33
+ if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state))
34
+ return result(`Kept current station ${station?.name ?? ''}.`);
35
+ return play(command.station, command.queue);
36
+ }
37
+ if (command.type === 'pause') {
38
+ const control = await player.pause();
39
+ if (control.ok)
40
+ store.finishActiveListeningSession();
41
+ return result(control.message ?? 'Paused.', control.ok);
42
+ }
43
+ if (command.type === 'resume') {
44
+ const control = await player.resume();
45
+ if (control.ok && station)
46
+ store.startListeningSession(station);
47
+ return result(control.message ?? 'Resumed.', control.ok);
48
+ }
49
+ if (command.type === 'stop') {
50
+ store.finishActiveListeningSession();
51
+ await player.stop();
52
+ station = null;
53
+ queue = [];
54
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50).unref();
55
+ return result('Stopped RadioCLI.');
56
+ }
57
+ if (command.type === 'alarm-preempt') {
58
+ store.finishActiveListeningSession();
59
+ await player.stop();
60
+ station = null;
61
+ queue = [];
62
+ return result('Interactive playback yielded to the alarm.');
63
+ }
64
+ if (command.type === 'set-volume') {
65
+ const control = await player.setVolume(command.volume);
66
+ if (control.ok)
67
+ store.updateSettings({ volume: player.getState().volume });
68
+ return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
69
+ }
70
+ if (command.type === 'set-muted') {
71
+ const control = await player.setMuted(command.muted);
72
+ return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
73
+ }
74
+ if (command.type === 'set-favorite') {
75
+ const target = command.station ?? station;
76
+ if (!target)
77
+ return result('No active station to favorite.', false);
78
+ const current = store.isFavorite(target);
79
+ if (current !== command.favorite)
80
+ store.toggleFavorite(target);
81
+ if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes)
82
+ void providers.vote(target);
83
+ return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`);
84
+ }
85
+ if (command.type === 'airplay-list') {
86
+ const devices = await player.refreshAirPlayDevices();
87
+ return result(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
88
+ }
89
+ if (command.type === 'airplay-select') {
90
+ const devices = await player.refreshAirPlayDevices();
91
+ const device = devices.find(item => item.id === command.deviceId);
92
+ if (!device)
93
+ return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
94
+ if (device.local)
95
+ return switchToLocal();
96
+ if (!detectPlaybackBackends().includes('airplay'))
97
+ return result('AirPlay playback is unavailable; run radiocli doctor.', false);
98
+ store.updateSettings({ preferredAirPlayDevice: device.id });
99
+ store.updateSettings({ preferredBackend: 'airplay' });
100
+ if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
101
+ return play(station, queue);
102
+ return result(`Audio output set to AirPlay receiver ${device.name}.`);
103
+ }
104
+ if (command.type === 'airplay-local')
105
+ return switchToLocal();
106
+ if (command.type === 'airplay-passcode') {
107
+ const control = player.submitAirPlayPasscode(command.code);
108
+ return result(control.message ?? 'AirPlay code sent.', control.ok);
109
+ }
110
+ if (command.type === 'update-settings') {
111
+ store.updateSettings(command.settings);
112
+ return result('RadioCLI settings updated.');
113
+ }
114
+ const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station)) : -1;
115
+ const delta = command.type === 'next' ? 1 : -1;
116
+ const next = queue.length ? queue[(Math.max(0, currentIndex) + delta + queue.length) % queue.length] : undefined;
117
+ return next ? play(next, queue) : result('No playback queue is available.', false);
118
+ };
119
+ const switchToLocal = async () => {
120
+ const backends = detectPlaybackBackends();
121
+ const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
122
+ if (!backend)
123
+ return result('No local playback backend is available. Run radiocli setup to install mpv.', false);
124
+ store.updateSettings({ preferredBackend: backend });
125
+ if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
126
+ return play(station, queue);
127
+ return result(`Audio output set to this device (${backend}).`);
128
+ };
129
+ const session = await startRadioSession(handle);
130
+ const checkpoint = setInterval(() => {
131
+ if (player.getState().state === 'playing')
132
+ store.checkpointActiveListeningSession();
133
+ }, 30_000);
134
+ const shutdown = async () => {
135
+ clearInterval(checkpoint);
136
+ store.finishActiveListeningSession();
137
+ await player.stop();
138
+ await session.close();
139
+ };
140
+ process.once('SIGTERM', () => { void shutdown().finally(() => process.exit(0)); });
141
+ process.once('SIGINT', () => { void shutdown().finally(() => process.exit(0)); });
142
+ await new Promise(() => undefined);
143
+ }