commit 3eee01f0f22986c4d43d667826833f8451efab9a Author: Matteo Benedetto Date: Thu Aug 20 18:48:51 2026 +0200 feat: dashboard KDE Plasma 6 per utilizzo OpenAI Codex Widget desktop 224x224 che mostra: - residuo settimanale con barra e percentuale - countdown esatto al reset (reset_at dall'API) - piano (Plus/Free), crediti, stato limiti - backend bash che chiama https://chatgpt.com/backend-api/wham/usage - legge il token OAuth da ~/.pi/agent/auth.json (provider openai-codex) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c9ef85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.bak +*.tmp +node_modules/ +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..29ffa9e --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# OpenAI Codex Usage — plasmoide KDE Plasma 6 + +Widget desktop 224×224 per KDE Plasma 6 che mostra il **residuo settimanale** +di [OpenAI Codex](https://openai.com/codex), autenticato via OAuth +(profilo ChatGPT). + +Legge il token OAuth da `~/.pi/agent/auth.json` (provider `openai-codex` +configurato nel [pi agent](https://pi.dev)) e interroga l'endpoint +(non documentato) `GET https://chatgpt.com/backend-api/wham/usage`. + +## Funzionalità + +- **Piano**: Plus / Free / Team visibile nell'header. +- **Residuo settimanale** con barra di avanzamento e percentuale. +- **Countdown esatto** al reset (data/ora + giorni/ore/minuti mancanti), + direttamente dal campo `reset_at` della risposta API. +- **Crediti**: saldo crediti extra (se disponibili). +- **Stato limiti**: indicatore visivo se `limit_reached` o `spend_control.reached`. +- **Aggiornamento automatico** configurabile + pulsante di refresh manuale. + +## File + +``` +metadata.json metadata del pacchetto (Plasma/Applet) +contents/config/main.xml schema della configurazione (kcfg) +contents/scripts/usage.sh backend: token OAuth, chiama wham/usage, stampa JSON +contents/ui/main.qml UI (dashboard 224×224) +contents/ui/configGeneral.qml pagina di configurazione +``` + +## Installazione + +```bash +# da sorgente +kpackagetool6 --type Plasma/Applet --install /home/enne2/Dev/org.enne2.codex.usage + +# da archivio +kpackagetool6 --type Plasma/Applet --install dist/org.enne2.codex.usage.tar.gz +``` + +## Dipendenze + +- `jq` e `curl` (per lo script di backend) +- il token OAuth di OpenAI Codex in `~/.pi/agent/auth.json` sotto `openai-codex` (impostato da pi con `/login`) + +## Note + +- L'endpoint `/backend-api/wham/usage` è **non documentato** e potrebbe cambiare. +- Se il token OAuth scade, il plasmoide mostrerà "token scaduto". Riesegui `/login` + in pi (provider `openai-codex`) per rigenerarlo. \ No newline at end of file diff --git a/contents/config/main.xml b/contents/config/main.xml new file mode 100644 index 0000000..2f5964f --- /dev/null +++ b/contents/config/main.xml @@ -0,0 +1,28 @@ + + + + + + 300 + 30 + 3600 + + Quanto spesso il plasmoide aggiorna i dati Codex. Minimo 30s. Default 300s (5 minuti). + + + 60 + 0 + 100 + + + + 30 + 0 + 100 + + + + \ No newline at end of file diff --git a/contents/scripts/usage.sh b/contents/scripts/usage.sh new file mode 100755 index 0000000..8c23eb8 --- /dev/null +++ b/contents/scripts/usage.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Backend del plasmoide OpenAI Codex Usage. +# Chiama l'endpoint (non documentato) GET https://chatgpt.com/backend-api/wham/usage +# con OAuth Bearer. La risposta contiene reset_at esatto → nessuna stima locale. +set -u + +AUTH_JSON="${CODEX_AUTH_JSON:-$HOME/.pi/agent/auth.json}" +TOKEN="" +if [ -f "$AUTH_JSON" ]; then + TOKEN="$(jq -r '.["openai-codex"].access // empty' "$AUTH_JSON" 2>/dev/null)" +fi +[ -n "$TOKEN" ] || TOKEN="${CODEX_ACCESS_TOKEN:-}" +[ -n "$TOKEN" ] || TOKEN="${OPENAI_CODEX_ACCESS_TOKEN:-}" + +if [ -z "$TOKEN" ]; then + printf '{"ok":false,"error":"no_token","detail":"nessun token Codex in %s"}\n' "$AUTH_JSON" + exit 0 +fi + +RESP="$(curl -sS --max-time 15 "https://chatgpt.com/backend-api/wham/usage" -H "Authorization: Bearer $TOKEN" 2>/dev/null)" +CURL_RC=$? +if [ "$CURL_RC" -ne 0 ] || [ -z "$RESP" ]; then + printf '{"ok":false,"error":"fetch_failed","detail":"curl exit %s"}\n' "$CURL_RC" + exit 0 +fi + +# Se il token è scaduto, l'API restituisce HTML di login invece di JSON. +if ! printf '%s' "$RESP" | jq -e '.rate_limit.primary_window.used_percent | type == "number"' >/dev/null 2>&1; then + printf '{"ok":false,"error":"token_expired","detail":"token Codex scaduto, riesegui /login in pi"}\n' + exit 0 +fi + +printf '%s' "$RESP" | jq -c '{ + ok: true, + plan_type: .plan_type, + remaining: ((100 - .rate_limit.primary_window.used_percent) / 100), + used_percent: .rate_limit.primary_window.used_percent, + reset_at: .rate_limit.primary_window.reset_at, + limit_window: .rate_limit.primary_window.limit_window_seconds, + credits: { balance: .credits.balance, has_credits: .credits.has_credits }, + spend_reached: .spend_control.reached, + limit_reached: .rate_limit.limit_reached +}' 2>/dev/null || printf '{"ok":false,"error":"bad_json","detail":"risposta non valida da wham/usage"}\n' + +exit 0 \ No newline at end of file diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml new file mode 100644 index 0000000..c84319b --- /dev/null +++ b/contents/ui/configGeneral.qml @@ -0,0 +1,57 @@ +/* + * configGeneral.qml — pagina di configurazione del plasmoide OpenAI Codex Usage. + */ +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.components 3.0 as PC3 +import org.kde.kirigami as Kirigami + +Item { + id: page + + property alias cfg_refreshIntervalSec: refreshSpin.value + property alias cfg_warnThreshold: warnSpin.value + property alias cfg_critThreshold: critSpin.value + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + text: i18n("Intervallo di aggiornamento (secondi)") + font.bold: true + } + PC3.SpinBox { + id: refreshSpin + from: 30 + to: 3600 + stepSize: 30 + Layout.fillWidth: true + } + + PC3.Label { + text: i18n("Soglia residuo giallo (%)") + font.bold: true + } + PC3.SpinBox { + id: warnSpin + from: 0 + to: 100 + Layout.fillWidth: true + } + + PC3.Label { + text: i18n("Soglia residuo rosso (%)") + font.bold: true + } + PC3.SpinBox { + id: critSpin + from: 0 + to: 100 + Layout.fillWidth: true + } + + Item { Layout.fillHeight: true } + } +} \ No newline at end of file diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..81ef6b8 --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,355 @@ +/* OpenAI Codex Usage — dashboard desktop per KDE Plasma 6. */ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.plasma5support as Plasma5Support +import org.kde.kirigami as Kirigami + +PlasmoidItem { + id: root + + property var data: null // { ok, plan_type, remaining, used_percent, reset_at, limit_window, credits, spend_reached, limit_reached } + property string lastUpdate: "" + property string errorMsg: "" + property bool loading: false + property int clockTick: 0 + + property int refreshSecs: Plasmoid.configuration.refreshIntervalSec + property int warnThreshold: Plasmoid.configuration.warnThreshold + property int critThreshold: Plasmoid.configuration.critThreshold + + Plasmoid.title: i18n("OpenAI Codex") + Plasmoid.icon: "code-context" + Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground + toolTipMainText: i18n("Codex — utilizzo settimanale") + toolTipSubText: root.data ? i18n("Residuo: %1% · %2", + Math.round((root.data.remaining || 0) * 100), + root.data.plan_type || "—") : (root.errorMsg || "") + + preferredRepresentation: fullRepresentation + switchWidth: 150 + switchHeight: 150 + + Plasma5Support.DataSource { + id: executable + engine: "executable" + connectedSources: [] + + onNewData: function(sourceName, result) { + root.loading = false + executable.disconnectSource(sourceName) + if (result["exit code"] !== 0) { + root.errorMsg = i18n("comando fallito (exit %1)", result["exit code"]) + return + } + try { + const parsed = JSON.parse((result["stdout"] || "").trim()) + if (parsed && parsed.ok === false) { + root.errorMsg = parsed.error + (parsed.detail ? " — " + parsed.detail : "") + root.data = null + } else if (parsed && parsed.ok && parsed.remaining !== undefined) { + root.data = parsed + root.errorMsg = "" + root.lastUpdate = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss") + } else { + root.errorMsg = i18n("risposta inattesa") + root.data = null + } + } catch (e) { + root.errorMsg = i18n("JSON non valido: %1", e.message) + root.data = null + } + } + } + + function refresh() { + if (root.loading) return + root.loading = true + const path = Qt.resolvedUrl("../scripts/usage.sh").toString().replace(/^file:\/\//, "") + executable.connectSource("/bin/bash " + shellQuote(path)) + } + function shellQuote(s) { return "'" + String(s).replace(/'/g, "'\"'\"'") + "'" } + + function remainingPct() { return root.data ? Math.round((root.data.remaining || 0) * 100) : 0 } + function usedPct() { return root.data ? Math.round(root.data.used_percent || 0) : 0 } + function accentColor() { + const pct = remainingPct() + if (pct <= root.critThreshold) return Kirigami.Theme.negativeTextColor + if (pct <= root.warnThreshold) return Kirigami.Theme.neutralTextColor + return "#7cb342" + } + + function resetMoment() { + const tick = root.clockTick + if (!root.data || !root.data.reset_at) return "—" + const d = new Date(root.data.reset_at * 1000) + return d.toLocaleDateString(Qt.locale(), "dd/MM") + " " + d.toLocaleTimeString(Qt.locale(), "HH:mm") + } + function resetRemaining() { + const tick = root.clockTick + if (!root.data || !root.data.reset_at) return "—" + let mins = Math.max(0, Math.floor((root.data.reset_at * 1000 - Date.now()) / 60000)) + if (mins === 0) return i18n("ora") + const days = Math.floor(mins / 1440) + mins -= days * 1440 + const hours = Math.floor(mins / 60) + mins -= hours * 60 + return days > 0 ? i18n("%1g %2h", days, hours) : i18n("%1h %2m", hours, mins) + } + + function windowLabel() { + if (!root.data || !root.data.limit_window) return "7D" + const secs = root.data.limit_window + if (secs >= 604800) return "7D" + if (secs >= 3600) return Math.round(secs / 3600) + "H" + return Math.round(secs / 60) + "M" + } + + Component.onCompleted: root.refresh() + Timer { + interval: Math.max(30, root.refreshSecs) * 1000 + repeat: true + running: true + onTriggered: root.refresh() + } + Timer { + interval: 30000 + repeat: true + running: true + onTriggered: root.clockTick += 1 + } + + compactRepresentation: Component { + Item { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + Kirigami.Icon { + anchors.fill: parent + source: Plasmoid.icon + color: root.data ? root.accentColor() : Kirigami.Theme.disabledTextColor + } + MouseArea { anchors.fill: parent; onClicked: Plasmoid.expanded = !Plasmoid.expanded } + } + } + + fullRepresentation: Component { + Item { + implicitWidth: 224 + implicitHeight: 224 + Layout.preferredWidth: 224 + Layout.preferredHeight: 224 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 9 + spacing: 5 + + // header + RowLayout { + Layout.fillWidth: true + Kirigami.Icon { + source: Plasmoid.icon + color: "#7cb342" + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + } + ColumnLayout { + spacing: 0 + PC3.Label { + text: i18n("CODEX") + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 1 + } + PC3.Label { + text: root.data ? (root.data.plan_type || "—").toUpperCase() : "—" + color: "#7cb342" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 2 + } + } + Item { Layout.fillWidth: true } + Rectangle { + Layout.preferredWidth: 9 + Layout.preferredHeight: 9 + radius: 5 + color: root.errorMsg ? Kirigami.Theme.negativeTextColor : "#7cb342" + } + PC3.Button { + icon.name: "view-refresh" + display: QQC2.AbstractButton.IconOnly + enabled: !root.loading + onClicked: root.refresh() + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.rgba(0.49, 0.76, 0.26, 0.45) + } + + PC3.Label { + visible: root.errorMsg.length > 0 + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Kirigami.Theme.negativeTextColor + text: i18n("Errore: %1", root.errorMsg) + } + + // barra principale + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 72 + radius: 6 + color: Qt.rgba(0, 0, 0, 0.26) + border.width: 1 + border.color: root.accentColor() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 7 + spacing: 3 + + RowLayout { + Layout.fillWidth: true + spacing: 5 + Rectangle { + Layout.preferredWidth: 24 + Layout.preferredHeight: 15 + radius: 8 + color: root.accentColor() + PC3.Label { + anchors.centerIn: parent + text: root.windowLabel() + color: "#101012" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 2 + } + } + PC3.Label { + text: i18n("Settimanale") + font.bold: true + Layout.fillWidth: true + } + PC3.Label { + text: root.remainingPct() + "%" + color: root.accentColor() + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 3 + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 8 + Rectangle { + anchors.fill: parent + radius: 4 + color: Qt.rgba(0, 0, 0, 0.55) + } + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: parent.width * Math.max(0, Math.min(1, root.data ? root.data.remaining : 0)) + height: parent.height + radius: 4 + color: root.accentColor() + } + } + + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: i18n("%1% usato", root.usedPct()) + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.Label { + visible: Boolean(root.data && root.data.limit_reached) + text: i18n("✕ LIMITE") + color: Kirigami.Theme.negativeTextColor + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } + } + + // reset + crediti + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 54 + radius: 5 + color: Qt.rgba(0, 0, 0, 0.28) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 2 + + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: i18n("RESET") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: root.resetMoment() + color: root.accentColor() + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 2 + } + } + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: root.resetRemaining() + color: "#7cb342" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 1 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: root.data && root.data.credits ? i18n("Cred. %1", root.data.credits.balance || "0") : "" + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + RowLayout { + Layout.fillWidth: true + PC3.Label { + visible: Boolean(root.data && root.data.spend_reached) + text: i18n("Spend control raggiunto") + color: Kirigami.Theme.negativeTextColor + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } + } + + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: root.lastUpdate ? i18n("SYNC %1", root.lastUpdate) : i18n("SYNC…") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: root.data && root.data.plan_type ? root.data.plan_type.toUpperCase() : "" + color: Kirigami.Theme.disabledTextColor + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } + } + } +} \ No newline at end of file diff --git a/dist/org.enne2.codex.usage.tar.gz b/dist/org.enne2.codex.usage.tar.gz new file mode 100644 index 0000000..bbc102b Binary files /dev/null and b/dist/org.enne2.codex.usage.tar.gz differ diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..34db869 --- /dev/null +++ b/metadata.json @@ -0,0 +1,16 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Id": "org.enne2.codex.usage", + "Name": "OpenAI Codex Usage", + "Name[it]": "Utilizzo OpenAI Codex", + "Description": "Shows the weekly usage allowance for OpenAI Codex (ChatGPT OAuth profile), with exact reset countdown, plan type, and credits", + "Description[it]": "Mostra il residuo settimanale di OpenAI Codex (profilo ChatGPT OAuth), con countdown esatto del reset, tipo piano e crediti", + "Icon": "code-context", + "Category": "System Information", + "License": "GPL-3.0", + "Version": "1.0.0", + "Authors": [ { "Name": "enne2" } ] + }, + "X-Plasma-API-Minimum-Version": "6.0" +} \ No newline at end of file