@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/dist/setup.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { createInterface } from 'node:readline/promises';
4
4
  import { clearCommandCache, commandExists } from './player/command.js';
5
5
  import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
6
+ import { configureMcpIntegrations } from './agent/mcp-install.js';
7
+ import { JsonLibraryStore } from './storage/store.js';
8
+ import { defaultAgentControlSettings } from './types.js';
6
9
  const components = ['mpv', 'ffmpeg', 'vlc'];
7
10
  const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper'];
8
11
  export async function runSetup(options = {}) {
@@ -21,23 +24,37 @@ export async function runSetup(options = {}) {
21
24
  if (!parsed.yes && !parsed.only && isInteractive(input, output)) {
22
25
  selected = await promptForComponents({ platform, installed, input, output });
23
26
  }
27
+ let mcp = parsed.mcp;
28
+ if (mcp === null && !parsed.yes && isInteractive(input, output)) {
29
+ mcp = await promptYesNo(input, output, ' Agent control via MCP (detected coding agents)', true);
30
+ }
31
+ let agentUi = parsed.agentUi;
32
+ if (mcp === true && agentUi === null && !parsed.yes && isInteractive(input, output)) {
33
+ const note = platform === 'darwin'
34
+ ? ' (macOS will ask the agent app to control Terminal on first use)'
35
+ : ' (opens a separate terminal window)';
36
+ agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true);
37
+ }
24
38
  const plan = createSetupPlan({ platform, osRelease, packageManager, installed, selected });
25
39
  printPlan(plan, output);
26
40
  const missing = plan.selected.filter(component => !plan.installed[component]);
27
41
  if (missing.length === 0) {
28
42
  output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n');
43
+ await finishMcpSetup(mcp, agentUi, parsed.dryRun, output);
29
44
  printVerification(output);
30
45
  return;
31
46
  }
32
47
  if (!plan.packageManager) {
33
48
  if (parsed.dryRun) {
34
49
  output.write('\nDry run complete. Install the missing components manually; no system packages were changed.\n');
50
+ await finishMcpSetup(mcp, agentUi, true, output);
35
51
  return;
36
52
  }
37
53
  throw new Error('No supported system package manager was found. Install mpv manually, then run radiocli doctor.');
38
54
  }
39
55
  if (parsed.dryRun) {
40
56
  output.write('\nDry run complete. No system packages were changed.\n');
57
+ await finishMcpSetup(mcp, agentUi, true, output);
41
58
  return;
42
59
  }
43
60
  if (parsed.packageManager && !hasCommand(parsed.packageManager)) {
@@ -64,6 +81,7 @@ export async function runSetup(options = {}) {
64
81
  }
65
82
  clearCommandCache();
66
83
  output.write('\nSetup complete.\n');
84
+ await finishMcpSetup(mcp, agentUi, false, output);
67
85
  printVerification(output);
68
86
  }
69
87
  export function createSetupPlan({ platform, osRelease = '', packageManager, installed, selected }) {
@@ -115,6 +133,8 @@ export function parseSetupArgs(args) {
115
133
  let yes = false;
116
134
  let only = null;
117
135
  let packageManager = null;
136
+ let mcp = null;
137
+ let agentUi = null;
118
138
  for (let index = 0; index < args.length; index += 1) {
119
139
  const arg = args[index];
120
140
  if (arg === '--all')
@@ -123,6 +143,26 @@ export function parseSetupArgs(args) {
123
143
  dryRun = true;
124
144
  else if (arg === '--yes' || arg === '-y')
125
145
  yes = true;
146
+ else if (arg === '--mcp') {
147
+ if (mcp === false)
148
+ throw new Error('Use either --mcp or --no-mcp, not both.');
149
+ mcp = true;
150
+ }
151
+ else if (arg === '--no-mcp') {
152
+ if (mcp === true)
153
+ throw new Error('Use either --mcp or --no-mcp, not both.');
154
+ mcp = false;
155
+ }
156
+ else if (arg === '--agent-ui') {
157
+ if (agentUi === false)
158
+ throw new Error('Use either --agent-ui or --headless-agent, not both.');
159
+ agentUi = true;
160
+ }
161
+ else if (arg === '--headless-agent') {
162
+ if (agentUi === true)
163
+ throw new Error('Use either --agent-ui or --headless-agent, not both.');
164
+ agentUi = false;
165
+ }
126
166
  else if (arg === '--only')
127
167
  only = parseComponents(args[++index]);
128
168
  else if (arg.startsWith('--only='))
@@ -136,7 +176,36 @@ export function parseSetupArgs(args) {
136
176
  }
137
177
  if (all && only)
138
178
  throw new Error('Use either --all or --only, not both.');
139
- return { all, dryRun, yes, only, packageManager };
179
+ if (agentUi !== null && mcp !== true)
180
+ throw new Error('--agent-ui and --headless-agent require --mcp.');
181
+ return { all, dryRun, yes, only, packageManager, mcp, agentUi };
182
+ }
183
+ async function finishMcpSetup(enabled, agentUi, dryRun, output) {
184
+ if (enabled === null)
185
+ return;
186
+ if (dryRun) {
187
+ output.write(`\nAgent integration: would be ${enabled ? 'enabled and installed for detected MCP clients' : 'disabled and removed from detected MCP clients'}.\n`);
188
+ return;
189
+ }
190
+ const entry = process.argv[1];
191
+ if (!entry)
192
+ throw new Error('Could not locate the RadioCLI executable for MCP setup.');
193
+ const results = await configureMcpIntegrations(enabled, { nodePath: process.execPath, cliPath: realpathSync(entry) }, output);
194
+ const failed = results.filter(result => result.status === 'failed');
195
+ if (failed.length > 0) {
196
+ throw new Error(`Playback setup finished, but ${failed.length} agent integration${failed.length === 1 ? '' : 's'} failed: ${failed.map(result => result.client).join(', ')}. Run radiocli mcp status for details.`);
197
+ }
198
+ if (enabled) {
199
+ const store = new JsonLibraryStore();
200
+ const current = store.snapshot().settings.agentControl ?? defaultAgentControlSettings;
201
+ const openUiOnPlay = agentUi ?? current.openUiOnPlay;
202
+ if (openUiOnPlay !== current.openUiOnPlay) {
203
+ store.updateSettings({ agentControl: { ...current, openUiOnPlay } });
204
+ }
205
+ output.write(openUiOnPlay
206
+ ? '\nAgent playback: terminal TUI enabled (default). The host OS may request app-control permission on first use.\n'
207
+ : '\nAgent playback: headless; no separate terminal window or app-control permission is needed.\n');
208
+ }
140
209
  }
141
210
  function parseComponents(value) {
142
211
  const values = value?.split(',').map(item => item.trim().toLowerCase()).filter(Boolean) ?? [];
@@ -61,7 +61,25 @@ const settingsSchema = z.object({
61
61
  transparentBackground: z.boolean().default(false),
62
62
  asciiMode: z.boolean().default(false),
63
63
  reduceMotion: z.boolean().default(false),
64
- mouseSupport: z.boolean().default(true)
64
+ mouseSupport: z.boolean().default(true),
65
+ automaticUpdateChecks: z.boolean().default(true),
66
+ agentControl: z.object({
67
+ enabled: z.boolean().default(false),
68
+ openUiOnPlay: z.boolean().default(true),
69
+ focusNowPlaying: z.boolean().default(true),
70
+ completionPreset: z.object({
71
+ action: z.enum(['play', 'pause', 'resume', 'stop']).default('play'),
72
+ source: z.enum(['recent', 'favorite', 'popular', 'country']).default('recent'),
73
+ countryCode: z.string().length(2).optional(),
74
+ ifPlaying: z.enum(['keep', 'replace']).default('keep'),
75
+ openUi: z.boolean().optional()
76
+ }).default({ action: 'play', source: 'recent', ifPlaying: 'keep' })
77
+ }).default({
78
+ enabled: false,
79
+ openUiOnPlay: true,
80
+ focusNowPlaying: true,
81
+ completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
82
+ })
65
83
  });
66
84
  const absoluteInstantSchema = z.string().refine(value => /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)), { message: 'Expected an absolute ISO-8601 instant with a timezone offset.' });
67
85
  const alarmMinuteInstantSchema = absoluteInstantSchema.refine(value => new Date(value).getUTCSeconds() === 0 && new Date(value).getUTCMilliseconds() === 0, { message: 'Alarm occurrences use minute precision; seconds must be zero.' });
@@ -194,6 +212,13 @@ const librarySchema = z.object({
194
212
  tuneTimeoutSeconds: 12,
195
213
  skipBrokenStreams: true,
196
214
  mouseSupport: true,
215
+ automaticUpdateChecks: true,
216
+ agentControl: {
217
+ enabled: false,
218
+ openUiOnPlay: true,
219
+ focusNowPlaying: true,
220
+ completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
221
+ },
197
222
  mediaKeys: defaultMediaKeys
198
223
  })
199
224
  });
@@ -636,6 +661,13 @@ function defaultState() {
636
661
  tuneTimeoutSeconds: 12,
637
662
  skipBrokenStreams: true,
638
663
  mouseSupport: true,
664
+ automaticUpdateChecks: true,
665
+ agentControl: {
666
+ enabled: false,
667
+ openUiOnPlay: true,
668
+ focusNowPlaying: true,
669
+ completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
670
+ },
639
671
  mediaKeys: defaultMediaKeys
640
672
  }
641
673
  };
package/dist/types.js CHANGED
@@ -2,3 +2,9 @@ import { defaultReceiverStyle, receiverStyleNames } from './ui/visualizers/recei
2
2
  export { defaultReceiverStyle, receiverStyleNames };
3
3
  const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
4
4
  export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
5
+ export const defaultAgentControlSettings = {
6
+ enabled: false,
7
+ openUiOnPlay: true,
8
+ focusNowPlaying: true,
9
+ completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
10
+ };
@@ -5,7 +5,7 @@ import { computeListeningStats } from '../activity/stats.js';
5
5
  import { playbackBackendLabel } from '../player/backend-install.js';
6
6
  import { visibleWindow } from './list-window.js';
7
7
  import { displayWidth, padDisplayEnd, stationLocation, stationTags, stationTech, truncate } from './format.js';
8
- import { homeItems, settingsItems, settingsSectionFor } from './screen-items.js';
8
+ import { homeItems, settingsGroup, settingsGroups, settingsItemsForPage } from './screen-items.js';
9
9
  import { screenTitle } from './screen-meta.js';
10
10
  import { keyHelpSections, commandHelp } from './help-content.js';
11
11
  import { settingLabel, settingValue } from './screens/SettingsScreen.js';
@@ -23,7 +23,7 @@ export function AdaptiveContent(props) {
23
23
  return _jsx(AdaptiveContentBody, { ...props });
24
24
  }
25
25
  function AdaptiveContentBody(props) {
26
- const { mode, screen, selected, height, width, theme, playback, playingStation, nowPlaying, stations, countries, airPlayDevices, airPlayCode, searchQuery, editingSearch, countryFilter, loadingCountries, loadingStations, exploreCursor, library, diagnostics, backends, updateCheck, favoriteKeys, stationTitle, } = props;
26
+ const { mode, screen, settingsPage = 'root', selected, height, width, theme, playback, playingStation, nowPlaying, stations, countries, airPlayDevices, airPlayCode, searchQuery, editingSearch, countryFilter, loadingCountries, loadingStations, exploreCursor, library, diagnostics, backends, updateCheck, appVersion, favoriteKeys, stationTitle, } = props;
27
27
  const accent = themeAccent(theme);
28
28
  const { ascii } = useDisplay();
29
29
  const { bodyRows } = adaptiveFrameMetrics(mode, height);
@@ -41,6 +41,7 @@ function AdaptiveContentBody(props) {
41
41
  if (screen === 'search') {
42
42
  const rows = adaptiveRows({
43
43
  screen,
44
+ settingsPage,
44
45
  stations,
45
46
  countries,
46
47
  airPlayDevices,
@@ -48,6 +49,7 @@ function AdaptiveContentBody(props) {
48
49
  diagnostics,
49
50
  backends,
50
51
  updateCheck,
52
+ appVersion,
51
53
  favoriteKeys,
52
54
  selected,
53
55
  width,
@@ -88,6 +90,7 @@ function AdaptiveContentBody(props) {
88
90
  }
89
91
  const rows = adaptiveRows({
90
92
  screen,
93
+ settingsPage,
91
94
  stations,
92
95
  countries,
93
96
  airPlayDevices,
@@ -95,6 +98,7 @@ function AdaptiveContentBody(props) {
95
98
  diagnostics,
96
99
  backends,
97
100
  updateCheck,
101
+ appVersion,
98
102
  favoriteKeys,
99
103
  selected,
100
104
  width,
@@ -255,16 +259,20 @@ function adaptiveRows(input) {
255
259
  return homeItems.map(item => ({
256
260
  key: item.screen,
257
261
  label: item.label,
258
- detail: mode === 'compact' && width >= 52 ? item.detail : undefined
262
+ detail: mode === 'compact' && width >= 52 ? item.detail : undefined,
263
+ separator: ' '
259
264
  }));
260
265
  }
261
266
  if (screen === 'settings') {
262
- const labels = settingsItems.map(item => settingLabel(item, updateCheck));
267
+ const pageItems = settingsItemsForPage(input.settingsPage);
268
+ const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion));
263
269
  const labelWidth = pairedColumnWidth(labels, width, mode);
264
- return settingsItems.map((item, index) => ({
270
+ return pageItems.map((item, index) => ({
265
271
  key: item,
266
- label: padDisplayEnd(truncate(settingLabel(item, updateCheck), labelWidth), labelWidth),
267
- detail: settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck),
272
+ label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion), labelWidth), labelWidth),
273
+ detail: input.settingsPage === 'root'
274
+ ? adaptiveSettingsRootValue(item)
275
+ : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion),
268
276
  separator: ' ',
269
277
  index
270
278
  }));
@@ -303,6 +311,10 @@ function adaptiveRows(input) {
303
311
  }
304
312
  return stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii);
305
313
  }
314
+ function adaptiveSettingsRootValue(item) {
315
+ const group = settingsGroups.find(candidate => candidate.label === item);
316
+ return group ? `${group.items.length} settings ›` : undefined;
317
+ }
306
318
  function stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii) {
307
319
  return stations.map((station, index) => {
308
320
  const favorite = favoriteKeys.has(`${station.provider}:${station.id}`);
@@ -362,7 +374,7 @@ function adaptiveEmptyState(input) {
362
374
  return [{ key: 'empty', label: `No stations in ${stationTitle.toLowerCase()}.` }, { key: 'hint', label: 'Try another view or clear filters.' }];
363
375
  }
364
376
  function adaptiveStatus(props) {
365
- const { screen, playback, playingStation, stations, countries, searchQuery, editingSearch, countryFilter, editingCountryFilter, library, filterLabel, selected } = props;
377
+ const { screen, playback, playingStation, stations, countries, searchQuery, editingSearch, countryFilter, editingCountryFilter, library, filterLabel } = props;
366
378
  if (screen === 'home') {
367
379
  return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported`;
368
380
  }
@@ -370,7 +382,10 @@ function adaptiveStatus(props) {
370
382
  return `${playbackBackendLabel(playback.backend)} · ${playback.state} · vol ${playback.volume}`;
371
383
  }
372
384
  if (screen === 'settings') {
373
- return `${settingsSectionFor(settingsItems[selected])} · Enter changes the selected setting`;
385
+ const group = settingsGroup(props.settingsPage ?? 'root');
386
+ return group
387
+ ? `${group.label} · Enter changes setting · b categories`
388
+ : 'Choose a category · Updates are available here';
374
389
  }
375
390
  if (screen === 'search') {
376
391
  const query = searchQuery || (editingSearch ? 'type to search' : 'press / to search');