forge-memory 0.2.118 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,32 +37,35 @@ the wiki tools (`forge_search_wiki`, `forge_get_wiki_page`, and maintenance
37
37
  tools). It also exposes `forge_memory_mcp_diagnostics` so adapter startup issues
38
38
  show up as a tool result instead of a closed MCP transport.
39
39
 
40
- `pair-ios` prefers the Iroh QR. Forge starts a Rust Iroh host, prints a QR payload
41
- with the desktop node id, pairing token, optional relay hint, ALPN
42
- `forge-companion/1`, and the request URL as a direct fallback when it is
43
- phone-reachable. The iPhone app connects through its native Rust bridge first and can
44
- retry through URLSession when that bridge times out. The CLI renders a short-schema QR
45
- to keep the terminal code scannable and saves the full manual payload under
46
- `~/.forge/pairing/` so you can paste it into the iPhone app if the camera cannot scan.
47
- Use `--manual-http` only when you intentionally want a LAN, Tailscale, or direct
48
- HTTP/TCP route. For a real iPhone, pass a phone-reachable URL:
40
+ `pair-ios` prefers Tailscale when it is installed, running, authenticated, and Forge
41
+ is reachable through the Mac's MagicDNS HTTPS URL. That gives the iPhone a normal
42
+ phone-reachable web URL for sync and the embedded Forge WebView. If Tailscale is
43
+ running but Forge is not served yet, the installer asks before configuring
44
+ `tailscale serve` for the local Forge runtime. If Tailscale is unavailable or
45
+ declined, Forge falls back to the Iroh QR: a Rust Iroh host with the desktop node id,
46
+ pairing token, optional relay hint, and ALPN `forge-companion/1`.
47
+
48
+ The CLI renders a short-schema QR to keep the terminal code scannable and saves the
49
+ full manual payload under `~/.forge/pairing/` so you can paste it into the iPhone app
50
+ if the camera cannot scan. Use `--public-url` when you intentionally want to force a
51
+ specific Tailscale, LAN, or fixed/private URL:
49
52
 
50
53
  ```bash
51
- npx forge-memory pair-ios --manual-http --public-url https://your-mac.tailnet.ts.net/forge/
54
+ npx forge-memory pair-ios --public-url https://your-mac.tailnet.ts.net/forge/
52
55
  ```
53
56
 
54
- Without `--public-url`, manual HTTP may resolve to `127.0.0.1`, which is useful for
55
- the iOS Simulator but not for a physical phone.
57
+ `--manual-http` is still available as an explicit direct HTTP/TCP override. Loopback
58
+ URLs such as `127.0.0.1` and `localhost` are rejected for physical-phone pairing.
56
59
 
57
60
  The base install stays one command on purpose. The detailed companion transport
58
61
  reference lives in the Forge repo at `docs/companion-iroh.md` and in the published
59
62
  docs at `https://albertbuchard.github.io/forge/companion-transport.html`. Forge
60
63
  Memory ships Forge's Iroh host source and lockfile, not native desktop binaries. When
61
- the default iOS pairing flow is selected, the installer checks for Cargo, offers to
62
- install the minimal Rust toolchain when the platform supports it, builds the local
63
- host from the bundled source, then creates the QR. If Cargo cannot be installed
64
- automatically, `install`, `configure`, and `pair-ios` stop with platform-specific
65
- steps instead of printing a localhost QR that a physical iPhone cannot use.
64
+ the Iroh fallback is selected, the installer checks for Cargo, offers to install the
65
+ minimal Rust toolchain when the platform supports it, builds the local host from the
66
+ bundled source, then creates the QR. If Cargo cannot be installed automatically,
67
+ `install`, `configure`, and `pair-ios` stop with platform-specific steps instead of
68
+ printing a localhost QR that a physical iPhone cannot use.
66
69
 
67
70
  `configure` reruns the full guided flow using the current config as defaults.
68
71
  Install and configure run Forge doctor before finishing. `doctor --repair` creates
@@ -362,6 +362,219 @@ function rustInstallGuidance() {
362
362
  ];
363
363
  }
364
364
 
365
+ function tailscaleInstallPlan() {
366
+ if (process.platform === "darwin") {
367
+ return {
368
+ installable: true,
369
+ autoInstallCommand: commandExists("brew")
370
+ ? { command: "brew", args: ["install", "--cask", "tailscale"] }
371
+ : null,
372
+ guidance: [
373
+ "Install Tailscale for macOS from https://tailscale.com/download/mac or with Homebrew: brew install --cask tailscale",
374
+ "Open Tailscale, sign in, and make sure this Mac and the iPhone are in the same tailnet.",
375
+ "Then rerun: npx forge-memory pair-ios"
376
+ ]
377
+ };
378
+ }
379
+ if (process.platform === "linux") {
380
+ return {
381
+ installable: true,
382
+ autoInstallCommand: commandExists("curl")
383
+ ? {
384
+ command: "sh",
385
+ args: ["-c", "curl -fsSL https://tailscale.com/install.sh | sh"]
386
+ }
387
+ : null,
388
+ guidance: [
389
+ "Install Tailscale with your package manager or the official installer: curl -fsSL https://tailscale.com/install.sh | sh",
390
+ "Start and authenticate it: sudo tailscale up",
391
+ "Then rerun: npx forge-memory pair-ios"
392
+ ]
393
+ };
394
+ }
395
+ if (process.platform === "win32") {
396
+ return {
397
+ installable: true,
398
+ autoInstallCommand: commandExists("winget")
399
+ ? {
400
+ command: "winget",
401
+ args: [
402
+ "install",
403
+ "--id",
404
+ "Tailscale.Tailscale",
405
+ "-e",
406
+ "--source",
407
+ "winget",
408
+ "--accept-package-agreements",
409
+ "--accept-source-agreements"
410
+ ]
411
+ }
412
+ : null,
413
+ guidance: [
414
+ "Install Tailscale for Windows from https://tailscale.com/download/windows or with winget: winget install Tailscale.Tailscale",
415
+ "Sign in, make sure this PC and the iPhone are in the same tailnet, then rerun: npx forge-memory pair-ios"
416
+ ]
417
+ };
418
+ }
419
+ return {
420
+ installable: false,
421
+ autoInstallCommand: null,
422
+ guidance: [
423
+ "Install Tailscale from https://tailscale.com/download if your platform supports it, then rerun: npx forge-memory pair-ios"
424
+ ]
425
+ };
426
+ }
427
+
428
+ function tailscaleAutodetectDisabled() {
429
+ return ["1", "true", "yes"].includes(
430
+ String(process.env.FORGE_MEMORY_SKIP_TAILSCALE_AUTODETECT ?? "").toLowerCase()
431
+ );
432
+ }
433
+
434
+ function parseTailscaleStatus(raw) {
435
+ if (!raw) return { running: false, authenticated: false, dnsName: null };
436
+ try {
437
+ const payload = JSON.parse(raw);
438
+ const self = payload.Self ?? payload.self ?? null;
439
+ const dnsName = String(self?.DNSName ?? self?.dnsName ?? "")
440
+ .trim()
441
+ .replace(/\.$/, "");
442
+ const backendState = String(payload.BackendState ?? payload.backendState ?? "");
443
+ const running = backendState.toLowerCase() === "running";
444
+ return {
445
+ running,
446
+ authenticated: running && Boolean(dnsName),
447
+ dnsName: dnsName || null,
448
+ backendState: backendState || null
449
+ };
450
+ } catch {
451
+ return { running: false, authenticated: false, dnsName: null };
452
+ }
453
+ }
454
+
455
+ function normalizeForgePublicUiUrl(value) {
456
+ const normalized = normalizePublicPairingUrl(value);
457
+ if (!normalized) return null;
458
+ const url = new URL(normalized);
459
+ if (!url.pathname || url.pathname === "/" || url.pathname.startsWith("/api/")) {
460
+ url.pathname = "/forge/";
461
+ }
462
+ if (!url.pathname.endsWith("/")) url.pathname = `${url.pathname}/`;
463
+ url.search = "";
464
+ url.hash = "";
465
+ return url.toString();
466
+ }
467
+
468
+ function forgePublicHealthUrl(publicUiUrl) {
469
+ const url = new URL(publicUiUrl);
470
+ url.pathname = "/api/v1/health";
471
+ url.search = "";
472
+ url.hash = "";
473
+ return url;
474
+ }
475
+
476
+ function publicUrlFallbackMode(publicUrl) {
477
+ try {
478
+ const host = new URL(publicUrl).hostname.toLowerCase();
479
+ return host.endsWith(".ts.net") ? "tailscale" : "fixed-ip";
480
+ } catch {
481
+ return "fixed-ip";
482
+ }
483
+ }
484
+
485
+ function detectTailscaleState() {
486
+ if (tailscaleAutodetectDisabled()) {
487
+ return {
488
+ installed: false,
489
+ running: false,
490
+ authenticated: false,
491
+ publicUrl: null,
492
+ disabled: true
493
+ };
494
+ }
495
+ if (!commandExists("tailscale")) {
496
+ return {
497
+ installed: false,
498
+ running: false,
499
+ authenticated: false,
500
+ publicUrl: null,
501
+ installPlan: tailscaleInstallPlan()
502
+ };
503
+ }
504
+ const status = parseTailscaleStatus(runCapture("tailscale", ["status", "--json"], 4_000));
505
+ const envPublicUrl = normalizeForgePublicUiUrl(process.env.FORGE_MEMORY_TAILSCALE_PUBLIC_URL);
506
+ const publicUrl =
507
+ envPublicUrl ??
508
+ (status.dnsName ? `https://${status.dnsName}/forge/` : null);
509
+ return {
510
+ installed: true,
511
+ running: status.running,
512
+ authenticated: status.authenticated,
513
+ publicUrl,
514
+ backendState: status.backendState,
515
+ dnsName: status.dnsName
516
+ };
517
+ }
518
+
519
+ async function probePublicForgeUrl(publicUrl, timeoutMs = 4_000) {
520
+ if (!publicUrl) return { ok: false, error: "no public URL candidate" };
521
+ const fakeProbeSequencePath =
522
+ process.env.FORGE_MEMORY_FAKE_TAILSCALE_PUBLIC_PROBE_SEQUENCE;
523
+ if (fakeProbeSequencePath) {
524
+ const source = fs.existsSync(fakeProbeSequencePath)
525
+ ? fs.readFileSync(fakeProbeSequencePath, "utf8")
526
+ : "";
527
+ const entries = source
528
+ .split(/\r?\n/)
529
+ .map((entry) => entry.trim())
530
+ .filter(Boolean);
531
+ const current = entries.shift() ?? "fail";
532
+ fs.writeFileSync(fakeProbeSequencePath, entries.length ? `${entries.join("\n")}\n` : "");
533
+ return current === "ok"
534
+ ? { ok: true, fake: true }
535
+ : { ok: false, error: current === "fail" ? "fake probe failure" : current };
536
+ }
537
+ if (
538
+ ["1", "true", "yes"].includes(
539
+ String(process.env.FORGE_MEMORY_SKIP_TAILSCALE_PUBLIC_PROBE ?? "").toLowerCase()
540
+ )
541
+ ) {
542
+ return { ok: true, skipped: true };
543
+ }
544
+ const controller = new AbortController();
545
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
546
+ try {
547
+ const response = await fetch(forgePublicHealthUrl(publicUrl), {
548
+ headers: { accept: "application/json", "x-forge-runtime-probe": "1" },
549
+ signal: controller.signal
550
+ });
551
+ if (!response.ok) return { ok: false, status: response.status };
552
+ const payload = await response.json().catch(() => null);
553
+ if (!isForgeHealthPayload(payload)) {
554
+ return { ok: false, status: response.status, error: "non-Forge health payload" };
555
+ }
556
+ return { ok: true, status: response.status };
557
+ } catch (error) {
558
+ return { ok: false, error: describeNetworkError(error) };
559
+ } finally {
560
+ clearTimeout(timeout);
561
+ }
562
+ }
563
+
564
+ async function configureTailscaleServe(config, flags) {
565
+ const target = `http://127.0.0.1:${config.port || DEFAULT_PORT}`;
566
+ return runCommand("tailscale", ["serve", "--bg", target], {
567
+ dryRun: flags?.dryRun
568
+ });
569
+ }
570
+
571
+ function tailscalePreferredMessage() {
572
+ return [
573
+ "Tailscale is preferred when available: it gives the iPhone a normal HTTPS Forge URL, is usually faster and steadier than relayed Iroh, and avoids the custom WebView URL scheme.",
574
+ "Iroh remains the fallback when Tailscale is missing, declined, or unreachable."
575
+ ].join(" ");
576
+ }
577
+
365
578
  function refreshCargoPath() {
366
579
  const cargoBin = path.join(homeDir(), ".cargo", "bin");
367
580
  if (fs.existsSync(cargoBin)) {
@@ -1972,11 +2185,7 @@ async function createPairing(config, options = {}) {
1972
2185
  body: JSON.stringify({
1973
2186
  userId: null,
1974
2187
  transportMode,
1975
- fallbackMode: publicUrl
1976
- ? publicUrl.includes(".ts.net")
1977
- ? "tailscale"
1978
- : "fixed-ip"
1979
- : "none",
2188
+ fallbackMode: publicUrl ? publicUrlFallbackMode(publicUrl) : "none",
1980
2189
  publicUrl: publicUrl ?? undefined
1981
2190
  })
1982
2191
  });
@@ -2016,7 +2225,128 @@ async function createPairing(config, options = {}) {
2016
2225
  return pairing;
2017
2226
  }
2018
2227
 
2019
- async function resolveIosPairingOptions(parsed) {
2228
+ async function maybeInstallTailscaleForPairing(parsed) {
2229
+ const plan = tailscaleInstallPlan();
2230
+ if (parsed.flags.json || parsed.flags.dryRun || !process.stdin.isTTY) {
2231
+ return { installed: false, guidance: plan.guidance };
2232
+ }
2233
+ console.log(color.bold("Tailscale pairing"));
2234
+ console.log(tailscalePreferredMessage());
2235
+ console.log(color.dim("Tailscale is not installed or not visible on PATH."));
2236
+ for (const item of plan.guidance) console.log(color.dim(`- ${item}`));
2237
+ if (!plan.installable || !plan.autoInstallCommand) {
2238
+ return { installed: false, guidance: plan.guidance };
2239
+ }
2240
+ const shouldInstall = await promptYesNo(
2241
+ "Install Tailscale now before falling back to Iroh?",
2242
+ true
2243
+ );
2244
+ if (!shouldInstall) return { installed: false, guidance: plan.guidance };
2245
+ const result = await runCommand(plan.autoInstallCommand.command, plan.autoInstallCommand.args);
2246
+ return { installed: result.ok, guidance: plan.guidance, result };
2247
+ }
2248
+
2249
+ async function resolveTailscalePairingOptions(parsed, config) {
2250
+ const state = detectTailscaleState();
2251
+ if (state.disabled) return null;
2252
+ if (!state.installed) {
2253
+ const installAttempt = await maybeInstallTailscaleForPairing(parsed);
2254
+ if (installAttempt.installed) {
2255
+ const installedState = detectTailscaleState();
2256
+ if (installedState.installed && installedState.running && installedState.authenticated) {
2257
+ return resolveTailscalePairingOptions(parsed, config);
2258
+ }
2259
+ }
2260
+ return null;
2261
+ }
2262
+ if (!state.running || !state.authenticated || !state.publicUrl) {
2263
+ if (!parsed.flags.json && process.stdin.isTTY) {
2264
+ console.log(color.bold("Tailscale pairing"));
2265
+ console.log(tailscalePreferredMessage());
2266
+ console.log(
2267
+ color.yellow(
2268
+ "Tailscale is installed, but it is not running/authenticated or does not expose a MagicDNS name."
2269
+ )
2270
+ );
2271
+ console.log(color.dim("Open Tailscale, sign in, then rerun npx forge-memory pair-ios. Falling back to Iroh for this pairing."));
2272
+ }
2273
+ return null;
2274
+ }
2275
+
2276
+ const firstProbe = await probePublicForgeUrl(state.publicUrl);
2277
+ if (firstProbe.ok) {
2278
+ if (!parsed.flags.json) {
2279
+ console.log(color.green(`Using Tailscale for iOS pairing: ${state.publicUrl}`));
2280
+ }
2281
+ return {
2282
+ transportMode: "manual-http",
2283
+ publicUrl: validatePairingOptions({
2284
+ transportMode: "manual-http",
2285
+ publicUrl: state.publicUrl
2286
+ }),
2287
+ source: "tailscale"
2288
+ };
2289
+ }
2290
+
2291
+ const canConfigureServe =
2292
+ !parsed.flags.json &&
2293
+ !parsed.flags.dryRun &&
2294
+ (parsed.flags.yes ||
2295
+ (process.stdin.isTTY &&
2296
+ (await promptYesNo(
2297
+ [
2298
+ `Tailscale is running, but Forge is not reachable at ${state.publicUrl}.`,
2299
+ "Serve Forge through Tailscale now?"
2300
+ ].join(" "),
2301
+ true
2302
+ ))));
2303
+ if (!canConfigureServe) {
2304
+ if (!parsed.flags.json && process.stdin.isTTY) {
2305
+ console.log(
2306
+ color.yellow(
2307
+ `Tailscale is running, but Forge was not reachable at ${state.publicUrl}: ${firstProbe.error ?? firstProbe.status ?? "unknown error"}. Falling back to Iroh.`
2308
+ )
2309
+ );
2310
+ }
2311
+ return null;
2312
+ }
2313
+
2314
+ const serveResult = await configureTailscaleServe(config, parsed.flags);
2315
+ if (!serveResult.ok) {
2316
+ if (!parsed.flags.json) {
2317
+ console.log(
2318
+ color.yellow(
2319
+ "Could not configure Tailscale Serve automatically. Falling back to Iroh."
2320
+ )
2321
+ );
2322
+ }
2323
+ return null;
2324
+ }
2325
+ const secondProbe = await probePublicForgeUrl(state.publicUrl, 8_000);
2326
+ if (!secondProbe.ok) {
2327
+ if (!parsed.flags.json) {
2328
+ console.log(
2329
+ color.yellow(
2330
+ `Tailscale Serve was configured, but Forge still was not reachable at ${state.publicUrl}: ${secondProbe.error ?? secondProbe.status ?? "unknown error"}. Falling back to Iroh.`
2331
+ )
2332
+ );
2333
+ }
2334
+ return null;
2335
+ }
2336
+ if (!parsed.flags.json) {
2337
+ console.log(color.green(`Using Tailscale for iOS pairing: ${state.publicUrl}`));
2338
+ }
2339
+ return {
2340
+ transportMode: "manual-http",
2341
+ publicUrl: validatePairingOptions({
2342
+ transportMode: "manual-http",
2343
+ publicUrl: state.publicUrl
2344
+ }),
2345
+ source: "tailscale"
2346
+ };
2347
+ }
2348
+
2349
+ async function resolveIosPairingOptions(parsed, config = null) {
2020
2350
  if (parsed.flags.manualHttp) {
2021
2351
  return {
2022
2352
  transportMode: "manual-http",
@@ -2026,7 +2356,21 @@ async function resolveIosPairingOptions(parsed) {
2026
2356
  })
2027
2357
  };
2028
2358
  }
2029
- if (parsed.flags.yes || parsed.flags.json || !process.stdin.isTTY || parsed.values.publicUrl) {
2359
+ if (parsed.values.publicUrl) {
2360
+ return {
2361
+ transportMode: "manual-http",
2362
+ publicUrl: validatePairingOptions({
2363
+ transportMode: "manual-http",
2364
+ publicUrl: parsed.values.publicUrl
2365
+ }),
2366
+ source: publicUrlFallbackMode(parsed.values.publicUrl)
2367
+ };
2368
+ }
2369
+ if (config) {
2370
+ const tailscalePairing = await resolveTailscalePairingOptions(parsed, config);
2371
+ if (tailscalePairing) return tailscalePairing;
2372
+ }
2373
+ if (parsed.flags.yes || parsed.flags.json || !process.stdin.isTTY) {
2030
2374
  return {
2031
2375
  transportMode: "iroh",
2032
2376
  publicUrl: validatePairingOptions({
@@ -2036,23 +2380,11 @@ async function resolveIosPairingOptions(parsed) {
2036
2380
  };
2037
2381
  }
2038
2382
  console.log(color.bold("iOS companion connection"));
2039
- console.log(color.dim("Default: Iroh tunnel. You can choose Tailscale or a fixed/private IP as the direct phone path."));
2383
+ console.log(color.dim(tailscalePreferredMessage()));
2384
+ console.log(color.dim("Choose Iroh or a fixed/private IP fallback for this pairing."));
2040
2385
  const choice = (
2041
- await promptLine("Connection [iroh/tailscale/ip]", "iroh")
2386
+ await promptLine("Connection [iroh/ip]", "iroh")
2042
2387
  ).toLowerCase();
2043
- if (choice === "tailscale" || choice === "ts") {
2044
- const publicUrl = await promptLine(
2045
- "Tailscale Forge URL",
2046
- parsed.values.publicUrl ?? "https://your-mac.tailnet.ts.net/forge/"
2047
- );
2048
- return {
2049
- transportMode: "manual-http",
2050
- publicUrl: validatePairingOptions({
2051
- transportMode: "manual-http",
2052
- publicUrl
2053
- })
2054
- };
2055
- }
2056
2388
  if (choice === "ip" || choice === "fixed" || choice === "private") {
2057
2389
  const publicUrl = await promptLine(
2058
2390
  "Private/fixed Forge URL",
@@ -2375,22 +2707,6 @@ async function runInstall(parsed, command) {
2375
2707
  (parsed.flags.yes
2376
2708
  ? true
2377
2709
  : await promptYesNo("Pair the iOS companion now?", true)));
2378
- const pairingOptions = shouldPair
2379
- ? await resolveIosPairingOptions(parsed)
2380
- : null;
2381
- let irohTransportResult = null;
2382
- if (
2383
- shouldPair &&
2384
- pairingOptions?.transportMode !== "manual-http" &&
2385
- !parsed.flags.dryRun
2386
- ) {
2387
- irohTransportResult = await withProgress(
2388
- "Preparing Forge Companion Iroh transport",
2389
- "checking Rust/Cargo and building the local host",
2390
- parsed.flags,
2391
- () => ensureIrohTransportPrepared(config, parsed.flags)
2392
- );
2393
- }
2394
2710
  let runtimeResult = null;
2395
2711
  if (!parsed.flags.noStart && !parsed.flags.dryRun) {
2396
2712
  runtimeResult = await withProgress(
@@ -2428,6 +2744,22 @@ async function runInstall(parsed, command) {
2428
2744
  }
2429
2745
  }
2430
2746
  }
2747
+ const pairingOptions = shouldPair
2748
+ ? await resolveIosPairingOptions(parsed, config)
2749
+ : null;
2750
+ let irohTransportResult = null;
2751
+ if (
2752
+ shouldPair &&
2753
+ pairingOptions?.transportMode !== "manual-http" &&
2754
+ !parsed.flags.dryRun
2755
+ ) {
2756
+ irohTransportResult = await withProgress(
2757
+ "Preparing Forge Companion Iroh transport",
2758
+ "checking Rust/Cargo and building the local host",
2759
+ parsed.flags,
2760
+ () => ensureIrohTransportPrepared(config, parsed.flags)
2761
+ );
2762
+ }
2431
2763
  let pairing = null;
2432
2764
  if (shouldPair && !parsed.flags.dryRun) {
2433
2765
  if (!runtimeResult) {
@@ -2441,7 +2773,7 @@ async function runInstall(parsed, command) {
2441
2773
  assertRuntimeStartedForPairing(runtimeResult, config);
2442
2774
  pairing = await withProgress(
2443
2775
  "Creating iOS companion pairing",
2444
- parsed.flags.manualHttp ? "manual HTTP" : "Iroh QR",
2776
+ pairingOptions.transportMode === "manual-http" ? "phone-reachable HTTP" : "Iroh QR",
2445
2777
  parsed.flags,
2446
2778
  () =>
2447
2779
  createPairing(config, {
@@ -2672,10 +3004,15 @@ async function runUi(parsed) {
2672
3004
 
2673
3005
  async function runPairIos(parsed) {
2674
3006
  const config = await readConfig();
2675
- const pairingOptions = await resolveIosPairingOptions(parsed);
2676
- const transportMode = pairingOptions.transportMode;
2677
- const publicUrl = pairingOptions.publicUrl;
2678
- if (transportMode === "iroh") {
3007
+ const explicitPairingOptions =
3008
+ parsed.flags.manualHttp || parsed.values.publicUrl
3009
+ ? await resolveIosPairingOptions(parsed, config)
3010
+ : null;
3011
+ const noStartPairingOptions =
3012
+ !explicitPairingOptions && parsed.flags.noStart
3013
+ ? await resolveIosPairingOptions(parsed, config)
3014
+ : null;
3015
+ if (noStartPairingOptions?.transportMode === "iroh") {
2679
3016
  await withProgress(
2680
3017
  "Preparing Forge Companion Iroh transport",
2681
3018
  "checking Rust/Cargo and building the local host",
@@ -2698,6 +3035,20 @@ async function runPairIos(parsed) {
2698
3035
  config
2699
3036
  );
2700
3037
  }
3038
+ const pairingOptions =
3039
+ explicitPairingOptions ??
3040
+ noStartPairingOptions ??
3041
+ await resolveIosPairingOptions(parsed, config);
3042
+ const transportMode = pairingOptions.transportMode;
3043
+ const publicUrl = pairingOptions.publicUrl;
3044
+ if (transportMode === "iroh" && noStartPairingOptions?.transportMode !== "iroh") {
3045
+ await withProgress(
3046
+ "Preparing Forge Companion Iroh transport",
3047
+ "checking Rust/Cargo and building the local host",
3048
+ parsed.flags,
3049
+ () => ensureIrohTransportPrepared(config, parsed.flags)
3050
+ );
3051
+ }
2701
3052
  const pairing = await createPairing(config, {
2702
3053
  transportMode,
2703
3054
  publicUrl
@@ -3037,8 +3388,8 @@ Options:
3037
3388
  --adapters <list> Comma list: openclaw,hermes,codex or none
3038
3389
  --skip-adapters Configure UI/runtime only
3039
3390
  --skip-pair-ios Do not prompt or create iOS pairing
3040
- --manual-http Use direct HTTP/TCP for iOS pairing instead of the default Iroh transport
3041
- --public-url <url> Phone-facing Tailscale/LAN/fixed URL for direct pairing or Iroh fallback; never localhost
3391
+ --manual-http Force direct HTTP/TCP for iOS pairing
3392
+ --public-url <url> Phone-facing Tailscale/LAN/fixed URL for direct pairing; never localhost
3042
3393
  --no-start Configure without starting runtime
3043
3394
  --no-doctor Skip install-time doctor checks
3044
3395
  --repair Let doctor create missing folders and restart unhealthy runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-memory",
3
- "version": "0.2.118",
3
+ "version": "0.3.0",
4
4
  "description": "Guided Forge installer and local runtime manager for the Forge UI, OpenClaw, Hermes, Codex, and iOS pairing.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",