From 07e5e799ea55951a60d7bfce11271b50f3e8005e Mon Sep 17 00:00:00 2001 From: Jiri Jakes Date: Sat, 7 Mar 2026 00:00:51 -0300 Subject: [PATCH] Initial commit --- AGENTS.md | 189 +++++++++++++++++++++ nano-gpt-status.el | 399 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 588 insertions(+) create mode 100644 AGENTS.md create mode 100644 nano-gpt-status.el diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..66357ff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,189 @@ +# Agent Guidelines for nano-gpt + +This is an Emacs Lisp project for a dashboard application. The codebase consists of: +- `dashboard.el` - Main dashboard implementation (180 lines) + +## Build/Load Commands + +```elisp +;; Load the dashboard in Emacs +(load-file "dashboard.el") + +;; Byte-compile for faster loading +(byte-compile-file "dashboard.el") +``` + +## Running Tests + +This project uses Emacs's built-in ERT testing framework. No external test framework is configured. + +To run a single test in Emacs: +```elisp +;; Evaluate the test definition, then run: +(ert-run-tests-interactively "^test-name$") +``` + +Or from command line: +```bash +emacs --batch --eval "(progn (load-file \"dashboard.el\") (ert \"^test-name$\"))" +``` + +Run all tests: +```bash +emacs --batch --eval "(progn (load-file \"dashboard.el\") (ert t))" +``` + +## Linting Commands + +Emacs has built-in linting tools available: + +```elisp +;; Check documentation strings +(checkdoc "dashboard.el") + +;; Check for undefined variables (elint) +(elint-file "dashboard.el") + +;; Byte-compile to catch errors +(byte-compile-file "dashboard.el") +``` + +Run linting from command line: +```bash +emacs --batch --eval "(progn (load-file \"dashboard.el\") (checkdoc \"dashboard.el\"))" +``` + +## Code Style Guidelines + +### General Principles +- Use CamelCase for function and variable names (Emacs Lisp convention) +- Prefix all public functions/variables with a project-specific namespace: `my-dashboard-` +- Use kebab-case for keymap names: `my-dashboard-mode-map` + +### Naming Conventions +- Functions: `my-dashboard-function-name` +- Variables: `my-dashboard-variable-name` +- Private functions/variables: prefix with `--` (e.g., `my-dashboard--internal`) +- Modes: `my-dashboard-foo-mode` +- Keymaps: `my-dashboard-foo-mode-map` +- Buffer names: `*Dashboard:Name*` + +### Formatting +- Indent using 2 spaces (Emacs default for Elisp) +- Maximum line length: 80 characters +- Use `setq` for setting variables, `defvar` for defining variables with docstrings +- Place docstrings on the line after `defun`/`defvar` opening paren +- Use consistent spacing around operators: `(+ x y)` not `(+x y)` + +### Imports and Dependencies +- Use `(require 'module)` at the top of files +- Common dependencies: `url`, `json`, `tabulated-list`, `org` +- Use `use-package` for declarative package management if needed + +### Types +- Use `:type` property in `defcustom` for customization types +- Use type predicates: `numberp`, `stringp`, `listp`, `functionp`, `bufferp` +- Check for nil explicitly: `(when var ...)` not `(if var ...)` +- Use `eq` for symbol comparison, `equal` for lists/strings + +### Error Handling +- Use `condition-case` for error catching +- Check API error status with `plist-get status :error` +- Provide meaningful error messages in docstrings +- Handle `:null` values from JSON parsing explicitly + +### Keymap Conventions +- Use `make-sparse-keymap` for mode keymaps +- Set keymap parent to appropriate mode map (e.g., `tabulated-list-mode-map`) +- Define keys using `define-key` with key sequences as strings (e.g., "q", "C-x C-f") +- Use `setq` with `let` for local keymap creation + +### Mode Definition +- Use `define-derived-mode` for major modes +- Set `tabulated-list-format` and `tabulated-list-entries` +- Call `tabulated-list-init-header` and `tabulated-list-print` +- Set `inhibit-read-only` before modifying read-only buffers + +### Async Operations +- Use `url-retrieve` for async HTTP requests +- Define callback functions with `status` parameter +- Check for errors with `plist-get status :error` +- Use `goto-char (point-min)` before parsing buffer content +- Use `re-search-forward` to skip HTTP headers in response + +### Documentation +- Every `defun` and `defvar` should have a docstring +- Docstrings start with descriptive verb ("Show", "Fetch", "Copy") +- Document interactive commands with `(interactive)` and "Calling from program..." + +### Package Management +- Use `defcustom` for user-configurable variables with `:type`, `:group`, `:default` +- Use `defvar` for internal variables with docstrings +- Prefix custom group with project namespace + +## Project-Specific Patterns + +### Safe List Access +```elisp +(defun my-dashboard--aget (alist key) + "Safely get value from ALIST for KEY, returns nil if null." + (let ((val (cdr (assq key alist)))) + (unless (eq val :null) val))) +``` + +### Buffer Management +```elisp +;; Get or create buffer +(get-buffer-create "*Buffer-Name*") + +;; Modify buffer safely +(with-current-buffer buf + ...) + +;; For read-only buffers +(let ((inhibit-read-only t)) + (erase-buffer) + ...) +``` + +### Window Configuration +```elisp +;; Save/restore window config +(setq my-dashboard--window-config (current-window-configuration)) +(set-window-configuration my-dashboard--window-config) + +;; Split windows +(split-window-right) ; vertical split +(split-window-below) ; horizontal split +(delete-other-windows) ; maximize current window +``` + +### JSON Parsing +```elisp +;; Parse JSON with alist object type +(let* ((json (json-parse-string (buffer-substring (point) (point-max)) + :object-type 'alist)) + (data (cdr (assq 'data json)))) + ...) +``` + +### Tabulated List Mode +```elisp +;; Format specification: [("Column" width sort-fn) ...] +(setq tabulated-list-format + [("Name" 30 t) + ("ID" 40 t) + ("Created" 18 t) + ("Ctx" 8 t :right-align t :pad-right 2)]) + +;; Entries: (id [col1 col2 col3] ...) +(setq tabulated-list-entries + '((1 ["Apple" "Red" "Sweet"]) + (2 ["Banana" "Yellow" "Sweet"]))) +``` + +### Properize Text with Faces +```elisp +(propertize text 'font-lock-face face) +;; face can be: '(:foreground "green"), 'bold, etc. +``` diff --git a/nano-gpt-status.el b/nano-gpt-status.el new file mode 100644 index 0000000..94dfd1c --- /dev/null +++ b/nano-gpt-status.el @@ -0,0 +1,399 @@ +;;; nano-gpt-status.el --- Dashboard for Nano-GPT API -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Nano-GPT Contributors + +;; Author: Nano-GPT Contributors +;; Maintainer: Nano-GPT Contributors +;; URL: https://github.com/nano-gpt/emacs +;; Version: 0.1.0 +;; Package-Requires: ((emacs "28.1") (qrencode "0.1")) +;; Keywords: convenience, api, dashboard + +;; This file is not part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; A dashboard for the Nano-GPT API showing models, usage, balance and deposits. + +;; Setup: +;; (require 'nano-gpt-status) +;; M-x nano-gpt-status +;; +;; Configure API key in auth-sources: +;; (add-to-list 'auth-sources '("nano-gpt.gpg" :host "nano-gpt.com")) + +;;; Code: + +(require 'url) +(require 'json) +(require 'auth-source) +(require 'org) +(require 'tabulated-list) +(require 'hl-line) + +(declare-function qrencode-string "qrencode") + +(defgroup nano-gpt nil + "Dashboard for Nano-GPT API." + :group 'convenience + :prefix "nano-gpt-" + :link '(url-link :tag "GitHub" "https://github.com/nano-gpt/emacs")) + +(defcustom nano-gpt-api-base-url "https://nano-gpt.com/api" + "Base URL for Nano-GPT API." + :type 'string + :group 'nano-gpt) + +(defcustom nano-gpt-models-endpoint "/v1/models?detailed=true" + "Endpoint for fetching models." + :type 'string + :group 'nano-gpt) + +(defvar nano-gpt--window-config nil + "Saved window configuration before dashboard was shown.") + +(defun nano-gpt--get-api-key () + "Get API key from auth-source for nano-gpt.com." + (let ((secret (auth-source-search :host "nano-gpt.com" :secret t))) + (when secret + (funcall (plist-get (car secret) :secret))))) + +;; --- Table 1: Fruits --- + +(defvar nano-gpt-fruits-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map tabulated-list-mode-map) + (define-key map "q" #'nano-gpt-quit) + map)) + +(define-derived-mode nano-gpt-fruits-mode tabulated-list-mode "Fruits" + "A table of fruits." + (setq tabulated-list-format [("Fruit" 15 t) ("Color" 10 t) ("Taste" 10 t)]) + (setq tabulated-list-entries + '((1 ["Apple" "Red" "Sweet"]) + (2 ["Banana" "Yellow" "Sweet"]) + (3 ["Lemon" "Yellow" "Sour"]) + (4 ["Grape" "Purple" "Sweet"]))) + (tabulated-list-init-header) + (tabulated-list-print)) + +;; --- Table 2: Models --- + +(defvar nano-gpt--models-data nil + "Cached models data from API.") + +(defun nano-gpt--aget (alist key) + "Safely get value from ALIST for KEY, returns nil if null." + (let ((val (cdr (assq key alist)))) + (unless (eq val :null) val))) + +(defun nano-gpt--format-context-length (num) + "Format NUM as human-readable context length." + (if (numberp num) + (cond + ((>= num 1000000) (format "%.1fM" (/ num 1000000.0))) + ((>= num 1000) (format "%.0fK" (/ num 1000.0))) + (t (number-to-string num))) + "N/A")) + +(defun nano-gpt--format-pricing (prompt completion) + "Format pricing from PROMPT and COMPLETION values." + (if (and (numberp prompt) (numberp completion) + (eq prompt 0) (eq completion 0)) + "Free" + (format "$%.2f/$%.2f" + (or prompt 0) + (or completion 0)))) + +(defun nano-gpt--fetch-models-callback (status) + "Callback for async model fetching. STATUS is the url-retrieve status." + (unless (plist-get status :error) + (goto-char (point-min)) + (re-search-forward "^$" nil t) + (let* ((json (json-parse-string (buffer-substring (point) (point-max)) :object-type 'alist)) + (models (cdr (assq 'data json)))) + (setq nano-gpt--models-data models) + (when (get-buffer "*Dashboard:Models*") + (with-current-buffer "*Dashboard:Models*" + (nano-gpt-models-mode))) + (message "Models loaded: %d entries" (length models))))) + +(defun nano-gpt--fetch-models-async () + "Fetch models from Nano-GPT API asynchronously." + (url-retrieve + (concat nano-gpt-api-base-url nano-gpt-models-endpoint) + #'nano-gpt--fetch-models-callback + nil t t)) + +;; --- Usage --- + +(defun nano-gpt--format-usage-time (epoch-ms) + "Format EPOCH-MS as human-readable local time." + (when epoch-ms + (format-time-string "%Y-%m-%d %H:%M" (/ epoch-ms 1000)))) + +(defun nano-gpt--fetch-usage-callback (status) + "Callback for async usage fetching. STATUS is the url-retrieve status." + (unless (plist-get status :error) + (goto-char (point-min)) + (re-search-forward "^$" nil t) + (let* ((json-str (buffer-substring (point) (point-max))) + (json (json-parse-string json-str :object-type 'alist)) + (usage json) + (state (nano-gpt--aget usage 'state)) + (active (nano-gpt--aget usage 'active)) + (limits (nano-gpt--aget usage 'limits)) + (weekly (nano-gpt--aget usage 'weeklyInputTokens)) + (period (nano-gpt--aget usage 'period))) + (when (get-buffer "*Dashboard:Info*") + (with-current-buffer "*Dashboard:Info*" + (let ((inhibit-read-only t)) + (goto-char (point-min)) + (when (re-search-forward "^\\*\\* Usage" nil t) + (delete-region (match-beginning 0) (progn (forward-line) (point))) + (insert "** Usage\n") + (insert (format "- State: %s\n" (or state "N/A"))) + (insert (format "- Active: %s\n\n" (if active "Yes" "No"))) + (insert "*** Weekly Input Tokens\n") + (insert (format "- Used: %s / %s\n" + (nano-gpt--aget weekly 'used) + (nano-gpt--aget limits 'weeklyInputTokens))) + (insert (format "- Remaining: %s\n" (nano-gpt--aget weekly 'remaining))) + (insert (format "- Percent: %s%%\n" + (let ((pct (nano-gpt--aget weekly 'percentUsed))) + (when pct (format "%.2f" (* pct 100)))))) + (insert (format "- Resets at: %s\n\n" + (nano-gpt--format-usage-time (nano-gpt--aget weekly 'resetAt)))) + (insert "*** Billing Period\n") + (insert (format "- Ends: %s\n" + (let ((end (nano-gpt--aget period 'currentPeriodEnd))) + (when end + (format-time-string "%Y-%m-%d %H:%M" + (date-to-time end)))))))))) + (message "Usage loaded")))) + +(defun nano-gpt--fetch-usage-async () + "Fetch usage from Nano-GPT API asynchronously." + (let ((api-key (nano-gpt--get-api-key))) + (when api-key + (let ((url-request-extra-headers (list (cons "Authorization" (format "Bearer %s" api-key))))) + (url-retrieve + (concat nano-gpt-api-base-url "/subscription/v1/usage") + #'nano-gpt--fetch-usage-callback + nil nil t))))) + +;; --- Balance --- + +(defun nano-gpt--fetch-balance-callback (status) + "Callback for async balance fetching. STATUS is the url-retrieve status." + (unless (plist-get status :error) + (goto-char (point-min)) + (re-search-forward "^$" nil t) + (let* ((json (json-parse-string (buffer-substring (point) (point-max)) :object-type 'alist)) + (usd (nano-gpt--aget json 'usd_balance))) + (when (get-buffer "*Dashboard:Info*") + (with-current-buffer "*Dashboard:Info*" + (let ((inhibit-read-only t)) + (goto-char (point-min)) + (when (re-search-forward "^\\*\\* Balance" nil t) + (delete-region (match-beginning 0) (progn (forward-line) (point))) + (insert "** Balance\n") + (insert (format "- USD: $%s\n" (or usd "N/A"))))))) + (message "Balance loaded")))) + +(defun nano-gpt--fetch-balance-async () + "Fetch balance from Nano-GPT API asynchronously." + (let ((api-key (nano-gpt--get-api-key))) + (when api-key + (let ((url-request-method "POST") + (url-request-extra-headers (list (cons "x-api-key" api-key)))) + (url-retrieve + (concat nano-gpt-api-base-url "/check-balance") + #'nano-gpt--fetch-balance-callback + nil nil t))))) + +;; --- Deposit --- + +(defun nano-gpt--create-deposit-callback (status) + "Callback for async deposit creation. STATUS is the url-retrieve status." + (unless (plist-get status :error) + (goto-char (point-min)) + (re-search-forward "^$" nil t) + (let* ((json-str (buffer-substring (point) (point-max))) + (json (json-parse-string json-str :object-type 'alist)) + (tx-id (nano-gpt--aget json 'txId)) + (amount (nano-gpt--aget json 'amount)) + (address (nano-gpt--aget json 'address))) + (when (get-buffer "*Dashboard:Info*") + (with-current-buffer "*Dashboard:Info*" + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (insert (format "\n*** Deposit Created\n")) + (insert (format "- txId: %s\n" (or tx-id "N/A"))) + (insert (format "- Amount: %s BTC\n" (or amount "N/A"))) + (when address + (insert (format "- Invoice (raw):\n%s\n" address)) + (insert (format "- Invoice (BOLT-11):\n")) + (insert (qrencode-string address)) + (insert "\n")) + (insert "\n")) + (goto-char (point-min))) + (message "Deposit created: %s" tx-id))))) + +(defun nano-gpt--create-deposit-async (amount) + "Create BTC-LN deposit for AMOUNT." + (let ((api-key (nano-gpt--get-api-key))) + (when api-key + (let ((url-request-method "POST") + (url-request-extra-headers (list (cons "Authorization" (format "Bearer %s" api-key)) + (cons "Content-Type" "application/json"))) + (url-request-data (json-encode (list (cons 'amount amount))))) + (url-retrieve + (concat nano-gpt-api-base-url "/transaction/create/btc-ln") + #'nano-gpt--create-deposit-callback + nil nil t))))) + +(defun nano-gpt--deposit-button-action (_) + "Action for deposit button." + (let* ((satoshis (read-number "Enter amount in satoshis: ")) + (amount (/ satoshis 100000000.0))) + (message "Creating BTC-LN deposit for %s satoshis (%s BTC)..." satoshis amount) + (nano-gpt--create-deposit-async amount))) + +(defun nano-gpt--insert-deposit-button () + "Insert a deposit button in the current buffer." + (insert-text-button "[Deposit BTC-LN]" + 'action (lambda (_) (nano-gpt--deposit-button-action 0.00001)) + 'follow-link t)) + +;; --- Models Mode --- + +(defvar nano-gpt-models-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map tabulated-list-mode-map) + (define-key map "q" #'nano-gpt-quit) + (define-key map "w" #'nano-gpt-models-copy-id) + map)) + +(defun nano-gpt-models-copy-id () + "Copy the ID of the current model to clipboard." + (interactive) + (when tabulated-list-entries + (let* ((entry (tabulated-list-get-entry)) + (id (aref entry 1))) + (kill-new id) + (message "Copied: %s" id)))) + +(define-derived-mode nano-gpt-models-mode tabulated-list-mode "Models" + "A table of Nano-GPT models." + (hl-line-mode 1) + (setq tabulated-list-format + [("Name" 30 t) + ("ID" 40 t) + ("Created" 10 t) + ("Ctx" 6 t :right-align t :pad-right 3) + ("Pricing" 18 t)]) + (setq tabulated-list-sort-key (cons "Created" t)) + (setq tabulated-list-entries + (mapcar + (lambda (model) + (let* ((pricing (nano-gpt--aget model 'pricing)) + (subscription (nano-gpt--aget model 'subscription)) + (has-sub (eq (nano-gpt--aget subscription 'included) t)) + (face (if has-sub '(:foreground "green") nil))) + (list (nano-gpt--aget model 'id) + (vector + (propertize (or (nano-gpt--aget model 'name) "N/A") 'font-lock-face face) + (propertize (or (nano-gpt--aget model 'id) "N/A") 'font-lock-face face) + (propertize (format-time-string "%Y-%m-%d" (nano-gpt--aget model 'created)) 'font-lock-face face) + (propertize (nano-gpt--format-context-length (nano-gpt--aget model 'context_length)) 'font-lock-face face) + (propertize (nano-gpt--format-pricing + (nano-gpt--aget pricing 'prompt) + (nano-gpt--aget pricing 'completion)) 'font-lock-face face))))) + nano-gpt--models-data)) + (tabulated-list-init-header) + (tabulated-list-print)) + +;; --- Quit function --- + +(defun nano-gpt-quit () + "Quit the dashboard, restore previous window configuration, and kill buffers." + (interactive) + (dolist (buf (list (get-buffer "*Dashboard:Fruits*") + (get-buffer "*Dashboard:Models*") + (get-buffer "*Dashboard:Info*"))) + (when buf (kill-buffer buf))) + (setq nano-gpt--models-data nil) + (when nano-gpt--window-config + (set-window-configuration nano-gpt--window-config) + (setq nano-gpt--window-config nil))) + +;; --- Main entry point --- + +;;;###autoload +(defun nano-gpt-status () + "Show a simple dashboard with two tables and an org buffer." + (interactive) + (setq nano-gpt--window-config (current-window-configuration)) + + (let ((fruits-buf (get-buffer-create "*Dashboard:Fruits*")) + (models-buf (get-buffer-create "*Dashboard:Models*")) + (org-buf (get-buffer-create "*Dashboard:Info*"))) + + (with-current-buffer fruits-buf + (nano-gpt-fruits-mode)) + + (with-current-buffer models-buf + (nano-gpt-models-mode)) + + (with-current-buffer org-buf + (let ((inhibit-read-only t)) + (erase-buffer) + (org-mode) + (org-indent-mode 1) + (insert "* Dashboard\n\n") + (insert "Welcome to the *dashboard*!\n\n") + (insert "** Notes\n") + (insert "- Press ~q~ in any table to quit\n") + (insert "- Table 1 :: Fruits of the world\n") + (insert "- Table 2 :: Nano-GPT Models (loading...)\n\n") + (insert "** Usage\n\n") + (insert "** Balance\n\n") + (insert "** Deposit (BTC-LN)\n") + (nano-gpt--insert-deposit-button) + (insert "\n")) + (goto-char (point-min)) + (use-local-map (let ((map (copy-keymap org-mode-map))) + (define-key map "q" #'nano-gpt-quit) + map))) + + (delete-other-windows) + (let* ((top-left (selected-window)) + (top-right (split-window-right)) + (bottom (split-window-below (/ (window-height) 2)))) + (set-window-buffer top-left fruits-buf) + (set-window-buffer top-right models-buf) + (set-window-buffer bottom org-buf)) + + (select-window (get-buffer-window fruits-buf))) + + (nano-gpt--fetch-models-async) + (nano-gpt--fetch-usage-async) + (nano-gpt--fetch-balance-async)) + +(provide 'nano-gpt-status) + +;;; nano-gpt-status.el ends here