aboutsummaryrefslogtreecommitdiff
path: root/gptel-tools
diff options
context:
space:
mode:
Diffstat (limited to 'gptel-tools')
-rw-r--r--gptel-tools/update_text_file.el330
1 files changed, 206 insertions, 124 deletions
diff --git a/gptel-tools/update_text_file.el b/gptel-tools/update_text_file.el
index 0125e2ab..492ed554 100644
--- a/gptel-tools/update_text_file.el
+++ b/gptel-tools/update_text_file.el
@@ -1,149 +1,231 @@
;;; update_text_file.el --- Update text files for gptel -*- lexical-binding: t; -*-
-;; Copyright (C) 2025
-
-;; Author: gptel-tool-writer
+;; Author: Craig Jennings <c@cjennings.net>
;; Keywords: convenience, tools
;; 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.
-
;;; Commentary:
-;; This file provides a gptel tool for updating text files with various
-;; operations including replace, append, prepend, insert-at-line, and
-;; delete-lines. The tool creates timestamped backups and shows diffs
-;; before applying changes.
+;; 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 ((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))
+ (when (file-directory-p full)
+ (error "Path is a directory, not a file: %s" full))
+ (unless (file-readable-p full)
+ (error "No read permission for file: %s" full))
+ (unless (file-writable-p full)
+ (error "No write permission for file: %s" full))
+ (if (file-symlink-p full)
+ (file-truename full)
+ full)))
+
+(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"))))
-;; Helper function for building sed commands
-(defun cj/build-sed-command (operation pattern replacement line-num temp-file)
- "Build appropriate sed/shell command for OPERATION."
+(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 (if trailing-newline
+ (1- (length lines))
+ (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"
- (unless (and pattern replacement)
- (error "Replace operation requires pattern and replacement"))
- (format "sed -i 's|%s|%s|g' '%s'"
- (replace-regexp-in-string "|" "\\\\\\\\|" pattern)
- (replace-regexp-in-string "|" "\\\\\\\\|" replacement)
- temp-file))
- ("append"
- (unless pattern
- (error "Append operation requires text to append"))
- (format "printf '%%s\\\\n' %s >> '%s'"
- (shell-quote-argument pattern)
- temp-file))
- ("prepend"
- (unless pattern
- (error "Prepend operation requires text to prepend"))
- (format "(printf '%%s\\\\n' %s; cat '%s') > '%s.new' && mv '%s.new' '%s'"
- (shell-quote-argument pattern)
- temp-file temp-file temp-file temp-file))
- ("insert-at-line"
- (unless (and pattern line-num)
- (error "Insert-at-line requires text and line number"))
- (format "sed -i '%di\\\\%s' '%s'"
- line-num
- (replace-regexp-in-string "/" "\\\\\\\\/" pattern)
- temp-file))
- ("delete-lines"
- (unless pattern
- (error "Delete-lines requires pattern"))
- (format "sed -i '/%s/d' '%s'"
- (replace-regexp-in-string "/" "\\\\\\\\/" pattern)
- temp-file))
- (_
- (error "Unknown operation: %s" operation))))
-
-;; Main tool definition
+ ("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)
- (let* ((full-path (expand-file-name path "~"))
- (temp-file (make-temp-file "gptel-update-" nil ".tmp"))
- (backup-name (format "%s-%s.bak"
- full-path
- (format-time-string "%Y-%m-%d-%H%M%S"))))
- (unwind-protect
- (progn
- ;; Validate path
- (unless (string-prefix-p (expand-file-name "~") full-path)
- (error "Path must be within home directory"))
- (unless (file-exists-p full-path)
- (error "File not found: %s" full-path))
- (unless (file-readable-p full-path)
- (error "No read permission for file: %s" full-path))
- ;; Check file size
- (let ((size (file-attribute-size (file-attributes full-path))))
- (when (> size (* 10 1024 1024))
- (error "File too large (%s): exceeds 10MB limit"
- (file-size-human-readable size))))
- ;; Create backup
- (copy-file full-path backup-name t)
- ;; Copy to temp file for operations
- (copy-file full-path temp-file t)
- ;; Execute operation and check diff
- (let* ((sed-cmd (cj/build-sed-command operation pattern replacement line-num temp-file))
- (result (shell-command-to-string sed-cmd))
- (diff-output (shell-command-to-string
- (format "diff -u '%s' '%s' 2>/dev/null" full-path temp-file))))
- (if (string-empty-p diff-output)
- (progn
- (delete-file backup-name)
- (format "No changes made to %s" full-path))
- (if (y-or-n-p (format "Apply these changes to %s?\\n\\n%s\\n"
- full-path diff-output))
- (progn
- (copy-file temp-file full-path t)
- (format "Updated %s (backup: %s)"
- full-path (file-name-nondirectory backup-name)))
- (progn
- (delete-file backup-name)
- (error "Update cancelled by user"))))))
- ;; Cleanup temp file
- (when (file-exists-p temp-file)
- (delete-file temp-file)))))
- :description "Update a text file with various operations: replace, append, prepend, insert-at-line, or delete-lines. Shows diff before applying changes and creates timestamped backups."
+ (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/myfile.txt' or '~/documents/myfile.txt'")
- '(:name "operation"
- :type string
- :enum ["replace" "append" "prepend" "insert-at-line" "delete-lines"]
- :description "The type of update operation to perform")
- '(:name "pattern"
- :type string
- :description "For replace/delete: pattern to match. For append/prepend/insert: text to add"
- :optional t)
- '(:name "replacement"
- :type string
- :description "For replace operation: the replacement text"
- :optional t)
- '(:name "line_num"
- :type integer
- :description "For insert-at-line operation: the line number where to insert"
- :optional t))
+ :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))
-
-;; Automatically add to gptel-tools on load
-(add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "update_text_file")))
+ :include t)
+ (add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "update_text_file"))))
(provide 'update_text_file)
-;;; update_text_file.el ends here"
+;;; update_text_file.el ends here