@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.
- package/CHANGELOG.md +47 -1
- package/README.md +30 -0
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -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 +248 -0
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/runner.js +43 -26
- package/dist/cli.js +60 -3
- package/dist/player/player-controller.js +19 -0
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +4 -0
- package/dist/setup.js +71 -2
- package/dist/storage/store.js +33 -1
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +24 -9
- package/dist/ui/App.js +299 -18
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/components/StationList.js +3 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/page-footer.js +4 -2
- package/dist/ui/screen-items.js +40 -9
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/use-alarm-tui.js +4 -0
- package/dist/ui/use-app-input.js +29 -3
- 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 +26 -7
- package/package.json +4 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { detectAlarmTerminal } from '../alarms/terminal-launcher.js';
|
|
5
|
+
const linuxTerminals = new Set(['alacritty', 'foot', 'ghostty', 'gnome-terminal', 'kitty', 'konsole', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'wezterm', 'xfce4-terminal', 'x-terminal-emulator']);
|
|
6
|
+
export async function launchRadioTui(nodePath, cliPath, encodedCommand) {
|
|
7
|
+
const terminal = detectAlarmTerminal();
|
|
8
|
+
const direct = environmentCommand(nodePath, cliPath, ['agent-ui', encodedCommand]);
|
|
9
|
+
const command = `${direct.map(shellQuote).join(' ')}`;
|
|
10
|
+
if (terminal === 'darwin:apple-terminal')
|
|
11
|
+
await launched(spawnDetached('/usr/bin/osascript', appleTerminalScript(command)));
|
|
12
|
+
else if (terminal === 'darwin:iterm')
|
|
13
|
+
await launched(spawnDetached('/usr/bin/osascript', iTermScript(command)));
|
|
14
|
+
else if (terminal === 'darwin:wezterm')
|
|
15
|
+
await launched(spawnDetached('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct]));
|
|
16
|
+
else if (terminal === 'darwin:ghostty')
|
|
17
|
+
await launched(spawnDetached('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct]));
|
|
18
|
+
else if (terminal === 'darwin:kitty')
|
|
19
|
+
await launched(spawnDetached('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct]));
|
|
20
|
+
else if (terminal === 'win32:windows-terminal')
|
|
21
|
+
await launched(spawnDetached('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI', ...direct]));
|
|
22
|
+
else if (terminal === 'win32:console')
|
|
23
|
+
await launched(spawnDetached('cmd.exe', ['/d', '/c', 'start', 'RadioCLI', 'cmd.exe', '/k', windowsCommand(direct)]));
|
|
24
|
+
else if (terminal.startsWith('linux:')) {
|
|
25
|
+
const executable = terminal.slice('linux:'.length);
|
|
26
|
+
if (!linuxTerminals.has(basename(executable)))
|
|
27
|
+
throw new Error('Saved Linux terminal is not supported.');
|
|
28
|
+
const name = basename(executable);
|
|
29
|
+
const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal'
|
|
30
|
+
? ['--']
|
|
31
|
+
: name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
|
|
32
|
+
await launched(spawnDetached(executable, [...prefix, ...direct]));
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
throw new Error('No supported graphical terminal was found. Set agentControl.openUiOnPlay to false or open radiocli manually.');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function launchHeadlessHost(nodePath, cliPath) {
|
|
39
|
+
await launched(spawnDetached(nodePath, [cliPath, 'agent-host']));
|
|
40
|
+
}
|
|
41
|
+
function environmentCommand(nodePath, cliPath, args) {
|
|
42
|
+
const command = [nodePath, cliPath, ...args];
|
|
43
|
+
const radioCliHome = process.env.RADIOCLI_HOME;
|
|
44
|
+
if (!radioCliHome)
|
|
45
|
+
return command;
|
|
46
|
+
if (process.platform === 'win32') {
|
|
47
|
+
return ['cmd.exe', '/d', '/c', `set "RADIOCLI_HOME=${cmdEscape(radioCliHome)}" && ${windowsCommand(command)}`];
|
|
48
|
+
}
|
|
49
|
+
return ['/usr/bin/env', `RADIOCLI_HOME=${radioCliHome}`, ...command];
|
|
50
|
+
}
|
|
51
|
+
function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; }
|
|
52
|
+
function windowsCommand(values) { return values.map(value => `"${value.replaceAll('"', '""')}"`).join(' '); }
|
|
53
|
+
function cmdEscape(value) { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); }
|
|
54
|
+
function appleTerminalScript(command) { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
|
|
55
|
+
function iTermScript(command) { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
|
|
56
|
+
function spawnDetached(command, args) { return spawn(command, [...args], { detached: true, stdio: 'ignore', windowsHide: false }); }
|
|
57
|
+
function launched(child) { return new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); }
|
|
58
|
+
export function resolveExecutable(input, env = process.env, platform = process.platform) {
|
|
59
|
+
if ((input.includes('/') || input.includes('\\')) && existsSync(input))
|
|
60
|
+
return input;
|
|
61
|
+
const suffixes = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
|
|
62
|
+
const pathDelimiter = platform === 'win32' ? ';' : ':';
|
|
63
|
+
for (const directory of (env.PATH ?? '').split(pathDelimiter)) {
|
|
64
|
+
for (const suffix of suffixes) {
|
|
65
|
+
const path = join(directory, `${input}${suffix}`);
|
|
66
|
+
if (existsSync(path))
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
@@ -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 { resolveExecutable } from './launcher.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 = resolveExecutable('claude');
|
|
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 (resolveExecutable('opencode') || 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(resolveExecutable('gemini')), 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 (resolveExecutable('orca')) {
|
|
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 = resolveExecutable('radiocli', process.env, platform);
|
|
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(resolveExecutable('claude'), command),
|
|
208
|
+
opencode: jsonClientState(Boolean(resolveExecutable('opencode') || existsSync(openCodePath)), openCodePath, [
|
|
209
|
+
['mcp', 'servers', 'radiocli'], ['mcp', 'radiocli']
|
|
210
|
+
], command),
|
|
211
|
+
cursor: jsonClientState(cursorDetected(), cursorPath, [['mcpServers', 'radiocli']], command),
|
|
212
|
+
gemini: jsonClientState(Boolean(resolveExecutable('gemini')), geminiPath, [['mcpServers', 'radiocli']], command),
|
|
213
|
+
vscode: jsonClientState(vsCodeDetected(), vscodePath, [['servers', 'radiocli']], command),
|
|
214
|
+
orca: {
|
|
215
|
+
detected: Boolean(resolveExecutable('orca')),
|
|
216
|
+
state: resolveExecutable('orca') ? '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 = resolveExecutable('codex', env, platform);
|
|
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(resolveExecutable('cursor') ||
|
|
247
|
+
resolveExecutable('cursor-agent') ||
|
|
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(resolveExecutable('code') ||
|
|
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); }
|