@chatpanel/bridge 0.1.1 → 0.2.0

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/README.md CHANGED
@@ -7,25 +7,45 @@ so this bridges the gap.
7
7
  - **Claude Code** — embedded via `@anthropic-ai/claude-agent-sdk`, using your
8
8
  existing Claude Code login (or `ANTHROPIC_API_KEY`).
9
9
  - **Codex** — driven via the `codex exec` CLI, using your `codex login`.
10
+ - **Gemini CLI** — driven via the `gemini -p` CLI, using your `gemini` login.
11
+
12
+ Bring whichever agent you already have installed — the extension auto-detects
13
+ the ones the bridge reports as available.
10
14
 
11
15
  ## Run it
12
16
 
13
- **No clone, no install** `npx` fetches the package (and its one dependency) and
14
- starts the server:
17
+ ### Option Adownload the app (no Node.js needed)
18
+
19
+ Grab the standalone binary for your OS from the
20
+ [latest release](https://github.com/chatpanel/chatpanel-bridge/releases/latest) —
21
+ it bundles its own runtime, so **nothing to install**. Run it once to set it up to
22
+ start automatically at login and run in the background:
15
23
 
16
24
  ```bash
17
- npx @chatpanel/bridge # http://127.0.0.1:4319
25
+ # macOS / Linux (make it executable first on macOS)
26
+ chmod +x chatpanel-bridge-macos-arm64
27
+ ./chatpanel-bridge-macos-arm64 --install
28
+
29
+ # Windows (PowerShell)
30
+ .\chatpanel-bridge-windows-x64.exe --install
18
31
  ```
19
32
 
20
- …then leave it running and open the ChatPanel side panel. That's the whole setup.
33
+ That's it open the ChatPanel side panel and your agents appear. Manage it with
34
+ `--status` (is it set up?) and `--uninstall` (remove auto-start). Run with no flags
35
+ to start it once in the foreground instead.
21
36
 
22
- Prefer a persistent command? Install it globally:
37
+ > Until these binaries are code-signed, macOS Gatekeeper / Windows SmartScreen may
38
+ > warn on first run (on macOS, right-click the file → **Open**). Signing is on the way.
39
+
40
+ ### Option B — via npm (needs Node.js 18+)
23
41
 
24
42
  ```bash
25
- npm i -g @chatpanel/bridge
26
- chatpanel-bridge # → http://127.0.0.1:4319
43
+ npx @chatpanel/bridge # → http://127.0.0.1:4319
27
44
  ```
28
45
 
46
+ …then leave it running and open the ChatPanel side panel. Prefer a persistent
47
+ command? `npm i -g @chatpanel/bridge` then `chatpanel-bridge`.
48
+
29
49
  Prerequisites (the agents you want to use must already be set up):
30
50
 
31
51
  - **Claude Code**: be signed in (`claude`) or set `ANTHROPIC_API_KEY`.
package/package.json CHANGED
@@ -1,18 +1,40 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "Local bridge that exposes Claude Code (Agent SDK) and Codex (CLI) to the ChatPanel Chrome extension over a localhost SSE endpoint.",
6
- "keywords": ["chatpanel", "claude-code", "codex", "chrome-extension", "ai-agents", "bridge"],
5
+ "description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (Agent SDK), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
+ "keywords": [
7
+ "chatpanel",
8
+ "claude-code",
9
+ "codex",
10
+ "gemini-cli",
11
+ "chrome-extension",
12
+ "ai-agents",
13
+ "bridge"
14
+ ],
7
15
  "homepage": "https://chatpanel.net",
8
- "repository": { "type": "git", "url": "git+https://github.com/chatpanel/chatpanel-bridge.git" },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/chatpanel/chatpanel-bridge.git"
19
+ },
9
20
  "license": "MIT",
10
- "bin": { "chatpanel-bridge": "src/server.js" },
11
- "files": ["src", "README.md", "LICENSE", ".env.example"],
12
- "engines": { "node": ">=18" },
21
+ "bin": {
22
+ "chatpanel-bridge": "src/server.js"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "scripts",
27
+ "README.md",
28
+ "LICENSE",
29
+ ".env.example"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
13
34
  "scripts": {
14
35
  "start": "node src/server.js",
15
- "dev": "node --watch src/server.js"
36
+ "dev": "node --watch src/server.js",
37
+ "build:bin": "bash scripts/build-binaries.sh"
16
38
  },
17
39
  "dependencies": {
18
40
  "@anthropic-ai/claude-agent-sdk": "^0.1.0"
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # Compile the bridge into standalone, single-file binaries (no Node required to
3
+ # run them). Needs Bun: https://bun.sh
4
+ set -euo pipefail
5
+ cd "$(dirname "$0")/.."
6
+ mkdir -p dist
7
+ rm -f dist/chatpanel-bridge-*
8
+
9
+ targets=(
10
+ "bun-darwin-arm64:chatpanel-bridge-macos-arm64"
11
+ "bun-darwin-x64:chatpanel-bridge-macos-x64"
12
+ "bun-linux-x64:chatpanel-bridge-linux-x64"
13
+ "bun-windows-x64:chatpanel-bridge-windows-x64.exe"
14
+ )
15
+ for t in "${targets[@]}"; do
16
+ target="${t%%:*}"; out="${t##*:}"
17
+ echo "→ building dist/$out ($target)"
18
+ bun build src/server.js --compile --target="$target" --outfile "dist/$out"
19
+ done
20
+ echo "✓ binaries in dist/"
21
+ ls -la dist
@@ -10,14 +10,6 @@
10
10
  import path from 'node:path';
11
11
  import os from 'node:os';
12
12
 
13
- // Always-on guidance so the coding agent behaves like a browser assistant by
14
- // default: prefer the page context the extension attaches over scanning files.
15
- const BASE_GUIDANCE =
16
- 'You are ChatPanel, an AI assistant living in a browser side panel. When the ' +
17
- "user's message includes <context> blocks (extracted web pages or selections), " +
18
- 'answer primarily from those. Only read or modify local files when the user ' +
19
- 'explicitly asks about code or a project.';
20
-
21
13
  let sdkPromise = null;
22
14
  function loadSdk() {
23
15
  if (!sdkPromise) sdkPromise = import('@anthropic-ai/claude-agent-sdk').catch(() => null);
@@ -86,11 +78,12 @@ export async function chat({ messages, system, options }, emit) {
86
78
  // servers and CLAUDE.md apply. Turn the agent's "Use my local skills &
87
79
  // config" off to run clean.
88
80
  settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
89
- systemPrompt: {
90
- type: 'preset',
91
- preset: 'claude_code',
92
- append: [BASE_GUIDANCE, system].filter(Boolean).join('\n\n'),
93
- },
81
+ // Native Claude Code system prompt. Only append the user's OWN system
82
+ // prompt if they set one — no ChatPanel persona is injected, so the agent
83
+ // is exactly as capable as it is in the terminal.
84
+ systemPrompt: system
85
+ ? { type: 'preset', preset: 'claude_code', append: system }
86
+ : { type: 'preset', preset: 'claude_code' },
94
87
  ...(options.model ? { model: options.model } : {}),
95
88
  ...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
96
89
  },
@@ -99,9 +92,14 @@ export async function chat({ messages, system, options }, emit) {
99
92
  for await (const message of iterator) {
100
93
  if (message.type === 'stream_event') {
101
94
  const ev = message.event;
102
- if (ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
103
- streamedAny = true;
104
- emit({ type: 'delta', text: ev.delta.text });
95
+ if (ev?.type === 'content_block_delta') {
96
+ if (ev.delta?.type === 'text_delta') {
97
+ streamedAny = true;
98
+ emit({ type: 'delta', text: ev.delta.text });
99
+ } else if (ev.delta?.type === 'thinking_delta') {
100
+ // Extended thinking — stream the reasoning text to the panel.
101
+ emit({ type: 'reasoning', text: ev.delta.thinking || '' });
102
+ }
105
103
  }
106
104
  } else if (message.type === 'assistant') {
107
105
  for (const block of message.message.content) {
@@ -58,14 +58,6 @@ function ensureIsolatedHome() {
58
58
  return ISO_HOME;
59
59
  }
60
60
 
61
- const BASE_GUIDANCE =
62
- 'You are ChatPanel, an AI assistant in a browser side panel. Answer the ' +
63
- "user's question using the <context> blocks provided (web pages or selections). " +
64
- 'Do NOT search the filesystem or read local files for general questions — ' +
65
- 'everything you need is in the prompt. Only inspect files if the user explicitly ' +
66
- 'asks about local code or a project. When asked for a table, return GitHub-' +
67
- 'flavored Markdown.';
68
-
69
61
  let installed = null;
70
62
  export async function available() {
71
63
  if (installed === null) {
@@ -82,7 +74,7 @@ export async function available() {
82
74
  }
83
75
 
84
76
  function buildPrompt(messages, system) {
85
- let p = `${[BASE_GUIDANCE, system].filter(Boolean).join('\n\n')}\n\n`;
77
+ let p = system ? `${system}\n\n` : '';
86
78
  const history = messages.slice(0, -1);
87
79
  const last = messages[messages.length - 1];
88
80
  if (history.length) {
@@ -0,0 +1,102 @@
1
+ // Gemini engine — drives the Gemini CLI (`gemini -p`) using your local login.
2
+ //
3
+ // Like the Codex engine, this shells out to the installed `gemini` binary in
4
+ // non-interactive mode: `gemini -p "<prompt>"` runs once, prints the answer to
5
+ // stdout, and exits. We run in an empty scratch dir for general chat so Gemini
6
+ // never crawls the bridge's own files; set a working dir on the agent to point
7
+ // it at a real project.
8
+ //
9
+ // Install: `npm i -g @google/gemini-cli`, then run `gemini` once to sign in.
10
+
11
+ import { spawn, spawnSync } from 'node:child_process';
12
+ import { mkdirSync } from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ const TIMEOUT_MS = Number(process.env.CHATPANEL_GEMINI_TIMEOUT_MS) || 180_000;
17
+ const SCRATCH = path.join(os.tmpdir(), 'chatpanel-gemini-scratch');
18
+
19
+ let installed = null;
20
+ export async function available() {
21
+ if (installed === null) {
22
+ try {
23
+ const r = spawnSync('gemini', ['--version'], { stdio: 'ignore', timeout: 5000 });
24
+ installed = r.status === 0 || (r.status === null && r.error === undefined);
25
+ } catch {
26
+ installed = false;
27
+ }
28
+ }
29
+ return installed
30
+ ? { ok: true }
31
+ : { ok: false, reason: 'gemini not found on PATH. Install @google/gemini-cli, then run `gemini` once to sign in.' };
32
+ }
33
+
34
+ function buildPrompt(messages, system) {
35
+ let p = system ? `${system}\n\n` : '';
36
+ const history = messages.slice(0, -1);
37
+ const last = messages[messages.length - 1];
38
+ if (history.length) {
39
+ p += 'Conversation so far:\n';
40
+ for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
41
+ p += '---\n\n';
42
+ }
43
+ p += last ? last.content : '';
44
+ return p;
45
+ }
46
+
47
+ export async function chat({ messages, system, options }, emit) {
48
+ try {
49
+ mkdirSync(SCRATCH, { recursive: true });
50
+ } catch {
51
+ /* best effort */
52
+ }
53
+ const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
54
+
55
+ // `-p` is non-interactive (no TTY prompts). `-m` picks the model. `-y` (yolo)
56
+ // auto-approves tool calls when the user opted into bypassPermissions — without
57
+ // it Gemini would block on an approval it can't show in a headless run.
58
+ const args = ['-p', buildPrompt(messages, system)];
59
+ if (options.model) args.push('-m', options.model);
60
+ if (options.permissionMode === 'bypassPermissions') args.push('-y');
61
+
62
+ await new Promise((resolve, reject) => {
63
+ let child;
64
+ try {
65
+ // stdin ignored: the prompt is passed via -p, and no TTY means no
66
+ // interactive "trust this folder?" dialog can block us.
67
+ child = spawn('gemini', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
68
+ } catch (e) {
69
+ return reject(new Error(`Failed to start gemini: ${e.message}`));
70
+ }
71
+
72
+ let out = '';
73
+ let err = '';
74
+ let streamed = false;
75
+ const timer = setTimeout(() => {
76
+ child.kill('SIGKILL');
77
+ reject(new Error(`Gemini timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
78
+ }, TIMEOUT_MS);
79
+
80
+ child.stdout.on('data', (d) => {
81
+ const s = d.toString();
82
+ out += s;
83
+ streamed = true;
84
+ emit({ type: 'delta', text: s });
85
+ });
86
+ child.stderr.on('data', (d) => (err += d.toString()));
87
+ child.on('error', (e) => {
88
+ clearTimeout(timer);
89
+ reject(new Error(`Failed to start gemini: ${e.message}`));
90
+ });
91
+ child.on('close', (code) => {
92
+ clearTimeout(timer);
93
+ if (code === 0) {
94
+ if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
95
+ emit({ type: 'done', text: '' });
96
+ resolve();
97
+ } else {
98
+ reject(new Error(`Gemini exited ${code}: ${err.trim() || out.trim() || 'failed'}`));
99
+ }
100
+ });
101
+ });
102
+ }
package/src/server.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // ChatPanel Bridge — a tiny localhost server that exposes the coding agents
3
- // running on this machine (Claude Code via the Agent SDK, Codex via its CLI) to
4
- // the ChatPanel Chrome extension. Zero runtime dependencies beyond the optional
5
- // Claude Agent SDK.
3
+ // running on this machine (Claude Code via the Agent SDK, Codex and Gemini via
4
+ // their CLIs) to the ChatPanel Chrome extension. Zero runtime dependencies
5
+ // beyond the optional Claude Agent SDK.
6
6
  //
7
7
  // GET /health → { ok, version, agents: [{id,label,available,reason}] }
8
8
  // POST /chat → Server-Sent Events stream of { type, ... }:
@@ -17,14 +17,17 @@
17
17
  import { createServer } from 'node:http';
18
18
  import * as claude from './engines/claude.js';
19
19
  import * as codex from './engines/codex.js';
20
+ import * as gemini from './engines/gemini.js';
21
+ import { installService, uninstallService, serviceStatus } from './service.js';
20
22
 
21
- const VERSION = '0.1.0';
23
+ const VERSION = '0.2.0';
22
24
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
23
25
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
24
26
 
25
27
  const ENGINES = {
26
28
  claude: { engine: claude, label: 'Claude Code' },
27
29
  codex: { engine: codex, label: 'Codex' },
30
+ gemini: { engine: gemini, label: 'Gemini CLI' },
28
31
  };
29
32
 
30
33
  // --------------------------------------------------------------------------
@@ -143,11 +146,74 @@ function log(level, msg) {
143
146
  fn(`[chatpanel-bridge] ${msg}`);
144
147
  }
145
148
 
146
- server.listen(PORT, HOST, async () => {
147
- log('info', `listening on http://${HOST}:${PORT}`);
148
- for (const [id, { engine, label }] of Object.entries(ENGINES)) {
149
- const a = await engine.available().catch(() => ({ ok: false }));
150
- log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
149
+ function startServer() {
150
+ server.listen(PORT, HOST, async () => {
151
+ log('info', `listening on http://${HOST}:${PORT}`);
152
+ for (const [, { engine, label }] of Object.entries(ENGINES)) {
153
+ const a = await engine.available().catch(() => ({ ok: false }));
154
+ log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
155
+ }
156
+ log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Gemini CLI) appear automatically.');
157
+ });
158
+ }
159
+
160
+ function printHelp() {
161
+ console.log(`ChatPanel Bridge v${VERSION}
162
+
163
+ Usage:
164
+ chatpanel-bridge start the bridge (foreground) on ${HOST}:${PORT}
165
+ chatpanel-bridge --install run automatically at login, in the background
166
+ chatpanel-bridge --uninstall remove the login auto-start
167
+ chatpanel-bridge --status show whether auto-start is set up
168
+ chatpanel-bridge --version print the version
169
+
170
+ Env: CHATPANEL_BRIDGE_HOST, CHATPANEL_BRIDGE_PORT`);
171
+ }
172
+
173
+ // Handle CLI commands before starting the server. Returns true if a command ran.
174
+ function runCli() {
175
+ const argv = process.argv;
176
+ const has = (...flags) => flags.some((f) => argv.includes(f));
177
+
178
+ if (has('--help', '-h')) {
179
+ printHelp();
180
+ return true;
151
181
  }
152
- log('info', 'Open the ChatPanel side panel; Claude Code & Codex will appear as agents.');
153
- });
182
+ if (has('--version', '-v')) {
183
+ console.log(VERSION);
184
+ return true;
185
+ }
186
+ if (has('--install')) {
187
+ try {
188
+ installService();
189
+ log('info', 'Installed. The bridge now starts automatically at login and is running in the background.');
190
+ } catch (e) {
191
+ log('error', 'Install failed: ' + (e?.message || e));
192
+ process.exitCode = 1;
193
+ }
194
+ return true;
195
+ }
196
+ if (has('--uninstall')) {
197
+ try {
198
+ uninstallService();
199
+ log('info', 'Removed the login auto-start.');
200
+ } catch (e) {
201
+ log('error', 'Uninstall failed: ' + (e?.message || e));
202
+ process.exitCode = 1;
203
+ }
204
+ return true;
205
+ }
206
+ if (has('--status')) {
207
+ let on = false;
208
+ try {
209
+ on = serviceStatus();
210
+ } catch (e) {
211
+ log('error', String(e?.message || e));
212
+ }
213
+ log('info', `auto-start: ${on ? 'installed' : 'not installed'}`);
214
+ return true;
215
+ }
216
+ return false;
217
+ }
218
+
219
+ if (!runCli()) startServer();
package/src/service.js ADDED
@@ -0,0 +1,147 @@
1
+ // Background auto-start for the ChatPanel Bridge.
2
+ //
3
+ // So non-technical users never touch a terminal: download the app, run it once
4
+ // with --install, and it launches at login and stays running.
5
+ //
6
+ // chatpanel-bridge --install register login auto-start + start now
7
+ // chatpanel-bridge --uninstall remove it
8
+ // chatpanel-bridge --status is it registered?
9
+ //
10
+ // macOS → LaunchAgent · Windows → Scheduled Task (ONLOGON) · Linux → systemd user.
11
+
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
15
+ import { spawnSync } from 'node:child_process';
16
+
17
+ const LABEL = 'net.chatpanel.bridge';
18
+ const DISPLAY = 'ChatPanel Bridge';
19
+
20
+ // The command that launches THIS bridge. A compiled single-file binary launches
21
+ // itself (no args); running under node/bun launches the interpreter + this script.
22
+ export function resolveLaunch() {
23
+ const exe = process.execPath;
24
+ const base = path.basename(exe).toLowerCase();
25
+ const underInterpreter = base.startsWith('node') || base.startsWith('bun');
26
+ if (underInterpreter && process.argv[1]) {
27
+ return { program: exe, args: [path.resolve(process.argv[1])] };
28
+ }
29
+ return { program: exe, args: [] };
30
+ }
31
+
32
+ function logPaths() {
33
+ const dir = path.join(os.homedir(), '.chatpanel');
34
+ mkdirSync(dir, { recursive: true });
35
+ return { out: path.join(dir, 'bridge.log'), err: path.join(dir, 'bridge.err.log') };
36
+ }
37
+
38
+ function run(cmd, args, opts = {}) {
39
+ return spawnSync(cmd, args, { encoding: 'utf8', ...opts });
40
+ }
41
+
42
+ // ---------------------------------------------------------------- macOS
43
+ const macPlist = () => path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
44
+
45
+ function macInstall() {
46
+ const { program, args } = resolveLaunch();
47
+ const { out, err } = logPaths();
48
+ const progArgs = [program, ...args].map((a) => ` <string>${a}</string>`).join('\n');
49
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
50
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
51
+ <plist version="1.0">
52
+ <dict>
53
+ <key>Label</key><string>${LABEL}</string>
54
+ <key>ProgramArguments</key>
55
+ <array>
56
+ ${progArgs}
57
+ </array>
58
+ <key>RunAtLoad</key><true/>
59
+ <key>KeepAlive</key><true/>
60
+ <key>StandardOutPath</key><string>${out}</string>
61
+ <key>StandardErrorPath</key><string>${err}</string>
62
+ </dict>
63
+ </plist>
64
+ `;
65
+ const p = macPlist();
66
+ mkdirSync(path.dirname(p), { recursive: true });
67
+ writeFileSync(p, plist);
68
+ run('launchctl', ['unload', p]); // ignore if not loaded
69
+ const r = run('launchctl', ['load', '-w', p]);
70
+ if (r.status !== 0) throw new Error((r.stderr || '').trim() || 'launchctl load failed');
71
+ }
72
+ function macUninstall() {
73
+ const p = macPlist();
74
+ run('launchctl', ['unload', '-w', p]);
75
+ if (existsSync(p)) rmSync(p);
76
+ }
77
+ function macStatus() {
78
+ return (run('launchctl', ['list']).stdout || '').includes(LABEL);
79
+ }
80
+
81
+ // ---------------------------------------------------------------- Windows
82
+ const WIN_TASK = 'ChatPanelBridge';
83
+
84
+ function winInstall() {
85
+ const { program, args } = resolveLaunch();
86
+ const tr = [`"${program}"`, ...args.map((a) => `"${a}"`)].join(' ');
87
+ const r = run('schtasks', ['/Create', '/TN', WIN_TASK, '/TR', tr, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/F']);
88
+ if (r.status !== 0) throw new Error((r.stderr || '').trim() || 'schtasks create failed');
89
+ run('schtasks', ['/Run', '/TN', WIN_TASK]); // start now
90
+ }
91
+ function winUninstall() {
92
+ run('schtasks', ['/Delete', '/TN', WIN_TASK, '/F']);
93
+ }
94
+ function winStatus() {
95
+ return run('schtasks', ['/Query', '/TN', WIN_TASK]).status === 0;
96
+ }
97
+
98
+ // ---------------------------------------------------------------- Linux (systemd user)
99
+ const linUnit = () => path.join(os.homedir(), '.config', 'systemd', 'user', 'chatpanel-bridge.service');
100
+
101
+ function linInstall() {
102
+ const { program, args } = resolveLaunch();
103
+ const exec = [program, ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
104
+ const unit = `[Unit]
105
+ Description=${DISPLAY}
106
+ After=network.target
107
+
108
+ [Service]
109
+ ExecStart=${exec}
110
+ Restart=on-failure
111
+
112
+ [Install]
113
+ WantedBy=default.target
114
+ `;
115
+ const p = linUnit();
116
+ mkdirSync(path.dirname(p), { recursive: true });
117
+ writeFileSync(p, unit);
118
+ run('systemctl', ['--user', 'daemon-reload']);
119
+ const r = run('systemctl', ['--user', 'enable', '--now', 'chatpanel-bridge']);
120
+ if (r.status !== 0) throw new Error((r.stderr || '').trim() || 'systemctl enable failed');
121
+ }
122
+ function linUninstall() {
123
+ run('systemctl', ['--user', 'disable', '--now', 'chatpanel-bridge']);
124
+ const p = linUnit();
125
+ if (existsSync(p)) rmSync(p);
126
+ }
127
+ function linStatus() {
128
+ return (run('systemctl', ['--user', 'is-enabled', 'chatpanel-bridge']).stdout || '').trim() === 'enabled';
129
+ }
130
+
131
+ // ---------------------------------------------------------------- dispatch
132
+ function byPlatform(mac, win, lin) {
133
+ if (process.platform === 'darwin') return mac();
134
+ if (process.platform === 'win32') return win();
135
+ if (process.platform === 'linux') return lin();
136
+ throw new Error(`Auto-start isn't supported on ${process.platform} yet — run the bridge directly.`);
137
+ }
138
+
139
+ export function installService() {
140
+ return byPlatform(macInstall, winInstall, linInstall);
141
+ }
142
+ export function uninstallService() {
143
+ return byPlatform(macUninstall, winUninstall, linUninstall);
144
+ }
145
+ export function serviceStatus() {
146
+ return byPlatform(macStatus, winStatus, linStatus);
147
+ }