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