@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -5,7 +5,128 @@ 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.4] - 2026-09-17
9
+
10
+ ### Added
11
+
12
+ - Added a shared platform layer for runtime eligibility, capabilities, executable
13
+ discovery, package installation, storage paths, networking, IPC, desktop
14
+ helpers, and terminal launching.
15
+ - Added first-class Node.js 22 and 24 CI coverage for macOS Apple Silicon and
16
+ Intel, Ubuntu x64 and arm64, Windows x64, and Windows 11 arm64, including
17
+ packed installation and native mpv IPC playback checks.
18
+ - Added repeatable packed CLI and mpv playback checks in full FreeBSD, OpenBSD,
19
+ and NetBSD guest kernels, plus explicit installation and capability guidance
20
+ for additional Unix, WSL, Termux, Haiku, illumos, and AIX environments.
21
+ - Added offline, low-bandwidth, screen-reader, reduced-motion, ASCII, limited-
22
+ color, and noninteractive terminal modes with deterministic full, compact,
23
+ and micro rendering coverage.
24
+ - Added automatic HTTP/ICY metadata discovery when importing a direct station
25
+ URL from the CLI or the TUI command palette.
26
+
27
+ ### Changed
28
+
29
+ - Centralized native package recipes and guided setup so dry runs and executed
30
+ commands use the same platform-specific definitions.
31
+ - Expanded `radiocli doctor --json` with host, runtime, network, storage,
32
+ terminal, playback, desktop, scheduling, and optional-feature capabilities.
33
+ - Reorganized installation, platform, architecture, reliability, privacy, and
34
+ contributor documentation as stable reference material with explicit support
35
+ boundaries and troubleshooting paths.
36
+ - Changed TUI `:import` to add custom stream URLs, renamed full-library recovery
37
+ to `:restore`, and consolidated shell URL additions under `radiocli import`.
38
+ - Placed newest imported stations after favorites and before recently played
39
+ stations in the Library.
40
+ - Changed resume for live radio to reload the active stream at its current live
41
+ edge instead of replaying audio accumulated in mpv's paused buffer.
42
+
43
+ ### Fixed
44
+
45
+ - Kept playback and the TUI usable when optional history, presence, cache, or
46
+ library writes fail, while preserving recoverable data after interrupted or
47
+ permission-denied writes.
48
+ - Hardened alarm deletion, native scheduler cleanup, terminal reopening, Alarm
49
+ Guard ownership, system-volume restoration, and forced-process teardown.
50
+ - Preserved exact arguments, data locations, and supported environment selectors
51
+ across macOS, Linux, BSD, and Windows child processes without forwarding
52
+ unrelated shell state.
53
+ - Improved player discovery, mpv readiness and IPC handling, provider timeouts,
54
+ mirror fallback, offline cache behavior, and degraded terminal rendering.
55
+ - Recovered ARM Linux playback from mpv audio-service initialization failures
56
+ by retrying once with ALSA first, retained bounded player diagnostics, and
57
+ stopped system-wide backend failures from skipping through every station.
58
+ - Replaced stale Radio Browser hosts with live DNS service discovery and added
59
+ a TLS-verified resolver fallback for temporary hostname failures without
60
+ bypassing configured proxies.
61
+ - Preserved full RGB theme colors when a terminal explicitly advertises
62
+ truecolor, even when its Node.js stream reports a conservative 256-color
63
+ depth; explicit 256-color, 16-color, and no-color modes remain unchanged.
64
+ - Kept retained Search results playable after clearing the submitted query and
65
+ added `Ctrl+F` to favorite the selected result while query input is active,
66
+ without intercepting an ordinary `f` typed into the search text.
67
+ - Dismissed added and removed favorite confirmations after 4.5 seconds on
68
+ Overview, Now Playing, Library, Explore, Search, Nearby, and country station
69
+ views.
70
+ - Marked AirPlay controls as macOS-only, hid the unsupported Settings shortcut,
71
+ and kept non-macOS users on their current screen with a direct platform notice.
72
+ - Corrected Windows terminal launches to create independent interactive console
73
+ handles and retain the intended user data location.
74
+
75
+ ### Security
76
+
77
+ - Updated documentation-site dependencies to patched releases.
78
+ - Limited child-process environments to the settings required for RadioCLI data
79
+ identity and native integrations, excluding unrelated credentials and proxy
80
+ secrets.
81
+ - Made native cleanup and ownership checks fail closed when a job, helper,
82
+ process, or saved state cannot be identified safely.
83
+
84
+ ## [0.2.3] - 2026-09-07
85
+
86
+ ### Added
87
+
88
+ - Added opt-in local coding-agent control through both a typed stdio MCP server
89
+ and `radiocli agent` shell commands, covering discovery, playback, favorites,
90
+ listening stats, appearance, and configurable task-completion actions.
91
+ - Added automatic MCP setup for detected Codex, Claude Code, OpenCode, Cursor,
92
+ Gemini CLI, VS Code/Copilot Agent Host, and Orca-backed runtimes, plus a
93
+ portable configuration for other MCP clients.
94
+ - Added typed MCP and voice-accessible alarm management, active ringing
95
+ controls, and macOS AirPlay discovery, receiver selection, local-output
96
+ switching, and ephemeral passcode submission.
97
+ - Added `radiocli update --install`, package-manager-aware upgrade commands,
98
+ an in-app update indicator, read-only MCP update status, and
99
+ `radiocli mcp repair`.
100
+ - Added Agent settings for installing or repairing MCP integrations and choosing
101
+ between a visible terminal player and headless playback.
102
+ - Added the animated Gallop receiver as the new default and a color-field
103
+ Crossfade receiver.
104
+
105
+ ### Fixed
106
+
107
+ - Agent playback continues to open the terminal TUI by default on every platform,
108
+ while interactive setup now explains the macOS Automation prompt and offers an
109
+ explicit headless opt-out. MCP guidance prevents ordinary task-completion radio
110
+ actions from creating implicit goals or monitors.
111
+ - MCP registrations now use upgrade-stable, directly executable launchers,
112
+ including a shell-free Node command on Windows, and enabled registrations are
113
+ repaired after an in-app or CLI update.
114
+ - Active interactive or agent playback now yields cleanly when an alarm begins,
115
+ preventing overlapping streams while preserving the alarm if TUI handoff fails.
116
+ - Kept the Countries station-count column fixed while scrolling, added focused
117
+ marquee reading for overlong country and station names across Explore,
118
+ Library, and Nearby, and aligned Overview descriptions behind a compact
119
+ separator-free title column.
120
+
121
+ ### Security
122
+
123
+ - Agent playback uses a single-owner, authenticated loopback session with
124
+ serialized commands, private discovery files, and strict isolation from
125
+ active alarm sessions. Agent tools accept saved or RadioCLI-discovered station
126
+ IDs rather than arbitrary stream URLs.
127
+ - Alarm removal requires an exact ID and explicit confirmation. AirPlay receiver
128
+ IDs must come from local discovery, and receiver passcodes are never persisted
129
+ or echoed by agent responses.
9
130
 
10
131
  ## [0.2.2] - 2026-09-04
11
132
 
@@ -351,6 +472,8 @@ Initial public release.
351
472
  [0.2.0]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.0
352
473
  [0.2.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.1
353
474
  [0.2.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.2
475
+ [0.2.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.3
476
+ [0.2.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.4
354
477
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
355
478
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
356
479
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/CONTRIBUTING.md CHANGED
@@ -12,19 +12,22 @@ npm run build
12
12
  npm run test
13
13
  ```
14
14
 
15
- Install `mpv` for playback controls, metadata polling, and the best playback
16
- experience:
15
+ Install `mpv` for playback controls and metadata polling. On a macOS
16
+ configuration supported by Homebrew:
17
17
 
18
18
  ```bash
19
19
  brew install mpv
20
20
  ```
21
21
 
22
- On Linux, use your package manager's `mpv` package. On Windows, use:
22
+ On Linux, use your package manager's `mpv` package. On Windows x64, use:
23
23
 
24
24
  ```powershell
25
25
  winget install --id shinchiro.mpv -e
26
26
  ```
27
27
 
28
+ Use the [platform installation routes](apps/docs/content/docs/platforms.mdx#installation-and-playback-prerequisites)
29
+ for Windows arm64 and other package managers.
30
+
28
31
  ## Verification
29
32
 
30
33
  Run these before opening a PR:
@@ -34,11 +37,19 @@ npm run check
34
37
  npm run lint
35
38
  npm run test
36
39
  npm run build
40
+ npm run pack:check
41
+ npm run fresh:check -- --require-mpv
42
+ npm run check:package
43
+ npm run smoke:mcp
44
+ npm run docs:check
45
+ npm run docs:build
37
46
  ```
38
47
 
39
- Run `npm run smoke:playback` when changing playback code. It opens a public
40
- stream briefly, so do not run it in environments where audio/network access is
41
- not acceptable.
48
+ Install documentation dependencies with `npm --prefix apps/docs ci` first.
49
+ Run `npm run smoke:playback` when changing playback code. Its default fixture is
50
+ a local WAV file; mpv uses null audio in deterministic package
51
+ checks. These checks exercise the player process and controls, and do not prove
52
+ speaker output. `npm run smoke:data` intentionally contacts public providers.
42
53
 
43
54
  Live provider checks are useful before releases, but ordinary pull requests
44
55
  should stay deterministic. Run `npm run verify:release` for a full maintainer
@@ -52,9 +63,28 @@ checks.
52
63
  - Never make location lookup implicit.
53
64
  - Prefer clear terminal text over decorative filler.
54
65
  - Keep command-line and TUI behavior aligned.
66
+ - Put OS decisions in `src/platform`; keep capability availability separate from
67
+ runtime eligibility and recorded platform verification.
55
68
  - Add tests for parsing, storage, layout, or provider transformations when the
56
69
  behavior can be checked without live audio.
57
70
 
71
+ ## Platform verification
72
+
73
+ The required Node 22/24 CI matrix tests x64 and arm64 installations and real mpv
74
+ control. Weekly or manually triggered BSD checks execute the packed JavaScript
75
+ in guest kernels. Changes to script harnesses
76
+ also require `node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs`.
77
+
78
+ Record OS release, CPU, endianness, libc, Node/npm versions, artifact hash, and
79
+ whether execution was native, virtualized, emulated, or mocked. An installation
80
+ test, native command mock, or foreign binary inspection alone cannot promote a
81
+ platform to supported. Follow the [support tiers](apps/docs/content/docs/platforms.mdx).
82
+
83
+ The deterministic visual fixtures cover full, compact, and micro layouts, all
84
+ major screens, and receiver styles. Preserve Unicode captures. When rendering
85
+ changes intentionally, describe the visible effect in the pull request and do
86
+ not update snapshots merely to hide failures.
87
+
58
88
  ## Issue Triage
59
89
 
60
90
  Playback issues should include station name, country, backend, and
package/README.md CHANGED
@@ -26,7 +26,7 @@ Built with [Ink](https://github.com/vadimdemedes/ink),
26
26
 
27
27
  ## Quick start
28
28
 
29
- macOS with Homebrew:
29
+ On macOS configurations supported by Homebrew:
30
30
 
31
31
  ```bash
32
32
  brew install ciphore/tap/radiocli
@@ -44,11 +44,43 @@ radiocli
44
44
  RadioCLI requires Node.js 22 or newer. The Homebrew formula installs `mpv` and
45
45
  FFmpeg. After an npm install, `radiocli setup` detects the operating system and
46
46
  package manager, lets you choose `mpv`, FFmpeg, and VLC, installs the selected
47
- native tools with branded progress feedback, and verifies playback readiness.
47
+ native tools with branded progress feedback, and checks that executables are
48
+ available. Play a station to verify the actual audio path. Intel or older macOS
49
+ installations should consult the platform matrix for alternative package routes.
48
50
 
49
51
  See the [installation guide](apps/docs/content/docs/getting-started/install.mdx)
50
52
  for Windows, Linux distributions, AirPlay prerequisites, and fallback players.
51
53
 
54
+ ## Platform coverage
55
+
56
+ RadioCLI separates core playback, storage, terminal rendering, desktop helpers,
57
+ and native alarm services. A missing scheduler or clipboard helper leaves the
58
+ other features available. `radiocli doctor --json` reports each capability,
59
+ terminal and network policy, and runtime eligibility separately.
60
+
61
+ The [platform matrix](apps/docs/content/docs/platforms.mdx) distinguishes required
62
+ CI targets from verified installations, experimental community paths, and known
63
+ runtime blockers. Node 22 and 24 are the reference test lines. BSD package plans,
64
+ Termux, Haiku and illumos guidance do not imply that every OS/CPU combination has
65
+ been exercised. AIX playback and Haiku's packaged Node 20 remain explicit gaps.
66
+ Linux without a usable systemd user session can still run the player and TUI;
67
+ reliable background alarms require a verified native adapter.
68
+
69
+ Invocation preferences do not change saved settings:
70
+
71
+ ```bash
72
+ RADIOCLI_ASCII=1 radiocli # ASCII decoration; station names preserved
73
+ NO_COLOR=1 radiocli # no color or background fills
74
+ RADIOCLI_SCREEN_READER=1 radiocli # readable controls; no visualizer animation
75
+ RADIOCLI_OFFLINE=1 radiocli # cached directories and saved stations
76
+ RADIOCLI_LOW_BANDWIDTH=1 radiocli # cached atlas; no automatic receiver scan
77
+ ```
78
+
79
+ Offline mode disables directory, location, update, vote and receiver-discovery
80
+ requests. An external player still needs a connection to listen to a live stream.
81
+ For proxy, SSH, read-only storage and custom-player paths, see
82
+ [troubleshooting](apps/docs/content/docs/troubleshooting.mdx).
83
+
52
84
  ## Visual tour
53
85
 
54
86
  These recordings come from the built TUI. Generate them locally with
@@ -81,10 +113,10 @@ These recordings come from the built TUI. Generate them locally with
81
113
  | `←` / `→` or `Tab` / `Shift+Tab` | Switch screens |
82
114
  | `↑` / `↓` or `n` / `p` | Move the selection |
83
115
  | `Enter` | Open or tune the selection |
84
- | `space` or `F8` | Pause or resume with `mpv` |
116
+ | `space` or `F8` | Pause; resume reconnects at the live edge with `mpv` |
85
117
  | `,` / `.` or `F7` / `F9` | Previous or next station |
86
118
  | `+` / `-` | Change volume |
87
- | `f` | Save or remove a favorite |
119
+ | `f` (`Ctrl+F` while typing in Search) | Save or remove a favorite |
88
120
  | `?` | Open all shortcuts and commands |
89
121
  | `q` or `Ctrl+C` twice | Quit cleanly |
90
122
 
@@ -94,20 +126,58 @@ covers filters, playback, media-key learning, AirPlay, and every command.
94
126
 
95
127
  ## CLI
96
128
 
129
+ RadioCLI can also be controlled by local coding agents. Run `radiocli setup`
130
+ and opt into MCP, or use `radiocli setup --mcp`, to configure detected Codex,
131
+ Claude, OpenCode, Cursor, Gemini, VS Code/Copilot, and Orca-backed runtimes
132
+ automatically. From the TUI, open **Settings → Agent control & MCP** and turn on
133
+ **Allow local agent control** for the same one-step setup—no terminal command is
134
+ required. The same actions are
135
+ available to non-MCP harnesses through `radiocli agent` and `radiocli alarm`:
136
+ search and play stations, browse recents/favorites/nearby stations, favorite the
137
+ current station, inspect listening stats, create and control alarms, control
138
+ macOS AirPlay, change the receiver look, or run a user-configured completion
139
+ preset. Codex Voice can invoke those same typed MCP tools. Agent playback opens
140
+ the normal RadioCLI TUI in a terminal window by default on macOS, Windows, and
141
+ Linux. Interactive setup explains that macOS may require the agent host to approve
142
+ a one-time Automation prompt; users can explicitly opt into headless playback.
143
+ Scheduled alarms remain isolated and always use local output rather than
144
+ unattended AirPlay. See the
145
+ [CLI guide](apps/docs/content/docs/getting-started/cli.mdx#local-agent-and-mcp-control).
146
+
97
147
  ```bash
98
148
  radiocli # Start the TUI
99
149
  radiocli check # Check providers, playback tools, and the local store
100
150
  radiocli doctor --json # Create a redacted support report
101
151
  radiocli search "japan hits"
102
152
  radiocli countries
153
+ radiocli import https://example.com/live.mp3
103
154
  radiocli import stations.m3u
104
155
  radiocli export favorites.m3u
105
- radiocli add-url <stream-url> [station name]
106
156
  radiocli alarm list
107
157
  radiocli alarm doctor
158
+ radiocli update --install # Upgrade and repair enabled MCP registrations
108
159
  ```
109
160
 
110
- RadioCLI imports `.m3u`, `.pls`, and `.xspf` playlists. It exports favorites
161
+ Existing installations keep their library and preferences when upgraded. Use
162
+ `radiocli update` to inspect the detected Homebrew, npm, pnpm, or Bun command,
163
+ or `radiocli update --install` to run it. MCP registrations use an
164
+ upgrade-stable launcher so versioned package paths do not break them. On
165
+ Windows this is a shell-free Node command; on Unix it uses the `radiocli` shim.
166
+ Run `radiocli mcp status` to inspect registrations and `radiocli mcp repair`
167
+ after moving an installation or when an agent client reports a stale server;
168
+ fully quit and reopen agent clients after enabling, repairing, or upgrading.
169
+ Until that restart, even a newly created task in an already-running client will
170
+ not have RadioCLI's tools and may incorrectly fall back to browser playback.
171
+
172
+ RadioCLI imports direct HTTP(S) station streams as well as `.m3u`, `.pls`, and
173
+ `.xspf` playlists. Direct import is useful when a station cannot be found through
174
+ an open radio directory—for example, an iHeartMedia station that is available
175
+ only through its direct stream. Find the station's direct stream URL and run
176
+ `radiocli import <stream-url> [station name]`, or enter
177
+ `:import <stream-url> [station name]` in the TUI. RadioCLI reads available
178
+ HTTP/ICY metadata to discover the name, codec, bitrate, and homepage; the name
179
+ argument is optional and overrides the published name. Imported stations appear
180
+ after favorites and before recently played stations. RadioCLI exports favorites
111
181
  and imported streams as `.m3u`.
112
182
 
113
183
  Alarms are experimental beta functionality and live under **Overview**,
@@ -184,6 +254,8 @@ complete data-flow description.
184
254
  - `src/ui` — screens, input, layout, and terminal rendering
185
255
  - `src/providers` — station directories, resolution, and caches
186
256
  - `src/player` — playback backends, metadata, and AirPlay
257
+ - `src/platform` — host identity, independent capabilities, native command plans,
258
+ paths, terminal policy, networking and runtime eligibility
187
259
  - `src/alarms` — schedules, native registration, Alarm Guard, and active controls
188
260
  - `src/storage` — local library persistence and migration
189
261
  - `apps/docs` — documentation website and manual
@@ -201,9 +273,10 @@ npm run verify
201
273
  npm run dev
202
274
  ```
203
275
 
204
- `npm run verify` checks types, lint, tests, the production build, and package
205
- contents. Playback and live-data smoke tests are available separately because
206
- they contact public services or start a local player.
276
+ `npm run verify` checks types, lint, tests, the production build, and live provider
277
+ data. `npm run fresh:check -- --require-mpv` tests packed installs with and without
278
+ optional dependencies using a local WAV, real mpv IPC controls and MCP. See
279
+ [contribution checks](CONTRIBUTING.md) for documentation and packaging validation.
207
280
 
208
281
  ## Contributing
209
282
 
@@ -214,9 +287,10 @@ include `radiocli check` output with playback reports.
214
287
 
215
288
  - [Getting started](apps/docs/content/docs/index.mdx)
216
289
  - [Installation](apps/docs/content/docs/getting-started/install.mdx)
290
+ - [Platform matrix](apps/docs/content/docs/platforms.mdx)
291
+ - [Troubleshooting](apps/docs/content/docs/troubleshooting.mdx)
217
292
  - [Controls](apps/docs/content/docs/getting-started/tui-controls.mdx)
218
293
  - [Architecture](apps/docs/content/docs/architecture.mdx)
219
- - [Roadmap](apps/docs/content/docs/roadmap.mdx)
220
294
  - [Release packaging](apps/docs/content/docs/release-packaging.mdx)
221
295
 
222
296
  Run the documentation site locally with `npm run docs:dev`.
@@ -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
+ }