diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-23 20:12:58 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-23 20:12:58 -0400 |
| commit | 10fa6f4e2e7150ad99827721ada1ae4badcc5e90 (patch) | |
| tree | 960065c8e69f1e7a4150ecf522e2f813c239b5ee /archive/gptel/gptel-tools/update_text_file.el | |
| parent | f4cc70c69e7707dd4a686637e14885f5443fcca6 (diff) | |
| download | dotemacs-10fa6f4e2e7150ad99827721ada1ae4badcc5e90.tar.gz dotemacs-10fa6f4e2e7150ad99827721ada1ae4badcc5e90.zip | |
chore(ai): archive gptel and remove it from the live config
I archived gptel to archive/gptel/ since I rarely use it. Moved there: the six gptel modules (ai-config, ai-conversations, ai-conversations-browser, ai-mcp, ai-quick-ask, ai-rewrite), the gptel-tools/ directory, custom/gptel-prompts.el, their test files and utilities, and the four gptel-only specs.
Scrubbed from the live config: the ai-config require in init.el, which also drops the whole C-; a keymap; the gptel-mode emojify hook in font-config.el; the gptel-tools entries in the Makefile clean target and the coverage runner; and the gptel feature notes in README. Cancelled the open gptel tasks in todo.org (the AI Open Work issues, the feature-extension brainstorm, the velox gptel-magit bug).
ai-term stays. It is the ghostel Claude launcher, independent of gptel.
Verified: every module loads, a batch init launch reaches completion clean, and the full test suite shows only pre-existing coverage failures unrelated to this change.
Diffstat (limited to 'archive/gptel/gptel-tools/update_text_file.el')
| -rw-r--r-- | archive/gptel/gptel-tools/update_text_file.el | 235 |
1 files changed, 235 insertions, 0 deletions
diff --git a/archive/gptel/gptel-tools/update_text_file.el b/archive/gptel/gptel-tools/update_text_file.el new file mode 100644 index 00000000..f8b58025 --- /dev/null +++ b/archive/gptel/gptel-tools/update_text_file.el @@ -0,0 +1,235 @@ +;;; update_text_file.el --- Update text files for gptel -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Keywords: convenience, tools + +;; This file is not part of GNU Emacs. + +;;; Commentary: + +;; Gptel tool for updating an existing text file with one of five +;; operations: +;; +;; replace Replace all occurrences of PATTERN with REPLACEMENT. +;; append Add TEXT at the end of the file. +;; prepend Add TEXT at the beginning of the file. +;; insert-at-line Insert TEXT at LINE-NUM (1-indexed). +;; delete-lines Delete every line containing PATTERN. +;; +;; The operations are pure-string transforms — file I/O happens only at +;; the outer wrapper, which validates the path, takes a timestamped +;; backup, and writes the new content atomically. The tool uses gptel's +;; `:confirm t' meta-flag for the user-facing prompt, mirroring how +;; `write_text_file' handles confirmation. +;; +;; PATTERN is a literal substring for `replace' and `delete-lines'. No +;; regex. The model can build literal multi-line patterns and we don't +;; want it to discover regex metacharacter gotchas through trial and +;; error. + +;;; Code: + +(require 'gptel) +(require 'subr-x) +(require 'cl-lib) + +;; ---------------------------------------------------------------- helpers + +(defun cj/update-text-file--validate-path (path) + "Validate PATH for update. Return the truename on success. + +PATH must resolve inside the user's home directory, must exist, must +be a regular file, and must be readable and writable." + (let* ((home (file-name-as-directory (file-truename (expand-file-name "~")))) + (full (expand-file-name path "~"))) + (unless (string-prefix-p (expand-file-name "~") full) + (error "Path must be within home directory: %s" path)) + (unless (file-exists-p full) + (error "File not found: %s" full)) + (let ((resolved (file-truename full))) + (unless (or (string= resolved (directory-file-name home)) + (string-prefix-p home resolved)) + (error "Resolved path must be within home directory: %s" path)) + (when (file-directory-p resolved) + (error "Path is a directory, not a file: %s" resolved)) + (unless (file-readable-p resolved) + (error "No read permission for file: %s" resolved)) + (unless (file-writable-p resolved) + (error "No write permission for file: %s" resolved)) + resolved))) + +(defun cj/update-text-file--backup-name (path) + "Return a backup filename for PATH timestamped to the current second." + (format "%s-%s.bak" path (format-time-string "%Y-%m-%d-%H%M%S"))) + +(defconst cj/update-text-file--size-limit (* 10 1024 1024) + "Reject files larger than 10MB so a runaway operation can't churn the disk.") + +;; ----------------------------------------------------- string transforms +;; +;; Each transform takes the file contents as a string plus operation +;; parameters and returns the new contents. Pure functions — no I/O. + +(defun cj/update-text-file--replace (content pattern replacement) + "Return CONTENT with every occurrence of PATTERN replaced by REPLACEMENT. +PATTERN is treated as a literal substring. Signal an error if PATTERN is +empty or nil." + (unless (and (stringp pattern) (> (length pattern) 0)) + (error "Replace operation requires a non-empty pattern")) + (unless (stringp replacement) + (error "Replace operation requires a replacement string")) + (replace-regexp-in-string (regexp-quote pattern) replacement content t t)) + +(defun cj/update-text-file--append (content text) + "Return CONTENT with TEXT added at the end, separated by a newline. +A trailing newline is guaranteed. Signal if TEXT is nil or empty." + (unless (and (stringp text) (> (length text) 0)) + (error "Append operation requires non-empty text")) + (let ((base (if (or (string-empty-p content) + (string-suffix-p "\n" content)) + content + (concat content "\n")))) + (if (string-suffix-p "\n" text) + (concat base text) + (concat base text "\n")))) + +(defun cj/update-text-file--prepend (content text) + "Return CONTENT with TEXT added at the beginning. +TEXT is separated from CONTENT by a newline. Signal if TEXT is nil +or empty." + (unless (and (stringp text) (> (length text) 0)) + (error "Prepend operation requires non-empty text")) + (if (string-suffix-p "\n" text) + (concat text content) + (concat text "\n" content))) + +(defun cj/update-text-file--insert-at-line (content line-num text) + "Return CONTENT with TEXT inserted before LINE-NUM (1-indexed). +LINE-NUM 1 prepends. LINE-NUM one past the last line appends. Signal +on out-of-range LINE-NUM or empty TEXT." + (unless (and (integerp line-num) (> line-num 0)) + (error "Insert-at-line requires a positive integer line number")) + (unless (and (stringp text) (> (length text) 0)) + (error "Insert-at-line requires non-empty text")) + (let* ((lines (split-string content "\n")) + ;; `split-string' on a newline-terminated string returns an + ;; extra empty element at the end. Trim it so the line count + ;; matches what a human would say. + (trailing-newline (string-suffix-p "\n" content)) + (line-count (cond + ((string-empty-p content) 0) + (trailing-newline (1- (length lines))) + (t (length lines))))) + (when (> line-num (1+ line-count)) + (error "Line %d out of range (file has %d lines)" line-num line-count)) + (let* ((to-insert (if (string-suffix-p "\n" text) + (substring text 0 (1- (length text))) + text)) + (idx (1- line-num)) + (head (cl-subseq lines 0 idx)) + (tail (cl-subseq lines idx))) + (mapconcat #'identity + (append head (list to-insert) tail) + "\n")))) + +(defun cj/update-text-file--delete-lines (content pattern) + "Return CONTENT with every line containing PATTERN removed. +PATTERN is a literal substring. Trailing-newline state is preserved +when at least one line survives; an empty result is returned as the +empty string." + (unless (and (stringp pattern) (> (length pattern) 0)) + (error "Delete-lines requires a non-empty pattern")) + (let* ((trailing-newline (string-suffix-p "\n" content)) + (raw-lines (split-string content "\n")) + ;; Drop the trailing empty element split-string produces when + ;; the input ends in a newline. + (lines (if trailing-newline + (butlast raw-lines) + raw-lines)) + (kept (cl-remove-if (lambda (line) + (string-match-p (regexp-quote pattern) line)) + lines))) + (cond + ((null kept) "") + (trailing-newline (concat (mapconcat #'identity kept "\n") "\n")) + (t (mapconcat #'identity kept "\n"))))) + +(defun cj/update-text-file--apply-operation + (content operation pattern replacement line-num) + "Dispatch OPERATION on CONTENT. Return the transformed string. + +OPERATION is one of \"replace\", \"append\", \"prepend\", +\"insert-at-line\", or \"delete-lines\". PATTERN, REPLACEMENT, and +LINE-NUM are used per operation; unused arguments are ignored." + (pcase operation + ("replace" (cj/update-text-file--replace content pattern replacement)) + ("append" (cj/update-text-file--append content pattern)) + ("prepend" (cj/update-text-file--prepend content pattern)) + ("insert-at-line" (cj/update-text-file--insert-at-line content line-num pattern)) + ("delete-lines" (cj/update-text-file--delete-lines content pattern)) + (_ (error "Unknown operation: %s" operation)))) + +;; ----------------------------------------------------- file-level wrapper + +(defun cj/update-text-file--run (path operation pattern replacement line-num) + "Update PATH with OPERATION and return a status string. + +PATTERN, REPLACEMENT, and LINE-NUM are passed through per operation. +A timestamped backup is created next to the file before writing. If +the operation produces no change the backup is removed and the file +is left untouched." + (let* ((full (cj/update-text-file--validate-path path)) + (size (file-attribute-size (file-attributes full)))) + (when (> size cj/update-text-file--size-limit) + (error "File too large (%s): exceeds 10MB limit" + (file-size-human-readable size))) + (let* ((before (with-temp-buffer + (insert-file-contents full) + (buffer-string))) + (after (cj/update-text-file--apply-operation + before operation pattern replacement line-num))) + (cond + ((string= before after) + (format "No changes made to %s" full)) + (t + (let ((backup (cj/update-text-file--backup-name full))) + (copy-file full backup t) + (with-temp-file full (insert after)) + (format "Updated %s (backup: %s)" + full (file-name-nondirectory backup)))))))) + +;; ----------------------------------------------------- tool registration + +(with-eval-after-load 'gptel + (gptel-make-tool + :name "update_text_file" + :function (lambda (path operation &optional pattern replacement line-num) + (cj/update-text-file--run path operation pattern replacement line-num)) + :description "Update an existing text file with one of: replace, append, prepend, insert-at-line, delete-lines. Creates a timestamped backup before writing. Patterns are literal substrings, not regex." + :args (list '(:name "path" + :type string + :description "File path relative to home directory, e.g. 'documents/foo.txt' or '~/documents/foo.txt'") + '(:name "operation" + :type string + :enum ["replace" "append" "prepend" "insert-at-line" "delete-lines"] + :description "Which update operation to perform") + '(:name "pattern" + :type string + :description "For replace/delete-lines: the literal substring to match. For append/prepend/insert-at-line: the text to add. Required for every operation." + :optional t) + '(:name "replacement" + :type string + :description "For replace: the literal replacement text. Ignored by other operations." + :optional t) + '(:name "line_num" + :type integer + :description "For insert-at-line: 1-indexed line number to insert before. Ignored by other operations." + :optional t)) + :category "filesystem" + :confirm t + :include t) + + (add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "update_text_file")))) + +(provide 'update_text_file) +;;; update_text_file.el ends here |
