bisync-cli 0.0.2 → 0.0.4

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/dist/bisync.js +426 -0
  2. package/package.json +1 -1
  3. package/src/bin.ts +124 -21
package/dist/bisync.js ADDED
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/bin.ts
5
+ import { readdir, stat } from "fs/promises";
6
+ import { homedir } from "os";
7
+ import { join, resolve } from "path";
8
+ // package.json
9
+ var version = "0.0.3";
10
+
11
+ // src/bin.ts
12
+ var CONFIG_DIR = join(homedir(), ".agent-bisync");
13
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
14
+ var CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
15
+ var CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
16
+ var DEBUG_LOG_PATH = join(CONFIG_DIR, "debug.log");
17
+ var CLIENT_ID = "bisync-cli";
18
+ var MAX_LINES_PER_BATCH = 200;
19
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
20
+ var usage = () => {
21
+ console.log(`bisync <command>
22
+
23
+ Commands:
24
+ setup --site-url <url> Configure hooks and authenticate
25
+ verify Check auth and list session ids
26
+ hook <HOOK> Handle Claude hook input (stdin JSON)
27
+
28
+ Global options:
29
+ --verbose Enable verbose logging
30
+ `);
31
+ };
32
+ var LOG_LEVEL = "info";
33
+ var log = (level, message) => {
34
+ if (level < LOG_LEVEL) {
35
+ return;
36
+ }
37
+ const logMessage = `[bisync][${level}] ${message}`;
38
+ console.log(logMessage);
39
+ };
40
+ var readConfig = async () => {
41
+ return await Bun.file(CONFIG_PATH).json();
42
+ };
43
+ var writeConfig = async (config) => {
44
+ await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2), {
45
+ createPath: true
46
+ });
47
+ };
48
+ var resolveSiteUrl = (args) => {
49
+ const argIndex = args.findIndex((value) => value === "--site-url" || value === "--siteUrl");
50
+ const argValue = argIndex >= 0 ? args[argIndex + 1] : undefined;
51
+ const envValue = process.env.BISYNC_SITE_URL;
52
+ if (argValue) {
53
+ return { siteUrl: argValue, source: "arg" };
54
+ }
55
+ if (envValue) {
56
+ return { siteUrl: envValue, source: "env" };
57
+ }
58
+ const viteConvexUrl = process.env.VITE_CONVEX_URL;
59
+ if (viteConvexUrl?.endsWith(".convex.cloud")) {
60
+ return {
61
+ siteUrl: viteConvexUrl.replace(".convex.cloud", ".convex.site"),
62
+ source: "vite"
63
+ };
64
+ }
65
+ if (viteConvexUrl) {
66
+ return { siteUrl: viteConvexUrl, source: "vite" };
67
+ }
68
+ return { siteUrl: null, source: "missing" };
69
+ };
70
+ var openBrowser = async (url) => {
71
+ if (process.platform !== "darwin") {
72
+ return;
73
+ }
74
+ try {
75
+ await Bun.$`open ${url}`;
76
+ } catch {}
77
+ };
78
+ var deviceAuthFlow = async (siteUrl) => {
79
+ log("debug", `device auth start siteUrl=${siteUrl}`);
80
+ const codeResponse = await fetch(`${siteUrl}/api/auth/device/code`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify({ client_id: CLIENT_ID })
84
+ });
85
+ if (!codeResponse.ok) {
86
+ const errorText = await codeResponse.text();
87
+ log("debug", `device code request failed status=${codeResponse.status}`);
88
+ throw new Error(`Device code request failed: ${codeResponse.status} ${errorText}`);
89
+ }
90
+ log("debug", `device code request ok status=${codeResponse.status}`);
91
+ const codeData = await codeResponse.json();
92
+ const verificationUrl = codeData.verification_uri_complete ?? codeData.verification_uri;
93
+ log("info", `Authorize this device: ${verificationUrl}`);
94
+ log("info", `User code: ${codeData.user_code}`);
95
+ await openBrowser(verificationUrl);
96
+ const intervalMs = Math.max(1, codeData.interval ?? 5) * 1000;
97
+ log("debug", `device auth polling intervalMs=${intervalMs}`);
98
+ let pollDelay = intervalMs;
99
+ while (true) {
100
+ await sleep(pollDelay);
101
+ const tokenResponse = await fetch(`${siteUrl}/api/auth/device/token`, {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify({
105
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
106
+ device_code: codeData.device_code,
107
+ client_id: CLIENT_ID
108
+ })
109
+ });
110
+ const tokenData = await tokenResponse.json();
111
+ if (tokenResponse.ok && tokenData.access_token) {
112
+ log("debug", `device auth success status=${tokenResponse.status}`);
113
+ return tokenData.access_token;
114
+ }
115
+ if (tokenData.error === "authorization_pending") {
116
+ continue;
117
+ }
118
+ if (tokenData.error === "slow_down") {
119
+ pollDelay += 1000;
120
+ log("debug", `device auth slow_down nextDelayMs=${pollDelay}`);
121
+ continue;
122
+ }
123
+ log("debug", `device auth failed status=${tokenResponse.status} error=${tokenData.error ?? "unknown"}`);
124
+ throw new Error(`Device auth failed: ${tokenData.error ?? "unknown"} ${tokenData.error_description ?? ""}`.trim());
125
+ }
126
+ };
127
+ var ensureHookEntry = (entries, next) => {
128
+ const sameMatcher = (entry) => (entry.matcher ?? null) === (next.matcher ?? null);
129
+ const command = next.hooks[0]?.command;
130
+ if (!command) {
131
+ return entries;
132
+ }
133
+ const existing = entries.find(sameMatcher);
134
+ if (!existing) {
135
+ entries.push(next);
136
+ return entries;
137
+ }
138
+ if (!existing.hooks.some((hook) => hook.command === command)) {
139
+ existing.hooks.push({ type: "command", command });
140
+ }
141
+ return entries;
142
+ };
143
+ var mergeSettings = async () => {
144
+ log("debug", `mergeSettings path=${CLAUDE_SETTINGS_PATH}`);
145
+ const settingsFile = Bun.file(CLAUDE_SETTINGS_PATH);
146
+ const settingsExists = await settingsFile.exists();
147
+ log("debug", `mergeSettings existing=${settingsExists}`);
148
+ let current = {};
149
+ if (settingsExists) {
150
+ try {
151
+ current = await settingsFile.json();
152
+ log("debug", `mergeSettings read ok`);
153
+ } catch (error) {
154
+ log("debug", `mergeSettings read failed: ${error instanceof Error ? error.message : String(error)}`);
155
+ current = {};
156
+ }
157
+ }
158
+ const hooks = current.hooks ?? {};
159
+ const definitions = [
160
+ { name: "SessionStart" },
161
+ { name: "SessionEnd" },
162
+ { name: "UserPromptSubmit" },
163
+ { name: "PostToolUse", matcher: "*" },
164
+ { name: "Stop", matcher: "*" }
165
+ ];
166
+ for (const definition of definitions) {
167
+ const command = `bisync hook ${definition.name}`;
168
+ const entries = hooks[definition.name] ?? [];
169
+ ensureHookEntry(entries, {
170
+ matcher: definition.matcher,
171
+ hooks: [{ type: "command", command }]
172
+ });
173
+ hooks[definition.name] = entries;
174
+ }
175
+ const nextConfig = {
176
+ ...current,
177
+ enabledPlugins: current.enabledPlugins ?? {},
178
+ hooks
179
+ };
180
+ await Bun.write(CLAUDE_SETTINGS_PATH, JSON.stringify(nextConfig, null, 2), {
181
+ createPath: true
182
+ });
183
+ log("debug", `mergeSettings wrote settings`);
184
+ try {
185
+ const info = await stat(CLAUDE_SETTINGS_PATH);
186
+ log("debug", `mergeSettings file size=${info.size} mtime=${new Date(info.mtimeMs).toISOString()}`);
187
+ } catch (error) {
188
+ log("debug", `mergeSettings stat failed: ${error instanceof Error ? error.message : String(error)}`);
189
+ }
190
+ };
191
+ var findSessionFile = async (sessionId) => {
192
+ let bestPath = null;
193
+ let bestMtimeMs = 0;
194
+ const targetName = `${sessionId}.jsonl`;
195
+ const walk = async (dir) => {
196
+ let entries;
197
+ try {
198
+ entries = await readdir(dir, { withFileTypes: true });
199
+ } catch {
200
+ return;
201
+ }
202
+ for (const entry of entries) {
203
+ const fullPath = join(dir, entry.name);
204
+ if (entry.isDirectory()) {
205
+ await walk(fullPath);
206
+ continue;
207
+ }
208
+ if (!entry.isFile() || entry.name !== targetName) {
209
+ continue;
210
+ }
211
+ const info = await stat(fullPath);
212
+ if (!bestPath || info.mtimeMs > bestMtimeMs) {
213
+ bestPath = fullPath;
214
+ bestMtimeMs = info.mtimeMs;
215
+ }
216
+ }
217
+ };
218
+ await walk(CLAUDE_PROJECTS_DIR);
219
+ return bestPath;
220
+ };
221
+ var appendDebugLog = async (message) => {
222
+ const logFile = Bun.file(DEBUG_LOG_PATH);
223
+ const content = await logFile.text();
224
+ await Bun.write(DEBUG_LOG_PATH, content + message, { createPath: true });
225
+ };
226
+ var buildLogLines = (raw) => {
227
+ const lines = raw.split(`
228
+ `).filter((line) => line.trim().length > 0);
229
+ const baseNow = Date.now();
230
+ return lines.map((line, index) => {
231
+ let ts = baseNow + index;
232
+ try {
233
+ const parsed = JSON.parse(line);
234
+ if (typeof parsed.ts === "number") {
235
+ ts = parsed.ts;
236
+ } else if (typeof parsed.timestamp === "number") {
237
+ ts = parsed.timestamp;
238
+ }
239
+ } catch {}
240
+ return { ts, log: line, encoding: "utf8" };
241
+ });
242
+ };
243
+ var uploadLogs = async (siteUrl, token, sessionId, raw) => {
244
+ const lines = buildLogLines(raw);
245
+ for (let i = 0;i < lines.length; i += MAX_LINES_PER_BATCH) {
246
+ const batch = lines.slice(i, i + MAX_LINES_PER_BATCH);
247
+ const response = await fetch(`${siteUrl}/api/ingest/logs`, {
248
+ method: "POST",
249
+ headers: {
250
+ "Content-Type": "application/json",
251
+ Authorization: `Bearer ${token}`
252
+ },
253
+ body: JSON.stringify({
254
+ session_id: sessionId,
255
+ lines: batch
256
+ })
257
+ });
258
+ if (!response.ok) {
259
+ const errorText = await response.text();
260
+ const error = new Error(`Log ingestion failed: ${response.status} ${errorText}`);
261
+ error.status = response.status;
262
+ throw error;
263
+ }
264
+ }
265
+ };
266
+ var runSetup = async (args) => {
267
+ log("debug", `runSetup start`);
268
+ const force = args.includes("--force");
269
+ log("debug", `runSetup force=${force}`);
270
+ const configExists = await Bun.file(CONFIG_PATH).exists();
271
+ log("debug", `config path=${CONFIG_PATH} exists=${configExists}`);
272
+ let existingConfig = null;
273
+ if (configExists) {
274
+ try {
275
+ existingConfig = await readConfig();
276
+ log("debug", `config loaded siteUrl=${existingConfig.siteUrl} token=${existingConfig.token ? "present" : "missing"}`);
277
+ } catch {
278
+ log("debug", `config load failed; continuing without existing config`);
279
+ existingConfig = null;
280
+ }
281
+ }
282
+ const resolution = resolveSiteUrl(args);
283
+ log("debug", `siteUrl resolved=${resolution.siteUrl ?? "null"} source=${resolution.source}`);
284
+ const siteUrl = resolution.siteUrl ?? existingConfig?.siteUrl ?? null;
285
+ if (!resolution.siteUrl && existingConfig?.siteUrl) {
286
+ log("debug", `siteUrl fallback to existing config ${existingConfig.siteUrl}`);
287
+ }
288
+ if (!siteUrl) {
289
+ log("debug", `runSetup aborted: missing siteUrl`);
290
+ throw new Error("Missing site URL. Provide --site-url or BISYNC_SITE_URL.");
291
+ }
292
+ if (configExists && existingConfig?.token && !force) {
293
+ log("debug", `using existing token; updating Claude settings only`);
294
+ await mergeSettings();
295
+ log("info", `Updated Claude settings using existing credentials at ${CONFIG_PATH}`);
296
+ return;
297
+ }
298
+ const token = await deviceAuthFlow(siteUrl);
299
+ await writeConfig({ siteUrl, token, clientId: CLIENT_ID });
300
+ log("debug", `config written to ${CONFIG_PATH}`);
301
+ await mergeSettings();
302
+ log("info", `Configured Claude settings and saved credentials to ${CONFIG_PATH}`);
303
+ };
304
+ var runVerify = async () => {
305
+ log("debug", `runVerify start config=${CONFIG_PATH}`);
306
+ const config = await readConfig();
307
+ const siteUrl = process.env.BISYNC_SITE_URL ?? config.siteUrl;
308
+ log("debug", `runVerify siteUrl=${siteUrl}`);
309
+ const response = await fetch(`${siteUrl}/api/list-sessions`, {
310
+ method: "GET",
311
+ headers: {
312
+ Authorization: `Bearer ${config.token}`
313
+ }
314
+ });
315
+ if (!response.ok) {
316
+ const errorText = await response.text();
317
+ log("debug", `runVerify failed status=${response.status}`);
318
+ throw new Error(`Verify failed: ${response.status} ${errorText}`);
319
+ }
320
+ const data = await response.json();
321
+ if (data.sessions.length === 0) {
322
+ log("info", "No sessions found.");
323
+ return;
324
+ }
325
+ for (const session of data.sessions) {
326
+ log("info", session.externalId);
327
+ }
328
+ };
329
+ var runHook = async (hookName) => {
330
+ log("debug", `runHook start hook=${hookName}`);
331
+ const stdinRaw = await new Promise((resolve2, reject) => {
332
+ let data = "";
333
+ process.stdin.setEncoding("utf8");
334
+ process.stdin.on("data", (chunk) => {
335
+ data += chunk;
336
+ });
337
+ process.stdin.on("end", () => resolve2(data));
338
+ process.stdin.on("error", (error) => reject(error));
339
+ });
340
+ await appendDebugLog(`[${new Date().toISOString()}] hook=${hookName} stdin=${stdinRaw.trim()}
341
+ `);
342
+ if (!stdinRaw.trim()) {
343
+ await appendDebugLog(`[${new Date().toISOString()}] hook=${hookName} error=empty-stdin
344
+ `);
345
+ return;
346
+ }
347
+ const stdinPayload = JSON.parse(stdinRaw);
348
+ const sessionId = stdinPayload.session_id;
349
+ if (!sessionId) {
350
+ await appendDebugLog(`[${new Date().toISOString()}] hook=${hookName} error=missing-session-id
351
+ `);
352
+ return;
353
+ }
354
+ const config = await readConfig();
355
+ const siteUrl = process.env.BISYNC_SITE_URL ?? config.siteUrl;
356
+ const token = config.token;
357
+ log("debug", `runHook siteUrl=${siteUrl}`);
358
+ let sessionFile = null;
359
+ if (typeof stdinPayload.transcript_path === "string") {
360
+ const resolvedPath = resolve(stdinPayload.transcript_path);
361
+ if (await Bun.file(resolvedPath).exists()) {
362
+ sessionFile = resolvedPath;
363
+ }
364
+ }
365
+ if (!sessionFile) {
366
+ sessionFile = await findSessionFile(sessionId);
367
+ }
368
+ if (!sessionFile) {
369
+ log("debug", `runHook session file missing sessionId=${sessionId}`);
370
+ await appendDebugLog(`[${new Date().toISOString()}] hook=${hookName} error=session-file-not-found session_id=${sessionId}
371
+ `);
372
+ return;
373
+ }
374
+ log("debug", `runHook sessionFile=${sessionFile}`);
375
+ const raw = await Bun.file(sessionFile).text();
376
+ if (!raw.trim()) {
377
+ log("debug", `runHook session file empty sessionId=${sessionId}`);
378
+ await appendDebugLog(`[${new Date().toISOString()}] hook=${hookName} error=empty-log session_id=${sessionId}
379
+ `);
380
+ return;
381
+ }
382
+ try {
383
+ await uploadLogs(siteUrl, token, sessionId, raw);
384
+ log("debug", `runHook upload ok sessionId=${sessionId}`);
385
+ } catch (error) {
386
+ log("debug", `runHook upload failed sessionId=${sessionId} message=${error instanceof Error ? error.message : String(error)}`);
387
+ await appendDebugLog(`[${new Date().toISOString()}] hook=${hookName} error=upload-failed session_id=${sessionId} message=${error instanceof Error ? error.message : String(error)}
388
+ `);
389
+ }
390
+ };
391
+ var main = async () => {
392
+ if (process.argv.includes("--version")) {
393
+ console.log(`\u25B6\uFE0E ${version} (Bun ${Bun.version})`);
394
+ process.exit(0);
395
+ }
396
+ if (process.argv.includes("--verbose")) {
397
+ LOG_LEVEL = "debug";
398
+ }
399
+ const rawArgs = process.argv.slice(2);
400
+ const [command, ...args] = rawArgs;
401
+ log("debug", `argv=${JSON.stringify(rawArgs)}`);
402
+ if (!command) {
403
+ usage();
404
+ process.exit(1);
405
+ }
406
+ try {
407
+ if (command === "setup") {
408
+ await runSetup(args);
409
+ return;
410
+ }
411
+ if (command === "verify") {
412
+ await runVerify();
413
+ return;
414
+ }
415
+ if (command === "hook") {
416
+ await runHook(args[0] ?? "unknown");
417
+ return;
418
+ }
419
+ usage();
420
+ process.exit(1);
421
+ } catch (error) {
422
+ log("error", error instanceof Error ? error.message : String(error));
423
+ process.exit(1);
424
+ }
425
+ };
426
+ await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bisync-cli",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "bin": {
5
5
  "bisync": "dist/bisync.js"
6
6
  },
package/src/bin.ts CHANGED
@@ -7,12 +7,13 @@ import { readdir, stat } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
8
  import { join, resolve } from "node:path";
9
9
 
10
+ import { version } from "../package.json" with { type: "json" };
11
+
10
12
  const CONFIG_DIR = join(homedir(), ".agent-bisync");
11
13
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
12
14
  const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
13
15
  const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
14
- const DEBUG_DIR = join(homedir(), ".bysync");
15
- const DEBUG_LOG_PATH = join(DEBUG_DIR, "debug.log");
16
+ const DEBUG_LOG_PATH = join(CONFIG_DIR, "debug.log");
16
17
  const CLIENT_ID = "bisync-cli";
17
18
  const MAX_LINES_PER_BATCH = 200;
18
19
 
@@ -22,6 +23,11 @@ type Config = {
22
23
  clientId: string;
23
24
  };
24
25
 
26
+ type SiteUrlResolution = {
27
+ siteUrl: string | null;
28
+ source: "arg" | "env" | "vite" | "missing";
29
+ };
30
+
25
31
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
26
32
 
27
33
  const usage = () => {
@@ -31,9 +37,21 @@ Commands:
31
37
  setup --site-url <url> Configure hooks and authenticate
32
38
  verify Check auth and list session ids
33
39
  hook <HOOK> Handle Claude hook input (stdin JSON)
40
+
41
+ Global options:
42
+ --verbose Enable verbose logging
34
43
  `);
35
44
  };
36
45
 
46
+ let LOG_LEVEL = "info";
47
+ const log = (level: "debug" | "info" | "warn" | "error", message: string) => {
48
+ if (level < LOG_LEVEL) {
49
+ return;
50
+ }
51
+ const logMessage = `[bisync][${level}] ${message}`;
52
+ console.log(logMessage);
53
+ };
54
+
37
55
  const readConfig = async (): Promise<Config> => {
38
56
  return await Bun.file(CONFIG_PATH).json();
39
57
  };
@@ -44,21 +62,27 @@ const writeConfig = async (config: Config) => {
44
62
  });
45
63
  };
46
64
 
47
- const resolveSiteUrl = (args: string[]) => {
65
+ const resolveSiteUrl = (args: string[]): SiteUrlResolution => {
48
66
  const argIndex = args.findIndex((value) => value === "--site-url" || value === "--siteUrl");
49
67
  const argValue = argIndex >= 0 ? args[argIndex + 1] : undefined;
50
68
  const envValue = process.env.BISYNC_SITE_URL;
51
69
  if (argValue) {
52
- return argValue;
70
+ return { siteUrl: argValue, source: "arg" };
53
71
  }
54
72
  if (envValue) {
55
- return envValue;
73
+ return { siteUrl: envValue, source: "env" };
56
74
  }
57
75
  const viteConvexUrl = process.env.VITE_CONVEX_URL;
58
76
  if (viteConvexUrl?.endsWith(".convex.cloud")) {
59
- return viteConvexUrl.replace(".convex.cloud", ".convex.site");
77
+ return {
78
+ siteUrl: viteConvexUrl.replace(".convex.cloud", ".convex.site"),
79
+ source: "vite",
80
+ };
81
+ }
82
+ if (viteConvexUrl) {
83
+ return { siteUrl: viteConvexUrl, source: "vite" };
60
84
  }
61
- return viteConvexUrl ?? null;
85
+ return { siteUrl: null, source: "missing" };
62
86
  };
63
87
 
64
88
  const openBrowser = async (url: string) => {
@@ -73,6 +97,7 @@ const openBrowser = async (url: string) => {
73
97
  };
74
98
 
75
99
  const deviceAuthFlow = async (siteUrl: string) => {
100
+ log("debug", `device auth start siteUrl=${siteUrl}`);
76
101
  const codeResponse = await fetch(`${siteUrl}/api/auth/device/code`, {
77
102
  method: "POST",
78
103
  headers: { "Content-Type": "application/json" },
@@ -81,9 +106,12 @@ const deviceAuthFlow = async (siteUrl: string) => {
81
106
 
82
107
  if (!codeResponse.ok) {
83
108
  const errorText = await codeResponse.text();
109
+ log("debug", `device code request failed status=${codeResponse.status}`);
84
110
  throw new Error(`Device code request failed: ${codeResponse.status} ${errorText}`);
85
111
  }
86
112
 
113
+ log("debug", `device code request ok status=${codeResponse.status}`);
114
+
87
115
  const codeData: {
88
116
  device_code: string;
89
117
  user_code: string;
@@ -93,11 +121,12 @@ const deviceAuthFlow = async (siteUrl: string) => {
93
121
  } = await codeResponse.json();
94
122
 
95
123
  const verificationUrl = codeData.verification_uri_complete ?? codeData.verification_uri;
96
- console.log(`Authorize this device: ${verificationUrl}`);
97
- console.log(`User code: ${codeData.user_code}`);
124
+ log("info", `Authorize this device: ${verificationUrl}`);
125
+ log("info", `User code: ${codeData.user_code}`);
98
126
  await openBrowser(verificationUrl);
99
127
 
100
128
  const intervalMs = Math.max(1, codeData.interval ?? 5) * 1000;
129
+ log("debug", `device auth polling intervalMs=${intervalMs}`);
101
130
  let pollDelay = intervalMs;
102
131
 
103
132
  while (true) {
@@ -119,6 +148,7 @@ const deviceAuthFlow = async (siteUrl: string) => {
119
148
  } = await tokenResponse.json();
120
149
 
121
150
  if (tokenResponse.ok && tokenData.access_token) {
151
+ log("debug", `device auth success status=${tokenResponse.status}`);
122
152
  return tokenData.access_token;
123
153
  }
124
154
 
@@ -127,9 +157,14 @@ const deviceAuthFlow = async (siteUrl: string) => {
127
157
  }
128
158
  if (tokenData.error === "slow_down") {
129
159
  pollDelay += 1000;
160
+ log("debug", `device auth slow_down nextDelayMs=${pollDelay}`);
130
161
  continue;
131
162
  }
132
163
 
164
+ log(
165
+ "debug",
166
+ `device auth failed status=${tokenResponse.status} error=${tokenData.error ?? "unknown"}`,
167
+ );
133
168
  throw new Error(
134
169
  `Device auth failed: ${tokenData.error ?? "unknown"} ${
135
170
  tokenData.error_description ?? ""
@@ -170,9 +205,23 @@ const ensureHookEntry = (entries: ClaudeHookEntry[], next: ClaudeHookEntry) => {
170
205
  };
171
206
 
172
207
  const mergeSettings = async () => {
173
- const current: ClaudeSettings = await Bun.file(CLAUDE_SETTINGS_PATH)
174
- .json()
175
- .catch(() => ({}));
208
+ log("debug", `mergeSettings path=${CLAUDE_SETTINGS_PATH}`);
209
+ const settingsFile = Bun.file(CLAUDE_SETTINGS_PATH);
210
+ const settingsExists = await settingsFile.exists();
211
+ log("debug", `mergeSettings existing=${settingsExists}`);
212
+ let current: ClaudeSettings = {};
213
+ if (settingsExists) {
214
+ try {
215
+ current = await settingsFile.json();
216
+ log("debug", `mergeSettings read ok`);
217
+ } catch (error) {
218
+ log(
219
+ "debug",
220
+ `mergeSettings read failed: ${error instanceof Error ? error.message : String(error)}`,
221
+ );
222
+ current = {};
223
+ }
224
+ }
176
225
 
177
226
  const hooks = current.hooks ?? {};
178
227
  const definitions: Array<{ name: string; matcher?: string }> = [
@@ -201,6 +250,19 @@ const mergeSettings = async () => {
201
250
  await Bun.write(CLAUDE_SETTINGS_PATH, JSON.stringify(nextConfig, null, 2), {
202
251
  createPath: true,
203
252
  });
253
+ log("debug", `mergeSettings wrote settings`);
254
+ try {
255
+ const info = await stat(CLAUDE_SETTINGS_PATH);
256
+ log(
257
+ "debug",
258
+ `mergeSettings file size=${info.size} mtime=${new Date(info.mtimeMs).toISOString()}`,
259
+ );
260
+ } catch (error) {
261
+ log(
262
+ "debug",
263
+ `mergeSettings stat failed: ${error instanceof Error ? error.message : String(error)}`,
264
+ );
265
+ }
204
266
  };
205
267
 
206
268
  const findSessionFile = async (sessionId: string) => {
@@ -240,7 +302,7 @@ const findSessionFile = async (sessionId: string) => {
240
302
  const appendDebugLog = async (message: string) => {
241
303
  const logFile = Bun.file(DEBUG_LOG_PATH);
242
304
  const content = await logFile.text();
243
- await logFile.write(content + message);
305
+ await Bun.write(DEBUG_LOG_PATH, content + message, { createPath: true });
244
306
  };
245
307
 
246
308
  const buildLogLines = (raw: string) => {
@@ -288,37 +350,55 @@ const uploadLogs = async (siteUrl: string, token: string, sessionId: string, raw
288
350
  };
289
351
 
290
352
  const runSetup = async (args: string[]) => {
353
+ log("debug", `runSetup start`);
291
354
  const force = args.includes("--force");
355
+ log("debug", `runSetup force=${force}`);
292
356
  const configExists = await Bun.file(CONFIG_PATH).exists();
357
+ log("debug", `config path=${CONFIG_PATH} exists=${configExists}`);
293
358
  let existingConfig: Config | null = null;
294
359
  if (configExists) {
295
360
  try {
296
361
  existingConfig = await readConfig();
362
+ log(
363
+ "debug",
364
+ `config loaded siteUrl=${existingConfig.siteUrl} token=${existingConfig.token ? "present" : "missing"}`,
365
+ );
297
366
  } catch {
367
+ log("debug", `config load failed; continuing without existing config`);
298
368
  existingConfig = null;
299
369
  }
300
370
  }
301
371
 
302
- const siteUrl = resolveSiteUrl(args) ?? existingConfig?.siteUrl ?? null;
372
+ const resolution = resolveSiteUrl(args);
373
+ log("debug", `siteUrl resolved=${resolution.siteUrl ?? "null"} source=${resolution.source}`);
374
+ const siteUrl = resolution.siteUrl ?? existingConfig?.siteUrl ?? null;
375
+ if (!resolution.siteUrl && existingConfig?.siteUrl) {
376
+ log("debug", `siteUrl fallback to existing config ${existingConfig.siteUrl}`);
377
+ }
303
378
  if (!siteUrl) {
379
+ log("debug", `runSetup aborted: missing siteUrl`);
304
380
  throw new Error("Missing site URL. Provide --site-url or BISYNC_SITE_URL.");
305
381
  }
306
382
 
307
383
  if (configExists && existingConfig?.token && !force) {
384
+ log("debug", `using existing token; updating Claude settings only`);
308
385
  await mergeSettings();
309
- console.log(`Updated Claude settings using existing credentials at ${CONFIG_PATH}`);
386
+ log("info", `Updated Claude settings using existing credentials at ${CONFIG_PATH}`);
310
387
  return;
311
388
  }
312
389
 
313
390
  const token = await deviceAuthFlow(siteUrl);
314
391
  await writeConfig({ siteUrl, token, clientId: CLIENT_ID });
392
+ log("debug", `config written to ${CONFIG_PATH}`);
315
393
  await mergeSettings();
316
- console.log(`Configured Claude settings and saved credentials to ${CONFIG_PATH}`);
394
+ log("info", `Configured Claude settings and saved credentials to ${CONFIG_PATH}`);
317
395
  };
318
396
 
319
397
  const runVerify = async () => {
398
+ log("debug", `runVerify start config=${CONFIG_PATH}`);
320
399
  const config = await readConfig();
321
400
  const siteUrl = process.env.BISYNC_SITE_URL ?? config.siteUrl;
401
+ log("debug", `runVerify siteUrl=${siteUrl}`);
322
402
  const response = await fetch(`${siteUrl}/api/list-sessions`, {
323
403
  method: "GET",
324
404
  headers: {
@@ -328,6 +408,7 @@ const runVerify = async () => {
328
408
 
329
409
  if (!response.ok) {
330
410
  const errorText = await response.text();
411
+ log("debug", `runVerify failed status=${response.status}`);
331
412
  throw new Error(`Verify failed: ${response.status} ${errorText}`);
332
413
  }
333
414
 
@@ -336,16 +417,17 @@ const runVerify = async () => {
336
417
  };
337
418
 
338
419
  if (data.sessions.length === 0) {
339
- console.log("No sessions found.");
420
+ log("info", "No sessions found.");
340
421
  return;
341
422
  }
342
423
 
343
424
  for (const session of data.sessions) {
344
- console.log(session.externalId);
425
+ log("info", session.externalId);
345
426
  }
346
427
  };
347
428
 
348
429
  const runHook = async (hookName: string) => {
430
+ log("debug", `runHook start hook=${hookName}`);
349
431
  const stdinRaw = await new Promise<string>((resolve, reject) => {
350
432
  let data = "";
351
433
  process.stdin.setEncoding("utf8");
@@ -378,6 +460,7 @@ const runHook = async (hookName: string) => {
378
460
  const config = await readConfig();
379
461
  const siteUrl = process.env.BISYNC_SITE_URL ?? config.siteUrl;
380
462
  const token = config.token;
463
+ log("debug", `runHook siteUrl=${siteUrl}`);
381
464
 
382
465
  let sessionFile: string | null = null;
383
466
  if (typeof stdinPayload.transcript_path === "string") {
@@ -390,14 +473,18 @@ const runHook = async (hookName: string) => {
390
473
  sessionFile = await findSessionFile(sessionId);
391
474
  }
392
475
  if (!sessionFile) {
476
+ log("debug", `runHook session file missing sessionId=${sessionId}`);
393
477
  await appendDebugLog(
394
478
  `[${new Date().toISOString()}] hook=${hookName} error=session-file-not-found session_id=${sessionId}\n`,
395
479
  );
396
480
  return;
397
481
  }
398
482
 
483
+ log("debug", `runHook sessionFile=${sessionFile}`);
484
+
399
485
  const raw = await Bun.file(sessionFile).text();
400
486
  if (!raw.trim()) {
487
+ log("debug", `runHook session file empty sessionId=${sessionId}`);
401
488
  await appendDebugLog(
402
489
  `[${new Date().toISOString()}] hook=${hookName} error=empty-log session_id=${sessionId}\n`,
403
490
  );
@@ -406,7 +493,12 @@ const runHook = async (hookName: string) => {
406
493
 
407
494
  try {
408
495
  await uploadLogs(siteUrl, token, sessionId, raw);
496
+ log("debug", `runHook upload ok sessionId=${sessionId}`);
409
497
  } catch (error) {
498
+ log(
499
+ "debug",
500
+ `runHook upload failed sessionId=${sessionId} message=${error instanceof Error ? error.message : String(error)}`,
501
+ );
410
502
  await appendDebugLog(
411
503
  `[${new Date().toISOString()}] hook=${hookName} error=upload-failed session_id=${sessionId} message=${error instanceof Error ? error.message : String(error)}\n`,
412
504
  );
@@ -414,7 +506,18 @@ const runHook = async (hookName: string) => {
414
506
  };
415
507
 
416
508
  const main = async () => {
417
- const [command, ...args] = process.argv.slice(2);
509
+ if (process.argv.includes("--version")) {
510
+ console.log(`▶︎ ${version} (Bun ${Bun.version})`);
511
+ process.exit(0);
512
+ }
513
+
514
+ if (process.argv.includes("--verbose")) {
515
+ LOG_LEVEL = "debug";
516
+ }
517
+
518
+ const rawArgs = process.argv.slice(2);
519
+ const [command, ...args] = rawArgs;
520
+ log("debug", `argv=${JSON.stringify(rawArgs)}`);
418
521
  if (!command) {
419
522
  usage();
420
523
  process.exit(1);
@@ -437,9 +540,9 @@ const main = async () => {
437
540
  usage();
438
541
  process.exit(1);
439
542
  } catch (error) {
440
- console.error(error instanceof Error ? error.message : error);
543
+ log("error", error instanceof Error ? error.message : String(error));
441
544
  process.exit(1);
442
545
  }
443
546
  };
444
547
 
445
- void main();
548
+ await main();