@drawsy/companion 0.0.0-stage → 0.1.29

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.
@@ -0,0 +1,201 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execute = promisify(execFile);
7
+ const label = "ai.drawsy.companion.cli";
8
+ const unit = "drawsy-companion-cli.service";
9
+ const stateDirectory = join(homedir(), ".drawsy-companion");
10
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
11
+ const unitDirectory = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd", "user");
12
+ const unitPath = join(unitDirectory, unit);
13
+ async function run(command, args) {
14
+ try {
15
+ const result = await execute(command, args, { windowsHide: true, timeout: 30_000 });
16
+ return result.stdout.trim();
17
+ }
18
+ catch (error) {
19
+ const details = error;
20
+ throw new Error(`Cannot manage Companion startup: ${details.stderr?.trim() || details.message}`);
21
+ }
22
+ }
23
+ async function exists(path) {
24
+ try {
25
+ await access(path);
26
+ return true;
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return false;
31
+ throw error;
32
+ }
33
+ }
34
+ function xml(value) {
35
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
36
+ .replace(/"/g, "&quot;").replace(/'/g, "&apos;");
37
+ }
38
+ function powershellLiteral(value) {
39
+ return `'${value.replace(/'/g, "''")}'`;
40
+ }
41
+ // Windows command-line quoting, independent of PowerShell string quoting.
42
+ function windowsArgument(value) {
43
+ return `"${value.replace(/(\\*)"/g, "$1$1\\\"").replace(/\\+$/g, "$&$&")}"`;
44
+ }
45
+ function unitArgument(value) {
46
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
47
+ .replace(/%/g, "%%").replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`;
48
+ }
49
+ async function powershell(script) {
50
+ return run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand",
51
+ Buffer.from(`$ErrorActionPreference = 'Stop'; ${script}`, "utf16le").toString("base64")]);
52
+ }
53
+ const taskIdentity = "$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent(); " +
54
+ "$taskName = 'DrawsyCompanion-CLI-' + $identity.User.Value; ";
55
+ function launchDomain() {
56
+ if (!process.getuid || process.getuid() === 0) {
57
+ throw new Error("Run Companion setup as your signed-in user, without sudo.");
58
+ }
59
+ return `gui/${process.getuid()}`;
60
+ }
61
+ async function requireSystemd() {
62
+ if (process.getuid?.() === 0) {
63
+ throw new Error("Run Companion setup as your signed-in user, without sudo.");
64
+ }
65
+ try {
66
+ await run("systemctl", ["--user", "show-environment"]);
67
+ }
68
+ catch {
69
+ throw new Error("Automatic startup requires a working systemd user session. You can still use Companion start or serve manually.");
70
+ }
71
+ }
72
+ /** Register and start the CLI for the signed-in user, without administrator access. */
73
+ export async function enableAutostart(nodeExecutable, cliEntry) {
74
+ if (!isAbsolute(nodeExecutable) || !isAbsolute(cliEntry) || /[\0\r\n]/.test(nodeExecutable + cliEntry)) {
75
+ throw new Error("Startup requires absolute Node and Companion paths without control characters.");
76
+ }
77
+ await access(nodeExecutable);
78
+ await access(cliEntry);
79
+ await mkdir(stateDirectory, { recursive: true, mode: 0o700 });
80
+ if (process.platform === "darwin") {
81
+ const domain = launchDomain();
82
+ await mkdir(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
83
+ // Replace only our existing registration. A missing loaded job is normal after logout.
84
+ if (await exists(plistPath)) {
85
+ const loaded = await execute("/bin/launchctl", ["print", `${domain}/${label}`], { timeout: 30_000 }).then(() => true, () => false);
86
+ if (loaded)
87
+ await run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
88
+ }
89
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
90
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
91
+ <plist version="1.0"><dict>
92
+ <key>Label</key><string>${label}</string>
93
+ <key>ProgramArguments</key><array><string>${xml(nodeExecutable)}</string><string>${xml(cliEntry)}</string><string>serve</string><string>--background-log</string></array>
94
+ <key>WorkingDirectory</key><string>${xml(stateDirectory)}</string>
95
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || "")}</string></dict>
96
+ <key>RunAtLoad</key><true/>
97
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
98
+ <key>ThrottleInterval</key><integer>15</integer>
99
+ <key>StandardOutPath</key><string>${xml(join(stateDirectory, "companion.log"))}</string>
100
+ <key>StandardErrorPath</key><string>${xml(join(stateDirectory, "companion.log"))}</string>
101
+ </dict></plist>
102
+ `;
103
+ await writeFile(plistPath, plist, { mode: 0o600 });
104
+ await run("/bin/launchctl", ["enable", `${domain}/${label}`]);
105
+ await run("/bin/launchctl", ["bootstrap", domain, plistPath]);
106
+ await run("/bin/launchctl", ["kickstart", `${domain}/${label}`]);
107
+ return;
108
+ }
109
+ if (process.platform === "win32") {
110
+ // A hidden launcher waits for Node so Task Scheduler observes server exit/restart.
111
+ const launchScript = `$ErrorActionPreference = 'Stop'; $env:PATH = ${powershellLiteral(process.env.PATH || "")}; ` +
112
+ `$child = Start-Process -FilePath ${powershellLiteral(nodeExecutable)} ` +
113
+ `-ArgumentList ${powershellLiteral(`${windowsArgument(cliEntry)} serve --background-log`)} ` +
114
+ `-WorkingDirectory ${powershellLiteral(stateDirectory)} -WindowStyle Hidden -Wait -PassThru; exit $child.ExitCode`;
115
+ const encodedLaunch = Buffer.from(launchScript, "utf16le").toString("base64");
116
+ await powershell(taskIdentity +
117
+ `$action = New-ScheduledTaskAction -Execute (Join-Path $PSHOME 'powershell.exe') ` +
118
+ `-Argument '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedLaunch}'; ` +
119
+ "$trigger = New-ScheduledTaskTrigger -AtLogOn -User $identity.Name; " +
120
+ "$principal = New-ScheduledTaskPrincipal -UserId $identity.Name -LogonType Interactive -RunLevel Limited; " +
121
+ "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries " +
122
+ "-ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew; " +
123
+ "Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null; " +
124
+ "Start-ScheduledTask -TaskName $taskName;");
125
+ return;
126
+ }
127
+ if (process.platform === "linux") {
128
+ await requireSystemd();
129
+ await mkdir(unitDirectory, { recursive: true });
130
+ // ':' disables systemd environment expansion in ExecStart, preserving literal paths.
131
+ await writeFile(unitPath, `[Unit]
132
+ Description=Drawsy Companion local canvas connection
133
+
134
+ [Service]
135
+ Type=simple
136
+ ExecStart=:${unitArgument(nodeExecutable)} ${unitArgument(cliEntry)} serve --background-log
137
+ WorkingDirectory=${unitArgument(stateDirectory)}
138
+ Environment=${unitArgument(`PATH=${process.env.PATH || ""}`)}
139
+ Restart=on-failure
140
+ RestartSec=15
141
+ TimeoutStopSec=20
142
+
143
+ [Install]
144
+ WantedBy=default.target
145
+ `, { mode: 0o600 });
146
+ await run("systemctl", ["--user", "daemon-reload"]);
147
+ await run("systemctl", ["--user", "enable", unit]);
148
+ await run("systemctl", ["--user", "restart", unit]);
149
+ return;
150
+ }
151
+ throw new Error(`Automatic startup is not supported on ${process.platform}.`);
152
+ }
153
+ /** Stop and unregister only the CLI's user startup entry. */
154
+ export async function disableAutostart() {
155
+ if (process.platform === "darwin") {
156
+ const domain = launchDomain();
157
+ const loaded = await execute("/bin/launchctl", ["print", `${domain}/${label}`], { timeout: 30_000 }).then(() => true, () => false);
158
+ if (loaded)
159
+ await run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
160
+ await rm(plistPath, { force: true });
161
+ return;
162
+ }
163
+ if (process.platform === "win32") {
164
+ await powershell(taskIdentity +
165
+ "$task = Get-ScheduledTask | Where-Object { $_.TaskName -eq $taskName -and $_.TaskPath -eq '\\' }; " +
166
+ "if ($task) { Stop-ScheduledTask -TaskName $taskName; Unregister-ScheduledTask -TaskName $taskName -Confirm:$false; }");
167
+ return;
168
+ }
169
+ if (process.platform === "linux") {
170
+ if (!(await exists(unitPath)))
171
+ return;
172
+ await requireSystemd();
173
+ await run("systemctl", ["--user", "disable", "--now", unit]);
174
+ await rm(unitPath, { force: true });
175
+ await run("systemctl", ["--user", "daemon-reload"]);
176
+ return;
177
+ }
178
+ throw new Error(`Automatic startup is not supported on ${process.platform}.`);
179
+ }
180
+ /** Report persistent login registration; this does not assert server health. */
181
+ export async function autostartEnabled() {
182
+ if (process.platform === "darwin") {
183
+ if (!(await exists(plistPath)))
184
+ return false;
185
+ const disabled = await run("/bin/launchctl", ["print-disabled", launchDomain()]);
186
+ return !disabled.includes(`"${label}" => true`);
187
+ }
188
+ if (process.platform === "win32") {
189
+ return (await powershell(taskIdentity +
190
+ "$task = Get-ScheduledTask | Where-Object { $_.TaskName -eq $taskName -and $_.TaskPath -eq '\\' }; " +
191
+ "if ($task -and $task.State -ne 'Disabled') { 'true' } else { 'false' }")) === "true";
192
+ }
193
+ if (process.platform === "linux") {
194
+ if (!(await exists(unitPath)))
195
+ return false;
196
+ await requireSystemd();
197
+ const result = await execute("systemctl", ["--user", "is-enabled", unit], { timeout: 30_000 }).then(({ stdout }) => stdout.trim(), (error) => error.stdout?.trim() || "disabled");
198
+ return result === "enabled";
199
+ }
200
+ throw new Error(`Automatic startup is not supported on ${process.platform}.`);
201
+ }
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { createServer } from "node:http";
5
+ import { mkdir, readFile, rm, stat, writeFile, open } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { Console } from "node:console";
10
+ import { createWriteStream } from "node:fs";
11
+ import { createDrawsyBridge } from "./bridge.js";
12
+ import { readLocalEngineStatus } from "./engine-status.js";
13
+ import { enableAutostart, disableAutostart, autostartEnabled } from "./cli-autostart.js";
14
+ const directory = path.join(homedir(), ".drawsy-companion");
15
+ const lockDirectory = path.join(directory, "runtime.lock");
16
+ const recordPath = path.join(lockDirectory, "runtime.json");
17
+ const logPath = path.join(directory, "companion.log");
18
+ const entry = fileURLToPath(import.meta.url);
19
+ const manifest = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
20
+ const alive = (pid) => {
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ return error.code === "EPERM";
27
+ }
28
+ };
29
+ const readRuntime = async () => {
30
+ try {
31
+ const value = JSON.parse(await readFile(recordPath, "utf8"));
32
+ if (!value || typeof value !== "object")
33
+ throw new Error("Invalid runtime record.");
34
+ const record = value;
35
+ if (!Number.isInteger(record.pid) || record.pid <= 0 || !Number.isInteger(record.port) || record.port <= 0 || record.port > 65535 || typeof record.token !== "string" || !/^[a-f0-9]{64}$/.test(record.token) || typeof record.version !== "string")
36
+ throw new Error("Invalid runtime record.");
37
+ return record;
38
+ }
39
+ catch (error) {
40
+ if (error.code === "ENOENT")
41
+ return null;
42
+ throw error;
43
+ }
44
+ };
45
+ const control = async (operation) => {
46
+ const record = await readRuntime();
47
+ if (!record || !alive(record.pid))
48
+ return null;
49
+ const response = await fetch(`http://127.0.0.1:${record.port}/${operation}`, {
50
+ method: "POST", headers: { authorization: `Bearer ${record.token}` },
51
+ signal: AbortSignal.timeout(3000),
52
+ });
53
+ if (!response.ok)
54
+ throw new Error("Could not authenticate the running CLI. No process was stopped.");
55
+ const result = await response.json();
56
+ if (result.service !== "drawsy-companion-cli" || result.pid !== record.pid)
57
+ throw new Error("Unexpected background process. No process was stopped.");
58
+ return result;
59
+ };
60
+ const bridgePresent = async () => {
61
+ try {
62
+ const response = await fetch("http://127.0.0.1:3031/health", { signal: AbortSignal.timeout(1000) });
63
+ const result = await response.json();
64
+ return response.ok && result.service === "drawsy-ai-bridge" ? result : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ };
70
+ const acquire = async () => {
71
+ await mkdir(directory, { recursive: true, mode: 0o700 });
72
+ try {
73
+ await mkdir(lockDirectory, { mode: 0o700 });
74
+ }
75
+ catch (error) {
76
+ if (error.code !== "EEXIST")
77
+ throw error;
78
+ // Called only after acquiring the fixed loopback listener: competing servers
79
+ // cannot reach this cleanup concurrently.
80
+ const record = await readRuntime();
81
+ if (record && alive(record.pid))
82
+ throw new Error("Companion CLI is already running. Use drawsy-companion status.");
83
+ const info = await stat(lockDirectory);
84
+ if (!record && Date.now() - info.mtimeMs < 30_000)
85
+ throw new Error("Companion is starting. Try again shortly.");
86
+ await rm(lockDirectory, { recursive: true });
87
+ await mkdir(lockDirectory, { mode: 0o700 });
88
+ }
89
+ };
90
+ const serve = async (backgroundLog) => {
91
+ let bridge;
92
+ let closing = false;
93
+ let ownsLock = false;
94
+ const manager = createServer((request, response) => {
95
+ if (request.headers.origin || request.method !== "POST" || request.headers.authorization !== `Bearer ${token}`) {
96
+ response.writeHead(403).end();
97
+ return;
98
+ }
99
+ if (request.url !== "/status" && request.url !== "/stop") {
100
+ response.writeHead(404).end();
101
+ return;
102
+ }
103
+ response.setHeader("Content-Type", "application/json");
104
+ response.end(JSON.stringify({ service: "drawsy-companion-cli", pid: process.pid, version: manifest.version }));
105
+ if (request.url === "/stop")
106
+ void shutdown();
107
+ });
108
+ const token = randomBytes(32).toString("hex");
109
+ const shutdown = async () => {
110
+ if (closing)
111
+ return;
112
+ closing = true;
113
+ let exitCode = 0;
114
+ try {
115
+ await bridge?.close();
116
+ }
117
+ catch {
118
+ exitCode = 1;
119
+ }
120
+ finally {
121
+ await new Promise((resolve) => manager.close(() => resolve()));
122
+ if (ownsLock)
123
+ await rm(lockDirectory, { recursive: true, force: true });
124
+ process.exit(exitCode);
125
+ }
126
+ };
127
+ try {
128
+ if (await bridgePresent())
129
+ throw new Error("The desktop app or another Companion server is running. Quit it before starting the CLI.");
130
+ // The distributed CLI always uses the production web origin policy and loopback.
131
+ process.env.NODE_ENV = "production";
132
+ bridge = createDrawsyBridge({ host: "127.0.0.1", port: 3031, version: manifest.version });
133
+ await bridge.listen();
134
+ await acquire();
135
+ ownsLock = true;
136
+ if (backgroundLog) {
137
+ const output = createWriteStream(logPath, { flags: "a", mode: 0o600 });
138
+ output.on("error", () => { process.stderr.write("Companion could not write its background log.\n"); });
139
+ globalThis.console = new Console({ stdout: output, stderr: output });
140
+ }
141
+ await new Promise((resolve, reject) => {
142
+ manager.once("error", reject);
143
+ manager.listen(0, "127.0.0.1", resolve);
144
+ });
145
+ const address = manager.address();
146
+ if (!address || typeof address === "string")
147
+ throw new Error("CLI control server could not start.");
148
+ await writeFile(recordPath, JSON.stringify({ pid: process.pid, port: address.port, token, version: manifest.version }), { mode: 0o600 });
149
+ process.once("SIGINT", () => { void shutdown(); });
150
+ process.once("SIGTERM", () => { void shutdown(); });
151
+ console.log(`Drawsy Companion ${manifest.version} running at ${bridge.address}`);
152
+ process.send?.({ ready: true });
153
+ process.disconnect?.();
154
+ }
155
+ catch (error) {
156
+ // close also clears the bridge's timer when listen failed. Preserve the
157
+ // original startup failure (including an occupied port) during cleanup.
158
+ await bridge?.close().catch(() => undefined);
159
+ manager.close();
160
+ if (ownsLock)
161
+ await rm(lockDirectory, { recursive: true, force: true });
162
+ throw error;
163
+ }
164
+ };
165
+ const start = async () => {
166
+ const existing = await control("status");
167
+ if (existing) {
168
+ console.log(`Companion ${existing.version} is already running.`);
169
+ return;
170
+ }
171
+ if (await bridgePresent())
172
+ throw new Error("The desktop app or another Companion server is running. Quit it first; this command will not replace it.");
173
+ await mkdir(directory, { recursive: true, mode: 0o700 });
174
+ const output = await open(logPath, "a", 0o600);
175
+ try {
176
+ await new Promise((resolve, reject) => {
177
+ const child = spawn(process.execPath, [entry, "serve"], {
178
+ detached: true, windowsHide: true, cwd: homedir(),
179
+ stdio: ["ignore", output.fd, output.fd, "ipc"],
180
+ });
181
+ const timer = setTimeout(() => {
182
+ child.disconnect();
183
+ child.unref();
184
+ reject(new Error("Startup is taking longer than expected. Check drawsy-companion status and logs."));
185
+ }, 15_000);
186
+ child.once("error", (error) => { clearTimeout(timer); reject(error); });
187
+ child.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Companion exited (${code}). Run drawsy-companion logs.`)); });
188
+ child.once("message", () => {
189
+ clearTimeout(timer);
190
+ child.unref();
191
+ resolve();
192
+ });
193
+ });
194
+ }
195
+ finally {
196
+ await output.close();
197
+ }
198
+ console.log(`Companion ${manifest.version} is running in the background. Open https://drawsyai.com`);
199
+ };
200
+ const help = () => console.log(`Drawsy Companion ${manifest.version}
201
+
202
+ setup Start now and automatically when you sign in
203
+ start Start in the background for this login session
204
+ serve Run visibly in this terminal (Ctrl+C stops it)
205
+ stop Stop the CLI server; keep login startup configured
206
+ status Show server and automatic startup status
207
+ logs Show the latest local log output
208
+ autostart enable Enable startup at login
209
+ autostart disable Disable startup at login and stop its managed job
210
+
211
+ Requires Node.js 22 or later. Codex/OpenCode installation and login stay unchanged.
212
+ Update or remove the package with npm after disabling automatic startup.
213
+ Only the CLI's authenticated process is stopped; the desktop app is never killed.`);
214
+ try {
215
+ const [command = "help", option, ...extra] = process.argv.slice(2);
216
+ if (extra.length || (option && command !== "autostart" && !(command === "serve" && option === "--background-log")))
217
+ throw new Error("Unexpected arguments. Use drawsy-companion help.");
218
+ switch (command) {
219
+ case "serve":
220
+ await serve(option === "--background-log");
221
+ break;
222
+ case "start":
223
+ await start();
224
+ break;
225
+ case "stop": {
226
+ const running = await control("stop");
227
+ if (running) {
228
+ for (let attempt = 0; attempt < 50 && alive(running.pid); attempt++)
229
+ await new Promise((resolve) => setTimeout(resolve, 100));
230
+ if (alive(running.pid))
231
+ throw new Error("Shutdown is still in progress. Check status before restarting.");
232
+ }
233
+ console.log(running ? "Companion CLI stopped." : "Companion CLI is not running.");
234
+ break;
235
+ }
236
+ case "status": {
237
+ const running = await control("status");
238
+ const other = running ? null : await bridgePresent();
239
+ console.log(running ? `Companion CLI ${running.version} running.` : other ? `Another Companion server ${other.version ?? ""} is running.` : "Companion is not running.");
240
+ console.log(`Startup at login: ${await autostartEnabled() ? "enabled" : "disabled"}.`);
241
+ for (const engine of readLocalEngineStatus()) {
242
+ console.log(`${engine.name}: ${engine.id === "opencode" && process.platform === "win32" ? "not supported by Companion on Windows" : engine.installed ? `available${engine.version ? ` (${engine.version})` : ""}` : "not found"}.`);
243
+ }
244
+ break;
245
+ }
246
+ case "logs": {
247
+ try {
248
+ const file = await open(logPath, "r");
249
+ try {
250
+ const info = await file.stat();
251
+ const buffer = Buffer.alloc(Math.min(info.size, 64 * 1024));
252
+ const result = await file.read(buffer, 0, buffer.length, Math.max(0, info.size - buffer.length));
253
+ process.stdout.write(buffer.subarray(0, result.bytesRead));
254
+ }
255
+ finally {
256
+ await file.close();
257
+ }
258
+ }
259
+ catch (error) {
260
+ if (error.code !== "ENOENT")
261
+ throw error;
262
+ console.log("No background log yet. Foreground output appears in its terminal.");
263
+ }
264
+ break;
265
+ }
266
+ case "setup":
267
+ case "autostart": {
268
+ const action = command === "setup" ? "enable" : option;
269
+ if (action === "disable") {
270
+ await control("stop");
271
+ await disableAutostart();
272
+ console.log("Startup at login disabled; CLI shutdown requested.");
273
+ }
274
+ else if (action === "enable") {
275
+ if (await autostartEnabled() && await control("status")) {
276
+ console.log("Companion running; startup at login enabled.");
277
+ break;
278
+ }
279
+ if (await control("status") || await bridgePresent())
280
+ throw new Error("Stop Companion first, then run setup. Existing sessions will not be interrupted automatically.");
281
+ await mkdir(directory, { recursive: true, mode: 0o700 });
282
+ await enableAutostart(process.execPath, entry);
283
+ console.log("Startup at login enabled. Run drawsy-companion status to check readiness; open https://drawsyai.com when running.");
284
+ }
285
+ else
286
+ throw new Error("Use autostart enable or autostart disable.");
287
+ break;
288
+ }
289
+ case "help":
290
+ case "--help":
291
+ case "-h":
292
+ help();
293
+ break;
294
+ case "--version":
295
+ console.log(manifest.version);
296
+ break;
297
+ default: throw new Error(`Unknown command: ${command}. Use drawsy-companion help.`);
298
+ }
299
+ }
300
+ catch (error) {
301
+ console.error(error instanceof Error ? error.message : "Companion could not complete this command.");
302
+ process.exitCode = 1;
303
+ }