hillclimb 0.1.2 → 0.1.3

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/dist/cli.js +242 -26
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs15 from "fs";
5
- import path18 from "path";
4
+ import fs16 from "fs";
5
+ import path19 from "path";
6
6
  import * as p6 from "@clack/prompts";
7
7
 
8
8
  // src/commands/init.ts
@@ -424,6 +424,27 @@ var TOOLS = [
424
424
  // Events are wired inside the plugin itself; see OPENCODE_PLUGIN_CONTENT.
425
425
  detect: () => isDir(path4.join(os2.homedir(), ".local", "share", "opencode")) || isDir(path4.join(os2.homedir(), ".config", "opencode"))
426
426
  },
427
+ {
428
+ // VS Code Copilot Chat. Same "no SessionEnd" constraint as Codex (the
429
+ // event is documented but never fires — VS Code issue microsoft/vscode#300650).
430
+ // Stale-state cleanup happens on next SessionStart, like Codex.
431
+ // Hook config lives at .github/hooks/hooks.json — distinct from
432
+ // .claude/settings.local.json so Claude Code and Copilot Chat don't
433
+ // fire each other's hooks even though VS Code's docs claim they share.
434
+ tool: "copilot-chat",
435
+ label: "GitHub Copilot Chat",
436
+ settingsFile: ".github/hooks/hooks.json",
437
+ format: "copilot",
438
+ events: [
439
+ { eventName: "SessionStart", command: GIT_TRACES_CMD("copilot-chat") },
440
+ { eventName: "Stop", command: GIT_TRACES_CMD("copilot-chat") },
441
+ {
442
+ eventName: "Stop",
443
+ command: `${HOOK_CMD("upload")} --tool=copilot-chat`
444
+ }
445
+ ],
446
+ detect: copilotChatDetect
447
+ },
427
448
  {
428
449
  // Codex has no SessionEnd hook (only SessionStart, PreToolUse, PostToolUse,
429
450
  // UserPromptSubmit, Stop). We can't clean up refs/state at session end;
@@ -451,6 +472,20 @@ function isDir(p7) {
451
472
  return false;
452
473
  }
453
474
  }
475
+ function copilotChatDetect() {
476
+ const home = os2.homedir();
477
+ const suffix = path4.join(
478
+ "User",
479
+ "globalStorage",
480
+ "github.copilot-chat"
481
+ );
482
+ const candidates = [
483
+ path4.join(home, "Library", "Application Support", "Code", suffix),
484
+ path4.join(home, ".config", "Code", suffix),
485
+ process.env.APPDATA ? path4.join(process.env.APPDATA, "Code", suffix) : null
486
+ ].filter((p7) => p7 !== null);
487
+ return candidates.some(isDir);
488
+ }
454
489
  function settingsPath(repoRoot, def) {
455
490
  return path4.join(repoRoot, def.settingsFile);
456
491
  }
@@ -545,6 +580,36 @@ function cursorUninstall(settings, eventName, command) {
545
580
  }
546
581
  return true;
547
582
  }
583
+ function copilotHookPresent(entries, command) {
584
+ return entries.some((e) => e.type === "command" && e.command === command);
585
+ }
586
+ function copilotInstall(settings, eventName, command) {
587
+ if (!settings.hooks) settings.hooks = {};
588
+ if (!settings.hooks[eventName]) settings.hooks[eventName] = [];
589
+ const entries = settings.hooks[eventName];
590
+ if (copilotHookPresent(entries, command)) return false;
591
+ entries.push({ type: "command", command });
592
+ return true;
593
+ }
594
+ function copilotCheck(settings, eventName, command) {
595
+ const entries = settings.hooks?.[eventName] ?? [];
596
+ return copilotHookPresent(entries, command);
597
+ }
598
+ function copilotUninstall(settings, eventName, command) {
599
+ const entries = settings.hooks?.[eventName];
600
+ if (!entries || entries.length === 0) return false;
601
+ const next = entries.filter(
602
+ (e) => !(e.type === "command" && e.command === command)
603
+ );
604
+ if (next.length === entries.length) return false;
605
+ if (next.length > 0) {
606
+ settings.hooks[eventName] = next;
607
+ } else {
608
+ delete settings.hooks[eventName];
609
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
610
+ }
611
+ return true;
612
+ }
548
613
  var OPENCODE_PLUGIN_VERSION = 2;
549
614
  var OPENCODE_PLUGIN_MARKER = `// HILLCLIMB_OPENCODE_PLUGIN_VERSION=${OPENCODE_PLUGIN_VERSION}`;
550
615
  var OPENCODE_PLUGIN_CONTENT = `${OPENCODE_PLUGIN_MARKER}
@@ -714,13 +779,34 @@ async function opencodeCheck(file) {
714
779
  }
715
780
  }
716
781
  function install(settings, format, eventName, command) {
717
- return format === "cursor" ? cursorInstall(settings, eventName, command) : claudeInstall(settings, eventName, command);
782
+ switch (format) {
783
+ case "cursor":
784
+ return cursorInstall(settings, eventName, command);
785
+ case "copilot":
786
+ return copilotInstall(settings, eventName, command);
787
+ default:
788
+ return claudeInstall(settings, eventName, command);
789
+ }
718
790
  }
719
791
  function check(settings, format, eventName, command) {
720
- return format === "cursor" ? cursorCheck(settings, eventName, command) : claudeCheck(settings, eventName, command);
792
+ switch (format) {
793
+ case "cursor":
794
+ return cursorCheck(settings, eventName, command);
795
+ case "copilot":
796
+ return copilotCheck(settings, eventName, command);
797
+ default:
798
+ return claudeCheck(settings, eventName, command);
799
+ }
721
800
  }
722
801
  function uninstall(settings, format, eventName, command) {
723
- return format === "cursor" ? cursorUninstall(settings, eventName, command) : claudeUninstall(settings, eventName, command);
802
+ switch (format) {
803
+ case "cursor":
804
+ return cursorUninstall(settings, eventName, command);
805
+ case "copilot":
806
+ return copilotUninstall(settings, eventName, command);
807
+ default:
808
+ return claudeUninstall(settings, eventName, command);
809
+ }
724
810
  }
725
811
  function legacyCommandsFor(command) {
726
812
  const prefix = "npx hillclimb ";
@@ -1277,7 +1363,7 @@ async function runInit(args = []) {
1277
1363
  const hookResults = await installDetectedHooks(repoRoot);
1278
1364
  if (hookResults.length === 0) {
1279
1365
  p3.log.warn(
1280
- "No supported tools detected (.claude/, .cursor/, .codex/). Hook not installed."
1366
+ "No supported tools detected (Claude Code, Cursor, Codex, opencode, GitHub Copilot Chat). Hook not installed."
1281
1367
  );
1282
1368
  } else {
1283
1369
  for (const r of hookResults) {
@@ -11464,6 +11550,9 @@ function formatTitleTimestamp(date) {
11464
11550
  const pad = (n) => String(n).padStart(2, "0");
11465
11551
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
11466
11552
  }
11553
+ function lineHasAssistant(line) {
11554
+ return line.includes('"type":"assistant"') || line.includes('"role":"assistant"') || line.includes('"type":"assistant.');
11555
+ }
11467
11556
  async function hasAssistantMessage(transcriptPath) {
11468
11557
  const stream = fs8.createReadStream(transcriptPath, { encoding: "utf-8" });
11469
11558
  let buffer = "";
@@ -11474,15 +11563,14 @@ async function hasAssistantMessage(transcriptPath) {
11474
11563
  while (newlineIdx !== -1) {
11475
11564
  const line = buffer.slice(0, newlineIdx);
11476
11565
  buffer = buffer.slice(newlineIdx + 1);
11477
- if (line.includes('"type":"assistant"') || line.includes('"role":"assistant"')) {
11566
+ if (lineHasAssistant(line)) {
11478
11567
  stream.destroy();
11479
11568
  return true;
11480
11569
  }
11481
11570
  newlineIdx = buffer.indexOf("\n");
11482
11571
  }
11483
11572
  }
11484
- if (buffer.includes('"type":"assistant"') || buffer.includes('"role":"assistant"'))
11485
- return true;
11573
+ if (lineHasAssistant(buffer)) return true;
11486
11574
  } catch {
11487
11575
  return true;
11488
11576
  }
@@ -11600,7 +11688,13 @@ async function uploadSession(args) {
11600
11688
  }
11601
11689
  const client = new PlatformClient(config.apiBaseUrl, identity.sessionCookie);
11602
11690
  const shortId = sessionId.slice(0, 12);
11603
- const toolLabels = { cursor: "Cursor", codex: "Codex", claude: "Claude", opencode: "opencode" };
11691
+ const toolLabels = {
11692
+ cursor: "Cursor",
11693
+ codex: "Codex",
11694
+ claude: "Claude",
11695
+ "copilot-chat": "GitHub Copilot Chat",
11696
+ opencode: "opencode"
11697
+ };
11604
11698
  const toolLabel = toolLabels[sourceTool] ?? "Claude";
11605
11699
  const title = `${toolLabel} session ${shortId} \u2014 ${formatTitleTimestamp(now)}`;
11606
11700
  const body = `Session ID: ${sessionId}
@@ -11641,6 +11735,15 @@ Uploaded: ${now.toISOString()}`;
11641
11735
  }
11642
11736
  }
11643
11737
  var WORKER_ENV_FLAG = "HILLCLIMB_UPLOAD_WORKER";
11738
+ var TOOL_ENV_FLAG = "HILLCLIMB_UPLOAD_TOOL";
11739
+ function parseToolArg(argv) {
11740
+ for (let i = 0; i < argv.length; i++) {
11741
+ const a = argv[i];
11742
+ if (a.startsWith("--tool=")) return a.slice("--tool=".length);
11743
+ if (a === "--tool" && i + 1 < argv.length) return argv[i + 1];
11744
+ }
11745
+ return null;
11746
+ }
11644
11747
  async function runUpload() {
11645
11748
  if (process.env[WORKER_ENV_FLAG] === "1") {
11646
11749
  await runUploadWorker();
@@ -11669,11 +11772,17 @@ async function runUpload() {
11669
11772
  appendLog("error", "Cannot detach worker: process.argv[1] is empty");
11670
11773
  return;
11671
11774
  }
11775
+ const toolArg = parseToolArg(process.argv.slice(2));
11776
+ const workerEnv = {
11777
+ ...process.env,
11778
+ [WORKER_ENV_FLAG]: "1"
11779
+ };
11780
+ if (toolArg) workerEnv[TOOL_ENV_FLAG] = toolArg;
11672
11781
  try {
11673
11782
  const child = spawn2(process.execPath, [entrypoint, "upload"], {
11674
11783
  detached: true,
11675
11784
  stdio: ["pipe", "ignore", "ignore"],
11676
- env: { ...process.env, [WORKER_ENV_FLAG]: "1" }
11785
+ env: workerEnv
11677
11786
  });
11678
11787
  child.on("error", (err) => {
11679
11788
  appendLog("error", `Failed to spawn worker: ${err.message}`);
@@ -11719,6 +11828,8 @@ async function runUploadWorker() {
11719
11828
  );
11720
11829
  return;
11721
11830
  }
11831
+ const toolOverride = process.env[TOOL_ENV_FLAG];
11832
+ if (toolOverride) payload.tool = toolOverride;
11722
11833
  try {
11723
11834
  await runUploadInner(payload);
11724
11835
  } catch (err) {
@@ -12058,9 +12169,9 @@ function parseCommitFiles(repoRoot, sha) {
12058
12169
  oldPath
12059
12170
  });
12060
12171
  } else {
12061
- const path19 = parts[parts.length - 1];
12062
- indexByPath.set(path19, files.length);
12063
- files.push({ path: path19, status, additions: 0, deletions: 0 });
12172
+ const path20 = parts[parts.length - 1];
12173
+ indexByPath.set(path20, files.length);
12174
+ files.push({ path: path20, status, additions: 0, deletions: 0 });
12064
12175
  }
12065
12176
  }
12066
12177
  for (const line of numstat.split("\n")) {
@@ -12188,6 +12299,7 @@ var TOOL_LABELS = {
12188
12299
  cursor: "Cursor",
12189
12300
  codex: "Codex",
12190
12301
  claude: "Claude",
12302
+ "copilot-chat": "GitHub Copilot Chat",
12191
12303
  opencode: "opencode"
12192
12304
  };
12193
12305
  function resolveCwd(payload) {
@@ -12542,9 +12654,15 @@ async function handleSessionEnd(payload, tool) {
12542
12654
 
12543
12655
  // src/git-traces/index.ts
12544
12656
  var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
12545
- var TOOL_ENV_FLAG = "HILLCLIMB_GIT_TRACES_TOOL";
12546
- var KNOWN_TOOLS = /* @__PURE__ */ new Set(["claude", "codex", "cursor", "opencode"]);
12547
- function parseToolArg(argv) {
12657
+ var TOOL_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_TOOL";
12658
+ var KNOWN_TOOLS = /* @__PURE__ */ new Set([
12659
+ "claude",
12660
+ "codex",
12661
+ "copilot-chat",
12662
+ "cursor",
12663
+ "opencode"
12664
+ ]);
12665
+ function parseToolArg2(argv) {
12548
12666
  for (let i = 0; i < argv.length; i++) {
12549
12667
  const a = argv[i];
12550
12668
  if (a.startsWith("--tool=")) return a.slice("--tool=".length);
@@ -12565,7 +12683,7 @@ async function runGitTraces() {
12565
12683
  await runGitTracesWorker();
12566
12684
  return;
12567
12685
  }
12568
- const toolArg = parseToolArg(process.argv.slice(2));
12686
+ const toolArg = parseToolArg2(process.argv.slice(2));
12569
12687
  appendLog(
12570
12688
  "info",
12571
12689
  `git-traces hook invoked (pid ${process.pid}, tool=${toolArg ?? "<none>"})`
@@ -12606,7 +12724,7 @@ async function runGitTraces() {
12606
12724
  env: {
12607
12725
  ...process.env,
12608
12726
  [WORKER_ENV_FLAG2]: "1",
12609
- [TOOL_ENV_FLAG]: toolArg
12727
+ [TOOL_ENV_FLAG2]: toolArg
12610
12728
  }
12611
12729
  }
12612
12730
  );
@@ -12628,7 +12746,7 @@ async function runGitTraces() {
12628
12746
  }
12629
12747
  }
12630
12748
  async function runGitTracesWorker() {
12631
- const tool = process.env[TOOL_ENV_FLAG] ?? parseToolArg(process.argv.slice(2)) ?? null;
12749
+ const tool = process.env[TOOL_ENV_FLAG2] ?? parseToolArg2(process.argv.slice(2)) ?? null;
12632
12750
  appendLog(
12633
12751
  "info",
12634
12752
  `git-traces worker started (pid ${process.pid}, tool=${tool ?? "<none>"})`
@@ -13202,8 +13320,106 @@ var CodexSource = class {
13202
13320
  }
13203
13321
  };
13204
13322
 
13323
+ // src/sources/copilotChat.ts
13324
+ import fs15 from "fs";
13325
+ import os11 from "os";
13326
+ import path18 from "path";
13327
+ import { fileURLToPath } from "url";
13328
+ function vsCodeUserDirs() {
13329
+ const home = os11.homedir();
13330
+ const dirs = [
13331
+ path18.join(home, "Library", "Application Support", "Code", "User"),
13332
+ path18.join(home, ".config", "Code", "User")
13333
+ ];
13334
+ if (process.env.APPDATA) {
13335
+ dirs.push(path18.join(process.env.APPDATA, "Code", "User"));
13336
+ }
13337
+ return dirs;
13338
+ }
13339
+ function uriToFsPath(uri) {
13340
+ if (!uri.startsWith("file://")) return null;
13341
+ try {
13342
+ return fileURLToPath(uri);
13343
+ } catch {
13344
+ return null;
13345
+ }
13346
+ }
13347
+ async function readWorkspaceFolder(workspaceJsonPath) {
13348
+ let raw;
13349
+ try {
13350
+ raw = await fs15.promises.readFile(workspaceJsonPath, "utf-8");
13351
+ } catch {
13352
+ return null;
13353
+ }
13354
+ let data;
13355
+ try {
13356
+ data = JSON.parse(raw);
13357
+ } catch {
13358
+ return null;
13359
+ }
13360
+ if (!data || typeof data !== "object") return null;
13361
+ const obj = data;
13362
+ if (typeof obj.folder === "string") {
13363
+ return uriToFsPath(obj.folder) ?? obj.folder;
13364
+ }
13365
+ return null;
13366
+ }
13367
+ var CopilotChatSource = class {
13368
+ name = "copilot-chat";
13369
+ async scan() {
13370
+ const results = [];
13371
+ for (const userDir of vsCodeUserDirs()) {
13372
+ const workspaceStorage = path18.join(userDir, "workspaceStorage");
13373
+ let hashDirs;
13374
+ try {
13375
+ hashDirs = await fs15.promises.readdir(workspaceStorage, {
13376
+ withFileTypes: true
13377
+ });
13378
+ } catch {
13379
+ continue;
13380
+ }
13381
+ for (const hash of hashDirs) {
13382
+ if (!hash.isDirectory()) continue;
13383
+ const wsRoot = path18.join(workspaceStorage, hash.name);
13384
+ const transcriptsDir = path18.join(
13385
+ wsRoot,
13386
+ "GitHub.copilot-chat",
13387
+ "transcripts"
13388
+ );
13389
+ let transcriptEntries;
13390
+ try {
13391
+ transcriptEntries = await fs15.promises.readdir(transcriptsDir, {
13392
+ withFileTypes: true
13393
+ });
13394
+ } catch {
13395
+ continue;
13396
+ }
13397
+ const repoPath = await readWorkspaceFolder(
13398
+ path18.join(wsRoot, "workspace.json")
13399
+ );
13400
+ if (!repoPath) continue;
13401
+ for (const entry of transcriptEntries) {
13402
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
13403
+ const sessionId = entry.name.slice(0, -".jsonl".length);
13404
+ results.push({
13405
+ sourceName: this.name,
13406
+ absolutePath: path18.join(transcriptsDir, entry.name),
13407
+ repoPath,
13408
+ metadata: { sessionId }
13409
+ });
13410
+ }
13411
+ }
13412
+ }
13413
+ return results;
13414
+ }
13415
+ };
13416
+
13205
13417
  // src/sources/index.ts
13206
- var sources = [new ClaudeSource(), new CodexSource()];
13418
+ var sources = [
13419
+ new ClaudeSource(),
13420
+ new CodexSource(),
13421
+ new CopilotChatSource()
13422
+ ];
13207
13423
 
13208
13424
  // src/cli.ts
13209
13425
  function reportRedactionStats(noun, stats) {
@@ -13227,7 +13443,7 @@ function reportRedactionStats(noun, stats) {
13227
13443
  async function filterByTimeRange(group, range) {
13228
13444
  const results = await Promise.all(
13229
13445
  group.files.map(
13230
- (f) => fs15.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
13446
+ (f) => fs16.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
13231
13447
  )
13232
13448
  );
13233
13449
  const filtered = [];
@@ -13254,10 +13470,10 @@ async function runInteractive() {
13254
13470
  s.start(`Scanning ${source.name} logs...`);
13255
13471
  const allFiles = await source.scan();
13256
13472
  const allGroups = await mergeByRepo(allFiles);
13257
- const repoRoot = path18.resolve(repo.root);
13473
+ const repoRoot = path19.resolve(repo.root);
13258
13474
  const matching = allGroups.filter((g) => {
13259
- const resolved = path18.resolve(g.repoPath);
13260
- return resolved === repoRoot || resolved.startsWith(repoRoot + path18.sep);
13475
+ const resolved = path19.resolve(g.repoPath);
13476
+ return resolved === repoRoot || resolved.startsWith(repoRoot + path19.sep);
13261
13477
  });
13262
13478
  if (matching.length === 0) {
13263
13479
  s.stop(`No ${source.name} logs found for ${repo.name}.`);
@@ -13288,7 +13504,7 @@ async function runInteractive() {
13288
13504
  }
13289
13505
  }
13290
13506
  const envFileNames = await discoverEnvFiles(repoRoot);
13291
- const envFilePaths = envFileNames.map((n) => path18.join(repoRoot, n));
13507
+ const envFilePaths = envFileNames.map((n) => path19.join(repoRoot, n));
13292
13508
  const additionalFiles = await promptSecretFiles(envFileNames);
13293
13509
  const secretResult = await collectSecrets(
13294
13510
  repoRoot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",