sakuraai 0.0.9 → 0.0.11

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/index.js CHANGED
@@ -103,8 +103,8 @@ var init_config = __esm({
103
103
  function apiBase() {
104
104
  return (loadAuth().serverUrl || DEFAULT_API_URL).replace(/\/+$/, "");
105
105
  }
106
- async function postJson(base, path10, body, token) {
107
- const res = await fetch(`${base}${path10}`, {
106
+ async function postJson(base, path12, body, token) {
107
+ const res = await fetch(`${base}${path12}`, {
108
108
  method: "POST",
109
109
  headers: {
110
110
  "Content-Type": "application/json",
@@ -114,7 +114,7 @@ async function postJson(base, path10, body, token) {
114
114
  });
115
115
  if (!res.ok) {
116
116
  const text = await res.text().catch(() => "");
117
- throw new Error(`${path10} \u2192 ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
117
+ throw new Error(`${path12} \u2192 ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
118
118
  }
119
119
  return await res.json();
120
120
  }
@@ -535,7 +535,7 @@ function sessionCwd(sessionId) {
535
535
  }
536
536
  return void 0;
537
537
  }
538
- function send(sessionId, message, onData, images) {
538
+ function send(sessionId, message, onData, images, model) {
539
539
  return new Promise((resolve) => {
540
540
  const bin = process.env.CLAUDE_BIN || "claude";
541
541
  const cwd = sessionCwd(sessionId);
@@ -546,6 +546,7 @@ function send(sessionId, message, onData, images) {
546
546
 
547
547
  ${refs}` : refs;
548
548
  }
549
+ const modelArgs = model ? ["--model", model] : [];
549
550
  const child2 = spawn2(
550
551
  bin,
551
552
  [
@@ -553,6 +554,7 @@ ${refs}` : refs;
553
554
  sessionId,
554
555
  "-p",
555
556
  prompt,
557
+ ...modelArgs,
556
558
  "--output-format",
557
559
  "stream-json",
558
560
  "--include-partial-messages",
@@ -723,10 +725,11 @@ function messages2(sessionId) {
723
725
  const file = findFile2(sessionId);
724
726
  return file ? readMessages2(file) : [];
725
727
  }
726
- function send2(sessionId, message, onData, images) {
728
+ function send2(sessionId, message, onData, images, model) {
727
729
  return new Promise((resolve) => {
728
730
  const imageArgs = (images ?? []).flatMap((p) => ["-i", p]);
729
- const child2 = spawn3(codexBin(), ["exec", "resume", sessionId, ...imageArgs, message], {
731
+ const modelArgs = model ? ["--model", model] : [];
732
+ const child2 = spawn3(codexBin(), ["exec", ...modelArgs, "resume", sessionId, ...imageArgs, message], {
730
733
  stdio: ["ignore", "pipe", "pipe"],
731
734
  env: process.env
732
735
  });
@@ -870,10 +873,11 @@ async function messages3(sessionId) {
870
873
  db.close();
871
874
  }
872
875
  }
873
- function send3(sessionId, message, onData, images) {
876
+ function send3(sessionId, message, onData, images, model) {
874
877
  return new Promise((resolve) => {
875
878
  const fileArgs = (images ?? []).flatMap((p) => ["-f", p]);
876
- const child2 = spawn4(opencodeBin(), ["run", "-s", sessionId, ...fileArgs, message], {
879
+ const modelArgs = model ? ["-m", model] : [];
880
+ const child2 = spawn4(opencodeBin(), ["run", "-s", sessionId, ...modelArgs, ...fileArgs, message], {
877
881
  stdio: ["ignore", "pipe", "pipe"],
878
882
  env: process.env
879
883
  });
@@ -1002,11 +1006,11 @@ async function history(sessionId, opts = {}, agent) {
1002
1006
  if (opts.reverse) msgs = [...msgs].reverse();
1003
1007
  return msgs;
1004
1008
  }
1005
- async function chat(sessionId, prompt, agent, onData, images) {
1009
+ async function chat(sessionId, prompt, agent, onData, images, model) {
1006
1010
  const a = agent ?? await detectAgent(sessionId);
1007
- if (a === "claude") return send(sessionId, prompt, onData, images);
1008
- if (a === "codex") return send2(sessionId, prompt, onData, images);
1009
- if (a === "opencode") return send3(sessionId, prompt, onData, images);
1011
+ if (a === "claude") return send(sessionId, prompt, onData, images, model);
1012
+ if (a === "codex") return send2(sessionId, prompt, onData, images, model);
1013
+ if (a === "opencode") return send3(sessionId, prompt, onData, images, model);
1010
1014
  return {
1011
1015
  ok: false,
1012
1016
  output: "",
@@ -1512,6 +1516,94 @@ var init_tailscale = __esm({
1512
1516
  }
1513
1517
  });
1514
1518
 
1519
+ // src/runtime/models.ts
1520
+ import fs10 from "fs";
1521
+ import path8 from "path";
1522
+ import { execFileSync } from "child_process";
1523
+ function codexModels() {
1524
+ const out = [{ id: "", label: "Default" }];
1525
+ const seen = /* @__PURE__ */ new Set();
1526
+ let configured;
1527
+ try {
1528
+ const cfg = fs10.readFileSync(path8.join(HOME, ".codex", "config.toml"), "utf8");
1529
+ configured = /^\s*model\s*=\s*"([^"]+)"/m.exec(cfg)?.[1];
1530
+ for (const m of cfg.matchAll(/model\s*=\s*"([^"]+)"/g)) {
1531
+ const id = m[1];
1532
+ if (id && !seen.has(id)) {
1533
+ seen.add(id);
1534
+ out.push({ id, label: id === configured ? `${id} (config)` : id });
1535
+ }
1536
+ }
1537
+ } catch {
1538
+ }
1539
+ for (const id of CODEX_CURATED) {
1540
+ if (!seen.has(id)) {
1541
+ seen.add(id);
1542
+ out.push({ id, label: id });
1543
+ }
1544
+ }
1545
+ return out;
1546
+ }
1547
+ function opencodeModels() {
1548
+ const out = [{ id: "", label: "Default" }];
1549
+ const allowed = /* @__PURE__ */ new Set(["opencode"]);
1550
+ try {
1551
+ const auth = JSON.parse(
1552
+ fs10.readFileSync(path8.join(HOME, ".local", "share", "opencode", "auth.json"), "utf8")
1553
+ );
1554
+ for (const k of Object.keys(auth)) allowed.add(k);
1555
+ } catch {
1556
+ }
1557
+ try {
1558
+ const raw = execFileSync(opencodeBin(), ["models"], { encoding: "utf8", timeout: 15e3 });
1559
+ for (const line of raw.split("\n").map((l) => l.trim()).filter(Boolean)) {
1560
+ const slash = line.indexOf("/");
1561
+ if (slash < 0) continue;
1562
+ const provider = line.slice(0, slash);
1563
+ const model = line.slice(slash + 1);
1564
+ if (!allowed.has(provider)) continue;
1565
+ out.push({
1566
+ id: line,
1567
+ // opencode wants the full provider/model string
1568
+ label: model,
1569
+ group: OC_PROVIDER_LABELS[provider] ?? provider,
1570
+ free: provider === "opencode" || /free/i.test(model)
1571
+ });
1572
+ }
1573
+ } catch {
1574
+ }
1575
+ return out;
1576
+ }
1577
+ function listModels(agent) {
1578
+ if (agent === "codex") return codexModels();
1579
+ if (agent === "opencode") return opencodeModels();
1580
+ return CLAUDE_MODELS;
1581
+ }
1582
+ var CLAUDE_MODELS, CODEX_CURATED, OC_PROVIDER_LABELS;
1583
+ var init_models = __esm({
1584
+ "src/runtime/models.ts"() {
1585
+ "use strict";
1586
+ init_util();
1587
+ init_opencode();
1588
+ CLAUDE_MODELS = [
1589
+ { id: "", label: "Default" },
1590
+ { id: "opus", label: "Opus" },
1591
+ { id: "sonnet", label: "Sonnet" },
1592
+ { id: "haiku", label: "Haiku" },
1593
+ { id: "opus[1m]", label: "Opus (1M context)" }
1594
+ ];
1595
+ CODEX_CURATED = ["gpt-5.5-codex", "gpt-5.5", "gpt-5-codex", "gpt-5"];
1596
+ OC_PROVIDER_LABELS = {
1597
+ opencode: "OpenCode Zen",
1598
+ "opencode-go": "OpenCode Go",
1599
+ openrouter: "OpenRouter",
1600
+ zai: "Z.AI",
1601
+ "zai-coding-plan": "Z.AI Coding",
1602
+ "zhipuai-coding-plan": "Zhipu Coding"
1603
+ };
1604
+ }
1605
+ });
1606
+
1515
1607
  // src/daemon/hub.ts
1516
1608
  function sessionKey(agent, sessionId) {
1517
1609
  return `${agent}:${sessionId}`;
@@ -1550,7 +1642,7 @@ var init_hub = __esm({
1550
1642
  });
1551
1643
 
1552
1644
  // src/daemon/stream.ts
1553
- async function runTurn(sessionId, agent, prompt, images) {
1645
+ async function runTurn(sessionId, agent, prompt, images, model) {
1554
1646
  const key = sessionKey(agent, sessionId);
1555
1647
  const emit = (e) => hub.broadcast(key, e);
1556
1648
  let lastEmitted = "\0";
@@ -1590,7 +1682,7 @@ async function runTurn(sessionId, agent, prompt, images) {
1590
1682
  } catch {
1591
1683
  }
1592
1684
  }, POLL_MS);
1593
- const result = await chat(sessionId, prompt, agent, onData, images);
1685
+ const result = await chat(sessionId, prompt, agent, onData, images, model);
1594
1686
  clearInterval(timer);
1595
1687
  emit({ type: "done", sessionId, agent, ok: result.ok, error: result.error });
1596
1688
  notifyTurnEnd(agent, sessionId, result.ok, result.error, stdoutAcc);
@@ -1698,15 +1790,15 @@ var init_terminal = __esm({
1698
1790
 
1699
1791
  // src/daemon/server.ts
1700
1792
  import http from "http";
1701
- import fs10 from "fs";
1793
+ import fs11 from "fs";
1702
1794
  import os7 from "os";
1703
- import path8 from "path";
1795
+ import path9 from "path";
1704
1796
  import { URL } from "url";
1705
1797
  import { WebSocketServer } from "ws";
1706
- function add(method, path10, handler) {
1798
+ function add(method, path12, handler) {
1707
1799
  const keys = [];
1708
1800
  const pattern = new RegExp(
1709
- "^" + path10.replace(/:[^/]+/g, (m) => {
1801
+ "^" + path12.replace(/:[^/]+/g, (m) => {
1710
1802
  keys.push(m.slice(1));
1711
1803
  return "([^/]+)";
1712
1804
  }) + "/?$"
@@ -1720,8 +1812,8 @@ function fail(code, stderr, data = null) {
1720
1812
  return { ok: false, data, stdout: "", stderr, code };
1721
1813
  }
1722
1814
  function saveImages(sessionId, images) {
1723
- const dir = path8.join(os7.tmpdir(), "sakura-uploads", sessionId.slice(0, 24));
1724
- fs10.mkdirSync(dir, { recursive: true });
1815
+ const dir = path9.join(os7.tmpdir(), "sakura-uploads", sessionId.slice(0, 24));
1816
+ fs11.mkdirSync(dir, { recursive: true });
1725
1817
  const paths = [];
1726
1818
  images.forEach((img, i) => {
1727
1819
  const raw = String(img?.data ?? "");
@@ -1732,8 +1824,8 @@ function saveImages(sessionId, images) {
1732
1824
  }
1733
1825
  const ext = EXT_BY_MIME[m[1]] ?? "png";
1734
1826
  const safe = img.name && /\.[a-z0-9]+$/i.test(img.name) ? img.name.replace(/[^\w.\-]/g, "_") : `image-${Date.now()}-${i + 1}.${ext}`;
1735
- const file = path8.join(dir, safe);
1736
- fs10.writeFileSync(file, Buffer.from(m[2], "base64"));
1827
+ const file = path9.join(dir, safe);
1828
+ fs11.writeFileSync(file, Buffer.from(m[2], "base64"));
1737
1829
  log.info(`saveImages: wrote ${file} (${m[2].length} b64 chars)`);
1738
1830
  paths.push(file);
1739
1831
  });
@@ -1874,6 +1966,7 @@ var init_server = __esm({
1874
1966
  init_fsops();
1875
1967
  init_tailscale();
1876
1968
  init_registry();
1969
+ init_models();
1877
1970
  init_hub();
1878
1971
  init_stream();
1879
1972
  init_terminal();
@@ -1905,7 +1998,8 @@ var init_server = __esm({
1905
1998
  log.info(`send: agent=${agent} msgLen=${message.length} bodyKeys=[${Object.keys(body ?? {}).join(",")}] images=${incoming.length}`);
1906
1999
  const imagePaths = incoming.length ? saveImages(params.id, incoming) : [];
1907
2000
  if (imagePaths.length) log.info(`send: attached ${imagePaths.length} image(s) to ${agent} prompt`);
1908
- const r = await runTurn(params.id, agent, message, imagePaths);
2001
+ const model = body?.model ? String(body.model) : void 0;
2002
+ const r = await runTurn(params.id, agent, message, imagePaths, model);
1909
2003
  return { ok: r.ok, error: r.error };
1910
2004
  });
1911
2005
  add("POST", "/sessions/:id/rename", ({ params, body }) => {
@@ -1944,7 +2038,7 @@ var init_server = __esm({
1944
2038
  case "logs": {
1945
2039
  let logs2 = "";
1946
2040
  try {
1947
- const lines = fs10.readFileSync(LOG_PATH, "utf8").split("\n");
2041
+ const lines = fs11.readFileSync(LOG_PATH, "utf8").split("\n");
1948
2042
  logs2 = lines.slice(-200).join("\n");
1949
2043
  } catch {
1950
2044
  }
@@ -1977,6 +2071,10 @@ var init_server = __esm({
1977
2071
  ({ url }) => ok(listMachines(url.searchParams.get("onlineOnly") === "true"))
1978
2072
  );
1979
2073
  add("GET", "/sakura/projects", () => ok(listProjects()));
2074
+ add("GET", "/sakura/models", ({ url }) => {
2075
+ const agent = url.searchParams.get("agent") || "claude";
2076
+ return ok(listModels(agent));
2077
+ });
1980
2078
  add("POST", "/sakura/push-token", ({ body }) => {
1981
2079
  const token = String(body?.token ?? "");
1982
2080
  if (!token) return fail("bad_request", "token required");
@@ -2012,7 +2110,8 @@ var init_server = __esm({
2012
2110
  add("POST", "/sakura/sessions/:id/chat", async ({ params, body }) => {
2013
2111
  const agent = body?.agent || await detectAgent(params.id);
2014
2112
  if (!agent) return fail("nonzero", "unknown agent for session");
2015
- const r = await runTurn(params.id, agent, String(body?.prompt ?? ""));
2113
+ const model = body?.model ? String(body.model) : void 0;
2114
+ const r = await runTurn(params.id, agent, String(body?.prompt ?? ""), void 0, model);
2016
2115
  return r.ok ? ok({ ok: true }) : fail("nonzero", r.error ?? "chat failed");
2017
2116
  });
2018
2117
  add(
@@ -2131,24 +2230,24 @@ var init_server = __esm({
2131
2230
  });
2132
2231
 
2133
2232
  // src/daemon/tsnet.ts
2134
- import fs11 from "fs";
2135
- import path9 from "path";
2233
+ import fs12 from "fs";
2234
+ import path10 from "path";
2136
2235
  import { fileURLToPath } from "url";
2137
2236
  import { spawn as spawn8 } from "child_process";
2138
2237
  function resolveBinary() {
2139
2238
  const envBin = process.env.SAKURA_TSNET_BIN;
2140
- if (envBin && fs11.existsSync(envBin)) return envBin;
2141
- const here = path9.dirname(fileURLToPath(import.meta.url));
2239
+ if (envBin && fs12.existsSync(envBin)) return envBin;
2240
+ const here = path10.dirname(fileURLToPath(import.meta.url));
2142
2241
  const name = process.platform === "win32" ? "sakura-tsnet.exe" : "sakura-tsnet";
2143
2242
  const candidates = [
2144
- path9.join(here, "..", "bin", name),
2243
+ path10.join(here, "..", "bin", name),
2145
2244
  // package/bin (postinstall download / build-sidecar.sh)
2146
- path9.join(here, "bin", name),
2147
- path9.join(process.cwd(), "bin", name),
2148
- path9.join(SAKURA_DIR, name)
2245
+ path10.join(here, "bin", name),
2246
+ path10.join(process.cwd(), "bin", name),
2247
+ path10.join(SAKURA_DIR, name)
2149
2248
  // user-dropped
2150
2249
  ];
2151
- return candidates.find((p) => fs11.existsSync(p)) ?? null;
2250
+ return candidates.find((p) => fs12.existsSync(p)) ?? null;
2152
2251
  }
2153
2252
  function startTsnet(port2) {
2154
2253
  const cfg = loadConfig();
@@ -2171,11 +2270,11 @@ function spawnSidecar(bin, controlUrl, authKey, hostname2, port2) {
2171
2270
  SAKURA_TS_CONTROL_URL: controlUrl,
2172
2271
  SAKURA_TS_AUTH_KEY: authKey,
2173
2272
  SAKURA_TS_HOSTNAME: hostname2,
2174
- SAKURA_TS_STATE_DIR: path9.join(SAKURA_DIR, "tsnet-state"),
2273
+ SAKURA_TS_STATE_DIR: path10.join(SAKURA_DIR, "tsnet-state"),
2175
2274
  SAKURA_TS_STATE_FILE: TSNET_PATH,
2176
2275
  SAKURA_TS_PORT: String(port2)
2177
2276
  };
2178
- log.info(`starting embedded Tailscale sidecar (${path9.basename(bin)})`);
2277
+ log.info(`starting embedded Tailscale sidecar (${path10.basename(bin)})`);
2179
2278
  const c = spawn8(bin, [], { stdio: ["ignore", "pipe", "pipe"], env });
2180
2279
  child = c;
2181
2280
  const pipe = (buf) => buf.toString().split("\n").filter(Boolean).forEach((line) => log.info(`[tsnet] ${line}`));
@@ -2204,7 +2303,7 @@ function stopTsnet() {
2204
2303
  child = null;
2205
2304
  }
2206
2305
  try {
2207
- fs11.rmSync(TSNET_PATH, { force: true });
2306
+ fs12.rmSync(TSNET_PATH, { force: true });
2208
2307
  } catch {
2209
2308
  }
2210
2309
  }
@@ -2221,7 +2320,7 @@ var init_tsnet = __esm({
2221
2320
  });
2222
2321
 
2223
2322
  // src/daemon/manager.ts
2224
- import fs12 from "fs";
2323
+ import fs13 from "fs";
2225
2324
  import { spawn as spawn9 } from "child_process";
2226
2325
  async function ensureTailnetProvisioned() {
2227
2326
  const serverToken = getServerToken();
@@ -2244,7 +2343,7 @@ async function ensureTailnetProvisioned() {
2244
2343
  }
2245
2344
  function readDaemonInfo() {
2246
2345
  try {
2247
- return JSON.parse(fs12.readFileSync(DAEMON_PATH, "utf8"));
2346
+ return JSON.parse(fs13.readFileSync(DAEMON_PATH, "utf8"));
2248
2347
  } catch {
2249
2348
  return null;
2250
2349
  }
@@ -2261,8 +2360,8 @@ function running() {
2261
2360
  const info2 = readDaemonInfo();
2262
2361
  if (info2 && pidAlive(info2.pid)) return info2;
2263
2362
  if (info2) {
2264
- fs12.rmSync(DAEMON_PATH, { force: true });
2265
- fs12.rmSync(PID_PATH, { force: true });
2363
+ fs13.rmSync(DAEMON_PATH, { force: true });
2364
+ fs13.rmSync(PID_PATH, { force: true });
2266
2365
  }
2267
2366
  return null;
2268
2367
  }
@@ -2285,8 +2384,8 @@ async function runServer(version) {
2285
2384
  url: `http://${host}:${port2}`,
2286
2385
  startedAt: Date.now()
2287
2386
  };
2288
- fs12.writeFileSync(DAEMON_PATH, JSON.stringify(info2, null, 2));
2289
- fs12.writeFileSync(PID_PATH, String(process.pid));
2387
+ fs13.writeFileSync(DAEMON_PATH, JSON.stringify(info2, null, 2));
2388
+ fs13.writeFileSync(PID_PATH, String(process.pid));
2290
2389
  await ensureTailnetProvisioned();
2291
2390
  startTsnet(port2);
2292
2391
  const tailnet = await discoverBaseUrl(port2).catch(() => null);
@@ -2297,8 +2396,8 @@ async function runServer(version) {
2297
2396
  log.info("shutting down");
2298
2397
  stopTsnet();
2299
2398
  server.close();
2300
- fs12.rmSync(DAEMON_PATH, { force: true });
2301
- fs12.rmSync(PID_PATH, { force: true });
2399
+ fs13.rmSync(DAEMON_PATH, { force: true });
2400
+ fs13.rmSync(PID_PATH, { force: true });
2302
2401
  process.exit(0);
2303
2402
  };
2304
2403
  process.on("SIGINT", shutdown);
@@ -2309,8 +2408,8 @@ function start() {
2309
2408
  if (existing) return existing;
2310
2409
  ensureSakuraDirs();
2311
2410
  const entry = process.argv[1] ?? "";
2312
- const out = fs12.openSync(LOG_PATH, "a");
2313
- const err = fs12.openSync(LOG_PATH, "a");
2411
+ const out = fs13.openSync(LOG_PATH, "a");
2412
+ const err = fs13.openSync(LOG_PATH, "a");
2314
2413
  const child2 = spawn9(process.execPath, [entry, "__run-daemon"], {
2315
2414
  detached: true,
2316
2415
  stdio: ["ignore", out, err],
@@ -2332,8 +2431,8 @@ function stop() {
2332
2431
  process.kill(info2.pid, "SIGTERM");
2333
2432
  } catch {
2334
2433
  }
2335
- fs12.rmSync(DAEMON_PATH, { force: true });
2336
- fs12.rmSync(PID_PATH, { force: true });
2434
+ fs13.rmSync(DAEMON_PATH, { force: true });
2435
+ fs13.rmSync(PID_PATH, { force: true });
2337
2436
  return true;
2338
2437
  }
2339
2438
  function restart() {
@@ -2343,7 +2442,7 @@ function restart() {
2343
2442
  }
2344
2443
  function logs(lines = 50) {
2345
2444
  try {
2346
- const all2 = fs12.readFileSync(LOG_PATH, "utf8").split("\n");
2445
+ const all2 = fs13.readFileSync(LOG_PATH, "utf8").split("\n");
2347
2446
  return all2.slice(-lines).join("\n");
2348
2447
  } catch {
2349
2448
  return "";
@@ -2428,11 +2527,15 @@ async function showPairing(opts = {}) {
2428
2527
  info(` Token: ${pairing.token}`);
2429
2528
  info("");
2430
2529
  if (pairing.embeddedTailscale) {
2431
- info("Embedded Tailscale: the QR also carries the tailnet join key, so the");
2432
- info("phone connects privately over the tunnel with no Tailscale app needed.");
2530
+ info("Embedded Tailscale: the QR carries the private tailnet join key, so the");
2531
+ info("phone connects over the tunnel from any network \u2014 no Tailscale app needed.");
2433
2532
  info("");
2533
+ info("\u26A0 Scan the QR to connect from another network or mobile data. Pasting the");
2534
+ info(" URL + token by hand does NOT include the join key, so it only works when");
2535
+ info(" the phone is on the same Wi\u2011Fi as this Mac.");
2536
+ } else {
2537
+ info("Or paste the URL + token into the app (Settings \u2192 Connect) \u2014 same Wi\u2011Fi.");
2434
2538
  }
2435
- info("Or paste the token into the app (Settings \u2192 Connect).");
2436
2539
  }
2437
2540
  function registerPair(program) {
2438
2541
  program.command("pair").description("Show a QR code + token to pair the Sakura mobile app").option("--json", "output the pairing payload as JSON").action(async (opts) => {
@@ -2461,7 +2564,7 @@ var init_pair = __esm({
2461
2564
  import { Command } from "commander";
2462
2565
 
2463
2566
  // src/version.ts
2464
- var VERSION = "0.0.9";
2567
+ var VERSION = "0.0.11";
2465
2568
 
2466
2569
  // src/index.ts
2467
2570
  init_config();
@@ -2522,7 +2625,25 @@ function registerAuth(program) {
2522
2625
  init_manager();
2523
2626
  init_tailscale();
2524
2627
  init_auth();
2628
+ init_config();
2525
2629
  init_output();
2630
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
2631
+ async function tunnelUrl(port2) {
2632
+ const ts = readTsnetState();
2633
+ if (ts?.tailscaleIp && ts.backendState === "Running") return `http://${ts.tailscaleIp}:${port2}`;
2634
+ return await discoverBaseUrl(port2).catch(() => null);
2635
+ }
2636
+ async function waitForTunnel(timeoutMs) {
2637
+ if (!getServerToken()) return false;
2638
+ const deadline = Date.now() + timeoutMs;
2639
+ while (Date.now() < deadline) {
2640
+ const cfg = loadConfig();
2641
+ const ts = readTsnetState();
2642
+ if (cfg.tailscale?.controlUrl && ts?.tailscaleIp && ts.backendState === "Running") return true;
2643
+ await sleep2(500);
2644
+ }
2645
+ return false;
2646
+ }
2526
2647
  function registerRuntime(program, version) {
2527
2648
  program.command("start").description("Run the Sakura runtime in this terminal (foreground)").option("--auth <token>", "sign in with a CLI token before starting").action(async (opts) => {
2528
2649
  if (opts.auth) {
@@ -2539,9 +2660,11 @@ function registerRuntime(program, version) {
2539
2660
  if (!isLoggedIn()) die("Not signed in. Run `sakuraai login` first.");
2540
2661
  const info0 = start();
2541
2662
  success(`Daemon running (pid ${info0.pid}) on ${info0.url}`);
2542
- const tailnet = await discoverBaseUrl(info0.port).catch(() => null);
2543
- if (!tailnet) {
2544
- warn("Tailscale not detected. Run `sakuraai serve` after `tailscale up` for a private URL.");
2663
+ const ready = await waitForTunnel(2e4);
2664
+ if (ready) {
2665
+ info("Embedded Tailscale tunnel up \u2014 the phone can connect from any network, no Tailscale app needed.");
2666
+ } else if (getServerToken()) {
2667
+ warn("Tunnel not up yet \u2014 pairing over LAN for now. Re-run `sakuraai pair` in a moment for the private URL.");
2545
2668
  }
2546
2669
  const { showPairing: showPairing2 } = await Promise.resolve().then(() => (init_pair(), pair_exports));
2547
2670
  await showPairing2();
@@ -2555,7 +2678,7 @@ function registerRuntime(program, version) {
2555
2678
  });
2556
2679
  d.command("status").description("Show runtime + connection status").option("--json", "output as JSON").action(async (opts) => {
2557
2680
  const i = running();
2558
- const tailnet = i ? await discoverBaseUrl(i.port).catch(() => null) : null;
2681
+ const tailnet = i ? await tunnelUrl(i.port) : null;
2559
2682
  if (opts.json) return printJson({ running: !!i, daemon: i, tailnetBaseUrl: tailnet });
2560
2683
  if (!i) return warn("Daemon is not running. Start it with `sakuraai daemon start`.");
2561
2684
  success(`Daemon running (pid ${i.pid}) on ${i.url}`);
@@ -2570,7 +2693,7 @@ function registerRuntime(program, version) {
2570
2693
  // src/commands/session.ts
2571
2694
  init_sessions();
2572
2695
  init_output();
2573
- import fs13 from "fs";
2696
+ import fs14 from "fs";
2574
2697
  function resolveSessionId(arg) {
2575
2698
  const id = arg || process.env.SAKURA_SESSION_ID;
2576
2699
  if (!id) die("Provide a sessionId (positional or via SAKURA_SESSION_ID).");
@@ -2579,7 +2702,7 @@ function resolveSessionId(arg) {
2579
2702
  async function resolvePrompt(positional, opts) {
2580
2703
  if (positional) return positional;
2581
2704
  if (opts.prompt) return opts.prompt;
2582
- if (opts.promptFile) return fs13.readFileSync(opts.promptFile, "utf8");
2705
+ if (opts.promptFile) return fs14.readFileSync(opts.promptFile, "utf8");
2583
2706
  if (!process.stdin.isTTY) {
2584
2707
  const chunks = [];
2585
2708
  for await (const c of process.stdin) chunks.push(c);
@@ -2891,6 +3014,86 @@ function registerConnectivity(program) {
2891
3014
 
2892
3015
  // src/index.ts
2893
3016
  init_pair();
3017
+
3018
+ // src/commands/welcome.ts
3019
+ init_config();
3020
+ import fs15 from "fs";
3021
+ import path11 from "path";
3022
+ import { fileURLToPath as fileURLToPath2 } from "url";
3023
+ import { spawn as spawn10 } from "child_process";
3024
+ var WELCOMED_MARKER = path11.join(SAKURA_DIR, ".welcomed");
3025
+ function resolveBinary2() {
3026
+ const envBin = process.env.SAKURA_ONBOARD_BIN;
3027
+ if (envBin && fs15.existsSync(envBin)) return envBin;
3028
+ const here = path11.dirname(fileURLToPath2(import.meta.url));
3029
+ const name = process.platform === "win32" ? "sakura-onboard.exe" : "sakura-onboard";
3030
+ const candidates = [
3031
+ path11.join(here, "..", "bin", name),
3032
+ // package/bin (postinstall download)
3033
+ path11.join(here, "bin", name),
3034
+ path11.join(process.cwd(), "bin", name),
3035
+ path11.join(SAKURA_DIR, name)
3036
+ // user-dropped
3037
+ ];
3038
+ return candidates.find((p) => fs15.existsSync(p)) ?? null;
3039
+ }
3040
+ function showWelcome() {
3041
+ return new Promise((resolve) => {
3042
+ const bin = resolveBinary2();
3043
+ if (!bin) {
3044
+ printFallback();
3045
+ return resolve();
3046
+ }
3047
+ const child2 = spawn10(bin, [], { stdio: "inherit" });
3048
+ child2.on("error", () => {
3049
+ printFallback();
3050
+ resolve();
3051
+ });
3052
+ child2.on("close", () => resolve());
3053
+ });
3054
+ }
3055
+ async function maybeFirstRunWelcome() {
3056
+ if (fs15.existsSync(WELCOMED_MARKER)) return;
3057
+ if (!process.stdout.isTTY || !process.stdin.isTTY) return;
3058
+ try {
3059
+ fs15.writeFileSync(WELCOMED_MARKER, (/* @__PURE__ */ new Date()).toISOString());
3060
+ } catch {
3061
+ }
3062
+ await showWelcome();
3063
+ }
3064
+ function printFallback() {
3065
+ const out = `
3066
+ \u{1F338} Sakura \u2014 your coding agents, in your pocket
3067
+
3068
+ Two ways your phone reaches this Mac:
3069
+
3070
+ 1 Home Wi-Fi (same network, simple)
3071
+ Phone \u2500\u2500Wi-Fi\u2500\u2500> Mac (192.168.x.x)
3072
+ \u2022 sakura daemon start
3073
+ \u2022 paste the URL + token in the app (or scan the QR)
3074
+ \u2022 works only while both are on the same Wi-Fi
3075
+
3076
+ 2 Tailscale (any network / mobile data, private)
3077
+ Phone \u2550tunnel\u2550> private mesh \u2550tunnel\u2550> Mac (100.x)
3078
+ \u2022 sakura login (links this Mac to your account)
3079
+ \u2022 sakura daemon start (joins the tunnel, prints a QR)
3080
+ \u2022 SCAN THE QR in the app (the QR carries the private key)
3081
+ \u2022 no Tailscale app needed \u2014 it's built in
3082
+
3083
+ Pasting the URL by hand can't carry the tunnel key, so paste is
3084
+ same-Wi-Fi only. Scan the QR to connect from anywhere.
3085
+
3086
+ Docs: https://www.sakura.mom \xB7 Show again: sakura welcome
3087
+ `;
3088
+ process.stdout.write(out + "\n");
3089
+ }
3090
+ function registerWelcome(program) {
3091
+ program.command("welcome").aliases(["guide"]).description("Show the Sakura connection guide (Home Wi-Fi vs Tailscale)").action(async () => {
3092
+ await showWelcome();
3093
+ });
3094
+ }
3095
+
3096
+ // src/index.ts
2894
3097
  init_manager();
2895
3098
  async function main() {
2896
3099
  ensureSakuraDirs();
@@ -2898,6 +3101,10 @@ async function main() {
2898
3101
  await runServer(VERSION);
2899
3102
  return;
2900
3103
  }
3104
+ const sub = process.argv[2];
3105
+ if (sub !== "welcome" && sub !== "guide") {
3106
+ await maybeFirstRunWelcome();
3107
+ }
2901
3108
  const program = new Command();
2902
3109
  program.name("sakuraai").description(
2903
3110
  "Sakura Agent CLI \u2014 manage local AI coding sessions and reach them from the Sakura app over Tailscale"
@@ -2908,6 +3115,7 @@ async function main() {
2908
3115
  registerRegistry(program);
2909
3116
  registerConnectivity(program);
2910
3117
  registerPair(program);
3118
+ registerWelcome(program);
2911
3119
  program.showHelpAfterError("(add --help for usage)");
2912
3120
  await program.parseAsync(process.argv);
2913
3121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sakuraai",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Sakura Agent CLI + local runtime for managing AI coding sessions (Claude Code, Codex, OpenCode) and reaching them privately from the Sakura mobile app over Tailscale",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,6 +28,7 @@
28
28
  "dist",
29
29
  "!dist/**/*.map",
30
30
  "scripts/download-tsnet.js",
31
+ "scripts/download-onboard.js",
31
32
  "README.md"
32
33
  ],
33
34
  "scripts": {
@@ -36,7 +37,7 @@
36
37
  "dev": "tsx src/index.ts",
37
38
  "clean": "rimraf dist",
38
39
  "typecheck": "tsc --noEmit",
39
- "postinstall": "node scripts/download-tsnet.js",
40
+ "postinstall": "node scripts/download-tsnet.js && node scripts/download-onboard.js",
40
41
  "prepublishOnly": "npm run clean && npm run build"
41
42
  },
42
43
  "dependencies": {
@@ -0,0 +1,68 @@
1
+ // postinstall: fetch the prebuilt `sakura-onboard` bubbletea welcome UI for this
2
+ // OS/arch from the public Nishu0/sakura-tsnet Release matching this package
3
+ // version. Best-effort by design — if anything fails (offline, unsupported
4
+ // platform, missing asset) we exit 0 so `npm install` never breaks; the CLI just
5
+ // prints a plain-text guide instead of the interactive one.
6
+ //
7
+ // Skips: SAKURA_SKIP_ONBOARD_DOWNLOAD=1, SAKURA_ONBOARD_BIN set, or already present.
8
+ // Override source: SAKURA_ONBOARD_TAG, SAKURA_ONBOARD_BASE_URL.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import https from "node:https";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
17
+
18
+ const PLAT = { darwin: "darwin", linux: "linux", win32: "windows" }[process.platform];
19
+ const ARCH = { x64: "amd64", arm64: "arm64" }[process.arch];
20
+ const isWin = process.platform === "win32";
21
+
22
+ const binDir = path.join(here, "..", "bin");
23
+ const outPath = path.join(binDir, isWin ? "sakura-onboard.exe" : "sakura-onboard");
24
+
25
+ if (process.env.SAKURA_SKIP_ONBOARD_DOWNLOAD === "1" || process.env.SAKURA_ONBOARD_BIN) process.exit(0);
26
+ if (fs.existsSync(outPath)) process.exit(0);
27
+ if (!PLAT || !ARCH) process.exit(0);
28
+
29
+ const asset = `sakura-onboard-${PLAT}-${ARCH}${isWin ? ".exe" : ""}`;
30
+ const tag = process.env.SAKURA_ONBOARD_TAG || `v${pkg.version}`;
31
+ const base = process.env.SAKURA_ONBOARD_BASE_URL || `https://github.com/Nishu0/sakura-tsnet/releases/download/${tag}`;
32
+ const url = `${base}/${asset}`;
33
+
34
+ function download(u, redirectsLeft = 5) {
35
+ return new Promise((resolve, reject) => {
36
+ https
37
+ .get(u, { headers: { "User-Agent": "sakuraai-postinstall" } }, (res) => {
38
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
39
+ if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
40
+ res.resume();
41
+ return resolve(download(res.headers.location, redirectsLeft - 1));
42
+ }
43
+ if (res.statusCode !== 200) {
44
+ res.resume();
45
+ return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
46
+ }
47
+ fs.mkdirSync(binDir, { recursive: true });
48
+ const tmp = `${outPath}.download`;
49
+ const file = fs.createWriteStream(tmp);
50
+ res.pipe(file);
51
+ file.on("finish", () => file.close(() => {
52
+ fs.renameSync(tmp, outPath);
53
+ if (!isWin) fs.chmodSync(outPath, 0o755);
54
+ resolve();
55
+ }));
56
+ file.on("error", reject);
57
+ })
58
+ .on("error", reject);
59
+ });
60
+ }
61
+
62
+ try {
63
+ await download(url);
64
+ console.log(`[sakura] installed welcome guide (${asset}). Run \`sakura welcome\` any time.`);
65
+ } catch {
66
+ // Silent — the CLI falls back to a plain-text guide.
67
+ process.exit(0);
68
+ }