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.
- package/dist/bisync.js +426 -0
- package/package.json +1 -1
- 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
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
|
|
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
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
+
log("info", "No sessions found.");
|
|
340
421
|
return;
|
|
341
422
|
}
|
|
342
423
|
|
|
343
424
|
for (const session of data.sessions) {
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
+
log("error", error instanceof Error ? error.message : String(error));
|
|
441
544
|
process.exit(1);
|
|
442
545
|
}
|
|
443
546
|
};
|
|
444
547
|
|
|
445
|
-
|
|
548
|
+
await main();
|