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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2486
|
+
quick: true,
|
|
2487
|
+
connector: { id: "chrome", name: "Chrome control", kind: "stdio", command: "npx", args: ["-y", "chrome-control-mcp"] },
|
|
2477
2488
|
setup: [
|
|
2478
|
-
"
|
|
2479
|
-
"
|
|
2480
|
-
"
|
|
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
|
|
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(
|
|
3117
|
+
const dir = mkdtempSync2(join7(tmpdir(), "friend-code-"));
|
|
2991
3118
|
try {
|
|
2992
|
-
const 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(
|
|
2998
|
-
chmodSync2(
|
|
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
|
|
3260
|
+
import { join as join12 } from "node:path";
|
|
3134
3261
|
|
|
3135
3262
|
// ../server/src/config.ts
|
|
3136
|
-
import { chmodSync as chmodSync3, existsSync as
|
|
3137
|
-
import { homedir } from "node:os";
|
|
3138
|
-
import { join as
|
|
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 ??
|
|
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
|
|
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 (!
|
|
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
|
|
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(
|
|
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
|
|
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 &&
|
|
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
|
|
4129
|
-
import { join as
|
|
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 =
|
|
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",
|
|
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 =
|
|
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 = () =>
|
|
4832
|
+
sessionFile = () => join10(this.home, "session-id");
|
|
4686
4833
|
readSession() {
|
|
4687
4834
|
try {
|
|
4688
|
-
return
|
|
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
|
|
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 (
|
|
5191
|
-
const key2 =
|
|
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
|
-
|
|
5399
|
+
mkdirSync8(dirname2(path), { recursive: true });
|
|
5196
5400
|
const key = randomBytes3(KEY_BYTES);
|
|
5197
|
-
|
|
5401
|
+
writeFileSync7(path, key, { mode: 384 });
|
|
5198
5402
|
try {
|
|
5199
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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" &&
|
|
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
|
|
6374
|
-
import { dirname as dirname5, join as
|
|
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(
|
|
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
|
-
|
|
6478
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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" ?
|
|
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
|
|
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
|
|
6715
|
-
import { dirname as dirname7, join as
|
|
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
|
|
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 =
|
|
6732
|
-
const
|
|
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:
|
|
6735
|
-
return { local, network: [
|
|
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 =
|
|
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
|
-
|
|
6770
|
-
|
|
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
|
|
6797
|
-
import { dirname as dirname6, join as
|
|
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 =
|
|
6801
|
-
const 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" ?
|
|
6806
|
-
models:
|
|
6807
|
-
marker:
|
|
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(
|
|
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
|
-
|
|
6910
|
-
if (!
|
|
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
|
-
|
|
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 (!
|
|
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 &&
|
|
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 =
|
|
7060
|
-
let token =
|
|
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
|
-
|
|
7344
|
+
writeFileSync10(tokenFile, token, { mode: 384 });
|
|
7064
7345
|
}
|
|
7065
7346
|
const node = startDeviceNode({
|
|
7066
|
-
url:
|
|
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
|
|
7296
|
-
import { dirname as dirname8, join as
|
|
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 =
|
|
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(
|
|
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,
|
|
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) &&
|
|
7344
|
-
const source = inCatalog ?
|
|
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
|
|
7399
|
-
import { mkdirSync as
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
7723
|
-
import { dirname as dirname9, join as
|
|
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) =>
|
|
8009
|
+
var statePath = (dataDir) => join17(dataDir, "onboarding.json");
|
|
7727
8010
|
function loadState(dataDir) {
|
|
7728
8011
|
const f = statePath(dataDir);
|
|
7729
|
-
if (!
|
|
8012
|
+
if (!existsSync13(f)) return emptyState();
|
|
7730
8013
|
try {
|
|
7731
|
-
const raw = JSON.parse(
|
|
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
|
-
|
|
8025
|
+
mkdirSync11(dirname9(f), { recursive: true });
|
|
7743
8026
|
const tmp = `${f}.tmp`;
|
|
7744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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>
|
|
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.
|
|
8728
|
+
io2.out(true ? "0.1.2" : "development");
|
|
8442
8729
|
return 0;
|
|
8443
8730
|
case "lan":
|
|
8444
8731
|
return await lanCommand(rest, io2);
|