friend-assistant 0.1.0 → 0.1.2

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/dist/friend.mjs CHANGED
@@ -2111,6 +2111,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2111
2111
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2112
2112
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2113
2113
  import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
2114
+ import crossSpawn from "cross-spawn";
2114
2115
  import { createHash } from "node:crypto";
2115
2116
 
2116
2117
  // ../server/src/connectors/store.ts
@@ -2235,6 +2236,20 @@ function friendlyToolName(original, taken) {
2235
2236
  taken.add(out);
2236
2237
  return out;
2237
2238
  }
2239
+ function killTree(pid) {
2240
+ try {
2241
+ if (process.platform === "win32") crossSpawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {
2242
+ });
2243
+ else process.kill(pid, "SIGKILL");
2244
+ } catch {
2245
+ }
2246
+ }
2247
+ async function stopLive(l) {
2248
+ const pid = l.transport?.pid ?? null;
2249
+ await Promise.race([l.client.close().catch(() => {
2250
+ }), new Promise((r) => setTimeout(r, 3e3).unref?.())]);
2251
+ if (pid) killTree(pid);
2252
+ }
2238
2253
  var messageOf = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 400);
2239
2254
  var sleepReject = (ms, what) => new Promise((_, rej) => {
2240
2255
  const t = setTimeout(() => rej(new Error(what)), ms);
@@ -2374,13 +2389,14 @@ ${l.stderr}` : ""}`, isError: true };
2374
2389
  entry.connecting = (async () => {
2375
2390
  try {
2376
2391
  const transport = this.transportFor(cfg, entry);
2392
+ entry.transport = transport;
2377
2393
  client.onclose = () => {
2378
2394
  if (this.live.get(id2) === entry && entry.status === "ready") {
2379
2395
  entry.status = "error";
2380
2396
  entry.error = "The connection closed.";
2381
2397
  }
2382
2398
  };
2383
- await Promise.race([client.connect(transport), sleepReject(this.opts.connectTimeoutMs ?? 3e4, "It did not answer in time.")]);
2399
+ await Promise.race([client.connect(transport), sleepReject(this.opts.connectTimeoutMs ?? 6e4, "It did not answer in time.")]);
2384
2400
  await this.readTools(id2, entry);
2385
2401
  client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
2386
2402
  try {
@@ -2393,10 +2409,7 @@ ${l.stderr}` : ""}`, isError: true };
2393
2409
  } catch (e) {
2394
2410
  entry.status = "error";
2395
2411
  entry.error = `${messageOf(e)}${entry.stderr ? ` (${entry.stderr.trim().split("\n").slice(-2).join(" ").slice(0, 200)})` : ""}`;
2396
- try {
2397
- await client.close();
2398
- } catch {
2399
- }
2412
+ await stopLive(entry);
2400
2413
  throw new Error(entry.error);
2401
2414
  } finally {
2402
2415
  entry.connecting = void 0;
@@ -2447,10 +2460,7 @@ ${l.stderr}` : ""}`, isError: true };
2447
2460
  this.forget(id2);
2448
2461
  if (!l) return;
2449
2462
  l.client.onclose = void 0;
2450
- try {
2451
- await l.client.close();
2452
- } catch {
2453
- }
2463
+ await stopLive(l);
2454
2464
  }
2455
2465
  };
2456
2466
  function flatten(content) {
@@ -2473,11 +2483,12 @@ var PRESETS = [
2473
2483
  id: "chrome",
2474
2484
  name: "Chrome control",
2475
2485
  description: "Lets Friend open pages, read them, click and type in your own Chrome, through your chrome-control extension.",
2476
- connector: { id: "chrome", name: "Chrome control", kind: "stdio", command: "chrome-control-mcp", args: [] },
2486
+ quick: true,
2487
+ connector: { id: "chrome", name: "Chrome control", kind: "stdio", command: "npx", args: ["-y", "chrome-control-mcp"] },
2477
2488
  setup: [
2478
- "Install it once: npm install -g chrome-control-mcp",
2479
- "Run chrome-control-mcp setup, then load the extension it points to in chrome://extensions (Developer mode, Load unpacked).",
2480
- "Switch the extension on in Chrome. Friend starts the program itself when you add the connector."
2489
+ "Press Connect. Friend checks that Chrome control is installed and, if it is, adds it for you: it runs npx -y chrome-control-mcp, the same as claude mcp add chrome-control -- npx -y chrome-control-mcp.",
2490
+ "If it is not installed yet, Friend says so and can install it for you in the background (npx -y chrome-control-mcp setup).",
2491
+ "The one step nobody can do for you, because Chrome does not allow silent installs: load the extension in chrome://extensions (Developer mode, Load unpacked) and switch it on."
2481
2492
  ],
2482
2493
  warning: "While the extension is on, Friend can act as you on any site it allows. Every action still asks for your approval; in the extension's popup you can limit it to certain sites."
2483
2494
  },
@@ -2496,6 +2507,122 @@ var PRESETS = [
2496
2507
  }
2497
2508
  ];
2498
2509
 
2510
+ // ../server/src/connectors/quick.ts
2511
+ import { existsSync as existsSync4 } from "node:fs";
2512
+ import { homedir } from "node:os";
2513
+ import { join as join6 } from "node:path";
2514
+ var find = (id2) => PRESETS.find((p) => p.id === id2 && p.quick);
2515
+ var isQuickPreset = (id2) => !!find(id2);
2516
+ var MANUAL_EXTENSION = "One step only you can do, because Chrome does not allow programs to install extensions silently: open chrome://extensions, turn on Developer mode (top right), click Load unpacked and choose the folder shown above. Then click the extension's icon and switch it on.";
2517
+ function chromeFolder(home) {
2518
+ return join6(home, ".chrome-control-mcp");
2519
+ }
2520
+ function chromeInstalled(home = homedir()) {
2521
+ const base = chromeFolder(home);
2522
+ const ext = join6(base, "extension");
2523
+ const installed = existsSync4(join6(base, "token")) && existsSync4(join6(ext, "manifest.json")) && existsSync4(join6(base, "native-host"));
2524
+ return { installed, extensionFolder: installed ? ext : null };
2525
+ }
2526
+ function quickCheck(id2, deps = {}) {
2527
+ const preset = find(id2);
2528
+ if (!preset) return null;
2529
+ const c = chromeInstalled(deps.home);
2530
+ if (c.installed) {
2531
+ return { preset: id2, name: preset.name, ready: true, message: "Chrome control is installed on this PC.", installCommand: null, manualStep: null, extensionFolder: c.extensionFolder };
2532
+ }
2533
+ return {
2534
+ preset: id2,
2535
+ name: preset.name,
2536
+ ready: false,
2537
+ message: "Chrome control is not installed on this PC yet. Please install it first, then press Connect again.",
2538
+ installCommand: "npx -y chrome-control-mcp setup",
2539
+ manualStep: MANUAL_EXTENSION,
2540
+ extensionFolder: null
2541
+ };
2542
+ }
2543
+ async function quickInstall(id2, deps, onLine) {
2544
+ if (!find(id2)) return { ok: false, output: "", extensionFolder: null, message: "That is not a one-click connector." };
2545
+ const lines = [];
2546
+ const r = await deps.run("npx", ["-y", "chrome-control-mcp", "setup", "--no-claude", "--no-open", "--no-flag"], {
2547
+ onLine: (l) => {
2548
+ lines.push(l);
2549
+ onLine?.(l);
2550
+ }
2551
+ });
2552
+ const after = chromeInstalled(deps.home);
2553
+ const output = (lines.length ? lines : r.stdout.split(/\r?\n/)).filter((l) => l.trim()).slice(-12).join("\n");
2554
+ if (r.code !== 0 || !after.installed) {
2555
+ return { ok: false, output, extensionFolder: null, message: "The install did not finish. You can run it yourself in a terminal: npx -y chrome-control-mcp setup" };
2556
+ }
2557
+ return { ok: true, output, extensionFolder: after.extensionFolder, message: `Chrome control is installed. ${MANUAL_EXTENSION}` };
2558
+ }
2559
+ function readExtensionState(text2) {
2560
+ try {
2561
+ const o = JSON.parse(text2);
2562
+ if (typeof o.extension === "boolean") return o.extension ? "connected" : "off";
2563
+ if (typeof o.connected === "boolean") return o.connected ? "connected" : "off";
2564
+ } catch {
2565
+ }
2566
+ const t = text2.toLowerCase();
2567
+ if (/not connected|disconnected|no extension|extension is (off|not)|waiting for (the )?extension|not enabled|disabled/.test(t)) return "off";
2568
+ if (/extension[^.\n]*connected|connected[^.\n]*extension|"connected"\s*:\s*true|bridge[^.\n]*connected/.test(t)) return "connected";
2569
+ return "unknown";
2570
+ }
2571
+ async function quickConnect(id2, deps) {
2572
+ const preset = find(id2);
2573
+ if (!preset) return { ok: false, connector: null, extension: "unknown", message: "That is not a one-click connector." };
2574
+ const check3 = quickCheck(id2, deps);
2575
+ if (!check3.ready) return { ok: false, connector: null, extension: "unknown", message: check3.message };
2576
+ const { hub } = deps;
2577
+ const existing = hub.list().find((c) => c.id === preset.connector.id);
2578
+ const view = existing ? existing.enabled ? await hub.reload(existing.id) : await hub.setEnabled(existing.id, true) : await hub.add(preset.connector);
2579
+ const connector = { id: view.id, name: view.name, status: view.status, error: view.error, tools: view.tools };
2580
+ if (view.status !== "ready") {
2581
+ return { ok: false, connector, extension: "unknown", message: `${preset.name} is saved, but it did not start: ${view.error ?? "unknown error"}` };
2582
+ }
2583
+ const statusTool = hub.toolSpecs().find((t) => t.name === `mcp.${view.id}.status`);
2584
+ let extension = "unknown";
2585
+ if (statusTool) {
2586
+ const r = await hub.call(statusTool.name, {});
2587
+ if (!r.isError) extension = readExtensionState(r.text);
2588
+ }
2589
+ const base = `${preset.name} is on with ${view.tools} tools.`;
2590
+ const message = extension === "connected" ? `${base} Your Chrome extension is connected. Friend can use Chrome now (it still asks before each action).` : extension === "off" ? `${base} But the Chrome extension is not connected yet. Click its icon in Chrome and switch it on (if it is not loaded: chrome://extensions, Developer mode, Load unpacked, then choose ${check3.extensionFolder}).` : `${base} Check that the Chrome extension is loaded and switched on (click its icon in Chrome).`;
2591
+ return { ok: true, connector, extension, message };
2592
+ }
2593
+
2594
+ // ../server/src/connectors/run.ts
2595
+ import crossSpawn2 from "cross-spawn";
2596
+ var LIMIT_MS = 5 * 6e4;
2597
+ var runCommand = (cmd, args, opts) => new Promise((resolve10) => {
2598
+ let stdout = "";
2599
+ let child;
2600
+ try {
2601
+ child = crossSpawn2(cmd, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
2602
+ } catch (e) {
2603
+ resolve10({ code: 127, stdout: e.message });
2604
+ return;
2605
+ }
2606
+ const timer = setTimeout(() => child.kill(), LIMIT_MS);
2607
+ const feed = (d) => {
2608
+ const s = d.toString();
2609
+ stdout += s;
2610
+ if (opts?.onLine) {
2611
+ for (const line of s.split(/\r?\n/)) if (line.trim()) opts.onLine(line);
2612
+ }
2613
+ };
2614
+ child.stdout?.on("data", feed);
2615
+ child.stderr?.on("data", feed);
2616
+ child.on("error", (e) => {
2617
+ clearTimeout(timer);
2618
+ resolve10({ code: 127, stdout: stdout + e.message });
2619
+ });
2620
+ child.on("close", (code) => {
2621
+ clearTimeout(timer);
2622
+ resolve10({ code: code ?? 1, stdout });
2623
+ });
2624
+ });
2625
+
2499
2626
  // ../server/src/approvals.ts
2500
2627
  import { randomUUID as randomUUID2 } from "node:crypto";
2501
2628
 
@@ -2927,7 +3054,7 @@ import { spawn } from "node:child_process";
2927
3054
  import { randomUUID as randomUUID4 } from "node:crypto";
2928
3055
  import { chmodSync as chmodSync2, mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
2929
3056
  import { tmpdir } from "node:os";
2930
- import { join as join6 } from "node:path";
3057
+ import { join as join7 } from "node:path";
2931
3058
  var MAX_CODE_CHARS = 5e4;
2932
3059
  var MAX_OUTPUT_CHARS = 2e4;
2933
3060
  var MAX_EXTRA_FILES = 10;
@@ -2987,15 +3114,15 @@ var CodeBox = class {
2987
3114
  if (extra.reduce((sum, [, body]) => sum + body.length, 0) > MAX_EXTRA_FILE_CHARS) return { ok: false, error: "The sandbox files are too large." };
2988
3115
  const setup = SETUP[input.language];
2989
3116
  const timeoutMs = input.timeoutSec && input.timeoutSec > 0 ? Math.min(input.timeoutSec * 1e3, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
2990
- const dir = mkdtempSync2(join6(tmpdir(), "friend-code-"));
3117
+ const dir = mkdtempSync2(join7(tmpdir(), "friend-code-"));
2991
3118
  try {
2992
- const file = join6(dir, setup.file);
3119
+ const file = join7(dir, setup.file);
2993
3120
  writeFileSync3(file, input.code);
2994
3121
  chmodSync2(dir, 493);
2995
3122
  chmodSync2(file, 420);
2996
3123
  for (const [name, body] of extra) {
2997
- writeFileSync3(join6(dir, name), body);
2998
- chmodSync2(join6(dir, name), 420);
3124
+ writeFileSync3(join7(dir, name), body);
3125
+ chmodSync2(join7(dir, name), 420);
2999
3126
  }
3000
3127
  const args = [
3001
3128
  "run",
@@ -3130,12 +3257,12 @@ var Memory = class {
3130
3257
  // ../server/src/app.ts
3131
3258
  import { dirname as dirname3, resolve as resolve2 } from "node:path";
3132
3259
  import { fileURLToPath } from "node:url";
3133
- import { join as join10 } from "node:path";
3260
+ import { join as join12 } from "node:path";
3134
3261
 
3135
3262
  // ../server/src/config.ts
3136
- import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
3137
- import { homedir } from "node:os";
3138
- import { join as join7 } from "node:path";
3263
+ import { chmodSync as chmodSync3, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
3264
+ import { homedir as homedir2 } from "node:os";
3265
+ import { join as join8 } from "node:path";
3139
3266
  var DEFAULT_CLAUDE_CODE = { model: "sonnet", command: "claude", fallback: "ollama" };
3140
3267
  function sanitizeClaudeCode(raw) {
3141
3268
  const r = raw && typeof raw === "object" ? raw : {};
@@ -3208,12 +3335,13 @@ var DEFAULT_DESTRUCTIVE_PATTERNS = [
3208
3335
  String.raw`\b(iex|invoke-expression)\b`
3209
3336
  ];
3210
3337
  function defaultDataDir() {
3211
- return process.env.FRIEND_HOME ?? join7(homedir(), ".friend");
3338
+ return process.env.FRIEND_HOME ?? join8(homedir2(), ".friend");
3212
3339
  }
3213
3340
  var defaults = (dataDir) => ({
3214
3341
  bind: "127.0.0.1",
3215
3342
  port: 8787,
3216
3343
  publicUrl: null,
3344
+ https: false,
3217
3345
  trustProxy: false,
3218
3346
  allowedOrigins: [],
3219
3347
  allowedHosts: [],
@@ -3230,7 +3358,7 @@ var defaults = (dataDir) => ({
3230
3358
  dataDir
3231
3359
  });
3232
3360
  function configPath(dataDir) {
3233
- return join7(dataDir, "config.json");
3361
+ return join8(dataDir, "config.json");
3234
3362
  }
3235
3363
  function saveConfig(cfg) {
3236
3364
  const { dataDir, ...rest } = cfg;
@@ -3245,7 +3373,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3245
3373
  mkdirSync4(dataDir, { recursive: true });
3246
3374
  const file = configPath(dataDir);
3247
3375
  const base = defaults(dataDir);
3248
- if (!existsSync4(file)) {
3376
+ if (!existsSync5(file)) {
3249
3377
  const { dataDir: _d, ...rest } = base;
3250
3378
  saveConfig({ ...rest, dataDir });
3251
3379
  return base;
@@ -3255,6 +3383,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3255
3383
  return {
3256
3384
  ...base,
3257
3385
  ...stored,
3386
+ https: stored.https === true,
3258
3387
  destructivePatterns,
3259
3388
  ollama: { ...DEFAULT_OLLAMA, ...stored.ollama },
3260
3389
  claudeCode: sanitizeClaudeCode(stored.claudeCode),
@@ -3268,7 +3397,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3268
3397
  // ../server/src/db.ts
3269
3398
  import Database from "better-sqlite3";
3270
3399
  import { mkdirSync as mkdirSync5 } from "node:fs";
3271
- import { join as join8 } from "node:path";
3400
+ import { join as join9 } from "node:path";
3272
3401
 
3273
3402
  // ../server/src/migrations.ts
3274
3403
  var MIGRATIONS = [
@@ -3385,7 +3514,7 @@ create table plugins (
3385
3514
  // ../server/src/db.ts
3386
3515
  function openDb(dataDir) {
3387
3516
  mkdirSync5(dataDir, { recursive: true });
3388
- const db = new Database(join8(dataDir, "friend.db"));
3517
+ const db = new Database(join9(dataDir, "friend.db"));
3389
3518
  db.pragma("journal_mode = WAL");
3390
3519
  db.pragma("foreign_keys = ON");
3391
3520
  db.exec("create table if not exists schema_version (id integer primary key, applied_at integer not null)");
@@ -3483,7 +3612,7 @@ var DeviceHub = class {
3483
3612
  import fastifyCookie from "@fastify/cookie";
3484
3613
  import fastifyStatic from "@fastify/static";
3485
3614
  import Fastify from "fastify";
3486
- import { existsSync as existsSync5 } from "node:fs";
3615
+ import { existsSync as existsSync6 } from "node:fs";
3487
3616
  import { isIP } from "node:net";
3488
3617
  import { WebSocket, WebSocketServer } from "ws";
3489
3618
  import { z as z10 } from "zod";
@@ -3558,7 +3687,7 @@ function createGateway(deps) {
3558
3687
  forceCloseConnections: true
3559
3688
  // shutdown must not wait for idle keep-alive connections
3560
3689
  };
3561
- const app = Fastify(options);
3690
+ const app = Fastify(deps.tls ? { ...options, https: { key: deps.tls.key, cert: deps.tls.cert } } : options);
3562
3691
  app.register(fastifyCookie);
3563
3692
  const hostOf = (header) => {
3564
3693
  const h = (header ?? "").trim().toLowerCase();
@@ -3768,6 +3897,24 @@ function createGateway(deps) {
3768
3897
  return connectorError(reply, e, "add");
3769
3898
  }
3770
3899
  });
3900
+ app.get("/api/connectors/quick/:preset", { onRequest: connectorGuard }, async (req, reply) => {
3901
+ try {
3902
+ return deps.connectors.quickCheck(req.params.preset);
3903
+ } catch (e) {
3904
+ return connectorError(reply, e, "quick-check");
3905
+ }
3906
+ });
3907
+ for (const action of ["install", "connect"]) {
3908
+ app.post(`/api/connectors/quick/:preset/${action}`, { onRequest: connectorGuard }, async (req, reply) => {
3909
+ const body = z10.object({ pin: z10.string().min(1).max(32) }).safeParse(req.body);
3910
+ if (!body.success) return reply.code(400).send({ error: "Enter your PIN to continue" });
3911
+ try {
3912
+ return await (action === "install" ? deps.connectors.quickInstall : deps.connectors.quickConnect)(req.params.preset, body.data.pin, req.ip);
3913
+ } catch (e) {
3914
+ return connectorError(reply, e, `quick-${action}`);
3915
+ }
3916
+ });
3917
+ }
3771
3918
  app.post("/api/connectors/:id/enabled", { onRequest: connectorGuard }, async (req, reply) => {
3772
3919
  const body = z10.object({ enabled: z10.boolean(), pin: z10.string().min(1).max(32).optional() }).safeParse(req.body);
3773
3920
  if (!body.success) return reply.code(400).send({ error: "Invalid input" });
@@ -3903,7 +4050,7 @@ function createGateway(deps) {
3903
4050
  deps.onLock?.();
3904
4051
  return { ok: true };
3905
4052
  });
3906
- if (deps.webDir && existsSync5(deps.webDir)) {
4053
+ if (deps.webDir && existsSync6(deps.webDir)) {
3907
4054
  app.register(fastifyStatic, { root: deps.webDir });
3908
4055
  app.setNotFoundHandler((req, reply) => {
3909
4056
  if (req.method === "GET" && !req.url.startsWith("/api")) return reply.sendFile("index.html");
@@ -4125,8 +4272,8 @@ var ClaudeProvider = class {
4125
4272
 
4126
4273
  // ../server/src/provider/claude-code/provider.ts
4127
4274
  import { createHash as createHash3 } from "node:crypto";
4128
- import { chmodSync as chmodSync4, existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
4129
- import { join as join9 } from "node:path";
4275
+ import { chmodSync as chmodSync4, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
4276
+ import { join as join10 } from "node:path";
4130
4277
 
4131
4278
  // ../server/src/provider/claude-code/bridge.ts
4132
4279
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -4411,7 +4558,7 @@ var NOT_SIGNED_IN = /not logged in|please run \/login|invalid api key|authentica
4411
4558
  var ClaudeCodeProvider = class {
4412
4559
  constructor(opts) {
4413
4560
  this.opts = opts;
4414
- this.home = join9(opts.dataDir, "claude");
4561
+ this.home = join10(opts.dataDir, "claude");
4415
4562
  this.now = opts.now ?? Date.now;
4416
4563
  this.bridge = new ToolBridge((call) => this.owner.inbox.push({ kind: "call", call }));
4417
4564
  }
@@ -4641,7 +4788,7 @@ ${item.stderr}`;
4641
4788
  "local",
4642
4789
  "--system-prompt",
4643
4790
  req.system,
4644
- ...conv.guest ? ["--no-session-persistence"] : ["--mcp-config", join9(this.home, "mcp.json"), "--allowedTools", req.tools.map((t) => `mcp__friend__${encodeToolName(t.name)}`).join(","), ...resume ? ["--resume", resume] : []]
4791
+ ...conv.guest ? ["--no-session-persistence"] : ["--mcp-config", join10(this.home, "mcp.json"), "--allowedTools", req.tools.map((t) => `mcp__friend__${encodeToolName(t.name)}`).join(","), ...resume ? ["--resume", resume] : []]
4645
4792
  ];
4646
4793
  const proc = new ClaudeProc(
4647
4794
  {
@@ -4672,7 +4819,7 @@ ${item.stderr}`;
4672
4819
  startBridge() {
4673
4820
  this.bridgeReady ??= (async () => {
4674
4821
  await this.bridge.start();
4675
- const file = join9(this.home, "mcp.json");
4822
+ const file = join10(this.home, "mcp.json");
4676
4823
  writeFileSync5(file, JSON.stringify({ mcpServers: { friend: { type: "http", url: this.bridge.url, headers: { Authorization: `Bearer ${this.bridge.token}` } } } }));
4677
4824
  try {
4678
4825
  chmodSync4(file, 384);
@@ -4682,10 +4829,10 @@ ${item.stderr}`;
4682
4829
  return this.bridgeReady;
4683
4830
  }
4684
4831
  // ---- the session id, kept so a restart of Friend can pick the conversation up again ----
4685
- sessionFile = () => join9(this.home, "session-id");
4832
+ sessionFile = () => join10(this.home, "session-id");
4686
4833
  readSession() {
4687
4834
  try {
4688
- return existsSync6(this.sessionFile()) ? readFileSync4(this.sessionFile(), "utf8").trim() || null : null;
4835
+ return existsSync7(this.sessionFile()) ? readFileSync4(this.sessionFile(), "utf8").trim() || null : null;
4689
4836
  } catch {
4690
4837
  return null;
4691
4838
  }
@@ -5120,6 +5267,63 @@ var OllamaProvider = class {
5120
5267
  }
5121
5268
  };
5122
5269
 
5270
+ // ../server/src/tls.ts
5271
+ import { chmodSync as chmodSync5, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
5272
+ import { networkInterfaces } from "node:os";
5273
+ import { isIP as isIP2 } from "node:net";
5274
+ import { join as join11 } from "node:path";
5275
+ import { generate } from "selfsigned";
5276
+ var DAYS = 825;
5277
+ var RENEW_BEFORE_MS = 30 * 24 * 36e5;
5278
+ function lanAddresses(ifaces = networkInterfaces) {
5279
+ const out = [];
5280
+ for (const list2 of Object.values(ifaces())) {
5281
+ for (const i of list2 ?? []) if (i.family === "IPv4" && !i.internal && !out.includes(i.address)) out.push(i.address);
5282
+ }
5283
+ return out;
5284
+ }
5285
+ function tlsHosts(extra = [], ifaces) {
5286
+ return [.../* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", ...lanAddresses(ifaces), ...extra.map((h) => h.toLowerCase()).filter(Boolean)])];
5287
+ }
5288
+ async function ensureTlsCert(dataDir, extraHosts = [], ifaces, now = Date.now) {
5289
+ const dir = join11(dataDir, "tls");
5290
+ const keyFile = join11(dir, "key.pem");
5291
+ const certFile = join11(dir, "cert.pem");
5292
+ const metaFile = join11(dir, "meta.json");
5293
+ const hosts = tlsHosts(extraHosts, ifaces);
5294
+ if (existsSync8(keyFile) && existsSync8(certFile) && existsSync8(metaFile)) {
5295
+ try {
5296
+ const meta = JSON.parse(readFileSync5(metaFile, "utf8"));
5297
+ const covered = hosts.every((h) => meta.hosts.includes(h));
5298
+ if (covered && meta.expires - now() > RENEW_BEFORE_MS) return { key: readFileSync5(keyFile, "utf8"), cert: readFileSync5(certFile, "utf8") };
5299
+ } catch {
5300
+ }
5301
+ }
5302
+ const altNames = hosts.map((h) => isIP2(h) ? { type: 7, ip: h } : { type: 2, value: h });
5303
+ const pems = await generate([{ name: "commonName", value: "Friend" }], {
5304
+ keySize: 2048,
5305
+ notBeforeDate: new Date(now() - 24 * 36e5),
5306
+ // a day back, so a phone whose clock is a little slow still accepts it
5307
+ notAfterDate: new Date(now() + DAYS * 24 * 36e5),
5308
+ algorithm: "sha256",
5309
+ extensions: [
5310
+ { name: "basicConstraints", cA: false },
5311
+ { name: "keyUsage", digitalSignature: true, keyEncipherment: true },
5312
+ { name: "extKeyUsage", serverAuth: true },
5313
+ { name: "subjectAltName", altNames }
5314
+ ]
5315
+ });
5316
+ mkdirSync7(dir, { recursive: true });
5317
+ writeFileSync6(keyFile, pems.private);
5318
+ writeFileSync6(certFile, pems.cert);
5319
+ writeFileSync6(metaFile, JSON.stringify({ hosts, expires: now() + DAYS * 24 * 36e5 }));
5320
+ try {
5321
+ chmodSync5(keyFile, 384);
5322
+ } catch {
5323
+ }
5324
+ return { key: pems.private, cert: pems.cert };
5325
+ }
5326
+
5123
5327
  // ../server/src/voice/challenge.ts
5124
5328
  import { randomUUID as randomUUID6 } from "node:crypto";
5125
5329
  var Challenges = class {
@@ -5181,22 +5385,22 @@ var Challenges = class {
5181
5385
 
5182
5386
  // ../server/src/voice/crypto.ts
5183
5387
  import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
5184
- import { chmodSync as chmodSync5, existsSync as existsSync7, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
5388
+ import { chmodSync as chmodSync6, existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "node:fs";
5185
5389
  import { dirname as dirname2 } from "node:path";
5186
5390
  var KEY_BYTES = 32;
5187
5391
  var IV_BYTES = 12;
5188
5392
  var TAG_BYTES = 16;
5189
5393
  function loadOrCreateKey(path) {
5190
- if (existsSync7(path)) {
5191
- const key2 = readFileSync5(path);
5394
+ if (existsSync9(path)) {
5395
+ const key2 = readFileSync6(path);
5192
5396
  if (key2.length !== KEY_BYTES) throw new Error(`invalid voice key file (expected ${KEY_BYTES} bytes): ${path}`);
5193
5397
  return key2;
5194
5398
  }
5195
- mkdirSync7(dirname2(path), { recursive: true });
5399
+ mkdirSync8(dirname2(path), { recursive: true });
5196
5400
  const key = randomBytes3(KEY_BYTES);
5197
- writeFileSync6(path, key, { mode: 384 });
5401
+ writeFileSync7(path, key, { mode: 384 });
5198
5402
  try {
5199
- chmodSync5(path, 384);
5403
+ chmodSync6(path, 384);
5200
5404
  } catch {
5201
5405
  }
5202
5406
  return key;
@@ -5614,7 +5818,7 @@ async function createApp(opts) {
5614
5818
  const camera = new CameraBroker(opts.cameraTimeoutMs);
5615
5819
  const memory = new Memory(db);
5616
5820
  const tasks = new Tasks(db);
5617
- const plugins = new Plugins(db, join10(opts.dataDir, "plugins"));
5821
+ const plugins = new Plugins(db, join12(opts.dataDir, "plugins"));
5618
5822
  const hub = new McpHub(new ConnectorStore(opts.dataDir));
5619
5823
  const admin = makeAdmin({ hub, plugins, dataDir: opts.dataDir, audit: (what, detail) => audit.log("agent", what, detail) });
5620
5824
  function commitSettings(patch, how) {
@@ -5720,7 +5924,7 @@ async function createApp(opts) {
5720
5924
  const voice2 = opts.voice;
5721
5925
  const speaker = voice2 ? new Speaker(voice2, audit, () => config.voice.ttsVoice) : null;
5722
5926
  const voiceAvailable = () => !!voice2 && voice2.available();
5723
- const voiceprint = voice2 ? new Voiceprint(db, loadOrCreateKey(join10(opts.dataDir, "voice.key"))) : null;
5927
+ const voiceprint = voice2 ? new Voiceprint(db, loadOrCreateKey(join12(opts.dataDir, "voice.key"))) : null;
5724
5928
  const enrollment = voiceprint ? new Enrollment(voiceprint, SPEAKER_MODEL) : null;
5725
5929
  const challenges = new Challenges((id2) => {
5726
5930
  broadcast({ type: "voice.challenge.end", id: id2 });
@@ -5837,7 +6041,7 @@ async function createApp(opts) {
5837
6041
  if (!isPluginId(id2)) throw new HttpError(400, "That is not a plugin name.");
5838
6042
  if (!listCatalog(catalogDir).some((c) => c.id === id2)) throw new HttpError(404, "That plugin is not in the catalog.");
5839
6043
  await requirePin(pin, ip);
5840
- const r = plugins.install(join10(catalogDir, id2));
6044
+ const r = plugins.install(join12(catalogDir, id2));
5841
6045
  if (!r.ok) throw new HttpError(400, `That plugin cannot be installed: ${r.error}`);
5842
6046
  audit.log("owner", "plugin.installed", { id: r.id, upgraded: r.upgraded, ip });
5843
6047
  return { id: r.id, upgraded: r.upgraded };
@@ -5892,6 +6096,24 @@ async function createApp(opts) {
5892
6096
  throw new HttpError(404, e.message);
5893
6097
  }
5894
6098
  },
6099
+ quickCheck: (preset) => {
6100
+ const c = quickCheck(preset, { home: opts.quick?.home });
6101
+ if (!c) throw new HttpError(404, "That is not a one-click connector.");
6102
+ return c;
6103
+ },
6104
+ quickInstall: async (preset, pin, ip) => {
6105
+ if (!isQuickPreset(preset)) throw new HttpError(404, "That is not a one-click connector.");
6106
+ await requirePin(pin, ip);
6107
+ audit.log("owner", "connector.quick_install", { preset, ip });
6108
+ return quickInstall(preset, { hub, run: opts.quick?.run ?? runCommand, home: opts.quick?.home });
6109
+ },
6110
+ quickConnect: async (preset, pin, ip) => {
6111
+ if (!isQuickPreset(preset)) throw new HttpError(404, "That is not a one-click connector.");
6112
+ await requirePin(pin, ip);
6113
+ const result = await quickConnect(preset, { hub, run: opts.quick?.run ?? runCommand, home: opts.quick?.home });
6114
+ audit.log("owner", "connector.quick_connect", { preset, ok: result.ok, extension: result.extension, ip });
6115
+ return result;
6116
+ },
5895
6117
  remove: async (id2) => {
5896
6118
  try {
5897
6119
  await hub.remove(id2);
@@ -5901,7 +6123,9 @@ async function createApp(opts) {
5901
6123
  }
5902
6124
  }
5903
6125
  };
6126
+ const tls = config.https ? await ensureTlsCert(opts.dataDir, [...config.allowedHosts, ...config.publicUrl ? [new URL(config.publicUrl).hostname] : []]) : void 0;
5904
6127
  const gateway = createGateway({
6128
+ ...tls ? { tls } : {},
5905
6129
  connectors: connectorsApi,
5906
6130
  voicePreview: async (id2) => {
5907
6131
  if (!voice2 || !voiceAvailable()) throw new HttpError(503, "Voice is not available. Run: friend voice setup");
@@ -6250,7 +6474,7 @@ var SidecarProcess = class {
6250
6474
  import { createInterface as createInterface2 } from "node:readline";
6251
6475
 
6252
6476
  // ../agent-cli/src/commands/config.ts
6253
- import { isIP as isIP2 } from "node:net";
6477
+ import { isIP as isIP3 } from "node:net";
6254
6478
  var list = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
6255
6479
  var bool = (v) => {
6256
6480
  if (v !== "true" && v !== "false") throw new Error("Expected true or false");
@@ -6280,10 +6504,11 @@ var PARSERS = {
6280
6504
  model: (v) => v,
6281
6505
  bind: (v) => {
6282
6506
  const t = v.trim();
6283
- if (t !== "localhost" && isIP2(t) === 0) throw new Error("bind must be an IP address such as 127.0.0.1 (this PC only) or 0.0.0.0 (also your network), or localhost. Easier: friend lan on");
6507
+ if (t !== "localhost" && isIP3(t) === 0) throw new Error("bind must be an IP address such as 127.0.0.1 (this PC only) or 0.0.0.0 (also your network), or localhost. Easier: friend lan on");
6284
6508
  return t;
6285
6509
  },
6286
6510
  publicUrl: (v) => v === "" ? null : v,
6511
+ https: bool,
6287
6512
  port: (v) => {
6288
6513
  const n = Number(v);
6289
6514
  if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error("Port must be a number between 0 and 65535");
@@ -6330,11 +6555,42 @@ var GROUPS = {
6330
6555
  }
6331
6556
  }
6332
6557
  };
6558
+ var KEY_HELP = {
6559
+ apiKey: 'Anthropic API key, used when the model is "claude"',
6560
+ model: "Claude model name, used with the API key",
6561
+ provider: "which model answers: claude (API key), claude-code (your Claude subscription) or ollama (on this PC)",
6562
+ bind: "address Friend listens on: 127.0.0.1 is this PC only, 0.0.0.0 is your whole network (easier: friend lan on)",
6563
+ port: "the web port (easier: friend lan port N)",
6564
+ https: "true serves a secure https:// address (Friend makes its own certificate), so a phone may use the microphone (easier: friend lan https on)",
6565
+ publicUrl: "the address people use when Friend is behind your own domain, or empty",
6566
+ roots: "folders Friend may read and write freely, separated by commas",
6567
+ allowedOrigins: "extra web addresses allowed to connect, separated by commas",
6568
+ allowedHosts: "extra host names that may reach Friend (for example my-pc), separated by commas",
6569
+ trustProxy: "true when Friend sits behind your own reverse proxy",
6570
+ "voice.enabled": "true or false: use speech at all",
6571
+ "voice.sttModel": "listening model: tiny, base, small, \u2026 (bigger is more accurate and slower)",
6572
+ "voice.ttsVoice": "speaking voice (easier: friend voice setup, or Settings)",
6573
+ "voice.language": "force a spoken language such as en or hi, or empty to detect it",
6574
+ "voice.speakTyped": "true or false: also speak the replies to typed messages",
6575
+ "voice.accept": "0 to 1: a voice at or above this match is you",
6576
+ "voice.challenge": "0 to 1: a voice between this and accept must be confirmed with your PIN",
6577
+ "ollama.url": "address of the Ollama server, for example http://127.0.0.1:11434",
6578
+ "ollama.model": "the local model, for example qwen2.5:7b",
6579
+ "ollama.vision": "true or false: the local model can look at pictures",
6580
+ "ollama.contextTokens": "how much the local model remembers at once (1024 to 131072)",
6581
+ "claudeCode.model": "model for your Claude subscription: sonnet, haiku, opus or a full name",
6582
+ "claudeCode.command": "the command that starts Claude Code, if it is not on the PATH",
6583
+ "claudeCode.fallback": "ollama or none: what to use when Claude is unavailable"
6584
+ };
6333
6585
  var allKeys = () => [...Object.keys(PARSERS), ...Object.entries(GROUPS).flatMap(([g, f]) => Object.keys(f).map((k) => `${g}.${k}`))];
6334
6586
  async function configCommand(args, io2) {
6335
6587
  const [sub, key, ...valueParts] = args;
6588
+ if (sub === "keys") {
6589
+ for (const k of allKeys()) io2.out(`${k.padEnd(24)} ${KEY_HELP[k] ?? ""}`);
6590
+ return 0;
6591
+ }
6336
6592
  if (sub !== "set" || !key) {
6337
- io2.err("Usage: friend config set <key> <value>");
6593
+ io2.err("Usage: friend config set <key> <value> | friend config keys");
6338
6594
  return 1;
6339
6595
  }
6340
6596
  const value = valueParts.join(" ");
@@ -6370,8 +6626,8 @@ import { resolve as resolve6 } from "node:path";
6370
6626
  import { WebSocket as WebSocket2 } from "ws";
6371
6627
 
6372
6628
  // ../agent-cli/src/tools/fs.ts
6373
- import { closeSync, mkdirSync as mkdirSync8, openSync, readSync, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync7 } from "node:fs";
6374
- import { dirname as dirname5, join as join11 } from "node:path";
6629
+ import { closeSync, mkdirSync as mkdirSync9, openSync, readSync, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync8 } from "node:fs";
6630
+ import { dirname as dirname5, join as join13 } from "node:path";
6375
6631
 
6376
6632
  // ../agent-cli/src/tools/roots.ts
6377
6633
  import { lstatSync as lstatSync2, realpathSync } from "node:fs";
@@ -6433,7 +6689,7 @@ function fsList(args, ctx) {
6433
6689
  if (e.isDirectory()) return `[dir] ${e.name}`;
6434
6690
  let size = 0;
6435
6691
  try {
6436
- size = statSync(join11(g.abs, e.name)).size;
6692
+ size = statSync(join13(g.abs, e.name)).size;
6437
6693
  } catch {
6438
6694
  }
6439
6695
  return `[file] ${e.name} (${size} bytes)`;
@@ -6474,8 +6730,8 @@ function fsWrite(args, ctx) {
6474
6730
  if ("error" in g) return { ok: false, error: g.error };
6475
6731
  if (Buffer.byteLength(args.content) > MAX_WRITE) return { ok: false, error: "Content too large (5 MB limit)" };
6476
6732
  try {
6477
- mkdirSync8(dirname5(g.abs), { recursive: true });
6478
- writeFileSync7(g.abs, args.content);
6733
+ mkdirSync9(dirname5(g.abs), { recursive: true });
6734
+ writeFileSync8(g.abs, args.content);
6479
6735
  return { ok: true, output: `Wrote ${Buffer.byteLength(args.content)} bytes to ${g.abs}`, truncated: false };
6480
6736
  } catch (e) {
6481
6737
  return { ok: false, error: e.message };
@@ -6484,7 +6740,7 @@ function fsWrite(args, ctx) {
6484
6740
 
6485
6741
  // ../agent-cli/src/tools/shell.ts
6486
6742
  import { spawn as spawn4, spawnSync } from "node:child_process";
6487
- function killTree(child) {
6743
+ function killTree2(child) {
6488
6744
  if (!child.pid) return;
6489
6745
  if (process.platform === "win32") {
6490
6746
  spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
@@ -6496,7 +6752,7 @@ function killTree(child) {
6496
6752
  }
6497
6753
  }
6498
6754
  }
6499
- function runCommand(command, cwdArg, ctx, argv) {
6755
+ function runCommand2(command, cwdArg, ctx, argv) {
6500
6756
  let cwd = ctx.roots[0] ?? process.cwd();
6501
6757
  if (cwdArg) {
6502
6758
  const r = resolveInRoots(cwdArg, ctx.roots);
@@ -6529,7 +6785,7 @@ function runCommand(command, cwdArg, ctx, argv) {
6529
6785
  child.stderr.on("data", take);
6530
6786
  const timer = setTimeout(() => {
6531
6787
  timedOut = true;
6532
- killTree(child);
6788
+ killTree2(child);
6533
6789
  }, ctx.timeoutMs);
6534
6790
  child.on("error", (e) => {
6535
6791
  clearTimeout(timer);
@@ -6587,7 +6843,7 @@ async function systemInfo(sampleMs = 250) {
6587
6843
  return { ok: true, output: lines.join("\n"), truncated: false };
6588
6844
  }
6589
6845
  function processList(ctx) {
6590
- return process.platform === "win32" ? runCommand("", void 0, ctx, { file: "tasklist.exe", args: ["/fo", "csv", "/nh"] }) : runCommand("", void 0, ctx, { file: "ps", args: ["-eo", "pid,comm"] });
6846
+ return process.platform === "win32" ? runCommand2("", void 0, ctx, { file: "tasklist.exe", args: ["/fo", "csv", "/nh"] }) : runCommand2("", void 0, ctx, { file: "ps", args: ["-eo", "pid,comm"] });
6591
6847
  }
6592
6848
 
6593
6849
  // ../agent-cli/src/tools/index.ts
@@ -6611,7 +6867,7 @@ async function runTool(name, args, ctx) {
6611
6867
  case "process.list":
6612
6868
  return await processList(ctx);
6613
6869
  case "shell.run":
6614
- return await runCommand(a.command, a.cwd, ctx);
6870
+ return await runCommand2(a.command, a.cwd, ctx);
6615
6871
  default:
6616
6872
  return { ok: false, error: `Unknown tool: ${name}` };
6617
6873
  }
@@ -6644,7 +6900,7 @@ function startDeviceNode(opts) {
6644
6900
  function connect() {
6645
6901
  if (stopped) return;
6646
6902
  setState("connecting");
6647
- const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` } });
6903
+ const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` }, ...opts.ca ? { ca: opts.ca } : {} });
6648
6904
  ws = sock;
6649
6905
  sock.on("error", () => {
6650
6906
  });
@@ -6711,15 +6967,15 @@ function startDeviceNode(opts) {
6711
6967
 
6712
6968
  // ../agent-cli/src/commands/start.ts
6713
6969
  import { randomBytes as randomBytes4 } from "node:crypto";
6714
- import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "node:fs";
6715
- import { dirname as dirname7, join as join13, resolve as resolve5 } from "node:path";
6970
+ import { existsSync as existsSync11, readFileSync as readFileSync8, writeFileSync as writeFileSync10 } from "node:fs";
6971
+ import { dirname as dirname7, join as join15, resolve as resolve5 } from "node:path";
6716
6972
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6717
6973
 
6718
6974
  // ../agent-cli/src/commands/lan.ts
6719
- import { networkInterfaces } from "node:os";
6975
+ import { networkInterfaces as networkInterfaces2 } from "node:os";
6720
6976
  var LOOPBACK3 = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
6721
6977
  var isLoopbackBind = (bind) => LOOPBACK3.has(bind);
6722
- function lanAddresses(ifaces = networkInterfaces) {
6978
+ function lanAddresses2(ifaces = networkInterfaces2) {
6723
6979
  const out = [];
6724
6980
  for (const list2 of Object.values(ifaces())) {
6725
6981
  for (const i of list2 ?? []) {
@@ -6728,11 +6984,12 @@ function lanAddresses(ifaces = networkInterfaces) {
6728
6984
  }
6729
6985
  return out;
6730
6986
  }
6731
- function urlsFor(bind, port, ifaces = networkInterfaces) {
6732
- const local = `http://127.0.0.1:${port}`;
6987
+ function urlsFor(bind, port, ifaces = networkInterfaces2, https = false) {
6988
+ const scheme = https ? "https" : "http";
6989
+ const local = `${scheme}://127.0.0.1:${port}`;
6733
6990
  if (isLoopbackBind(bind)) return { local, network: [] };
6734
- if (bind === "0.0.0.0" || bind === "::") return { local, network: lanAddresses(ifaces).map((a) => `http://${a}:${port}`) };
6735
- return { local, network: [`http://${bind}:${port}`] };
6991
+ if (bind === "0.0.0.0" || bind === "::") return { local, network: lanAddresses2(ifaces).map((a) => `${scheme}://${a}:${port}`) };
6992
+ return { local, network: [`${scheme}://${bind}:${port}`] };
6736
6993
  }
6737
6994
  var PORT_HELP = "The port must be a whole number from 1024 to 65535.";
6738
6995
  function parsePort(v) {
@@ -6740,34 +6997,58 @@ function parsePort(v) {
6740
6997
  if (v === void 0 || v.trim() === "" || !Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(PORT_HELP);
6741
6998
  return n;
6742
6999
  }
6743
- function show(io2, bind, port, ifaces) {
6744
- const u = urlsFor(bind, port, ifaces);
7000
+ function show(io2, bind, port, ifaces, https = false) {
7001
+ const u = urlsFor(bind, port, ifaces, https);
6745
7002
  io2.out(`Listens on: ${bind}:${port}`);
6746
7003
  io2.out(`On this PC: ${u.local}`);
6747
7004
  if (u.network.length) for (const n of u.network) io2.out(`On your network: ${n}`);
6748
7005
  else if (isLoopbackBind(bind)) io2.out("On your network: not reachable (only this PC). Turn it on with: friend lan on");
6749
7006
  else io2.out("On your network: no network address found. Check that this PC is connected.");
6750
7007
  }
6751
- async function lanCommand(args, io2, ifaces = networkInterfaces) {
7008
+ async function lanCommand(args, io2, ifaces = networkInterfaces2) {
6752
7009
  const [sub, ...rest] = args;
6753
7010
  const cfg = loadConfig(io2.dataDir);
6754
7011
  switch (sub) {
6755
7012
  case void 0:
6756
7013
  case "status":
6757
- show(io2, cfg.bind, cfg.port, ifaces);
7014
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
7015
+ io2.out(cfg.https ? "Secure (https): on. A phone can use the microphone and camera." : "Secure (https): off. A phone cannot use the microphone or camera. Turn it on with: friend lan https on");
6758
7016
  return 0;
7017
+ case "https": {
7018
+ if (rest[0] !== "on" && rest[0] !== "off") {
7019
+ io2.err("Usage: friend lan https on | off");
7020
+ return 1;
7021
+ }
7022
+ cfg.https = rest[0] === "on";
7023
+ saveConfig(cfg);
7024
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
7025
+ io2.out("");
7026
+ io2.out("Restart Friend to apply (stop it, then: friend start).");
7027
+ if (cfg.https) {
7028
+ io2.out("Friend makes its own security certificate, so the first time a phone opens the https:// address it shows a warning:");
7029
+ io2.out(" Android Chrome: Advanced, then Proceed. iPhone Safari: Show Details, then visit this website.");
7030
+ io2.out("After that, the microphone and camera work from the phone. Only that address is affected; nothing is installed on the phone.");
7031
+ if (isLoopbackBind(cfg.bind)) io2.out("Friend is still only reachable from this PC. To open it from a phone too: friend lan on");
7032
+ }
7033
+ return 0;
7034
+ }
6759
7035
  case "on": {
6760
7036
  const portFlag = rest.indexOf("--port");
6761
7037
  if (portFlag >= 0) cfg.port = parsePort(rest[portFlag + 1]);
6762
7038
  cfg.bind = "0.0.0.0";
7039
+ if (rest.includes("--https")) cfg.https = true;
6763
7040
  saveConfig(cfg);
6764
- show(io2, cfg.bind, cfg.port, ifaces);
7041
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
6765
7042
  io2.out("");
6766
7043
  io2.out("Restart Friend to apply (stop it, then: friend start).");
6767
7044
  io2.out("Things to know:");
6768
7045
  io2.out(" - Anyone on your network can open the sign-in page. Your password and PIN still guard everything; use a strong password.");
6769
- io2.out(" - The connection is plain http: other people on the network could read it. Use it only on a network you trust, or put Friend behind HTTPS (see publicUrl in the README).");
6770
- io2.out(" - Browsers allow the microphone and camera only on localhost or HTTPS, so voice and camera will not work from another device over plain http.");
7046
+ if (cfg.https) {
7047
+ io2.out(" - Secure (https) is on. The first time a phone opens the address it shows a certificate warning (Advanced, then Proceed); after that the microphone works.");
7048
+ } else {
7049
+ io2.out(" - The connection is plain http: other people on the network could read it. Use it only on a network you trust.");
7050
+ io2.out(" - Browsers allow the microphone and camera only on localhost or HTTPS, so voice and camera will not work from a phone over plain http. Fix: friend lan https on");
7051
+ }
6771
7052
  io2.out(" - Windows may ask to allow Node.js through the firewall the first time: allow it on Private networks.");
6772
7053
  io2.out(" - Going to a name such as http://my-pc:8787 needs: friend config set allowedHosts my-pc");
6773
7054
  return 0;
@@ -6775,41 +7056,41 @@ async function lanCommand(args, io2, ifaces = networkInterfaces) {
6775
7056
  case "off":
6776
7057
  cfg.bind = "127.0.0.1";
6777
7058
  saveConfig(cfg);
6778
- show(io2, cfg.bind, cfg.port, ifaces);
7059
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
6779
7060
  io2.out("Restart Friend to apply.");
6780
7061
  return 0;
6781
7062
  case "port": {
6782
7063
  cfg.port = parsePort(rest[0]);
6783
7064
  saveConfig(cfg);
6784
- show(io2, cfg.bind, cfg.port, ifaces);
7065
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
6785
7066
  io2.out("Restart Friend to apply.");
6786
7067
  return 0;
6787
7068
  }
6788
7069
  default:
6789
- io2.err("Usage: friend lan status | on [--port N] | off | port N");
7070
+ io2.err("Usage: friend lan status | on [--port N] [--https] | off | port N | https on|off");
6790
7071
  return 1;
6791
7072
  }
6792
7073
  }
6793
7074
 
6794
7075
  // ../agent-cli/src/commands/voice.ts
6795
7076
  import { spawn as spawn5 } from "node:child_process";
6796
- import { existsSync as existsSync8, mkdirSync as mkdirSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync8 } from "node:fs";
6797
- import { dirname as dirname6, join as join12, resolve as resolve4 } from "node:path";
7077
+ import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "node:fs";
7078
+ import { dirname as dirname6, join as join14, resolve as resolve4 } from "node:path";
6798
7079
  import { fileURLToPath as fileURLToPath2 } from "node:url";
6799
7080
  function voicePaths(dataDir) {
6800
- const home = join12(dataDir, "voice");
6801
- const venv = join12(home, "venv");
7081
+ const home = join14(dataDir, "voice");
7082
+ const venv = join14(home, "venv");
6802
7083
  return {
6803
7084
  home,
6804
7085
  venv,
6805
- python: process.platform === "win32" ? join12(venv, "Scripts", "python.exe") : join12(venv, "bin", "python"),
6806
- models: join12(home, "models"),
6807
- marker: join12(home, "installed.json")
7086
+ python: process.platform === "win32" ? join14(venv, "Scripts", "python.exe") : join14(venv, "bin", "python"),
7087
+ models: join14(home, "models"),
7088
+ marker: join14(home, "installed.json")
6808
7089
  };
6809
7090
  }
6810
7091
  function readMarker(dataDir) {
6811
7092
  try {
6812
- return JSON.parse(readFileSync6(voicePaths(dataDir).marker, "utf8"));
7093
+ return JSON.parse(readFileSync7(voicePaths(dataDir).marker, "utf8"));
6813
7094
  } catch {
6814
7095
  return null;
6815
7096
  }
@@ -6906,8 +7187,8 @@ async function voiceSetupCommand(io2, deps = {}, args = []) {
6906
7187
  io2.err("Python 3.10 or newer is required. Install it from https://www.python.org/downloads/ and run this again.");
6907
7188
  return 1;
6908
7189
  }
6909
- mkdirSync9(p.home, { recursive: true });
6910
- if (!existsSync8(p.python)) {
7190
+ mkdirSync10(p.home, { recursive: true });
7191
+ if (!existsSync10(p.python)) {
6911
7192
  io2.out("1/3 Creating a private Python environment\u2026");
6912
7193
  const r = await run2(py.cmd, [...py.args, "-m", "venv", p.venv], { onLine });
6913
7194
  if (r.code !== 0) {
@@ -6933,7 +7214,7 @@ ${tail(dl.stdout)}`);
6933
7214
  return 1;
6934
7215
  }
6935
7216
  const marker = { python: p.python, sttModel: cfg.voice.sttModel, ttsVoice: cfg.voice.ttsVoice, at: Date.now() };
6936
- writeFileSync8(p.marker, JSON.stringify(marker, null, 2));
7217
+ writeFileSync9(p.marker, JSON.stringify(marker, null, 2));
6937
7218
  io2.out("Voice is ready. Start Friend with `friend start`, then hold Space to talk.");
6938
7219
  return 0;
6939
7220
  }
@@ -6949,7 +7230,7 @@ async function voiceStatusCommand(io2) {
6949
7230
  io2.out("Voice engine: not installed. Run: friend voice setup");
6950
7231
  return 0;
6951
7232
  }
6952
- if (!existsSync8(marker.python)) {
7233
+ if (!existsSync10(marker.python)) {
6953
7234
  io2.out("Voice engine: installed, but its Python is missing. Run: friend voice setup");
6954
7235
  return 0;
6955
7236
  }
@@ -7022,7 +7303,7 @@ async function voiceCommand(args, io2) {
7022
7303
  function findWebDir(explicit) {
7023
7304
  const here = dirname7(fileURLToPath3(import.meta.url));
7024
7305
  const candidates = [explicit, process.env.FRIEND_WEB_DIR, resolve5(here, "../../../web/dist"), resolve5(here, "web")];
7025
- return candidates.find((c) => !!c && existsSync9(join13(c, "index.html")));
7306
+ return candidates.find((c) => !!c && existsSync11(join15(c, "index.html")));
7026
7307
  }
7027
7308
  async function startFriend(opts) {
7028
7309
  const stored = loadConfig(opts.dataDir);
@@ -7056,18 +7337,20 @@ async function startFriend(opts) {
7056
7337
  }
7057
7338
  });
7058
7339
  const { port } = await app.start();
7059
- const tokenFile = join13(opts.dataDir, "local-device.token");
7060
- let token = existsSync9(tokenFile) ? readFileSync7(tokenFile, "utf8").trim() : "";
7340
+ const tokenFile = join15(opts.dataDir, "local-device.token");
7341
+ let token = existsSync11(tokenFile) ? readFileSync8(tokenFile, "utf8").trim() : "";
7061
7342
  if (!token || !app.auth.resolveDevice(token)) {
7062
7343
  token = app.auth.redeemPairCode(app.auth.createPairCode(), "this-pc");
7063
- writeFileSync9(tokenFile, token, { mode: 384 });
7344
+ writeFileSync10(tokenFile, token, { mode: 384 });
7064
7345
  }
7065
7346
  const node = startDeviceNode({
7066
- url: `ws://127.0.0.1:${port}/device`,
7347
+ url: `${app.config.https ? "wss" : "ws"}://127.0.0.1:${port}/device`,
7067
7348
  token,
7068
- roots: app.config.roots
7349
+ roots: app.config.roots,
7350
+ ...app.config.https ? { ca: readFileSync8(join15(opts.dataDir, "tls", "cert.pem"), "utf8") } : {}
7351
+ // this PC trusts only its own Friend's certificate
7069
7352
  });
7070
- const urls = urlsFor(app.config.bind, port);
7353
+ const urls = urlsFor(app.config.bind, port, void 0, app.config.https);
7071
7354
  opts.out(`Friend is running at ${urls.local}`);
7072
7355
  for (const n of urls.network) opts.out(`Also on your network: ${n}`);
7073
7356
  if (!app.auth.hasOwner()) opts.out("No owner yet. Run: friend onboard (guided), or friend setup (just the account)");
@@ -7292,8 +7575,8 @@ async function memoryCommand(args, io2) {
7292
7575
  }
7293
7576
 
7294
7577
  // ../agent-cli/src/commands/plugin.ts
7295
- import { existsSync as existsSync10, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
7296
- import { dirname as dirname8, join as join14, resolve as resolve7 } from "node:path";
7578
+ import { existsSync as existsSync12, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
7579
+ import { dirname as dirname8, join as join16, resolve as resolve7 } from "node:path";
7297
7580
  import { fileURLToPath as fileURLToPath4 } from "node:url";
7298
7581
  var USAGE2 = "Usage: friend plugin catalog | list | install <name|folder> [--yes] | enable <id> | disable <id> | remove <id>";
7299
7582
  function defaultCatalog() {
@@ -7307,10 +7590,10 @@ async function pluginCommand(args, io2, deps = {}) {
7307
7590
  const flags = rest.filter((a) => a.startsWith("--"));
7308
7591
  const arg = rest.find((a) => !a.startsWith("--"));
7309
7592
  if (sub === "catalog") {
7310
- const dirs = existsSync10(catalogDir) ? readdirSync3(catalogDir).filter((d) => statSync2(join14(catalogDir, d)).isDirectory()) : [];
7593
+ const dirs = existsSync12(catalogDir) ? readdirSync3(catalogDir).filter((d) => statSync2(join16(catalogDir, d)).isDirectory()) : [];
7311
7594
  if (dirs.length === 0) io2.out("The catalog is empty.");
7312
7595
  for (const d of dirs.sort()) {
7313
- const r = inspectPlugin(join14(catalogDir, d));
7596
+ const r = inspectPlugin(join16(catalogDir, d));
7314
7597
  if (!r.ok) {
7315
7598
  io2.out(`${d} (invalid: ${r.error})`);
7316
7599
  continue;
@@ -7326,7 +7609,7 @@ async function pluginCommand(args, io2, deps = {}) {
7326
7609
  }
7327
7610
  const db = openDb(io2.dataDir);
7328
7611
  try {
7329
- const plugins = new Plugins(db, join14(io2.dataDir, "plugins"));
7612
+ const plugins = new Plugins(db, join16(io2.dataDir, "plugins"));
7330
7613
  if (sub === "list") {
7331
7614
  const all = plugins.list();
7332
7615
  if (all.length === 0) io2.out("No plugins installed. See: friend plugin catalog");
@@ -7340,8 +7623,8 @@ async function pluginCommand(args, io2, deps = {}) {
7340
7623
  io2.err(USAGE2);
7341
7624
  return 1;
7342
7625
  }
7343
- const inCatalog = !/[\\/]/.test(arg) && existsSync10(join14(catalogDir, arg));
7344
- const source = inCatalog ? join14(catalogDir, arg) : existsSync10(resolve7(arg)) ? resolve7(arg) : null;
7626
+ const inCatalog = !/[\\/]/.test(arg) && existsSync12(join16(catalogDir, arg));
7627
+ const source = inCatalog ? join16(catalogDir, arg) : existsSync12(resolve7(arg)) ? resolve7(arg) : null;
7345
7628
  if (!source) {
7346
7629
  io2.err(`"${arg}" is not in the catalog and there is no such folder. See: friend plugin catalog`);
7347
7630
  return 1;
@@ -7395,8 +7678,8 @@ async function pluginCommand(args, io2, deps = {}) {
7395
7678
  }
7396
7679
 
7397
7680
  // ../agent-cli/src/commands/onboard/index.ts
7398
- import crossSpawn2 from "cross-spawn";
7399
- import { mkdirSync as mkdirSync11 } from "node:fs";
7681
+ import crossSpawn4 from "cross-spawn";
7682
+ import { mkdirSync as mkdirSync12 } from "node:fs";
7400
7683
  import { resolve as resolve8 } from "node:path";
7401
7684
 
7402
7685
  // ../agent-cli/src/commands/ollama.ts
@@ -7564,13 +7847,13 @@ async function ollamaCommand(args, io2, deps = {}) {
7564
7847
  }
7565
7848
 
7566
7849
  // ../agent-cli/src/commands/onboard/exec.ts
7567
- import crossSpawn from "cross-spawn";
7850
+ import crossSpawn3 from "cross-spawn";
7568
7851
  var CHECK_TIMEOUT_MS = 2e4;
7569
7852
  var capture = (cmd, args, opts) => new Promise((resolve10) => {
7570
7853
  let stdout = "";
7571
7854
  let child;
7572
7855
  try {
7573
- child = crossSpawn(cmd, args, { cwd: opts?.cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
7856
+ child = crossSpawn3(cmd, args, { cwd: opts?.cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
7574
7857
  } catch (e) {
7575
7858
  resolve10({ code: 127, stdout: e.message });
7576
7859
  return;
@@ -7597,7 +7880,7 @@ var capture = (cmd, args, opts) => new Promise((resolve10) => {
7597
7880
  var interactive = (cmd, args) => new Promise((resolve10) => {
7598
7881
  let child;
7599
7882
  try {
7600
- child = crossSpawn(cmd, args, { stdio: "inherit" });
7883
+ child = crossSpawn3(cmd, args, { stdio: "inherit" });
7601
7884
  } catch {
7602
7885
  resolve10(127);
7603
7886
  return;
@@ -7719,16 +8002,16 @@ function installPlan(id2, platform, have) {
7719
8002
  }
7720
8003
 
7721
8004
  // ../agent-cli/src/commands/onboard/state.ts
7722
- import { existsSync as existsSync11, mkdirSync as mkdirSync10, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync10 } from "node:fs";
7723
- import { dirname as dirname9, join as join15 } from "node:path";
8005
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync11 } from "node:fs";
8006
+ import { dirname as dirname9, join as join17 } from "node:path";
7724
8007
  var FEATURES = ["claude-code", "claude-api", "ollama", "voice", "sandbox"];
7725
8008
  var emptyState = () => ({ version: 1, done: {}, features: [], restart: [] });
7726
- var statePath = (dataDir) => join15(dataDir, "onboarding.json");
8009
+ var statePath = (dataDir) => join17(dataDir, "onboarding.json");
7727
8010
  function loadState(dataDir) {
7728
8011
  const f = statePath(dataDir);
7729
- if (!existsSync11(f)) return emptyState();
8012
+ if (!existsSync13(f)) return emptyState();
7730
8013
  try {
7731
- const raw = JSON.parse(readFileSync8(f, "utf8"));
8014
+ const raw = JSON.parse(readFileSync9(f, "utf8"));
7732
8015
  const done = raw.done && typeof raw.done === "object" ? Object.fromEntries(Object.entries(raw.done).filter(([, v]) => typeof v === "string")) : {};
7733
8016
  const features2 = Array.isArray(raw.features) ? raw.features.filter((x) => FEATURES.includes(x)) : [];
7734
8017
  const restart = Array.isArray(raw.restart) ? raw.restart.filter((x) => typeof x === "string") : [];
@@ -7739,9 +8022,9 @@ function loadState(dataDir) {
7739
8022
  }
7740
8023
  function saveState(dataDir, state) {
7741
8024
  const f = statePath(dataDir);
7742
- mkdirSync10(dirname9(f), { recursive: true });
8025
+ mkdirSync11(dirname9(f), { recursive: true });
7743
8026
  const tmp = `${f}.tmp`;
7744
- writeFileSync10(tmp, JSON.stringify(state, null, 2));
8027
+ writeFileSync11(tmp, JSON.stringify(state, null, 2));
7745
8028
  renameSync3(tmp, f);
7746
8029
  }
7747
8030
  function resetState(dataDir) {
@@ -7830,7 +8113,7 @@ function defaultOnboardDeps() {
7830
8113
  openUrl: async (url) => {
7831
8114
  const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
7832
8115
  try {
7833
- crossSpawn2(cmd, args, { stdio: "ignore", detached: true }).unref();
8116
+ crossSpawn4(cmd, args, { stdio: "ignore", detached: true }).unref();
7834
8117
  } catch {
7835
8118
  }
7836
8119
  },
@@ -8097,7 +8380,7 @@ async function workspace(c) {
8097
8380
  const answer = await text(c.ui, current ? "Workspace folder" : "Workspace folder (Enter to skip)", current ?? "");
8098
8381
  if (answer) {
8099
8382
  const abs = resolve8(answer);
8100
- mkdirSync11(abs, { recursive: true });
8383
+ mkdirSync12(abs, { recursive: true });
8101
8384
  cfg.roots = [abs];
8102
8385
  saveConfig(cfg);
8103
8386
  io2.out(`Workspace: ${abs}`);
@@ -8131,11 +8414,12 @@ async function network(c) {
8131
8414
  }
8132
8415
  cfg.port = port;
8133
8416
  cfg.bind = where === "lan" ? "0.0.0.0" : "127.0.0.1";
8417
+ cfg.https = where === "lan" ? await yesNo(c.ui, "Use a secure (https) address so a phone can use the microphone? Your phone will show a one-time certificate warning.", true) : false;
8134
8418
  saveConfig(cfg);
8135
- const u = urlsFor(cfg.bind, cfg.port);
8419
+ const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
8136
8420
  io2.out(`Friend will be at ${u.local}`);
8137
8421
  for (const n of u.network) io2.out(`and on your network at ${n}`);
8138
- if (where === "lan") io2.out("Note: the microphone and camera only work on this PC (or over HTTPS). Everything else works from the other devices.");
8422
+ if (where === "lan" && !cfg.https) io2.out("Note: the microphone and camera only work on this PC (or over HTTPS). Everything else works from the other devices. Later: friend lan https on");
8139
8423
  return "done";
8140
8424
  }
8141
8425
  async function look(c) {
@@ -8154,7 +8438,7 @@ async function look(c) {
8154
8438
  return "done";
8155
8439
  }
8156
8440
  function summary(cfg, c) {
8157
- const u = urlsFor(cfg.bind, cfg.port);
8441
+ const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
8158
8442
  const db = openDb(c.io.dataDir);
8159
8443
  let owner = "not created yet";
8160
8444
  try {
@@ -8184,7 +8468,7 @@ async function finish(c) {
8184
8468
  io2.out("Start it whenever you like with: friend start");
8185
8469
  return "done";
8186
8470
  }
8187
- const url = urlsFor(cfg.bind, cfg.port).local;
8471
+ const url = urlsFor(cfg.bind, cfg.port, void 0, cfg.https).local;
8188
8472
  if (await yesNo(c.ui, "Open it in your browser when it is up?", true)) setTimeout(() => void deps.openUrl(url), 2500);
8189
8473
  io2.out("Starting Friend. Press Ctrl+C to stop it.");
8190
8474
  c.state.done.finish = (/* @__PURE__ */ new Date()).toISOString();
@@ -8334,7 +8618,7 @@ async function claudeCommand(args, io2, deps = {}) {
8334
8618
  }
8335
8619
 
8336
8620
  // ../agent-cli/src/commands/setup.ts
8337
- import { mkdirSync as mkdirSync12 } from "node:fs";
8621
+ import { mkdirSync as mkdirSync13 } from "node:fs";
8338
8622
  import { resolve as resolve9 } from "node:path";
8339
8623
  async function setupCommand3(io2) {
8340
8624
  const db = openDb(io2.dataDir);
@@ -8360,7 +8644,7 @@ async function setupCommand3(io2) {
8360
8644
  const cfg = loadConfig(io2.dataDir);
8361
8645
  if (workspace2) {
8362
8646
  const abs = resolve9(workspace2);
8363
- mkdirSync12(abs, { recursive: true });
8647
+ mkdirSync13(abs, { recursive: true });
8364
8648
  cfg.roots = [abs];
8365
8649
  }
8366
8650
  if (apiKey) cfg.apiKey = apiKey;
@@ -8382,12 +8666,12 @@ async function statusCommand3(io2) {
8382
8666
  const cfg = loadConfig(io2.dataDir);
8383
8667
  const db = openDb(io2.dataDir);
8384
8668
  const auth = new Auth(db, new Audit(db));
8385
- const owner = auth.hasOwner() ? `yes (${auth.assistantName()})` : "no, run: friend setup";
8669
+ const owner = auth.hasOwner() ? `yes (${auth.assistantName()})` : "no, run: friend onboard";
8386
8670
  db.close();
8387
8671
  io2.out(`Data folder: ${cfg.dataDir}`);
8388
8672
  io2.out(`Owner set up: ${owner}`);
8389
8673
  io2.out(`Listens on: ${cfg.bind}:${cfg.port}`);
8390
- for (const n of urlsFor(cfg.bind, cfg.port).network) io2.out(`Network URL: ${n}`);
8674
+ for (const n of urlsFor(cfg.bind, cfg.port, void 0, cfg.https).network) io2.out(`Network URL: ${n}`);
8391
8675
  io2.out(`Public URL: ${cfg.publicUrl ?? "(none)"}`);
8392
8676
  io2.out(`Workspace: ${cfg.roots.length ? cfg.roots.join(", ") : "(none, every file action asks first)"}`);
8393
8677
  io2.out(`Model: ${cfg.model}`);
@@ -8408,13 +8692,16 @@ var USAGE3 = `Usage: friend <command>
8408
8692
  ollama setup|status use a local model through Ollama (no API key needed)
8409
8693
  lan status|on|off|port N let other devices on your network open Friend, or change the port (restart to apply)
8410
8694
  claude setup|status use your Claude subscription through Claude Code (no API key needed)
8411
- voice setup|status|forget install or check local speech (hold Space to talk); forget deletes your saved voice
8695
+ voice setup|list|status|forget install local speech and choose a female or male voice; check it; forget deletes your saved voice
8412
8696
  memory list|forget <n>|clear see and delete the notes Friend keeps about you
8413
8697
  plugin catalog|list|install|enable|disable|remove add skills and sandboxed tools
8414
8698
  rules list|remove <id> manage always-allow rules
8415
8699
  devices list|revoke <id> manage paired devices
8416
8700
  status show configuration
8417
- config set <key> <value> keys: apiKey, model, port, bind, publicUrl, roots, trustProxy, allowedOrigins`;
8701
+ config set <key> <value> change a setting; "friend config keys" lists them all
8702
+ version print the version
8703
+
8704
+ Start here: friend onboard`;
8418
8705
  async function run(argv, io2) {
8419
8706
  const [cmd, ...rest] = argv;
8420
8707
  try {
@@ -8438,7 +8725,7 @@ async function run(argv, io2) {
8438
8725
  case "version":
8439
8726
  case "--version":
8440
8727
  case "-v":
8441
- io2.out(true ? "0.1.0" : "development");
8728
+ io2.out(true ? "0.1.2" : "development");
8442
8729
  return 0;
8443
8730
  case "lan":
8444
8731
  return await lanCommand(rest, io2);