Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,31 @@ jobs:
Compress-Archive -Path ./publish-hookbridge-self-contained/* -DestinationPath ./HookBridge-${{ github.ref_name }}-self-contained-win-x64.zip
Compress-Archive -Path ./publish-hookbridge-framework-dependent/* -DestinationPath ./HookBridge-${{ github.ref_name }}-framework-dependent-win-x64.zip

- name: Create GitHub Release with auto-generated notes
run: gh release create ${{ github.ref_name }} --title "Claude Tracker ${{ github.ref_name }}" --generate-notes
- name: Generate release notes from merged PR
id: release_notes
shell: bash
run: |
# Find the PR that was merged for this tag's commit
COMMIT_SHA=$(git rev-list -1 ${{ github.ref_name }})
PR_BODY=$(gh api repos/TobiiNT/ClaudeTracker/commits/$COMMIT_SHA/pulls --jq '.[0].body // empty' 2>/dev/null || echo "")
if [ -z "$PR_BODY" ]; then
# Fallback: auto-generate from commits
echo "notes<<NOTES_EOF" >> $GITHUB_OUTPUT
echo "## What's Changed" >> $GITHUB_OUTPUT
gh api repos/TobiiNT/ClaudeTracker/compare/$(git describe --tags --abbrev=0 ${{ github.ref_name }}^)...${{ github.ref_name }} --jq '.commits[].commit.message' | head -20 | while read msg; do echo "- $msg"; done >> $GITHUB_OUTPUT
echo "NOTES_EOF" >> $GITHUB_OUTPUT
else
# Strip test plan and everything after it
CLEAN_BODY=$(echo "$PR_BODY" | sed '/^## Test plan/,$d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
echo "notes<<NOTES_EOF" >> $GITHUB_OUTPUT
echo "$CLEAN_BODY" >> $GITHUB_OUTPUT
echo "NOTES_EOF" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Create GitHub Release
run: gh release create ${{ github.ref_name }} --title "Claude Tracker ${{ github.ref_name }}" --notes "${{ steps.release_notes.outputs.notes }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
88 changes: 81 additions & 7 deletions src/ClaudeTracker.HookBridge/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,28 @@ private struct PROCESS_BASIC_INFORMATION
private static string ClaudeSettingsPath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "settings.json");

private static readonly string[] AllEvents =
// Pre-v2.1.37: core hook events (existed from early hooks support)
private static readonly string[] CoreEvents =
{
"PreToolUse", "PostToolUse", "PostToolUseFailure",
"PermissionRequest", "Notification", "Stop",
"SessionStart", "SessionEnd", "UserPromptSubmit",
"SubagentStart", "SubagentStop",
"PreCompact", "PostCompact",
"WorktreeCreate", "WorktreeRemove",
"InstructionsLoaded", "ConfigChange",
"Elicitation", "ElicitationResult",
"TeammateIdle", "TaskCompleted"
};

// Version-gated events with the version they were introduced
private static readonly (string Event, int Major, int Minor, int Patch)[] VersionedEvents =
{
("ConfigChange", 2, 1, 49),
("WorktreeCreate", 2, 1, 50),
("WorktreeRemove", 2, 1, 50),
("InstructionsLoaded",2, 1, 69),
("PostCompact", 2, 1, 76),
("Elicitation", 2, 1, 76),
("ElicitationResult", 2, 1, 76),
};

private static readonly HashSet<string> AsyncEvents = new()
{
"PostToolUse", "PostToolUseFailure",
Expand Down Expand Up @@ -190,6 +199,69 @@ private static async Task<int> HandleHookEvent()
return 0;
}

private static string? DetectClaudeCodeVersion()
{
try
{
var process = Process.Start(new ProcessStartInfo
{
FileName = "claude",
Arguments = "--version",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
if (process == null) return null;
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(3000);
return output;
}
catch { return null; }
}

private static bool VersionAtLeast(int major, int minor, int patch, int reqMajor, int reqMinor, int reqPatch)
{
if (major != reqMajor) return major > reqMajor;
if (minor != reqMinor) return minor > reqMinor;
return patch >= reqPatch;
}

private static string[] GetSupportedEvents()
{
var version = DetectClaudeCodeVersion();
var events = new List<string>(CoreEvents);

if (version == null)
{
Console.WriteLine(" Could not detect Claude Code version — installing core events only");
return events.ToArray();
}

Console.WriteLine($" Claude Code version: {version}");

try
{
var parts = version.Split('.');
if (parts.Length >= 3 && int.TryParse(parts[0], out var major)
&& int.TryParse(parts[1], out var minor)
&& int.TryParse(parts[2], out var patch))
{
foreach (var (evt, reqMajor, reqMinor, reqPatch) in VersionedEvents)
{
if (VersionAtLeast(major, minor, patch, reqMajor, reqMinor, reqPatch))
events.Add(evt);
else
Console.WriteLine($" Skipping {evt} (requires v{reqMajor}.{reqMinor}.{reqPatch})");
}
return events.ToArray();
}
}
catch { /* fall through */ }

Console.WriteLine(" Could not parse version — installing core events only");
return events.ToArray();
}

// --- Install ---
private static int Install()
{
Expand Down Expand Up @@ -228,8 +300,10 @@ private static int Install()
settings["hooks"] = hooksObj;
}

var eventsToInstall = GetSupportedEvents();

// Register each event — preserve existing hooks from other tools
foreach (var eventName in AllEvents)
foreach (var eventName in eventsToInstall)
{
var hookConfig = new JsonObject
{
Expand Down Expand Up @@ -294,7 +368,7 @@ private static int Install()
Console.WriteLine($"ClaudeTracker hooks installed successfully.");
Console.WriteLine($" Settings: {settingsPath}");
Console.WriteLine($" Bridge: {exePath}");
Console.WriteLine($" Events: {AllEvents.Length} registered");
Console.WriteLine($" Events: {eventsToInstall.Length} registered");
return 0;
}
catch (Exception ex)
Expand Down
80 changes: 79 additions & 1 deletion src/ClaudeTracker/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,12 @@ Events.Notification when entry.Summary.Contains("idle", StringComparison.Ordinal
var body = !string.IsNullOrEmpty(entry.Detail)
? $"{entry.Summary}\n{entry.Detail}" : entry.Summary;

// Find cwd from session so clicking notification focuses the right terminal
var sessionCwd = sessionTracking.ActiveSessions
.FirstOrDefault(s => s.SessionId == entry.SessionId)?.Cwd;

((NotificationService)notificationServiceForHooks).SendNotification(
title, body, level);
title, body, level, cwd: sessionCwd);
}
}
};
Expand All @@ -263,6 +267,9 @@ Events.Notification when entry.Summary.Contains("idle", StringComparison.Ordinal
});
});

// Check Claude Code version for update notification
_ = Task.Run(() => CheckClaudeCodeVersion(settingsService));

// Listen for system theme changes
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;

Expand Down Expand Up @@ -402,6 +409,77 @@ public static void ApplyTheme(string theme)
paletteHelper.SetTheme(mdTheme);
}

private void CheckClaudeCodeVersion(ISettingsService settingsService)
{
try
{
var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "claude",
Arguments = "--version",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
if (process == null) return;
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(3000);

if (string.IsNullOrEmpty(output)) return;

LoggingService.Instance.Log($"Claude Code version detected: {output}");

// Check if there's a newer version available via WebFetch to GitHub releases API
var latestVersion = GetLatestClaudeCodeVersion();
if (latestVersion != null && latestVersion != output && IsNewerVersion(latestVersion, output))
{
Dispatcher.Invoke(() =>
{
var notificationService = _services!.GetRequiredService<INotificationService>();
((NotificationService)notificationService).SendNotification(
"Claude Code Update Available",
$"Version {latestVersion} is available (current: {output}). Run 'npm update -g @anthropic-ai/claude-code' to update.",
Views.NotificationPopup.NotificationLevel.Info);
});
}
}
catch (Exception ex)
{
LoggingService.Instance.LogWarning($"Claude Code version check failed: {ex.Message}");
}
}

private static string? GetLatestClaudeCodeVersion()
{
try
{
using var client = new System.Net.Http.HttpClient();
client.DefaultRequestHeaders.UserAgent.ParseAdd("ClaudeTracker");
var response = client.GetAsync("https://registry.npmjs.org/@anthropic-ai/claude-code/latest").Result;
if (!response.IsSuccessStatusCode) return null;
var json = response.Content.ReadAsStringAsync().Result;
using var doc = System.Text.Json.JsonDocument.Parse(json);
return doc.RootElement.GetProperty("version").GetString();
}
catch { return null; }
}

private static bool IsNewerVersion(string latest, string current)
{
try
{
var l = latest.Split('.').Select(int.Parse).ToArray();
var c = current.Split('.').Select(int.Parse).ToArray();
for (int i = 0; i < Math.Min(l.Length, c.Length); i++)
{
if (l[i] > c[i]) return true;
if (l[i] < c[i]) return false;
}
return false;
}
catch { return false; }
}

private void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e)
{
if (e.Category == UserPreferenceCategory.General)
Expand Down
6 changes: 3 additions & 3 deletions src/ClaudeTracker/ClaudeTracker.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
<ApplicationIcon>Assets\app_icon.ico</ApplicationIcon>
<AssemblyName>ClaudeTracker</AssemblyName>
<RootNamespace>ClaudeTracker</RootNamespace>
<Version>2.0.4</Version>
<AssemblyVersion>2.0.4.0</AssemblyVersion>
<FileVersion>2.0.4.0</FileVersion>
<Version>2.1.0</Version>
<AssemblyVersion>2.1.0.0</AssemblyVersion>
<FileVersion>2.1.0.0</FileVersion>
<Description>Claude AI Usage Tracker for Windows</Description>
<Authors>TobiiNT</Authors>
<Company>Tobii Development</Company>
Expand Down
3 changes: 3 additions & 0 deletions src/ClaudeTracker/Models/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ public class AppSettings
[JsonPropertyName("hookPopupPosition")]
public string HookPopupPosition { get; set; } = "BottomRight";

[JsonPropertyName("hookPopupMonitor")]
public int HookPopupMonitor { get; set; } = 0;

[JsonPropertyName("hooksEnabled")]
public bool HooksEnabled { get; set; }

Expand Down
4 changes: 4 additions & 0 deletions src/ClaudeTracker/Models/ClaudeUsage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public class ClaudeUsage
[JsonPropertyName("costCurrency")]
public string? CostCurrency { get; set; }

/// <summary>Error message if cost data fetch failed (e.g., no permission in org).</summary>
[JsonIgnore]
public string? CostFetchError { get; set; }

// Metadata
[JsonPropertyName("lastUpdated")]
public DateTime LastUpdated { get; set; }
Expand Down
11 changes: 10 additions & 1 deletion src/ClaudeTracker/Services/ClaudeApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,16 @@ public async Task<ClaudeUsage> FetchUsageData()
claudeUsage.CostCurrency = overage.Currency;
}
}
catch { /* overage is optional */ }
catch (HttpRequestException ex) when (ex.Message.Contains("Unauthorized"))
{
claudeUsage.CostFetchError = "No permission to access cost data";
LoggingService.Instance.LogWarning("Cost data fetch: no permission (organization restriction)");
}
catch (Exception ex)
{
claudeUsage.CostFetchError = ex.Message;
LoggingService.Instance.LogWarning($"Cost data fetch failed: {ex.Message}");
}
}

return claudeUsage;
Expand Down
14 changes: 5 additions & 9 deletions src/ClaudeTracker/Services/Handlers/StopHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ public Task<HookResponse> HandleAsync(HookEvent evt)
var prefs = _settingsService.Settings.HookNotificationPreferences;
if (prefs.TryGetValue("stop", out var enabled) && enabled)
{
var projectName = ParseProjectName(evt.Payload);
var cwd = ParseCwd(evt.Payload);
var projectName = string.IsNullOrEmpty(cwd) ? "" : Path.GetFileName(cwd) ?? cwd;
var title = "Task Complete";
var message = string.IsNullOrEmpty(projectName)
? "Claude Code has finished processing."
: $"Claude Code has finished processing in {projectName}.";

((NotificationService)_notificationService).SendNotification(
title, message, NotificationPopup.NotificationLevel.Info);
title, message, NotificationPopup.NotificationLevel.Info, cwd: cwd);

LoggingService.Instance.Log($"StopHandler: Sent completion notification for project '{projectName}'");
}
Expand All @@ -60,20 +61,15 @@ public Task<HookResponse> HandleAsync(HookEvent evt)
});
}

private static string ParseProjectName(string payload)
private static string ParseCwd(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return string.Empty;

try
{
var node = JsonNode.Parse(payload);
var cwd = node?[Fields.Cwd]?.GetValue<string>();

if (string.IsNullOrEmpty(cwd))
return string.Empty;

return Path.GetFileName(cwd) ?? cwd;
return node?[Fields.Cwd]?.GetValue<string>() ?? string.Empty;
}
catch
{
Expand Down
11 changes: 9 additions & 2 deletions src/ClaudeTracker/Services/NotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,21 @@ private void SendThresholdNotification(string profileKey, int threshold, double
}

public void SendNotification(string title, string message,
NotificationPopup.NotificationLevel level = NotificationPopup.NotificationLevel.Warning)
NotificationPopup.NotificationLevel level = NotificationPopup.NotificationLevel.Warning,
string? cwd = null)
{
try
{
Application.Current.Dispatcher.Invoke(() =>
{
var popup = new NotificationPopup(title, message, level);
popup.NotificationClicked += (_, _) => NotificationClicked?.Invoke(this, EventArgs.Empty);
popup.NotificationClicked += (_, _) =>
{
if (!string.IsNullOrEmpty(cwd))
Utilities.TerminalFocusHelper.BringToFront(cwd);
else
NotificationClicked?.Invoke(this, EventArgs.Empty);
};
popup.Show();
});

Expand Down
Loading
Loading