From 8b5d2f4283a43a3d74218234ac92c627474d1af3 Mon Sep 17 00:00:00 2001 From: ksmith Date: Wed, 21 Jan 2026 19:02:03 -0700 Subject: [PATCH] Initial commit: Claude Status Cinnamon applet Add Cinnamon panel applet that displays Claude Pro subscription usage and limits with configurable refresh interval and colors. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 132 +++++++++++++++++++ applet.js | 306 +++++++++++++++++++++++++++++++++++++++++++ install.sh | 22 ++++ metadata.json | 10 ++ settings-schema.json | 36 +++++ 5 files changed, 506 insertions(+) create mode 100644 README.md create mode 100644 applet.js create mode 100755 install.sh create mode 100644 metadata.json create mode 100644 settings-schema.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..7370a4e --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# Claude Status - Cinnamon Applet + +A Cinnamon panel applet that displays your Claude Pro subscription usage and limits. + +## Features + +- Displays current session (5-hour) usage percentage on the panel +- Color-coded indicator (green < 50%, yellow 50-80%, red > 80%) +- Popup menu showing: + - Session usage percentage and reset time + - Weekly (7-day) usage percentage and reset time + - Manual refresh button +- Automatically refreshes every 5 minutes +- Uses Claude Code OAuth credentials for authentication + +## Installation + +The applet has already been installed to: +``` +~/.local/share/cinnamon/applets/claude-status@ksmith/ +``` + +### Adding to Panel + +1. Right-click on your Cinnamon panel +2. Select "Applets" +3. Click "Download" or "Manage" tab +4. Find "Claude Status" in the list +5. Click the "+" button or drag it to your panel + +Alternatively, you can reload Cinnamon to make the applet available: +- Press `Alt+F2` +- Type `r` and press Enter +- This will restart Cinnamon and reload all applets + +## Requirements + +- Cinnamon desktop environment +- Claude Code credentials file at `~/.claude/.credentials.json` +- Active internet connection for API calls + +## Usage + +Once added to the panel: + +- The applet displays your current session usage percentage (e.g., "85%") +- Click the applet to open a popup menu with detailed information: + - Session usage and time until reset + - Weekly usage and time until reset + - Refresh button to manually update the data + +The applet will automatically refresh usage data every 5 minutes (configurable). + +## Configuration + +Right-click the applet and select "Configure..." to access settings: + +### Available Settings + +- **Refresh interval**: How often to check for usage updates (1-60 minutes, default: 5) +- **Color for low usage (< 50%)**: Color displayed when usage is below 50% (default: green) +- **Color for medium usage (50-80%)**: Color displayed when usage is between 50% and 80% (default: yellow) +- **Color for high usage (> 80%)**: Color displayed when usage is above 80% (default: red) +- **Show 'Claude:' prefix**: Display "Claude: " before the percentage on the panel (default: off) + +Settings are applied immediately without requiring a Cinnamon restart. + +## API + +The applet uses the Claude API endpoint: +- **Endpoint**: `https://api.anthropic.com/api/oauth/usage` +- **Authentication**: OAuth token from `~/.claude/.credentials.json` +- **Headers**: + - `Authorization: Bearer {accessToken}` + - `anthropic-beta: oauth-2025-04-20` + +## Files + +- `metadata.json` - Applet metadata and configuration +- `applet.js` - Main applet logic (JavaScript with GJS bindings) +- `settings-schema.json` - Configuration options schema +- `README.md` - This file + +## Troubleshooting + +### Applet shows "ERR" + +This indicates an error occurred. Click the applet to see the error message in the popup menu. Common issues: + +1. **"Could not read credentials file"**: The credentials file is missing or inaccessible + - Check that `~/.claude/.credentials.json` exists + - Verify file permissions + +2. **"Authentication failed"**: The OAuth token is invalid or expired + - Try logging in again with Claude Code + - Check that the credentials file contains a valid `accessToken` + +3. **"API error: [status code]"**: The API request failed + - Check your internet connection + - The API might be temporarily unavailable + +### Applet doesn't appear + +1. Make sure the files are in the correct location: + ```bash + ls -la ~/.local/share/cinnamon/applets/claude-status@ksmith/ + ``` + +2. Restart Cinnamon: + - Press `Alt+F2` + - Type `r` and press Enter + +3. Check Cinnamon logs for errors: + ```bash + journalctl -f /usr/bin/cinnamon + ``` + +## Development + +To update the applet: + +1. Make changes to the source files in `/home/ksmith/Projects/ClaudeStatus/` +2. Copy updated files to the applet directory: + ```bash + cp /home/ksmith/Projects/ClaudeStatus/*.js ~/.local/share/cinnamon/applets/claude-status@ksmith/ + cp /home/ksmith/Projects/ClaudeStatus/metadata.json ~/.local/share/cinnamon/applets/claude-status@ksmith/ + ``` +3. Restart Cinnamon or reload the applet + +## License + +This applet is provided as-is for personal use. diff --git a/applet.js b/applet.js new file mode 100644 index 0000000..8cf7565 --- /dev/null +++ b/applet.js @@ -0,0 +1,306 @@ +const Applet = imports.ui.applet; +const PopupMenu = imports.ui.popupMenu; +const Mainloop = imports.mainloop; +const GLib = imports.gi.GLib; +const Soup = imports.gi.Soup; +const Lang = imports.lang; +const Settings = imports.ui.settings; + +// Constants +const CREDENTIALS_PATH = GLib.get_home_dir() + '/.claude/.credentials.json'; +const API_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'; + +function ClaudeStatusApplet(metadata, orientation, panel_height, instance_id) { + this._init(metadata, orientation, panel_height, instance_id); +} + +ClaudeStatusApplet.prototype = { + __proto__: Applet.TextIconApplet.prototype, + + _init: function(metadata, orientation, panel_height, instance_id) { + Applet.TextIconApplet.prototype._init.call(this, orientation, panel_height, instance_id); + + this.metadata = metadata; + this.orientation = orientation; + + // Initialize settings + this.settings = new Settings.AppletSettings(this, metadata.uuid, instance_id); + this.settings.bind("refresh-interval", "refreshInterval", this._onSettingsChanged); + this.settings.bind("color-low", "colorLow", this._onSettingsChanged); + this.settings.bind("color-medium", "colorMedium", this._onSettingsChanged); + this.settings.bind("color-high", "colorHigh", this._onSettingsChanged); + this.settings.bind("show-label-prefix", "showLabelPrefix", this._onSettingsChanged); + + // Initialize HTTP session + if (Soup.Session.prototype.hasOwnProperty('new')) { + this.httpSession = new Soup.Session(); + } else { + this.httpSession = Soup.Session.new(); + } + + // Set up UI + this.set_applet_icon_symbolic_name("dialog-information"); + this.set_applet_tooltip("Claude Status - Click for details"); + + // Initialize menu + this.menuManager = new PopupMenu.PopupMenuManager(this); + this.menu = new Applet.AppletPopupMenu(this, orientation); + this.menuManager.addMenu(this.menu); + + // Menu items (will be populated later) + this.sessionItem = null; + this.weeklyItem = null; + this.errorItem = null; + + this._buildMenu(); + + // Initial data fetch + this.accessToken = null; + this.updateTimeoutId = null; + this._loadCredentials(); + + if (this.accessToken) { + this._fetchUsageData(); + } + + // Set up periodic updates + this._updateLoop(); + }, + + _buildMenu: function() { + // Session usage item + this.sessionItem = new PopupMenu.PopupMenuItem("Session: Loading...", { + reactive: false + }); + this.menu.addMenuItem(this.sessionItem); + + // Weekly usage item + this.weeklyItem = new PopupMenu.PopupMenuItem("Weekly: Loading...", { + reactive: false + }); + this.menu.addMenuItem(this.weeklyItem); + + // Error message item (hidden by default) + this.errorItem = new PopupMenu.PopupMenuItem("", { + reactive: false + }); + this.menu.addMenuItem(this.errorItem); + this.errorItem.actor.hide(); + + // Separator + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + // Refresh button + let refreshItem = new PopupMenu.PopupMenuItem("Refresh"); + refreshItem.connect('activate', Lang.bind(this, this._fetchUsageData)); + this.menu.addMenuItem(refreshItem); + }, + + _loadCredentials: function() { + try { + let [success, contents] = GLib.file_get_contents(CREDENTIALS_PATH); + + if (!success) { + this._showError("Could not read credentials file"); + return; + } + + // Convert ByteArray to string if needed + let credentialsText; + if (contents instanceof Uint8Array) { + credentialsText = new TextDecoder().decode(contents); + } else { + credentialsText = contents.toString(); + } + + let credentials = JSON.parse(credentialsText); + + if (credentials && credentials.claudeAiOauth && credentials.claudeAiOauth.accessToken) { + this.accessToken = credentials.claudeAiOauth.accessToken; + } else { + this._showError("No access token found in credentials"); + } + } catch (e) { + this._showError("Failed to load credentials: " + e.message); + } + }, + + _fetchUsageData: function() { + if (!this.accessToken) { + this._loadCredentials(); + if (!this.accessToken) { + return; + } + } + + try { + let message = Soup.Message.new('GET', API_ENDPOINT); + + // Set headers + let headers = message.get_request_headers(); + headers.append('Authorization', 'Bearer ' + this.accessToken); + headers.append('anthropic-beta', 'oauth-2025-04-20'); + headers.append('Content-Type', 'application/json'); + + // Send async request + this.httpSession.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + null, + Lang.bind(this, function(session, result) { + try { + let bytes = session.send_and_read_finish(result); + let decoder = new TextDecoder('utf-8'); + let responseData = decoder.decode(bytes.get_data()); + + let statusCode = message.get_status(); + + if (statusCode === 200) { + let data = JSON.parse(responseData); + this._updateDisplay(data); + this._hideError(); + } else if (statusCode === 401) { + this._showError("Authentication failed. Check credentials."); + } else { + this._showError("API error: " + statusCode); + } + } catch (e) { + this._showError("Failed to parse response: " + e.message); + } + }) + ); + } catch (e) { + this._showError("Request failed: " + e.message); + } + }, + + _updateDisplay: function(data) { + try { + // Extract five_hour (session) data + let sessionData = data ? data.five_hour : null; + let weeklyData = data ? data.seven_day : null; + + // Update panel text with session percentage + if (sessionData && sessionData.utilization !== undefined) { + let percentage = Math.round(sessionData.utilization); + let label = percentage + "%"; + if (this.showLabelPrefix) { + label = "Claude: " + label; + } + this.set_applet_label(label); + + // Update session menu item + let resetTime = this._formatResetTime(sessionData.resets_at); + this.sessionItem.label.set_text("Session: " + percentage + "% (resets " + resetTime + ")"); + + // Update color based on usage using settings + let color; + if (percentage >= 80) { + color = this.colorHigh; + } else if (percentage >= 50) { + color = this.colorMedium; + } else { + color = this.colorLow; + } + this.actor.style = 'color: ' + color + ';'; + } else { + this.set_applet_label("?%"); + this.sessionItem.label.set_text("Session: No data"); + } + + // Update weekly menu item + if (weeklyData && weeklyData.utilization !== undefined) { + let percentage = Math.round(weeklyData.utilization); + let resetTime = this._formatResetTime(weeklyData.resets_at); + this.weeklyItem.label.set_text("Weekly: " + percentage + "% (resets " + resetTime + ")"); + } else { + this.weeklyItem.label.set_text("Weekly: No data"); + } + + } catch (e) { + this._showError("Failed to update display: " + e.message); + } + }, + + _formatResetTime: function(resetAt) { + if (!resetAt) { + return "unknown"; + } + + try { + // Parse ISO 8601 timestamp + let resetDate = new Date(resetAt); + let now = new Date(); + let diffMs = resetDate - now; + + if (diffMs < 0) { + return "soon"; + } + + let diffSeconds = Math.floor(diffMs / 1000); + let diffMinutes = Math.floor(diffSeconds / 60); + let diffHours = Math.floor(diffMinutes / 60); + let diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) { + let hours = diffHours % 24; + return "in " + diffDays + "d " + hours + "h"; + } else if (diffHours > 0) { + let minutes = diffMinutes % 60; + return "in " + diffHours + "h " + minutes + "m"; + } else if (diffMinutes > 0) { + return "in " + diffMinutes + "m"; + } else { + return "in <1m"; + } + } catch (e) { + return "unknown"; + } + }, + + _showError: function(message) { + this.set_applet_label("ERR"); + this.actor.style = 'color: #ff6b6b;'; + this.errorItem.label.set_text("Error: " + message); + this.errorItem.actor.show(); + }, + + _hideError: function() { + this.errorItem.actor.hide(); + }, + + _updateLoop: function() { + // Cancel existing timeout if any + if (this.updateTimeoutId) { + Mainloop.source_remove(this.updateTimeoutId); + this.updateTimeoutId = null; + } + + this._fetchUsageData(); + + // Convert minutes to seconds + let intervalSeconds = this.refreshInterval * 60; + this.updateTimeoutId = Mainloop.timeout_add_seconds(intervalSeconds, Lang.bind(this, this._updateLoop)); + }, + + _onSettingsChanged: function() { + // Restart update loop with new interval + this._updateLoop(); + }, + + on_applet_clicked: function(event) { + this.menu.toggle(); + }, + + on_applet_removed_from_panel: function() { + // Cleanup timeout + if (this.updateTimeoutId) { + Mainloop.source_remove(this.updateTimeoutId); + this.updateTimeoutId = null; + } + } +}; + +function main(metadata, orientation, panel_height, instance_id) { + return new ClaudeStatusApplet(metadata, orientation, panel_height, instance_id); +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f4ba751 --- /dev/null +++ b/install.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Install/update the Claude Status applet + +APPLET_DIR="$HOME/.local/share/cinnamon/applets/claude-status@ksmith" + +# Create directory if it doesn't exist +mkdir -p "$APPLET_DIR" + +# Copy files +cp metadata.json "$APPLET_DIR/" +cp applet.js "$APPLET_DIR/" +cp settings-schema.json "$APPLET_DIR/" + +echo "Applet files copied to $APPLET_DIR" +echo "" +echo "To activate changes:" +echo "1. Press Alt+F2" +echo "2. Type: r" +echo "3. Press Enter" +echo "" +echo "Or restart your session." diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..ae33cb0 --- /dev/null +++ b/metadata.json @@ -0,0 +1,10 @@ +{ + "uuid": "claude-status@ksmith", + "name": "Claude Status", + "description": "Display Claude Pro subscription usage and limits on the panel", + "icon": "dialog-information", + "max-instances": 1, + "version": "1.0.0", + "author": "ksmith", + "cinnamon-version": ["3.0", "3.2", "3.4", "3.6", "3.8", "4.0", "4.2", "4.4", "4.6", "4.8", "5.0", "5.2", "5.4", "5.6", "5.8", "6.0", "6.2"] +} diff --git a/settings-schema.json b/settings-schema.json new file mode 100644 index 0000000..b345404 --- /dev/null +++ b/settings-schema.json @@ -0,0 +1,36 @@ +{ + "refresh-interval": { + "type": "spinbutton", + "default": 5, + "min": 1, + "max": 60, + "step": 1, + "units": "minutes", + "description": "Refresh interval", + "tooltip": "How often to check for usage updates (in minutes)" + }, + "color-low": { + "type": "colorchooser", + "default": "rgb(107, 207, 127)", + "description": "Color for low usage (< 50%)", + "tooltip": "Color to display when usage is below 50%" + }, + "color-medium": { + "type": "colorchooser", + "default": "rgb(255, 217, 61)", + "description": "Color for medium usage (50-80%)", + "tooltip": "Color to display when usage is between 50% and 80%" + }, + "color-high": { + "type": "colorchooser", + "default": "rgb(255, 107, 107)", + "description": "Color for high usage (> 80%)", + "tooltip": "Color to display when usage is above 80%" + }, + "show-label-prefix": { + "type": "checkbox", + "default": false, + "description": "Show 'Claude:' prefix", + "tooltip": "Display 'Claude: ' before the percentage on the panel" + } +}