agent-relay-server 0.4.25 → 0.4.26

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
@@ -115,7 +115,7 @@ Launch with `AGENT_RELAY_PROFILE=backend-tester codex` or
115
115
  `AGENT_RELAY_PROFILE=backend-tester claude`. Explicit env vars override the
116
116
  profile where they overlap.
117
117
 
118
- Codex has additional tuning vars documented in [codex/README.md](codex/README.md).
118
+ Codex has additional tuning vars documented in [codex/README.md](codex/README.md). The interactive `codex-relay` launcher starts `codex app-server` and connects the TUI with `codex --remote`; `codex-relay --headless` starts a relay-only session and prints a `codex resume --remote` attach command.
119
119
 
120
120
  Agent IDs are deterministic: `{hostname}-{project}-{session-hash}`.
121
121
 
@@ -270,11 +270,21 @@ Lifecycle: `agent-relay daemon status|logs|restart|stop|start|enable|disable|uni
270
270
 
271
271
  ### Version compatibility
272
272
 
273
- The plugin checks the server version on startup and warns if they diverge. Both packages share version numbers. Update them together:
273
+ The provider integrations check the server version on startup and warn if they diverge. Server, Claude plugin, and Codex packages share version numbers. Upgrade a host with:
274
274
 
275
275
  ```bash
276
- systemctl --user restart agent-relay # server pulls latest on restart
277
- claude plugin update agent-relay@agent-relay # Claude plugin
276
+ agent-relay upgrade --dry-run
277
+ agent-relay upgrade --yes
278
+ ```
279
+
280
+ `agent-relay upgrade` detects optional providers. It refreshes Codex when `codex-relay` is installed, updates the Claude plugin when `claude` has `agent-relay@agent-relay` installed, and restarts the managed `agent-relay.service` unless `--no-restart` is passed.
281
+
282
+ Provider-specific aliases are available when you know what you want:
283
+
284
+ ```bash
285
+ agent-relay upgrade --providers codex
286
+ agent-relay upgrade --providers claude
287
+ agent-relay-codex upgrade --dry-run
278
288
  ```
279
289
 
280
290
  ## API Reference
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -16,6 +16,13 @@ import {
16
16
  formatSetupPlan,
17
17
  pathExists,
18
18
  } from "./setup";
19
+ import {
20
+ createUpgradePlan,
21
+ detectUpgradeSnapshot,
22
+ executeUpgradePlan,
23
+ formatUpgradePlan,
24
+ type UpgradeProvider,
25
+ } from "./upgrade";
19
26
  import { VERSION } from "./config";
20
27
 
21
28
  const HELP = `
@@ -24,6 +31,8 @@ agent-relay ${VERSION}
24
31
  Usage:
25
32
  agent-relay [start]
26
33
  agent-relay setup [--yes] [--dry-run] [--force] [--env-file PATH] [--host HOST] [--port PORT] [--db-path PATH] [--token TOKEN|--no-token]
34
+ agent-relay upgrade [--dry-run] [--version VERSION] [--providers auto|all|codex|claude] [--no-restart] [--yes]
35
+ agent-relay setup upgrade [same options as upgrade]
27
36
  agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]
28
37
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
29
38
  agent-relay message <target> <body> [options]
@@ -62,6 +71,13 @@ Daemon options:
62
71
  --yes Skip confirmation prompts
63
72
  --force Overwrite/remove managed-file guardrails
64
73
  --json Print structured output
74
+
75
+ Upgrade options:
76
+ --version VERSION Target version (default: latest published server version)
77
+ --providers LIST Provider integrations to upgrade: auto, all, codex, claude
78
+ --no-restart Do not restart agent-relay.service
79
+ --dry-run Print detected state and planned commands
80
+ --yes Skip confirmation prompts
65
81
  `.trim();
66
82
 
67
83
  const DAEMON_ACTIONS = new Set<DaemonAction>([
@@ -87,6 +103,10 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
87
103
  console.log(VERSION);
88
104
  return "handled";
89
105
  }
106
+ if (command === "upgrade" || (command === "setup" && args[1] === "upgrade")) {
107
+ await handleUpgradeCommand(command === "setup" ? args.slice(2) : args.slice(1));
108
+ return "handled";
109
+ }
90
110
  if (command === "setup" || command === "init") {
91
111
  await handleSetupCommand(args.slice(1));
92
112
  return "handled";
@@ -122,6 +142,68 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
122
142
  throw new Error(`Unknown command "${command}". Run agent-relay --help.`);
123
143
  }
124
144
 
145
+ async function handleUpgradeCommand(args: string[]): Promise<void> {
146
+ let targetVersion: string | undefined;
147
+ let dryRun = false;
148
+ let noRestart = false;
149
+ let yes = false;
150
+ let json = false;
151
+ const providers: UpgradeProvider[] = [];
152
+
153
+ for (let i = 0; i < args.length; i++) {
154
+ const arg = args[i];
155
+ if (arg === "--version" && i + 1 < args.length) targetVersion = args[++i];
156
+ else if (arg === "--providers" && i + 1 < args.length) {
157
+ for (const provider of args[++i]!.split(",")) providers.push(parseUpgradeProvider(provider));
158
+ } else if (arg === "--provider" && i + 1 < args.length) providers.push(parseUpgradeProvider(args[++i]!));
159
+ else if (arg === "--codex") providers.push("codex");
160
+ else if (arg === "--claude") providers.push("claude");
161
+ else if (arg === "--all") providers.push("all");
162
+ else if (arg === "--dry-run") dryRun = true;
163
+ else if (arg === "--no-restart") noRestart = true;
164
+ else if (arg === "--yes" || arg === "-y") yes = true;
165
+ else if (arg === "--json") json = true;
166
+ else throw new Error(`Unknown upgrade option "${arg}"`);
167
+ }
168
+
169
+ const snapshot = await detectUpgradeSnapshot({
170
+ ...(targetVersion ? { targetVersion } : {}),
171
+ providers,
172
+ noRestart,
173
+ });
174
+ const plan = createUpgradePlan(snapshot, {
175
+ ...(targetVersion ? { targetVersion } : {}),
176
+ providers,
177
+ noRestart,
178
+ });
179
+
180
+ if (json) {
181
+ console.log(JSON.stringify({ plan }, null, 2));
182
+ return;
183
+ }
184
+
185
+ if (dryRun) {
186
+ console.log(formatUpgradePlan(plan, { dryRun: true }));
187
+ return;
188
+ }
189
+
190
+ if (!yes) {
191
+ console.log(formatUpgradePlan(plan));
192
+ const ok = await confirm("Run this upgrade plan?");
193
+ if (!ok) {
194
+ console.log("Upgrade cancelled.");
195
+ return;
196
+ }
197
+ }
198
+
199
+ console.log(await executeUpgradePlan(plan));
200
+ }
201
+
202
+ function parseUpgradeProvider(value: string): UpgradeProvider {
203
+ if (value === "auto" || value === "all" || value === "codex" || value === "claude") return value;
204
+ throw new Error(`Unknown upgrade provider "${value}". Expected auto, all, codex, or claude.`);
205
+ }
206
+
125
207
  async function handleSlashOrPairCommand(command: string, args: string[]): Promise<void> {
126
208
  if (command === "/disconnect") {
127
209
  await handlePairCommand(["hangup", ...args]);
package/src/upgrade.ts ADDED
@@ -0,0 +1,378 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { VERSION } from "./config";
5
+
6
+ export type UpgradeProvider = "auto" | "all" | "codex" | "claude";
7
+
8
+ export type UpgradeOptions = {
9
+ targetVersion?: string;
10
+ providers?: UpgradeProvider[];
11
+ noRestart?: boolean;
12
+ };
13
+
14
+ export type InstalledPackage = {
15
+ version?: string;
16
+ source: "bun" | "npm" | "copied" | "claude-plugin";
17
+ path?: string;
18
+ };
19
+
20
+ export type ClaudePluginInstall = {
21
+ id: string;
22
+ version?: string;
23
+ scope?: string;
24
+ installPath?: string;
25
+ };
26
+
27
+ export type UpgradeSnapshot = {
28
+ targetVersion: string;
29
+ serverPackage?: InstalledPackage;
30
+ codexPackage?: InstalledPackage;
31
+ codexCopiedPackage?: InstalledPackage;
32
+ claudePluginInstalls: ClaudePluginInstall[];
33
+ hasCodexCommand: boolean;
34
+ hasClaudeCommand: boolean;
35
+ hasSystemdUserService: boolean;
36
+ runningServerVersion?: string;
37
+ packageManager: "bun" | "npm" | "none";
38
+ };
39
+
40
+ export type UpgradeAction = {
41
+ label: string;
42
+ command: string[];
43
+ reason: string;
44
+ mutates: boolean;
45
+ };
46
+
47
+ export type UpgradePlan = {
48
+ targetVersion: string;
49
+ providers: { codex: boolean; claude: boolean };
50
+ snapshot: UpgradeSnapshot;
51
+ actions: UpgradeAction[];
52
+ warnings: string[];
53
+ };
54
+
55
+ type CommandResult = {
56
+ exitCode: number;
57
+ stdout: string;
58
+ stderr: string;
59
+ };
60
+
61
+ type Runner = (command: string[]) => CommandResult;
62
+
63
+ export async function detectUpgradeSnapshot(options: UpgradeOptions = {}): Promise<UpgradeSnapshot> {
64
+ const targetVersion = options.targetVersion ?? await npmViewVersion("agent-relay-server@latest") ?? VERSION;
65
+ const bunPackages = commandExists("bun") ? bunGlobalPackages() : new Map<string, string>();
66
+ const npmPackages = commandExists("npm") ? npmGlobalPackages() : new Map<string, string>();
67
+ const codexCopiedPackage = readPackageVersion(join(homeDir(), ".agent-relay", "codex", "package", "package.json"));
68
+ const claudePluginInstalls = readClaudePluginInstalls(join(homeDir(), ".claude", "plugins", "installed_plugins.json"));
69
+
70
+ const packageManager = bunPackages.has("agent-relay-server") || bunPackages.has("agent-relay-codex")
71
+ ? "bun"
72
+ : npmPackages.has("agent-relay-server") || npmPackages.has("agent-relay-codex")
73
+ ? "npm"
74
+ : commandExists("bun")
75
+ ? "bun"
76
+ : commandExists("npm")
77
+ ? "npm"
78
+ : "none";
79
+
80
+ return {
81
+ targetVersion,
82
+ serverPackage: installedPackage("agent-relay-server", bunPackages, npmPackages),
83
+ codexPackage: installedPackage("agent-relay-codex", bunPackages, npmPackages),
84
+ codexCopiedPackage: codexCopiedPackage
85
+ ? { version: codexCopiedPackage, source: "copied", path: join(homeDir(), ".agent-relay", "codex", "package") }
86
+ : undefined,
87
+ claudePluginInstalls,
88
+ hasCodexCommand: commandExists("agent-relay-codex") || commandExists("codex-relay") || existsSync(join(homeDir(), ".agent-relay", "codex", "package")),
89
+ hasClaudeCommand: commandExists("claude"),
90
+ hasSystemdUserService: hasSystemdUserService("agent-relay.service"),
91
+ runningServerVersion: await runningServerVersion(),
92
+ packageManager,
93
+ };
94
+ }
95
+
96
+ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOptions = {}): UpgradePlan {
97
+ const targetVersion = options.targetVersion ?? snapshot.targetVersion;
98
+ const requestedProviders = options.providers?.length ? options.providers : ["auto"];
99
+ const providerSet = new Set(requestedProviders);
100
+ const codexRequested = providerSet.has("all") || providerSet.has("codex") || (providerSet.has("auto") && isCodexDetected(snapshot));
101
+ const claudeRequested = providerSet.has("all") || providerSet.has("claude") || (providerSet.has("auto") && isClaudeRelayDetected(snapshot));
102
+ const actions: UpgradeAction[] = [];
103
+ const warnings: string[] = [];
104
+
105
+ if (snapshot.packageManager === "none") {
106
+ warnings.push("No supported global package manager found. Install Bun or npm, then rerun `agent-relay upgrade`.");
107
+ } else {
108
+ const packages = [`agent-relay-server@${targetVersion}`];
109
+ if (codexRequested) packages.push(`agent-relay-codex@${targetVersion}`);
110
+ const command = snapshot.packageManager === "bun"
111
+ ? ["bun", "add", "-g", ...packages]
112
+ : ["npm", "install", "-g", ...packages];
113
+ actions.push({
114
+ label: "Upgrade global packages",
115
+ command,
116
+ reason: `Update server${codexRequested ? " and Codex integration" : ""} packages to ${targetVersion}.`,
117
+ mutates: true,
118
+ });
119
+ }
120
+
121
+ if (codexRequested) {
122
+ actions.push({
123
+ label: "Refresh Codex relay install",
124
+ command: ["agent-relay-codex", "install", "--alias"],
125
+ reason: "Refresh copied Codex package, launcher shims, hooks, and plugin marketplace files.",
126
+ mutates: true,
127
+ });
128
+ } else if (providerSet.has("auto") && !isCodexDetected(snapshot)) {
129
+ warnings.push("Codex provider not detected; skipping Codex integration upgrade.");
130
+ }
131
+
132
+ if (claudeRequested) {
133
+ if (snapshot.hasClaudeCommand && snapshot.claudePluginInstalls.length > 0) {
134
+ for (const scope of uniqueStrings(snapshot.claudePluginInstalls.map((install) => install.scope || "user"))) {
135
+ actions.push({
136
+ label: `Update Claude plugin (${scope})`,
137
+ command: ["claude", "plugin", "update", "agent-relay@agent-relay", "--scope", scope],
138
+ reason: "Update installed Claude Code Agent Relay plugin.",
139
+ mutates: true,
140
+ });
141
+ }
142
+ } else if (!snapshot.hasClaudeCommand) {
143
+ warnings.push("Claude Code command not detected; skipping Claude plugin upgrade.");
144
+ } else {
145
+ warnings.push("Claude Code detected, but agent-relay@agent-relay plugin is not installed; skipping Claude plugin upgrade.");
146
+ }
147
+ } else if (providerSet.has("auto") && !isClaudeRelayDetected(snapshot)) {
148
+ warnings.push("Claude Agent Relay plugin not detected; skipping Claude plugin upgrade.");
149
+ }
150
+
151
+ if (snapshot.hasSystemdUserService) {
152
+ if (options.noRestart) {
153
+ warnings.push("agent-relay.service detected but --no-restart was set; restart manually to run the upgraded server.");
154
+ } else {
155
+ actions.push({
156
+ label: "Restart Agent Relay service",
157
+ command: ["systemctl", "--user", "restart", "agent-relay.service"],
158
+ reason: "Restart managed server so /api/stats reports the upgraded version.",
159
+ mutates: true,
160
+ });
161
+ }
162
+ } else {
163
+ warnings.push("No systemd user service detected; restart any manually running Agent Relay server yourself.");
164
+ }
165
+
166
+ return {
167
+ targetVersion,
168
+ providers: { codex: codexRequested, claude: claudeRequested },
169
+ snapshot: { ...snapshot, targetVersion },
170
+ actions,
171
+ warnings,
172
+ };
173
+ }
174
+
175
+ export async function executeUpgradePlan(plan: UpgradePlan, options: { dryRun?: boolean; runner?: Runner } = {}): Promise<string> {
176
+ if (options.dryRun) return formatUpgradePlan(plan, { dryRun: true });
177
+ const runner = options.runner ?? runCommand;
178
+ const lines = [`Upgrading Agent Relay to ${plan.targetVersion}`];
179
+ for (const action of plan.actions) {
180
+ lines.push(`\n${action.label}`);
181
+ lines.push(`$ ${action.command.map(shellQuote).join(" ")}`);
182
+ const result = runner(action.command);
183
+ if (result.stdout.trim()) lines.push(result.stdout.trim());
184
+ if (result.stderr.trim()) lines.push(result.stderr.trim());
185
+ if (result.exitCode !== 0) {
186
+ throw new Error(`${action.label} failed with exit code ${result.exitCode}`);
187
+ }
188
+ }
189
+ if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay.service")) {
190
+ await new Promise((resolve) => setTimeout(resolve, 1000));
191
+ const serverVersion = await runningServerVersion();
192
+ if (serverVersion && serverVersion !== plan.targetVersion) {
193
+ throw new Error(`agent-relay.service restarted but /api/stats reports ${serverVersion}, expected ${plan.targetVersion}`);
194
+ }
195
+ if (serverVersion) lines.push(`Running server: ${serverVersion}`);
196
+ }
197
+ lines.push("\nUpgrade commands completed.");
198
+ if (plan.warnings.length > 0) {
199
+ lines.push("\nWarnings:");
200
+ for (const warning of plan.warnings) lines.push(`- ${warning}`);
201
+ }
202
+ return lines.join("\n");
203
+ }
204
+
205
+ export function formatUpgradePlan(plan: UpgradePlan, options: { dryRun?: boolean } = {}): string {
206
+ const lines = [
207
+ `${options.dryRun ? "Upgrade dry run" : "Upgrade plan"}: Agent Relay ${plan.targetVersion}`,
208
+ "",
209
+ "Detected:",
210
+ `- server package: ${formatPackage(plan.snapshot.serverPackage)}`,
211
+ `- running server: ${plan.snapshot.runningServerVersion ?? "unknown"}`,
212
+ `- codex package: ${formatPackage(plan.snapshot.codexPackage)}`,
213
+ `- codex copied package: ${formatPackage(plan.snapshot.codexCopiedPackage)}`,
214
+ `- claude command: ${plan.snapshot.hasClaudeCommand ? "yes" : "no"}`,
215
+ `- claude agent-relay plugin: ${formatClaudePlugins(plan.snapshot.claudePluginInstalls)}`,
216
+ `- systemd user service: ${plan.snapshot.hasSystemdUserService ? "yes" : "no"}`,
217
+ "",
218
+ `Providers: codex=${plan.providers.codex ? "yes" : "no"}, claude=${plan.providers.claude ? "yes" : "no"}`,
219
+ "",
220
+ "Actions:",
221
+ ];
222
+ if (plan.actions.length === 0) lines.push("- none");
223
+ for (const action of plan.actions) {
224
+ lines.push(`- ${action.label}: ${action.reason}`);
225
+ lines.push(` ${action.command.map(shellQuote).join(" ")}`);
226
+ }
227
+ if (plan.warnings.length > 0) {
228
+ lines.push("", "Warnings:");
229
+ for (const warning of plan.warnings) lines.push(`- ${warning}`);
230
+ }
231
+ return lines.join("\n");
232
+ }
233
+
234
+ function isCodexDetected(snapshot: UpgradeSnapshot): boolean {
235
+ return Boolean(snapshot.codexPackage || snapshot.codexCopiedPackage || snapshot.hasCodexCommand);
236
+ }
237
+
238
+ function isClaudeRelayDetected(snapshot: UpgradeSnapshot): boolean {
239
+ return snapshot.claudePluginInstalls.length > 0;
240
+ }
241
+
242
+ function installedPackage(name: string, bunPackages: Map<string, string>, npmPackages: Map<string, string>): InstalledPackage | undefined {
243
+ const bunVersion = bunPackages.get(name);
244
+ if (bunVersion) return { version: bunVersion, source: "bun" };
245
+ const npmVersion = npmPackages.get(name);
246
+ if (npmVersion) return { version: npmVersion, source: "npm" };
247
+ return undefined;
248
+ }
249
+
250
+ function bunGlobalPackages(): Map<string, string> {
251
+ const result = runCommand(["bun", "pm", "ls", "-g"]);
252
+ const packages = new Map<string, string>();
253
+ for (const line of result.stdout.split("\n")) {
254
+ const match = line.match(/(?:├──|└──)\s+(@?[^@\s]+(?:\/[^@\s]+)?)@([^\s]+)/);
255
+ if (match?.[1] && match[2]) packages.set(match[1], match[2]);
256
+ }
257
+ return packages;
258
+ }
259
+
260
+ function npmGlobalPackages(): Map<string, string> {
261
+ const result = runCommand(["npm", "list", "-g", "--depth=0", "--json"]);
262
+ const packages = new Map<string, string>();
263
+ if (result.exitCode !== 0 && !result.stdout.trim()) return packages;
264
+ try {
265
+ const parsed = JSON.parse(result.stdout) as { dependencies?: Record<string, { version?: string }> };
266
+ for (const [name, dep] of Object.entries(parsed.dependencies ?? {})) {
267
+ if (dep.version) packages.set(name, dep.version);
268
+ }
269
+ } catch {
270
+ // Ignore unparsable npm output; the plan can still use other signals.
271
+ }
272
+ return packages;
273
+ }
274
+
275
+ async function npmViewVersion(spec: string): Promise<string | undefined> {
276
+ if (!commandExists("npm")) return undefined;
277
+ const result = runCommand(["npm", "view", spec, "version"]);
278
+ return result.exitCode === 0 ? result.stdout.trim() || undefined : undefined;
279
+ }
280
+
281
+ async function runningServerVersion(): Promise<string | undefined> {
282
+ try {
283
+ const headers: Record<string, string> = {};
284
+ if (process.env.AGENT_RELAY_TOKEN) headers["X-Agent-Relay-Token"] = process.env.AGENT_RELAY_TOKEN;
285
+ const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
286
+ const response = await fetch(`${relayUrl}/api/stats`, { headers });
287
+ if (!response.ok) return undefined;
288
+ const payload = await response.json() as { version?: string };
289
+ return payload.version;
290
+ } catch {
291
+ return undefined;
292
+ }
293
+ }
294
+
295
+ function hasSystemdUserService(name: string): boolean {
296
+ if (!commandExists("systemctl")) return false;
297
+ const result = runCommand(["systemctl", "--user", "status", name, "--no-pager"]);
298
+ return result.exitCode === 0 || result.stdout.includes("Loaded: loaded") || result.stderr.includes("Loaded: loaded");
299
+ }
300
+
301
+ function readPackageVersion(path: string): string | undefined {
302
+ if (!existsSync(path)) return undefined;
303
+ try {
304
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { version?: string };
305
+ return parsed.version;
306
+ } catch {
307
+ return undefined;
308
+ }
309
+ }
310
+
311
+ function readClaudePluginInstalls(path: string): ClaudePluginInstall[] {
312
+ if (!existsSync(path)) return [];
313
+ try {
314
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as {
315
+ plugins?: Record<string, Array<{ scope?: string; installPath?: string; version?: string }>>;
316
+ };
317
+ return (parsed.plugins?.["agent-relay@agent-relay"] ?? []).map((install) => ({
318
+ id: "agent-relay@agent-relay",
319
+ version: install.version,
320
+ scope: install.scope,
321
+ installPath: install.installPath,
322
+ }));
323
+ } catch {
324
+ return [];
325
+ }
326
+ }
327
+
328
+ function commandExists(command: string): boolean {
329
+ const result = runCommand(["which", command]);
330
+ if (result.exitCode === 0) return true;
331
+ return [
332
+ join(homeDir(), ".local", "bin", command),
333
+ join(homeDir(), ".bun", "bin", command),
334
+ join(homeDir(), ".npm-global", "bin", command),
335
+ join(homeDir(), ".agent-relay", "codex", "bin", command),
336
+ ].some((path) => existsSync(path));
337
+ }
338
+
339
+ function runCommand(command: string[]): CommandResult {
340
+ try {
341
+ const result = Bun.spawnSync(command, { stdout: "pipe", stderr: "pipe" });
342
+ return {
343
+ exitCode: result.exitCode,
344
+ stdout: result.stdout?.toString() ?? "",
345
+ stderr: result.stderr?.toString() ?? "",
346
+ };
347
+ } catch (error) {
348
+ return {
349
+ exitCode: 127,
350
+ stdout: "",
351
+ stderr: error instanceof Error ? error.message : String(error),
352
+ };
353
+ }
354
+ }
355
+
356
+ function homeDir(): string {
357
+ return process.env.HOME || process.env.USERPROFILE || homedir();
358
+ }
359
+
360
+ function uniqueStrings(values: string[]): string[] {
361
+ return [...new Set(values.filter(Boolean))];
362
+ }
363
+
364
+ function formatPackage(pkg: InstalledPackage | undefined): string {
365
+ if (!pkg) return "not detected";
366
+ const path = pkg.path ? ` at ${pkg.path}` : "";
367
+ return `${pkg.version ?? "unknown"} (${pkg.source}${path})`;
368
+ }
369
+
370
+ function formatClaudePlugins(installs: ClaudePluginInstall[]): string {
371
+ if (installs.length === 0) return "not detected";
372
+ return installs.map((install) => `${install.version ?? "unknown"} (${install.scope ?? "user"})`).join(", ");
373
+ }
374
+
375
+ function shellQuote(value: string): string {
376
+ if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
377
+ return `'${value.replaceAll("'", "'\\''")}'`;
378
+ }