summaryrefslogtreecommitdiff
path: root/gptel-tools
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
committerCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
commit092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch)
treeea81999b8442246c978b364dd90e8c752af50db5 /gptel-tools
changing repositories
Diffstat (limited to 'gptel-tools')
-rw-r--r--gptel-tools/fetch_page.el399
-rw-r--r--gptel-tools/list_directory_files.el200
-rw-r--r--gptel-tools/move_to_trash.el142
-rw-r--r--gptel-tools/read_buffer.el27
-rw-r--r--gptel-tools/read_text_file.el144
-rw-r--r--gptel-tools/update_text_file.el149
-rw-r--r--gptel-tools/write_text_file.el94
7 files changed, 1155 insertions, 0 deletions
diff --git a/gptel-tools/fetch_page.el b/gptel-tools/fetch_page.el
new file mode 100644
index 00000000..deaae1f9
--- /dev/null
+++ b/gptel-tools/fetch_page.el
@@ -0,0 +1,399 @@
+;;; fetch_page.el --- Web fetch tool for gptel (HTML/text/links) -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 cjennings
+;; Author: cjennings and AI assistant
+;; Keywords: convenience, tools, web
+;; Package-Requires: ((emacs "27.1"))
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;;
+;; fetch_page: In-memory web fetch-and-parse tool for gptel tools.
+;;
+;; Features
+;; - Fetch a URL (http/https), follow redirects.
+;; - Return one of: rendered text (DOM+shr), raw HTML, or extracted links.
+;; - Size/time guards: max_bytes, max_chars, timeout.
+;; - User-Agent presets; Accept/Accept-Language overrides.
+;; - Predictable, LLM-friendly outputs and explicit error suggestions.
+;;
+;; Notes
+;; - This tool uses url.el (asynchronous url-retrieve) and libxml/shr for HTML.
+;; - For non-text content (e.g., PDFs), it returns an explicit error and suggests
+;; using a download tool (e.g., download_url) instead.
+;;
+;; Installation
+;; - Load after gptel and registers a gptel tool named "fetch_page" (category "web").
+;;
+;;; Code:
+
+(require 'url)
+(require 'subr-x)
+(eval-when-compile (require 'cl-lib))
+(require 'dom)
+(require 'shr)
+
+;; Internal helpers are prefixed gptel-fetch-page--
+
+(defconst gptel-fetch-page--default-max-bytes (* 16 1024 1024))
+(defconst gptel-fetch-page--max-allowed-bytes (* 32 1024 1024))
+(defconst gptel-fetch-page--default-max-chars 300000)
+(defconst gptel-fetch-page--default-timeout 30)
+
+(defconst gptel-fetch-page--ua-presets
+ '(("desktop-chrome" . "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
+ ("desktop-firefox" . "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0")
+ ("desktop-safari" . "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15")
+ ("edge-windows" . "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0")
+ ("mobile-iphone" . "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
+ ("mobile-android" . "Mozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36")
+ ("emacs-default" . "Emacs gptel/fetch_page (+https://github.com/karthink/gptel)") )
+ "Preset User-Agent strings.")
+
+(defun gptel-fetch-page--coerce-bool (v)
+ (cond
+ ((eq v :json-false) nil)
+ ((stringp v) (not (string-blank-p v)))
+ (t (and v t))))
+
+(defun gptel-fetch-page--coerce-int (v &optional default min max)
+ (let ((n (cond
+ ((numberp v) v)
+ ((stringp v) (string-to-number v))
+ (t nil))))
+ (setq n (or n default))
+ (when (and min n (< n min)) (setq n min))
+ (when (and max n (> n max)) (setq n max))
+ n))
+
+(defun gptel-fetch-page--ua (ua)
+ "Return a User-Agent string from UA preset or use UA directly."
+ (cond
+ ((null ua) (cdr (assoc "desktop-chrome" gptel-fetch-page--ua-presets)))
+ ((stringp ua)
+ (or (cdr (assoc ua gptel-fetch-page--ua-presets)) ua))
+ (t (cdr (assoc "desktop-chrome" gptel-fetch-page--ua-presets)))))
+
+(defun gptel-fetch-page--ok-scheme-p (url)
+ (let ((u (ignore-errors (url-generic-parse-url url))))
+ (and u (member (url-type u) '("http" "https")))))
+
+(defun gptel-fetch-page--parse-headers ()
+ "Parse headers in current buffer up to `url-http-end-of-headers'."
+ (let (hdrs)
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^\([^:]+\):[ \t]*\(.*\)$" url-http-end-of-headers t)
+ (push (cons (downcase (match-string 1)) (string-trim (match-string 2))) hdrs)))
+ (nreverse hdrs)))
+
+(defun gptel-fetch-page--content-type (headers)
+ "Return (mime charset) from HEADERS alist."
+ (let* ((ct (cdr (assoc "content-type" headers)))
+ (mime (and ct (car (split-string ct ";" t "[ \t]*"))))
+ (charset (and ct (when (string-match "charset=\\([^;]+\\)" ct)
+ (downcase (match-string 1 ct))))))
+ (list (or mime "unknown") charset)))
+
+(defun gptel-fetch-page--header-bytes (headers)
+ (let ((cl (cdr (assoc "content-length" headers))))
+ (and cl (ignore-errors (string-to-number cl)))))
+
+(defun gptel-fetch-page--region-string (beg end)
+ (save-excursion
+ (goto-char beg)
+ (buffer-substring-no-properties beg end)))
+
+(defun gptel-fetch-page--html-to-dom (html)
+ (with-temp-buffer
+ (insert html)
+ (condition-case err
+ (if (fboundp 'libxml-parse-html-region)
+ (libxml-parse-html-region (point-min) (point-max))
+ (signal 'error '(libxml-missing)))
+ (error (signal (car err) (cdr err))))))
+
+(defun gptel-fetch-page--dom-to-text (dom)
+ (with-temp-buffer
+ ;; Use shr to render
+ (let ((shr-use-fonts nil)
+ (shr-width (min 120 (window-body-width))))
+ (shr-insert-document dom))
+ (buffer-string)))
+
+(defun gptel-fetch-page--dom-base (dom final-url)
+ (or (when-let* ((node (car (dom-by-tag dom 'base)))
+ (href (dom-attr node 'href))) href)
+ final-url))
+
+(defun gptel-fetch-page--resolve (href base)
+ (condition-case _
+ (if (and href (not (string-empty-p href)))
+ (url-expand-file-name href base)
+ nil)
+ (error nil)))
+
+(defun gptel-fetch-page--collect-links (dom final-url include-titles)
+ (let* ((base (gptel-fetch-page--dom-base dom final-url))
+ (seen (make-hash-table :test 'equal))
+ (out '()))
+ (cl-labels ((push-link (u ttitle)
+ (when (and u (not (gethash u seen)))
+ (puthash u t seen)
+ (push (if include-titles
+ (format "%s\t%s" u (or (and ttitle (string-trim ttitle)) ""))
+ u)
+ out))))
+ ;; <a> and <area>
+ (dolist (tag '(a area))
+ (dolist (n (dom-by-tag dom tag))
+ (let ((href (dom-attr n 'href)))
+ (push-link (gptel-fetch-page--resolve href base)
+ (ignore-errors (string-trim (dom-texts n)))))))
+ ;; <link rel="stylesheet"> included by request
+ (dolist (n (dom-by-tag dom 'link))
+ (let* ((rel (downcase (or (dom-attr n 'rel) "")))
+ (is-style (or (string-match-p "stylesheet" rel)
+ (string-match-p "\bstyle\b" rel)))
+ (href (dom-attr n 'href)))
+ (when is-style
+ (push-link (gptel-fetch-page--resolve href base)
+ (or (dom-attr n 'title) rel))))))
+ (mapconcat #'identity (nreverse out) "\n")))
+
+(defun gptel-fetch-page--truncate (s max-chars)
+ (if (and max-chars (> (length s) max-chars))
+ (concat (substring s 0 max-chars) "\nnote: output truncated at " (number-to-string max-chars) " chars")
+ s))
+
+(defun gptel-fetch-page--format-error (&rest kv)
+ (let ((summary (plist-get kv :summary))
+ (status (or (plist-get kv :status) "unknown"))
+ (url (or (plist-get kv :url) "unknown"))
+ (type (or (plist-get kv :type) "unknown"))
+ (limit (plist-get kv :limit))
+ (suggest (plist-get kv :suggest)))
+ (string-join
+ (delq nil
+ (list (format "ERROR: %s" summary)
+ (format "details: status=%s url=%s type=%s" status url type)
+ (and limit (format "limit: %s" limit))
+ (and suggest (format "suggest: %s" suggest))))
+ "\n")))
+
+(defun gptel-fetch-page--format-header (status final-url type size)
+ (format "[%s] final-url: %s | type: %s | size: %s B"
+ status final-url type (or size "unknown")))
+
+(defun gptel-fetch-page--run (callback url format timeout max-bytes max-chars include-titles
+ user-agent accept accept-language debug)
+ (let* ((format (or format "text"))
+ (timeout (gptel-fetch-page--coerce-int timeout gptel-fetch-page--default-timeout 1 nil))
+ (max-bytes (gptel-fetch-page--coerce-int max-bytes gptel-fetch-page--default-max-bytes 1 gptel-fetch-page--max-allowed-bytes))
+ (max-chars (gptel-fetch-page--coerce-int max-chars gptel-fetch-page--default-max-chars 1000 nil))
+ (include-titles (gptel-fetch-page--coerce-bool include-titles))
+ (debug (gptel-fetch-page--coerce-bool debug))
+ (ua (gptel-fetch-page--ua user-agent))
+ (accept (or accept "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"))
+ (accept-language (or accept-language "en-US,en;q=0.9")))
+ (unless (gptel-fetch-page--ok-scheme-p url)
+ (funcall callback (gptel-fetch-page--format-error
+ :summary "Only http/https URLs are supported"
+ :url url
+ :suggest "use an http/https URL") )
+ (cl-return-from gptel-fetch-page--run))
+
+ (let* ((url-request-method "GET")
+ (url-request-extra-headers
+ `(("User-Agent" . ,ua)
+ ("Accept" . ,accept)
+ ("Accept-Language" . ,accept-language)
+ ;; Byte range to hint the server to cap size
+ ,@(and max-bytes (list (cons "Range" (format "bytes=0-%d" (1- max-bytes)))))))
+ (done nil)
+ (timer nil)
+ (orig-url url))
+ (setq timer (run-at-time timeout nil
+ (lambda ()
+ (unless done
+ (setq done t)
+ (funcall callback (gptel-fetch-page--format-error
+ :summary (format "Request timed out after %ss" timeout)
+ :url orig-url
+ :suggest "retry with a larger timeout or probe connectivity")))) )
+ (url-retrieve
+ url
+ (lambda (status)
+ (let ((inhibit-read-only t))
+ (unwind-protect
+ (progn
+ (when (and (not done)) (setq done t) (when timer (cancel-timer timer)))
+ (let ((bail nil))
+ (when (plist-get status :error)
+ (let ((err (plist-get status :error)))
+ (funcall callback (gptel-fetch-page--format-error
+ :summary (error-message-string err)
+ :url orig-url
+ :suggest "check URL or try again")))
+ (setq bail t))
+ ;; Parse status line & headers
+ (goto-char (point-min))
+ (let* ((status-line (buffer-substring (line-beginning-position) (line-end-position)))
+ (http-status (or (and (boundp 'url-http-response-status) url-http-response-status)
+ (and (string-match "HTTP/[0-9.]+ +\([0-9]+\)" status-line)
+ (string-to-number (match-string 1 status-line)))))
+ (status-msg (or (and (boundp 'url-http-response-status) url-http-response-status)
+ http-status))
+ (_ (search-forward "\n\n" nil t))
+ (headers (gptel-fetch-page--parse-headers))
+ (final-url (or (and (boundp 'url-current-object)
+ (ignore-errors (url-recreate-url url-current-object))) orig-url))
+ (ctype (gptel-fetch-page--content-type headers))
+ (mime (car ctype))
+ (_charset (cadr ctype))
+ (declared-bytes (gptel-fetch-page--header-bytes headers))
+ (body (gptel-fetch-page--region-string url-http-end-of-headers (point-max)))
+ (actual-bytes (string-bytes body)))
+ ;; Enforce size limits
+ (when (and (not bail) declared-bytes max-bytes (> declared-bytes max-bytes))
+ (funcall callback (gptel-fetch-page--format-error
+ :summary (format "Response exceeds max_bytes (%d bytes) for in-memory fetch" max-bytes)
+ :status http-status
+ :url final-url
+ :type mime
+ :limit (format "max_bytes=%d max_chars=%d timeout=%ss" max-bytes max-chars timeout)
+ :suggest (format "call download_url to save the file, or retry fetch_page with a larger max_bytes (up to %d)" gptel-fetch-page--max-allowed-bytes)))
+ (setq bail t))
+ (when (and (not bail) max-bytes (> actual-bytes max-bytes))
+ (funcall callback (gptel-fetch-page--format-error
+ :summary (format "Response exceeds max_bytes (%d bytes) during retrieval" max-bytes)
+ :status http-status
+ :url final-url
+ :type mime
+ :limit (format "max_bytes=%d max_chars=%d timeout=%ss" max-bytes max-chars timeout)
+ :suggest (format "call download_url to save the file, or retry fetch_page with a larger max_bytes (up to %d)" gptel-fetch-page--max-allowed-bytes)))
+ (setq bail t))
+
+ (unless bail
+ ;; Route based on content-type & requested format
+ (pcase mime
+ ((or "text/html" "application/xhtml+xml")
+ (pcase format
+ ("html"
+ (let* ((content (gptel-fetch-page--truncate body max-chars))
+ (out (if debug
+ (concat (gptel-fetch-page--format-header status-msg final-url mime actual-bytes)
+ "\n\n" content)
+ content)))
+ (funcall callback out)))
+ ("text"
+ (if (fboundp 'libxml-parse-html-region)
+ (condition-case err
+ (let* ((dom (gptel-fetch-page--html-to-dom body))
+ (txt (gptel-fetch-page--truncate (gptel-fetch-page--dom-to-text dom) max-chars))
+ (out (if debug
+ (concat (gptel-fetch-page--format-header status-msg final-url mime actual-bytes)
+ "\n\n" txt)
+ txt)))
+ (funcall callback out))
+ (error
+ (funcall callback (gptel-fetch-page--format-error
+ :summary "HTML parsing failed"
+ :status http-status
+ :url final-url
+ :type mime
+ :suggest "return html format (raw), use download_url, or install Emacs with libxml2")))
+ (funcall callback (gptel-fetch-page--format-error
+ :summary "HTML parsing requires Emacs built with libxml2"
+ :status http-status
+ :url final-url
+ :type mime
+ :suggest "return html format (raw), use download_url, or install Emacs with libxml2"))))
+ ("links"
+ (if (fboundp 'libxml-parse-html-region)
+ (condition-case err
+ (let* ((dom (gptel-fetch-page--html-to-dom body))
+ (links (gptel-fetch-page--collect-links dom final-url include-titles))
+ (content (gptel-fetch-page--truncate links max-chars))
+ (out (if debug
+ (concat (gptel-fetch-page--format-header status-msg final-url mime actual-bytes)
+ "\n\n" content)
+ content)))
+ (funcall callback out))
+ (error
+ (funcall callback (gptel-fetch-page--format-error
+ :summary "HTML parsing failed"
+ :status http-status
+ :url final-url
+ :type mime
+ :suggest "return html format (raw), use download_url, or install Emacs with libxml2")))
+ (funcall callback (gptel-fetch-page--format-error
+ :summary "HTML parsing requires Emacs built with libxml2"
+ :status http-status
+ :url final-url
+ :type mime
+ :suggest "return html format (raw), use download_url, or install Emacs with libxml2"))))))
+ ("text/plain"
+ (let* ((content (gptel-fetch-page--truncate body max-chars))
+ (out (if debug
+ (concat (gptel-fetch-page--format-header status-msg final-url mime actual-bytes)
+ "\n\n" content)
+ content)))
+ (funcall callback out)))
+ (_
+ (funcall callback (gptel-fetch-page--format-error
+ :summary (format "Non-text content not supported for parsing (%s)" mime)
+ :status http-status
+ :url final-url
+ :type mime
+ :suggest "use download_url to save the file locally, then parse with a file tool")))))))
+ (kill-buffer (current-buffer)))))
+ nil t nil))))))))
+
+;; Public entry for the gptel tool function.
+(defun gptel-fetch-page--tool (callback url &optional format timeout max_bytes max_chars include_titles user_agent accept accept_language debug)
+ (gptel-fetch-page--run callback url format timeout max_bytes max_chars include_titles user_agent accept accept_language debug))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "fetch_page"
+ :function #'gptel-fetch-page--tool
+ :description (concat
+ "Fetch a web page and return rendered text, raw HTML, or links.\n"
+ "- format=text|html|links (default text)\n"
+ "- Guards: timeout (30s), max_bytes (default 16MiB, up to 32MiB), max_chars (300k)\n"
+ "- Includes stylesheets in links mode; relative URLs resolved with <base>/final URL\n"
+ "- Non-text types error with explicit suggestion to use a downloader.")
+ :category "web"
+ :async t
+ :include t
+ :args (list
+ '(:name "url" :type string
+ :description "The http/https URL to fetch")
+ '(:name "format" :type string
+ :enum ["text" "html" "links"]
+ :optional t
+ :description "Output format: text (rendered), html (raw), or links (absolute URLs)")
+ '(:name "timeout" :type integer :optional t
+ :description "Timeout in seconds (default 30)")
+ '(:name "max_bytes" :type integer :optional t
+ :description "Max response bytes (default 16MiB, up to 32MiB)")
+ '(:name "max_chars" :type integer :optional t
+ :description "Max output chars (default 300k)")
+ '(:name "include_titles" :type boolean :optional t
+ :description "In links mode, include titles as a tab-separated second column")
+ '(:name "user_agent" :type string :optional t
+ :description "User-Agent preset or full string. Presets: desktop-chrome, desktop-firefox, desktop-safari, edge-windows, mobile-iphone, mobile-android, emacs-default")
+ '(:name "accept" :type string :optional t
+ :description "Accept header override")
+ '(:name "accept_language" :type string :optional t
+ :description "Accept-Language header override (e.g., en-US,en;q=0.9)")
+ '(:name "debug" :type boolean :optional t
+ :description "If true, prepend a compact metadata header"))))
+
+;; Automatically add to gptel-tools on load
+(add-to-list 'gptel-tools (gptel-get-tool '("web" "fetch_page")))
+
+
+(provide 'fetch_page)
+;;; fetch_page.el ends here
diff --git a/gptel-tools/list_directory_files.el b/gptel-tools/list_directory_files.el
new file mode 100644
index 00000000..8da9ba28
--- /dev/null
+++ b/gptel-tools/list_directory_files.el
@@ -0,0 +1,200 @@
+;;; list_directory_files.el --- List directory files for GPTel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Copyright (C) 2025
+
+;; Author: gptel-tool-writer
+;; Keywords: convenience, tools
+;; Version: 2.0.0
+
+;; 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 tool provides a comprehensive directory listing function for use within gptel.
+;; It lists files and directories with detailed attributes such as size, last modification time,
+;; permissions, and executable status, supporting optional recursive traversal and filtering
+;; by file extension.
+;;
+;; Features:
+;; - Lists files with Unix-style permissions, size, and modification date
+;; - Optional recursive directory traversal
+;; - Filter files by extension
+;; - Graceful error handling and reporting
+;; - Human-readable file sizes and dates
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'seq)
+(require 'subr-x)
+
+;;; Helper Functions
+
+(defun list-directory-files--mode-to-permissions (mode)
+ "Convert numeric MODE to symbolic Unix style permissions string."
+ (concat
+ (if (eq (logand #o40000 mode) #o40000) "d" "-")
+ (mapconcat
+ (lambda (bit)
+ (cond ((eq bit ?r) (if (> (logand mode #o400) 0) "r" "-"))
+ ((eq bit ?w) (if (> (logand mode #o200) 0) "w" "-"))
+ ((eq bit ?x) (if (> (logand mode #o100) 0) "x" "-"))))
+ '(?r ?w ?x) "")
+ (mapconcat
+ (lambda (bit)
+ (cond ((eq bit ?r) (if (> (logand mode #o040) 0) "r" "-"))
+ ((eq bit ?w) (if (> (logand mode #o020) 0) "w" "-"))
+ ((eq bit ?x) (if (> (logand mode #o010) 0) "x" "-"))))
+ '(?r ?w ?x) "")
+ (mapconcat
+ (lambda (bit)
+ (cond ((eq bit ?r) (if (> (logand mode #o004) 0) "r" "-"))
+ ((eq bit ?w) (if (> (logand mode #o002) 0) "w" "-"))
+ ((eq bit ?x) (if (> (logand mode #o001) 0) "x" "-"))))
+ '(?r ?w ?x) "")))
+
+(defun list-directory-files--get-file-info (filepath)
+ "Get file information for FILEPATH as a plist."
+ (condition-case err
+ (let* ((attrs (file-attributes filepath 'string))
+ (size (file-attribute-size attrs))
+ (last-mod (file-attribute-modification-time attrs))
+ (dirp (eq t (file-attribute-type attrs)))
+ (mode (file-modes filepath))
+ (perm (list-directory-files--mode-to-permissions mode))
+ (execp (file-executable-p filepath)))
+ (list :success t
+ :path filepath
+ :size size
+ :last-modified last-mod
+ :is-directory dirp
+ :permissions perm
+ :executable execp))
+ (error
+ (list :success nil
+ :path filepath
+ :error (error-message-string err)))))
+
+(defun list-directory-files--filter-by-extension (extension)
+ "Create a filter function for files with EXTENSION."
+ (when extension
+ (lambda (file-info)
+ (or (plist-get file-info :is-directory) ; Always include directories
+ (and (plist-get file-info :success)
+ (string-suffix-p (concat "." extension)
+ (file-name-nondirectory (plist-get file-info :path))
+ t))))))
+
+(defun list-directory-files--format-file-entry (file-info base-path)
+ "Format a single FILE-INFO entry relative to BASE-PATH."
+ (format " %s%s %10s %s %s"
+ (plist-get file-info :permissions)
+ (if (plist-get file-info :executable) "*" " ")
+ (file-size-human-readable (or (plist-get file-info :size) 0))
+ (format-time-string "%Y-%m-%d %H:%M" (plist-get file-info :last-modified))
+ (file-relative-name (plist-get file-info :path) base-path)))
+
+;;; Core Implementation
+
+(defun list-directory-files--list-directory (path &optional recursive filter max-depth current-depth)
+ "List files in PATH directory.
+RECURSIVE enables subdirectory traversal.
+FILTER is a predicate function for filtering files.
+MAX-DEPTH limits recursion depth (nil for unlimited).
+CURRENT-DEPTH tracks the current recursion level."
+ (let ((files '())
+ (errors '())
+ (current-depth (or current-depth 0))
+ (expanded-path (expand-file-name (or path ".") "~")))
+
+ (if (not (file-directory-p expanded-path))
+ ;; Return error if not a directory
+ (list :files nil
+ :errors (list (format "Not a directory: %s" expanded-path)))
+ ;; Process directory
+ (condition-case err
+ (dolist (entry (directory-files expanded-path t "^\\([^.]\\|\\.[^.]\\|\\.\\..\\)"))
+ (let ((info (list-directory-files--get-file-info entry)))
+ (if (plist-get info :success)
+ (progn
+ ;; Add file if it passes the filter
+ (when (or (not filter) (funcall filter info))
+ (push info files))
+ ;; Recurse into directories if needed
+ (when (and recursive
+ (plist-get info :is-directory)
+ (or (not max-depth) (< current-depth max-depth)))
+ (let ((subdir-result (list-directory-files--list-directory
+ entry recursive filter max-depth (1+ current-depth))))
+ (setq files (nconc files (plist-get subdir-result :files)))
+ (setq errors (nconc errors (plist-get subdir-result :errors))))))
+ ;; Handle file access error
+ (push (format "%s: %s" (plist-get info :path) (plist-get info :error)) errors))))
+ (error
+ (push (format "Error accessing directory %s: %s" expanded-path (error-message-string err)) errors)))
+
+ (list :files (nreverse files) :errors (nreverse errors)))))
+
+(defun list-directory-files--format-output (path result)
+ "Format the directory listing RESULT for PATH as a string."
+ (let ((files (plist-get result :files))
+ (errors (plist-get result :errors))
+ (base-path (expand-file-name "~")))
+ (concat
+ (when files
+ (format "Found %d file%s in %s:\n%s"
+ (length files)
+ (if (= (length files) 1) "" "s")
+ path
+ (mapconcat (lambda (f) (list-directory-files--format-file-entry f base-path))
+ files "\n")))
+ (when (and files errors) "\n\n")
+ (when errors
+ (format "Errors encountered:\n%s"
+ (mapconcat (lambda (e) (format " - %s" e)) errors "\n")))
+ ;; Handle case where there are no files and no errors
+ (when (and (not files) (not errors))
+ (format "No files found in %s" path)))))
+
+;;; Tool Registration
+
+(gptel-make-tool
+ :name "list_directory_files"
+ :function (lambda (path &optional recursive filter-extension)
+ "List files in directory PATH.
+RECURSIVE enables subdirectory listing.
+FILTER-EXTENSION limits results to files with the specified extension."
+ (let* ((filter (list-directory-files--filter-by-extension filter-extension))
+ (result (list-directory-files--list-directory path recursive filter)))
+ (list-directory-files--format-output (or path ".") result)))
+ :description "List files in a directory with detailed attributes. Returns formatted listing with permissions, size, modification time."
+ :args (list '(:name "path"
+ :type string
+ :description "Directory path to list (relative to home directory)")
+ '(:name "recursive"
+ :type boolean
+ :description "Recursively list subdirectories"
+ :optional t)
+ '(:name "filter-extension"
+ :type string
+ :description "Only include files with this extension"
+ :optional t))
+ :category "filesystem"
+ :confirm nil
+ :include t)
+
+;; Automatically add to gptel-tools on load
+(add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "list_directory_files")))
+
+(provide 'list_directory_files)
+;;; list_directory_files.el ends here \ No newline at end of file
diff --git a/gptel-tools/move_to_trash.el b/gptel-tools/move_to_trash.el
new file mode 100644
index 00000000..6ea97995
--- /dev/null
+++ b/gptel-tools/move_to_trash.el
@@ -0,0 +1,142 @@
+;;; move_to_trash.el --- Move files/directories to trash for gptel -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025
+
+;; Author: gptel-tool-writer
+;; Keywords: convenience, tools, files
+
+;; 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 moving files and directories to the trash.
+;; Files are moved to ~/.local/share/Trash/files with automatic timestamping for
+;; name conflicts. The tool operates only within the home directory and /tmp
+;; for security reasons.
+
+;;; Code:
+
+(require 'gptel)
+(require 'subr-x)
+
+(defun gptel--move-to-trash-generate-unique-name (original-name trash-dir)
+ "Generate a unique name for ORIGINAL-NAME in TRASH-DIR.
+If a file with the same name exists, append a timestamp in the format
+YYYY-MM-DD-HH-MM-SS."
+ (let* ((base-name (file-name-nondirectory original-name))
+ (target-path (expand-file-name base-name trash-dir)))
+ (if (not (file-exists-p target-path))
+ target-path
+ ;; Name conflict: add timestamp
+ (let* ((extension (file-name-extension base-name t))
+ (name-sans-ext (file-name-sans-extension base-name))
+ (timestamp (format-time-string "%Y-%m-%d-%H-%M-%S"))
+ (new-name (if extension
+ (concat name-sans-ext "-" timestamp extension)
+ (concat base-name "-" timestamp))))
+ (expand-file-name new-name trash-dir)))))
+
+(defun gptel--move-to-trash-validate-path (path)
+ "Validate that PATH is safe to trash.
+Returns the expanded path if valid, signals an error otherwise.
+Ensures path is within home directory or /tmp, and prevents
+trashing of critical system directories."
+ (let ((expanded-path (expand-file-name path))
+ (home-dir (expand-file-name "~"))
+ (critical-dirs (list (expand-file-name "~")
+ (expand-file-name "~/.emacs.d")
+ (expand-file-name "~/.config")
+ "/tmp")))
+ ;; Security check: must be within allowed directories
+ (unless (or (string-prefix-p home-dir expanded-path)
+ (string-prefix-p "/tmp" expanded-path))
+ (error "Path must be within home directory or /tmp: %s" path))
+
+ ;; Prevent trashing critical directories
+ (when (member expanded-path critical-dirs)
+ (error "Cannot trash critical directory: %s" path))
+
+ ;; Existence check
+ (unless (file-exists-p expanded-path)
+ (error "File or directory does not exist: %s" path))
+
+ expanded-path))
+
+(defun gptel--move-to-trash-perform (expanded-path trash-dir)
+ "Move EXPANDED-PATH to TRASH-DIR with unique naming.
+Returns a formatted message describing the operation."
+ (let* ((is-directory (file-directory-p expanded-path))
+ (is-symlink (file-symlink-p expanded-path))
+ (trash-path (gptel--move-to-trash-generate-unique-name
+ expanded-path trash-dir))
+ (item-type (cond
+ (is-symlink "Symlink")
+ (is-directory "Directory")
+ (t "File"))))
+
+ ;; Perform the move
+ (condition-case move-err
+ (progn
+ (rename-file expanded-path trash-path)
+
+ ;; Verify success
+ (cond
+ ((file-exists-p expanded-path)
+ (error "Failed to move %s to trash - file still exists at original location"
+ expanded-path))
+ ((not (file-exists-p trash-path))
+ (error "Move operation failed - file not found in trash"))
+ (t
+ (format "%s moved to trash: %s → %s"
+ item-type
+ (abbreviate-file-name expanded-path)
+ (file-name-nondirectory trash-path)))))
+ (permission-denied
+ (error "Permission denied: cannot move %s to trash" expanded-path))
+ (error
+ (error "Failed to move %s to trash: %s"
+ expanded-path (error-message-string move-err))))))
+
+;; Main tool definition
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "move_to_trash"
+ :function (lambda (path)
+ "Move PATH to the trash directory.
+Creates the trash directory if needed, handles naming conflicts,
+and provides detailed error messages."
+ (condition-case err
+ (let* ((trash-dir (expand-file-name "~/.local/share/Trash/files"))
+ (expanded-path (gptel--move-to-trash-validate-path path)))
+
+ ;; Ensure trash directory exists
+ (unless (file-exists-p trash-dir)
+ (make-directory trash-dir t))
+
+ ;; Move and return status message
+ (gptel--move-to-trash-perform expanded-path trash-dir))
+ (error
+ (error "Tool error: %s" (error-message-string err)))))
+ :description "Move a file or directory to the trash (~/.local/share/Trash/files). Works recursively for directories. Handles name conflicts with timestamps. Operates only within home directory and /tmp. Does not follow symlinks. Synonyms: delete, remove, trash file/directory."
+ :args (list '(:name "path"
+ :type string
+ :description "Path to the file or directory to move to trash. Must be within home directory or /tmp."))
+ :category "filesystem"
+ :confirm nil ; No confirmation needed
+ :include t))
+
+;; Automatically add to gptel-tools on load
+(add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "move_to_trash")))
+
+(provide 'move_to_trash)
+;;; move_to_trash.el ends here
diff --git a/gptel-tools/read_buffer.el b/gptel-tools/read_buffer.el
new file mode 100644
index 00000000..d01cee71
--- /dev/null
+++ b/gptel-tools/read_buffer.el
@@ -0,0 +1,27 @@
+;;; read_buffer.el --- Read buffer tool for GPTel -*- coding: utf-8; lexical-binding: t; -*-
+
+;;; Commentary:
+;;
+
+;;; Code:
+
+(require 'gptel)
+
+(gptel-make-tool
+ :name "read_buffer"
+ :function (lambda (buffer)
+ (unless (buffer-live-p (get-buffer buffer))
+ (error "Error: buffer %s is not live" buffer))
+ (with-current-buffer buffer
+ (buffer-substring-no-properties (point-min) (point-max))))
+ :description "return the contents of an emacs buffer"
+ :args (list '(:name "buffer"
+ :type string ; :type value must be a symbol
+ :description "the name of the buffer whose contents are to be retrieved"))
+ :category "emacs")
+
+;; Automatically add to gptel-tools on load
+(add-to-list 'gptel-tools (gptel-get-tool '("emacs" "read_buffer")))
+
+(provide 'read_buffer)
+;;; read_buffer.el ends here.
diff --git a/gptel-tools/read_text_file.el b/gptel-tools/read_text_file.el
new file mode 100644
index 00000000..8e0433a9
--- /dev/null
+++ b/gptel-tools/read_text_file.el
@@ -0,0 +1,144 @@
+;;; read_text_file.el --- Read text files for GPTel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Copyright (C) 2025
+
+;; Author: gptel-tool-writer
+;; Keywords: convenience, tools
+;; Package-Requires: ((emacs "27.1") (gptel "0.9.0"))
+
+;; 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:
+
+;;; Code:
+
+;; Helper functions for read_text_file tool
+(defun cj/validate-file-path (path)
+ "Validate PATH is within home directory and exists."
+ (let ((full-path (expand-file-name 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))
+ (when (file-directory-p full-path)
+ (error "Path is a directory, not a file: %s" full-path))
+ (unless (file-readable-p full-path)
+ (error "No read permission for file: %s" full-path))
+ ;; Follow symlinks
+ (if (file-symlink-p full-path)
+ (file-truename full-path)
+ full-path)))
+
+(defun cj/get-file-metadata (path)
+ "Return formatted metadata string for file at PATH."
+ (let* ((attributes (file-attributes path))
+ (size (file-attribute-size attributes))
+ (modes (file-attribute-modes attributes))
+ (modtime (format-time-string "%Y-%m-%d"
+ (file-attribute-modification-time attributes))))
+ (list :size size
+ :string (format "File: %s (%s, %s, modified %s)"
+ path modes
+ (file-size-human-readable size)
+ modtime))))
+
+(defun cj/check-file-size-limits (size no-confirm)
+ "Check file SIZE against limits, prompting user unless NO-CONFIRM."
+ (let ((size-warning-limit (* 10 1024 1024)) ; 10MB
+ (size-hard-limit (* 100 1024 1024))) ; 100MB
+ (when (> size size-hard-limit)
+ (error "File too large (%s): exceeds 100MB limit"
+ (file-size-human-readable size)))
+ (when (and (> size size-warning-limit)
+ (not no-confirm))
+ (unless (y-or-n-p (format "File is large (%s). Continue? "
+ (file-size-human-readable size)))
+ (error "File read cancelled: size exceeds 10MB")))))
+
+(defun cj/detect-binary-file (path)
+ "Check if file at PATH appears to be binary."
+ (with-temp-buffer
+ (insert-file-contents path nil 0 1024)
+ (goto-char (point-min))
+ (search-forward "\0" nil t)))
+
+(defun cj/handle-special-file-types (path no-confirm)
+ "Handle PDF, EPUB, and other binary files at PATH."
+ (cond
+ ((string-match-p "\\.pdf\\'" path)
+ (when (and (not no-confirm)
+ (not (y-or-n-p "This is a PDF file. Extract text for LLM (y) or cancel (n)? ")))
+ (error "PDF file read cancelled"))
+ ;; Extract text from PDF
+ (let ((text (shell-command-to-string
+ (format "pdftotext '%s' -" path))))
+ (if (string-empty-p text)
+ (error "Could not extract text from PDF: %s" path)
+ text)))
+ ((string-match-p "\\.epub\\'" path)
+ (when (and (not no-confirm)
+ (not (y-or-n-p "This is an EPUB file. Extract text for LLM (y) or cancel (n)? ")))
+ (error "EPUB file read cancelled"))
+ (error "EPUB text extraction not yet implemented"))
+ (t
+ (when (and (not no-confirm)
+ (not (y-or-n-p "This appears to be a binary file. Read anyway? ")))
+ (error "Binary file read cancelled"))
+ nil))) ; Return nil to indicate normal read
+
+;; Main tool function using the helpers
+(gptel-make-tool
+ :name "read_text_file"
+ :function (lambda (path &optional no-confirm)
+ (let* ((full-path (cj/validate-file-path path))
+ (metadata (cj/get-file-metadata full-path))
+ (size (plist-get metadata :size))
+ (metadata-string (plist-get metadata :string)))
+ ;; Show metadata and confirm
+ (unless no-confirm
+ (unless (y-or-n-p (format "%s\nRead this file? " metadata-string))
+ (error "File read cancelled by user")))
+ ;; Check size limits
+ (cj/check-file-size-limits size no-confirm)
+ ;; Handle binary/special files
+ (let ((content
+ (if (cj/detect-binary-file full-path)
+ (or (cj/handle-special-file-types full-path no-confirm)
+ ;; If not a special type or user wants to read anyway
+ (with-temp-buffer
+ (insert-file-contents full-path)
+ (buffer-string)))
+ ;; Normal text file
+ (with-temp-buffer
+ (insert-file-contents full-path)
+ (buffer-string)))))
+ (format "Read %d bytes from %s\n\n%s"
+ (length content) full-path content))))
+ :description "Read text content from a file within the user's home directory. Shows file metadata and requests confirmation before reading. Handles large files, binary detection, and PDF text extraction."
+ :args (list '(:name "path"
+ :type string
+ :description "File path relative to home directory, e.g., 'documents/myfile.txt' or '~/documents/myfile.txt'")
+ '(:name "no_confirm"
+ :type boolean
+ :description "If true, skip confirmation prompts and read immediately"
+ :optional t))
+ :category "filesystem"
+ :confirm t
+ :include t)
+
+;; Automatically add to gptel-tools on load
+(add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "read_text_file")))
+
+
+(provide 'read_text_file)
+;;; read_text_file.el ends here.
diff --git a/gptel-tools/update_text_file.el b/gptel-tools/update_text_file.el
new file mode 100644
index 00000000..0125e2ab
--- /dev/null
+++ b/gptel-tools/update_text_file.el
@@ -0,0 +1,149 @@
+;;; update_text_file.el --- Update text files for gptel -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025
+
+;; Author: gptel-tool-writer
+;; 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.
+
+;;; Code:
+
+(require 'gptel)
+(require 'subr-x)
+
+;; 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."
+ (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
+(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."
+ :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))
+ :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")))
+
+
+(provide 'update_text_file)
+;;; update_text_file.el ends here"
diff --git a/gptel-tools/write_text_file.el b/gptel-tools/write_text_file.el
new file mode 100644
index 00000000..03d64e57
--- /dev/null
+++ b/gptel-tools/write_text_file.el
@@ -0,0 +1,94 @@
+;;; write_text_file.el --- Write text files for gptel -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025
+
+;; Author: gptel-tool-writer
+;; Keywords: convenience, tools
+;; Package-Requires: ((emacs "27.1") (gptel "0.9.0"))
+
+;; 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 writing text files to the filesystem.
+;; The tool includes safety features like backup creation, size limits,
+;; and restriction to the user's home directory.
+
+;;; Code:
+
+(require 'gptel)
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "write_text_file"
+ :function (lambda (path content &optional overwrite)
+ (let* ((full-path (expand-file-name path "~"))
+ (content (or content ""))
+ (content-size (length content))
+ (size-limit (* 1024 1024 1024))) ; 1 GB
+ ;; Check if path is within home directory
+ (unless (string-prefix-p (expand-file-name "~") full-path)
+ (error "Path must be within home directory"))
+ ;; Check size limit
+ (when (> content-size size-limit)
+ (unless (y-or-n-p (format "File is %s. Write anyway? "
+ (file-size-human-readable content-size)))
+ (error "File write cancelled: size exceeds 1GB limit")))
+ ;; Check write permission on parent directory
+ (let ((parent-dir (file-name-directory full-path)))
+ (when parent-dir
+ ;; Create parent directories if needed
+ (unless (file-exists-p parent-dir)
+ (condition-case err
+ (make-directory parent-dir t)
+ (error (error "Cannot create directory %s: %s"
+ parent-dir (error-message-string err)))))
+ ;; Check write permission
+ (unless (file-writable-p parent-dir)
+ (error "No write permission for directory %s" parent-dir))))
+ ;; Handle existing file
+ (when (file-exists-p full-path)
+ (if overwrite
+ ;; Create backup with timestamp
+ (let* ((backup-name
+ (format "%s-%s.bak"
+ full-path
+ (format-time-string "%Y-%m-%d-%H%M%S"))))
+ (copy-file full-path backup-name t)
+ (message "Backed up existing file to %s" backup-name))
+ (error "File %s already exists. Set overwrite to true to replace it" full-path)))
+ ;; Write the file atomically
+ (with-temp-file full-path
+ (insert content))
+ (format "Successfully wrote %d bytes to %s"
+ content-size full-path)))
+ :description "Write text content to a file within the user's home directory. Creates parent directories if needed. Backs up existing files with timestamp when overwriting."
+ :args (list '(:name "path"
+ :type string
+ :description "File path relative to home directory, e.g., 'documents/myfile.txt' or '~/documents/myfile.txt'")
+ '(:name "content"
+ :type string
+ :description "The text content to write to the file")
+ '(:name "overwrite"
+ :type boolean
+ :description "If true, backup and overwrite existing file. If false or omitted, error if file exists"
+ :optional t))
+ :category "filesystem"
+ :confirm t
+ :include t)
+
+ ;; Automatically add to gptel-tools on load
+ (add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "write_text_file"))))
+
+(provide 'write_text_file)
+;;; write_text_file.el ends here \ No newline at end of file