codetime-cli 0.4.0 → 0.6.0

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 (2) hide show
  1. package/bin/codetime.mjs +242 -43
  2. package/package.json +1 -1
package/bin/codetime.mjs CHANGED
@@ -160,6 +160,7 @@ import { fileURLToPath } from "node:url";
160
160
  // ../shared/src/index.ts
161
161
  import { createHash } from "node:crypto";
162
162
  var AGENT_TIME_SCHEMA_VERSION = "2026-04-29";
163
+ var AGENT_ROLLUP_SCHEMA_VERSION = 2;
163
164
  var TELEMETRY_EVENT_TYPES = [
164
165
  "session.started",
165
166
  "session.ended",
@@ -973,11 +974,12 @@ import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
973
974
  import path2 from "node:path";
974
975
 
975
976
  // src/lib/constants.ts
976
- var PACKAGE_VERSION = true ? "0.4.0" : "0.1.1";
977
+ var PACKAGE_VERSION = true ? "0.6.0" : "0.1.1";
977
978
  var DEFAULT_API_URL = "https://codetime.dev";
978
979
  var DEFAULT_BACKFILL_BATCH_SIZE = 50;
979
980
  var DEFAULT_BACKFILL_BATCH_BYTES = 800 * 1024;
980
981
  var ROLLUP_BUCKET_MS = 15 * 60 * 1e3;
982
+ var TURN_GAP_CLAMP_MS = 5 * 60 * 1e3;
981
983
  var DEFAULT_HOOK_SYNC_MIN_INTERVAL_SECONDS = 60;
982
984
 
983
985
  // src/lib/fields.ts
@@ -2243,6 +2245,23 @@ async function parseCodexSessionFile(filePath, options) {
2243
2245
  let sessionMetaLocked = false;
2244
2246
  let lastTokenUsageKey;
2245
2247
  const pendingToolCalls = /* @__PURE__ */ new Map();
2248
+ const turnLastEventAt = /* @__PURE__ */ new Map();
2249
+ const turnStartedAt = /* @__PURE__ */ new Map();
2250
+ const closedTurnIds = /* @__PURE__ */ new Set();
2251
+ const pushEvent = (event, refs) => {
2252
+ const turnId = event.turnId;
2253
+ if (turnId && event.ts) {
2254
+ if (!turnStartedAt.has(turnId)) {
2255
+ turnStartedAt.set(turnId, event.ts);
2256
+ }
2257
+ const prev = turnLastEventAt.get(turnId);
2258
+ if (!prev || event.ts > prev) {
2259
+ turnLastEventAt.set(turnId, event.ts);
2260
+ }
2261
+ }
2262
+ events.push(withBackfillRefs(event, refs));
2263
+ };
2264
+ const turnCloseTs = (turnId) => turnLastEventAt.get(turnId) || turnStartedAt.get(turnId) || (/* @__PURE__ */ new Date()).toISOString();
2246
2265
  for (const [index, line] of lines.entries()) {
2247
2266
  const lineNumber = index + 1;
2248
2267
  const raw = parseJsonLine(line);
@@ -2299,23 +2318,23 @@ async function parseCodexSessionFile(filePath, options) {
2299
2318
  const eventModel = parsedModel || model || "gpt-5";
2300
2319
  const headlessTs = headlessCodexTimestamp(raw) || ts;
2301
2320
  if (!sessionStartEmitted) {
2302
- events.push(withBackfillRefs(baseCodexEvent({
2321
+ pushEvent(baseCodexEvent({
2303
2322
  ts: headlessTs,
2304
2323
  type: "session.started",
2305
2324
  sessionId,
2306
2325
  model: eventModel,
2307
2326
  confidence: "derived"
2308
- }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "session", options }));
2327
+ }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "session", options });
2309
2328
  sessionStartEmitted = true;
2310
2329
  }
2311
- events.push(withBackfillRefs(baseCodexEvent({
2330
+ pushEvent(baseCodexEvent({
2312
2331
  ts: headlessTs,
2313
2332
  type: "model.usage",
2314
2333
  sessionId,
2315
2334
  model: eventModel,
2316
2335
  confidence: "partial",
2317
2336
  metrics: usage
2318
- }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "headless", options }));
2337
+ }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "headless", options });
2319
2338
  }
2320
2339
  continue;
2321
2340
  }
@@ -2325,7 +2344,7 @@ async function parseCodexSessionFile(filePath, options) {
2325
2344
  switch (payloadType) {
2326
2345
  case "task_started": {
2327
2346
  currentTurnId = stringField(payload, "turn_id") || currentTurnId;
2328
- events.push(withBackfillRefs(baseCodexEvent({
2347
+ pushEvent(baseCodexEvent({
2329
2348
  ts,
2330
2349
  type: "turn.started",
2331
2350
  sessionId,
@@ -2334,14 +2353,15 @@ async function parseCodexSessionFile(filePath, options) {
2334
2353
  project,
2335
2354
  model,
2336
2355
  confidence: "exact"
2337
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2356
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2338
2357
  break;
2339
2358
  }
2340
2359
  case "user_message": {
2341
2360
  const message = stringField(payload, "message") || "";
2342
- if (currentTurnId && currentTurnId !== turnIdAtLastUserMessage && lastTurnIdForComplete) {
2343
- events.push(withBackfillRefs(baseCodexEvent({
2344
- ts,
2361
+ if (currentTurnId && currentTurnId !== turnIdAtLastUserMessage && lastTurnIdForComplete && !closedTurnIds.has(lastTurnIdForComplete)) {
2362
+ closedTurnIds.add(lastTurnIdForComplete);
2363
+ pushEvent(baseCodexEvent({
2364
+ ts: turnCloseTs(lastTurnIdForComplete),
2345
2365
  type: "turn.completed",
2346
2366
  sessionId,
2347
2367
  turnId: lastTurnIdForComplete,
@@ -2349,14 +2369,14 @@ async function parseCodexSessionFile(filePath, options) {
2349
2369
  project,
2350
2370
  model,
2351
2371
  confidence: "derived"
2352
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2372
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2353
2373
  }
2354
2374
  if (!currentTurnId || currentTurnId === turnIdAtLastUserMessage) {
2355
2375
  currentTurnId = `turn_${createStableHash([sessionId, lineNumber, ts]).slice(0, 24)}`;
2356
2376
  }
2357
2377
  lastTurnIdForComplete = currentTurnId;
2358
2378
  turnIdAtLastUserMessage = currentTurnId;
2359
- events.push(withBackfillRefs(baseCodexEvent({
2379
+ pushEvent(baseCodexEvent({
2360
2380
  ts,
2361
2381
  type: "prompt.submitted",
2362
2382
  sessionId,
@@ -2372,7 +2392,7 @@ async function parseCodexSessionFile(filePath, options) {
2372
2392
  refs: stringRefs({
2373
2393
  promptHash: message ? `sha256:${createStableHash(message)}` : void 0
2374
2394
  })
2375
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2395
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2376
2396
  break;
2377
2397
  }
2378
2398
  case "token_count": {
@@ -2394,7 +2414,7 @@ async function parseCodexSessionFile(filePath, options) {
2394
2414
  break;
2395
2415
  }
2396
2416
  lastTokenUsageKey = usageKey;
2397
- events.push(withBackfillRefs(baseCodexEvent({
2417
+ pushEvent(baseCodexEvent({
2398
2418
  ts,
2399
2419
  type: "model.usage",
2400
2420
  sessionId,
@@ -2407,13 +2427,13 @@ async function parseCodexSessionFile(filePath, options) {
2407
2427
  model: rewriteCodexModelForTier(model, serviceTier),
2408
2428
  confidence: "partial",
2409
2429
  metrics: usage
2410
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2430
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2411
2431
  }
2412
2432
  break;
2413
2433
  }
2414
2434
  case "agent_message": {
2415
2435
  const message = stringField(payload, "message") || "";
2416
- events.push(withBackfillRefs(baseCodexEvent({
2436
+ pushEvent(baseCodexEvent({
2417
2437
  ts,
2418
2438
  type: "agent.operation",
2419
2439
  operation: "agent message",
@@ -2426,7 +2446,7 @@ async function parseCodexSessionFile(filePath, options) {
2426
2446
  metrics: {
2427
2447
  agentMessageChars: message.length
2428
2448
  }
2429
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2449
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2430
2450
  break;
2431
2451
  }
2432
2452
  case "function_call":
@@ -2438,7 +2458,7 @@ async function parseCodexSessionFile(filePath, options) {
2438
2458
  if (callId) {
2439
2459
  pendingToolCalls.set(callId, { tool, startedAt: ts, turnId: currentTurnId });
2440
2460
  }
2441
- events.push(withBackfillRefs(baseCodexEvent({
2461
+ pushEvent(baseCodexEvent({
2442
2462
  ts,
2443
2463
  type: "tool.started",
2444
2464
  operation: `${tool} started`,
@@ -2455,9 +2475,9 @@ async function parseCodexSessionFile(filePath, options) {
2455
2475
  refs: stringRefs({
2456
2476
  sourceId: callId
2457
2477
  })
2458
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2478
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2459
2479
  if (fileActivities.length > 0) {
2460
- events.push(withBackfillRefs(baseCodexEvent({
2480
+ pushEvent(baseCodexEvent({
2461
2481
  ts,
2462
2482
  type: eventTypeFromFileActivities(fileActivities),
2463
2483
  operation: `${tool} file activity`,
@@ -2473,7 +2493,7 @@ async function parseCodexSessionFile(filePath, options) {
2473
2493
  refs: stringRefs({
2474
2494
  sourceId: callId
2475
2495
  })
2476
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2496
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2477
2497
  }
2478
2498
  break;
2479
2499
  }
@@ -2485,7 +2505,7 @@ async function parseCodexSessionFile(filePath, options) {
2485
2505
  pendingToolCalls.delete(callId);
2486
2506
  }
2487
2507
  const durationMs = pending ? durationMsBetween(pending.startedAt, ts) : void 0;
2488
- events.push(withBackfillRefs(baseCodexEvent({
2508
+ pushEvent(baseCodexEvent({
2489
2509
  ts,
2490
2510
  type: "tool.completed",
2491
2511
  operation: pending ? `${pending.tool} completed` : "tool completed",
@@ -2503,13 +2523,13 @@ async function parseCodexSessionFile(filePath, options) {
2503
2523
  refs: stringRefs({
2504
2524
  sourceId: callId
2505
2525
  })
2506
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2526
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2507
2527
  break;
2508
2528
  }
2509
2529
  case "exec_command_end": {
2510
2530
  const durationMs = durationObjectToMs(objectField(payload, "duration"));
2511
2531
  const success = Number(payload.exit_code) === 0;
2512
- events.push(withBackfillRefs(baseCodexEvent({
2532
+ pushEvent(baseCodexEvent({
2513
2533
  ts,
2514
2534
  type: success ? "command.completed" : "command.failed",
2515
2535
  operation: "command completed",
@@ -2530,7 +2550,7 @@ async function parseCodexSessionFile(filePath, options) {
2530
2550
  sourceId: stringField(payload, "call_id"),
2531
2551
  commandHash: createStableHash(payload.command)
2532
2552
  })
2533
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2553
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2534
2554
  break;
2535
2555
  }
2536
2556
  case "patch_apply_end": {
@@ -2540,7 +2560,7 @@ async function parseCodexSessionFile(filePath, options) {
2540
2560
  cwd,
2541
2561
  displayFilePath2
2542
2562
  );
2543
- events.push(withBackfillRefs(baseCodexEvent({
2563
+ pushEvent(baseCodexEvent({
2544
2564
  ts,
2545
2565
  type: "file.changed",
2546
2566
  operation: "apply patch",
@@ -2557,15 +2577,19 @@ async function parseCodexSessionFile(filePath, options) {
2557
2577
  refs: stringRefs({
2558
2578
  sourceId: stringField(payload, "call_id")
2559
2579
  })
2560
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2580
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2561
2581
  break;
2562
2582
  }
2563
2583
  case "task_complete": {
2564
- events.push(withBackfillRefs(baseCodexEvent({
2584
+ const completedTurnId = stringField(payload, "turn_id") || currentTurnId;
2585
+ if (completedTurnId) {
2586
+ closedTurnIds.add(completedTurnId);
2587
+ }
2588
+ pushEvent(baseCodexEvent({
2565
2589
  ts,
2566
2590
  type: "turn.completed",
2567
2591
  sessionId,
2568
- turnId: stringField(payload, "turn_id") || currentTurnId,
2592
+ turnId: completedTurnId,
2569
2593
  cwd,
2570
2594
  project,
2571
2595
  model,
@@ -2573,15 +2597,15 @@ async function parseCodexSessionFile(filePath, options) {
2573
2597
  metrics: {
2574
2598
  durationMs: numberField(payload, "duration_ms")
2575
2599
  }
2576
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2600
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2577
2601
  break;
2578
2602
  }
2579
2603
  }
2580
2604
  }
2581
- if (lastTurnIdForComplete) {
2582
- const lastTs = events.length > 0 ? events.at(-1).ts : (/* @__PURE__ */ new Date()).toISOString();
2583
- events.push(withBackfillRefs(baseCodexEvent({
2584
- ts: lastTs,
2605
+ if (lastTurnIdForComplete && !closedTurnIds.has(lastTurnIdForComplete)) {
2606
+ closedTurnIds.add(lastTurnIdForComplete);
2607
+ pushEvent(baseCodexEvent({
2608
+ ts: turnCloseTs(lastTurnIdForComplete),
2585
2609
  type: "turn.completed",
2586
2610
  sessionId,
2587
2611
  turnId: lastTurnIdForComplete,
@@ -2589,7 +2613,7 @@ async function parseCodexSessionFile(filePath, options) {
2589
2613
  project,
2590
2614
  model,
2591
2615
  confidence: "derived"
2592
- }), { filePath, sourcePathHash, lineNumber: lines.length, topType: "event_msg", payloadType: "turn.completed", options }));
2616
+ }), { filePath, sourcePathHash, lineNumber: lines.length, topType: "event_msg", payloadType: "turn.completed", options });
2593
2617
  }
2594
2618
  return events.filter((event) => validateCanonicalEvent(event).valid);
2595
2619
  }
@@ -2628,10 +2652,11 @@ function headlessCodexUsage(raw) {
2628
2652
  if (input === 0 && cached === 0 && output === 0 && reasoning === 0 && total === 0) {
2629
2653
  return;
2630
2654
  }
2655
+ const billableOutput = output + reasoning;
2631
2656
  return {
2632
2657
  tokensInput: input || void 0,
2633
2658
  tokensCachedInput: cached || void 0,
2634
- tokensOutput: output || void 0,
2659
+ tokensOutput: billableOutput || void 0,
2635
2660
  tokensReasoningOutput: reasoning || void 0,
2636
2661
  tokensTotal: total,
2637
2662
  modelCalls: 1
@@ -2992,9 +3017,10 @@ async function parseGeminiSessionFile(filePath, options) {
2992
3017
  let lastTs = usages[0].ts;
2993
3018
  for (const [index, usage] of usages.entries()) {
2994
3019
  lastTs = usage.ts;
3020
+ const billableOutput = usage.tokensOutput + usage.tokensReasoning;
2995
3021
  const metrics = {
2996
3022
  tokensInput: usage.tokensInput || void 0,
2997
- tokensOutput: usage.tokensOutput || void 0,
3023
+ tokensOutput: billableOutput || void 0,
2998
3024
  tokensCachedInput: usage.tokensCacheRead || void 0,
2999
3025
  tokensCacheReadInput: usage.tokensCacheRead || void 0,
3000
3026
  tokensReasoningOutput: usage.tokensReasoning || void 0,
@@ -3439,13 +3465,14 @@ function opencodeUsageFromInfo(info) {
3439
3465
  const cacheRead = Math.max(0, numberField(cache, "read") || 0);
3440
3466
  const cacheWrite = Math.max(0, numberField(cache, "write") || 0);
3441
3467
  const totalInput = input + cacheRead + cacheWrite;
3442
- const total = Math.max(0, numberField(tokensObj, "total") || totalInput + output + reasoning);
3468
+ const billableOutput = output + reasoning;
3469
+ const total = Math.max(0, numberField(tokensObj, "total") || totalInput + billableOutput);
3443
3470
  if (total <= 0) {
3444
3471
  return void 0;
3445
3472
  }
3446
3473
  return {
3447
3474
  tokensInput: totalInput || void 0,
3448
- tokensOutput: output || void 0,
3475
+ tokensOutput: billableOutput || void 0,
3449
3476
  tokensReasoningOutput: reasoning || void 0,
3450
3477
  tokensCachedInput: cacheRead + cacheWrite || void 0,
3451
3478
  tokensCacheReadInput: cacheRead || void 0,
@@ -4113,6 +4140,7 @@ function buildSessionRollup(rollupKey, events) {
4113
4140
  const toolRollups = /* @__PURE__ */ new Map();
4114
4141
  const fileRollups = /* @__PURE__ */ new Map();
4115
4142
  const turnRollups = /* @__PURE__ */ new Map();
4143
+ const turnEventTimes = /* @__PURE__ */ new Map();
4116
4144
  let promptCount = 0;
4117
4145
  let turnCount = 0;
4118
4146
  let toolCallCount = 0;
@@ -4210,6 +4238,12 @@ function buildSessionRollup(rollupKey, events) {
4210
4238
  turnRollup.outputTokens += eventOutputTokens;
4211
4239
  turnRollup.totalTokens += eventTotalTokens;
4212
4240
  turnRollups.set(event.turnId, turnRollup);
4241
+ const times = turnEventTimes.get(event.turnId);
4242
+ if (times) {
4243
+ times.push(event.ts);
4244
+ } else {
4245
+ turnEventTimes.set(event.turnId, [event.ts]);
4246
+ }
4213
4247
  }
4214
4248
  inputTokens += eventInputTokens;
4215
4249
  cachedInputTokens += eventCachedInputTokens;
@@ -4327,6 +4361,12 @@ function buildSessionRollup(rollupKey, events) {
4327
4361
  const baseRollup = {
4328
4362
  rollupKey,
4329
4363
  payloadHash: "",
4364
+ // v2 schema: trustworthy gap-clamped turn durations + billable-output token
4365
+ // convention. Set on baseRollup (not after) so it participates in payloadHash:
4366
+ // every historical rollup's hash changes, and a re-backfill (uploaded with
4367
+ // replace=true by default) cleanly refreshes all data onto the new convention.
4368
+ // This full-refresh churn is intentional.
4369
+ schemaVersion: AGENT_ROLLUP_SCHEMA_VERSION,
4330
4370
  source: first.source,
4331
4371
  project,
4332
4372
  sessionId,
@@ -4354,7 +4394,10 @@ function buildSessionRollup(rollupKey, events) {
4354
4394
  fileRollups: [...fileRollups.values()].sort((a, b) => b.writes - a.writes || b.reads - a.reads || a.displayPath.localeCompare(b.displayPath)),
4355
4395
  turnRollups: [...turnRollups.values()].map((rollup) => ({
4356
4396
  ...rollup,
4357
- durationMs: Math.max(0, Date.parse(rollup.lastEventAt) - Date.parse(rollup.startedAt))
4397
+ // Gap-clamped active duration: startedAt/lastEventAt/completedAt keep their
4398
+ // real timestamps, but durationMs only counts active intervals so lazy
4399
+ // completed_at timestamps and long in-turn silences don't inflate it.
4400
+ durationMs: gapClampedTurnDurationMs(rollup, turnEventTimes.get(rollup.turnId) || [])
4358
4401
  })).sort((a, b) => a.startedAt.localeCompare(b.startedAt))
4359
4402
  };
4360
4403
  return {
@@ -4362,6 +4405,22 @@ function buildSessionRollup(rollupKey, events) {
4362
4405
  payloadHash: createPayloadHash(baseRollup)
4363
4406
  };
4364
4407
  }
4408
+ function gapClampedTurnDurationMs(rollup, eventTimes) {
4409
+ const millis = [
4410
+ rollup.promptSubmittedAt,
4411
+ rollup.startedAt,
4412
+ ...eventTimes
4413
+ ].map((ts) => ts ? Date.parse(ts) : Number.NaN).filter((ms) => Number.isFinite(ms));
4414
+ const unique = [...new Set(millis)].sort((a, b) => a - b);
4415
+ if (unique.length < 2) {
4416
+ return 0;
4417
+ }
4418
+ let duration = 0;
4419
+ for (let i = 1; i < unique.length; i += 1) {
4420
+ duration += Math.min(unique[i] - unique[i - 1], TURN_GAP_CLAMP_MS);
4421
+ }
4422
+ return Math.max(0, duration);
4423
+ }
4365
4424
  function floorRollupBucket(ts) {
4366
4425
  const ms = Date.parse(ts);
4367
4426
  if (!Number.isFinite(ms)) {
@@ -4374,7 +4433,7 @@ function totalTokensFromEvent(event) {
4374
4433
  if (typeof explicit === "number" && explicit > 0) {
4375
4434
  return explicit;
4376
4435
  }
4377
- return Math.max(0, event.metrics?.tokensInput || 0) + Math.max(0, event.metrics?.tokensOutput || 0) + Math.max(0, event.metrics?.tokensReasoningOutput || 0);
4436
+ return Math.max(0, event.metrics?.tokensInput || 0) + Math.max(0, event.metrics?.tokensOutput || 0);
4378
4437
  }
4379
4438
  function lineStatsFromEvent(event) {
4380
4439
  const files = event.fileActivities || [];
@@ -4578,6 +4637,40 @@ async function logError(scope, error, meta = {}, home = homedir2()) {
4578
4637
  await writeLog({ scope, error, meta, level: "error" }, home);
4579
4638
  }
4580
4639
 
4640
+ // src/lib/login.ts
4641
+ function sleep(ms) {
4642
+ return new Promise((resolve) => setTimeout(resolve, ms));
4643
+ }
4644
+ function openBrowser(ctx, url) {
4645
+ const { command, args } = browserCommand(ctx.env.CODETIME_OS ?? process.platform, url);
4646
+ try {
4647
+ const child = ctx.spawn(command, args, { detached: true, stdio: "ignore" });
4648
+ child.unref?.();
4649
+ } catch {
4650
+ }
4651
+ }
4652
+ function browserCommand(platform, url) {
4653
+ if (platform === "darwin") {
4654
+ return { command: "open", args: [url] };
4655
+ }
4656
+ if (platform === "win32") {
4657
+ return { command: "cmd", args: ["/c", "start", "", url] };
4658
+ }
4659
+ return { command: "xdg-open", args: [url] };
4660
+ }
4661
+ function isHeadless(ctx) {
4662
+ if (ctx.env.CODETIME_NO_BROWSER) {
4663
+ return true;
4664
+ }
4665
+ if (ctx.env.SSH_CONNECTION || ctx.env.SSH_TTY) {
4666
+ return true;
4667
+ }
4668
+ if (process.platform === "linux") {
4669
+ return !ctx.env.DISPLAY && !ctx.env.WAYLAND_DISPLAY;
4670
+ }
4671
+ return false;
4672
+ }
4673
+
4581
4674
  // src/lib/progress.ts
4582
4675
  var BAR_WIDTH = 24;
4583
4676
  var ProgressBar = class {
@@ -4737,6 +4830,28 @@ async function deleteRollupsBySource(remote, source, machine) {
4737
4830
  const body = await response.json();
4738
4831
  return Number(body.deleted) || 0;
4739
4832
  }
4833
+ async function startCliLink(remote) {
4834
+ const response = await remote.fetchImpl(joinUrl(remote.baseUrl, "/v3/agent/cli/link/start"), {
4835
+ method: "POST",
4836
+ headers: buildHeaders(),
4837
+ body: "{}"
4838
+ });
4839
+ if (!response.ok) {
4840
+ throw new Error(`Failed to start login: ${response.status}`);
4841
+ }
4842
+ return await response.json();
4843
+ }
4844
+ async function pollCliLink(remote, deviceCode) {
4845
+ const response = await remote.fetchImpl(joinUrl(remote.baseUrl, "/v3/agent/cli/link/poll"), {
4846
+ method: "POST",
4847
+ headers: buildHeaders(),
4848
+ body: JSON.stringify({ deviceCode })
4849
+ });
4850
+ if (!response.ok) {
4851
+ throw new Error(`Login poll failed: ${response.status}`);
4852
+ }
4853
+ return await response.json();
4854
+ }
4740
4855
  async function listMachines(remote) {
4741
4856
  const response = await remote.fetchImpl(joinUrl(remote.baseUrl, "/v3/machines"), {
4742
4857
  headers: buildHeaders(remote.token)
@@ -4843,6 +4958,11 @@ function createCli(ctx, registry) {
4843
4958
  cli.command("sync-local-trigger", "Trigger one background local sync with throttle and locking").option("--min-interval <seconds>", "Minimum seconds between sync triggers").action((options) => syncLocalTriggerCommand(normalizeOptions(options), ctx, registry));
4844
4959
  cli.command("sync-local-runner", "Internal background local sync runner").option("--lock-file <path>", "Lock file for the active sync").option("--state-file <path>", "State file for trigger metadata").action((options) => syncLocalRunnerCommand(normalizeOptions(options), ctx, registry));
4845
4960
  cli.command("backfill [action]", "Inspect local history import candidates").option("--source <source>", "Backfill source").option("--since <time>", "Only include history after this time").option("--until <time>", "Only include history before this time").option("--project <name>", "Project filter").option("--source-root <path>", "Override source history root").option("--include-source-path", "Include local source paths in output").option("--import-run <id>", "Import run id for verify/resume workflows").option("--limit <count>", "Maximum session files to parse").option("--batch-size <count>", "Max rollups per request (also bounded by --batch-bytes)").option("--batch-bytes <bytes>", "Soft byte cap for the JSON body of a single ingest POST").option("--replace", "Replace conflicting records during import (default)").option("--skip-conflicts", "Skip conflicting records instead of replacing them").option("--force", "Force full re-import: clear watermark and re-process all files").action((action, options) => backfillCommand({ ...normalizeOptions(options), action }, ctx, registry));
4961
+ cli.command("sync", "Import and upload all local agent history (shorthand for `backfill import --source all`)").option("--source <source>", "Limit to one source (default: all)").option("--since <time>", "Only include history after this time").option("--until <time>", "Only include history before this time").option("--project <name>", "Project filter").option("--batch-size <count>", "Max rollups per request (also bounded by --batch-bytes)").option("--force", "Force full re-import: clear watermark and re-process all files").option("--dry-run", "Print the planned import without uploading").action((options) => {
4962
+ const opts = normalizeOptions(options);
4963
+ return backfillCommand({ ...opts, action: "import", source: stringOption(opts.source) || "all" }, ctx, registry);
4964
+ });
4965
+ cli.command("login", "Authorize this machine by signing in through your browser").option("--remote <url>", "Override API base URL for this login").option("--no-browser", "Print the login URL instead of opening a browser").action((options) => loginCommand(normalizeOptions(options), ctx));
4846
4966
  cli.command("token [action] [value]", "Set, show, or clear the persisted API token").option("--remote <url>", "Override API base URL when setting a token").action((action, value, options) => tokenCommand(action, value, normalizeOptions(options), ctx));
4847
4967
  cli.command("machine [action]", "List or rename machines (requires login)").option("--name <name>", "New display name (used by `machine rename`)").option("--id <id>", "Machine id (defaults to current machine)").action((action, options) => machineCommand(action, normalizeOptions(options), ctx));
4848
4968
  return cli;
@@ -5716,6 +5836,80 @@ function maskToken(token) {
5716
5836
  }
5717
5837
  return `${token.slice(0, 3)}\u2026${token.slice(-4)}`;
5718
5838
  }
5839
+ var LOGIN_MAX_WAIT_MS = 15 * 60 * 1e3;
5840
+ async function loginCommand(options, ctx) {
5841
+ const home = resolveHome(options, ctx);
5842
+ const remoteOverride = stringOption(options.remote) || stringOption(options["api-url"]);
5843
+ const existing = readConfig(home);
5844
+ const baseUrl = (remoteOverride || ctx.env.CODETIME_API_URL || existing.remoteUrl || DEFAULT_API_URL).replace(/\/$/, "");
5845
+ const remote = resolveRemote({
5846
+ apiUrl: baseUrl,
5847
+ env: ctx.env,
5848
+ fetch: ctx.fetch,
5849
+ homeOverride: home
5850
+ });
5851
+ if (!remote) {
5852
+ write(ctx.stderr, "No fetch implementation available.\n");
5853
+ return 1;
5854
+ }
5855
+ let link;
5856
+ try {
5857
+ link = await startCliLink(remote);
5858
+ } catch (error) {
5859
+ write(ctx.stderr, `${error.message}
5860
+ `);
5861
+ return 1;
5862
+ }
5863
+ const authUrl = `${baseUrl}/cli/auth?code=${encodeURIComponent(link.userCode)}`;
5864
+ const noBrowser = options.browser === false;
5865
+ const headless = isHeadless(ctx);
5866
+ write(ctx.stdout, `
5867
+ To sign in, visit:
5868
+
5869
+ ${authUrl}
5870
+
5871
+ and confirm this code: ${link.userCode}
5872
+
5873
+ `);
5874
+ if (!noBrowser && !headless) {
5875
+ openBrowser(ctx, authUrl);
5876
+ }
5877
+ write(ctx.stdout, "Waiting for authorization\u2026\n");
5878
+ const intervalMs = Math.max(1, link.interval || 4) * 1e3;
5879
+ const deadline = Date.now() + Math.min(LOGIN_MAX_WAIT_MS, Math.max(1, link.expiresIn || 600) * 1e3);
5880
+ while (Date.now() < deadline) {
5881
+ let poll;
5882
+ try {
5883
+ poll = await pollCliLink(remote, link.deviceCode);
5884
+ } catch (error) {
5885
+ void error;
5886
+ await sleep(intervalMs);
5887
+ continue;
5888
+ }
5889
+ if (poll.status === "pending") {
5890
+ await sleep(intervalMs);
5891
+ continue;
5892
+ }
5893
+ if (poll.status === "expired") {
5894
+ write(ctx.stderr, "Login code expired before it was approved. Re-run `codetime login`.\n");
5895
+ return 1;
5896
+ }
5897
+ writeConfig({
5898
+ ...existing,
5899
+ token: poll.token,
5900
+ ...poll.userId == null ? {} : { userId: String(poll.userId) },
5901
+ // Persist the host only when explicitly overridden, matching
5902
+ // `token set` so a default-host login never clobbers an earlier
5903
+ // --remote choice.
5904
+ ...remoteOverride ? { remoteUrl: remoteOverride } : {}
5905
+ }, home);
5906
+ write(ctx.stdout, `Logged in. Token saved (${maskToken(poll.token)}).
5907
+ `);
5908
+ return 0;
5909
+ }
5910
+ write(ctx.stderr, "Timed out waiting for authorization. Re-run `codetime login`.\n");
5911
+ return 1;
5912
+ }
5719
5913
  async function tokenCommand(action, value, options, ctx) {
5720
5914
  const verb = action || "show";
5721
5915
  const home = resolveHome(options, ctx);
@@ -5827,20 +6021,25 @@ Usage:
5827
6021
  codetime detect [--json] [--home <path>]
5828
6022
  codetime install [--target codex,claude,opencode,pi] [--all] [--dry-run] [--force] [--home <path>]
5829
6023
  codetime hook --agent <name>
6024
+ codetime sync [--source <source>] [--force] [--dry-run]
5830
6025
  codetime backfill discover|plan|import|verify --source codex|claude-code|opencode|pi|all --dry-run [--json] [--batch-size <count>]
6026
+ codetime login [--no-browser] [--remote <url>]
5831
6027
  codetime token set <token>
5832
6028
  codetime token show
5833
6029
  codetime token clear
5834
6030
 
5835
6031
  Setup:
5836
- Copy your upload token from https://codetime.dev/dashboard/settings,
5837
- then run: codetime token set <token>
6032
+ Run: codetime login (signs in through your browser)
6033
+ Or copy your upload token from https://codetime.dev/dashboard/settings
6034
+ and run: codetime token set <token>
5838
6035
 
5839
6036
  Commands:
5840
6037
  detect Show supported local targets and install status.
5841
6038
  install Install integration files into detected or requested targets.
5842
6039
  hook Read agent hook JSON from stdin and report a throttled event.
6040
+ sync Import and upload all local agent history (backfill import --source all).
5843
6041
  backfill Discover local history and create metadata-only import plans.
6042
+ login Authorize this machine by signing in through your browser.
5844
6043
  token Set, show, or clear the persisted API token.
5845
6044
  machine List your machines (read-only).
5846
6045
  version Print CLI version.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "codetime-cli",
3
3
  "type": "module",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
5
  "description": "codetime CLI — install AI-agent hooks (Claude Code, Codex, OpenCode, Pi) and report activity to codetime.dev.",
6
6
  "license": "MIT",
7
7
  "publishConfig": {