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 <noreply@anthropic.com>
This commit is contained in:
132
README.md
Normal file
132
README.md
Normal file
@@ -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.
|
||||
306
applet.js
Normal file
306
applet.js
Normal file
@@ -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);
|
||||
}
|
||||
22
install.sh
Executable file
22
install.sh
Executable file
@@ -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."
|
||||
10
metadata.json
Normal file
10
metadata.json
Normal file
@@ -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"]
|
||||
}
|
||||
36
settings-schema.json
Normal file
36
settings-schema.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user