sakuraai 0.0.12 → 0.0.13

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.
Files changed (3) hide show
  1. package/README.md +15 -2
  2. package/dist/index.js +221 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # sakuraai
2
2
 
3
3
  CLI + local runtime for the [Sakura](https://sakura.mom) app. Manage your local
4
- AI coding sessions (Claude Code, Codex, OpenCode) and reach them from your phone
5
- over Tailscale.
4
+ AI coding sessions (Claude Code, Codex, OpenCode, ZCode, DeepSeek) and reach them
5
+ from your phone over Tailscale.
6
6
 
7
7
  ```bash
8
8
  npm i -g sakuraai
@@ -12,4 +12,17 @@ sakuraai daemon start # start the runtime
12
12
  sakuraai session list # list sessions across all agents
13
13
  ```
14
14
 
15
+ ### Menu bar app (optional, macOS)
16
+
17
+ Prefer a GUI? `sakuraai app install` adds **Sakura** to your menu bar: daemon
18
+ status, a pairing QR for your phone, and **Stay awake** sessions (timer, until a
19
+ time, or unlimited) — including **Closed lid** mode, so you can keep driving your
20
+ agents from your phone with the MacBook shut. Closed lid asks for your password,
21
+ needs the charger by default, and puts sleep back when the session ends.
22
+
23
+ ```bash
24
+ sakuraai app install # download Sakura.app into /Applications and open it
25
+ sakuraai app open
26
+ ```
27
+
15
28
  Run `sakuraai --help` for all commands. More at **https://sakura.mom**.
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, path16, body, token) {
107
- const res = await fetch(`${base}${path16}`, {
106
+ async function postJson(base, path18, body, token) {
107
+ const res = await fetch(`${base}${path18}`, {
108
108
  method: "POST",
109
109
  headers: {
110
110
  "Content-Type": "application/json",
@@ -114,7 +114,7 @@ async function postJson(base, path16, body, token) {
114
114
  });
115
115
  if (!res.ok) {
116
116
  const text = await res.text().catch(() => "");
117
- throw new Error(`${path16} \u2192 ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
117
+ throw new Error(`${path18} \u2192 ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
118
118
  }
119
119
  return await res.json();
120
120
  }
@@ -2925,8 +2925,13 @@ var init_hub = __esm({
2925
2925
  });
2926
2926
 
2927
2927
  // src/daemon/stream.ts
2928
+ function turnState(agent, sessionId) {
2929
+ return turns.get(sessionKey(agent, sessionId)) ?? null;
2930
+ }
2928
2931
  async function runTurn(sessionId, agent, prompt, images, model) {
2929
2932
  const key = sessionKey(agent, sessionId);
2933
+ const turn = { status: "running", startedAt: Date.now() };
2934
+ turns.set(key, turn);
2930
2935
  const emit = (e) => hub.broadcast(key, e);
2931
2936
  let lastEmitted = "\0";
2932
2937
  const emitLive = (text) => {
@@ -2987,11 +2992,22 @@ async function runTurn(sessionId, agent, prompt, images, model) {
2987
2992
  }, POLL_MS);
2988
2993
  const result = await chat(sessionId, prompt, agent, onData, images, model);
2989
2994
  clearInterval(timer);
2990
- emit({ type: "done", sessionId, agent, ok: result.ok, error: result.error });
2995
+ turn.status = result.ok ? "done" : "error";
2996
+ turn.finishedAt = Date.now();
2997
+ turn.error = result.error;
2998
+ emit({
2999
+ type: "done",
3000
+ sessionId,
3001
+ agent,
3002
+ ok: result.ok,
3003
+ error: result.error,
3004
+ startedAt: turn.startedAt,
3005
+ finishedAt: turn.finishedAt
3006
+ });
2991
3007
  notifyTurnEnd(agent, sessionId, result.ok, result.error, stdoutAcc);
2992
3008
  return { ok: result.ok, error: result.error };
2993
3009
  }
2994
- var ANSI, stripAnsi, LIVE_ID, POLL_MS;
3010
+ var ANSI, stripAnsi, LIVE_ID, POLL_MS, turns;
2995
3011
  var init_stream = __esm({
2996
3012
  "src/daemon/stream.ts"() {
2997
3013
  "use strict";
@@ -3002,6 +3018,77 @@ var init_stream = __esm({
3002
3018
  stripAnsi = (s) => s.replace(ANSI, "");
3003
3019
  LIVE_ID = "__live__";
3004
3020
  POLL_MS = 300;
3021
+ turns = /* @__PURE__ */ new Map();
3022
+ }
3023
+ });
3024
+
3025
+ // src/runtime/presence.ts
3026
+ import fs15 from "fs";
3027
+ import path13 from "path";
3028
+ import { execFileSync as execFileSync2 } from "child_process";
3029
+ function alive(pid) {
3030
+ try {
3031
+ process.kill(pid, 0);
3032
+ return true;
3033
+ } catch (e) {
3034
+ return e?.code === "EPERM";
3035
+ }
3036
+ }
3037
+ function claudePresence(sessionId) {
3038
+ const dir = path13.join(HOME, ".claude", "sessions");
3039
+ let files = [];
3040
+ try {
3041
+ files = fs15.readdirSync(dir).filter((f) => f.endsWith(".json"));
3042
+ } catch {
3043
+ return { open: false };
3044
+ }
3045
+ for (const f of files) {
3046
+ try {
3047
+ const rec = JSON.parse(fs15.readFileSync(path13.join(dir, f), "utf8"));
3048
+ if (rec?.sessionId !== sessionId || typeof rec.pid !== "number") continue;
3049
+ if (rec.kind && rec.kind !== "interactive") continue;
3050
+ if (!alive(rec.pid)) continue;
3051
+ const where = rec.entrypoint === "cli" ? "Claude Code in a terminal" : "Claude Code";
3052
+ return { open: true, where, pid: rec.pid };
3053
+ } catch {
3054
+ }
3055
+ }
3056
+ return { open: false };
3057
+ }
3058
+ function processPresence(agent, sessionId) {
3059
+ const program = AGENT_PROGRAM[agent];
3060
+ if (!program) return { open: false };
3061
+ let out = "";
3062
+ try {
3063
+ out = execFileSync2("ps", ["-axo", "pid=,command="], { encoding: "utf8", timeout: 2e3 });
3064
+ } catch {
3065
+ return { open: false };
3066
+ }
3067
+ for (const line of out.split("\n")) {
3068
+ if (!line.includes(sessionId)) continue;
3069
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
3070
+ if (!m) continue;
3071
+ const pid = Number(m[1]);
3072
+ if (pid === process.pid || !program.test(m[2])) continue;
3073
+ return { open: true, where: agent, pid };
3074
+ }
3075
+ return { open: false };
3076
+ }
3077
+ function presence(agent, sessionId) {
3078
+ if (!sessionId) return { open: false };
3079
+ return agent === "claude" ? claudePresence(sessionId) : processPresence(agent, sessionId);
3080
+ }
3081
+ var AGENT_PROGRAM;
3082
+ var init_presence = __esm({
3083
+ "src/runtime/presence.ts"() {
3084
+ "use strict";
3085
+ init_util();
3086
+ AGENT_PROGRAM = {
3087
+ codex: /(^|\/)codex(\s|$)/,
3088
+ opencode: /(^|\/)opencode(-cli)?(\s|$)/,
3089
+ zcode: /(^|\/)zcode(\.cjs)?(\s|$)/,
3090
+ deepseek: /(^|\/)dsh(\s|$)|dsh-desktop-host/
3091
+ };
3005
3092
  }
3006
3093
  });
3007
3094
 
@@ -3093,17 +3180,17 @@ var init_terminal = __esm({
3093
3180
 
3094
3181
  // src/daemon/server.ts
3095
3182
  import http from "http";
3096
- import fs15 from "fs";
3183
+ import fs16 from "fs";
3097
3184
  import os8 from "os";
3098
3185
  import zlib2 from "zlib";
3099
- import path13 from "path";
3186
+ import path14 from "path";
3100
3187
  import { URL } from "url";
3101
- import { execFileSync as execFileSync2 } from "child_process";
3188
+ import { execFileSync as execFileSync3 } from "child_process";
3102
3189
  import { WebSocketServer } from "ws";
3103
- function add2(method, path16, handler) {
3190
+ function add2(method, path18, handler) {
3104
3191
  const keys = [];
3105
3192
  const pattern = new RegExp(
3106
- "^" + path16.replace(/:[^/]+/g, (m) => {
3193
+ "^" + path18.replace(/:[^/]+/g, (m) => {
3107
3194
  keys.push(m.slice(1));
3108
3195
  return "([^/]+)";
3109
3196
  }) + "/?$"
@@ -3126,8 +3213,8 @@ function sniffExt(buf) {
3126
3213
  return null;
3127
3214
  }
3128
3215
  function saveImages(sessionId, images) {
3129
- const dir = path13.join(os8.tmpdir(), "sakura-uploads", sessionId.slice(0, 24));
3130
- fs15.mkdirSync(dir, { recursive: true });
3216
+ const dir = path14.join(os8.tmpdir(), "sakura-uploads", sessionId.slice(0, 24));
3217
+ fs16.mkdirSync(dir, { recursive: true });
3131
3218
  const paths = [];
3132
3219
  images.forEach((img, i) => {
3133
3220
  const raw = String(img?.data ?? "");
@@ -3138,15 +3225,15 @@ function saveImages(sessionId, images) {
3138
3225
  }
3139
3226
  const bytes = Buffer.from(m[2], "base64");
3140
3227
  const ext = sniffExt(bytes) ?? EXT_BY_MIME[m[1]] ?? "png";
3141
- const base = img.name ? path13.parse(img.name).name.replace(/[^\w.\-]/g, "_") : "";
3228
+ const base = img.name ? path14.parse(img.name).name.replace(/[^\w.\-]/g, "_") : "";
3142
3229
  const stem = `${base || "image"}-${Date.now()}-${i + 1}`;
3143
- let file = path13.join(dir, `${stem}.${ext}`);
3144
- fs15.writeFileSync(file, bytes);
3230
+ let file = path14.join(dir, `${stem}.${ext}`);
3231
+ fs16.writeFileSync(file, bytes);
3145
3232
  if (ext === "heic") {
3146
- const jpg = path13.join(dir, `${stem}.jpg`);
3233
+ const jpg = path14.join(dir, `${stem}.jpg`);
3147
3234
  try {
3148
- execFileSync2("sips", ["-s", "format", "jpeg", file, "--out", jpg], { stdio: "ignore" });
3149
- fs15.rmSync(file, { force: true });
3235
+ execFileSync3("sips", ["-s", "format", "jpeg", file, "--out", jpg], { stdio: "ignore" });
3236
+ fs16.rmSync(file, { force: true });
3150
3237
  file = jpg;
3151
3238
  } catch {
3152
3239
  log.info(`saveImages: HEIC\u2192JPEG conversion failed for ${file}; sending HEIC as-is`);
@@ -3321,6 +3408,7 @@ var init_server = __esm({
3321
3408
  init_zcode();
3322
3409
  init_hub();
3323
3410
  init_stream();
3411
+ init_presence();
3324
3412
  init_terminal();
3325
3413
  routes = [];
3326
3414
  EXT_BY_MIME = {
@@ -3340,13 +3428,13 @@ var init_server = __esm({
3340
3428
  const reader = params.agent === "opencode" ? image : params.agent === "zcode" ? image2 : null;
3341
3429
  sendBytes(res, reader ? await reader(params.session, params.part) : null);
3342
3430
  });
3343
- UPLOADS_DIR = path13.join(os8.tmpdir(), "sakura-uploads");
3431
+ UPLOADS_DIR = path14.join(os8.tmpdir(), "sakura-uploads");
3344
3432
  add2("GET", "/uploads/:dir/:file", ({ res, params }) => {
3345
- const file = path13.resolve(UPLOADS_DIR, params.dir, params.file);
3346
- const ext = path13.extname(file).slice(1).toLowerCase();
3433
+ const file = path14.resolve(UPLOADS_DIR, params.dir, params.file);
3434
+ const ext = path14.extname(file).slice(1).toLowerCase();
3347
3435
  const mime = Object.entries(EXT_BY_MIME).find(([, e]) => e === ext)?.[0];
3348
- const ok2 = file.startsWith(UPLOADS_DIR + path13.sep) && mime && fs15.existsSync(file);
3349
- sendBytes(res, ok2 ? { mediaType: mime, data: fs15.readFileSync(file) } : null);
3436
+ const ok2 = file.startsWith(UPLOADS_DIR + path14.sep) && mime && fs16.existsSync(file);
3437
+ sendBytes(res, ok2 ? { mediaType: mime, data: fs16.readFileSync(file) } : null);
3350
3438
  });
3351
3439
  add2("GET", "/sessions", async ({ url }) => {
3352
3440
  const archived = url.searchParams.get("archived") === "true";
@@ -3371,6 +3459,13 @@ var init_server = __esm({
3371
3459
  const r = await runTurn(params.id, agent, message, imagePaths, model);
3372
3460
  return { ok: r.ok, error: r.error };
3373
3461
  });
3462
+ add2("GET", "/sessions/:id/status", async ({ params, url }) => {
3463
+ const agent = url.searchParams.get("agent") || await detectAgent(params.id);
3464
+ if (!agent) return { ok: false, error: "unknown agent for session" };
3465
+ const turn = turnState(agent, params.id);
3466
+ const elsewhere = turn?.status === "running" ? { open: false } : presence(agent, params.id);
3467
+ return { ok: true, agent, turn, openOnMac: elsewhere };
3468
+ });
3374
3469
  add2("POST", "/sessions/:id/rename", ({ params, body }) => {
3375
3470
  const title = String(body?.title ?? "").trim();
3376
3471
  if (!title) return { ok: false, error: "title is required" };
@@ -3407,7 +3502,7 @@ var init_server = __esm({
3407
3502
  case "logs": {
3408
3503
  let logs2 = "";
3409
3504
  try {
3410
- const lines2 = fs15.readFileSync(LOG_PATH, "utf8").split("\n");
3505
+ const lines2 = fs16.readFileSync(LOG_PATH, "utf8").split("\n");
3411
3506
  logs2 = lines2.slice(-200).join("\n");
3412
3507
  } catch {
3413
3508
  }
@@ -3459,7 +3554,14 @@ var init_server = __esm({
3459
3554
  return ok(listModels(agent));
3460
3555
  });
3461
3556
  add2("POST", "/sakura/push-token", ({ body }) => {
3462
- const token = String(body?.token ?? "");
3557
+ let b = body;
3558
+ if (typeof b === "string") {
3559
+ try {
3560
+ b = JSON.parse(b);
3561
+ } catch {
3562
+ }
3563
+ }
3564
+ const token = String(b?.token ?? "");
3463
3565
  if (!token) return fail("bad_request", "token required");
3464
3566
  addPushToken(token);
3465
3567
  return ok({ registered: true });
@@ -3613,24 +3715,24 @@ var init_server = __esm({
3613
3715
  });
3614
3716
 
3615
3717
  // src/daemon/tsnet.ts
3616
- import fs16 from "fs";
3617
- import path14 from "path";
3718
+ import fs17 from "fs";
3719
+ import path15 from "path";
3618
3720
  import { fileURLToPath } from "url";
3619
3721
  import { spawn as spawn10 } from "child_process";
3620
3722
  function resolveBinary() {
3621
3723
  const envBin = process.env.SAKURA_TSNET_BIN;
3622
- if (envBin && fs16.existsSync(envBin)) return envBin;
3623
- const here = path14.dirname(fileURLToPath(import.meta.url));
3724
+ if (envBin && fs17.existsSync(envBin)) return envBin;
3725
+ const here = path15.dirname(fileURLToPath(import.meta.url));
3624
3726
  const name = process.platform === "win32" ? "sakura-tsnet.exe" : "sakura-tsnet";
3625
3727
  const candidates = [
3626
- path14.join(here, "..", "bin", name),
3728
+ path15.join(here, "..", "bin", name),
3627
3729
  // package/bin (postinstall download / build-sidecar.sh)
3628
- path14.join(here, "bin", name),
3629
- path14.join(process.cwd(), "bin", name),
3630
- path14.join(SAKURA_DIR, name)
3730
+ path15.join(here, "bin", name),
3731
+ path15.join(process.cwd(), "bin", name),
3732
+ path15.join(SAKURA_DIR, name)
3631
3733
  // user-dropped
3632
3734
  ];
3633
- return candidates.find((p) => fs16.existsSync(p)) ?? null;
3735
+ return candidates.find((p) => fs17.existsSync(p)) ?? null;
3634
3736
  }
3635
3737
  function startTsnet(port2) {
3636
3738
  const cfg = loadConfig();
@@ -3653,11 +3755,11 @@ function spawnSidecar(bin, controlUrl, authKey, hostname2, port2) {
3653
3755
  SAKURA_TS_CONTROL_URL: controlUrl,
3654
3756
  SAKURA_TS_AUTH_KEY: authKey,
3655
3757
  SAKURA_TS_HOSTNAME: hostname2,
3656
- SAKURA_TS_STATE_DIR: path14.join(SAKURA_DIR, "tsnet-state"),
3758
+ SAKURA_TS_STATE_DIR: path15.join(SAKURA_DIR, "tsnet-state"),
3657
3759
  SAKURA_TS_STATE_FILE: TSNET_PATH,
3658
3760
  SAKURA_TS_PORT: String(port2)
3659
3761
  };
3660
- log.info(`starting embedded Tailscale sidecar (${path14.basename(bin)})`);
3762
+ log.info(`starting embedded Tailscale sidecar (${path15.basename(bin)})`);
3661
3763
  const c = spawn10(bin, [], { stdio: ["ignore", "pipe", "pipe"], env });
3662
3764
  child = c;
3663
3765
  const pipe = (buf) => buf.toString().split("\n").filter(Boolean).forEach((line) => log.info(`[tsnet] ${line}`));
@@ -3686,7 +3788,7 @@ function stopTsnet() {
3686
3788
  child = null;
3687
3789
  }
3688
3790
  try {
3689
- fs16.rmSync(TSNET_PATH, { force: true });
3791
+ fs17.rmSync(TSNET_PATH, { force: true });
3690
3792
  } catch {
3691
3793
  }
3692
3794
  }
@@ -3703,7 +3805,7 @@ var init_tsnet = __esm({
3703
3805
  });
3704
3806
 
3705
3807
  // src/daemon/manager.ts
3706
- import fs17 from "fs";
3808
+ import fs18 from "fs";
3707
3809
  import { spawn as spawn11 } from "child_process";
3708
3810
  async function ensureTailnetProvisioned() {
3709
3811
  const serverToken = getServerToken();
@@ -3726,7 +3828,7 @@ async function ensureTailnetProvisioned() {
3726
3828
  }
3727
3829
  function readDaemonInfo() {
3728
3830
  try {
3729
- return JSON.parse(fs17.readFileSync(DAEMON_PATH, "utf8"));
3831
+ return JSON.parse(fs18.readFileSync(DAEMON_PATH, "utf8"));
3730
3832
  } catch {
3731
3833
  return null;
3732
3834
  }
@@ -3743,8 +3845,8 @@ function running() {
3743
3845
  const info2 = readDaemonInfo();
3744
3846
  if (info2 && pidAlive(info2.pid)) return info2;
3745
3847
  if (info2) {
3746
- fs17.rmSync(DAEMON_PATH, { force: true });
3747
- fs17.rmSync(PID_PATH, { force: true });
3848
+ fs18.rmSync(DAEMON_PATH, { force: true });
3849
+ fs18.rmSync(PID_PATH, { force: true });
3748
3850
  }
3749
3851
  return null;
3750
3852
  }
@@ -3767,8 +3869,8 @@ async function runServer(version) {
3767
3869
  url: `http://${host}:${port2}`,
3768
3870
  startedAt: Date.now()
3769
3871
  };
3770
- fs17.writeFileSync(DAEMON_PATH, JSON.stringify(info2, null, 2));
3771
- fs17.writeFileSync(PID_PATH, String(process.pid));
3872
+ fs18.writeFileSync(DAEMON_PATH, JSON.stringify(info2, null, 2));
3873
+ fs18.writeFileSync(PID_PATH, String(process.pid));
3772
3874
  await ensureTailnetProvisioned();
3773
3875
  startTsnet(port2);
3774
3876
  const tailnet = await discoverBaseUrl(port2).catch(() => null);
@@ -3779,8 +3881,8 @@ async function runServer(version) {
3779
3881
  log.info("shutting down");
3780
3882
  stopTsnet();
3781
3883
  server.close();
3782
- fs17.rmSync(DAEMON_PATH, { force: true });
3783
- fs17.rmSync(PID_PATH, { force: true });
3884
+ fs18.rmSync(DAEMON_PATH, { force: true });
3885
+ fs18.rmSync(PID_PATH, { force: true });
3784
3886
  process.exit(0);
3785
3887
  };
3786
3888
  process.on("SIGINT", shutdown);
@@ -3791,8 +3893,8 @@ function start() {
3791
3893
  if (existing) return existing;
3792
3894
  ensureSakuraDirs();
3793
3895
  const entry = process.argv[1] ?? "";
3794
- const out = fs17.openSync(LOG_PATH, "a");
3795
- const err = fs17.openSync(LOG_PATH, "a");
3896
+ const out = fs18.openSync(LOG_PATH, "a");
3897
+ const err = fs18.openSync(LOG_PATH, "a");
3796
3898
  const child2 = spawn11(process.execPath, [entry, "__run-daemon"], {
3797
3899
  detached: true,
3798
3900
  stdio: ["ignore", out, err],
@@ -3814,8 +3916,8 @@ function stop() {
3814
3916
  process.kill(info2.pid, "SIGTERM");
3815
3917
  } catch {
3816
3918
  }
3817
- fs17.rmSync(DAEMON_PATH, { force: true });
3818
- fs17.rmSync(PID_PATH, { force: true });
3919
+ fs18.rmSync(DAEMON_PATH, { force: true });
3920
+ fs18.rmSync(PID_PATH, { force: true });
3819
3921
  return true;
3820
3922
  }
3821
3923
  function restart() {
@@ -3825,7 +3927,7 @@ function restart() {
3825
3927
  }
3826
3928
  function logs(lines2 = 50) {
3827
3929
  try {
3828
- const all2 = fs17.readFileSync(LOG_PATH, "utf8").split("\n");
3930
+ const all2 = fs18.readFileSync(LOG_PATH, "utf8").split("\n");
3829
3931
  return all2.slice(-lines2).join("\n");
3830
3932
  } catch {
3831
3933
  return "";
@@ -3947,7 +4049,7 @@ var init_pair = __esm({
3947
4049
  import { Command } from "commander";
3948
4050
 
3949
4051
  // src/version.ts
3950
- var VERSION = "0.0.12";
4052
+ var VERSION = "0.0.13";
3951
4053
 
3952
4054
  // src/index.ts
3953
4055
  init_config();
@@ -4076,7 +4178,7 @@ function registerRuntime(program, version) {
4076
4178
  // src/commands/session.ts
4077
4179
  init_sessions();
4078
4180
  init_output();
4079
- import fs18 from "fs";
4181
+ import fs19 from "fs";
4080
4182
  function resolveSessionId(arg) {
4081
4183
  const id = arg || process.env.SAKURA_SESSION_ID;
4082
4184
  if (!id) die("Provide a sessionId (positional or via SAKURA_SESSION_ID).");
@@ -4085,7 +4187,7 @@ function resolveSessionId(arg) {
4085
4187
  async function resolvePrompt(positional, opts) {
4086
4188
  if (positional) return positional;
4087
4189
  if (opts.prompt) return opts.prompt;
4088
- if (opts.promptFile) return fs18.readFileSync(opts.promptFile, "utf8");
4190
+ if (opts.promptFile) return fs19.readFileSync(opts.promptFile, "utf8");
4089
4191
  if (!process.stdin.isTTY) {
4090
4192
  const chunks = [];
4091
4193
  for await (const c of process.stdin) chunks.push(c);
@@ -4400,25 +4502,25 @@ init_pair();
4400
4502
 
4401
4503
  // src/commands/welcome.ts
4402
4504
  init_config();
4403
- import fs19 from "fs";
4404
- import path15 from "path";
4505
+ import fs20 from "fs";
4506
+ import path16 from "path";
4405
4507
  import { fileURLToPath as fileURLToPath2 } from "url";
4406
4508
  import { spawn as spawn12 } from "child_process";
4407
- var WELCOMED_MARKER = path15.join(SAKURA_DIR, ".welcomed");
4509
+ var WELCOMED_MARKER = path16.join(SAKURA_DIR, ".welcomed");
4408
4510
  function resolveBinary2() {
4409
4511
  const envBin = process.env.SAKURA_ONBOARD_BIN;
4410
- if (envBin && fs19.existsSync(envBin)) return envBin;
4411
- const here = path15.dirname(fileURLToPath2(import.meta.url));
4512
+ if (envBin && fs20.existsSync(envBin)) return envBin;
4513
+ const here = path16.dirname(fileURLToPath2(import.meta.url));
4412
4514
  const name = process.platform === "win32" ? "sakura-onboard.exe" : "sakura-onboard";
4413
4515
  const candidates = [
4414
- path15.join(here, "..", "bin", name),
4516
+ path16.join(here, "..", "bin", name),
4415
4517
  // package/bin (postinstall download)
4416
- path15.join(here, "bin", name),
4417
- path15.join(process.cwd(), "bin", name),
4418
- path15.join(SAKURA_DIR, name)
4518
+ path16.join(here, "bin", name),
4519
+ path16.join(process.cwd(), "bin", name),
4520
+ path16.join(SAKURA_DIR, name)
4419
4521
  // user-dropped
4420
4522
  ];
4421
- return candidates.find((p) => fs19.existsSync(p)) ?? null;
4523
+ return candidates.find((p) => fs20.existsSync(p)) ?? null;
4422
4524
  }
4423
4525
  function showWelcome() {
4424
4526
  return new Promise((resolve) => {
@@ -4436,10 +4538,10 @@ function showWelcome() {
4436
4538
  });
4437
4539
  }
4438
4540
  async function maybeFirstRunWelcome() {
4439
- if (fs19.existsSync(WELCOMED_MARKER)) return;
4541
+ if (fs20.existsSync(WELCOMED_MARKER)) return;
4440
4542
  if (!process.stdout.isTTY || !process.stdin.isTTY) return;
4441
4543
  try {
4442
- fs19.writeFileSync(WELCOMED_MARKER, (/* @__PURE__ */ new Date()).toISOString());
4544
+ fs20.writeFileSync(WELCOMED_MARKER, (/* @__PURE__ */ new Date()).toISOString());
4443
4545
  } catch {
4444
4546
  }
4445
4547
  await showWelcome();
@@ -4476,6 +4578,59 @@ function registerWelcome(program) {
4476
4578
  });
4477
4579
  }
4478
4580
 
4581
+ // src/commands/app.ts
4582
+ import fs21 from "fs";
4583
+ import os10 from "os";
4584
+ import path17 from "path";
4585
+ import { execFileSync as execFileSync4 } from "child_process";
4586
+ init_output();
4587
+ var APP_PATH = "/Applications/Sakura.app";
4588
+ function dmgUrl() {
4589
+ const base = process.env.SAKURA_APP_BASE_URL || `https://github.com/Nishu0/sakura-tsnet/releases/download/v${VERSION}`;
4590
+ return `${base}/Sakura.dmg`;
4591
+ }
4592
+ async function download(url, dest) {
4593
+ const res = await fetch(url, { redirect: "follow" });
4594
+ if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
4595
+ fs21.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
4596
+ }
4597
+ async function install() {
4598
+ if (process.platform !== "darwin") die("The Sakura menu bar app is macOS only.");
4599
+ const tmp = fs21.mkdtempSync(path17.join(os10.tmpdir(), "sakura-app-"));
4600
+ const dmg = path17.join(tmp, "Sakura.dmg");
4601
+ const mount = path17.join(tmp, "mnt");
4602
+ info(`Downloading Sakura.app ${VERSION}\u2026`);
4603
+ try {
4604
+ await download(dmgUrl(), dmg);
4605
+ } catch (e) {
4606
+ die(`${e.message}
4607
+ Build it yourself instead: ./mac/scripts/build-dmg.sh in the Sakura repo.`);
4608
+ }
4609
+ fs21.mkdirSync(mount);
4610
+ execFileSync4("hdiutil", ["attach", dmg, "-nobrowse", "-quiet", "-mountpoint", mount]);
4611
+ try {
4612
+ try {
4613
+ execFileSync4("osascript", ["-e", 'tell application id "mom.sakura.mac" to quit']);
4614
+ } catch {
4615
+ }
4616
+ fs21.rmSync(APP_PATH, { recursive: true, force: true });
4617
+ execFileSync4("ditto", [path17.join(mount, "Sakura.app"), APP_PATH]);
4618
+ } finally {
4619
+ execFileSync4("hdiutil", ["detach", mount, "-quiet"]);
4620
+ fs21.rmSync(tmp, { recursive: true, force: true });
4621
+ }
4622
+ execFileSync4("open", [APP_PATH]);
4623
+ success("Sakura is in your menu bar (look for the blossom).");
4624
+ }
4625
+ function registerApp(program) {
4626
+ const app = program.command("app").description("The optional Sakura menu bar app for macOS");
4627
+ app.command("install").description("Download Sakura.app into /Applications and open it").action(install);
4628
+ app.command("open").description("Open the Sakura menu bar app").action(() => {
4629
+ if (!fs21.existsSync(APP_PATH)) die("Sakura.app is not installed. Run `sakuraai app install`.");
4630
+ execFileSync4("open", [APP_PATH]);
4631
+ });
4632
+ }
4633
+
4479
4634
  // src/index.ts
4480
4635
  init_manager();
4481
4636
  async function main() {
@@ -4499,6 +4654,7 @@ async function main() {
4499
4654
  registerConnectivity(program);
4500
4655
  registerPair(program);
4501
4656
  registerWelcome(program);
4657
+ registerApp(program);
4502
4658
  program.showHelpAfterError("(add --help for usage)");
4503
4659
  await program.parseAsync(process.argv);
4504
4660
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sakuraai",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Sakura Agent CLI + local runtime for managing AI coding sessions (Claude Code, Codex, OpenCode, ZCode, DeepSeek) and reaching them privately from the Sakura mobile app over Tailscale",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",