codetime-cli 0.1.0 → 0.3.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 +260 -173
  2. package/package.json +5 -5
package/bin/codetime.mjs CHANGED
@@ -143,6 +143,9 @@ var init_fs = __esm({
143
143
  }
144
144
  });
145
145
 
146
+ // src/lib/types.ts
147
+ var BACKFILL_STATE_SCHEMA_VERSION = 2;
148
+
146
149
  // src/cli.ts
147
150
  import { spawn } from "node:child_process";
148
151
  import { mkdir as mkdir2, rm, stat as stat4, writeFile as writeFile2 } from "node:fs/promises";
@@ -1329,9 +1332,10 @@ function msToIso(ms) {
1329
1332
  }
1330
1333
 
1331
1334
  // src/lib/constants.ts
1332
- var PACKAGE_VERSION = true ? "0.1.0" : "0.1.1";
1335
+ var PACKAGE_VERSION = true ? "0.3.0" : "0.1.1";
1333
1336
  var DEFAULT_API_URL = "https://codetime.dev";
1334
- var DEFAULT_BACKFILL_BATCH_SIZE = 500;
1337
+ var DEFAULT_BACKFILL_BATCH_SIZE = 50;
1338
+ var DEFAULT_BACKFILL_BATCH_BYTES = 800 * 1024;
1335
1339
  var ROLLUP_BUCKET_MS = 15 * 60 * 1e3;
1336
1340
  var DEFAULT_HOOK_SYNC_MIN_INTERVAL_SECONDS = 60;
1337
1341
 
@@ -1471,6 +1475,7 @@ async function parseClaudeCodeSessionFile(filePath, options) {
1471
1475
  const lines = text.split("\n").filter(Boolean);
1472
1476
  const projectContext = await claudeProjectContextFromLines(filePath, lines, options);
1473
1477
  const pendingTools = /* @__PURE__ */ new Map();
1478
+ const seenUsageKeys = /* @__PURE__ */ new Set();
1474
1479
  let sessionId = sessionIdFromFilePath(filePath, "claude");
1475
1480
  let cwd;
1476
1481
  let project = projectContext.project;
@@ -1622,8 +1627,19 @@ async function parseClaudeCodeSessionFile(filePath, options) {
1622
1627
  continue;
1623
1628
  }
1624
1629
  model = stringField(message, "model") || model;
1630
+ const messageId = stringField(message, "id");
1631
+ const requestId = stringField(raw, "requestId");
1632
+ const usageKey = messageId ? `${messageId}:${requestId}` : null;
1633
+ if (usageKey != null && seenUsageKeys.has(usageKey)) {
1634
+ continue;
1635
+ }
1636
+ if (usageKey != null) {
1637
+ seenUsageKeys.add(usageKey);
1638
+ }
1625
1639
  const usage = claudeUsageFromMessage(message);
1626
1640
  if (usage) {
1641
+ const speed = stringField(objectField(message, "usage"), "speed");
1642
+ const usageModel = speed === "fast" && model ? `${model}-fast` : model;
1627
1643
  push(baseClaudeEvent({
1628
1644
  ts,
1629
1645
  type: "model.usage",
@@ -1631,7 +1647,7 @@ async function parseClaudeCodeSessionFile(filePath, options) {
1631
1647
  turnId: state.currentTurnId,
1632
1648
  cwd,
1633
1649
  project,
1634
- model,
1650
+ model: usageModel,
1635
1651
  confidence: "partial",
1636
1652
  metrics: usage
1637
1653
  }), lineNumber, topType, "usage");
@@ -1939,35 +1955,43 @@ function hookConfig() {
1939
1955
  }
1940
1956
  };
1941
1957
  }
1958
+ function claudeConfigDir(home, env) {
1959
+ const override = env?.CLAUDE_CONFIG_DIR;
1960
+ if (override && override.trim()) {
1961
+ return path5.resolve(override);
1962
+ }
1963
+ return path5.join(home, ".claude");
1964
+ }
1942
1965
  function createClaudeCodeAdapter() {
1943
- const CLAUDE_PATH = ".claude";
1944
1966
  return {
1945
1967
  id: "claude-code",
1946
1968
  label: "Claude Code",
1947
1969
  agentName: "claude",
1948
1970
  kind: "agent",
1949
- detectPath(home) {
1950
- return path5.join(home, CLAUDE_PATH);
1971
+ detectPath(home, env) {
1972
+ return claudeConfigDir(home, env);
1951
1973
  },
1952
- installedPath(home) {
1953
- return path5.join(home, CLAUDE_PATH, "settings.json");
1974
+ installedPath(home, env) {
1975
+ return path5.join(claudeConfigDir(home, env), "settings.json");
1954
1976
  },
1955
- async isInstalled(home) {
1977
+ async isInstalled(home, env) {
1956
1978
  return isHooksJsonInstalled(
1957
- path5.join(home, CLAUDE_PATH, "settings.json"),
1979
+ path5.join(claudeConfigDir(home, env), "settings.json"),
1958
1980
  "codetime hook --agent claude"
1959
1981
  );
1960
1982
  },
1961
- installEntries(home) {
1983
+ installEntries(home, env) {
1962
1984
  return [{
1963
1985
  kind: "hooks-json",
1964
- path: path5.join(home, CLAUDE_PATH, "settings.json"),
1986
+ path: path5.join(claudeConfigDir(home, env), "settings.json"),
1965
1987
  content: hookConfig()
1966
1988
  }];
1967
1989
  },
1968
- sourcePaths(home) {
1990
+ sourcePaths(home, env) {
1991
+ const base = claudeConfigDir(home, env);
1969
1992
  return [
1970
- path5.join(home, ".claude", "projects"),
1993
+ path5.join(base, "projects"),
1994
+ path5.join(base, ".claude.json"),
1971
1995
  path5.join(home, ".claude.json")
1972
1996
  ];
1973
1997
  },
@@ -2472,36 +2496,43 @@ function hookConfig2() {
2472
2496
  }
2473
2497
  };
2474
2498
  }
2499
+ function codexHome(home, env) {
2500
+ const override = env?.CODEX_HOME;
2501
+ if (override && override.trim()) {
2502
+ return path6.resolve(override);
2503
+ }
2504
+ return path6.join(home, ".codex");
2505
+ }
2475
2506
  function createCodexAdapter() {
2476
- const CODE_PATH = ".codex";
2477
2507
  return {
2478
2508
  id: "codex",
2479
2509
  label: "Codex",
2480
2510
  agentName: "codex",
2481
2511
  kind: "agent",
2482
- detectPath(home) {
2483
- return path6.join(home, CODE_PATH);
2512
+ detectPath(home, env) {
2513
+ return codexHome(home, env);
2484
2514
  },
2485
- installedPath(home) {
2486
- return path6.join(home, CODE_PATH, "hooks.json");
2515
+ installedPath(home, env) {
2516
+ return path6.join(codexHome(home, env), "hooks.json");
2487
2517
  },
2488
- async isInstalled(home) {
2518
+ async isInstalled(home, env) {
2489
2519
  return isHooksJsonInstalled(
2490
- path6.join(home, CODE_PATH, "hooks.json"),
2520
+ path6.join(codexHome(home, env), "hooks.json"),
2491
2521
  "codetime hook --agent codex"
2492
2522
  );
2493
2523
  },
2494
- installEntries(home) {
2524
+ installEntries(home, env) {
2495
2525
  return [{
2496
2526
  kind: "hooks-json",
2497
- path: path6.join(home, CODE_PATH, "hooks.json"),
2527
+ path: path6.join(codexHome(home, env), "hooks.json"),
2498
2528
  content: hookConfig2()
2499
2529
  }];
2500
2530
  },
2501
- sourcePaths(home) {
2531
+ sourcePaths(home, env) {
2532
+ const base = codexHome(home, env);
2502
2533
  return [
2503
- path6.join(home, ".codex", "sessions"),
2504
- path6.join(home, ".codex", "history.jsonl")
2534
+ path6.join(base, "sessions"),
2535
+ path6.join(base, "history.jsonl")
2505
2536
  ];
2506
2537
  },
2507
2538
  parseSessionFile: parseCodexSessionFile
@@ -2854,7 +2885,23 @@ function opencodeUsageFromInfo(info) {
2854
2885
  tokensTotal: total
2855
2886
  };
2856
2887
  }
2857
- async function opencodeBackfillFiles(sourceRoot) {
2888
+ function opencodeConfigDir(home, env) {
2889
+ const override = env?.OPENCODE_CONFIG_DIR;
2890
+ if (override && override.trim()) {
2891
+ return path7.resolve(override);
2892
+ }
2893
+ const xdgConfig = env?.XDG_CONFIG_HOME;
2894
+ if (xdgConfig && xdgConfig.trim()) {
2895
+ return path7.join(path7.resolve(xdgConfig), "opencode");
2896
+ }
2897
+ return path7.join(home, ".config", "opencode");
2898
+ }
2899
+ function opencodeDataCandidates(home, env) {
2900
+ const xdgData = env?.XDG_DATA_HOME;
2901
+ const primary = xdgData && xdgData.trim() ? path7.join(path7.resolve(xdgData), "opencode", "opencode.db") : path7.join(home, ".local", "share", "opencode", "opencode.db");
2902
+ return [primary, path7.join(home, ".opencode", "opencode.db")];
2903
+ }
2904
+ async function opencodeBackfillFiles(sourceRoot, home = os2.homedir(), env) {
2858
2905
  const { stat: stat5 } = await import("node:fs/promises");
2859
2906
  if (sourceRoot) {
2860
2907
  if (!sourceRoot.endsWith(".db")) {
@@ -2866,11 +2913,7 @@ async function opencodeBackfillFiles(sourceRoot) {
2866
2913
  }
2867
2914
  return [{ path: sourceRoot, modifiedAt: info.mtime.toISOString() }];
2868
2915
  }
2869
- const candidates = [
2870
- path7.join(os2.homedir(), ".local", "share", "opencode", "opencode.db"),
2871
- path7.join(os2.homedir(), ".opencode", "opencode.db")
2872
- ];
2873
- for (const candidatePath of candidates) {
2916
+ for (const candidatePath of opencodeDataCandidates(home, env)) {
2874
2917
  const info = await stat5(candidatePath).catch(() => null);
2875
2918
  if (info) {
2876
2919
  return [{ path: candidatePath, modifiedAt: info.mtime.toISOString() }];
@@ -2924,39 +2967,35 @@ export const AgentTime = async ({ $, directory }) => {
2924
2967
  `;
2925
2968
  }
2926
2969
  function createOpenCodeAdapter() {
2927
- const OPENCODE_CONFIG = ".config/opencode";
2928
2970
  const PLUGIN_PATH = "plugins/codetime.mjs";
2929
2971
  return {
2930
2972
  id: "opencode",
2931
2973
  label: "OpenCode",
2932
2974
  agentName: "opencode",
2933
2975
  kind: "agent",
2934
- detectPath(home) {
2935
- return path7.join(home, OPENCODE_CONFIG);
2976
+ detectPath(home, env) {
2977
+ return opencodeConfigDir(home, env);
2936
2978
  },
2937
- installedPath(home) {
2938
- return path7.join(home, OPENCODE_CONFIG, PLUGIN_PATH);
2979
+ installedPath(home, env) {
2980
+ return path7.join(opencodeConfigDir(home, env), PLUGIN_PATH);
2939
2981
  },
2940
- async isInstalled(home) {
2982
+ async isInstalled(home, env) {
2941
2983
  try {
2942
2984
  const { pathExists: pathExists2 } = await Promise.resolve().then(() => (init_fs(), fs_exports));
2943
- return await pathExists2(path7.join(home, OPENCODE_CONFIG, PLUGIN_PATH)) || await pathExists2(path7.join(".opencode", PLUGIN_PATH));
2985
+ return await pathExists2(path7.join(opencodeConfigDir(home, env), PLUGIN_PATH)) || await pathExists2(path7.join(".opencode", PLUGIN_PATH));
2944
2986
  } catch {
2945
2987
  return false;
2946
2988
  }
2947
2989
  },
2948
- installEntries(home) {
2990
+ installEntries(home, env) {
2949
2991
  return [{
2950
2992
  kind: "file",
2951
- path: path7.join(home, OPENCODE_CONFIG, PLUGIN_PATH),
2993
+ path: path7.join(opencodeConfigDir(home, env), PLUGIN_PATH),
2952
2994
  content: opencodePluginContent()
2953
2995
  }];
2954
2996
  },
2955
- sourcePaths(home) {
2956
- return [
2957
- path7.join(home, ".local", "share", "opencode", "opencode.db"),
2958
- path7.join(home, ".opencode", "opencode.db")
2959
- ];
2997
+ sourcePaths(home, env) {
2998
+ return opencodeDataCandidates(home, env);
2960
2999
  },
2961
3000
  parseSessionFile: parseOpenCodeSessionFile
2962
3001
  };
@@ -3390,36 +3429,49 @@ export default function (pi: ExtensionAPI) {
3390
3429
  }
3391
3430
  `;
3392
3431
  }
3432
+ function piAgentDir(home, env) {
3433
+ const override = env?.PI_CODING_AGENT_DIR;
3434
+ if (override && override.trim()) {
3435
+ return path8.resolve(override);
3436
+ }
3437
+ return path8.join(home, ".pi", "agent");
3438
+ }
3439
+ function piSessionDir(home, env) {
3440
+ const override = env?.PI_CODING_AGENT_SESSION_DIR;
3441
+ if (override && override.trim()) {
3442
+ return path8.resolve(override);
3443
+ }
3444
+ return path8.join(piAgentDir(home, env), "sessions");
3445
+ }
3393
3446
  function createPiAdapter() {
3394
- const PI_EXTENSIONS_DIR = ".pi/agent/extensions";
3395
3447
  return {
3396
3448
  id: "pi",
3397
3449
  label: "Pi",
3398
3450
  agentName: "pi",
3399
3451
  kind: "agent",
3400
- detectPath(home) {
3401
- return path8.join(home, ".pi", "agent");
3452
+ detectPath(home, env) {
3453
+ return piAgentDir(home, env);
3402
3454
  },
3403
- installedPath(home) {
3404
- return path8.join(home, PI_EXTENSIONS_DIR, "codetime.ts");
3455
+ installedPath(home, env) {
3456
+ return path8.join(piAgentDir(home, env), "extensions", "codetime.ts");
3405
3457
  },
3406
- async isInstalled(home) {
3458
+ async isInstalled(home, env) {
3407
3459
  try {
3408
3460
  const { pathExists: pathExists2 } = await Promise.resolve().then(() => (init_fs(), fs_exports));
3409
- return await pathExists2(path8.join(home, PI_EXTENSIONS_DIR, "codetime.ts"));
3461
+ return await pathExists2(path8.join(piAgentDir(home, env), "extensions", "codetime.ts"));
3410
3462
  } catch {
3411
3463
  return false;
3412
3464
  }
3413
3465
  },
3414
- installEntries(home) {
3466
+ installEntries(home, env) {
3415
3467
  return [{
3416
3468
  kind: "file",
3417
- path: path8.join(home, PI_EXTENSIONS_DIR, "codetime.ts"),
3469
+ path: path8.join(piAgentDir(home, env), "extensions", "codetime.ts"),
3418
3470
  content: piExtensionContent()
3419
3471
  }];
3420
3472
  },
3421
- sourcePaths(home) {
3422
- return [path8.join(home, ".pi", "agent", "sessions")];
3473
+ sourcePaths(home, env) {
3474
+ return [piSessionDir(home, env)];
3423
3475
  },
3424
3476
  parseSessionFile: parsePiSessionFile
3425
3477
  };
@@ -4538,7 +4590,7 @@ function createCli(ctx, registry) {
4538
4590
  cli.command("hook", "Read agent hook JSON from stdin and report a throttled event").option("--agent <name>", "Agent name").option("--project <name>", "Project name").option("--min-interval <seconds>", "Minimum seconds between similar hook reports").action((options) => hookCommand(normalizeOptions(options), ctx));
4539
4591
  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));
4540
4592
  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));
4541
- 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>", "Events per batch during import").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));
4593
+ 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));
4542
4594
  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));
4543
4595
  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));
4544
4596
  return cli;
@@ -4556,6 +4608,7 @@ function normalizeOptions(options) {
4556
4608
  sourceRoot: "source-root",
4557
4609
  importRun: "import-run",
4558
4610
  batchSize: "batch-size",
4611
+ batchBytes: "batch-bytes",
4559
4612
  skipConflicts: "skip-conflicts"
4560
4613
  };
4561
4614
  for (const [camel, dashed] of Object.entries(aliases)) {
@@ -4567,18 +4620,19 @@ function normalizeOptions(options) {
4567
4620
  }
4568
4621
  async function detectCommand(options, ctx, registry) {
4569
4622
  const home = resolveHome(options, ctx);
4623
+ const env = ctx.env;
4570
4624
  const adapters = registry.all();
4571
4625
  const targets = await Promise.all(adapters.map(async (adapter) => {
4572
- const detected = await pathExists(adapter.detectPath(home));
4573
- const installed = await adapter.isInstalled(home);
4626
+ const detected = await pathExists(adapter.detectPath(home, env));
4627
+ const installed = await adapter.isInstalled(home, env);
4574
4628
  return {
4575
4629
  id: adapter.id,
4576
4630
  label: adapter.label,
4577
4631
  kind: adapter.kind,
4578
4632
  detected,
4579
4633
  installed,
4580
- detectPath: adapter.detectPath(home),
4581
- installedPath: adapter.installedPath(home)
4634
+ detectPath: adapter.detectPath(home, env),
4635
+ installedPath: adapter.installedPath(home, env)
4582
4636
  };
4583
4637
  }));
4584
4638
  if (options.json) {
@@ -4595,6 +4649,7 @@ async function detectCommand(options, ctx, registry) {
4595
4649
  }
4596
4650
  async function installCommand(options, ctx, registry) {
4597
4651
  const home = resolveHome(options, ctx);
4652
+ const env = ctx.env;
4598
4653
  const dryRun = Boolean(options["dry-run"]);
4599
4654
  const force = Boolean(options.force);
4600
4655
  const allAdapters = registry.all();
@@ -4605,7 +4660,7 @@ async function installCommand(options, ctx, registry) {
4605
4660
  }
4606
4661
  const detected = [];
4607
4662
  for (const adapter of allAdapters) {
4608
- if (await pathExists(adapter.detectPath(home))) {
4663
+ if (await pathExists(adapter.detectPath(home, env))) {
4609
4664
  detected.push(adapter.id);
4610
4665
  }
4611
4666
  }
@@ -4615,7 +4670,7 @@ async function installCommand(options, ctx, registry) {
4615
4670
  return 1;
4616
4671
  }
4617
4672
  for (const adapter of allAdapters.filter((a) => selectedIds.includes(a.id))) {
4618
- for (const entry of adapter.installEntries(home)) {
4673
+ for (const entry of adapter.installEntries(home, env)) {
4619
4674
  await installEntry(entry, {
4620
4675
  dryRun,
4621
4676
  force,
@@ -4744,13 +4799,14 @@ async function backfillCommand(options, ctx, registry) {
4744
4799
  }
4745
4800
  async function createBackfillPlanFromOptions(options, ctx, action, registry) {
4746
4801
  const home = resolveHome(options, ctx);
4802
+ const env = ctx.env;
4747
4803
  const source = normalizeBackfillSource(stringOption(options.source) || "all");
4748
- const sourceDefs = source === "all" ? registry.all().map((a) => ({ id: a.id, label: a.label, paths: a.sourcePaths(home) })) : (() => {
4804
+ const sourceDefs = source === "all" ? registry.all().map((a) => ({ id: a.id, label: a.label, paths: a.sourcePaths(home, env) })) : (() => {
4749
4805
  const adapter = registry.get(source);
4750
4806
  if (!adapter) {
4751
4807
  return [];
4752
4808
  }
4753
- return [{ id: adapter.id, label: adapter.label, paths: adapter.sourcePaths(home) }];
4809
+ return [{ id: adapter.id, label: adapter.label, paths: adapter.sourcePaths(home, env) }];
4754
4810
  })();
4755
4811
  if (sourceDefs.length === 0) {
4756
4812
  throw new Error(`Unknown backfill source: ${source}`);
@@ -4759,7 +4815,7 @@ async function createBackfillPlanFromOptions(options, ctx, action, registry) {
4759
4815
  const candidates = candidateList.flat();
4760
4816
  let events = [];
4761
4817
  if (action !== "discover") {
4762
- const eventList = await createBackfillEventsFromDefs(sourceDefs, options, registry);
4818
+ const eventList = await createBackfillEventsFromDefs(sourceDefs, options, registry, ctx);
4763
4819
  events = eventList.flat();
4764
4820
  }
4765
4821
  const plannedEvents = events.map((event) => ({
@@ -4809,14 +4865,14 @@ async function createBackfillPlanFromOptions(options, ctx, action, registry) {
4809
4865
  privacy: "metadata only; prompt text, command text, source code, and diffs are not imported"
4810
4866
  };
4811
4867
  }
4812
- async function createBackfillEventsFromDefs(sourceDefs, options, registry, overrideFiles) {
4868
+ async function createBackfillEventsFromDefs(sourceDefs, options, registry, ctx, overrideFiles) {
4813
4869
  const events = [];
4814
4870
  for (const item of sourceDefs) {
4815
4871
  const parser = registry.getParser(item.id);
4816
4872
  if (!parser) {
4817
4873
  continue;
4818
4874
  }
4819
- const sourceFiles = await listBackfillSourceFiles(item, options);
4875
+ const sourceFiles = await listBackfillSourceFiles(item, options, ctx);
4820
4876
  const files = overrideFiles ?? sourceFiles.map((f) => f.path);
4821
4877
  for (const filePath of files) {
4822
4878
  const parsed = await parser(filePath, options);
@@ -4844,9 +4900,9 @@ async function createBackfillCandidates(source, options) {
4844
4900
  };
4845
4901
  }));
4846
4902
  }
4847
- async function listBackfillSourceFiles(source, options) {
4903
+ async function listBackfillSourceFiles(source, options, ctx) {
4848
4904
  if (source.id === "opencode") {
4849
- return opencodeBackfillFiles(stringOption(options["source-root"]));
4905
+ return opencodeBackfillFiles(stringOption(options["source-root"]), resolveHome(options, ctx), ctx.env);
4850
4906
  }
4851
4907
  const roots = stringOption(options["source-root"]) ? [requiredOption(options, "source-root")] : source.paths;
4852
4908
  const fileLists = await Promise.all(roots.map((r) => listJsonlFiles(r)));
@@ -4934,117 +4990,122 @@ async function importBackfillPlan(plan, options, ctx, registry) {
4934
4990
  write(ctx.stderr, "Only Codex, Claude Code, OpenCode, and Pi backfill import are implemented.\n");
4935
4991
  return 1;
4936
4992
  }
4937
- const allAdapters = registry.all();
4938
- const sourceDefs = allAdapters.filter((a) => supportedSources.has(a.id) && (source === "all" || a.id === source)).map((a) => ({ id: a.id, label: a.label, paths: a.sourcePaths(home) }));
4993
+ const sourceDefs = registry.all().filter((a) => supportedSources.has(a.id) && (source === "all" || a.id === source)).map((a) => ({ id: a.id, label: a.label, paths: a.sourcePaths(home, ctx.env) }));
4939
4994
  if (options.force) {
4940
- try {
4941
- await rm(backfillIncrementalStatePath(home), { force: true });
4942
- } catch {
4943
- }
4944
- for (const item of sourceDefs) {
4945
- try {
4946
- const deleted = await deleteSessionRollupsBySourceAPI(item.id, options, ctx);
4947
- if (!options.json) {
4948
- write(ctx.stdout, `purged ${item.id}: ${deleted} old rollups
4949
- `);
4950
- }
4951
- } catch (error) {
4952
- debug(ctx, `Failed to purge ${item.id} rollups: ${error.message}
4953
- `);
4954
- }
4955
- }
4995
+ await purgeForcedSources(sourceDefs, home, options, ctx);
4956
4996
  }
4957
- const incrementalState = shouldUseIncrementalBackfill(options) ? await readBackfillIncrementalState(home) : void 0;
4958
- const selectedFilesBySource = /* @__PURE__ */ new Map();
4959
- const canonicalEvents = [];
4997
+ const incrementalState = shouldUseIncrementalBackfill(options) ? await readBackfillIncrementalState(home, ctx) : void 0;
4960
4998
  if (!options.json) {
4961
4999
  write(ctx.stdout, `importRun ${plan.importRun.importRunId}
4962
5000
  `);
4963
5001
  write(ctx.stdout, `sources ${sourceDefs.map((s) => s.id).join(", ") || "none"}
5002
+ `);
5003
+ }
5004
+ const { canonicalEvents, selectedFilesBySource } = await collectCanonicalEvents(
5005
+ sourceDefs,
5006
+ registry,
5007
+ incrementalState,
5008
+ options,
5009
+ ctx
5010
+ );
5011
+ const rollups = buildSessionRollups(canonicalEvents);
5012
+ const counts = await uploadSessionRollups(rollups, canonicalEvents.length, options, ctx);
5013
+ const result = {
5014
+ importRunId: plan.importRun.importRunId,
5015
+ source,
5016
+ planned: rollups.length,
5017
+ sourceEvents: canonicalEvents.length,
5018
+ ...counts
5019
+ };
5020
+ if (options.json) {
5021
+ write(ctx.stdout, `${JSON.stringify(result, null, 2)}
5022
+ `);
5023
+ }
5024
+ if (counts.failed === 0 && counts.conflicts === 0 && incrementalState) {
5025
+ await updateBackfillIncrementalState(home, incrementalState, selectedFilesBySource);
5026
+ }
5027
+ return counts.failed > 0 || counts.conflicts > 0 && !options["skip-conflicts"] ? 1 : 0;
5028
+ }
5029
+ async function purgeForcedSources(sourceDefs, home, options, ctx) {
5030
+ try {
5031
+ await rm(backfillIncrementalStatePath(home), { force: true });
5032
+ } catch (error) {
5033
+ debug(ctx, `Failed to clear backfill watermark: ${error.message}
4964
5034
  `);
4965
5035
  }
5036
+ for (const item of sourceDefs) {
5037
+ try {
5038
+ const deleted = await deleteSessionRollupsBySourceAPI(item.id, options, ctx);
5039
+ if (!options.json) {
5040
+ write(ctx.stdout, `purged ${item.id}: ${deleted} old rollups
5041
+ `);
5042
+ }
5043
+ } catch (error) {
5044
+ debug(ctx, `Failed to purge ${item.id} rollups: ${error.message}
5045
+ `);
5046
+ }
5047
+ }
5048
+ }
5049
+ async function collectCanonicalEvents(sourceDefs, registry, incrementalState, options, ctx) {
5050
+ const selectedFilesBySource = /* @__PURE__ */ new Map();
5051
+ const canonicalEvents = [];
4966
5052
  for (const item of sourceDefs) {
4967
5053
  const parser = registry.getParser(item.id);
4968
5054
  if (!parser) {
4969
5055
  continue;
4970
5056
  }
4971
- const sourceFiles = await listBackfillSourceFiles(item, options);
5057
+ const sourceFiles = await listBackfillSourceFiles(item, options, ctx);
4972
5058
  const selectedFiles = selectBackfillFilesForImport(sourceFiles, incrementalState?.sources[item.id]?.watermarkTs);
4973
5059
  selectedFilesBySource.set(item.id, selectedFiles);
4974
5060
  const filePaths = selectedFiles.map((f) => f.path);
4975
5061
  const sourceEvents = [];
4976
- if (options.json) {
4977
- for (const filePath of filePaths) {
4978
- const parsed = await parser(filePath, options);
4979
- for (const event of parsed) {
4980
- if (matchesBackfillFilters(event, options)) {
4981
- sourceEvents.push(event);
4982
- }
4983
- }
4984
- }
4985
- } else {
4986
- const bar = new ProgressBar(ctx.stdout, `${item.id.padEnd(12)}`);
4987
- bar.init(filePaths.length, `0 events`);
4988
- for (let fi = 0; fi < filePaths.length; fi += 1) {
4989
- const parsed = await parser(filePaths[fi], options);
4990
- for (const event of parsed) {
4991
- if (matchesBackfillFilters(event, options)) {
4992
- sourceEvents.push(event);
4993
- }
5062
+ const bar = options.json ? void 0 : new ProgressBar(ctx.stdout, `${item.id.padEnd(12)}`);
5063
+ bar?.init(filePaths.length, `0 events`);
5064
+ for (let fi = 0; fi < filePaths.length; fi += 1) {
5065
+ const parsed = await parser(filePaths[fi], options);
5066
+ for (const event of parsed) {
5067
+ if (matchesBackfillFilters(event, options)) {
5068
+ sourceEvents.push(event);
4994
5069
  }
4995
- bar.tick(`${fi + 1}/${filePaths.length} files, ${sourceEvents.length} events`);
4996
5070
  }
4997
- bar.finalize(`${sourceEvents.length} events`);
5071
+ bar?.tick(`${fi + 1}/${filePaths.length} files, ${sourceEvents.length} events`);
4998
5072
  }
5073
+ bar?.finalize(`${sourceEvents.length} events`);
4999
5074
  for (const event of sourceEvents) canonicalEvents.push(event);
5000
5075
  }
5076
+ return { canonicalEvents, selectedFilesBySource };
5077
+ }
5078
+ async function uploadSessionRollups(rollups, eventCount, options, ctx) {
5001
5079
  const counts = { inserted: 0, skipped: 0, conflicts: 0, failed: 0 };
5002
- const rollups = buildSessionRollups(canonicalEvents);
5003
5080
  const batchSize = Math.max(1, Math.floor(numberOption(options["batch-size"]) || DEFAULT_BACKFILL_BATCH_SIZE));
5004
- const totalBatches = Math.ceil(rollups.length / batchSize);
5081
+ const batchBytes = Math.max(64 * 1024, Math.floor(numberOption(options["batch-bytes"]) || DEFAULT_BACKFILL_BATCH_BYTES));
5082
+ const batches = packRollupBatches(rollups, batchSize, batchBytes);
5083
+ const totalBatches = batches.length;
5005
5084
  let uploadBar;
5006
5085
  if (!options.json) {
5007
- write(ctx.stdout, `rollup ${rollups.length} from ${canonicalEvents.length} events
5086
+ write(ctx.stdout, `rollup ${rollups.length} from ${eventCount} events
5008
5087
  `);
5009
5088
  uploadBar = new ProgressBar(ctx.stdout, `upload`.padEnd(12));
5010
5089
  uploadBar.init(totalBatches, `0/${totalBatches} batches, 0 inserted`);
5011
5090
  }
5012
- for (let index = 0; index < rollups.length; index += batchSize) {
5013
- const batch = rollups.slice(index, index + batchSize);
5014
- const batchNumber = Math.floor(index / batchSize) + 1;
5091
+ for (const [i, batch_] of batches.entries()) {
5092
+ const batch = batch_;
5093
+ const batchNumber = i + 1;
5015
5094
  try {
5016
- const result2 = await sendSessionRollupBatch(batch, options, ctx);
5017
- counts.inserted += result2.inserted;
5018
- counts.skipped += result2.skipped;
5019
- counts.conflicts += result2.conflicts;
5020
- counts.failed += result2.failed;
5095
+ const result = await sendSessionRollupBatch(batch, options, ctx);
5096
+ counts.inserted += result.inserted;
5097
+ counts.skipped += result.skipped;
5098
+ counts.conflicts += result.conflicts;
5099
+ counts.failed += result.failed;
5021
5100
  } catch (error) {
5022
- debug(ctx, `backfill rollup batch ${index + 1}-${index + batch.length} failed: ${error.message}
5101
+ debug(ctx, `backfill rollup batch ${batchNumber}/${totalBatches} (${batch.length} rollups) failed: ${error.message}
5023
5102
  `);
5024
5103
  counts.failed += batch.length;
5025
5104
  }
5026
- if (uploadBar) {
5027
- uploadBar.update(batchNumber, `${batchNumber}/${totalBatches} batches, inserted ${counts.inserted}`);
5028
- }
5029
- }
5030
- if (uploadBar) {
5031
- uploadBar.finalize(`inserted ${counts.inserted} \xB7 skipped ${counts.skipped}${counts.conflicts ? ` \xB7 conflicts ${counts.conflicts}` : ""}${counts.failed ? ` \xB7 failed ${counts.failed}` : ""}`);
5032
- }
5033
- const result = {
5034
- importRunId: plan.importRun.importRunId,
5035
- source,
5036
- planned: rollups.length,
5037
- sourceEvents: canonicalEvents.length,
5038
- ...counts
5039
- };
5040
- if (options.json) {
5041
- write(ctx.stdout, `${JSON.stringify(result, null, 2)}
5042
- `);
5043
- }
5044
- if (counts.failed === 0 && counts.conflicts === 0 && incrementalState) {
5045
- await updateBackfillIncrementalState(home, incrementalState, selectedFilesBySource);
5105
+ uploadBar?.update(batchNumber, `${batchNumber}/${totalBatches} batches, inserted ${counts.inserted}`);
5046
5106
  }
5047
- return counts.failed > 0 || counts.conflicts > 0 && !options["skip-conflicts"] ? 1 : 0;
5107
+ uploadBar?.finalize(`inserted ${counts.inserted} \xB7 skipped ${counts.skipped}${counts.conflicts ? ` \xB7 conflicts ${counts.conflicts}` : ""}${counts.failed ? ` \xB7 failed ${counts.failed}` : ""}`);
5108
+ return counts;
5048
5109
  }
5049
5110
  function backfillVerifyCommand(options, ctx) {
5050
5111
  const importRunId = stringOption(options["import-run"]);
@@ -5068,12 +5129,7 @@ ${result.message}
5068
5129
  return 0;
5069
5130
  }
5070
5131
  async function sendSessionRollupBatch(rollups, options, ctx) {
5071
- const remote = resolveRemote({
5072
- apiUrl: stringOption(options["api-url"]),
5073
- token: stringOption(options.token),
5074
- env: ctx.env,
5075
- fetch: ctx.fetch
5076
- });
5132
+ const remote = resolveRemoteFromOptions(options, ctx);
5077
5133
  if (!remote) {
5078
5134
  throw new Error("No fetch available for HTTP upload");
5079
5135
  }
@@ -5092,12 +5148,7 @@ async function sendSessionRollupBatch(rollups, options, ctx) {
5092
5148
  return result;
5093
5149
  }
5094
5150
  async function deleteSessionRollupsBySourceAPI(source, options, ctx) {
5095
- const remote = resolveRemote({
5096
- apiUrl: stringOption(options["api-url"]),
5097
- token: stringOption(options.token),
5098
- env: ctx.env,
5099
- fetch: ctx.fetch
5100
- });
5151
+ const remote = resolveRemoteFromOptions(options, ctx);
5101
5152
  if (!remote) {
5102
5153
  throw new Error("No fetch available for HTTP delete");
5103
5154
  }
@@ -5111,6 +5162,26 @@ async function deleteSessionRollupsBySourceAPI(source, options, ctx) {
5111
5162
  function shouldUseIncrementalBackfill(options) {
5112
5163
  return !stringOption(options.since) && !stringOption(options.until) && !stringOption(options["source-root"]) && numberOption(options.limit) === void 0;
5113
5164
  }
5165
+ function packRollupBatches(rollups, maxCount, maxBytes) {
5166
+ const batches = [];
5167
+ let current = [];
5168
+ let currentBytes = 0;
5169
+ for (const rollup of rollups) {
5170
+ const size = JSON.stringify(rollup).length;
5171
+ const wouldExceed = current.length > 0 && (current.length >= maxCount || currentBytes + size > maxBytes);
5172
+ if (wouldExceed) {
5173
+ batches.push(current);
5174
+ current = [];
5175
+ currentBytes = 0;
5176
+ }
5177
+ current.push(rollup);
5178
+ currentBytes += size;
5179
+ }
5180
+ if (current.length > 0) {
5181
+ batches.push(current);
5182
+ }
5183
+ return batches;
5184
+ }
5114
5185
  function selectBackfillFilesForImport(files, watermarkTs) {
5115
5186
  if (!watermarkTs) {
5116
5187
  return files;
@@ -5133,10 +5204,25 @@ function syncLocalTriggerStatePath(home) {
5133
5204
  function syncLocalTriggerLockPath(home) {
5134
5205
  return path11.join(home, ".codetime", "sync-local-trigger.lock");
5135
5206
  }
5136
- async function readBackfillIncrementalState(home) {
5137
- const state = await readJsonIfExists(backfillIncrementalStatePath(home));
5207
+ async function readBackfillIncrementalState(home, ctx) {
5208
+ const statePath = backfillIncrementalStatePath(home);
5209
+ const state = await readJsonIfExists(statePath);
5210
+ if (state === null) {
5211
+ return { version: BACKFILL_STATE_SCHEMA_VERSION, sources: {} };
5212
+ }
5138
5213
  if (!isPlainObject(state) || !isPlainObject(state.sources)) {
5139
- return { version: 1, sources: {} };
5214
+ if (ctx) {
5215
+ debug(ctx, `backfill-state malformed at ${statePath}; ignoring watermarks
5216
+ `);
5217
+ }
5218
+ return { version: BACKFILL_STATE_SCHEMA_VERSION, sources: {} };
5219
+ }
5220
+ if (state.version !== BACKFILL_STATE_SCHEMA_VERSION) {
5221
+ if (ctx) {
5222
+ debug(ctx, `backfill-state version ${String(state.version)} at ${statePath} differs from current v${BACKFILL_STATE_SCHEMA_VERSION}; dropping watermarks so the next sync re-imports under the new parser
5223
+ `);
5224
+ }
5225
+ return { version: BACKFILL_STATE_SCHEMA_VERSION, sources: {} };
5140
5226
  }
5141
5227
  const sources = {};
5142
5228
  for (const source of ["codex", "claude-code", "opencode", "pi"]) {
@@ -5145,7 +5231,7 @@ async function readBackfillIncrementalState(home) {
5145
5231
  sources[source] = { watermarkTs: item.watermarkTs };
5146
5232
  }
5147
5233
  }
5148
- return { version: 1, sources };
5234
+ return { version: BACKFILL_STATE_SCHEMA_VERSION, sources };
5149
5235
  }
5150
5236
  async function updateBackfillIncrementalState(home, state, selectedFilesBySource) {
5151
5237
  for (const [source, files] of selectedFilesBySource.entries()) {
@@ -5317,6 +5403,15 @@ function debug(ctx, message) {
5317
5403
  write(ctx.stderr, message);
5318
5404
  }
5319
5405
  }
5406
+ function resolveRemoteFromOptions(options, ctx) {
5407
+ return resolveRemote({
5408
+ apiUrl: stringOption(options["api-url"]),
5409
+ token: stringOption(options.token),
5410
+ env: ctx.env,
5411
+ fetch: ctx.fetch,
5412
+ homeOverride: stringOption(options.home)
5413
+ });
5414
+ }
5320
5415
  function maskToken(token) {
5321
5416
  if (token.length <= 8) {
5322
5417
  return "***";
@@ -5377,12 +5472,7 @@ Usage: codetime token [set <token>|show|clear] [--remote <url>]
5377
5472
  return 1;
5378
5473
  }
5379
5474
  async function machineCommand(action, options, ctx) {
5380
- const remote = resolveRemote({
5381
- apiUrl: stringOption(options["api-url"]),
5382
- token: stringOption(options.token),
5383
- env: ctx.env,
5384
- fetch: ctx.fetch
5385
- });
5475
+ const remote = resolveRemoteFromOptions(options, ctx);
5386
5476
  if (!remote || !remote.token) {
5387
5477
  write(ctx.stderr, "machine command requires login (run `codetime login`).\n");
5388
5478
  return 1;
@@ -5463,14 +5553,11 @@ Global options:
5463
5553
  --token <token> Bearer token for this invocation (overrides config).
5464
5554
 
5465
5555
  Token precedence (highest first):
5466
- --token flag > CODETIME_AGENT_TOKEN env > AGENT_TIME_API_TOKEN env
5467
- (legacy alias) > saved config (~/.codetime/config.json).
5556
+ --token flag > CODETIME_TOKEN env > saved config (~/.codetime/config.json).
5468
5557
 
5469
5558
  Environment:
5470
5559
  CODETIME_API_URL Defaults to ${DEFAULT_API_URL}
5471
- CODETIME_AGENT_TOKEN Bearer token for the API
5472
- AGENT_TIME_API_URL Legacy alias for CODETIME_API_URL
5473
- AGENT_TIME_API_TOKEN Legacy alias for CODETIME_AGENT_TOKEN
5560
+ CODETIME_TOKEN Bearer token for the API
5474
5561
  `;
5475
5562
  }
5476
5563
  export {
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "codetime-cli",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.3.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": {
8
8
  "access": "public"
9
9
  },
10
- "homepage": "https://github.com/jannchie/codetime-cli#readme",
10
+ "homepage": "https://github.com/codetime-dev/codetime-cli#readme",
11
11
  "repository": {
12
12
  "type": "git",
13
- "url": "git+https://github.com/jannchie/codetime-cli.git"
13
+ "url": "git+https://github.com/codetime-dev/codetime-cli.git"
14
14
  },
15
15
  "bugs": {
16
- "url": "https://github.com/jannchie/codetime-cli/issues"
16
+ "url": "https://github.com/codetime-dev/codetime-cli/issues"
17
17
  },
18
18
  "keywords": [
19
19
  "codetime",
@@ -35,7 +35,7 @@
35
35
  "devDependencies": {
36
36
  "cac": "^7.0.0",
37
37
  "esbuild": "^0.28.0",
38
- "@codetime/shared": "0.1.0"
38
+ "@codetime/shared": "0.3.0"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.json",