aboutsummaryrefslogtreecommitdiff
path: root/archive
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
committerCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
commite41c25068d0cec9434895a6d3e3a25d3a26f645f (patch)
tree5e30938a3fd6d80f501ffe3e6c1c187c5ddeb2c9 /archive
parenta936e081b7270fbd4f1e7e9cb67ca1d4c2291ce6 (diff)
downloaddotemacs-e41c25068d0cec9434895a6d3e3a25d3a26f645f.tar.gz
dotemacs-e41c25068d0cec9434895a6d3e3a25d3a26f645f.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')
-rw-r--r--archive/gptel/custom/gptel-prompts.el418
-rw-r--r--archive/gptel/docs-specs/gptel-gh-tool-spec.org1065
-rw-r--r--archive/gptel/docs-specs/gptel-git-tools-magit-backend-spec.org196
-rw-r--r--archive/gptel/docs-specs/gptel-network-tools-spec.org411
-rw-r--r--archive/gptel/docs-specs/mcp-el-gptel-integration-spec-doing.org1438
-rw-r--r--archive/gptel/gptel-tools/git_diff.el110
-rw-r--r--archive/gptel/gptel-tools/git_log.el100
-rw-r--r--archive/gptel/gptel-tools/git_status.el85
-rw-r--r--archive/gptel/gptel-tools/list_directory_files.el200
-rw-r--r--archive/gptel/gptel-tools/move_to_trash.el149
-rw-r--r--archive/gptel/gptel-tools/read_buffer.el33
-rw-r--r--archive/gptel/gptel-tools/read_text_file.el146
-rw-r--r--archive/gptel/gptel-tools/update_text_file.el235
-rw-r--r--archive/gptel/gptel-tools/web_fetch.el150
-rw-r--r--archive/gptel/gptel-tools/write_text_file.el107
-rw-r--r--archive/gptel/modules/ai-config.el585
-rw-r--r--archive/gptel/modules/ai-conversations-browser.el241
-rw-r--r--archive/gptel/modules/ai-conversations.el369
-rw-r--r--archive/gptel/modules/ai-mcp.el416
-rw-r--r--archive/gptel/modules/ai-quick-ask.el141
-rw-r--r--archive/gptel/modules/ai-rewrite.el108
-rw-r--r--archive/gptel/tests/test-ai-config--apply-model-selection.el45
-rw-r--r--archive/gptel/tests/test-ai-config-auth-source-secret.el27
-rw-r--r--archive/gptel/tests/test-ai-config-backend-and-model.el78
-rw-r--r--archive/gptel/tests/test-ai-config-build-model-list.el101
-rw-r--r--archive/gptel/tests/test-ai-config-commands.el160
-rw-r--r--archive/gptel/tests/test-ai-config-current-model-selection.el74
-rw-r--r--archive/gptel/tests/test-ai-config-fresh-org-prefix.el65
-rw-r--r--archive/gptel/tests/test-ai-config-gptel-backend-libs.el58
-rw-r--r--archive/gptel/tests/test-ai-config-gptel-commands.el155
-rw-r--r--archive/gptel/tests/test-ai-config-gptel-local-tools.el57
-rw-r--r--archive/gptel/tests/test-ai-config-gptel-magit-lazy-loading.el151
-rw-r--r--archive/gptel/tests/test-ai-config-helpers.el183
-rw-r--r--archive/gptel/tests/test-ai-config-model-to-string.el60
-rw-r--r--archive/gptel/tests/test-ai-config-model-to-symbol.el61
-rw-r--r--archive/gptel/tests/test-ai-conversations-browser.el244
-rw-r--r--archive/gptel/tests/test-ai-conversations.el564
-rw-r--r--archive/gptel/tests/test-ai-mcp-helpers.el419
-rw-r--r--archive/gptel/tests/test-ai-quick-ask.el149
-rw-r--r--archive/gptel/tests/test-ai-rewrite.el159
-rw-r--r--archive/gptel/tests/test-gptel-tools-git-diff.el163
-rw-r--r--archive/gptel/tests/test-gptel-tools-git-log.el183
-rw-r--r--archive/gptel/tests/test-gptel-tools-git-status.el124
-rw-r--r--archive/gptel/tests/test-gptel-tools-list-directory-files.el257
-rw-r--r--archive/gptel/tests/test-gptel-tools-move-to-trash.el219
-rw-r--r--archive/gptel/tests/test-gptel-tools-read-buffer.el74
-rw-r--r--archive/gptel/tests/test-gptel-tools-read-text-file.el201
-rw-r--r--archive/gptel/tests/test-gptel-tools-web-fetch.el230
-rw-r--r--archive/gptel/tests/test-gptel-tools-write-text-file.el223
-rw-r--r--archive/gptel/tests/test-update-text-file.el473
-rw-r--r--archive/gptel/tests/testutil-ai-config.el81
-rw-r--r--archive/gptel/tests/testutil-filesystem.el180
52 files changed, 11921 insertions, 0 deletions
diff --git a/archive/gptel/custom/gptel-prompts.el b/archive/gptel/custom/gptel-prompts.el
new file mode 100644
index 000000000..a2b266f27
--- /dev/null
+++ b/archive/gptel/custom/gptel-prompts.el
@@ -0,0 +1,418 @@
+;;; gptel-prompts.el --- GPTel directive management using files -*- lexical-binding: t -*-
+
+;; Copyright (C) 2025 John Wiegley
+
+;; Author: John Wiegley <johnw@gnu.org>
+;; Created: 19 May 2025
+;; Version: 1.0
+;; Keywords: ai gptel prompts
+;; X-URL: https://github.com/jwiegley/dot-emacs
+;; Package-Requires: ((emacs "24.1"))
+
+;; This file is NOT part of GNU Emacs.
+
+;;; License:
+
+;; 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 2, 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 GNU Emacs; see the file COPYING. If not, write to the
+;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
+;; Boston, MA 02111-1307, USA.
+
+;;; Commentary:
+
+;; This package provides enhanced prompt management capabilities for GPTel,
+;; allowing you to organize and dynamically load AI prompts from external
+;; files rather than hardcoding them in your Emacs configuration.
+
+;; Key Features:
+;;
+;; * Multi-format prompt support: Load prompts from .txt, .md, .org, .json,
+;; .eld (Emacs Lisp data), .el (Emacs Lisp functions), and .poet/.jinja
+;; (Prompt Poet/Jinja2 templates)
+;;
+;; * Template interpolation: Use Jinja2-style {{variable}} syntax with
+;; customizable variables and dynamic functions
+;;
+;; * File watching: Automatically reload prompts when files change
+;;
+;; * Project-aware prompts: Automatically load project-specific conventions
+;; from CONVENTIONS.md or CLAUDE.md files
+;;
+;; * Conversation format support: Handle multi-turn conversations with
+;; system/user/assistant roles
+
+;; Setup:
+;;
+;; (use-package gptel-prompts
+;; :after (gptel)
+;; :custom
+;; (gptel-prompts-directory "~/my-prompts")
+;; :config
+;; (gptel-prompts-update)
+;; ;; Optional: auto-reload on file changes
+;; (gptel-prompts-add-update-watchers))
+
+;; File Formats:
+;;
+;; * Plain text (.txt, .md, .org): Used as-is for system prompts
+;; * JSON (.json): Array of {role: "system/user/assistant", content: "..."}
+;; * Emacs Lisp data (.eld): List format for conversations
+;; * Emacs Lisp code (.el): Lambda functions for dynamic prompts
+;; * Prompt Poet (.poet, .j2, .jinja, .jinja2): YAML + Jinja2 templates
+
+;; Template Variables:
+;;
+;; Use {{variable_name}} in your prompts. Variables can be defined in
+;; `gptel-prompts-template-variables' or generated dynamically by functions
+;; in `gptel-prompts-template-functions'.
+
+;; Project Integration:
+;;
+;; Add `gptel-prompts-project-conventions' to `gptel-directives' to
+;; automatically load project-specific prompts from CONVENTIONS.md or
+;; CLAUDE.md files in your project root.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'cl-macs)
+(require 'rx)
+(require 'filenotify)
+(require 'gptel)
+
+(defgroup gptel-prompts nil
+ "Helper library for managing GPTel prompts (aka directives)."
+ :group 'gptel)
+
+(defcustom gptel-prompts-directory "~/.emacs.d/prompts"
+ "*Directory where GPTel prompts are defined, one per file.
+
+Note that files can be of different types, which will cause them to be
+represented as directives differently. See `gptel-prompts-file-regexp'
+for more information."
+ :type 'file
+ :group 'gptel-prompts)
+
+(defcustom gptel-prompts-file-regexp
+ (rx "." (group
+ (or "txt"
+ "md"
+ "org"
+ "eld"
+ "el"
+ (seq "j" (optional "inja") (optional "2"))
+ "poet"
+ "json"))
+ string-end)
+ "*Directory where GPTel prompts are defined, one per file.
+
+Note that files can be of different types, which will cause them
+to be represented as directives differently:
+
+ .txt, .md, .org Purely textual prompts that are used as-is
+ .eld Must be a Lisp list represent a conversation:
+ SYSTEM, USER, ASSISTANT, [USER, ASSISTANT, ...]
+ .el Must evaluate to a Lisp function
+ .poet See https://github.com/character-ai/prompt-poet
+ .json JSON list of role-assigned prompts"
+ :type 'regexp
+ :group 'gptel-prompts)
+
+(defcustom gptel-prompts-template-variables nil
+ "*An alist of names to strings used during template expansion.
+
+Example:
+ ((\"name\" . \"John\")
+ (\"hobbies\" . \"Emacs\"))
+
+These would referred to using {{ name }} and {{ hobbies }} in the
+prompt template."
+ :type '(alist :key-type string :value-type string)
+ :group 'gptel-prompts)
+
+(defcustom gptel-prompts-template-functions
+ '(gptel-prompts-add-current-time)
+ "*Set of functions run when a template prompt is used.
+
+These are called when the template is going to be used by
+`gptel-request'. Each function receives the name of the template file,
+and must return either nil or an alist of variable values to prepend to
+`gptel-prompts-template-variables'. See that variable's documentation
+for the expected format."
+ :type '(list function)
+ :group 'gptel-prompts)
+
+(defun gptel-prompts-process-prompts (prompts)
+ "Convert from a list of PROMPTS in dialog format, to GPTel.
+
+For example:
+
+ (((role . \"system\")
+ (content . \"Sample\")
+ (name . \"system instructions\"))
+ ((role . \"system\")
+ (content . \"Sample\")
+ (name . \"further system instructions\"))
+ ((role . \"user\")
+ (content . \"Sample\")
+ (name . \"User message\"))
+ ((role . \"assistant\")
+ (content . \"Sample\")
+ (name . \"Model response\"))
+ ((role . \"user\")
+ (content . \"Sample\")
+ (name . \"Second user message\")))
+
+Becomes:
+
+ (\"system instructions\nfurther system instructions\"
+ (prompt \"User message\")
+ (response \"Model response\")
+ (prompt \"Second user message\"))"
+ (let ((system "") result)
+ (dolist (prompt prompts)
+ (let ((content (alist-get 'content prompt))
+ (role (alist-get 'role prompt)))
+ (cond
+ ((string= role "system")
+ (setq system (if (string-empty-p system)
+ content
+ (concat system "\n" content))))
+ ((string= role "user")
+ (setq result (cons (list 'prompt content) result)))
+ ((string= role "assistant")
+ (setq result (cons (list 'response content) result)))
+ ((string= role "tool")
+ (error "Tools not yet supported in Poet prompts"))
+ (t
+ (error "Role not recognized in prompt: %s"
+ (pp-to-string prompt))))))
+ (cons system (nreverse result))))
+
+(defun gptel-prompts-interpolate (prompt &optional file)
+ "Expand Jinja-style references to `gptel-prompts-template-variables'.
+The references are expected in the string PROMPT, possibly from FILE.
+`gptel-prompts-template-functions' are called to add to this list as
+well, so some variables can be dynamic in nature."
+ (require 'templatel)
+ (let ((vars (apply #'append
+ (mapcar #'(lambda (f) (funcall f file))
+ gptel-prompts-template-functions))))
+ (templatel-render-string
+ prompt
+ (cl-remove-duplicates
+ (append vars gptel-prompts-template-variables)
+ :test #'string= :from-end t :key #'car))))
+
+(defun gptel-prompts-interpolate-buffer ()
+ "Expand Jinja-style references to `gptel-prompts-template-variables'.
+See `gptel-prompts-interpolate'.
+This function can be added to `gptel-prompt-transform-functions'."
+ (let ((replacement (gptel-prompts-interpolate (buffer-string))))
+ (delete-region (point-min) (point-max))
+ (insert replacement)))
+
+(defun gptel-prompts-poet (file)
+ "Read Yaml + Jinja FILE in prompt-poet format."
+ (require 'yaml)
+ (gptel-prompts-process-prompts
+ (mapcar #'yaml--hash-table-to-alist
+ (yaml-parse-string
+ (gptel-prompts-interpolate
+ (with-temp-buffer
+ (insert-file-contents file)
+ (buffer-string))
+ file)))))
+
+(defun gptel-prompts-process-file (file)
+ "Process FILE and return appropriate content.
+
+FILE is a string path to the file to be processed.
+
+Handles different file types based on extension:
+- .eld files: Read as Emacs Lisp data, must evaluate to a list
+- .el files: Read as Emacs Lisp code, must evaluate to a function/lambda
+- .json files: Parse as JSON array and process as prompts via
+ `gptel-prompts-process-prompts'
+- .j2/.jinja/.jinja2/.poet files: Return lambda that calls
+ `gptel-prompts-poet' with FILE
+- Other files: Return trimmed file contents as plain text string
+
+Returns the processed content in the appropriate format for each file
+type. Signals an error if the file content doesn't match expected format
+for typed files."
+ (cond ((string-match "\\.eld\\'" file)
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (let ((lst (read (current-buffer))))
+ (if (listp lst)
+ lst
+ (error "Emacs Lisp data prompts must evaluate to a list")))))
+ ((string-match "\\.el\\'" file)
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (let ((func (read (current-buffer))))
+ (if (and (functionp func)
+ (listp func)
+ (eq 'lambda (car func)))
+ func
+ (error "Emacs Lisp prompts must evaluate to a function/lambda")))))
+ ((string-match "\\.json\\'" file)
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (let ((conversation (json-read)))
+ (if (vectorp conversation)
+ (gptel-prompts-process-prompts (seq-into conversation 'list))
+ (error "Emacs Lisp prompts must evaluate to a list")))))
+ ((string-match "\\.\\(j\\(inja\\)?2?\\|poet\\)\\'" file)
+ `(lambda () (gptel-prompts-poet ,file)))
+ (t
+ (with-temp-buffer
+ (insert-file-contents file)
+ (string-trim (buffer-string))))))
+
+(defun gptel-prompts-read-directory (dir)
+ "Read prompts from directory DIR and establish them in `gptel-directives'."
+ (cl-loop for file in (directory-files dir t gptel-prompts-file-regexp)
+ collect (cons (intern (file-name-sans-extension
+ (file-name-nondirectory file)))
+ (gptel-prompts-process-file file))))
+
+(defun gptel-prompts-update ()
+ "Update `gptel-directives' from files in `gptel-prompts-directory'."
+ (interactive)
+ (dolist (prompt (gptel-prompts-read-directory gptel-prompts-directory))
+ (setq gptel-directives
+ (cl-delete-if #'(lambda (x) (eq (car x) (car prompt)))
+ gptel-directives))
+ (add-to-list 'gptel-directives prompt)))
+
+(defun gptel-prompts-add-current-time (_file)
+ "Add the current time as a variable for Poet interpolation."
+ `(("current_time" . ,(format-time-string "%F %T"))))
+
+(defun gptel-prompts-add-update-watchers ()
+ "Watch all files in DIR and run CALLBACK when any is modified."
+ (let ((watches (list (file-notify-add-watch
+ gptel-prompts-directory '(change)
+ #'(lambda (&rest _events)
+ (gptel-prompts-update))))))
+ (dolist (file (directory-files gptel-prompts-directory
+ t gptel-prompts-file-regexp))
+ (when (file-regular-p file)
+ (push (file-notify-add-watch file '(change)
+ #'(lambda (&rest _events)
+ (gptel-prompts-update)))
+ watches)))
+ watches))
+
+(defvar gptel-prompts--project-conventions-alist nil
+ "Alist mapping projects to project conventions for LLMs.")
+
+(defcustom gptel-prompts-project-files
+ '("CONVENTIONS.md"
+ "CLAUDE.md"
+ "AGENTS.md"
+ (".github" . "copilot-instructions\\.md")
+ (".instructions.d" . "^.*\\.md$")
+ ".instructions.md")
+ "A list of files or directories with prompts for the current project.
+Entries can be strings (file/directory names) or cons cells where the
+CAR is a directory path and the CDR is either a regexp string or a
+filter function for selecting which files in that directory should be
+chosen.
+
+The first matching rule in the list for a given project is used, with
+the rest ignored.
+
+If a directory is specified without a filter (as a plain string), all
+markdown files within it will be aggregated into a single prompt."
+ :type '(repeat (choice file directory
+ (cons directory (choice regexp function))))
+ :group 'gptel-prompts)
+
+(defun gptel-prompts--read-directory-filtered (dir regexp-or-function)
+ "Read files from DIR for which REGEXP-OR-FUNCTION is a match."
+ (when (and (file-directory-p dir)
+ (file-readable-p dir))
+ (let ((files
+ (cl-remove-if-not
+ (cond
+ ((functionp regexp-or-function)
+ (lambda (f)
+ (funcall regexp-or-function (file-name-nondirectory f))))
+ ((stringp regexp-or-function)
+ (lambda (f)
+ (string-match-p regexp-or-function (file-name-nondirectory f))))
+ (t (error "Invalid filter: %s" regexp-or-function)))
+ (directory-files dir t "^[^.].*" t))))
+ (unless (null files)
+ (mapconcat
+ (lambda (file)
+ (when (and (file-regular-p file)
+ (file-readable-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (buffer-string))))
+ files "\n\n")))))
+
+(defun gptel-prompts--read-directory (dir)
+ "Read all Markdown files from DIR, concated together."
+ (let ((contents
+ (mapconcat
+ (lambda (file)
+ (when (and (file-regular-p file)
+ (file-readable-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (buffer-string))))
+ (directory-files dir t "^[^.].*\\.md$" t)
+ "\n\n")))
+ (unless (string-empty-p contents)
+ contents)))
+
+(defun gptel-prompts-project-conventions ()
+ "System prompt is obtained from project CONVENTIONS.
+This function should be added to `gptel-directives'. To replace
+the default directive, use:
+
+ (setf (alist-get \\'default gptel-directives)
+ #\\'gptel-project-conventions)"
+ (when-let* ((project (project-current))
+ (root (project-root project)))
+ (with-memoization
+ (alist-get root gptel-prompts--project-conventions-alist
+ nil nil #'equal)
+ (or (cl-loop
+ for item in gptel-prompts-project-files
+ for path = (expand-file-name
+ (if (consp item) (car item) item)
+ root)
+ when (file-readable-p path)
+ return (cond
+ ((consp item)
+ (gptel-prompts--read-directory-filtered (car item) (cdr item)))
+ ((file-directory-p path)
+ (gptel-prompts--read-directory path))
+ (t
+ (with-temp-buffer
+ (insert-file-contents path)
+ (buffer-string)))))
+ "You are a helpful assistant. Respond concisely."))))
+
+(provide 'gptel-prompts)
+
+;;; gptel-prompts.el ends here
diff --git a/archive/gptel/docs-specs/gptel-gh-tool-spec.org b/archive/gptel/docs-specs/gptel-gh-tool-spec.org
new file mode 100644
index 000000000..80ecc0ab6
--- /dev/null
+++ b/archive/gptel/docs-specs/gptel-gh-tool-spec.org
@@ -0,0 +1,1065 @@
+:PROPERTIES:
+:ID: a124dd0f-1f40-4533-aeb8-595d93e20865
+:STATUS: not-started
+:END:
+#+TITLE: Design: Wrap the gh CLI as a GPTel tool
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-16
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft (revision 2). Pre-implementation; no code shipped yet. =gh=
+v2.92.0 installed and authenticated against both =github.com= (account
+=cjennings=) and =deepsat.ghe.com= (account =craig-jennings=).
+
+Revision 2 incorporates a code-review pass that caught several
+incorrect =gh= CLI assumptions and a critical safety gap: with
+=gptel-confirm-tool-calls= set to =nil= in =modules/ai-config.el:386=,
+the "irreversible-only blocklist" V1 from revision 1 would let the
+agent run unconfirmed writes (=pr merge=, =run cancel=, =api -f
+body=...=, =release create=, etc.). Revision 2 keeps Craig's intent
+of a general-capability tool but applies =:confirm t= to every
+classified write, drops the "scan command string for =--hostname=
+and =-H=" approach in favor of argv-list builders, and expands the
+wrapper set so most agent workflows don't need to fall back to the
+general tool.
+
+* Problem
+
+GPTel agents can read git history locally (=git_log=, =git_diff=,
+=git_status=) but have no access to the GitHub side of the workflow:
+PRs, issues, reviews, CI runs, releases, gists, repo metadata. The
+=gh= CLI does all of this and is already authenticated against both
+hosts the user works with. Wrapping it as a GPTel tool gives the
+agent the same GitHub surface the user has.
+
+The wrapper has to handle four complications the local git tools
+don't:
+
+1. *Two authenticated hosts* -- personal (=github.com=) and work
+ GHE (=deepsat.ghe.com=) -- with different policies and different
+ blast radii for destructive operations.
+2. *A vast subcommand surface* (~30 top-level subcommands, hundreds
+ of leaves) that doesn't fit cleanly into one typed schema per
+ leaf.
+3. *Inconsistent flag semantics across subcommands.* =--hostname=
+ exists only on =gh api= and =gh auth status=; the rest use
+ =--repo [HOST/]OWNER/REPO=. =-H= means =--head= on =gh pr list=
+ but =--header= on =gh api=. Naive string-scanning to detect
+ user intent is wrong.
+4. *Global GPTel setting* (=gptel-confirm-tool-calls nil=) means
+ the agent can call any registered tool without user confirmation
+ unless that specific tool is registered with =:confirm t=. V1
+ safety must come from per-tool flags, not from a session-level
+ gate.
+
+* Goals
+
+1. The agent can invoke the non-interactive subset of =gh= against
+ either host, gated by a safety policy.
+2. High-frequency reads have typed wrappers with sensible defaults
+ (host/repo resolution, JSON-by-default with minimal fields,
+ output cap, timeout). Wrappers auto-execute.
+3. The active host is resolved from a single context object that
+ considers (in order): explicit =hostname=/=repo= args, branch
+ upstream remote, =origin= remote, =cj/gh-default-host=. Every
+ tool response prefixes the resolved host/repo so cross-host
+ mistakes are visible.
+4. Writes via the general tool execute only after user
+ confirmation (=:confirm t= on the gptel tool registration).
+ Unknown classifications fail closed (=:confirm t=).
+ Irreversible commands hard-block.
+5. Interactive commands (=auth login=, =codespace ssh=, =--web=,
+ =browse=, =pr checkout=, =repo clone=, =config set=,
+ =alias set=, =extension exec=) are hard-blocked in V1.
+6. File exfiltration paths (=api --input=, =api -F key=@file=,
+ =release upload=, =gist create=) are hard-blocked in V1.
+7. Output is capped *during capture*, not after, so a runaway
+ =--paginate= or =--log= can't fill memory or block Emacs.
+ Long-output commands have higher timeouts but the same byte
+ cap.
+8. Every call produces a structured debug record (host, repo, cwd,
+ sanitized argv, classification, policy decision, exit code,
+ duration, bytes captured, truncation flag, error kind) inspectable
+ via =cj/gh-tool-last-error=.
+9. =cj/gh-doctor= diagnoses missing prerequisites (=gh=
+ executable, version floor, auth per host, cwd repo detection,
+ environment overrides) before they fail at runtime.
+10. V2 adds true per-host policy (currently uniform across both
+ hosts), profile-based tool subsets, and bridge helpers between
+ local git tools and the gh wrappers.
+
+* Non-Goals
+
+- *Interactive subcommands.* =gh auth login=, =gh codespace ssh=,
+ =gh pr checkout= (modifies working tree), =gh repo clone=
+ (writes outside controlled paths) are not in scope. The user
+ runs those in a real terminal.
+- *Replicating gh extensions.* Core gh only. Extensions can be
+ invoked from a regular terminal if needed.
+- *Streaming long-running output.* =gh run watch=, =gh
+ --paginate= for unbounded result sets, =gh repo clone= for large
+ repos. V1 blocks =--paginate= and =watch= verbs; future
+ versions may add streaming support.
+- *Per-host write policies* (work-read-only / personal-full-write).
+ V1 applies the same write-confirmation policy to both hosts.
+ V2 makes the policy host-keyed.
+- *Conversation-context injection* and *local/remote bridge
+ helpers* (current branch → PR, changed files → PR comments).
+ Future enhancements; tracked as a follow-up.
+- *Artifact download* (=gh run download=). Writes files; needs
+ its own confirmation flow. Deferred to V2.
+
+* Verified gh CLI Contracts
+
+These were checked against =gh --version 2.92.0= help output before
+the spec was revised. Implementation can rely on them.
+
+** Host / repo selection per subcommand
+
+- =gh api= and =gh auth status=: use =--hostname HOST= (long form
+ only; no short form).
+- =gh pr {view,list,diff,checks}=, =gh issue {view,list}=,
+ =gh run {view,list}=, =gh release *=, =gh search *=: use
+ =--repo [HOST/]OWNER/REPO= (short =-R=).
+- =gh repo view= and similar root-level commands: infer from cwd
+ remote; no =--hostname= or =--repo= flag.
+- Environment variables: =GH_HOST= and =GH_REPO= work for commands
+ that lack explicit flags.
+
+** Flags with overloaded short forms
+
+- =gh pr list -H FOO= → =--head FOO= (branch).
+- =gh search prs -H FOO= → =--head FOO= (branch).
+- =gh api -H KEY:VAL= → =--header KEY:VAL=.
+
+Substring scanning =-H= as "hostname" is wrong on every command
+that uses it.
+
+** =gh api= method classification
+
+The help text explicitly says: "adding request parameters will
+automatically switch the method to POST". This means:
+
+- =gh api repos/x/y/issues= → GET (read).
+- =gh api repos/x/y/issues -f title=Foo= → POST (write).
+- =gh api repos/x/y/issues -F body=@file= → POST (write +
+ exfiltration).
+- =gh api --method GET repos/x/y/issues -f q=x= → GET (read,
+ explicit override).
+
+Classifier must inspect for =-f=, =-F=, =--field=, =--raw-field=,
+=--input=, and =--method= before defaulting to GET.
+
+** Wrappers without =--json= support
+
+- =gh pr diff= produces patch output; no =--json= flag. Wrapper
+ is text-only. Structured file metadata available via
+ =gh pr view --json files= (new wrapper =gh_pr_files=).
+
+** Environment variables for non-interactive use
+
+All confirmed via =gh help environment=:
+
+- =GH_PROMPT_DISABLED= -- disable interactive prompting.
+- =GH_PAGER= / =PAGER= -- set to =cat= so no pager fires.
+- =GH_NO_UPDATE_NOTIFIER= -- silence the "new version" banner.
+- =GH_NO_EXTENSION_UPDATE_NOTIFIER= -- same for extensions.
+- =GH_SPINNER_DISABLED= -- silence the progress spinner.
+- =NO_COLOR= -- disable ANSI color (cleaner LLM context).
+
+V1 subprocess env always includes all of the above.
+
+** Authentication
+
+#+begin_example
+✓ Logged in to github.com account cjennings (keyring)
+✓ Logged in to deepsat.ghe.com account craig-jennings (keyring)
+#+end_example
+
+Both stored in the system keyring. =gh= reads the keyring per
+call; the wrapper does not handle tokens. Expired auth surfaces
+as exit code 4 with a clear stderr message; recovery is interactive
+(=gh auth login --hostname HOST= in a terminal).
+
+* Current State
+
+** =modules/ai-config.el=
+
+- =gptel-confirm-tool-calls nil= at line 386. Per-tool
+ =:confirm t= is the only confirmation mechanism in V1.
+- =cj/gptel-load-local-tools= (lines 71-96) loads tools from
+ =gptel-tools/=. Add new gh feature names to
+ =cj/gptel-local-tool-features=.
+
+** =gptel-tools/=
+
+Ten tools today. =git_log.el= is the closest analogue: validates
+input, runs subprocess with =process-file=, caps output, signals
+clear errors. The gh wrappers follow the same shape with shared
+helpers in =gh-common.el=.
+
+* Design
+
+** File layout
+
+Two files plus per-wrapper registrations. Wrappers are
+deliberately small (argv builders + JSON-field defaults) so they
+all live in one file:
+
+- =gptel-tools/gh-common.el= -- shared helpers: host/repo context,
+ argv runner, subprocess env, classifier, blocklist, redaction,
+ debug record, last-error buffer. No tool registrations.
+- =gptel-tools/gh.el= -- the general tool + every wrapper
+ registration. Pulls in =gh-common= via =require=.
+
+This matches Craig's preference for low loader surface (per the
+MCP revision discussion). If =gh.el= grows past ~600 lines we
+split per-resource later (=gh-pr.el=, =gh-issue.el=, etc.).
+
+** Host / repo resolution
+
+A single helper returns a structured context object used by every
+tool entry point:
+
+#+begin_src emacs-lisp
+(defun cj/gh--resolve-context (cwd hostname repo)
+ "Return a context plist for a gh invocation.
+Resolution order:
+1. Explicit REPO argument (`[HOST/]OWNER/REPO'). If it contains
+ `HOST/', split off the host.
+2. Explicit HOSTNAME argument.
+3. CWD's branch upstream remote URL (`git rev-parse --abbrev-ref
+ @{u}' then `git remote get-url REMOTE').
+4. CWD's `origin' remote URL.
+5. `cj/gh-default-host'.
+
+Returns:
+ (:host HOST :owner OWNER :repo REPO :source SYM)
+where SOURCE is one of: explicit-repo, explicit-host, upstream,
+origin, default."
+ ...)
+#+end_src
+
+URL parsing handles all three forms produced by git:
+
+- =git@HOST:OWNER/REPO.git=
+- =ssh://git@HOST/OWNER/REPO.git=
+- =https://HOST/OWNER/REPO.git=
+
+Returns =nil host= if the URL doesn't match a known host; the
+caller uses =cj/gh-default-host= in that case.
+
+#+begin_src emacs-lisp
+(defcustom cj/gh-default-host "github.com"
+ "Host used when no other resolution path yields one."
+ :type 'string
+ :group 'cj)
+
+(defcustom cj/gh-known-hosts '("github.com" "deepsat.ghe.com")
+ "Hosts the gh CLI is authenticated against."
+ :type '(repeat string)
+ :group 'cj)
+#+end_src
+
+Context appears in every tool response as a one-line prefix:
+
+#+begin_example
+[gh github.com/cjennings/dotemacs read ok 12.4KB] ...
+[gh deepsat.ghe.com/org/repo write confirmed 0.8KB] ...
+[gh github.com api unknown blocked] ...
+#+end_example
+
+This makes cross-host accidents visible.
+
+** Argv-list builders, never command strings
+
+Every tool builds an explicit argv list and hands it to a shared
+runner. Wrappers expose typed parameters that map directly into
+argv positions:
+
+#+begin_src emacs-lisp
+(defun cj/gh-pr-view--build-argv (context number include-comments fields)
+ "Return an argv list for `gh pr view NUMBER ...'."
+ (let ((repo-arg (format "%s/%s/%s"
+ (plist-get context :host)
+ (plist-get context :owner)
+ (plist-get context :repo))))
+ (append (list "pr" "view" (number-to-string number)
+ "--repo" repo-arg)
+ (when include-comments '("--comments"))
+ (when fields (list "--json" fields)))))
+#+end_src
+
+The general tool takes an =args= parameter typed as array-of-string
+(not a shell string) so the agent constructs argv directly:
+
+#+begin_src emacs-lisp
+;; agent passes: ["pr" "view" "171" "--repo" "deepsat.ghe.com/org/repo"]
+;; runner runs: gh pr view 171 --repo deepsat.ghe.com/org/repo
+#+end_src
+
+If the agent accidentally includes leading =gh= in args[0], the
+runner strips it and logs the normalization.
+
+** Subprocess contract
+
+One runner for everything:
+
+#+begin_src emacs-lisp
+(defun cj/gh--run (argv &key timeout context)
+ "Run gh with ARGV (a list of strings).
+Returns a plist:
+ (:exit-code N :stdout STR :stderr STR :truncated BOOL :duration-ms N
+ :argv ARGV :context CONTEXT)
+Enforces TIMEOUT (default `cj/gh--default-timeout').
+Captures output in-flight, killing the process at
+`cj/gh--max-bytes' to prevent runaway memory use."
+ ...)
+#+end_src
+
+Contract:
+
+- *Env vars set every call:* =GH_PROMPT_DISABLED=1=, =GH_PAGER=cat=,
+ =PAGER=cat=, =NO_COLOR=1=, =GH_NO_UPDATE_NOTIFIER=1=,
+ =GH_NO_EXTENSION_UPDATE_NOTIFIER=1=, =GH_SPINNER_DISABLED=1=.
+- *Timeout:* default 20 s for reads, 60 s for diffs/logs/search.
+ Per-tool override allowed but capped at 120 s. Timeout kills
+ the process and returns =:error-kind 'timeout=.
+- *Output cap during capture:* a process filter accumulates bytes
+ up to =cj/gh--max-bytes= (64 KB). At the cap, the filter sets
+ =truncated=, ignores further output, and sends SIGTERM after a
+ short grace. Truncation marker appended to returned stdout.
+- *TRAMP rejection:* if =(file-remote-p default-directory)= is
+ non-nil, return =:error-kind 'remote-cwd= without invoking gh.
+- *Executable check:* if =(executable-find cj/gh--executable)= is
+ nil, return =:error-kind 'no-executable= with install
+ instructions.
+- *Version check:* the first call per session verifies
+ =gh --version= meets =cj/gh--min-version= (default "2.50.0");
+ cached for the session.
+
+** Read / write / destructive classifier
+
+Used to apply =:confirm t= and the irreversible blocklist:
+
+#+begin_src emacs-lisp
+(defconst cj/gh--read-verbs
+ '("view" "list" "status" "search" "diff" "checks" "describe"
+ "show" "logs" "auth-status"))
+
+(defconst cj/gh--write-verbs
+ '("create" "edit" "merge" "close" "reopen" "comment" "review"
+ "upload" "set" "add" "remove" "rerun" "cancel" "delete"
+ "fork" "archive" "unarchive" "lock" "unlock" "pin" "unpin"
+ "ready" "draft" "rename" "transfer" "approve" "label" "assign"))
+
+(defconst cj/gh--blocked-verbs
+ '(;; interactive / opens UI
+ "login" "logout" "checkout" "clone" "ssh" "code" "edit-prompt"
+ ;; modifies user config
+ "alias" "config" "extension"
+ ;; opens browser
+ "browse"))
+
+(defconst cj/gh--blocked-flags
+ '("--web" ; many commands
+ "--paginate" ; can produce unbounded output
+ "--input" ; gh api file upload
+ "--editor")) ; opens editor
+
+(defconst cj/gh--irreversible-patterns
+ '("\\`repo delete\\b"
+ "\\`release delete\\b"
+ "\\`secret delete\\b"
+ "\\`ssh-key delete\\b"
+ "\\`gpg-key delete\\b"
+ "\\`org delete\\b"
+ "\\`project delete\\b"
+ "\\`variable delete\\b"
+ "\\`ruleset delete\\b"
+ "\\`label delete\\b.*--yes"))
+#+end_src
+
+Classifier:
+
+#+begin_src emacs-lisp
+(defun cj/gh--classify (argv)
+ "Return one of: read, write, destructive, blocked, unknown."
+ (let* ((stripped (cj/gh--strip-flags argv))
+ (resource (car stripped))
+ (verb (cadr stripped)))
+ (cond
+ ;; Hard blocks first.
+ ((cj/gh--has-blocked-verb-p argv) 'blocked)
+ ((cj/gh--has-blocked-flag-p argv) 'blocked)
+ ((cj/gh--has-file-arg-p argv) 'blocked) ; -F key=@file
+ ((cj/gh--matches-irreversible-p argv) 'destructive)
+ ;; gh api is special.
+ ((string= resource "api") (cj/gh--classify-api argv))
+ ;; Verb match.
+ ((member verb cj/gh--read-verbs) 'read)
+ ((member verb cj/gh--write-verbs) 'write)
+ (t 'unknown))))
+
+(defun cj/gh--classify-api (argv)
+ "Classify a `gh api ...' invocation.
+Reads: explicit method GET/HEAD with no -f/-F/--input.
+Writes: any -f/-F/--field/--raw-field/--input, OR explicit
+non-GET/HEAD method.
+Default (no method, no field): read (matches gh's GET default)."
+ (let* ((explicit-method (or (cadr (member "-X" argv))
+ (cadr (member "--method" argv))))
+ (has-field (cl-some
+ (lambda (f) (cl-some (lambda (a) (string-prefix-p f a))
+ argv))
+ '("-f" "--raw-field" "-F" "--field")))
+ (has-input (member "--input" argv)))
+ (cond
+ (has-input 'blocked) ; file exfiltration
+ ((and explicit-method
+ (not (member (upcase explicit-method) '("GET" "HEAD"))))
+ 'write)
+ (has-field 'write) ; -f/-F auto-promotes to POST
+ ((and explicit-method
+ (member (upcase explicit-method) '("GET" "HEAD")))
+ 'read)
+ (t 'read))))
+#+end_src
+
+Classifier-driven policy table:
+
+| Classification | Policy |
+|----------------+--------|
+| =read= | auto-execute |
+| =write= | =:confirm t= on registration; agent's call shows in confirm prompt |
+| =destructive= | hard-block; return =:error-kind 'irreversible-blocked= |
+| =blocked= | hard-block; return =:error-kind 'policy-blocked= with reason |
+| =unknown= | =:confirm t= (fail closed) |
+
+** Safety policy
+
+V1 uniform policy applied to both hosts:
+
+#+begin_src emacs-lisp
+(defcustom cj/gh-policy
+ '((read . auto)
+ (write . confirm)
+ (destructive . block)
+ (blocked . block)
+ (unknown . confirm))
+ "Per-classification policy. V1 applies uniformly to both hosts."
+ :type '(alist :key-type symbol :value-type symbol)
+ :group 'cj)
+#+end_src
+
+Since GPTel's =:confirm t= flag is per-tool-registration (not
+per-call), the general tool is registered with =:confirm t=
+always. The wrappers are registered per their classification:
+
+| Tool | Registered with |
+|------+-----------------|
+| Read wrappers (=gh_pr_view=, etc.) | =:confirm nil= |
+| =gh= general tool | =:confirm t= |
+
+The general tool then applies the policy table at invocation
+time: reads execute without further prompting (already past
+GPTel's confirm because :confirm t fires once); writes show
+detail before invocation; destructive/blocked never reach gh.
+
+** General tool: =gh=
+
+Single tool registered with =:confirm t= covering everything the
+wrappers don't. Schema:
+
+| Arg | Type | Required | Purpose |
+|-----+------+----------+---------|
+| =args= | array of string | yes | argv list, e.g. =["pr", "view", "171", "--repo", "deepsat.ghe.com/org/repo"]= |
+| =hostname= | string | no | Override host for commands that accept =--hostname= (=api=, =auth status=); ignored otherwise |
+| =repo= | string | no | =[HOST/]OWNER/REPO= for repo-scoped commands; if HOST is present in repo, hostname arg is overridden |
+| =cwd= | string | no | Working directory; defaults to current buffer; must be under =$HOME=, must not be TRAMP |
+| =timeout= | integer | no | Seconds before kill; default 20, max 120 |
+
+Description (registered) explicitly says:
+
+#+begin_example
+Use this only when no task-specific gh_* tool fits. Prefer
+gh_pr_view, gh_pr_list, gh_pr_checks, gh_issue_view, gh_issue_list,
+gh_run_view, gh_run_list, gh_run_logs_failed, gh_repo_view,
+gh_search_prs, gh_search_issues, gh_api_get.
+
+Writes (create/edit/merge/etc.) require user confirmation.
+Destructive (repo/release/secret delete) are hard-blocked.
+Interactive commands (auth login, codespace ssh, --web, browse)
+are hard-blocked. File uploads (api --input, -F @file, release
+upload, gist create) are hard-blocked.
+#+end_example
+
+** Wrapper inventory
+
+Twelve wrappers grouped by resource. Each has typed args, JSON
+field defaults, output truncation, timeout override, and a
+description that names its scope.
+
+| Tool | gh command | Defaults | Timeout |
+|------+------------+----------+---------|
+| =gh_repo_view= | =repo view --json= | JSON fields: =name,nameWithOwner,description,defaultBranchRef,url,visibility= | 20s |
+| =gh_pr_view= | =pr view N --json= | JSON fields: =number,title,state,author,createdAt,url,body= (body truncated) | 20s |
+| =gh_pr_list= | =pr list --json= | JSON fields: =number,title,state,author,createdAt,headRefName=; default =--limit 30= (capped at 100) | 20s |
+| =gh_pr_diff= | =pr diff N --color never= | text only; capped at 64 KB | 60s |
+| =gh_pr_checks= | =pr checks N --json= | JSON fields: =name,status,conclusion,startedAt,completedAt,link= | 20s |
+| =gh_pr_files= | =pr view N --json files= | JSON fields: =files= (path, additions, deletions, mode) | 20s |
+| =gh_pr_current= | =pr view --json= (no number — auto-detect) | Same as =gh_pr_view= | 20s |
+| =gh_issue_view= | =issue view N --json= | JSON fields: =number,title,state,author,createdAt,url,body= (body truncated) | 20s |
+| =gh_issue_list= | =issue list --json= | JSON fields: =number,title,state,author,createdAt,labels=; default =--limit 30= | 20s |
+| =gh_run_view= | =run view RUN-ID --json= | JSON fields: =databaseId,name,status,conclusion,startedAt,headBranch,event,url= | 20s |
+| =gh_run_list= | =run list --json= | JSON fields: =databaseId,name,status,conclusion,startedAt,headBranch=; default =--limit 20= | 20s |
+| =gh_run_logs_failed= | =run view RUN-ID --log-failed= | text only; capped at 64 KB | 60s |
+| =gh_search_prs= | =search prs --json= | JSON fields: =number,title,state,author,repository,url=; default =--limit 30= (capped at 100) | 30s |
+| =gh_search_issues= | =search issues --json= | Same as PR search | 30s |
+| =gh_api_get= | =api ENDPOINT --method GET= | text/JSON pass-through; rejects fields/input args | 30s |
+
+Common args for every wrapper (unless noted):
+
+| Arg | Type | Required | Purpose |
+|-----+------+----------+---------|
+| =repo= | string | no | =[HOST/]OWNER/REPO=; resolved from context otherwise |
+| =hostname= | string | no | Override host for context resolution |
+| =limit= | integer | no | =--limit= for list/search wrappers; clamped to per-wrapper max |
+
+The =gh_api_get= wrapper *explicitly* rejects =-f=, =-F=,
+=--field=, =--raw-field=, =--input=, =--method=, and any method
+override. It only accepts =ENDPOINT= and optional =-H= headers
+that don't carry secrets (the runner redacts =Authorization:= and
+similar regardless). Writes via API go through the general tool
+with confirmation.
+
+** JSON field defaults
+
+Per-wrapper defaults are minimal -- enough for the agent to decide
+whether to drill in, not so much that one call fills the context.
+
+For =gh_pr_view= specifically:
+
+- *Default* (no =fields= override): the small list above (number,
+ title, state, author, createdAt, url, body-truncated-to-2KB).
+- *Override*: agent passes =fields= as a comma-separated string;
+ wrapper validates against a per-resource allowlist (so the agent
+ can't request =reviews,comments,files= in one call to bypass the
+ cap).
+- *Include flags*: =include-body t/nil=, =include-comments t/nil=,
+ =include-reviews t/nil= as boolean args. Each adds the
+ corresponding JSON field; agent opts in only when needed.
+
+** Output truncation
+
+Process filter pattern, not post-hoc cap:
+
+#+begin_src emacs-lisp
+(defun cj/gh--make-filter (state-var)
+ "Return a process filter that accumulates into STATE-VAR's :stdout,
+stops collecting after `cj/gh--max-bytes', sets :truncated, and
+sends SIGTERM to the process."
+ (lambda (proc output)
+ (let* ((state (symbol-value state-var))
+ (current (plist-get state :stdout))
+ (current-len (length current))
+ (remaining (- cj/gh--max-bytes current-len)))
+ (cond
+ ((<= remaining 0) nil) ; already at cap
+ ((<= (length output) remaining)
+ (plist-put state :stdout (concat current output)))
+ (t
+ (plist-put state :stdout
+ (concat current (substring output 0 remaining)))
+ (plist-put state :truncated t)
+ (ignore-errors (delete-process proc)))))))
+#+end_src
+
+Truncation marker appended before return:
+
+#+begin_example
+[truncated at 64KB; use --limit, narrower fields, or a specific
+wrapper to reduce output]
+#+end_example
+
+** Error classification + debug record
+
+Every call returns (and =cj/gh-tool-last-error= caches) a debug
+record:
+
+#+begin_src emacs-lisp
+(defun cj/gh--debug-record (argv context exit-code stdout stderr
+ duration-ms truncated)
+ (list :host (plist-get context :host)
+ :repo (cj/gh--repo-arg context)
+ :cwd default-directory
+ :argv (cj/gh--redact-argv argv)
+ :classification (cj/gh--classify argv)
+ :policy (cj/gh--policy-decision argv)
+ :exit-code exit-code
+ :duration-ms duration-ms
+ :bytes-captured (length stdout)
+ :truncated truncated
+ :error-kind (cj/gh--error-kind exit-code stderr)))
+#+end_src
+
+=:error-kind= mapping:
+
+| Condition | =:error-kind= | Returned message |
+|-----------+---------------+------------------|
+| Exit 4 + "authentication required" | =auth= | "Run =gh auth login --hostname HOST= in a terminal." |
+| Process killed by timeout timer | =timeout= | "Command exceeded N seconds; narrow the query or use a more specific wrapper." |
+| Policy block | =policy-blocked= | "Blocked by V1 policy: REASON." |
+| Irreversible match | =irreversible-blocked= | "Hard-blocked irreversible command: COMMAND." |
+| Truncated output | =truncated= | "Output truncated at 64KB; reduce scope." |
+| TRAMP cwd | =remote-cwd= | "Cannot run gh from remote directory: CWD." |
+| Missing executable | =no-executable= | "gh not found at CJ/GH--EXECUTABLE; install via 'pacman -S github-cli' (or equivalent)." |
+| Other non-zero exit | =gh-exit= | (raw stderr, redacted) |
+
+Each error includes a sanitized reproduce line:
+
+#+begin_example
+Reproduce: GH_PROMPT_DISABLED=1 GH_PAGER=cat gh pr view 171 --repo HOST/OWNER/REPO
+#+end_example
+
+(Secrets, body text, file paths redacted via =cj/gh--redact-argv=.)
+
+** Audit log (V1, opt-out)
+
+Every call appends one line to
+=~/.emacs.d/data/gh-tool-log/YYYY-MM-DD.log=:
+
+#+begin_example
+2026-05-16T14:23:45-0500 host=github.com repo=cjennings/dotemacs class=read policy=auto exit=0 duration=128ms bytes=4321
+2026-05-16T14:24:02-0500 host=deepsat.ghe.com repo=org/repo class=write policy=confirm exit=0 duration=412ms bytes=88
+#+end_example
+
+Metadata only, not output bodies. Defcustom
+=cj/gh-tool-audit-log-enabled= (default =t=). Daily rotation
+implicit (one file per day). Cleanup manual.
+
+** Secrets redaction
+
+=cj/gh--redact-argv= masks:
+
+- Anything after =--token=, =--secret=, =--password= flags.
+- Authorization headers (=-H "Authorization: ..."=).
+- =--figma-api-key=KEY= (in case general tool spawns figma-mcp
+ somehow).
+- Bearer tokens in URLs (=?token=...=).
+- Values for =-f=/=-F= keys named like =body=, =text=,
+ =description= (private content; metadata still logged).
+
+Applied to:
+- All stderr returned to the agent.
+- All audit-log lines.
+- All debug records.
+- The reproduce line on error.
+
+* Commands & UX
+
+** =cj/gh-doctor=
+
+Diagnostic command. No side effects. Checks:
+
+- =gh= executable found at =cj/gh--executable=.
+- =gh --version= meets =cj/gh--min-version=.
+- =gh auth status= for each host in =cj/gh-known-hosts=.
+- Current buffer's cwd repo detection: resolves to which host/repo?
+- Environment overrides effective (=GH_PROMPT_DISABLED= etc. would
+ be set by the runner).
+- Active account per host.
+- Warnings if any block-list-relevant env is set externally
+ (e.g. user already has =GH_PAGER= set to something that pages).
+
+Output: a buffer with PASS / FAIL / WARN per check + recovery
+actions for failures.
+
+** =cj/gh-tool-last-error=
+
+Opens a buffer showing the last call's debug record:
+
+#+begin_example
+Host: deepsat.ghe.com
+Repo: org/repo
+Source: upstream
+CWD: ~/projects/work/foo
+Argv: ("pr" "view" "171" "--repo" "deepsat.ghe.com/org/repo")
+Classification: read
+Policy: auto
+Exit code: 0
+Duration: 412 ms
+Bytes captured: 4321
+Truncated: no
+Error kind: none
+
+Reproduce:
+ GH_PROMPT_DISABLED=1 GH_PAGER=cat NO_COLOR=1 \
+ gh pr view 171 --repo deepsat.ghe.com/org/repo
+#+end_example
+
+** Tool response header
+
+Every tool result begins with a one-line header so cross-host /
+policy decisions are visible:
+
+#+begin_example
+[gh github.com/cjennings/dotemacs read ok 4.3KB]
+{ ... }
+[gh deepsat.ghe.com/org/repo write confirmed 0.2KB]
+{ ... }
+[gh github.com/cjennings/dotemacs api blocked policy-blocked]
+Error: Hard-blocked file-upload path: --input file.
+Reproduce: gh api repos/cjennings/dotemacs/contents/foo --input file
+#+end_example
+
+* Implementation Plan
+
+Eight phases. Each ends with green ERT tests + manual smoke
+before the next.
+
+** Phase 1 -- Common helpers + context resolver
+
+=gh-common.el=: =cj/gh--executable=, =cj/gh--available-p=,
+=cj/gh--version= (cached), =cj/gh--validate-cwd= (HOME + non-TRAMP),
+=cj/gh--parse-remote-url=, =cj/gh--resolve-context=,
+=cj/gh--redact-argv=. No subprocess execution yet.
+
+Tests cover all helpers against fixture remote URLs and synthetic
+git directories. No real gh calls.
+
+** Phase 2 -- Runner with subprocess env + timeout + in-flight cap
+
+=cj/gh--run=, the process filter, the timer kill path, env-var
+setup. Tests stub =make-process= to simulate output / exit / hang
+/ truncation paths.
+
+Acceptance: with stub configured to produce 100 KB of output,
+returned stdout is exactly 64 KB plus the truncation marker, and
+the process gets SIGTERM.
+
+** Phase 3 -- Classifier + blocklist + policy
+
+=cj/gh--classify=, =cj/gh--classify-api=, blocklist constants,
+=cj/gh--policy-decision=. Tests for every blocklist pattern
+(verbs + flags + file-arg paths), API edge cases (=-f= promotes
+to POST, =--method GET -f x=y= stays GET, etc.), and the
+unknown-fails-closed contract.
+
+** Phase 4 -- Read wrappers (5 first)
+
+=gh_repo_view=, =gh_pr_view=, =gh_pr_list=, =gh_pr_diff=,
+=gh_issue_view=. Each is a thin schema + argv builder + delegate
+to =cj/gh--run=. Tests verify argv shape for typical args.
+
+Manual smoke against both hosts. First real gh calls.
+
+** Phase 5 -- Remaining wrappers + JSON defaults
+
+Eight more wrappers (=gh_pr_checks=, =gh_pr_files=,
+=gh_pr_current=, =gh_issue_list=, =gh_run_view=, =gh_run_list=,
+=gh_run_logs_failed=, =gh_search_prs=, =gh_search_issues=,
+=gh_api_get=). JSON-field defaults per wrapper. Tests for the
+=gh_api_get= flag-rejection contract.
+
+** Phase 6 -- General tool
+
+=gh.el= general tool registration with =:confirm t=, blocklist
+enforcement, policy decision applied before invocation. Tests
+verify confirmation gate (stub gptel's confirm flow), blocked
+commands never reach =cj/gh--run=, destructive commands return
+=:irreversible-blocked= without prompting.
+
+** Phase 7 -- UX: doctor, last-error, response header
+
+=cj/gh-doctor=, =cj/gh-tool-last-error=, response header
+formatting. Audit log writer. Defcustom for log enable.
+
+** Phase 8 -- Loader wiring + integration
+
+Add the 16 feature names to =cj/gptel-local-tool-features= (one
+per wrapper + the general tool + the helpers feature). Verify
+they land in =gptel-tools=.
+
+* Test Plan
+
+Target: 55-70 ERT tests across four files. No real subprocesses,
+no real network, no real =~/.claude.json= (gh tools don't use it,
+but the no-real-process rule applies uniformly).
+
+** =tests/test-gh-common.el= -- pure helpers (~25 tests)
+
+- =cj/gh--parse-remote-url=: ssh-scp, ssh-url, https, with/without
+ =.git=, with/without trailing slash, unknown host returns =:host
+ nil=, =github.com.evil.example= does NOT match =github.com=.
+- =cj/gh--resolve-context=: explicit repo wins; explicit hostname
+ wins over remote; upstream beats origin; origin beats default;
+ default fires when no git.
+- =cj/gh--validate-cwd=: HOME-rooted ok; outside HOME errors;
+ TRAMP errors; non-directory errors.
+- =cj/gh--redact-argv=: =--token=, =-H "Authorization: ..."=,
+ =--figma-api-key=, =-f body=...= → body redacted; sentinel
+ =REDACTED_TEST_SECRET= never appears in any output of any
+ helper.
+- =cj/gh--available-p=: nil when =executable-find= fails; t
+ otherwise.
+- =cj/gh--version=: caches per session; floor check rejects
+ too-old.
+
+** =tests/test-gh-runner.el= -- runner contract (~15 tests)
+
+Stub =make-process=:
+- Normal exit 0 with short output: returned verbatim, no
+ truncation flag.
+- Long output (100 KB stub): truncated at 64 KB exactly,
+ truncation marker present, =:truncated t=.
+- Process hangs past timeout: timer fires, SIGTERM sent, returns
+ =:error-kind 'timeout=.
+- Exit 4 + auth stderr: returns =:error-kind 'auth= with recovery
+ message.
+- TRAMP cwd: never invokes =make-process=, returns =:error-kind
+ 'remote-cwd=.
+- Missing executable: returns =:error-kind 'no-executable=.
+- Environment: =process-environment= includes all six required
+ vars before =make-process= call.
+- Argv with leading "gh" stripped + logged.
+
+** =tests/test-gh-classifier.el= -- policy logic (~20 tests)
+
+- Every read verb classifies as read.
+- Every write verb classifies as write.
+- Every destructive pattern matches.
+- Every blocked verb (=login=, =browse=, =clone=, etc.) classifies
+ as blocked.
+- Every blocked flag (=--web=, =--paginate=, =--input=) classifies
+ as blocked.
+- File-upload (=-F key=@/path=) classifies as blocked.
+- =gh api= GET (no fields): read.
+- =gh api= GET (=-f x=y=): write (auto-POST per gh's rules).
+- =gh api --method GET -f q=x=: read (explicit override).
+- =gh api -X DELETE=: destructive.
+- =gh api -X PATCH=: write.
+- =gh api --input file=: blocked.
+- Unknown verb (=gh frobnicate=): unknown → confirm.
+- =-H= as branch (=gh pr list -H feature=) doesn't trigger host
+ treatment.
+- =-H= as header (=gh api -H Accept:json=) doesn't trigger host
+ treatment.
+- Policy decision: read → auto; write → confirm; destructive →
+ block; blocked → block; unknown → confirm.
+
+** =tests/test-gh-wrappers.el= -- per-wrapper builders + schemas (~15 tests)
+
+- Every wrapper's schema is valid (correct =:name=, =:type=,
+ =:description=, =:args= shape).
+- =gh_pr_view= argv with number 171 and repo "host/o/r" produces
+ =("pr" "view" "171" "--repo" "host/o/r" "--json" "DEFAULTS")=.
+- =gh_pr_diff= rejects =format='json=.
+- =gh_api_get= rejects =-f=, =-F=, =--field=, =--raw-field=,
+ =--input=, =--method= other than GET.
+- =gh_pr_current= invokes without a number arg (uses cwd).
+- =limit= clamped to per-wrapper max.
+- =fields= validated against per-resource allowlist.
+
+** Manual smoke (every phase)
+
+| Phase | Smoke |
+|-------+-------|
+| 4 | =gh_pr_view N= against both hosts |
+| 4 | =gh_pr_list= in =~/.emacs.d= → uses github.com/cjennings/dotemacs |
+| 5 | =gh_pr_checks= shows CI status without full logs |
+| 5 | =gh_run_logs_failed= cap kicks in on a long-failed run |
+| 5 | =gh_api_get= rejects a =-f= arg with clear error |
+| 6 | =gh= general tool: agent asked to merge a PR triggers GPTel confirm prompt |
+| 6 | Agent asked to =gh repo delete= gets irreversible-blocked |
+| 6 | Agent asked to =gh --web=... gets policy-blocked |
+| 7 | =cj/gh-doctor= correctly identifies an unauthenticated host |
+| 7 | =cj/gh-tool-last-error= shows debug record after a failing call |
+
+** Opt-in integration suite
+
+A small set of real-gh tests (in =tests/test-gh-integration.el=
+marked =:tag :integration=, default skipped):
+
+- =gh auth status --hostname github.com= ok.
+- =gh auth status --hostname deepsat.ghe.com= ok.
+- =gh repo view cjennings/dotemacs --json name=
+ returns parseable JSON.
+- =gh pr list --repo cjennings/dotemacs --limit 1= returns ≤ 1
+ PR.
+
+Run manually via =make test-name TEST=gh-integration=.
+
+* Acceptance Criteria
+
+1. *Argv contract.* No tool produces a command string for execution;
+ every call goes through the argv-list runner.
+2. *No silent writes.* Every classified write either prompts
+ GPTel's confirm or hard-blocks. Verified by an end-to-end
+ test where the agent attempts =pr merge= and the test fails if
+ =make-process= is invoked before confirm.
+3. *In-flight cap.* A stubbed process emitting 1 MB returns
+ exactly 64 KB; the runner never holds more than 65 KB in
+ memory.
+4. *Host visibility.* Every successful tool response begins with
+ =[gh HOST/REPO ...]=. Verified by a test that greps the
+ response text.
+5. *Doctor coverage.* =cj/gh-doctor= correctly identifies (a) no
+ gh executable, (b) too-old gh, (c) unauthenticated host, (d)
+ non-git cwd, (e) git cwd whose remote points to an unknown
+ host.
+6. *No secret leakage.* Test fixtures containing
+ =REDACTED_TEST_SECRET= in every secret-bearing slot
+ (=--token=, =-H Authorization=, =-f body=, etc.) produce zero
+ matches when grepping audit log, debug record, and any
+ user-facing message.
+
+* Risks
+
+** R1 -- gh CLI evolves and verbs drift
+
+New =gh= versions may add subcommands the blocklist doesn't
+cover. Or rename verbs.
+
+*Mitigation:* the blocklist works on verbs (not full subcommand
+paths) so most additions are caught. Doctor includes a
+=gh --version= floor. Periodic review when gh bumps a major
+version.
+
+** R2 -- The "all reads auto-execute" default may still be too broad
+
+Some reads expose private content (issue bodies, PR descriptions
+from private repos). An agent surfacing a confidential issue
+body into a saved conversation has data-leak implications.
+
+*Mitigation:* response header makes the host/repo visible in
+every result, so the saved conversation makes the privacy
+boundary auditable. Wrappers truncate body/comments by default;
+agent must explicitly opt-in to include them. Documented in
+commentary.
+
+** R3 -- The general tool's =:confirm t= prompt may become click-fatigue
+
+If the agent uses the general tool heavily during a write-heavy
+workflow (PR creation, label management), confirming every call
+becomes tedious.
+
+*Mitigation:* the expanded wrapper set covers most reads, so the
+general tool fires mainly for writes -- where confirmation is
+exactly the right behavior. If usage shows confirm fatigue,
+V2's per-host policy can add =:auto= for explicit
+write-confirmed contexts.
+
+** R4 -- =--paginate= block conflicts with legitimate large queries
+
+Blocking =--paginate= globally means the agent can't get
+historical CI runs (which may need pagination).
+
+*Mitigation:* =gh_run_list= and =gh_search_*= accept a clamped
+=--limit= which usually substitutes. If a use case needs more,
+the agent can request multiple non-paginated pages explicitly.
+
+** R5 -- Token expiry surfaces as cryptic exit 4
+
+When a host's keyring entry expires, every call returns exit 4
+with "authentication required" on stderr. The agent sees the
+error but may not realize the fix is interactive.
+
+*Mitigation:* the runner's =:error-kind 'auth= mapping prepends
+the recovery message before returning to the agent. =cj/gh-doctor=
+proactively checks auth status.
+
+** R6 -- TRAMP cwd silently runs gh remotely
+
+Without the explicit TRAMP rejection, =process-file= would try to
+spawn =gh= on the remote host (where it may not exist, or may
+authenticate against the wrong keyring).
+
+*Mitigation:* runner checks =(file-remote-p default-directory)=
+first and returns =:error-kind 'remote-cwd= with a clear message.
+
+** R7 -- =gh search= behaves differently on GHE
+
+GHE may not support every advanced search operator
+=github.com= does. Search wrappers may return inconsistent
+results across hosts.
+
+*Mitigation:* documented in =gh_search_*= wrapper descriptions.
+Result header makes the host visible so the agent can adjust.
+
+** R8 -- Audit log grows unbounded
+
+One file per day, but no automatic cleanup.
+
+*Mitigation:* metadata-only entries are tiny (~150 bytes); a
+year of heavy use is a few MB. Manual cleanup acceptable.
+Defcustom to disable for users who don't want it.
+
+* Open Questions
+
+** Q1 -- Should the general tool's confirmation prompt include the classification?
+
+When GPTel asks "Run gh tool? (y/n)" the prompt shows the argv but
+not the classification. Showing "WRITE: gh pr merge 171" gives the
+user more context. Need to investigate gptel's confirm-prompt
+extensibility.
+
+** Q2 -- Should =gh_pr_diff= cap differently from text wrappers?
+
+A PR diff can legitimately be 100KB+ for a large refactor. The
+64KB cap is the same as everywhere else. If diffs need a higher
+cap (256KB?), that's per-wrapper config.
+
+** Q3 -- Should wrappers expose =include-body=, =include-comments=, etc., as separate args, or as a comma-separated list?
+
+The spec proposes separate boolean args (=:include-body t=,
+=:include-comments t=). Alternative: one =:include= comma-list
+arg. Separate args are more discoverable; comma-list is more
+compact. Decide during Phase 4.
+
+** Q4 -- Should =cj/gh-tool-audit-log= grow into a query interface?
+
+V1 writes one line per call. Future: a command to query the
+log (=cj/gh-audit-search REGEX=) for surfacing "what did the agent
+do to this PR last week?".
+
+* V2 Roadmap
+
+Items intentionally deferred:
+
+- *Per-host policy.* =cj/gh-host-policy= alist keyed by hostname
+ (mirror of the MCP spec's structure) so work GHE can be
+ read-only while personal allows writes-with-confirm.
+- *Conversation context injection.* After a PR view, the wrapper
+ inserts a "GitHub context: HOST/REPO PR #N at URL" line into the
+ GPTel buffer so saved conversations stay traceable without
+ bundling full output.
+- *Local/remote bridge helpers.* current-branch → PR-number,
+ changed-files → matching PR file comments, etc.
+- *Artifact download.* =gh_run_artifacts=, =gh_release_download=
+ with explicit confirm and write to a controlled directory.
+- *Async general tool.* =make-process= + sentinel for the cases
+ where 60s timeout isn't enough (rare, but real for some
+ =--paginate= scenarios).
+- *Audit log query interface.* =cj/gh-audit-search=,
+ =cj/gh-audit-by-host=.
+- *Profile-based tool subsets.* e.g. read-only profile
+ vs. write-capable profile per buffer.
+
+* References
+
+- [[file:../../gptel-tools/git_log.el][gptel-tools/git_log.el]] -- pattern reference for new tool files.
+- [[file:../../modules/ai-config.el][modules/ai-config.el]] -- =gptel-confirm-tool-calls nil= at
+ line 386; loader at lines 71-96.
+- [[file:gptel-agentic-tool-ideas.org][gptel-agentic-tool-ideas.org]] -- broader agentic-tool design;
+ =gh= sits alongside the MCP integration as the
+ collaboration tier.
+- [[id:b4c274c5-8572-4a7b-b657-d315712bd6af][mcp-el-gptel-integration-spec-doing.org]] -- sibling design; same
+ confirm-on-write pattern for safety.
+- [[https://cli.github.com/manual/][gh CLI manual]] -- subcommand reference.
+- =gh --version 2.92.0= help output -- verified flag semantics
+ per subcommand.
+- =gh help environment= -- verified env-var names for non-interactive
+ mode.
diff --git a/archive/gptel/docs-specs/gptel-git-tools-magit-backend-spec.org b/archive/gptel/docs-specs/gptel-git-tools-magit-backend-spec.org
new file mode 100644
index 000000000..bd84b0595
--- /dev/null
+++ b/archive/gptel/docs-specs/gptel-git-tools-magit-backend-spec.org
@@ -0,0 +1,196 @@
+:PROPERTIES:
+:ID: bd47c9a8-aae1-4a3d-ad5b-b8767f2fd580
+:STATUS: not-started
+:END:
+#+TITLE: Design: gptel git tools on a magit backend
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-16
+
+* Status
+
+Draft. Supersedes the three current git-tool implementations
+(=gptel-tools/git_status.el=, =gptel-tools/git_log.el=,
+=gptel-tools/git_diff.el=) shipped in commit =ceeae9b5=. Trigger:
+Craig flagged that magit already does much of this and could carry
+the backend for more git tools cheaply.
+
+* Problem
+
+The three current git_* tools shell out to git directly via
+=process-file= and parse stdout. Each carries:
+
+- Its own =--is-inside-work-tree= path-validation step.
+- Its own =-c color.ui=false= color suppression workaround (`git
+ status' doesn't accept =--no-color= the way `git log' / `git diff'
+ do).
+- Boilerplate to set up a temp buffer, run =process-file=, capture
+ output, return the string.
+
+There's also an opportunity cost: adding more git context tools
+(=git_blame=, =git_show=, =git_branches=, etc.) would mean
+duplicating the same boilerplate per tool.
+
+* Wins from a magit backend
+
+Three concrete things magit provides:
+
+1. *Path validation via =magit-toplevel=.* One call replaces the
+ two-step =process-file= + =rev-parse --is-inside-work-tree=
+ check. Returns the working-tree root or nil.
+
+2. *Process plumbing via =magit-git-insert= / =magit-git-string= /
+ =magit-git-lines=.* These wrap git invocation with magit's
+ environment, encoding handling, and the right color posture.
+ Drops the per-subcommand color-flag bikeshedding.
+
+3. *Typed helpers for higher-level concepts* -- =magit-get-current-branch=,
+ =magit-list-branches=, =magit-rev-ancestor-p=, etc. Most
+ relevant for the *new* tools (branches, show, blame), not the
+ three we already wrote.
+
+What magit doesn't give us: high-level "give me status as a string"
+helpers. =magit-status= / =magit-log-current= etc. populate
+interactive magit buffers, not strings. For tool output we'd still
+call =magit-git-insert "status" "--short" "--branch"= and grab the
+buffer string. Same shape, less boilerplate.
+
+* Costs
+
+- *Magit loads on first invocation* of any git_* tool. Magit pulls
+ in transient, with-editor, magit-section, magit-core -- heavyweight.
+ Mitigation: lazy =(require 'magit)= inside each tool's function
+ body so cold-start Emacs sessions don't pay the cost unless the
+ user actually calls a git tool.
+- *Tools no longer portable* to a no-magit Emacs. Acceptable here
+ because magit is a non-negotiable in this config; a future
+ drop-in distribution would need to publish a magit-free fallback.
+
+* Proposed shape
+
+** Single-file module: =gptel-tools/git_tools.el=
+
+The current "one file per tool" convention exists because the
+existing tools share little. These six tools share a lot
+(validate-path, run-git, truncate-output), so a single file with
+shared helpers is more honest.
+
+** Shared helpers
+
+- =cj/gptel-git--toplevel-or-error PATH=
+ - Wraps =magit-toplevel=. Signals =user-error= when PATH escapes
+ HOME, doesn't exist, or isn't inside a working tree.
+ - Returns the resolved working-tree root on success.
+
+- =cj/gptel-git--insert ARGS...=
+ - Wraps =magit-git-insert= in a =with-temp-buffer=, returns
+ =buffer-string=. Single chokepoint for color / encoding / error
+ handling.
+
+- =cj/gptel-git--truncate TEXT MAX-BYTES=
+ - Caps output, appends a one-line truncation marker when
+ triggered.
+
+ Open question: consolidate the matching helper from =web_fetch.el=
+ (=cj/gptel-web-fetch--truncate=) and the
+ =cj/update-text-file--*= analogue into a shared
+ =cj/gptel-tools--truncate-bytes= in =system-lib.el=, or keep
+ per-tool.
+
+** Six tools
+
+| Name | Magit-flavored shape |
+|------------------+--------------------------------------------------------------------|
+| =git_status= | =magit-git-insert "status" "--short" "--branch"= |
+| =git_log= | =magit-git-insert "log" "--oneline" (format "-n%d" N) ?--since= |
+| =git_diff= | =magit-git-insert "diff" REF1 REF2 "--" FILE= (each optional) |
+| =git_blame= | =magit-git-insert "blame" "--line-porcelain" FILE [-L S,E]= |
+| =git_show= | =magit-git-insert "show" REF= (message + full diff) |
+| =git_branches= | =magit-list-branches= (optionally filtered by =--list PATTERN=) |
+
+Each tool:
+- Validates =path= via =cj/gptel-git--toplevel-or-error=.
+- Calls =cj/gptel-git--insert= with the appropriate args.
+- Truncates via =cj/gptel-git--truncate=.
+- Registered as a separate tool with =gptel-make-tool= for
+ description / argv clarity at the model side.
+
+** Caps
+
+| Tool | Default cap | Hard cap |
+|---------------+----------------+--------------|
+| =git_status= | uncapped | uncapped |
+| =git_log= | 100 commits | 100 commits |
+| =git_diff= | 500 KB | 500 KB |
+| =git_blame= | 500 KB | 500 KB |
+| =git_show= | 500 KB | 500 KB |
+| =git_branches=| uncapped | uncapped |
+
+=git_log='s cap is on commit count; the rest cap output bytes.
+
+** :confirm posture
+
+All six tools are read-only. Same posture as the current
+implementation: =:confirm nil= (the model can call them
+autonomously, since they can't mutate state). The current
+git_status / git_log / git_diff already ship with =:confirm nil= --
+keeping it.
+
+** Tests
+
+Single file =tests/test-gptel-tools-git-tools.el=, replacing the
+three current per-tool test files. Real temp git repos via
+=process-file= (same pattern as current tests). Coverage per tool:
+Normal / Boundary / Error.
+
+Rough count: ~12 shared-helper tests (validator, insert wrapper,
+truncate) + ~7 per tool × 6 tools = ~54 tests total.
+
+* Migration
+
+1. Delete =gptel-tools/git_status.el=, =git_log.el=, =git_diff.el=.
+2. Delete =tests/test-gptel-tools-git-status.el=,
+ =test-gptel-tools-git-log.el=, =test-gptel-tools-git-diff.el=.
+3. Create =gptel-tools/git_tools.el= containing all six tools +
+ shared helpers.
+4. Create =tests/test-gptel-tools-git-tools.el=.
+5. Update =cj/gptel-local-tool-features= in =modules/ai-config.el=:
+ replace the three =git_*= symbols with one =git_tools= symbol
+ (or six if each tool wants its own feature file -- decide during
+ implementation).
+6. Make sure =modules/ai-config.el= can re-load without breaking the
+ live gptel session if the old tool symbols are still registered
+ from a prior Emacs.
+
+* Risks
+
+| Risk | Mitigation |
+|-------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------------|
+| Magit load slows first git_* tool call | One-time hit per session, scoped to the tool's :function body. Acceptable for opt-in tools. |
+| Tool registration name collision with the old git_* symbols | Use distinct names (git_status / git_log / git_diff stay; new tools join them). Or remove + restart. |
+| =magit-toplevel= behavior on TRAMP / remote paths | Validator rejects paths outside HOME first, so TRAMP paths can't reach magit-toplevel. |
+| =git_blame= exposes code surfaces the model shouldn't read | =:confirm nil= is the wrong posture if blame is sensitive. Open question for review. |
+| =git_show= reveals past-self commit message wording | Same as blame -- low risk on personal repo, but worth flagging. |
+
+* Open questions
+
+1. Build all six tools in one push, or phase status/log/diff first
+ and add blame/show/branches in a follow-up? My read: one push.
+ The helpers are shared, marginal cost of three more tools is
+ small, and the model gets meaningfully more useful git context.
+2. Consolidate the output-truncation helper into =system-lib.el=,
+ touching =web_fetch.el= and =update_text_file.el= for a cleaner
+ API? Or defer that to a separate refactor commit?
+3. =git_blame= and =git_show= -- =:confirm nil= or =:confirm t=?
+ Personal repo lowers the stakes but the model could ask for
+ blame on /any/ file under HOME.
+4. Tool feature symbols: one =git_tools= entry in
+ =cj/gptel-local-tool-features=, or six (one per tool)?
+ Currently each tool lives in its own provide-symbol file. With
+ the single-file design we'd register one feature symbol that
+ loads all six.
+
+* Effort estimate
+
+M (1-3 hours). Helpers + six tool wrappers + ~50 tests + migration.
+Most of the time is test authoring; the production code is small
+because magit absorbs the boilerplate.
diff --git a/archive/gptel/docs-specs/gptel-network-tools-spec.org b/archive/gptel/docs-specs/gptel-network-tools-spec.org
new file mode 100644
index 000000000..c28d54694
--- /dev/null
+++ b/archive/gptel/docs-specs/gptel-network-tools-spec.org
@@ -0,0 +1,411 @@
+:PROPERTIES:
+:ID: 6388588c-dac2-4c52-97ad-2343ba1443fc
+:STATUS: not-started
+:END:
+#+TITLE: Design: gptel network tools
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-16
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft. Brainstorm output captured from a =/brainstorm= session on
+2026-05-16. Sibling to
+=docs/specs/gptel-git-tools-magit-backend-spec.org= and the broader theme
+hierarchy under =** TODO [#B] GPTel Tool Work= in =todo.org=.
+
+The conventional vs tail-sample exploration covered three categories
+(network, text/data, build/code). Network was selected as the next
+build target; this doc captures the network slice in full. The other
+two categories are referenced briefly and live as theme stubs under
+=*** TODO [#B] Filesystem Related Tools= and
+=*** TODO [#B] Development Workflow Related Tools= in =todo.org=.
+
+* Problem
+
+The current =gptel-tools/= set covers filesystem CRUD, web fetch, and
+git status/log/diff. When the user asks the agent "why can't I reach
+X?" or "what's on my LAN right now?" the agent has no affordances --
+it can only suggest commands the user runs manually.
+
+Network diagnosis is a recurring task on this laptop (homelab, mixed
+wifi/wired, occasional VPN, NetworkManager-managed connections). The
+agent should be able to run read-only network probes directly, return
+structured findings, and synthesize an explanation. Anything that
+mutates network state (=nmcli connection up=, route changes) stays
+behind =:confirm t=.
+
+* Non-goals
+
+- Active offensive scanning, vulnerability probes, or exploitation
+ tooling. Out of scope at the wrapper boundary -- nmap's
+ =-A=/=-O=/aggressive modes are rejected, NSE is deferred.
+- Scanning networks the user doesn't own. Public targets are gated
+ behind an explicit =external=t= flag and =:confirm t=.
+- Real-time/streaming inspection (=iftop=, =nethogs=, =tcpdump
+ follow=). Snapshot tools only; streaming tools don't fit the
+ request/response shape of gptel tools.
+- Replacing Magit's git tooling, mu4e's mail handling, or any other
+ Emacs-native workflow. Network tooling is the gap.
+
+* Approaches considered
+
+The =/brainstorm= run generated six candidate themes across three
+categories. Three conventional (high-prior), three tail samples
+(genuinely different regions of the option space). Network was
+chosen as the first build target; the others are recorded for
+follow-up sessions.
+
+** Recommended: network triage bundle (conventional #1)
+
+Five tools covering discovery, diagnostics, and inspection:
+
+| Tool | Purpose |
+|-------------------+--------------------------------------------------|
+| =net_diagnose= | "Why can't I reach X?" -- composite probe |
+| =net_discover= | "What's on this subnet?" -- LAN host discovery |
+| =net_services= | "What's listening on host X?" -- service detect |
+| =network_status= | "What's my current network state?" -- snapshot |
+| =dns_lookup= | Typed DNS query (A/AAAA/MX/NS/TXT/SRV/CAA) |
+
+Detailed in =* Design= below.
+
+*** Pros
+
+- Hits the highest-leverage daily question (connectivity diagnosis)
+ with a single mental entry point (=net_diagnose=).
+- Atomic tools (=dns_lookup=, =network_status=) for cases the
+ composite is too coarse for.
+- All read-only at the network layer; =:confirm nil= for RFC1918,
+ =:confirm t= for public targets.
+- nmap's two genuinely-unique capabilities (subnet discovery, service
+ enumeration) get first-class wrappers.
+
+*** Cons
+
+- Five tools is heavy for one category. Some are thin wrappers around
+ a single command.
+- Composite =net_diagnose= hides which sub-check fired; debugging the
+ tool itself is harder than debugging atomic tools.
+- nmap is the one tool that *can* get the user in trouble. Target
+ gating must be airtight or it's the wrong tool to ship.
+
+** Rejected: code-quality fan-out (conventional #2)
+
+=shellcheck_run=, =format_check= (black/prettier/gofmt/rustfmt/elisp,
+returns unified diff), =lint_run= (eslint/ruff/golangci-lint),
+=dot_render=, =mermaid_render=.
+
+Folded into =*** TODO [#B] Development Workflow Related Tools= as
+per-language work rather than a standalone bundle. Most of the per-
+language wins land in the existing prog-*.el modules' format-on-save
+and LSP attachments; the agent benefits more from /reading/ those
+buffers than from re-running the formatters via tool calls.
+
+** Rejected: GitHub workspace (conventional #3)
+
+=gh_pr_view=, =gh_issue_search=, =gh_run_logs=, =gh_pr_diff=.
+
+Overlaps with the magit-backend track (=gptel-git-tools-magit-backend=)
+for several queries. Better treated as a follow-on once the magit
+backend lands -- some queries are local (magit) and some are remote
+(gh), and the seam is clearer after the local side is built.
+
+** Rejected: DNS-chain inspector (tail sample)
+
+=dns_chain= walks NS -> A/AAAA -> MX -> SPF -> DMARC -> DKIM for a
+domain and returns a structured assessment with red flags ("MX
+missing TLS-RPT", "SPF includes >10 lookups", "DMARC policy=none").
+
+Real value when it's useful but probably 5 calls/year for this
+laptop. =dns_lookup= covers 90% of the recurring need; the chain
+walker is parked for a possible follow-on.
+
+** Rejected: awk_eval / sed_eval with explanation (tail sample)
+
+Accept snippet + sample input, return both the transformed output and
+a plain-English explanation of what the snippet does.
+
+Doubles work the model already does internally -- the model is
+already good at generating and explaining awk/sed. Real win would
+only be the actual execution against actual data, which the eshell
+escape hatch in the Filesystem section already covers.
+
+** Adopted as project convention: plan/apply split (tail sample)
+
+=rsync_plan= / =rsync_apply= split: plan always runs =--dry-run= and
+returns the file list and byte counts that *would* transfer; apply is
+a separate tool registration with =:confirm t=. Same shape for
+=nmcli= (status read vs connection mutate) and any other mutating
+tool.
+
+Promoted to a documented convention rather than a single tool: any
+mutating wrapper in =gptel-tools/= should split into a preview and an
+apply. The preview is =:confirm nil= so the agent can plan
+autonomously; the apply is =:confirm t= and stops cleanly for human
+review. Applies to =rsync=, =nmcli connection up=, =ssh= mutations,
+and the pandoc/ffmpeg/imagemagick output-writing tools in the
+Filesystem section.
+
+* Design
+
+** Tool 1: =net_diagnose=
+
+Composite "why can't I reach X?" probe. Given a target (hostname or
+IP), runs a sequence of sub-checks and returns a structured result:
+
+1. =dig +short= on the name (skip if target is an IP literal).
+2. =ping -c 3 -W 2= against the resolved IP.
+3. =traceroute -n -w 2 -q 1 -m 20= to the IP.
+4. If a port is given: =curl --max-time 5 -o /dev/null -sw '%{http_code}\n'=
+ for ports 80/443, or =nc -zv -w 3= for arbitrary TCP ports.
+
+Output shape (alist or plist returned to the model):
+
+#+begin_src text
+ ((target . "example.com")
+ (resolved-to . "93.184.216.34")
+ (dns-time-ms . 12)
+ (ping . ((sent . 3) (received . 3) (avg-ms . 14.2)))
+ (traceroute . ((hops . 8) (last-hop . "93.184.216.34")))
+ (port-check . ((port . 443) (status . "200") (tls . "ok"))))
+#+end_src
+
+Caps: total runtime <30s. Each sub-check has its own timeout. If a
+sub-check fails (no ping reply, no route, no DNS), the field carries
+the failure mode rather than aborting the whole call -- the agent
+needs the partial picture to reason.
+
+=:confirm nil=. Read-only.
+
+** Tool 2: =net_discover=
+
+Wraps =nmap -sn <subnet>= for LAN host discovery. Two argv shapes:
+
+- =net_discover ()= -- defaults to the current LAN, derived from
+ =ip route get 1.1.1.1= and the matching interface's =/24=.
+- =net_discover :subnet "192.168.1.0/24"= -- explicit subnet.
+
+Guardrails:
+
+- Subnet must be RFC1918, link-local (169.254/16), CGNAT (100.64/10),
+ or loopback. Public subnets rejected at the validator.
+- Subnet mask must be /22 or smaller (no /16 or wider). At /22 that's
+ ~1024 hosts -- enough for any homelab. Default home network is /24.
+- =--host-timeout 30s --max-retries 1= to bound runtime.
+
+Output: list of =(ip mac hostname state)= tuples.
+
+=:confirm nil= for RFC1918 / link-local / CGNAT / loopback. Public
+subnets never reach this tool (validator rejects).
+
+** Tool 3: =net_services=
+
+Wraps =nmap -sV= for service/version detection on a single host.
+
+Argv:
+
+- =:host= -- required. RFC1918 / link-local / CGNAT / loopback by
+ default. Public hosts require =:external t= which flips
+ =:confirm t=.
+- =:ports= -- optional port spec. Default: top-100 (=--top-ports
+ 100=). Custom lists allowed: ="22,80,443,5432,6379"= or
+ ="1-1024"=. Hard cap: 1024 ports total.
+- =:fast= -- if t, uses =--top-ports 20= for a quick check.
+
+Mode allowlist enforced at the wrapper: only =-sV= with optional
+=-p=. Reject =-A=, =-O=, =-T4=/=-T5=, =--script=, raw-packet flags.
+
+Output: list of =(port protocol state service version banner)=
+tuples, parsed from =-oG -= (greppable output).
+
+=:confirm nil= for RFC1918 / link-local / CGNAT / loopback.
+=:confirm t= for any target reachable only as a public IP/hostname.
+
+** Tool 4: =network_status=
+
+Snapshot of the local network state. Composite of:
+
+- =ip -br addr= -- interfaces and their addresses.
+- =ip route= -- routing table.
+- =nmcli -t -f NAME,TYPE,DEVICE,STATE connection show --active= --
+ active NetworkManager connections.
+- =ss -tulpn= (or =netstat -tulpn= fallback) -- listening sockets.
+- =resolvectl status= (or =/etc/resolv.conf= fallback) -- DNS
+ resolver state.
+
+Output: structured alist with sections for each.
+
+=:confirm nil=. Read-only.
+
+Note: this is also the candidate target for the plan/apply split if
+=nmcli connection up=/=down= ever lands as a tool -- =network_status=
+becomes the "plan" side and any mutation is a separate tool.
+
+** Tool 5: =dns_lookup=
+
+Typed DNS query. Argv:
+
+- =:name= -- required. The DNS name to query.
+- =:type= -- record type. Default =A=. Allowed: =A=, =AAAA=, =MX=,
+ =NS=, =TXT=, =SRV=, =CAA=, =CNAME=, =PTR=, =SOA=.
+- =:server= -- optional resolver. Default uses system resolver.
+ When set, must be RFC1918 or one of a small allowlist (=1.1.1.1=,
+ =8.8.8.8=, =9.9.9.9=) so the tool can't be used to probe arbitrary
+ hosts via DNS.
+
+Output: list of records with TTL. For =MX= and =SRV=, includes
+priority/weight/port. For =TXT=, the records are split into the
+quoted segments dig returns.
+
+=:confirm nil=. Read-only.
+
+** Shared helpers
+
+In =gptel-tools/network_tools.el= (single file, mirrors the
+magit-backend plan for git tools):
+
+- =cj/gptel-net--validate-target HOST &optional ALLOW-PUBLIC=
+ - Resolves HOST. Rejects unless resolved IP is RFC1918 /
+ link-local / CGNAT / loopback, unless ALLOW-PUBLIC is non-nil.
+ - Returns the resolved IP on success.
+
+- =cj/gptel-net--validate-subnet CIDR=
+ - Rejects non-private subnets and subnets wider than /22.
+ - Returns =(network mask)= on success.
+
+- =cj/gptel-net--current-lan=
+ - Derives the current /24 from =ip route get 1.1.1.1=.
+
+- =cj/gptel-net--run ARGS &key TIMEOUT=
+ - Wraps =process-file= with a uniform timeout, color/encoding
+ posture, and structured return =(exit-code stdout stderr)=.
+
+- =cj/gptel-net--parse-nmap-greppable STRING=
+ - Parses nmap =-oG -= output into structured tuples.
+
+- =cj/gptel-net--truncate TEXT MAX-BYTES=
+ - Same shape as the existing per-tool truncate helpers. Open
+ question whether this consolidates into =system-lib.el= alongside
+ the matching helpers in =web_fetch.el= and =update_text_file.el=.
+
+** Caps
+
+| Tool | Default cap | Hard cap |
+|------------------+------------------------+------------------------|
+| =net_diagnose= | <30s total runtime | <30s total runtime |
+| =net_discover= | /24 default, /22 max | /22 |
+| =net_services= | top-100 ports | 1024 ports |
+| =network_status= | uncapped (snapshot) | uncapped |
+| =dns_lookup= | uncapped | uncapped |
+
+** =:confirm= posture
+
+| Tool | RFC1918 target | Public target |
+|------------------+-------------------+-------------------------|
+| =net_diagnose= | =:confirm nil= | =:confirm t= |
+| =net_discover= | =:confirm nil= | rejected at validator |
+| =net_services= | =:confirm nil= | =:confirm t= |
+| =network_status= | =:confirm nil= | n/a (local snapshot) |
+| =dns_lookup= | =:confirm nil= | =:confirm nil= |
+
+=dns_lookup= stays =:confirm nil= for public names because DNS is
+read-only and innocuous. =net_diagnose= and =net_services= against
+public targets are gated because pinging/probing public hosts isn't
+*illegal* but it can trip rate-limits or get the user flagged on a
+managed network.
+
+** Tests
+
+Single file =tests/test-gptel-tools-network-tools.el=. Real subnets
+are not available in CI, so:
+
+- =net_discover= and =net_services= are stubbed via =cl-letf= on
+ =cj/gptel-net--run=, returning canned nmap output. Real nmap
+ invocation tested via one =:tags '(:integration)= test that runs
+ =nmap -sn 127.0.0.1/32= and asserts the parser handles the real
+ format.
+- =net_diagnose= sub-checks stubbed individually so each failure mode
+ can be exercised.
+- =network_status= sections stubbed per-command; one integration test
+ runs against the live system and asserts the structure parses.
+- =dns_lookup= stubbed against canned =dig= output; one integration
+ test against =localhost= via the system resolver.
+
+Rough count: ~12 shared-helper tests (validators, current-lan
+detector, parsers) + ~7 per tool x 5 tools = ~47 tests.
+
+** Risk surface
+
+| Risk | Mitigation |
+|-----------------------------------------------------------+---------------------------------------------------------------------|
+| nmap scan against an unintended target | Validator gates on resolved IP, not on the input string. Public |
+| | targets require explicit =:external t= flag + =:confirm t=. |
+| Scan triggers IDS/IPS on a corporate/managed network | Default modes are non-aggressive (=-sn=, =-sV= only). No =-A=, no |
+| | NSE, no high T-level. =:confirm t= for non-RFC1918 targets gives |
+| | the user a manual checkpoint. |
+| =net_diagnose= hangs on a slow target | Per-sub-check timeouts; total runtime cap; partial-failure return |
+| | rather than abort. |
+| nmap not installed on the system | =:command= check at module load via =cj/executable-find-or-warn= |
+| | (matching the prettier/pyright pattern documented in CLAUDE.md). |
+| Network tools shell out via =process-file= | argv-list invocation, no shell. =shell-quote-argument= unused |
+| | because no shell is involved. |
+| /tmp pollution or banner output writing to disk | All output captured to buffer via =process-file=, never written. |
+
+* Open questions
+
+1. *Default port set for =net_services=.* Top-100 (nmap default),
+ top-1000 (full default scan, slower), or a custom homelab-tuned
+ list (=22, 80, 443, 445, 3389, 5432, 6379, 8080, 8443, 9090, 9000,
+ 631=)? My read: top-100 default + =:fast t= for top-20 + custom
+ override for the homelab list when needed.
+2. *NSE in v1 or deferred?* Skip entirely (clean v1) or ship a small
+ allowlist (=ssl-cert=, =http-title=, =ssh-hostkey=)? My read:
+ skip in v1. If a real use case shows up (TLS audit), add a single
+ =net_tls_audit= tool wrapping just =ssl-enum-ciphers=/=ssl-cert=
+ rather than a generic NSE escape hatch.
+3. *Consolidate the truncate helper.* Same open question as the
+ magit-backend doc: move =cj/gptel-net--truncate= and its siblings
+ into =system-lib.el= as =cj/gptel-tools--truncate-bytes=, or keep
+ per-module? My read: consolidate when there are three callers
+ (web_fetch, update_text_file, network_tools all qualify).
+4. *Composite vs atomic for =net_diagnose=.* Build it as one
+ composite, or break it into =ping_run=, =traceroute_run=,
+ =port_check= and let the agent compose? My read: composite is
+ better -- the agent reasons in "diagnose-this-target" terms more
+ often than in "just-ping-this". Atomic sub-tools can be added
+ later if the composite proves coarse-grained.
+5. *Promote plan/apply split to documented convention now?* Or wait
+ until a second tool exercises it (post-rsync)? My read: document
+ the convention in the Filesystem section body now, since pandoc /
+ ffmpeg / imagemagick all benefit, even before any of them ship.
+6. *nmcli mutation tools.* Out of scope for this doc but worth
+ flagging: =nmcli connection up <name>= / =nmcli connection down
+ <name>= / =nmcli device wifi connect <ssid>=. These would be the
+ first apply-side tools under the plan/apply convention, with
+ =network_status= as the plan side.
+
+* Effort estimate
+
+M (1-3 hours). Five tools + shared helpers + ~47 tests. Most of the
+time is test authoring (canned nmap output, dig output, ss output);
+production code is small because each tool is a thin =process-file=
+wrapper plus a parser.
+
+* Next steps
+
+- Resolve open questions #1 and #2 before any code lands (the
+ =net_services= shape can't be finalized without them).
+- Once approved, the work attaches to =*** TODO [#B] (Network bundle:
+ net_diagnose / net_discover / net_services / network_status /
+ dns_lookup)= -- a new theme under =*** TODO [#B] (Networking tools
+ category)= which itself becomes a new top-level under =** TODO [#B]
+ GPTel Tool Work= in =todo.org=, peer to the existing Filesystem
+ section.
+- Implementation follows =/start-work= flow: TDD, characterization
+ tests for the parsers first (canned nmap/dig/ss fixtures), then
+ the wrappers, then the registrations in
+ =cj/gptel-local-tool-features=.
+- After landing, revisit candidate #6 (plan/apply split) -- the
+ first apply-side tool (=nmcli connection up=, =rsync_apply=,
+ pandoc-output) exercises the convention end-to-end.
diff --git a/archive/gptel/docs-specs/mcp-el-gptel-integration-spec-doing.org b/archive/gptel/docs-specs/mcp-el-gptel-integration-spec-doing.org
new file mode 100644
index 000000000..f22e91959
--- /dev/null
+++ b/archive/gptel/docs-specs/mcp-el-gptel-integration-spec-doing.org
@@ -0,0 +1,1438 @@
+:PROPERTIES:
+:ID: b4c274c5-8572-4a7b-b657-d315712bd6af
+:STATUS: doing
+:END:
+#+TITLE: Design: Wire mcp.el into GPTel for MCP server access
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-16
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft (revision 3). Pre-implementation; no code shipped yet. The
+mcp.el package is cloned at =~/code/mcp.el/= (fork of
+[[https://github.com/lizqwerscott/mcp.el][lizqwerscott/mcp.el]]) but not wired into the config.
+
+Revision 3 tightens seven contracts the revision-2 review flagged:
+
+1. *GPTel confirmation* -- =gptel-confirm-tool-calls= is =nil= at
+ =ai-config.el:386=, which short-circuits every per-tool
+ =:confirm= slot. The integration flips it to =auto= as a hard
+ precondition.
+2. *Async timeout mechanics* -- replaced =with-timeout= (which
+ only supervises dynamic extent) with an explicit timer/callback
+ race for async tool calls.
+3. *Startup completion semantics* -- the hub's completion callback
+ is opportunistic, not authoritative; the stall timer + polling
+ =mcp-server-connections= is the source of truth.
+4. *Server identity at registration* -- walk
+ =mcp-server-connections= directly instead of parsing
+ =:category mcp-SERVER= out of =mcp-hub-get-all-tool=.
+5. *Server enablement* -- =cj/mcp-enabled-servers= defcustom lets
+ users disable a server without writing code. Profiles still
+ deferred.
+6. *Keymap pinned* -- =C-; a C= (Connect) is the MCP subprefix.
+ =M= (=gptel-menu=) and =m= (=cj/gptel-change-model=) stay
+ where they are.
+7. *mcp.el private-API isolation* -- a compat layer wraps every
+ =mcp--*= call so version drift surfaces in one place.
+
+Plus several smaller changes: every MCP tool registers async,
+description normalization adds a server-name prefix and a write
+risk note, =cj/mcp-start-on-entry-points= defcustom scopes
+startup triggers (default: full chat only), TRAMP processes
+local-only, doctor gains live-auth-check, =cj/mcp-wait-until-ready=
+command added, audit buffer surfaces failed servers prominently.
+
+* Problem
+
+GPTel exposes ten local tools today (=read_buffer=, =read_text_file=,
+=write_text_file=, =update_text_file=, =list_directory_files=,
+=move_to_trash=, =git_status=, =git_log=, =git_diff=, =web_fetch= --
+see =gptel-tools/=). Claude Code, by contrast, has access to nine
+external MCP servers (linear, notion, figma, slack-deepsat,
+google-calendar, google-docs-personal, google-docs-work, drawio,
+google-keep), each exposing 10-70 additional tools.
+
+The asymmetry means agentic work done in GPTel can't touch the same
+external systems Claude Code can. Wiring [[https://github.com/lizqwerscott/mcp.el][mcp.el]] into the config
+closes the gap: GPTel gains access to every MCP server Claude Code
+uses, modulo three claude.ai-hosted servers whose OAuth is bound to
+the Claude.ai session (see Non-Goals).
+
+* Goals
+
+1. GPTel sees every tool from the enabled subset of nine reusable
+ MCP servers in =gptel-menu=, grouped by server via the tool's
+ =:category= field.
+2. Servers spawn *asynchronously*. Opening GPTel never blocks on
+ MCP startup; tools arrive incrementally and =gptel-tools= updates
+ as each server reports its inventory. =cj/toggle-gptel= must
+ return without waiting for any MCP subprocess.
+3. Write/destructive MCP tools are gated by a confirmation prompt
+ the user actually sees. Two preconditions:
+ =gptel-confirm-tool-calls= is set to =auto= (so the per-tool
+ =:confirm= slot is honored), and write/destructive tools are
+ registered with =:confirm t=. Read-only tools execute without
+ confirmation.
+4. Secrets stay in =~/.claude.json= (single source of truth, shared
+ with Claude Code). The Emacs config reads env vars from there
+ at server-spawn time, with an mtime-aware cache. Secrets are
+ never echoed to status, errors, hub buffers, or tests.
+5. A per-server status alist tracks each server's lifecycle (idle /
+ starting / ready / failed / stopped) and is inspectable via
+ =cj/mcp-status= and a =cj/mcp-list-tools= audit buffer.
+6. Server-management commands live under a =C-; a C= (Connect)
+ subprefix so existing GPTel keys (=C-; a M=, =m=) aren't
+ disturbed.
+7. A failed server (network down, OAuth token expired, npx package
+ 404) is surfaced clearly via the OAuth-recovery pattern matcher
+ and does not block GPTel itself. Successful servers' tools are
+ available immediately; failed servers' tools are absent (not
+ stale).
+8. The config can swap between MELPA mcp.el and the local
+ =~/code/mcp.el/= checkout with a one-line uncomment, gated by a
+ capability check that asserts required API functions exist.
+9. A first-run =cj/mcp-doctor= command diagnoses missing
+ prerequisites (=npx=, =uvx=, =~/.claude.json=, per-server
+ commands, known local endpoints) and optionally runs a
+ live-auth probe before they fail at runtime.
+
+* Non-Goals
+
+- The three claude.ai-hosted MCP servers (Gmail / Drive / Calendar
+ served from =*.googleapis.com/mcp/v1=). Their OAuth is issued
+ by the Claude.ai session and is not transferable to GPTel.
+- *MCP resources and prompts.* v1 registers tools only.
+ Resource browsing and prompt invocation are tracked as
+ follow-ups; the local checkout has the API surface ready.
+- *Per-conversation tool profiles.* v1 ships
+ =cj/mcp-enabled-servers= for whole-server enable/disable;
+ profiles (different tool subsets per chat) wait for v1.5 once
+ usage shows whether they're needed.
+- *Auth-source migration.* Deferred until the OAuth re-auth flow
+ for expiring tokens is understood. Tracked in Open Questions
+ §Q3.
+- *Automated OAuth re-auth when tokens expire.* Out of scope; the
+ user re-authenticates via Claude Code, and the next GPTel
+ invocation picks up the refreshed values from =~/.claude.json=.
+- *Modifying mcp.el itself in this repo.* Upstream patches and
+ tests live in =~/code/mcp.el/= and ship via PRs to lizqwerscott's
+ master.
+
+* Verified API Contracts
+
+These were checked against the actual source before each revision.
+Behavior summarized here so implementation can rely on it.
+
+** GPTel confirmation gating (=gptel.el:2244=)
+
+The confirmation check is:
+
+#+begin_src emacs-lisp
+(if (and gptel-confirm-tool-calls
+ (or (eq gptel-confirm-tool-calls t)
+ (gptel-tool-confirm tool-spec)))
+ ;; ask user
+ ...)
+#+end_src
+
+When =gptel-confirm-tool-calls= is =nil=, the =(and ...)=
+short-circuits and the tool's =:confirm= slot is ignored.
+
+The defcustom default is ='auto=, which "seeks confirmation only
+when the corresponding tool spec has a non-nil :confirm slot"
+(=gptel.el:1601-1603=). =ai-config.el:386= currently sets it to
+=nil=.
+
+*Implementation consequence:* =ai-mcp.el= must =setq
+gptel-confirm-tool-calls 'auto= as part of its setup (and
+=ai-config.el= drops the explicit =nil= setting). Without this,
+write-gated tools register =:confirm t= and gptel ignores it.
+
+** mcp-hub callback ownership (=~/code/mcp.el/mcp-hub.el:53-90=)
+
+=mcp-hub--start-server= unconditionally appends its own six
+callbacks (=:initial-callback=, =:tools-callback=,
+=:prompts-callback=, =:resources-callback=,
+=:resources-templates-callback=, =:error-callback=) to whatever
+the caller passes. Per-server custom callbacks in the alist
+result in duplicate keyword arguments to =mcp-connect-server= --
+behavior implementation-defined.
+
+*Implementation consequence:* the integration does not slip custom
+callbacks through =mcp-hub-servers=. It uses
+=mcp-hub-start-all-server='s top-level completion callback as an
+opportunistic signal, walks =mcp-server-connections= directly for
+authoritative state, and uses a stall timer as the deadline.
+
+** mcp-hub-start-all-server completion semantics
+
+The hub's completion callback (=mcp-hub-start-all-server='s
+=CALLBACK= argument) fires when its internal counter reaches the
+total server count. The counter increments:
+
+- On immediate Elisp errors from =mcp-hub--start-server=.
+- When the =:inited-callback= passed to =mcp-hub--start-server=
+ fires, which happens inside the hub's =:tools-callback=.
+
+Async error paths flow through =:error-callback= -- which the hub
+also installs but does *not* obviously chain into the inited
+callback. Servers without tools may not pass through the tools
+callback in the same way.
+
+*Implementation consequence:* the callback is treated as an
+opportunistic readiness signal, *not* as "all initialized or
+failed". The authoritative state comes from polling
+=mcp-server-connections= (each entry has =mcp--status= of
+=connected= / =error= / =starting=) and from the stall timer
+deadline.
+
+** gptel-make-tool registration semantics (=gptel.el:1729-1820=)
+
+=gptel-make-tool= registers the tool into =gptel--known-tools=
+keyed by category + name. It does *not* add the tool to
+=gptel-tools= (the per-buffer active list). The existing local
+tools (=gptel-tools/git_log.el:97=) explicitly do:
+
+#+begin_src emacs-lisp
+(gptel-make-tool ...)
+(add-to-list 'gptel-tools (gptel-get-tool '("category" "name")))
+#+end_src
+
+*Implementation consequence:* the registration pipeline does
+both calls per tool, tracks tool names per server in a hash, and
+deregisters cleanly on restart/stop without disturbing local
+tools.
+
+** MELPA vs local checkout
+
+The local checkout (=~/code/mcp.el/=, tip =f10768e=) has HTTP
+transport (=mcp-http-process-connection=) and recent UX
+improvements (resource reading, imenu support, detail mode).
+MELPA parity not yet verified.
+
+*Implementation consequence:* =cj/mcp--assert-capabilities=
+checks for required functions at load time and signals a clear
+=user-error= if missing. Use-package block defaults to MELPA;
+the local-checkout =:load-path= line stays commented until the
+capability check tells us MELPA is missing something.
+
+* GPTel Confirmation Contract
+
+The single most consequential precondition for the safety story:
+
+** Current state
+
+=modules/ai-config.el:386= sets =gptel-confirm-tool-calls= to
+=nil=. This was a deliberate "allow tool access by default"
+choice when only the ten local tools existed -- all of which are
+either read-only (git_log, git_status, list_directory_files, etc.)
+or already wrap their own confirm prompts (web_fetch uses
+=:confirm t= but is ignored under the current setting; this was
+acceptable because the only "real" tool there is on a user-typed
+URL).
+
+** Required state
+
+The MCP integration cannot ship without flipping this to =auto=.
+Specifically, =modules/ai-mcp.el= must:
+
+1. =setq gptel-confirm-tool-calls 'auto= during its load.
+2. Audit the existing local tools and add =:confirm t= to any
+ that should be gated. =web_fetch= is the obvious candidate;
+ =write_text_file=, =update_text_file=, =move_to_trash= may
+ also warrant it depending on Craig's preference.
+
+The existing =ai-config.el:386= line is removed. A comment
+points readers at =ai-mcp.el= for the new value.
+
+** Verification test
+
+A test in =tests/test-ai-mcp-confirm-contract.el= asserts:
+
+- After =ai-mcp= loads, =gptel-confirm-tool-calls= is ='auto=.
+- A write-classified MCP tool registered with =:confirm t= takes
+ the confirmation branch in =gptel-send='s tool-dispatch code
+ (verified by stubbing gptel's confirm-prompt and checking it
+ fires).
+- A read-classified MCP tool registered with =:confirm nil= does
+ not take the confirmation branch.
+- Local =git_log= (=:confirm nil=) still runs without prompting.
+
+* Current State
+
+** =modules/ai-config.el=
+
+- =use-package gptel= block at lines 363-414, defer-loaded on the
+ =gptel= / =gptel-send= / =gptel-menu= commands.
+- =gptel-confirm-tool-calls nil= at line 386. Removed by this
+ integration; see § GPTel Confirmation Contract.
+- =cj/gptel-load-local-tools= (lines 71-96) loads the ten local
+ tools from =gptel-tools/=.
+- =cj/toggle-gptel= (lines 418-441) is the primary entry point
+ (=C-; a t=). Other entry points: =cj/gptel-quick-ask=
+ (=C-; a q=), =gptel-magit-commit-generate= (=g= in magit),
+ =cj/gptel-rewrite-with-directive= (=C-; a r=), =gptel-send=
+ (=C-RET= in gptel buffer).
+- =cj/ai-keymap= (lines 510-528) currently uses keys A B M d . f
+ b l m p q r R c s t x. =C= (uppercase) is free and becomes the
+ MCP subprefix.
+
+** =gptel-tools/=
+
+Ten =.el= files. =git_log.el= is the closest analogue;
+=web_fetch.el= demonstrates the =:confirm t= pattern.
+
+** =~/.claude.json=
+
+Mode 0600, ~75 KB. Top-level =mcpServers= key holds the nine
+servers we want. Env-var names per server (values redacted):
+
+| Server | Env vars |
+|----------------------+-------------------------------------------------------|
+| google-calendar | =GOOGLE_OAUTH_CREDENTIALS= |
+| google-docs-personal | =GOOGLE_CLIENT_ID=, =GOOGLE_CLIENT_SECRET=, =GOOGLE_MCP_PROFILE= |
+| google-docs-work | Same three vars (different values) |
+| google-keep | =GOOGLE_EMAIL=, =GOOGLE_MASTER_TOKEN= |
+
+* Design
+
+** Module split
+
+Implementation lives in =modules/ai-mcp.el=. =modules/ai-config.el=
+gains only autoload declarations and the =C-; a C= subprefix
+wiring.
+
+** Code organization outline (=ai-mcp.el=)
+
+The file is organized in seven sections so it stays readable as
+features land:
+
+1. *Constants and defcustoms* -- =cj/mcp-server-specs=,
+ =cj/mcp-claude-config=, =cj/mcp-enabled-servers=,
+ =cj/mcp-start-on-entry-points=, =cj/mcp-startup-timeout=,
+ =cj/mcp-tool-timeout=, =cj/mcp-tool-confirm-overrides=,
+ audit-log defcustoms.
+2. *Public commands* -- =cj/mcp-ensure-started=, =cj/mcp-hub=,
+ =cj/mcp-status=, =cj/mcp-list-tools=,
+ =cj/mcp-restart-failed=, =cj/mcp-restart-server=,
+ =cj/mcp-stop-all=, =cj/mcp-doctor=,
+ =cj/mcp-wait-until-ready=.
+3. *Pure helpers* -- Claude config reader, =cj/mcp--build-server-alist=,
+ =cj/mcp--redact=, =cj/mcp--confirm-p=,
+ =cj/mcp--normalize-description=.
+4. *mcp.el compatibility layer* -- 3-5 wrappers around private
+ API (=mcp--status=, =mcp--tools=, etc.). Single source of
+ version-drift risk.
+5. *Registration pipeline* -- =cj/mcp--register-tool=,
+ =cj/mcp--register-server-tools=,
+ =cj/mcp--deregister-server-tools=,
+ =cj/mcp--registered-tools= hash.
+6. *Async state machine* -- =cj/mcp--state=,
+ =cj/mcp--server-status=, =cj/mcp--on-all-started=,
+ =cj/mcp--stall-timer=, =cj/mcp--poll-status=.
+7. *UI* -- audit-buffer mode, doctor buffer, recovery-pattern
+ matcher, response prefixes.
+
+This explicit outline doubles as the file's table of contents in
+its commentary block.
+
+** Server inventory: data first
+
+The nine servers are described as a defconst of plists, with no
+secrets baked in:
+
+#+begin_src emacs-lisp
+(defconst cj/mcp-server-specs
+ '((:name "linear"
+ :transport http
+ :url "https://mcp.linear.app/mcp"
+ :auth in-protocol
+ :risk write-capable)
+ (:name "notion"
+ :transport http
+ :url "https://mcp.notion.com/mcp"
+ :auth in-protocol
+ :risk write-capable)
+ (:name "figma"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "figma-developer-mcp" "--stdio")
+ :secret-args ("--figma-api-key" :figma-api-key)
+ :auth args-token
+ :risk arg-leak)
+ (:name "slack-deepsat"
+ :transport sse
+ :url "http://127.0.0.1:13080/sse"
+ :auth local
+ :risk write-capable)
+ (:name "drawio"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@drawio/mcp")
+ :auth none
+ :risk none)
+ (:name "google-calendar"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@cocal/google-calendar-mcp")
+ :env (:GOOGLE_OAUTH_CREDENTIALS t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-docs-personal"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@a-bonus/google-docs-mcp")
+ :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-docs-work"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@a-bonus/google-docs-mcp")
+ :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-keep"
+ :transport stdio
+ :command "uvx"
+ :args ("--from" "keep-mcp" "python" "-m" "server.cli")
+ :env (:GOOGLE_EMAIL t :GOOGLE_MASTER_TOKEN t)
+ :auth token
+ :risk write-capable)))
+#+end_src
+
+The same data drives the doctor check list, status labels, and
+recovery messages -- a single source of truth keeps them from
+drifting.
+
+** Server enablement
+
+#+begin_src emacs-lisp
+(defcustom cj/mcp-enabled-servers
+ (mapcar (lambda (s) (plist-get s :name)) cj/mcp-server-specs)
+ "List of MCP server names to start.
+Defaults to every server in `cj/mcp-server-specs'. Set to a
+shorter list to disable specific servers without editing the
+spec. Changes take effect on next `cj/mcp-restart-failed' or
+Emacs restart."
+ :type '(repeat string)
+ :group 'cj)
+#+end_src
+
+=cj/mcp--build-server-alist= filters by this list before
+returning. A user who wants only =linear= and =drawio= sets:
+
+#+begin_src emacs-lisp
+(setq cj/mcp-enabled-servers '("linear" "drawio"))
+#+end_src
+
+This is the answer to "100+ tools is overwhelming" without
+needing per-conversation profiles.
+
+** Entry-point policy
+
+Not every GPTel entry point should trigger MCP startup. Quick
+ask, rewrite, and magit commit-message generation are
+lightweight; spinning up nine subprocesses for a 50-word commit
+message is surprising overhead.
+
+#+begin_src emacs-lisp
+(defcustom cj/mcp-start-on-entry-points
+ '(toggle-gptel)
+ "GPTel entry points that trigger MCP startup.
+Symbols correspond to commands: `toggle-gptel', `gptel-send',
+`gptel-quick-ask', `gptel-rewrite-with-directive',
+`gptel-magit-generate-message'. Default: only full chat
+(`toggle-gptel')."
+ :type '(repeat symbol)
+ :group 'cj)
+#+end_src
+
+Each entry-point command checks membership before calling
+=cj/mcp-ensure-started=:
+
+#+begin_src emacs-lisp
+(defun cj/toggle-gptel ()
+ ...
+ (when (memq 'toggle-gptel cj/mcp-start-on-entry-points)
+ (cj/mcp-ensure-started))
+ ...)
+#+end_src
+
+** Claude config reader (mtime-cached, structured returns)
+
+#+begin_src emacs-lisp
+(defcustom cj/mcp-claude-config
+ (expand-file-name "~/.claude.json")
+ "Path to the Claude Code config that holds MCP server env vars."
+ :type 'file
+ :group 'cj)
+
+(defvar cj/mcp--config-cache nil
+ "Cons of (MTIME . PARSED) for `cj/mcp-claude-config'.")
+
+(defun cj/mcp--read-claude-config ()
+ "Return a structured result describing the Claude config state.
+Result shape:
+ (:ok t :data PLIST)
+ (:ok nil :reason missing-file)
+ (:ok nil :reason unreadable)
+ (:ok nil :reason malformed-json :message STR)
+Cached by mtime; subsequent calls reparse only on change."
+ ...)
+#+end_src
+
+** mcp.el compatibility layer
+
+All private-API access lives in 3-5 helpers documented with the
+upstream commit they target. This is the only file that touches
+=mcp--*= names; everything else calls these wrappers.
+
+#+begin_src emacs-lisp
+;; ai-mcp-compat -- isolates private mcp.el API.
+;; Verified against upstream commit f10768e (2026-05-16).
+
+(defun cj/mcp--server-status (connection)
+ "Return CONNECTION's lifecycle status: connected, error, starting."
+ (mcp--status connection))
+
+(defun cj/mcp--server-tools (connection)
+ "Return CONNECTION's discovered tool list (plists)."
+ (mcp--tools connection))
+
+(defun cj/mcp--server-name (connection)
+ "Return CONNECTION's logical server name."
+ (jsonrpc-name connection))
+
+(defun cj/mcp--assert-capabilities ()
+ "Signal `user-error' if any required mcp.el function is missing."
+ (dolist (fn '(mcp-connect-server mcp-make-text-tool
+ mcp-hub mcp-hub-start-all-server
+ mcp-hub-get-all-tool mcp-server-connections))
+ (unless (fboundp fn)
+ (user-error "mcp.el too old; missing %s. Upgrade or switch \
+to local checkout in `ai-mcp.el' use-package block" fn))))
+#+end_src
+
+If mcp.el renames a slot or changes a return shape, only these
+helpers break. Tests cover each helper against stub objects.
+
+** Startup model: async + state machine + polling
+
+Three state structures capture lifecycle:
+
+#+begin_src emacs-lisp
+(defvar cj/mcp--state 'idle
+ "Overall MCP integration state: idle, starting, partial, ready, failed.")
+
+(defvar cj/mcp--server-status nil
+ "Alist mapping server name to status plist:
+ (:state STATE :tool-count N :tools (NAME ...) :last-error STR
+ :started-at TIME :ready-at TIME)")
+
+(defvar cj/mcp--stall-timer nil
+ "Timer guarding against servers that never call back.")
+#+end_src
+
+=cj/mcp-ensure-started= is the only entry point consumers call:
+
+#+begin_src emacs-lisp
+(defun cj/mcp-ensure-started ()
+ "Schedule MCP startup if it hasn't run yet this session.
+Returns immediately. Servers spawn asynchronously."
+ (when (eq cj/mcp--state 'idle)
+ (setq cj/mcp--state 'starting)
+ (cj/mcp--assert-capabilities)
+ (cj/mcp--build-status-from-specs)
+ (setq mcp-hub-servers (cj/mcp--build-server-alist))
+ (message "MCP: starting %d server(s) in background..."
+ (length mcp-hub-servers))
+ ;; The hub callback is opportunistic. We poll status on each
+ ;; tick and the stall timer is the authoritative deadline.
+ (mcp-hub-start-all-server #'cj/mcp--on-hub-callback nil nil)
+ (cj/mcp--start-stall-timer)))
+#+end_src
+
+State transitions and authority:
+
+- *Hub completion callback (opportunistic).* Triggers an
+ immediate poll + registration pass for whatever's ready. Does
+ not signal completion by itself.
+- *Stall timer (authoritative deadline).* After
+ =cj/mcp-startup-timeout= (default 30 s), marks every server
+ still in =starting= state as =failed= with reason =timeout=,
+ registers tools from servers that did become ready, transitions
+ =cj/mcp--state= to its final value (=ready=, =partial=, or
+ =failed=).
+- *Polling (authoritative state).* =cj/mcp--poll-status= walks
+ =mcp-server-connections= and maps each entry's =mcp--status= to
+ =cj/mcp--server-status=. Called from the hub callback and from
+ the stall timer. Servers that transitioned through
+ =:error-callback= (which the hub doesn't chain into the inited
+ callback) show up here.
+
+Properties:
+
+- =cj/mcp-ensure-started= returns in <100 ms regardless of
+ subprocess state.
+- =mcp-hub-start-all-server= itself is async (third =SYNCP= arg
+ is =nil=).
+- Servers transition independently; tools land in =gptel-tools=
+ as each server reports inventory.
+- A server that never connects, never errors, and never reports
+ tools is caught by the stall timer.
+
+** Tool registration pipeline
+
+Walks =mcp-server-connections= directly, per server, after each
+status poll. This gives clean per-server bookkeeping without
+parsing the =mcp-SERVER= category prefix:
+
+#+begin_src emacs-lisp
+(defun cj/mcp--register-server-tools (server-name)
+ "Register every tool from the connected server SERVER-NAME.
+Idempotent: re-registration replaces the function pointer
+without duplicating menu entries."
+ (let ((connection (gethash server-name mcp-server-connections)))
+ (when (and connection
+ (eq (cj/mcp--server-status connection) 'connected))
+ ;; First, deregister any existing tools for this server.
+ (cj/mcp--deregister-server-tools server-name)
+ (dolist (raw-tool (cj/mcp--server-tools connection))
+ (cj/mcp--register-tool server-name raw-tool))
+ (cj/mcp--update-server-status server-name :state 'ready))))
+
+(defun cj/mcp--register-tool (server-name raw-tool)
+ "Register one tool from SERVER-NAME.
+RAW-TOOL is the plist from `mcp--tools' (untransformed)."
+ (let* ((remote-name (plist-get raw-tool :name))
+ (gptel-name (format "mcp__%s__%s" server-name remote-name))
+ (description (cj/mcp--normalize-description
+ server-name raw-tool))
+ (confirm-p (cj/mcp--confirm-p gptel-name remote-name))
+ ;; mcp-make-text-tool builds the closure; we async by default.
+ (mcp-plist (mcp-make-text-tool server-name remote-name t))
+ ;; Rewrite name + description + confirm after mcp.el builds the closure.
+ (gptel-plist (cj/mcp--rewrite-plist
+ mcp-plist
+ :name gptel-name
+ :description description
+ :confirm confirm-p
+ :async t
+ :category (format "mcp-%s" server-name))))
+ (apply #'gptel-make-tool gptel-plist)
+ (add-to-list 'gptel-tools
+ (gptel-get-tool
+ (list (format "mcp-%s" server-name) gptel-name)))
+ (push gptel-name
+ (gethash server-name cj/mcp--registered-tools))))
+#+end_src
+
+Key properties:
+
+- *Async by default.* All MCP tools register with =:async t=.
+ This avoids any sync MCP tool call blocking Emacs during
+ =gptel-send='s tool dispatch. Per-call timeout uses the
+ timer-race pattern (next subsection).
+- *Closure preserves remote name.* =mcp-make-text-tool= built
+ the function before we rewrote =:name=, so the closure calls
+ =mcp-call-tool SERVER REMOTE-NAME=, not the prefixed
+ =mcp__SERVER__TOOL=.
+- *Idempotent.* Each registration deregisters first, so
+ callbacks firing multiple times or restarts don't accumulate
+ duplicate entries.
+- *Description normalization.* See next subsection.
+
+** Per-call timeout: explicit timer/callback race
+
+=with-timeout= only supervises the dynamic extent of the form it
+wraps. For async tools where the function returns immediately
+and the callback fires later, it does nothing. The correct
+pattern is an explicit timer:
+
+#+begin_src emacs-lisp
+(defun cj/mcp--wrap-async-with-timeout (server-name remote-name
+ mcp-callback)
+ "Return a gptel-compatible async wrapper for an MCP tool.
+GPTel calls the returned function with (CALLBACK . ARGS).
+The wrapper races MCP's response against `cj/mcp-tool-timeout';
+whichever fires first wins, and late callbacks are ignored."
+ (lambda (gptel-callback &rest args)
+ (let* ((done nil)
+ (timer (run-at-time cj/mcp-tool-timeout nil
+ (lambda ()
+ (unless done
+ (setq done t)
+ (funcall gptel-callback
+ (format "MCP tool %s/%s \
+timed out after %ds"
+ server-name
+ remote-name
+ cj/mcp-tool-timeout)))))))
+ (mcp-async-call-tool
+ (gethash server-name mcp-server-connections)
+ remote-name
+ (cj/mcp--args-to-plist args)
+ (lambda (result)
+ (cancel-timer timer)
+ (unless done
+ (setq done t)
+ (funcall gptel-callback
+ (mcp--parse-tool-call-result result))))
+ (lambda (code message)
+ (cancel-timer timer)
+ (unless done
+ (setq done t)
+ (funcall gptel-callback
+ (format "MCP error %s: %s" code
+ (cj/mcp--redact message)))))))))
+#+end_src
+
+Both branches set =done= before invoking gptel's callback so a
+late response (e.g., MCP responds after the timer fired but
+before its sentinel cancels) doesn't deliver twice. Timer is
+canceled on the success and error paths.
+
+The closure that =mcp-make-text-tool= built gets replaced with
+this wrapper during registration (the =:function= slot of the
+rewritten plist).
+
+** Confirmation / safety policy
+
+All enabled tools are registered (per the goal of full
+visibility), but write/destructive tools get =:confirm t=.
+Classification is name-based:
+
+#+begin_src emacs-lisp
+(defconst cj/mcp--write-name-patterns
+ '("\\`create\\b" "\\`update\\b" "\\`delete\\b" "\\`remove\\b"
+ "\\`send\\b" "\\`post\\b" "\\`add\\b" "\\`move\\b"
+ "\\`invite\\b" "\\`share\\b" "\\`upload\\b" "\\`set\\b"
+ "\\`patch\\b" "\\`import\\b" "\\`sync\\b" "\\`merge\\b"
+ "\\`close\\b" "\\`reopen\\b" "\\`archive\\b" "\\`unarchive\\b"
+ "\\`approve\\b" "\\`reject\\b" "\\`label\\b" "\\`assign\\b"
+ "\\`reply\\b" "\\`comment\\b" "\\`trash\\b" "\\`restore\\b"
+ "\\`pin\\b" "\\`unpin\\b" "\\`copy\\b" "\\`rename\\b"))
+
+(defconst cj/mcp--read-name-patterns
+ '("\\`get\\b" "\\`list\\b" "\\`read\\b" "\\`search\\b"
+ "\\`find\\b" "\\`fetch\\b" "\\`view\\b" "\\`query\\b"
+ "\\`describe\\b" "\\`show\\b" "\\`check\\b"))
+
+(defcustom cj/mcp-tool-confirm-overrides nil
+ "Per-tool confirmation overrides.
+Alist mapping fully qualified MCP tool name (e.g.,
+\"mcp__linear__create_issue\") to t or nil. Wins over the
+pattern-based classifier."
+ :type '(alist :key-type string :value-type boolean)
+ :group 'cj)
+
+(defun cj/mcp--confirm-p (gptel-name remote-name)
+ "Return non-nil if the tool should register with `:confirm t'."
+ (let ((override (assoc gptel-name cj/mcp-tool-confirm-overrides)))
+ (cond
+ (override (cdr override))
+ ((seq-some (lambda (p) (string-match-p p remote-name))
+ cj/mcp--write-name-patterns) t)
+ ((seq-some (lambda (p) (string-match-p p remote-name))
+ cj/mcp--read-name-patterns) nil)
+ (t t)))) ; unknown → confirm
+#+end_src
+
+This is the second half of the safety story; the first half
+(=gptel-confirm-tool-calls 'auto=) is enforced by ai-mcp.el at
+load time.
+
+** Description normalization
+
+mcp-side descriptions are written for an arbitrary client and
+vary in quality. The registration pipeline prefixes a stable
+"server / write-risk" header so the agent and the user have
+consistent context:
+
+#+begin_src emacs-lisp
+(defun cj/mcp--normalize-description (server-name raw-tool)
+ "Return a normalized description string for RAW-TOOL.
+Prefix: [SERVER] for reads, [SERVER WRITE] for writes,
+[SERVER ?] for unknown classification. Then the upstream
+description, unchanged."
+ (let* ((remote-name (plist-get raw-tool :name))
+ (upstream (or (plist-get raw-tool :description)
+ "(no description provided by server)"))
+ (cls (cond
+ ((seq-some (lambda (p) (string-match-p p remote-name))
+ cj/mcp--write-name-patterns) "WRITE")
+ ((seq-some (lambda (p) (string-match-p p remote-name))
+ cj/mcp--read-name-patterns) "")
+ (t "?"))))
+ (format "[%s%s] %s" server-name
+ (if (string-empty-p cls) "" (concat " " cls))
+ upstream)))
+#+end_src
+
+Tools in =gptel-menu= show as:
+
+#+begin_example
+[linear WRITE] Create a new Linear issue in a team.
+[linear] List issues in a Linear team.
+[google-keep ?] Frobnicate a note (unknown classification).
+#+end_example
+
+** Tool deregistration
+
+Three triggers remove tools from =gptel-tools=:
+
+- =cj/mcp-stop-all= -- removes every MCP-registered tool, clears
+ =cj/mcp--registered-tools=, calls =mcp-stop-server= per server.
+ Local tools untouched.
+- =cj/mcp-restart-server= -- removes tools for the named server
+ before re-registering.
+- =cj/mcp-restart-failed= -- deregister + re-register each
+ =failed= server.
+
+#+begin_src emacs-lisp
+(defun cj/mcp--deregister-server-tools (server-name)
+ "Remove every GPTel tool this integration registered for SERVER-NAME.
+Local tools (those not in `cj/mcp--registered-tools') are
+preserved."
+ (let ((mcp-tools (gethash server-name cj/mcp--registered-tools)))
+ (dolist (tool-name mcp-tools)
+ (setq gptel-tools
+ (cl-remove-if (lambda (tool)
+ (string= (gptel-tool-name tool) tool-name))
+ gptel-tools)))
+ (remhash server-name cj/mcp--registered-tools)))
+#+end_src
+
+** Secrets redaction
+
+=cj/mcp--redact= masks every secret-bearing field before any
+string surfaces in messages, errors, status buffers, hub
+displays, or test fixtures. Used by status formatting, audit
+buffer, failure surfacing, and error wrappers. Tests assert
+sentinel =REDACTED_TEST_SECRET= never appears in any user-facing
+output.
+
+** Process cleanup
+
+=kill-emacs-hook= gets one entry: =cj/mcp-stop-all=. Stdio
+process sentinels record abnormal exits into the per-server
+status.
+
+*Local-only constraint.* MCP server processes are always spawned
+under the local Emacs's =default-directory='s root. TRAMP /
+remote buffers do not relocate the spawn; MCP processes are
+local-only. This is enforced implicitly (mcp-hub-start-all-server
+uses =make-process= which is local) and documented in the
+commentary.
+
+** Privacy: external tool output in saved conversations
+
+MCP tool results land in the GPTel buffer. GPTel's autosave (when
+enabled) persists those results to
+=~/.emacs.d/ai-conversations/=. Concrete implications:
+
+- A Slack channel excerpt the agent fetched is now on disk.
+- A Google Docs snippet the agent quoted is now on disk.
+- A Linear issue body the agent read is now on disk.
+
+This is normal external-tool behavior, but users may not realize
+it. Two mitigations:
+
+- =ai-mcp.el='s commentary explicitly documents the autosave
+ privacy implication.
+- The audit buffer (=cj/mcp-list-tools=) includes a header note:
+ "Tool results land in =gptel-tools= responses; saved
+ conversations persist them. Use =cj/gptel-autosave-toggle= per
+ buffer to opt out."
+
+A future enhancement (V1.5+) could mark MCP-sourced tool output
+with a visible delimiter in the GPTel buffer so the user sees
+"this came from an external server" during the chat. Not v1.
+
+** Per-server auth matrix
+
+| =:auth= value | Servers | How it works | Recovery if failed |
+|---------------+---------+--------------+--------------------|
+| =in-protocol= | linear, notion | mcp.el HTTP transport handles OAuth handshake | Open URL surfaced in =cj/mcp-status= |
+| =local= | slack-deepsat | Local SSE; no auth but proxy must run | Start the local proxy |
+| =none= | drawio | No auth | n/a |
+| =args-token= | figma | API key in process args | Update Claude config, restart |
+| =oauth= | google-calendar, google-docs-* | OAuth token in env; refresh out-of-band | Re-auth via Claude Code, restart |
+| =token= | google-keep | Long-lived token in env | Regenerate, update Claude config, restart |
+
+Recovery surfaces via pattern matching on errors:
+
+#+begin_src emacs-lisp
+(defconst cj/mcp--recovery-patterns
+ '(("\\(401\\|unauthorized\\|token expired\\)"
+ . "Token expired -- re-auth via Claude Code, then C-; a C r SERVER")
+ ("\\(connection refused\\|ECONNREFUSED\\)"
+ . "Local endpoint unreachable -- check the upstream service is running")
+ ("\\(ENOENT\\|command not found\\)"
+ . "Missing dependency -- run `cj/mcp-doctor' to diagnose")))
+#+end_src
+
+** Timeouts
+
+| Timeout | Variable | Default | Behavior |
+|---------+----------+---------+----------|
+| Startup / tool discovery | =cj/mcp-startup-timeout= | 30 s | Server marked =failed/timeout=; integration continues with ready servers |
+| Per-call tool execution | =cj/mcp-tool-timeout= | 60 s | Tool call returns timeout string to agent via the timer-race wrapper; server stays connected |
+
+Both via defcustoms. Per-tool override possible via
+=cj/mcp-tool-timeout-overrides= alist.
+
+* Status UX
+
+** Echo-area summary: =cj/mcp-status=
+
+Single-line summary keyed off =cj/mcp--state=:
+
+| State | Echo |
+|-------+------|
+| idle | =MCP: not started. (C-; a t triggers it.)= |
+| starting | =MCP: starting (3/9 ready)...= |
+| partial | =MCP: 7/9 ready (failed: google-calendar, slack-deepsat).= |
+| ready | =MCP: 9/9 ready, 87 tools registered.= |
+| failed | =MCP: all 9 servers failed. C-; a C d to diagnose.= |
+
+** Wait until ready: =cj/mcp-wait-until-ready=
+
+For the case where the agent asks for a Calendar tool immediately
+after =cj/toggle-gptel=:
+
+#+begin_src emacs-lisp
+(defun cj/mcp-wait-until-ready (&optional timeout)
+ "Block until MCP startup completes or TIMEOUT seconds pass.
+TIMEOUT defaults to `cj/mcp-startup-timeout'. Returns the final
+state symbol (`ready', `partial', `failed', `starting')."
+ (interactive)
+ ...)
+#+end_src
+
+Bound to =C-; a C w=. Reports progress every second via
+=message= so the user sees countdown.
+
+** Audit buffer: =cj/mcp-list-tools=
+
+Tabulated-list buffer. Failed servers appear at the top with a
+red face so they're visually obvious. Each row:
+
+#+begin_example
+Server State Tools Confirm Description
+-------------------- -------- ----- ------- ------------------------------
+google-calendar FAILED 0 - Token expired; see status
+slack-deepsat FAILED 0 - Local proxy unreachable
+-------------------- -------- ----- ------- ------------------------------
+linear/list_issues ready - no List issues in a Linear team
+linear/create_issue ready - YES Create a new Linear issue
+linear/... ready 44 total
+...
+#+end_example
+
+Sort: failed servers first, then by server name, then by tool
+name. Keys: =g= refresh, =RET= jump to tool's category in
+gptel-menu, =r= restart server under point, =c= toggle confirm
+override for tool under point.
+
+* Commands & Keymap
+
+=C-; a C= becomes the MCP (Connect) subprefix. Existing keys are
+preserved: =M= keeps =gptel-menu=, =m= keeps
+=cj/gptel-change-model=.
+
+| Key | Command | Purpose |
+|-----+---------+---------|
+| =C-; a C h= | =cj/mcp-hub= | Open server-management buffer |
+| =C-; a C s= | =cj/mcp-status= | Echo state summary |
+| =C-; a C l= | =cj/mcp-list-tools= | Open audit buffer |
+| =C-; a C r= | =cj/mcp-restart-failed= | Restart failed servers |
+| =C-; a C R= | =cj/mcp-restart-server= | Restart a named server |
+| =C-; a C S= | =cj/mcp-stop-all= | Stop everything |
+| =C-; a C d= | =cj/mcp-doctor= | Diagnose prerequisites |
+| =C-; a C w= | =cj/mcp-wait-until-ready= | Block until ready |
+
+which-key labels mirror the table.
+
+** cj/mcp-doctor
+
+Diagnostic command. Two modes:
+
+- *Static* (default) -- no side effects, no network: capability
+ check, =npx=/=uvx= on PATH, Claude config parseability,
+ per-server env-var presence, local endpoint reachability.
+- *Live* (=C-u C-; a C d=) -- opt-in: invokes a single safe read
+ per auth class to verify OAuth tokens haven't silently expired.
+ For example, =gh_search= against linear is one tool call; same
+ for notion, google-calendar, google-docs, google-keep. Static
+ checks first; live probe only fires if static passes.
+
+Output buffer keys:
+
+- =c= copy diagnostic summary to kill ring (for pasting into
+ bug reports / notes).
+- =r= rerun all failed checks.
+- =q= quit.
+
+Each check row formats as: =PASS / FAIL / WARN CHECK RECOVERY=.
+
+* Implementation Plan
+
+Eight phases (was seven in rev 2; added Phase 1.5 for the
+confirmation-contract setup). Each ends with green ERT tests +
+a manual smoke test before the next.
+
+** Phase 1 -- Module + pure helpers
+
+Create =modules/ai-mcp.el=. Implement: =cj/mcp-server-specs=,
+=cj/mcp-enabled-servers=, =cj/mcp-start-on-entry-points=,
+=cj/mcp--read-claude-config=, =cj/mcp--get-env=,
+=cj/mcp--build-server-alist= (pure transformer; filters by
+=cj/mcp-enabled-servers=), =cj/mcp--redact=,
+=cj/mcp--confirm-p=, =cj/mcp--normalize-description=.
+
+Tests cover all of the above against fixtures.
+
+** Phase 1.5 -- Confirmation contract
+
+Flip =gptel-confirm-tool-calls= to ='auto= in =ai-mcp.el='s
+setup. Remove the =(setq gptel-confirm-tool-calls nil)= line
+from =ai-config.el=. Audit existing local tools and add
+=:confirm t= to any that should be gated (=web_fetch=
+guaranteed; =write_text_file=, =update_text_file=,
+=move_to_trash= per Craig's decision).
+
+Verification test in =tests/test-ai-mcp-confirm-contract.el=
+asserts the setting, the local-tool gating behavior, and that
+=git_log= (=:confirm nil=) still runs without prompting.
+
+** Phase 2 -- Compat layer + tool registration with fake inventory
+
+Add =ai-mcp-compat= helpers. Build the registration pipeline
+against a stubbed =mcp-server-connections=. Verify:
+- Tool name rewriting (=remote-name= stays in closure;
+ =gptel-name= is =mcp__SERVER__TOOL=).
+- =gptel-make-tool= + explicit =add-to-list 'gptel-tools=.
+- =:confirm= application per policy + overrides.
+- Description normalization adds expected prefix.
+- =cj/mcp--registered-tools= bookkeeping.
+- Deregister removes from =gptel-tools= without disturbing local
+ tools (test pre-populates =gptel-tools= with a local tool and
+ asserts it survives).
+- Re-register after deregister doesn't duplicate.
+- All tools register with =:async t=.
+
+** Phase 3 -- Async state machine + timeout wrapper
+
+Implement =cj/mcp-ensure-started=, =cj/mcp--on-hub-callback=,
+=cj/mcp--state= transitions, stall timer, polling. Implement
+=cj/mcp--wrap-async-with-timeout=. Stub
+=mcp-hub-start-all-server= with synthetic delayed callbacks and
+synthetic async errors.
+
+Verify:
+- =cj/mcp-ensure-started= returns in <100 ms regardless of stubs.
+- Hub callback triggers status poll + registration.
+- Stall timer fires for stuck servers.
+- Async error path (=:error-callback= without inited callback)
+ reaches =cj/mcp--server-status= via polling.
+- =cj/mcp--wrap-async-with-timeout=: timer-first ignores late MCP
+ response; MCP-first cancels timer; both branches deliver
+ exactly once.
+
+** Phase 4 -- First real connection (no auth)
+
+Wire one =drawio= or =slack-deepsat= server. Verify the stubbed
+Phase 3 behavior matches real subprocesses.
+
+** Phase 5 -- Status UX + commands + doctor (static)
+
+Implement =cj/mcp-status=, =cj/mcp-list-tools= (with failed
+servers at top, red face), =cj/mcp-doctor= (static mode only),
+=cj/mcp-wait-until-ready=, restart commands, keymap entries,
+which-key labels.
+
+Investigate =gptel-menu= refresh behavior: if the transient is
+already open when a new tool registers, does it pick it up on
+next invocation? Document and add an acceptance test:
+"register new tool while gptel-menu is open; close and reopen;
+new tool appears."
+
+** Phase 6 -- HTTP servers
+
+Add =linear= and =notion=. Test in-protocol OAuth handshake.
+Add live-auth-check mode to doctor.
+
+** Phase 7 -- Env-dependent stdio servers
+
+Add =figma=, =google-calendar=, =google-docs-personal=,
+=google-docs-work=, =google-keep=.
+
+** Phase 8 -- Privacy + audit polish
+
+Add audit buffer's privacy header, autosave commentary, audit-log
+hygiene (=cj/mcp-tool-audit-log-enabled= defcustom). Update
+=ai-mcp.el= commentary with the code-organization outline as a
+TOC.
+
+* Test Plan
+
+** Confirmation contract (Phase 1.5)
+
+- =gptel-confirm-tool-calls= is ='auto= after =ai-mcp= loads.
+- Write-classified MCP tool with =:confirm t= triggers confirm
+ prompt (stub gptel's prompt and assert it fires).
+- Read-classified MCP tool with =:confirm nil= does not trigger
+ prompt.
+- Pre-existing =git_log= (=:confirm nil=) does not trigger
+ prompt.
+- =web_fetch= (newly gated with =:confirm t=) triggers prompt.
+
+** Pure helpers (Phase 1-2)
+
+- =cj/mcp--read-claude-config=: good fixture, missing, unreadable,
+ malformed JSON, missing =:mcpServers=, missing server, empty
+ env, non-string env values. Cache invalidation on mtime
+ change.
+- =cj/mcp--build-server-alist=: each transport, each auth class,
+ env merge, args splicing for figma, no mutation of
+ =cj/mcp-server-specs=, filter by =cj/mcp-enabled-servers=.
+- =cj/mcp--redact=: bearer tokens, OAuth credentials,
+ TOKEN/KEY/SECRET/CREDENTIALS-suffixed env vars, URL query
+ tokens, figma args slot. Sentinel never leaks.
+- =cj/mcp--confirm-p=: read patterns, write patterns, unknown →
+ t, override map wins.
+- =cj/mcp--normalize-description=: prefix shape per
+ classification.
+
+** Registration pipeline (Phase 2)
+
+- Single tool registers into all three structures.
+- Two tools with same =:name= from different servers don't
+ collide.
+- Re-registration replaces function pointer without duplicating.
+- Deregister removes from =gptel-tools= without touching a
+ pre-populated local tool.
+- All tools register with =:async t=.
+- Confirm overrides win over patterns.
+
+** Compat layer (Phase 2)
+
+- Each =cj/mcp--*-server-*= helper returns expected value against
+ stub object.
+- =cj/mcp--assert-capabilities= signals when a required function
+ is missing.
+
+** State machine + timeout (Phase 3)
+
+- =cj/mcp-ensure-started= from idle returns in <100 ms with
+ delayed-callback stub.
+- Second call from =starting= is no-op.
+- Hub callback triggers status poll.
+- Stall timer marks slow servers =failed/timeout=.
+- Async error path: server emits error callback only (no inited
+ callback); polling catches it and marks =failed/error= within
+ stall window.
+- =cj/mcp--wrap-async-with-timeout=:
+ - MCP responds first, before timer: gptel callback fires once
+ with result; timer canceled.
+ - Timer fires first: gptel callback fires once with timeout
+ message; late MCP response is ignored (done flag).
+ - Error callback: cancels timer, fires once with redacted
+ error.
+
+** Async / freeze (Phase 3)
+
+- Stub =mcp-hub-start-all-server= to delay callbacks 5 s.
+ =cj/toggle-gptel= returns within 250 ms; buffer is
+ interactive.
+- Stub a server that never responds. After
+ =cj/mcp-startup-timeout=, server is =failed=,
+ =cj/mcp--state= is =partial= or =failed=.
+
+** Entry-point policy (Phase 5)
+
+- With =cj/mcp-start-on-entry-points '(toggle-gptel)=, calling
+ =cj/toggle-gptel= triggers startup; calling
+ =cj/gptel-quick-ask= does not.
+- Adding =gptel-quick-ask= to the list makes quick-ask trigger.
+
+** Local-tool preservation (Phase 2)
+
+- =cj/mcp-stop-all= removes only MCP-registered tools from
+ =gptel-tools=; local tools like =git_log= remain.
+- =cj/mcp-restart-server= removes only that server's tools;
+ other MCP servers' tools and local tools both remain.
+
+** Process / cleanup (Phase 4+)
+
+- =kill-emacs-hook= calls =cj/mcp-stop-all=; subprocesses exit.
+- =cj/mcp-stop-all= clears =gptel-tools= MCP entries and
+ =cj/mcp--registered-tools=.
+- Restart doesn't leak process buffers or duplicate process
+ objects.
+- Process sentinel records abnormal exits into status.
+
+** Partial availability (Phase 4+)
+
+- 8 of 9 servers ready, 1 failed: ready tools available;
+ failed-server tools absent.
+- Restart-failed only retries the failed one.
+
+** Saved-conversation behavior (Phase 7+)
+
+- After a successful MCP tool call, GPTel autosave (when on)
+ persists the tool result. Test asserts the saved file
+ contains the result text and the audit buffer's privacy
+ header is updated.
+- =cj/gptel-autosave-toggle= off → result not saved.
+
+** Keymap (Phase 5)
+
+- =C-; a C h/s/l/r/R/S/d/w= all bound after =ai-mcp= loads.
+- Existing =C-; a M=, =C-; a m= still bound to
+ =gptel-menu=, =cj/gptel-change-model=.
+- which-key labels present for every new binding.
+- No duplicate labels.
+
+** =gptel-menu= refresh (Phase 5)
+
+- Register new tool while a previously-opened gptel-menu is
+ closed; reopen; new tool appears. Document whether mid-open
+ refresh works.
+
+** No-real-process rule
+
+All tests in =tests/test-ai-mcp*= use stubs for =process-file=,
+=make-process=, =mcp-hub-start-all-server=,
+=mcp-server-connections=, =mcp--tools=, =mcp--status=. No real
+=npx=, no network, no real =~/.claude.json=.
+
+** Manual test matrix
+
+| Scenario | Expected |
+|----------+----------|
+| No =~/.claude.json= | Doctor warns; env-free servers still start |
+| Malformed Claude config | Status shows =malformed-json=; integration =failed= cleanly |
+| Network offline | HTTP servers fail; stdio servers start; status =partial= |
+| =npx= not on PATH | Doctor flags it; stdio servers fail with clear message |
+| One stdio server exits immediately | Sentinel records failure; others continue |
+| slack-deepsat endpoint down | Server =failed=; recovery message points at local proxy |
+| Google token expired | Server starts; tool calls fail; live-auth check (=C-u doctor=) surfaces it |
+| All servers available | =MCP: 9/9 ready, ~N tools registered= |
+| =cj/mcp-restart-failed= after fix | Only retried servers transition |
+| =cj/mcp-stop-all= then call a tool | Tool absent from =gptel-tools= |
+| Disable a server via defcustom | Doctor and status reflect the absence |
+| TRAMP buffer open + =cj/toggle-gptel= | MCP starts locally; no remote spawn |
+
+* Acceptance Criteria
+
+1. *No freeze.* =cj/toggle-gptel= returns in <250 ms with mcp.el
+ wired and nine real servers starting in background.
+2. *Incremental registration.* As each server reports tools,
+ =gptel-tools= updates; in-flight =gptel-send= calls see
+ newly-added tools on the next request.
+3. *No MCP failure breaks ordinary GPTel chat.* With every MCP
+ server failing, =cj/toggle-gptel= still opens a usable chat
+ buffer; non-tool prompts work normally; local tools (git_log
+ etc.) still callable.
+4. *Confirm gate works.* After =ai-mcp= loads, a write-classified
+ MCP tool actually prompts before invocation. Verified by a
+ test that fails if =mcp-async-call-tool= is invoked before
+ gptel's confirm-prompt stub fires.
+5. *Local-tool preservation.* =cj/mcp-stop-all= and per-server
+ restart remove only MCP-owned tools.
+6. *Partial availability.* With one failed server, status is
+ =partial=, ready servers' tools work, failed server's tools
+ absent.
+7. *Idempotent restart.* Calling =cj/mcp-restart-failed= twice
+ with no intervening change produces identical state.
+8. *No secret leakage.* Grep every user-facing output for
+ sentinel fixture secrets; zero matches.
+9. *Doctor coverage (static).* Identifies each diagnosable
+ failure in the manual test matrix.
+10. *Server enablement.* Setting =cj/mcp-enabled-servers= to a
+ subset starts only those servers; doctor reports the disabled
+ ones as expected-absent.
+
+* Risks
+
+** R1 -- mcp.el API drift behind compat layer
+
+Even with the compat layer, an upstream rename could break us if
+the capability check misses it.
+
+*Mitigation:* compat helpers document the upstream commit and
+file location. Tests cover each helper against stub objects;
+when mcp.el bumps, run those tests first.
+
+** R2 -- Cold-start latency for nine subprocesses
+
+Nine =npx -y= invocations cold-start over several seconds. Time
+to =ready= state may be 10-30 seconds on a cold machine.
+
+*Mitigation:* async model means user doesn't wait. Tools arrive
+incrementally; status indicator shows progress.
+=cj/mcp-wait-until-ready= for the rare case where the agent needs
+a specific tool immediately.
+
+** R3 -- OAuth token expiry surfaces silently
+
+A Google server starts cleanly but every tool call fails with
+auth errors.
+
+*Mitigation:* the OAuth recovery pattern matcher inspects every
+tool-call error. Live-auth-check mode in doctor proactively
+calls one safe read per auth class.
+
+** R4 -- Tool count balloons gptel-menu
+
+Up to 100+ tools. Even with category grouping, the transient
+menu is large.
+
+*Mitigation:* =cj/mcp-enabled-servers= lets users disable
+servers they don't need. Audit buffer is the alternate browser.
+Per-conversation profiles deferred to v1.5.
+
+** R5 -- =~/.claude.json= schema change
+
+Parser breaks if Anthropic restructures the file.
+
+*Mitigation:* =cj/mcp--read-claude-config= returns structured
+errors that surface in status and doctor. Integration degrades
+to "env-free servers work, env-dependent servers fail" rather
+than crashing.
+
+** R6 -- Process argument leakage (figma)
+
+figma's API key is in process args -- visible via =ps=,
+=/proc/PID/cmdline=, =list-system-processes=.
+
+*Mitigation:* accepted risk (the figma package only supports
+args-token). =cj/mcp--redact= ensures the key never appears in
+Emacs-side output. Commentary flags this.
+
+** R7 -- Confirmation fatigue from unknown-classification tools
+
+Default for unknown is =:confirm t=. A server with many tools
+matching neither read nor write pattern produces many prompts.
+
+*Mitigation:* audit buffer surfaces unknown classifications with
+their confirm state. =cj/mcp-tool-confirm-overrides= alist lets
+the user pin specific tools to =nil= once vetted. A "review
+unknowns" doctor pass could enumerate them on demand (v1.5).
+
+** R8 -- Subprocess accumulation across sessions
+
+If =kill-emacs-hook= is bypassed (kill -9, crash), subprocesses
+persist.
+
+*Mitigation:* =cj/mcp-doctor= can detect orphaned mcp processes
+via =list-system-processes= (v1.5 enhancement).
+
+* Open Questions
+
+** Q1 -- Should =gptel-menu= refresh after mid-call tool registration?
+
+Investigation during Phase 5. If gptel-menu's transient caches
+=gptel-tools= at open time, mid-call additions won't appear
+until close+reopen. Document the behavior; if it's a real
+pain, file a gptel upstream issue.
+
+** Q2 -- Should write-confirmation overrides be per-host?
+
+A v1.5 question: when per-conversation profiles land, the
+override alist could also be scoped (e.g., "in /work/ folder,
+auto-confirm google-docs-work writes"). Out of v1 scope.
+
+** Q3 -- Auth-source migration path for OAuth tokens
+
+Three candidate paths (Elisp OAuth client / Claude Code refresh
+script / timer-based refresh) all have meaningful complexity.
+Until one is viable, =~/.claude.json= stays the source.
+
+** Q4 -- Live-auth check cadence
+
+Doctor's live-auth mode is opt-in. Should there be a periodic
+auto-check (every N hours via timer) that catches expiry between
+explicit doctor runs? Adds complexity; defer until usage shows
+need.
+
+* Considered Alternatives
+
+** =gptel-mcp.el= (declined; cited as prior art)
+
+[[https://github.com/lizqwerscott/gptel-mcp.el][lizqwerscott/gptel-mcp.el]] is a 96-line wrapper from the same
+author as mcp.el that bridges mcp.el's tool inventory into gptel.
+It exposes five functions:
+
+- =gptel-mcp-register-tool= -- walks =mcp-hub-get-all-tool= and
+ calls =gptel-make-tool= on each plist.
+- =gptel-mcp-activate-all-tool= -- pushes tools into
+ =gptel-tools=.
+- =gptel-mcp-deactivate-all-tool= -- removes them by category +
+ name lookup.
+- =gptel-mcp-start-all-server-and-register= -- chains
+ =mcp-hub-start-all-server= with the register callback.
+- =gptel-mcp-dispatch= -- a transient menu with three keys (=s=
+ start, =A= activate, =C= deactivate).
+
+*Why this matters for the spec.* The package independently
+validates the integration shape this spec converged on:
+=mcp-hub-start-all-server= → walk connections → =gptel-make-tool=
+→ =add-to-list gptel-tools= is the canonical path.
+
+*Why we are not adopting it.* The package solves the trivial
+"wire tools through" problem but skips every concern this spec
+exists to address:
+
+| Concern | Spec | gptel-mcp.el |
+|---------+------+--------------|
+| GPTel confirmation contract (=gptel-confirm-tool-calls 'auto=) | Required precondition | Not addressed |
+| Tool-name collisions | Rewrites to =mcp__SERVER__TOOL= | Silent overwrite |
+| Confirm-on-write policy | Per-tool =:confirm t= for writes | All tools register with default |
+| Async startup contract | =cj/mcp-ensure-started= returns in <100 ms | Synchronous-feel start |
+| Async per-call timeout | Explicit timer/callback race | None |
+| State machine | =cj/mcp--state= + per-server status | None |
+| Server identity strategy | Walks =mcp-server-connections= directly | Uses =mcp-hub-get-all-tool= + parses =mcp-SERVER= |
+| Secrets handling | Reads env from =~/.claude.json= with mtime cache | None |
+| Deregistration tracking | =cj/mcp--registered-tools= hash, preserves local tools | Removes by category, no local-tool guarantee |
+| Server enablement | =cj/mcp-enabled-servers= defcustom | None |
+| Entry-point scoping | =cj/mcp-start-on-entry-points= defcustom | Manual via dispatch menu |
+| Status UX | =cj/mcp-status=, =cj/mcp-list-tools= audit buffer | Just dispatch menu |
+| OAuth recovery | Pattern matcher with per-auth-class recovery | None |
+| Secrets redaction | =cj/mcp--redact= applied everywhere | None |
+| mcp.el compat layer | Isolated wrappers around private API | Direct =mcp--*= access scattered |
+| Tests | 10 acceptance criteria + manual matrix + no-real-process | None |
+| Doctor / live-auth check | Static + live-probe diagnostic | None |
+
+Adopting it would force shipping safely or wrapping with
+everything specified above (two layers, no code reduction).
+
+*What we are taking from it.* Confidence the API path is right.
+The transient-dispatch UX was considered for the keymap (rev 2),
+but the keymap is now pinned to discrete commands under =C-; a
+C= so existing GPTel keys aren't disturbed (rev 3).
+
+* References
+
+- [[file:../../modules/ai-config.el][modules/ai-config.el]] -- =gptel-confirm-tool-calls nil= at
+ line 386 (removed by this integration); loader at lines 71-96.
+- [[file:gptel-tools-shortlist.org][gptel-tools-shortlist.org]] -- local-tools shortlist; MCP servers
+ slot in as the "external" tier.
+- [[file:gptel-agentic-tool-ideas.org][gptel-agentic-tool-ideas.org]] -- broader agentic-tool design.
+- [[id:a124dd0f-1f40-4533-aeb8-595d93e20865][gptel-gh-tool-spec.org]] -- sibling design; same confirm-on-write
+ pattern.
+- [[https://github.com/lizqwerscott/mcp.el][lizqwerscott/mcp.el]] -- upstream.
+- [[https://github.com/lizqwerscott/gptel-mcp.el][lizqwerscott/gptel-mcp.el]] -- considered and declined; see §
+ Considered Alternatives.
+- =~/code/mcp.el/mcp-hub.el:53-90,131-160= -- verified callback
+ ownership and start-all helper.
+- =elpa/gptel-0.9.8.5/gptel.el:1595-1607,2244-2245= -- verified
+ =gptel-confirm-tool-calls= semantics and tool-confirm gate.
+- =elpa/gptel-0.9.8.5/gptel.el:1729-1820= -- verified
+ =gptel-make-tool= registration.
+- =~/.claude.json= -- Claude Code config.
diff --git a/archive/gptel/gptel-tools/git_diff.el b/archive/gptel/gptel-tools/git_diff.el
new file mode 100644
index 000000000..47db8dae0
--- /dev/null
+++ b/archive/gptel/gptel-tools/git_diff.el
@@ -0,0 +1,110 @@
+;;; git_diff.el --- Read-only git diff tool for gptel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools, git
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool returning `git diff' output for a path under the user's
+;; home directory. Read-only. Output is capped at ~500KB so a
+;; runaway diff can't blow up the model's context budget; truncation
+;; is reported in the output when it triggers.
+
+;;; Code:
+
+(require 'gptel)
+
+(defconst cj/gptel-git-diff--max-output-bytes (* 500 1024)
+ "Cap on diff output size. Larger diffs are truncated with a note.")
+
+(defun cj/gptel-git-diff--validate-path (path)
+ "Validate PATH for a git diff call. Return the expanded path on success.
+Same contract as the other git_* validators: under HOME, a directory,
+inside a git working tree."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name (or path "~") "~")))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (unless (file-directory-p full)
+ (error "Not a directory: %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))
+ (setq full resolved))
+ (let ((default-directory full))
+ (unless (zerop (process-file "git" nil nil nil
+ "rev-parse" "--is-inside-work-tree"))
+ (error "Not a git working tree: %s" full)))
+ full))
+
+(defun cj/gptel-git-diff--truncate (text)
+ "Truncate TEXT to `cj/gptel-git-diff--max-output-bytes' bytes.
+Returns TEXT unchanged when it's under the cap, otherwise returns the
+prefix plus a one-line truncation marker."
+ (if (<= (length text) cj/gptel-git-diff--max-output-bytes)
+ text
+ (concat (substring text 0 cj/gptel-git-diff--max-output-bytes)
+ (format
+ "\n\n[truncated: output exceeded %d bytes; %d bytes total]"
+ cj/gptel-git-diff--max-output-bytes
+ (length text)))))
+
+(defun cj/gptel-git-diff--build-args (ref1 ref2 file)
+ "Build the `git' argv from optional REF1, REF2, FILE.
+Uses `-c color.ui=false' at the git level so output is plain across
+git subcommands."
+ (let ((args (list "-c" "color.ui=false" "diff")))
+ (when (and (stringp ref1) (not (string-empty-p ref1)))
+ (setq args (append args (list ref1))))
+ (when (and (stringp ref2) (not (string-empty-p ref2)))
+ (setq args (append args (list ref2))))
+ (when (and (stringp file) (not (string-empty-p file)))
+ (setq args (append args (list "--" file))))
+ args))
+
+(defun cj/gptel-git-diff--run (path &optional ref1 ref2 file)
+ "Run `git diff [REF1 [REF2]] [-- FILE]' in PATH. Return the output."
+ (let* ((dir (cj/gptel-git-diff--validate-path path))
+ (args (cj/gptel-git-diff--build-args ref1 ref2 file))
+ (default-directory dir))
+ (with-temp-buffer
+ (let ((exit (apply #'process-file "git" nil t nil args)))
+ (unless (or (zerop exit) (= exit 1))
+ (error "git diff exited with %d: %s" exit (buffer-string)))
+ (let ((out (buffer-string)))
+ (if (string-empty-p out)
+ (format "No diff in %s for the given refs/file" dir)
+ (cj/gptel-git-diff--truncate out)))))))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "git_diff"
+ :function (lambda (path &optional ref1 ref2 file)
+ (cj/gptel-git-diff--run path ref1 ref2 file))
+ :description "Return the output of `git diff' for a directory in the user's home tree. Read-only. REF1 and REF2 are optional git revisions (commit SHA, branch, tag, or expressions like HEAD~3); when both are present the diff is between them, when only REF1 is present the diff is between REF1 and the working tree, when neither is present the diff is unstaged-vs-HEAD. FILE optionally narrows the diff to one path. Output is capped at ~500KB."
+ :args (list '(:name "path"
+ :type string
+ :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject').")
+ '(:name "ref1"
+ :type string
+ :description "Optional first git revision (commit, branch, tag, or expression like HEAD~3)."
+ :optional t)
+ '(:name "ref2"
+ :type string
+ :description "Optional second git revision; pair with REF1 to diff between two refs."
+ :optional t)
+ '(:name "file"
+ :type string
+ :description "Optional path inside the working tree to narrow the diff to."
+ :optional t))
+ :category "git"
+ :confirm nil
+ :include t)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_diff"))))
+
+(provide 'git_diff)
+;;; git_diff.el ends here
diff --git a/archive/gptel/gptel-tools/git_log.el b/archive/gptel/gptel-tools/git_log.el
new file mode 100644
index 000000000..324435dc6
--- /dev/null
+++ b/archive/gptel/gptel-tools/git_log.el
@@ -0,0 +1,100 @@
+;;; git_log.el --- Read-only git log tool for gptel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools, git
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool returning `git log --oneline -n N' for a path under the
+;; user's home directory. Read-only. N is capped to keep the model's
+;; context budget predictable.
+
+;;; Code:
+
+(require 'gptel)
+
+(defconst cj/gptel-git-log--max-count 100
+ "Hard cap on the number of commits `git_log' will return.")
+
+(defconst cj/gptel-git-log--default-count 20
+ "Default commit count when the caller doesn't specify one.")
+
+(defun cj/gptel-git-log--validate-path (path)
+ "Validate PATH for a git log call. Return the expanded path on success.
+Same contract as the git_status validator: must be under HOME, must
+be a directory, must be inside a git working tree."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name (or path "~") "~")))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (unless (file-directory-p full)
+ (error "Not a directory: %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))
+ (setq full resolved))
+ (let ((default-directory full))
+ (unless (zerop (process-file "git" nil nil nil
+ "rev-parse" "--is-inside-work-tree"))
+ (error "Not a git working tree: %s" full)))
+ full))
+
+(defun cj/gptel-git-log--effective-count (n)
+ "Return the commit count to use given caller-supplied N.
+Nil / non-integer N → `cj/gptel-git-log--default-count'.
+Values above `cj/gptel-git-log--max-count' get capped."
+ (cond
+ ((not (integerp n)) cj/gptel-git-log--default-count)
+ ((< n 1) cj/gptel-git-log--default-count)
+ ((> n cj/gptel-git-log--max-count) cj/gptel-git-log--max-count)
+ (t n)))
+
+(defun cj/gptel-git-log--run (path &optional n since)
+ "Run `git log --oneline -n N' in PATH. Return the output as a string.
+SINCE, if a non-empty string, is passed as `--since=SINCE'."
+ (let* ((dir (cj/gptel-git-log--validate-path path))
+ (count (cj/gptel-git-log--effective-count n))
+ (args (list "-c" "color.ui=false"
+ "log" "--oneline"
+ (format "-n%d" count)))
+ (args (if (and (stringp since) (not (string-empty-p since)))
+ (append args (list (format "--since=%s" since)))
+ args))
+ (default-directory dir))
+ (with-temp-buffer
+ (let ((exit (apply #'process-file "git" nil t nil args)))
+ (unless (zerop exit)
+ (error "git log exited with %d: %s" exit (buffer-string)))
+ (let ((out (buffer-string)))
+ (if (string-empty-p out)
+ (format "No commits in %s matching the filter" dir)
+ out))))))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "git_log"
+ :function (lambda (path &optional n since)
+ (cj/gptel-git-log--run path n since))
+ :description "Return the output of `git log --oneline -n N' for a directory in the user's home tree. Read-only. N defaults to 20 and is capped at 100. Use SINCE to filter commits more recent than a date expression git understands (e.g. '2 weeks ago', '2026-05-01')."
+ :args (list '(:name "path"
+ :type string
+ :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject').")
+ '(:name "n"
+ :type integer
+ :description "Number of commits to return. Defaults to 20; capped at 100."
+ :optional t)
+ '(:name "since"
+ :type string
+ :description "Optional date expression for `git log --since='; e.g. '2 weeks ago' or '2026-05-01'."
+ :optional t))
+ :category "git"
+ :confirm nil
+ :include t)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_log"))))
+
+(provide 'git_log)
+;;; git_log.el ends here
diff --git a/archive/gptel/gptel-tools/git_status.el b/archive/gptel/gptel-tools/git_status.el
new file mode 100644
index 000000000..de76a985b
--- /dev/null
+++ b/archive/gptel/gptel-tools/git_status.el
@@ -0,0 +1,85 @@
+;;; git_status.el --- Read-only git status tool for gptel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools, git
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool returning `git status --short --branch' for a path under
+;; the user's home directory. Read-only: never writes to the repo,
+;; never runs anything that could mutate state. Path validation
+;; rejects anything outside HOME and any path that doesn't resolve to
+;; a directory inside a git working tree.
+
+;;; Code:
+
+(require 'gptel)
+(require 'cl-lib)
+
+(defun cj/gptel-git-status--validate-path (path)
+ "Validate PATH as a usable working directory for a git status call.
+PATH must resolve under the user's home directory, must be an
+existing directory, and must be inside a git working tree. Returns
+the expanded path string on success; signals `error' otherwise."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name (or path "~") "~")))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (unless (file-directory-p full)
+ (error "Not a directory: %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))
+ (setq full resolved))
+ (let ((default-directory full))
+ (unless (zerop (process-file "git" nil nil nil
+ "rev-parse" "--is-inside-work-tree"))
+ (error "Not a git working tree: %s" full)))
+ full))
+
+(defun cj/gptel-git-status--run (path)
+ "Run `git status --short --branch' in PATH. Return the output.
+Color is disabled via `-c color.ui=false' at the git level (`git status'
+itself doesn't accept `--no-color' like `git log' / `git diff' do)."
+ (let* ((dir (cj/gptel-git-status--validate-path path))
+ (default-directory dir))
+ (with-temp-buffer
+ (let ((exit (process-file "git" nil t nil
+ "-c" "color.ui=false"
+ "status" "--short" "--branch")))
+ (unless (zerop exit)
+ (error "git status exited with %d: %s" exit (buffer-string)))
+ ;; `--branch' always prints a `## <branch>' header, so empty
+ ;; output is unreachable. Detect a clean tree by counting the
+ ;; non-branch lines: if only the header is present, no files
+ ;; are modified / staged / untracked.
+ (let* ((out (buffer-string))
+ (non-branch-lines
+ (cl-count-if
+ (lambda (l)
+ (and (not (string-empty-p l))
+ (not (string-prefix-p "## " l))))
+ (split-string out "\n"))))
+ (if (zerop non-branch-lines)
+ (format "Clean working tree in %s\n%s" dir (string-trim out))
+ out))))))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "git_status"
+ :function (lambda (path) (cj/gptel-git-status--run path))
+ :description "Return the output of `git status --short --branch' for a directory in the user's home tree. Read-only. Useful for seeing which files are modified, staged, or untracked, and how the current branch compares to its upstream."
+ :args (list '(:name "path"
+ :type string
+ :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject')."))
+ :category "git"
+ :confirm nil
+ :include t)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_status"))))
+
+(provide 'git_status)
+;;; git_status.el ends here
diff --git a/archive/gptel/gptel-tools/list_directory_files.el b/archive/gptel/gptel-tools/list_directory_files.el
new file mode 100644
index 000000000..8da9ba28d
--- /dev/null
+++ b/archive/gptel/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/archive/gptel/gptel-tools/move_to_trash.el b/archive/gptel/gptel-tools/move_to_trash.el
new file mode 100644
index 000000000..923da7902
--- /dev/null
+++ b/archive/gptel/gptel-tools/move_to_trash.el
@@ -0,0 +1,149 @@
+;;; 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 (and extension (not (string= 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))
+ (resolved-path (and (file-exists-p expanded-path)
+ (file-truename expanded-path)))
+ (home-dir (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (tmp-dir (file-name-as-directory (file-truename "/tmp")))
+ (critical-dirs (list (directory-file-name home-dir)
+ (file-truename (expand-file-name "~/.emacs.d"))
+ (file-truename (expand-file-name "~/.config"))
+ (directory-file-name tmp-dir))))
+ ;; Security check: must be within allowed directories
+ (unless (or (string-prefix-p home-dir expanded-path)
+ (string-prefix-p tmp-dir 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))
+
+ (unless (or (string-prefix-p home-dir resolved-path)
+ (string-prefix-p tmp-dir resolved-path))
+ (error "Resolved path must be within home directory or /tmp: %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/archive/gptel/gptel-tools/read_buffer.el b/archive/gptel/gptel-tools/read_buffer.el
new file mode 100644
index 000000000..c9136e3cf
--- /dev/null
+++ b/archive/gptel/gptel-tools/read_buffer.el
@@ -0,0 +1,33 @@
+;;; read_buffer.el --- Read buffer tool for GPTel -*- coding: utf-8; lexical-binding: t; -*-
+
+;;; Commentary:
+;; Gptel tool that returns the contents of an Emacs buffer by name.
+
+;;; Code:
+
+(require 'gptel)
+
+(defun cj/read-buffer--get-content (buffer)
+ "Return the substring of BUFFER from `point-min' to `point-max'.
+BUFFER may be a buffer object or a buffer name string. Signal an
+error when no live buffer matches."
+ (unless (buffer-live-p (get-buffer buffer))
+ (error "Buffer %s is not live" buffer))
+ (with-current-buffer buffer
+ (save-restriction
+ (widen)
+ (buffer-substring-no-properties (point-min) (point-max)))))
+
+(gptel-make-tool
+ :name "read_buffer"
+ :function (lambda (buffer) (cj/read-buffer--get-content buffer))
+ :description "return the contents of an emacs buffer"
+ :args (list '(:name "buffer"
+ :type string
+ :description "the name of the buffer whose contents are to be retrieved"))
+ :category "emacs")
+
+(add-to-list 'gptel-tools (gptel-get-tool '("emacs" "read_buffer")))
+
+(provide 'read_buffer)
+;;; read_buffer.el ends here
diff --git a/archive/gptel/gptel-tools/read_text_file.el b/archive/gptel/gptel-tools/read_text_file.el
new file mode 100644
index 000000000..f35c94941
--- /dev/null
+++ b/archive/gptel/gptel-tools/read_text_file.el
@@ -0,0 +1,146 @@
+;;; 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* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (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))
+ (let ((resolved (file-truename full-path)))
+ (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))
+ resolved)))
+
+(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/archive/gptel/gptel-tools/update_text_file.el b/archive/gptel/gptel-tools/update_text_file.el
new file mode 100644
index 000000000..f8b58025c
--- /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
diff --git a/archive/gptel/gptel-tools/web_fetch.el b/archive/gptel/gptel-tools/web_fetch.el
new file mode 100644
index 000000000..b2f80c5fe
--- /dev/null
+++ b/archive/gptel/gptel-tools/web_fetch.el
@@ -0,0 +1,150 @@
+;;; web_fetch.el --- Web fetch tool for gptel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools, web
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool that fetches an HTTP/HTTPS URL and returns its body.
+;; HTML is piped through `pandoc -f html -t plain' (falling back to
+;; `w3m -dump -T text/html') so the model gets a reading shape that
+;; isn't full of markup; pass RAW=t to skip stripping and get the
+;; verbatim response. Output is capped at 200KB by default (hard cap
+;; 1MB) and the cap is reported inline when triggered.
+;;
+;; This tool is `:confirm t' because it makes outbound network
+;; requests -- the user sees every URL before the fetch happens. The
+;; URL goes wherever the user-agent points it, including internal
+;; networks if the URL names one; consider the network posture before
+;; approving sensitive endpoints.
+
+;;; Code:
+
+(require 'gptel)
+(require 'url)
+
+(defconst cj/gptel-web-fetch--default-max-bytes (* 200 1024)
+ "Default cap on returned body size. ~200KB.")
+
+(defconst cj/gptel-web-fetch--hard-max-bytes (* 1024 1024)
+ "Hard upper bound on the user-controllable byte cap. 1MB.")
+
+(defun cj/gptel-web-fetch--validate-url (url)
+ "Validate URL as an http or https request target. Return URL on success.
+Signals `user-error' for non-string, empty, or non-http/https URLs."
+ (unless (and (stringp url) (not (string-empty-p url)))
+ (user-error "web_fetch: expected non-empty URL string, got %S" url))
+ (unless (string-match-p "\\`https?://[^[:space:]]+\\'" url)
+ (user-error "web_fetch: URL must be http:// or https://, got %S" url))
+ url)
+
+(defun cj/gptel-web-fetch--effective-max-bytes (n)
+ "Return the byte cap to use given caller-supplied N.
+Nil / non-integer / out-of-range → default. Above hard cap → hard cap."
+ (cond
+ ((not (integerp n)) cj/gptel-web-fetch--default-max-bytes)
+ ((< n 1) cj/gptel-web-fetch--default-max-bytes)
+ ((> n cj/gptel-web-fetch--hard-max-bytes) cj/gptel-web-fetch--hard-max-bytes)
+ (t n)))
+
+(defun cj/gptel-web-fetch--retrieve (url)
+ "Synchronously GET URL. Return a cons (STATUS-CODE . BODY).
+Signals on network failure. STATUS-CODE is an integer when parseable
+from the response status line, or nil when the line is unrecognized."
+ (let ((buf (url-retrieve-synchronously url t t 30)))
+ (unless buf
+ (error "web_fetch: no response from %s" url))
+ (unwind-protect
+ (with-current-buffer buf
+ (goto-char (point-min))
+ (let* ((status (when (re-search-forward
+ "^HTTP/[0-9.]+ \\([0-9]+\\)" (point-max) t)
+ (string-to-number (match-string 1))))
+ (body-start (when (re-search-forward "\r?\n\r?\n" nil t)
+ (point))))
+ (cons status
+ (if body-start
+ (buffer-substring-no-properties body-start (point-max))
+ (buffer-substring-no-properties (point-min) (point-max))))))
+ (kill-buffer buf))))
+
+(defun cj/gptel-web-fetch--html-to-text (html)
+ "Strip HTML to plain text. Returns the stripped string.
+Tries `pandoc -f html -t plain' first, falls back to
+`w3m -dump -T text/html'. Signals `user-error' if neither is
+on PATH."
+ (let* ((coding-system-for-write 'utf-8)
+ (coding-system-for-read 'utf-8)
+ (tool (cond
+ ((executable-find "pandoc")
+ (list "pandoc" "-f" "html" "-t" "plain"))
+ ((executable-find "w3m")
+ (list "w3m" "-dump" "-T" "text/html"))
+ (t nil))))
+ (unless tool
+ (user-error
+ "web_fetch: HTML stripping needs pandoc or w3m on PATH; pass raw=t to bypass"))
+ ;; `call-process-region' with DELETE=t and OUTPUT=t replaces the
+ ;; input range with the tool's output, so `buffer-string' returns
+ ;; the stripped text.
+ (with-temp-buffer
+ (insert html)
+ (let ((exit (apply #'call-process-region
+ (point-min) (point-max) (car tool)
+ t t nil (cdr tool))))
+ (if (zerop exit)
+ (buffer-string)
+ (error "web_fetch: %s exited with %d" (car tool) exit))))))
+
+(defun cj/gptel-web-fetch--truncate (text max-bytes)
+ "Truncate TEXT to MAX-BYTES. Returns TEXT unchanged when under the cap."
+ (if (<= (length text) max-bytes)
+ text
+ (concat (substring text 0 max-bytes)
+ (format
+ "\n\n[truncated: response exceeded %d bytes; %d bytes total]"
+ max-bytes (length text)))))
+
+(defun cj/gptel-web-fetch--run (url &optional raw max-bytes)
+ "Fetch URL and return its body.
+When RAW is nil (the default) HTML responses are stripped to plain
+text via pandoc or w3m. MAX-BYTES caps the returned size; nil /
+out-of-range falls back to the default 200KB cap."
+ (let* ((validated (cj/gptel-web-fetch--validate-url url))
+ (cap (cj/gptel-web-fetch--effective-max-bytes max-bytes))
+ (response (cj/gptel-web-fetch--retrieve validated))
+ (status (car response))
+ (body (cdr response)))
+ (when (and status (>= status 400))
+ (error "web_fetch: HTTP %d from %s" status validated))
+ (let ((text (if raw body
+ (cj/gptel-web-fetch--html-to-text body))))
+ (cj/gptel-web-fetch--truncate text cap))))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "web_fetch"
+ :function (lambda (url &optional raw max_bytes)
+ (cj/gptel-web-fetch--run url raw max_bytes))
+ :description "Fetch an http:// or https:// URL and return its body. HTML responses are stripped to plain text via pandoc (or w3m as a fallback); pass raw=true to skip stripping. Output is capped at 200KB by default (max 1MB); the cap is reported inline when triggered. Network call: the URL goes wherever the user-agent points, including internal networks if specified."
+ :args (list '(:name "url"
+ :type string
+ :description "HTTP or HTTPS URL to fetch. Non-http schemes are rejected.")
+ '(:name "raw"
+ :type boolean
+ :description "When true, return the response body verbatim without HTML stripping. Default false."
+ :optional t)
+ '(:name "max_bytes"
+ :type integer
+ :description "Output size cap in bytes. Defaults to 200000; hard-capped at 1048576."
+ :optional t))
+ :category "web"
+ :confirm t
+ :include t)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("web" "web_fetch"))))
+
+(provide 'web_fetch)
+;;; web_fetch.el ends here
diff --git a/archive/gptel/gptel-tools/write_text_file.el b/archive/gptel/gptel-tools/write_text_file.el
new file mode 100644
index 000000000..1bda54469
--- /dev/null
+++ b/archive/gptel/gptel-tools/write_text_file.el
@@ -0,0 +1,107 @@
+;;; write_text_file.el --- Write 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 writing a text file under the user's home directory.
+;; Creates parent directories as needed, optionally overwrites an
+;; existing file (with a timestamped backup), and rejects writes
+;; larger than 1 GB unless the user confirms.
+
+;;; Code:
+
+(require 'gptel)
+
+(defconst cj/write-text-file--size-limit (* 1024 1024 1024)
+ "Soft cap for new-file writes (1 GB). Above this size a confirm is required.")
+
+(defun cj/write-text-file--validate-path (path)
+ "Validate PATH for write. Return the expanded path on success.
+PATH must resolve inside the user's home directory."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name path "~"))
+ (existing (and (file-exists-p full) (file-truename full)))
+ (parent (file-name-directory full))
+ (resolved-parent (and parent
+ (file-exists-p parent)
+ (file-truename parent))))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (when (and existing
+ (not (string-prefix-p home existing)))
+ (error "Resolved path must be within home directory: %s" path))
+ (when (and resolved-parent
+ (not (or (string= resolved-parent (directory-file-name home))
+ (string-prefix-p home resolved-parent))))
+ (error "Resolved parent must be within home directory: %s" path))
+ full))
+
+(defun cj/write-text-file--backup-name (path)
+ "Return a timestamped backup filename for PATH."
+ (format "%s-%s.bak"
+ path
+ (format-time-string "%Y-%m-%d-%H%M%S")))
+
+(defun cj/write-text-file--ensure-parent (path)
+ "Ensure the parent directory of PATH exists and is writable.
+Create missing parents. Signal on failure."
+ (let ((parent (file-name-directory path)))
+ (when parent
+ (unless (file-exists-p parent)
+ (condition-case err
+ (make-directory parent t)
+ (error (error "Cannot create directory %s: %s"
+ parent (error-message-string err)))))
+ (unless (file-writable-p parent)
+ (error "No write permission for directory %s" parent)))))
+
+(defun cj/write-text-file--run (path content &optional overwrite)
+ "Write CONTENT to PATH. Return a status string.
+PATH must be inside the user's home directory. If the file exists
+and OVERWRITE is non-nil, make a timestamped backup before writing;
+otherwise signal."
+ (let* ((full (cj/write-text-file--validate-path path))
+ (content (or content ""))
+ (size (length content)))
+ (when (> size cj/write-text-file--size-limit)
+ (unless (y-or-n-p (format "File is %s. Write anyway? "
+ (file-size-human-readable size)))
+ (error "File write cancelled: size exceeds 1GB limit")))
+ (cj/write-text-file--ensure-parent full)
+ (when (file-exists-p full)
+ (if overwrite
+ (let ((backup (cj/write-text-file--backup-name full)))
+ (copy-file full backup t)
+ (message "Backed up existing file to %s" backup))
+ (error "File %s already exists. Set overwrite to true to replace it" full)))
+ (with-temp-file full (insert content))
+ (format "Successfully wrote %d bytes to %s" size full)))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "write_text_file"
+ :function (lambda (path content &optional overwrite)
+ (cj/write-text-file--run path content overwrite))
+ :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)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "write_text_file"))))
+
+(provide 'write_text_file)
+;;; write_text_file.el ends here
diff --git a/archive/gptel/modules/ai-config.el b/archive/gptel/modules/ai-config.el
new file mode 100644
index 000000000..97af1296d
--- /dev/null
+++ b/archive/gptel/modules/ai-config.el
@@ -0,0 +1,585 @@
+;;; ai-config.el --- Configuration for AI Integrations -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; Layer: 3 (Domain Workflow).
+;; Category: D/P.
+;; Load shape: eager.
+;; Eager reason: registers the cj/ai-keymap (C-; a); GPTel itself should load on
+;; command, a Phase 5 deferral candidate.
+;; Top-level side effects: defines cj/ai-keymap, registers it under cj/custom-keymap.
+;; Runtime requires: keybindings, system-lib.
+;; Direct test load: yes (requires keybindings explicitly).
+;;
+;; Configuration for AI integrations in Emacs, focused on GPTel.
+;;
+;; Main Features:
+;; - Quick toggle for AI assistant window (C-; a t)
+;; - Custom keymap (C-; a prefix) for AI-related commands.
+;; - Enhanced org-mode conversation formatting with timestamps
+;; allows switching models and easily compare and track responses.
+;; - Various specialized AI directives (coder, reviewer, etc.)
+;; - Context management for adding files/buffers to conversations
+;; - Conversation persistence with save/load functionality
+;; - Integration with Magit for code review
+;;
+;; Basic Workflow
+;;
+;; Using a side-chat window:
+;; - Launch GPTel via C-; a t, and chat in the AI-Assistant side window (C-<return> to send)
+;; - Change system prompt (expertise, personalities) with C-; a p
+;; - Add context from files (C-; a f) or current buffer (C-; a .)
+;; - Save conversations with C-; a s, load previous ones with C-; a l
+;; - Clear the conversation and start over with C-; a x
+;; Or in any buffer:
+;; - Add directive as above, and select a region to rewrite with C-; a r.
+;;
+
+;;; Code:
+
+(require 'keybindings) ;; provides cj/custom-keymap
+(require 'system-lib) ;; provides cj/auth-source-secret-value
+(require 'cj-window-toggle-lib) ;; side-window size memory for the panel
+
+(autoload 'cj/gptel-save-conversation "ai-conversations" "Save the AI conversation to a file." t)
+(autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t)
+(autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t)
+(autoload 'cj/gptel-autosave-toggle "ai-conversations" "Toggle autosave in the current GPTel buffer." t)
+(autoload 'cj/gptel-quick-ask "ai-quick-ask" "One-shot quick-ask in a transient buffer." t)
+(autoload 'cj/gptel-rewrite-with-directive "ai-rewrite" "Pick a directive and run gptel-rewrite on the region." t)
+(autoload 'cj/gptel-rewrite-redo-with-different-directive "ai-rewrite" "Re-run the previous rewrite with a different directive." t)
+(autoload 'cj/gptel-browse-conversations "ai-conversations-browser" "Browse saved GPTel conversations." t)
+
+;;; ------------------------- AI Config Helper Functions ------------------------
+
+;; Define variables upfront
+(defvar cj/anthropic-api-key-cached nil "Cached Anthropic API key.")
+(defvar cj/openai-api-key-cached nil "Cached OpenAI API key.")
+(defvar gptel-claude-backend nil "Claude backend, lazy-initialized.")
+(defvar gptel-chatgpt-backend nil "ChatGPT backend, lazy-initialized.")
+
+(defcustom cj/gptel-tools-directory
+ (expand-file-name "gptel-tools/" user-emacs-directory)
+ "Directory containing optional local GPTel tool modules."
+ :type 'directory
+ :group 'cj)
+
+(defcustom cj/gptel-local-tool-features
+ '(read_buffer
+ read_text_file
+ write_text_file
+ update_text_file
+ list_directory_files
+ move_to_trash
+ git_status
+ git_log
+ git_diff
+ web_fetch)
+ "Feature symbols for optional local GPTel tool modules."
+ :type '(repeat symbol)
+ :group 'cj)
+
+(defun cj/gptel-load-local-tools
+ (&optional tools-directory tool-features)
+ "Load optional GPTel tools from TOOLS-DIRECTORY.
+TOOL-FEATURES defaults to `cj/gptel-local-tool-features'. Return a list
+of loaded feature symbols. Missing directories or individual optional
+tools are reported with `message' and do not signal."
+ (let ((dir (file-name-as-directory
+ (expand-file-name (or tools-directory cj/gptel-tools-directory))))
+ (features (or tool-features cj/gptel-local-tool-features))
+ (loaded nil))
+ (cond
+ ((not (file-directory-p dir))
+ (message "GPTel tools directory not found: %s" dir)
+ nil)
+ (t
+ (add-to-list 'load-path dir)
+ (dolist (feature features)
+ (condition-case err
+ (if (require feature nil 'noerror)
+ (push feature loaded)
+ (message "Optional GPTel tool not found: %s" feature))
+ (error
+ (message "Failed to load GPTel tool %s: %s"
+ feature
+ (error-message-string err)))))
+ (nreverse loaded)))))
+
+(with-eval-after-load 'gptel
+ (require 'ai-conversations)
+ (cj/gptel-load-local-tools))
+
+(defun cj/auth-source-secret (host user)
+ "Fetch a required secret from auth-source for HOST and USER.
+
+HOST and USER must be strings that identify the credential to return.
+Errors when no secret is found."
+ (or (cj/auth-source-secret-value host user)
+ (error "No usable secret found for host %s and user %s" host user)))
+
+(defun cj/anthropic-api-key ()
+ "Return the Anthropic API key, caching the result after first retrieval."
+ (or cj/anthropic-api-key-cached
+ (setq cj/anthropic-api-key-cached
+ (cj/auth-source-secret "api.anthropic.com" "apikey"))))
+
+(defun cj/openai-api-key ()
+ "Return the OpenAI API key, caching the result after first retrieval."
+ (or cj/openai-api-key-cached
+ (setq cj/openai-api-key-cached
+ (cj/auth-source-secret "api.openai.com" "apikey"))))
+
+(defun cj/--gptel-load-backend-libs ()
+ "Require the gptel backend libraries so their `gptel-make-*' constructors exist.
+The local fork (`:load-path \"~/code/gptel\"', `:ensure nil') ships no generated
+autoloads, so requiring `gptel' alone never loads `gptel-anthropic' /
+`gptel-openai', where the constructors are defined."
+ (require 'gptel-anthropic)
+ (require 'gptel-openai))
+
+(defun cj/ensure-gptel-backends ()
+ "Initialize GPTel backends if they are not already available.
+Loads the backend libraries first so the `gptel-make-*' constructors are
+defined even when gptel is the local fork without generated autoloads."
+ (cj/--gptel-load-backend-libs)
+ (unless gptel-claude-backend
+ (setq gptel-claude-backend
+ (gptel-make-anthropic
+ "Claude"
+ :key (cj/anthropic-api-key)
+ :models '(
+ "claude-opus-4-7"
+ "claude-sonnet-4-6"
+ "claude-haiku-4-5-20251001"
+ )
+ :stream t)))
+ (unless gptel-chatgpt-backend
+ (setq gptel-chatgpt-backend
+ (gptel-make-openai
+ "ChatGPT"
+ :key (cj/openai-api-key)
+ :models '(
+ "gpt-5.5"
+ "gpt-5.4-mini"
+ "o3"
+ )
+ :stream t)))
+ ;; Set default backend and model
+ (unless gptel-backend
+ (setq gptel-backend (or gptel-chatgpt-backend gptel-claude-backend))
+ (setq gptel-model 'gpt-5.5)))
+
+;; ------------------ GPTel Conversation And Utility Commands ------------------
+
+(defun cj/gptel--available-backends ()
+ "Return an alist of (NAME . BACKEND).
+Ensures gptel and backends are initialized."
+ (unless (featurep 'gptel)
+ (require 'gptel))
+ (cj/ensure-gptel-backends)
+ (delq nil
+ (list (and (bound-and-true-p gptel-claude-backend)
+ (cons "Anthropic - Claude" gptel-claude-backend))
+ (and (bound-and-true-p gptel-chatgpt-backend)
+ (cons "OpenAI - ChatGPT" gptel-chatgpt-backend)))))
+
+(defun cj/gptel--model-to-string (m)
+ "Return model M as a string regardless of its type."
+ (cond
+ ((stringp m) m)
+ ((symbolp m) (symbol-name m))
+ (t (format "%s" m))))
+
+(defun cj/gptel--model-to-symbol (m)
+ "Return model M as a symbol regardless of its type.
+`gptel-model' must be a symbol: gptel's modeline code calls `symbolp'
+on it and signals `wrong-type-argument' on a string, which surfaces as a
+redisplay hang. Coerce any model value through this before assigning it."
+ (cond
+ ((symbolp m) m)
+ ((stringp m) (intern m))
+ (t (intern (format "%s" m)))))
+
+;; Backend/model switching helpers (pure logic, extracted for testability)
+
+(defun cj/gptel--build-model-list (backends model-fn)
+ "Build a flat list of all models across BACKENDS.
+BACKENDS is an alist of (NAME . BACKEND-OBJECT). MODEL-FN is called
+with each backend object and should return a list of model identifiers.
+Returns a list of entries: (DISPLAY-STRING BACKEND MODEL-STRING BACKEND-NAME)
+where DISPLAY-STRING is \"Backend: model\" for use in completing-read."
+ (mapcan
+ (lambda (pair)
+ (let* ((backend-name (car pair))
+ (backend (cdr pair))
+ (models (funcall model-fn backend)))
+ (mapcar (lambda (m)
+ (list (format "%s: %s" backend-name (cj/gptel--model-to-string m))
+ backend
+ (cj/gptel--model-to-string m)
+ backend-name))
+ models)))
+ backends))
+
+(defun cj/gptel--current-model-selection (backends current-backend current-model)
+ "Format the current backend/model as a display string.
+BACKENDS is the alist from `cj/gptel--available-backends'.
+CURRENT-BACKEND and CURRENT-MODEL are the active gptel settings.
+Returns a string like \"Anthropic - Claude: claude-opus-4-7\"."
+ (let ((backend-name (car (rassoc current-backend backends))))
+ (format "%s: %s"
+ (or backend-name "AI")
+ (cj/gptel--model-to-string current-model))))
+
+(defun cj/--gptel-apply-model-selection (scope backend model backend-name)
+ "Set gptel BACKEND and MODEL, globally or buffer-locally per SCOPE.
+SCOPE is \"global\" or \"buffer\"; any non-\"global\" value is buffer-local.
+MODEL is a symbol. BACKEND-NAME is the display name for the confirmation.
+Returns the confirmation message string."
+ (if (string= scope "global")
+ (progn
+ (setq gptel-backend backend)
+ (setq gptel-model model)
+ (format "Changed to %s model: %s (global)" backend-name model))
+ (setq-local gptel-backend backend)
+ (setq-local gptel-model model)
+ (format "Changed to %s model: %s (buffer-local)" backend-name model)))
+
+;; Backend/model switching commands
+(defun cj/gptel-change-model ()
+ "Change the GPTel backend and select a model from that backend.
+Present all available models from every backend, switching backends when
+necessary. Prompt for whether to apply the selection globally or buffer-locally."
+ (interactive)
+ (let* ((backends (cj/gptel--available-backends))
+ (all-models (cj/gptel--build-model-list
+ backends
+ (lambda (b)
+ (when (fboundp 'gptel-backend-models)
+ (gptel-backend-models b)))))
+ (current-selection (cj/gptel--current-model-selection
+ backends
+ (bound-and-true-p gptel-backend)
+ (bound-and-true-p gptel-model)))
+ (scope (completing-read "Set model for: " '("buffer" "global") nil t))
+ (selected (completing-read
+ (format "Select model (current: %s): " current-selection)
+ (mapcar #'car all-models) nil t nil nil current-selection)))
+ (let* ((model-info (assoc selected all-models))
+ (backend (nth 1 model-info))
+ (model (intern (nth 2 model-info)))
+ (backend-name (nth 3 model-info)))
+ (message "%s" (cj/--gptel-apply-model-selection
+ scope backend model backend-name)))))
+
+(defun cj/gptel-switch-backend ()
+ "Switch the GPTel backend and then choose one of its models."
+ (interactive)
+ (let* ((backends (cj/gptel--available-backends))
+ (choice (completing-read "Select GPTel backend: " (mapcar #'car backends) nil t))
+ (backend (cdr (assoc choice backends))))
+ (unless backend
+ (user-error "Invalid GPTel backend: %s" choice))
+ (let* ((models (when (fboundp 'gptel-backend-models)
+ (gptel-backend-models backend)))
+ (model (completing-read (format "Select %s model: " choice)
+ (mapcar #'cj/gptel--model-to-string models)
+ nil t nil nil (cj/gptel--model-to-string (bound-and-true-p gptel-model)))))
+ (setq gptel-backend backend
+ gptel-model (cj/gptel--model-to-symbol model))
+ (message "Switched to %s with model: %s" choice model))))
+
+;; Clear assistant buffer (moved out so it's always available)
+(defun cj/gptel-clear-buffer ()
+ "Erase the current GPTel buffer while preserving the initial Org heading.
+Operate only when `gptel-mode' is active in an Org buffer so the heading
+can be reinserted."
+ (interactive)
+ (let ((is-gptel (bound-and-true-p gptel-mode))
+ (is-org (derived-mode-p 'org-mode)))
+ (if (and is-gptel is-org)
+ (progn
+ (erase-buffer)
+ (when (fboundp 'cj/gptel--fresh-org-prefix)
+ (insert (cj/gptel--fresh-org-prefix)))
+ (message "GPTel buffer cleared and heading reset"))
+ (message "Not a GPTel buffer in org-mode. Nothing cleared."))))
+
+;; ----------------------------- Context Management ----------------------------
+
+(defun cj/gptel--add-file-to-context (file-path)
+ "Add FILE-PATH to the GPTel context.
+Returns t on success, nil on failure.
+Provides consistent user feedback about the context state."
+ (when (and file-path (file-exists-p file-path))
+ (gptel-add-file file-path)
+ (let ((context-count (if (boundp 'gptel-context--alist)
+ (length gptel-context--alist)
+ 0)))
+ (message "Added %s to GPTel context (%d sources total)"
+ (file-name-nondirectory file-path)
+ context-count))
+ t))
+
+(defun cj/gptel-add-file ()
+ "Add a file to the GPTel context.
+If inside a Projectile project, prompt from that project's file list.
+Otherwise, prompt with `read-file-name'."
+ (interactive)
+ (let* ((in-proj (and (featurep 'projectile)
+ (fboundp 'projectile-project-p)
+ (projectile-project-p)))
+ (file-name (if in-proj
+ (let ((cands (projectile-current-project-files)))
+ (if (fboundp 'projectile-completing-read)
+ (projectile-completing-read "GPTel add file: " cands)
+ (completing-read "GPTel add file: " cands nil t)))
+ (read-file-name "GPTel add file: ")))
+ (file-path (if in-proj
+ (expand-file-name file-name (projectile-project-root))
+ file-name)))
+ (unless (cj/gptel--add-file-to-context file-path)
+ (error "Failed to add file: %s" file-path))))
+
+(defun cj/gptel-add-buffer-file ()
+ "Select a buffer and add its associated file to the GPTel context.
+Lists all open buffers for selection. If the selected buffer is visiting
+a file, that file is added to the GPTel context. Otherwise, an error
+message is displayed."
+ (interactive)
+ (let* ((buffers (mapcar #'buffer-name (buffer-list)))
+ (selected-buffer-name (completing-read "Add file from buffer: " buffers nil t))
+ (selected-buffer (get-buffer selected-buffer-name))
+ (file-path (and selected-buffer
+ (buffer-file-name selected-buffer))))
+ (if file-path
+ (cj/gptel--add-file-to-context file-path)
+ (message "Buffer '%s' is not visiting a file" selected-buffer-name))))
+
+(defun cj/gptel-add-this-buffer ()
+ "Add the current buffer to the GPTel context.
+Works for any buffer, whether it's visiting a file or not."
+ (interactive)
+ ;; Load gptel-context if needed
+ (unless (featurep 'gptel-context)
+ (require 'gptel-context))
+ ;; Use gptel-add with prefix arg '(4) to add current buffer
+ (gptel-add '(4))
+ (message "Added buffer '%s' to GPTel context" (buffer-name)))
+
+;;; -------------------------- Org Header Construction --------------------------
+
+(defun cj/gptel--fresh-org-prefix ()
+ "Generate a fresh org-mode header with current timestamp for user messages."
+ (concat "* " user-login-name " " (format-time-string "[%Y-%m-%d %H:%M:%S]") "\n"))
+
+(defun cj/gptel--refresh-org-prefix (&rest _)
+ "Update the org-mode prefix with fresh timestamp before sending message."
+ (setf (alist-get 'org-mode gptel-prompt-prefix-alist)
+ (cj/gptel--fresh-org-prefix)))
+
+(defun cj/gptel-backend-and-model ()
+ "Return backend, model, and timestamp as a single string."
+ (let* ((backend (pcase (bound-and-true-p gptel-backend)
+ ((and v (pred vectorp)) (aref v 1))
+ (_ "AI")))
+ (model (format "%s" (or (bound-and-true-p gptel-model) "")))
+ (ts (format-time-string "[%Y-%m-%d %H:%M:%S]")))
+ (format "%s: %s %s" backend model ts)))
+
+(defun cj/gptel-insert-model-heading (response-begin-pos _response-end-pos)
+ "Insert an Org heading for the AI reply at RESPONSE-BEGIN-POS."
+ (save-excursion
+ (goto-char response-begin-pos)
+ (insert (format "* %s\n" (cj/gptel-backend-and-model)))))
+
+;;; ---------------------------- GPTel Configuration ----------------------------
+
+(use-package gptel
+ :load-path "~/code/gptel"
+ :ensure nil
+ :defer t
+ :commands (gptel gptel-send gptel-menu)
+ :bind
+ (:map gptel-mode-map
+ ("C-<return>" . gptel-send))
+ :custom
+ (gptel-default-mode 'org-mode)
+ (gptel-expert-commands t)
+ (gptel-track-media t)
+ ;; Options: t (include + resend), 'ignore (show but don't resend),
+ ;; nil (discard), or a buffer name to redirect reasoning to
+ (gptel-include-reasoning "*AI-Reasoning*")
+ (gptel-log-level 'info)
+ (gptel--debug nil)
+ :config
+ (cj/ensure-gptel-backends)
+ ;; Set ChatGPT (gpt-5.5) as default after initialization. Model
+ ;; must be a symbol -- gptel's modeline-display code calls `symbolp'
+ ;; on it and signals `wrong-type-argument' otherwise.
+ (setq gptel-backend gptel-chatgpt-backend)
+ (setq gptel-model 'gpt-5.5)
+
+ (setq gptel-confirm-tool-calls nil) ;; allow tool access by default
+
+ ;; Initialize org-mode user prefix and wire up hooks
+ (setf (alist-get 'org-mode gptel-prompt-prefix-alist)
+ (cj/gptel--fresh-org-prefix))
+ (advice-add 'gptel-send :before #'cj/gptel--refresh-org-prefix)
+ (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading))
+
+;;; ---------------------------- Toggle GPTel Window ----------------------------
+
+(defvar cj/ai-assistant-window-width 0.4
+ "Default fraction of frame width for the *AI-Assistant* side window.
+Used until the panel is resized and toggled off this session; after that,
+the toggled-off width is remembered in `cj/--ai-assistant-width'.")
+
+(defvar cj/--ai-assistant-width nil
+ "Last width fraction the *AI-Assistant* side window was toggled off at.
+nil falls back to `cj/ai-assistant-window-width'. Shared by the panel's
+entry points (toggle, load-conversation, quick-ask escalation) so the
+panel reopens at one consistent width. In-memory only -- resets each
+Emacs session.")
+
+(defun cj/toggle-gptel ()
+ "Toggle the visibility of the AI-Assistant buffer, and place point at its end.
+The panel opens at `cj/ai-assistant-window-width'; once it has been resized
+and toggled off this session, it reopens at that remembered width."
+ (interactive)
+ (let* ((buf-name "*AI-Assistant*")
+ (buffer (get-buffer buf-name))
+ (win (and buffer (get-buffer-window buffer))))
+ (if win
+ (progn
+ (cj/side-window-capture-size win 'right 'cj/--ai-assistant-width)
+ (delete-window win))
+ ;; Ensure GPTel and our backends are initialized before creating the buffer
+ (unless (featurep 'gptel)
+ (require 'gptel))
+ (cj/ensure-gptel-backends)
+ (unless buffer
+ ;; Pass backend, not model
+ (gptel buf-name gptel-backend))
+ (setq buffer (get-buffer buf-name))
+ (setq win
+ (cj/side-window-display
+ buffer 'right 'cj/--ai-assistant-width
+ cj/ai-assistant-window-width))
+ (select-window win)
+ (with-current-buffer buffer
+ (goto-char (point-max))))))
+
+;; ------------------------------- Clear Context -------------------------------
+
+(defun cj/gptel-context-clear ()
+ "Clear all GPTel context sources, with compatibility across GPTel versions."
+ (interactive)
+ (cond
+ ((fboundp 'gptel-context-remove-all)
+ (call-interactively 'gptel-context-remove-all)
+ (message "GPTel context cleared"))
+ ((fboundp 'gptel-context-clear)
+ (call-interactively 'gptel-context-clear)
+ (message "GPTel context cleared"))
+ ((boundp 'gptel-context--alist)
+ (setq gptel-context--alist nil)
+ (message "GPTel context cleared"))
+ (t
+ (message "No known GPTel context clearing function available"))))
+
+;;; -------------------------------- GPTel-Magit --------------------------------
+
+;; Each integration point waits on its actual dependency, not on `magit'
+;; broadly. `magit.el' calls `(provide 'magit)' BEFORE its
+;; `cl-eval-when (load eval) ...' block requires `magit-commit' and
+;; `magit-stash', so a single `with-eval-after-load 'magit' fires while
+;; the transient prefixes the wiring references are still undefined.
+;; `transient-append-suffix' silently no-ops on missing prefixes (it
+;; calls `message' unless `transient-error-on-insert-failure' is set),
+;; which is how the failure stayed invisible.
+;;
+;; Keys:
+;; M-g — generate commit message (in commit message buffer)
+;; g — generate commit (in magit-commit transient)
+;; x — explain diff (in magit-diff transient)
+
+(use-package gptel-magit
+ :defer t
+ :commands (gptel-magit-generate-message
+ gptel-magit-commit-generate
+ gptel-magit-diff-explain)
+ :init
+ (with-eval-after-load 'git-commit
+ (define-key git-commit-mode-map (kbd "M-g") #'gptel-magit-generate-message))
+ (with-eval-after-load 'magit-commit
+ (transient-append-suffix 'magit-commit #'magit-commit-create
+ '("g" "Generate commit" gptel-magit-commit-generate)))
+ (with-eval-after-load 'magit-diff
+ (transient-append-suffix 'magit-diff #'magit-stash-show
+ '("x" "Explain" gptel-magit-diff-explain))))
+
+;; ------------------------------ GPTel Directives -----------------------------
+
+(use-package gptel-prompts
+ :load-path (lambda () (expand-file-name "custom/" user-emacs-directory))
+ :after gptel
+ :if (file-exists-p (expand-file-name "custom/gptel-prompts.el" user-emacs-directory))
+ :custom
+ (gptel-prompts-directory (concat user-emacs-directory "ai-prompts"))
+ :config
+ (gptel-prompts-update)
+ (gptel-prompts-add-update-watchers)
+ ;; gptel--system-message is set at gptel load time, before gptel-prompts
+ ;; replaces the default directive. Re-apply it now.
+ (when-let* ((dir (alist-get 'default gptel-directives)))
+ (setq gptel--system-message dir)))
+
+;;; --------------------------------- AI Keymap ---------------------------------
+
+(defvar-keymap cj/ai-keymap
+ :doc "Keymap for gptel and other AI operations."
+ "A" #'cj/gptel-autosave-toggle ;; toggle autosave on the current GPTel buffer
+ "B" #'cj/gptel-switch-backend ;; change the backend (OpenAI, Anthropic, etc.
+ "M" #'gptel-menu ;; gptel's transient menu
+ "d" #'cj/gptel-delete-conversation ;; delete conversation
+ "." #'cj/gptel-add-this-buffer ;; add buffer to context
+ "f" #'cj/gptel-add-file ;; add a file to context
+ "b" #'cj/gptel-browse-conversations ;; browse saved conversations
+ "l" #'cj/gptel-load-conversation ;; load and continue conversation
+ "m" #'cj/gptel-change-model ;; change the LLM model
+ "p" #'gptel-system-prompt ;; change prompt
+ "q" #'cj/gptel-quick-ask ;; one-shot quick ask
+ "r" #'cj/gptel-rewrite-with-directive ;; rewrite region with a chosen directive
+ "R" #'cj/gptel-rewrite-redo-with-different-directive ;; redo last rewrite, new directive
+ "c" #'cj/gptel-context-clear ;; clear all context
+ "s" #'cj/gptel-save-conversation ;; save conversation
+ "t" #'cj/toggle-gptel ;; toggles the ai-assistant window
+ "x" #'cj/gptel-clear-buffer) ;; clears the assistant buffer
+(cj/register-prefix-map "a" cj/ai-keymap)
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; a" "AI assistant menu"
+ "C-; a A" "toggle autosave"
+ "C-; a B" "switch backend"
+ "C-; a M" "gptel menu"
+ "C-; a b" "browse conversations"
+ "C-; a d" "delete conversation"
+ "C-; a ." "add buffer"
+ "C-; a f" "add file"
+ "C-; a l" "load conversation"
+ "C-; a m" "change model"
+ "C-; a p" "change prompt"
+ "C-; a q" "quick ask"
+ "C-; a r" "rewrite region (directive)"
+ "C-; a R" "redo rewrite, new directive"
+ "C-; a c" "clear context"
+ "C-; a s" "save conversation"
+ "C-; a t" "toggle window"
+ "C-; a x" "clear buffer"))
+
+(provide 'ai-config)
+;;; ai-config.el ends here.
diff --git a/archive/gptel/modules/ai-conversations-browser.el b/archive/gptel/modules/ai-conversations-browser.el
new file mode 100644
index 000000000..9f2a7de43
--- /dev/null
+++ b/archive/gptel/modules/ai-conversations-browser.el
@@ -0,0 +1,241 @@
+;;; ai-conversations-browser.el --- Browse saved GPTel conversations -*- lexical-binding: t; coding: utf-8; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Provides `cj/gptel-browse-conversations': a dired-style buffer
+;; listing saved conversations in `cj/gptel-conversations-directory'.
+;; Each row shows date, time, topic, and a short preview of the most
+;; recent message. Single-key bindings load / delete / rename a
+;; conversation in place.
+;;
+;; RET, l Load the conversation under point
+;; d Delete the conversation under point
+;; r Rename the conversation under point (renames the file)
+;; g Refresh the listing
+;; n / p Move to next / previous row
+;; q Quit the browser window
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'subr-x)
+
+(declare-function cj/gptel-load-conversation "ai-conversations" ())
+(declare-function cj/gptel--slugify-topic "ai-conversations" (s))
+(declare-function cj/gptel--timestamp-from-filename "ai-conversations" (filename))
+
+(defcustom cj/gptel-browser-preview-length 60
+ "Number of preview characters shown per row in the browser."
+ :type 'integer
+ :group 'cj/ai-conversations)
+
+(defconst cj/gptel-browser--buffer-name "*GPTel-Conversations*"
+ "Buffer name for the saved-conversations browser.")
+
+(defvar-keymap cj/gptel-browser-mode-map
+ :doc "Keymap for `cj/gptel-browser-mode'."
+ "RET" #'cj/gptel-browser-load
+ "l" #'cj/gptel-browser-load
+ "d" #'cj/gptel-browser-delete
+ "r" #'cj/gptel-browser-rename
+ "g" #'cj/gptel-browser-refresh
+ "n" #'next-line
+ "p" #'previous-line
+ "q" #'quit-window)
+
+(define-derived-mode cj/gptel-browser-mode special-mode "GPTel-Browser"
+ "Major mode for browsing saved GPTel conversations."
+ (setq-local truncate-lines t))
+
+;; -------------------------- helpers (pure where possible)
+
+(defun cj/gptel-browser--topic-from-filename (filename)
+ "Return the topic slug from FILENAME, or nil if it isn't a gptel file."
+ (when (string-match "\\`\\(.+\\)_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel\\'" filename)
+ (match-string 1 filename)))
+
+(defun cj/gptel-browser--strip-headers (text)
+ "Drop the org #+STARTUP / #+VISIBILITY headers from TEXT and return the rest."
+ (let ((s text))
+ (while (string-match "\\`#\\+\\(STARTUP\\|VISIBILITY\\):.*\n" s)
+ (setq s (substring s (match-end 0))))
+ (while (and (> (length s) 0) (eq (aref s 0) ?\n))
+ (setq s (substring s 1)))
+ s))
+
+(defun cj/gptel-browser--last-message (text)
+ "Return a short preview of the last user/AI message in TEXT.
+Returns the empty string when no message body is present."
+ (let* ((stripped (cj/gptel-browser--strip-headers text))
+ ;; Last org-mode top-level heading body, or the whole text if
+ ;; there isn't one.
+ (body (if (string-match "\\`\\*+[^\n]*\n\\(\\(?:.\\|\n\\)*\\)\\'" stripped)
+ (let* ((all-text stripped)
+ ;; Walk backward to find the last '* ' or '** ' heading
+ (idx (or (cl-loop for i from (1- (length all-text)) downto 0
+ when (and (or (zerop i)
+ (eq (aref all-text (1- i)) ?\n))
+ (eq (aref all-text i) ?*))
+ return i)
+ 0)))
+ (substring all-text idx))
+ stripped)))
+ ;; Drop the heading line itself, then collapse whitespace.
+ (when (string-match "\\`\\*+[^\n]*\n" body)
+ (setq body (substring body (match-end 0))))
+ (setq body (replace-regexp-in-string "[\n\t ]+" " " body))
+ (string-trim body)))
+
+(defun cj/gptel-browser--preview (text length)
+ "Return a LENGTH-char preview from TEXT, ellipsized when truncated."
+ (let* ((line (cj/gptel-browser--last-message text))
+ (max-len (max 1 length)))
+ (cond
+ ((string-empty-p line) "")
+ ((> (length line) max-len)
+ (concat (substring line 0 (1- max-len)) "…"))
+ (t line))))
+
+(defun cj/gptel-browser--row-for-file (file dir)
+ "Return a propertized row string for FILE under DIR, or nil."
+ (let* ((filename (file-name-nondirectory file))
+ (topic (cj/gptel-browser--topic-from-filename filename))
+ (ts (and topic (cj/gptel--timestamp-from-filename filename))))
+ (when (and topic ts)
+ (let* ((preview (with-temp-buffer
+ (ignore-errors (insert-file-contents file))
+ (cj/gptel-browser--preview
+ (buffer-string) cj/gptel-browser-preview-length)))
+ (row (format "%s %-22s %s"
+ (format-time-string "%Y-%m-%d %H:%M" ts)
+ topic preview)))
+ (propertize row
+ 'cj/gptel-browser-file filename
+ 'cj/gptel-browser-topic topic)))))
+
+(defun cj/gptel-browser--rows ()
+ "Return propertized row strings for every conversation in the directory."
+ (when (and (boundp 'cj/gptel-conversations-directory)
+ (file-directory-p cj/gptel-conversations-directory))
+ (let ((dir cj/gptel-conversations-directory))
+ (delq nil
+ (mapcar (lambda (f) (cj/gptel-browser--row-for-file f dir))
+ (directory-files dir t "\\.gptel\\'"))))))
+
+(defun cj/gptel-browser--render ()
+ "Replace the current buffer's contents with the conversation listing.
+Sort newest first."
+ (let ((inhibit-read-only t)
+ (rows (sort (cj/gptel-browser--rows)
+ (lambda (a b)
+ (string> (substring-no-properties a 0 16)
+ (substring-no-properties b 0 16))))))
+ (erase-buffer)
+ (insert (propertize
+ "Saved GPTel conversations -- RET/l load d delete r rename g refresh q quit\n\n"
+ 'face 'header-line))
+ (cond
+ ((null rows)
+ (insert " (no saved conversations)\n"))
+ (t
+ (dolist (row rows)
+ (insert row "\n"))))
+ (goto-char (point-min))
+ (forward-line 2)))
+
+;; -------------------------- entry point
+
+;;;###autoload
+(defun cj/gptel-browse-conversations ()
+ "Open the saved GPTel conversations browser."
+ (interactive)
+ (let ((buf (get-buffer-create cj/gptel-browser--buffer-name)))
+ (with-current-buffer buf
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render))
+ (pop-to-buffer buf)))
+
+(defun cj/gptel-browser-refresh ()
+ "Re-read the conversations directory and refresh the browser."
+ (interactive)
+ (cj/gptel-browser--render))
+
+;; -------------------------- row-level actions
+
+(defun cj/gptel-browser--filename-at-point ()
+ "Return the conversation filename on the current line, or nil."
+ (get-text-property (line-beginning-position) 'cj/gptel-browser-file))
+
+(defun cj/gptel-browser--filepath-at-point ()
+ "Return the absolute filepath for the row at point, or nil."
+ (when-let ((filename (cj/gptel-browser--filename-at-point)))
+ (expand-file-name filename cj/gptel-conversations-directory)))
+
+(defun cj/gptel-browser-load ()
+ "Load the conversation on the current row via `cj/gptel-load-conversation'.
+The browser is buried after the load fires."
+ (interactive)
+ (let ((filepath (cj/gptel-browser--filepath-at-point)))
+ (unless filepath
+ (user-error "No conversation on this line"))
+ (let ((filename (file-name-nondirectory filepath)))
+ ;; Stand in for cj/gptel-load-conversation's completing-read so
+ ;; the user doesn't get prompted twice.
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p cands &rest _)
+ (or (car (cl-find filename cands
+ :key (lambda (c) (cdr c))
+ :test #'equal))
+ (caar cands))))
+ ((symbol-function 'y-or-n-p) (lambda (&rest _) nil)))
+ (cj/gptel-load-conversation)))
+ (quit-window)))
+
+(defun cj/gptel-browser-delete ()
+ "Delete the conversation file on the current row, after confirmation."
+ (interactive)
+ (let ((filepath (cj/gptel-browser--filepath-at-point)))
+ (unless filepath
+ (user-error "No conversation on this line"))
+ (let ((filename (file-name-nondirectory filepath)))
+ (when (y-or-n-p (format "Delete %s? " filename))
+ (delete-file filepath)
+ (message "Deleted %s" filename)
+ (cj/gptel-browser--render)))))
+
+(defun cj/gptel-browser--rename-target (filepath new-topic)
+ "Compute the renamed FILEPATH for NEW-TOPIC, preserving the timestamp.
+NEW-TOPIC is slugified. Returns the new absolute filepath."
+ (let* ((dir (file-name-directory filepath))
+ (filename (file-name-nondirectory filepath))
+ (timestamp (and (string-match "_\\([0-9]\\{8\\}-[0-9]\\{6\\}\\)\\.gptel\\'"
+ filename)
+ (match-string 1 filename)))
+ (slug (cj/gptel--slugify-topic new-topic)))
+ (unless timestamp
+ (error "Cannot extract timestamp from filename: %s" filename))
+ (expand-file-name (format "%s_%s.gptel" slug timestamp) dir)))
+
+(defun cj/gptel-browser-rename ()
+ "Rename the conversation file on the current row, preserving its timestamp."
+ (interactive)
+ (let ((filepath (cj/gptel-browser--filepath-at-point)))
+ (unless filepath
+ (user-error "No conversation on this line"))
+ (let* ((old (file-name-nondirectory filepath))
+ (current-topic (cj/gptel-browser--topic-from-filename old))
+ (new-topic (read-string
+ (format "New topic (was %s): " current-topic)
+ current-topic))
+ (target (cj/gptel-browser--rename-target filepath new-topic)))
+ (when (equal target filepath)
+ (user-error "Topic unchanged"))
+ (when (file-exists-p target)
+ (user-error "Target already exists: %s" (file-name-nondirectory target)))
+ (rename-file filepath target)
+ (message "Renamed to %s" (file-name-nondirectory target))
+ (cj/gptel-browser--render))))
+
+(provide 'ai-conversations-browser)
+;;; ai-conversations-browser.el ends here
diff --git a/archive/gptel/modules/ai-conversations.el b/archive/gptel/modules/ai-conversations.el
new file mode 100644
index 000000000..8061051a8
--- /dev/null
+++ b/archive/gptel/modules/ai-conversations.el
@@ -0,0 +1,369 @@
+;;; ai-conversations.el --- GPTel conversation persistence and autosave -*- lexical-binding: t; coding: utf-8; -*-
+;; Author: Craig Jennings <c@cjennings.net>
+;; Maintainer: Craig Jennings <c@cjennings.net>
+;; Version 0.1
+;; Package-Requires: ((emacs "27.1"))
+;; Keywords: convenience, tools
+;;
+;;; Commentary:
+;; Provides conversation save/load/delete, autosave after responses, and
+;; org-visibility headers for GPTel-powered assistant buffers.
+;;
+;; Loads lazily via autoloads for the interactive entry points.
+
+;;; Code:
+
+(require 'cj-window-toggle-lib) ;; cj/side-window-display
+
+;; Shared *AI-Assistant* remembered-width state, owned by ai-config.el.
+;; Forward-declared so loading a conversation reopens the panel at the same
+;; width as the F-key toggle without a circular require.
+(defvar cj/--ai-assistant-width)
+
+(defgroup cj/ai-conversations nil
+ "Conversation persistence for GPTel (save/load/delete, autosave)."
+ :group 'gptel
+ :prefix "cj/")
+
+(defcustom cj/gptel-conversations-directory
+ (expand-file-name "ai-conversations" user-emacs-directory)
+ "Directory where GPTel conversations are stored."
+ :type 'directory
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-window-side 'right
+ "Side to display the AI-Assistant buffer when loading a conversation."
+ :type '(choice (const :tag "Right" right)
+ (const :tag "Left" left)
+ (const :tag "Bottom" bottom)
+ (const :tag "Top" top))
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-window-width 0.4
+ "Set the side window width when loading a conversation.
+
+If displaying on the top or bottom, treat this value as a height fraction."
+ :type 'number
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-sort-order 'newest-first
+ "Sort order for conversation selection prompts."
+ :type '(choice (const :tag "Newest first" newest-first)
+ (const :tag "Oldest first" oldest-first))
+ :group 'cj/ai-conversations)
+
+(defvar-local cj/gptel-autosave-enabled nil
+ "Non-nil means auto-save after each AI response in GPTel buffers.")
+
+(defvar-local cj/gptel-autosave-filepath nil
+ "File path used for auto-saving the conversation buffer.")
+
+(defvar-local cj/gptel-autosave--timer nil
+ "Repeating timer used to auto-save the current GPTel buffer.")
+
+(defcustom cj/gptel-autosave-interval 60
+ "Seconds between periodic GPTel conversation autosaves."
+ :type 'number
+ :group 'cj/ai-conversations)
+
+(defvar cj/gptel-autosave-mode-line-format
+ '(:eval (when (bound-and-true-p cj/gptel-autosave-enabled) " [AS]"))
+ "Mode-line construct that surfaces autosave state in GPTel buffers.")
+(put 'cj/gptel-autosave-mode-line-format 'risky-local-variable t)
+
+(defun cj/gptel--autosave-active-p ()
+ "Return non-nil when the current buffer has an autosave target."
+ (and (bound-and-true-p gptel-mode)
+ cj/gptel-autosave-enabled
+ (stringp cj/gptel-autosave-filepath)
+ (> (length cj/gptel-autosave-filepath) 0)))
+
+(defun cj/gptel--autosave-stop-timer ()
+ "Cancel the current buffer's periodic autosave timer, if any."
+ (when cj/gptel-autosave--timer
+ (cancel-timer cj/gptel-autosave--timer)
+ (setq-local cj/gptel-autosave--timer nil)))
+
+(defun cj/gptel--autosave-timer-callback (buffer)
+ "Auto-save BUFFER from a periodic timer when autosave is still active."
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (if (cj/gptel--autosave-active-p)
+ (condition-case err
+ (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
+ (error (message "cj/gptel periodic autosave failed: %s"
+ (error-message-string err))))
+ (cj/gptel--autosave-stop-timer)))))
+
+(defun cj/gptel--autosave-start-timer ()
+ "Start the current buffer's periodic autosave timer when autosave is active."
+ (when (and (cj/gptel--autosave-active-p)
+ (not cj/gptel-autosave--timer))
+ (setq-local cj/gptel-autosave--timer
+ (run-with-timer cj/gptel-autosave-interval
+ cj/gptel-autosave-interval
+ #'cj/gptel--autosave-timer-callback
+ (current-buffer)))))
+
+(defun cj/gptel-autosave-toggle ()
+ "Toggle autosave on/off in the current GPTel buffer.
+Flips `cj/gptel-autosave-enabled' and forces a mode-line redisplay so
+the [AS] indicator updates immediately. When turning autosave ON
+without a configured filepath, prompt to save the conversation first
+so a path exists to autosave to."
+ (interactive)
+ (unless (bound-and-true-p gptel-mode)
+ (user-error "Not a GPTel buffer"))
+ (if cj/gptel-autosave-enabled
+ (progn
+ (setq-local cj/gptel-autosave-enabled nil)
+ (cj/gptel--autosave-stop-timer)
+ (message "Autosave disabled"))
+ (cond
+ ((and (stringp cj/gptel-autosave-filepath)
+ (> (length cj/gptel-autosave-filepath) 0))
+ (setq-local cj/gptel-autosave-enabled t)
+ (cj/gptel--autosave-start-timer)
+ (message "Autosave enabled (saving to %s)"
+ (file-name-nondirectory cj/gptel-autosave-filepath)))
+ ((y-or-n-p "No save target yet. Save conversation first? ")
+ (call-interactively #'cj/gptel-save-conversation))
+ (t
+ (message "Autosave not enabled (no save target)"))))
+ (force-mode-line-update))
+
+(defcustom cj/gptel-conversations-autosave-on-send t
+ "Non-nil means auto-save the conversation immediately after `gptel-send'."
+ :type 'boolean
+ :group 'cj/ai-conversations)
+
+(defun cj/gptel--autosave-after-send (&rest _args)
+ "Auto-save current GPTel buffer right after `gptel-send' if enabled."
+ (when (and cj/gptel-conversations-autosave-on-send
+ (cj/gptel--autosave-active-p))
+ (condition-case err
+ (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
+ (error (message "cj/gptel autosave-on-send failed: %s" (error-message-string err))))))
+
+(with-eval-after-load 'gptel
+ (unless (advice-member-p #'cj/gptel--autosave-after-send #'gptel-send)
+ (advice-add 'gptel-send :after #'cj/gptel--autosave-after-send)))
+
+(defun cj/gptel--install-autosave-mode-line ()
+ "Add the [AS] autosave indicator to the current buffer's mode-line.
+Idempotent: re-running in the same buffer does not duplicate the
+construct."
+ (unless (member 'cj/gptel-autosave-mode-line-format mode-line-format)
+ (setq-local mode-line-format
+ (append mode-line-format
+ (list 'cj/gptel-autosave-mode-line-format)))))
+
+(defun cj/gptel--install-autosave-buffer-hooks ()
+ "Install buffer-local cleanup hooks for GPTel autosave."
+ (add-hook 'kill-buffer-hook #'cj/gptel--autosave-stop-timer nil t))
+
+(with-eval-after-load 'gptel
+ (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-mode-line)
+ (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-buffer-hooks))
+
+(defun cj/gptel--slugify-topic (s)
+ "Return a filesystem-friendly slug for topic string S."
+ (let* ((down (downcase (or s "")))
+ (repl (replace-regexp-in-string "[^a-z0-9]+" "-" down))
+ (trim (replace-regexp-in-string "^-+\\|-+$" "" repl)))
+ (or (and (> (length trim) 0) trim) "conversation")))
+
+(defun cj/gptel--existing-topics ()
+ "Return topic slugs, without timestamps, present in the conversations directory."
+ (when (file-exists-p cj/gptel-conversations-directory)
+ (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$")))
+ (delete-dups
+ (mapcar
+ (lambda (f)
+ (replace-regexp-in-string "_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" "" f))
+ files)))))
+
+(defun cj/gptel--latest-file-for-topic (topic-slug)
+ "Return the newest saved conversation filename for TOPIC-SLUG, or nil."
+ (let* ((rx (format "^%s_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$"
+ (regexp-quote topic-slug)))
+ (files (and (file-exists-p cj/gptel-conversations-directory)
+ (directory-files cj/gptel-conversations-directory nil rx))))
+ (car (sort files #'string>))))
+
+(defun cj/gptel--timestamp-from-filename (filename)
+ "Return an Emacs timestamp extracted from FILENAME, or nil.
+
+Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel."
+ (when (string-match "_\\([0-9]\\{8\\}\\)-\\([0-9]\\{6\\}\\)\\.gptel\\'" filename)
+ (let* ((date (match-string 1 filename))
+ (time (match-string 2 filename))
+ (Y (string-to-number (substring date 0 4)))
+ (M (string-to-number (substring date 4 6)))
+ (D (string-to-number (substring date 6 8)))
+ (h (string-to-number (substring time 0 2)))
+ (m (string-to-number (substring time 2 4)))
+ (s (string-to-number (substring time 4 6))))
+ (encode-time s m h D M Y))))
+
+(defun cj/gptel--conversation-candidates ()
+ "Return conversation candidates sorted per `cj/gptel-conversations-sort-order'."
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$"))
+ (enriched
+ (mapcar
+ (lambda (f)
+ (let* ((full (expand-file-name f cj/gptel-conversations-directory))
+ (ptime (or (cj/gptel--timestamp-from-filename f)
+ (nth 5 (file-attributes full))))
+ (disp (format "%s [%s]" f (format-time-string "%Y-%m-%d %H:%M" ptime))))
+ (list :file f :time ptime :display disp)))
+ files))
+ (sorted
+ (sort enriched
+ (lambda (a b)
+ (let ((ta (plist-get a :time))
+ (tb (plist-get b :time)))
+ (if (eq cj/gptel-conversations-sort-order 'newest-first)
+ (time-less-p tb ta) ;; tb earlier than ta => a first
+ (time-less-p ta tb))))))
+ (cands (mapcar (lambda (pl)
+ (cons (plist-get pl :display)
+ (plist-get pl :file)))
+ sorted)))
+ cands))
+
+(defun cj/gptel--save-buffer-to-file (buffer filepath)
+ "Save BUFFER content to FILEPATH with Org visibility properties."
+ (with-current-buffer buffer
+ (let ((content (buffer-string)))
+ (with-temp-buffer
+ (insert "#+STARTUP: showeverything\n")
+ (insert "#+VISIBILITY: all\n\n")
+ (insert content)
+ (write-region (point-min) (point-max) filepath nil 'silent))))
+ filepath)
+
+(defun cj/gptel--ensure-ai-buffer ()
+ "Return the *AI-Assistant* buffer, creating it via `gptel' if needed."
+ (let* ((buf-name "*AI-Assistant*")
+ (buffer (get-buffer buf-name)))
+ (unless buffer
+ (gptel buf-name))
+ (or (get-buffer buf-name)
+ (user-error "Could not create or find *AI-Assistant* buffer"))))
+
+(defun cj/gptel-save-conversation ()
+ "Save the current AI-Assistant buffer to a .gptel file.
+
+Enable autosave for subsequent AI responses to the same file."
+ (interactive)
+ (let ((buf (get-buffer "*AI-Assistant*")))
+ (unless buf
+ (user-error "No AI-Assistant buffer found"))
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (make-directory cj/gptel-conversations-directory t)
+ (message "Created directory: %s" cj/gptel-conversations-directory))
+ (let* ((topics (or (cj/gptel--existing-topics) '()))
+ (input (completing-read "Conversation topic: " topics nil nil))
+ (topic-slug (cj/gptel--slugify-topic input))
+ (latest (cj/gptel--latest-file-for-topic topic-slug))
+ (use-existing (and latest
+ (y-or-n-p (format "Update existing file %s? " latest))))
+ (filepath (if use-existing
+ (expand-file-name latest cj/gptel-conversations-directory)
+ (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S"))
+ (filename (format "%s_%s.gptel" topic-slug timestamp)))
+ (expand-file-name filename cj/gptel-conversations-directory)))))
+ (cj/gptel--save-buffer-to-file buf filepath)
+ (with-current-buffer buf
+ (setq-local cj/gptel-autosave-filepath filepath)
+ (setq-local cj/gptel-autosave-enabled t)
+ (cj/gptel--autosave-start-timer))
+ (message "Conversation saved to: %s" filepath))))
+
+(defun cj/gptel-delete-conversation ()
+ "Delete a saved GPTel conversation file (chronologically sorted candidates)."
+ (interactive)
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((cands (cj/gptel--conversation-candidates)))
+ (unless cands
+ (user-error "No saved conversations found in %s" cj/gptel-conversations-directory))
+ (let* ((completion-extra-properties '(:display-sort-function identity
+ :cycle-sort-function identity))
+ (selection (completing-read "Delete conversation: " cands nil t))
+ (filename (cdr (assoc selection cands)))
+ (filepath (and filename
+ (expand-file-name filename cj/gptel-conversations-directory))))
+ (unless filename
+ (user-error "No conversation selected"))
+ (when (y-or-n-p (format "Really delete %s? " filename))
+ (delete-file filepath)
+ (message "Deleted conversation: %s" filename)))))
+
+(defun cj/gptel--strip-visibility-headers ()
+ "Strip org visibility headers at the top of the current buffer if present."
+ (save-excursion
+ (goto-char (point-min))
+ (while (looking-at "^#\\+\\(STARTUP\\|VISIBILITY\\):.*\n")
+ (delete-region (match-beginning 0) (match-end 0)))
+ (when (looking-at "^\n+")
+ (delete-region (point) (match-end 0)))))
+
+(defun cj/gptel-load-conversation ()
+ "Load a saved GPTel conversation into the AI-Assistant buffer.
+
+Prompt to save the current conversation first when appropriate, then
+enable autosave."
+ (interactive)
+ (let ((ai-buffer (get-buffer-create "*AI-Assistant*")))
+ (when (and (with-current-buffer ai-buffer (> (buffer-size) 0))
+ (with-current-buffer ai-buffer (bound-and-true-p gptel-mode)))
+ (when (y-or-n-p "Save current conversation before loading new one? ")
+ (with-current-buffer ai-buffer
+ (call-interactively #'cj/gptel-save-conversation)))))
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((cands (cj/gptel--conversation-candidates)))
+ (unless cands
+ (user-error "No saved conversations found in %s" cj/gptel-conversations-directory))
+ (let* ((completion-extra-properties '(:display-sort-function identity
+ :cycle-sort-function identity))
+ (selection (completing-read "Load conversation: " cands nil t))
+ (filename (cdr (assoc selection cands)))
+ (filepath (and filename
+ (expand-file-name filename cj/gptel-conversations-directory))))
+ (unless filename
+ (user-error "No conversation selected"))
+ (with-current-buffer (cj/gptel--ensure-ai-buffer)
+ (erase-buffer)
+ (insert-file-contents filepath)
+ (cj/gptel--strip-visibility-headers)
+ (goto-char (point-max))
+ (set-buffer-modified-p t)
+ (setq-local cj/gptel-autosave-filepath filepath)
+ (setq-local cj/gptel-autosave-enabled t)
+ (cj/gptel--autosave-start-timer))
+ (let ((buf (get-buffer "*AI-Assistant*")))
+ (unless (get-buffer-window buf)
+ (cj/side-window-display
+ buf cj/gptel-conversations-window-side
+ 'cj/--ai-assistant-width cj/gptel-conversations-window-width)))
+ (select-window (get-buffer-window "*AI-Assistant*"))
+ (message "Loaded conversation from: %s" filepath))))
+
+(defun cj/gptel--autosave-after-response (&rest _args)
+ "Auto-save the current GPTel buffer when enabled."
+ (when (cj/gptel--autosave-active-p)
+ (condition-case err
+ (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
+ (error (message "cj/gptel autosave failed: %s" (error-message-string err))))))
+
+(with-eval-after-load 'gptel
+ (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions)
+ (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response)))
+
+(provide 'ai-conversations)
+;;; ai-conversations.el ends here
diff --git a/archive/gptel/modules/ai-mcp.el b/archive/gptel/modules/ai-mcp.el
new file mode 100644
index 000000000..510805be4
--- /dev/null
+++ b/archive/gptel/modules/ai-mcp.el
@@ -0,0 +1,416 @@
+;;; ai-mcp.el --- MCP server integration for GPTel -*- lexical-binding: t; coding: utf-8; -*-
+;; Author: Craig Jennings <c@cjennings.net>
+;; Maintainer: Craig Jennings <c@cjennings.net>
+;; Version 0.1
+;; Package-Requires: ((emacs "30.1") (mcp "0.1.0") (gptel "0.9.8"))
+;; Keywords: convenience, tools, ai
+;;
+;;; Commentary:
+;; Wires mcp.el's MCP server inventory into GPTel. GPTel agents gain
+;; access to the MCP servers Claude Code already uses (linear, notion,
+;; figma, slack-deepsat, drawio, google-calendar, google-docs-personal,
+;; google-docs-work, google-keep), with write-confirmation gating and a
+;; doctor for diagnosing prerequisites.
+;;
+;; Design doc: docs/specs/mcp-el-gptel-integration-spec-doing.org
+;;
+;; File organization (seven sections, populated by phases):
+;; 1. Constants and defcustoms <- this phase
+;; 2. Public commands <- later phase
+;; 3. Pure helpers <- this phase
+;; 4. mcp.el compatibility layer <- later phase
+;; 5. Registration pipeline <- later phase
+;; 6. Async state machine <- later phase
+;; 7. UI <- later phase
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'json)
+
+;;;; --- 1. Constants and defcustoms -----------------------------------
+
+(defgroup cj/ai-mcp nil
+ "MCP server integration for GPTel."
+ :group 'gptel
+ :prefix "cj/")
+
+(defcustom cj/mcp-claude-config
+ (expand-file-name "~/.claude.json")
+ "Path to the Claude Code config that holds MCP server env vars.
+The config is read at server-spawn time and cached by mtime."
+ :type 'file
+ :group 'cj/ai-mcp)
+
+(defconst cj/mcp-server-specs
+ '((:name "linear"
+ :transport http
+ :url "https://mcp.linear.app/mcp"
+ :auth in-protocol
+ :risk write-capable)
+ (:name "notion"
+ :transport http
+ :url "https://mcp.notion.com/mcp"
+ :auth in-protocol
+ :risk write-capable)
+ (:name "figma"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "figma-developer-mcp" "--stdio")
+ :secret-args ("--figma-api-key" :figma-api-key)
+ :auth args-token
+ :risk arg-leak)
+ (:name "slack-deepsat"
+ :transport sse
+ :url "http://127.0.0.1:13080/sse"
+ :auth local
+ :risk write-capable)
+ (:name "drawio"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@drawio/mcp")
+ :auth none
+ :risk none)
+ (:name "google-calendar"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@cocal/google-calendar-mcp")
+ :env (:GOOGLE_OAUTH_CREDENTIALS t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-docs-personal"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@a-bonus/google-docs-mcp")
+ :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-docs-work"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@a-bonus/google-docs-mcp")
+ :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-keep"
+ :transport stdio
+ :command "uvx"
+ :args ("--from" "keep-mcp" "python" "-m" "server.cli")
+ :env (:GOOGLE_EMAIL t :GOOGLE_MASTER_TOKEN t)
+ :auth token
+ :risk write-capable))
+ "Static, secret-free description of the MCP servers we wire to GPTel.
+Each entry is a plist describing one server. `:env' values are
+placeholders (t) replaced at spawn time from `cj/mcp-claude-config'.
+`:secret-args' (e.g. for figma) names the flag whose value is pulled
+from the Claude config's args at spawn time.")
+
+(defcustom cj/mcp-enabled-servers
+ (mapcar (lambda (s) (plist-get s :name)) cj/mcp-server-specs)
+ "List of MCP server names to start.
+Defaults to every server in `cj/mcp-server-specs'. Set to a
+shorter list to disable specific servers without editing the
+spec. Changes take effect on next `cj/mcp-restart-failed' or
+Emacs restart."
+ :type '(repeat string)
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-start-on-entry-points
+ '(toggle-gptel)
+ "GPTel entry points that trigger MCP startup.
+Symbols correspond to commands: `toggle-gptel', `gptel-send',
+`gptel-quick-ask', `gptel-rewrite-with-directive',
+`gptel-magit-generate-message'. Default: only full chat
+\(`toggle-gptel')."
+ :type '(repeat symbol)
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-startup-timeout 30
+ "Seconds before a still-starting MCP server is marked failed."
+ :type 'integer
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-tool-timeout 60
+ "Seconds before an in-flight MCP tool call times out."
+ :type 'integer
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-tool-confirm-overrides nil
+ "Per-tool confirmation overrides.
+Alist mapping fully qualified MCP tool name (e.g.,
+\"mcp__linear__create_issue\") to t or nil. Wins over the
+pattern-based classifier in `cj/mcp--confirm-p'."
+ :type '(alist :key-type string :value-type boolean)
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-tool-audit-log-enabled t
+ "When non-nil, append metadata for every MCP tool call to the audit log."
+ :type 'boolean
+ :group 'cj/ai-mcp)
+
+;; Classifier patterns: name prefixes that indicate read vs write.
+
+(defconst cj/mcp--write-name-patterns
+ '("\\`create\\b" "\\`update\\b" "\\`delete\\b" "\\`remove\\b"
+ "\\`send\\b" "\\`post\\b" "\\`add\\b" "\\`move\\b"
+ "\\`invite\\b" "\\`share\\b" "\\`upload\\b" "\\`set\\b"
+ "\\`patch\\b" "\\`import\\b" "\\`sync\\b" "\\`merge\\b"
+ "\\`close\\b" "\\`reopen\\b" "\\`archive\\b" "\\`unarchive\\b"
+ "\\`approve\\b" "\\`reject\\b" "\\`label\\b" "\\`assign\\b"
+ "\\`reply\\b" "\\`comment\\b" "\\`trash\\b" "\\`restore\\b"
+ "\\`pin\\b" "\\`unpin\\b" "\\`copy\\b" "\\`rename\\b")
+ "Tool-name prefixes that indicate a write/mutate operation.
+Matched after the `mcp__SERVER__' prefix is stripped.")
+
+(defconst cj/mcp--read-name-patterns
+ '("\\`get\\b" "\\`list\\b" "\\`read\\b" "\\`search\\b"
+ "\\`find\\b" "\\`fetch\\b" "\\`view\\b" "\\`query\\b"
+ "\\`describe\\b" "\\`show\\b" "\\`check\\b")
+ "Tool-name prefixes that indicate a read-only operation.")
+
+;; Secret-pattern list for redaction. Each entry is (REGEX
+;; . GROUP-NUMBER); the substring matched by GROUP-NUMBER is replaced
+;; with "***".
+
+(defconst cj/mcp--secret-redaction-patterns
+ '(("\\(--token\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
+ ("\\(--secret\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
+ ("\\(--password\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
+ ("\\(--figma-api-key\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
+ ("\\(Authorization:\\s-*\\)\\(\\S-[^\"\n]*\\)" . 2)
+ ("\\([?&]token=\\)\\([^&[:space:]\"]+\\)" . 2))
+ "List of (REGEX . GROUP-NUMBER) for masking secrets in user-facing strings.
+Applied in order by `cj/mcp--redact'.")
+
+;;;; --- 3. Pure helpers -----------------------------------------------
+
+;; ---- secrets redaction ----
+
+(defun cj/mcp--redact (str)
+ "Return STR with known secret patterns replaced by `***'.
+Returns nil when STR is not a string. See
+`cj/mcp--secret-redaction-patterns' for the matched patterns."
+ (when (stringp str)
+ (let ((result str))
+ (dolist (entry cj/mcp--secret-redaction-patterns result)
+ (let ((re (car entry))
+ (group (cdr entry))
+ (start 0))
+ (while (and (< start (length result))
+ (string-match re result start))
+ (setq result
+ (concat (substring result 0 (match-beginning group))
+ "***"
+ (substring result (match-end group))))
+ (setq start (+ (match-beginning group) 3))))))))
+
+;; ---- confirm-policy classifier ----
+
+(defun cj/mcp--strip-name-prefix (name)
+ "Strip the `mcp__SERVER__' prefix from NAME, if present."
+ (replace-regexp-in-string "\\`mcp__[^_]+__" "" name))
+
+(defun cj/mcp--name-matches-p (name patterns)
+ "Non-nil if NAME matches any regexp in PATTERNS."
+ (cl-some (lambda (p) (string-match-p p name)) patterns))
+
+(defun cj/mcp--confirm-p (gptel-name &optional remote-name)
+ "Return non-nil if a tool should register with `:confirm t'.
+GPTEL-NAME is the fully qualified `mcp__SERVER__TOOL' string.
+REMOTE-NAME, if provided, overrides the prefix-strip of GPTEL-NAME.
+
+Decision order:
+1. `cj/mcp-tool-confirm-overrides' alist entry wins.
+2. Bare name matches a write pattern → t.
+3. Bare name matches a read pattern → nil.
+4. Neither → t (fail closed)."
+ (let ((override (assoc gptel-name cj/mcp-tool-confirm-overrides)))
+ (cond
+ (override (cdr override))
+ (t
+ (let ((bare (or remote-name (cj/mcp--strip-name-prefix gptel-name))))
+ (cond
+ ((cj/mcp--name-matches-p bare cj/mcp--write-name-patterns) t)
+ ((cj/mcp--name-matches-p bare cj/mcp--read-name-patterns) nil)
+ (t t)))))))
+
+;; ---- description normalizer ----
+
+(defun cj/mcp--normalize-description (server-name raw-tool)
+ "Return a normalized description string for RAW-TOOL from SERVER-NAME.
+Prefix `[SERVER]' for reads, `[SERVER WRITE]' for writes,
+`[SERVER ?]' for unknown classification, then the upstream
+description unchanged."
+ (let* ((remote-name (plist-get raw-tool :name))
+ (upstream (or (plist-get raw-tool :description)
+ "(no description provided by server)"))
+ (suffix (cond
+ ((cj/mcp--name-matches-p remote-name
+ cj/mcp--write-name-patterns)
+ " WRITE")
+ ((cj/mcp--name-matches-p remote-name
+ cj/mcp--read-name-patterns)
+ "")
+ (t " ?"))))
+ (format "[%s%s] %s" server-name suffix upstream)))
+
+;; ---- Claude config reader (mtime-cached, structured returns) ----
+
+(defvar cj/mcp--config-cache nil
+ "Cache for the parsed Claude config.
+Plist of (:path P :mtime M :data PARSED) or nil when empty.")
+
+(defun cj/mcp--invalidate-config-cache ()
+ "Force the next `cj/mcp--read-claude-config' call to reparse."
+ (setq cj/mcp--config-cache nil))
+
+(defun cj/mcp--read-claude-config (&optional path)
+ "Return a structured plist describing the Claude config state.
+PATH defaults to `cj/mcp-claude-config'.
+
+Result shape:
+ (:ok t :data PLIST)
+ (:ok nil :reason missing-file)
+ (:ok nil :reason unreadable)
+ (:ok nil :reason malformed-json :message STR)
+
+The parsed result is cached by (PATH, MTIME); subsequent calls
+reparse only if the file has changed."
+ (let ((path (or path cj/mcp-claude-config)))
+ (cond
+ ((not (file-exists-p path))
+ (list :ok nil :reason 'missing-file))
+ ((not (file-readable-p path))
+ (list :ok nil :reason 'unreadable))
+ (t
+ (let ((mtime (file-attribute-modification-time
+ (file-attributes path))))
+ (if (and cj/mcp--config-cache
+ (equal (plist-get cj/mcp--config-cache :path) path)
+ (equal (plist-get cj/mcp--config-cache :mtime) mtime))
+ (list :ok t :data (plist-get cj/mcp--config-cache :data))
+ (condition-case err
+ (let* ((json-object-type 'plist)
+ (json-array-type 'list)
+ (data (with-temp-buffer
+ (insert-file-contents path)
+ (goto-char (point-min))
+ (json-read))))
+ (setq cj/mcp--config-cache
+ (list :path path :mtime mtime :data data))
+ (list :ok t :data data))
+ (error
+ (setq cj/mcp--config-cache nil)
+ (list :ok nil :reason 'malformed-json
+ :message (error-message-string err))))))))))
+
+;; ---- env / secret-args resolution ----
+
+(defun cj/mcp--get-server-entry (server-name &optional config-result)
+ "Return the parsed Claude-config entry plist for SERVER-NAME.
+CONFIG-RESULT, if provided, is a return value from
+`cj/mcp--read-claude-config' (avoids re-reading). Returns nil
+when the config is unavailable or SERVER-NAME is unknown."
+ (let ((result (or config-result (cj/mcp--read-claude-config))))
+ (when (plist-get result :ok)
+ (let* ((data (plist-get result :data))
+ (servers (plist-get data :mcpServers))
+ (server-key (intern (concat ":" server-name))))
+ (plist-get servers server-key)))))
+
+(defun cj/mcp--get-env (server-name &optional config-result)
+ "Return the env plist for SERVER-NAME from the parsed Claude config.
+CONFIG-RESULT, if provided, is reused to avoid re-reading the
+config. Returns nil when the config is unavailable, the server
+is unknown, or the server has no env section."
+ (plist-get (cj/mcp--get-server-entry server-name config-result) :env))
+
+(defun cj/mcp--get-secret-arg (server-name flag &optional config-result)
+ "Return the secret value for SERVER-NAME's FLAG from the Claude config.
+FLAG is the option name (e.g. \"--figma-api-key\"). Returns the
+value following `FLAG=' in the server entry's args, or nil if
+not found."
+ (let* ((entry (cj/mcp--get-server-entry server-name config-result))
+ (args (plist-get entry :args))
+ (prefix (concat flag "=")))
+ (cl-some
+ (lambda (a)
+ (when (and (stringp a) (string-prefix-p prefix a))
+ (substring a (length prefix))))
+ args)))
+
+;; ---- server-alist builder (pure transform from specs + config) ----
+
+(defun cj/mcp--resolve-env (env-spec server-name config-result)
+ "Return a flat (KEY1 VAL1 KEY2 VAL2 ...) list for ENV-SPEC.
+ENV-SPEC is a plist of `(:VAR1 t :VAR2 t)`. Values come from
+SERVER-NAME's env subtree in the parsed Claude config. Vars
+without a value are omitted."
+ (let ((source-env (cj/mcp--get-env server-name config-result))
+ (result nil))
+ (cl-loop for (key _placeholder) on env-spec by #'cddr
+ do (let ((value (plist-get source-env key)))
+ (when value
+ (push key result)
+ (push value result))))
+ (nreverse result)))
+
+(defun cj/mcp--resolve-args (args secret-args-spec server-name config-result)
+ "Return ARGS with `:secret-args' placeholders filled in.
+SECRET-ARGS-SPEC is (FLAG-STRING SLOT-KEYWORD). When the value is
+available in the Claude config, append `FLAG=VALUE' to ARGS;
+otherwise return ARGS unchanged."
+ (if (not secret-args-spec)
+ args
+ (let* ((flag (car secret-args-spec))
+ (value (cj/mcp--get-secret-arg server-name flag config-result)))
+ (if value
+ (append args (list (format "%s=%s" flag value)))
+ args))))
+
+(defun cj/mcp--spec-to-alist-entry (spec config-result)
+ "Translate one SPEC plist into a `(NAME . PLIST)' alist entry.
+Pulls env values from CONFIG-RESULT; splices `:secret-args' into
+`:args' for stdio specs that declare one."
+ (let* ((name (plist-get spec :name))
+ (transport (plist-get spec :transport))
+ (entry (list :type (symbol-name transport)))
+ (env-spec (plist-get spec :env))
+ (secret-args-spec (plist-get spec :secret-args)))
+ (pcase transport
+ ('stdio
+ (setq entry (append entry
+ (list :command (plist-get spec :command)
+ :args (cj/mcp--resolve-args
+ (plist-get spec :args)
+ secret-args-spec
+ name
+ config-result)))))
+ ((or 'http 'sse)
+ (setq entry (append entry
+ (list :url (plist-get spec :url))))))
+ (when env-spec
+ (let ((env-pairs (cj/mcp--resolve-env env-spec name config-result)))
+ (when env-pairs
+ (setq entry (append entry (list :env env-pairs))))))
+ (cons name entry)))
+
+(defun cj/mcp--build-server-alist (&optional specs enabled-names config-result)
+ "Return an alist suitable for `mcp-hub-servers'.
+SPECS defaults to `cj/mcp-server-specs'. ENABLED-NAMES defaults
+to `cj/mcp-enabled-servers'. CONFIG-RESULT, if provided, is a
+parsed Claude-config result (reused for env/secret resolution).
+Does not mutate SPECS."
+ (let* ((specs (or specs cj/mcp-server-specs))
+ (enabled-names (or enabled-names cj/mcp-enabled-servers))
+ (config-result (or config-result (cj/mcp--read-claude-config))))
+ (delq nil
+ (mapcar
+ (lambda (spec)
+ (let ((name (plist-get spec :name)))
+ (when (member name enabled-names)
+ (cj/mcp--spec-to-alist-entry spec config-result))))
+ specs))))
+
+(provide 'ai-mcp)
+;;; ai-mcp.el ends here
diff --git a/archive/gptel/modules/ai-quick-ask.el b/archive/gptel/modules/ai-quick-ask.el
new file mode 100644
index 000000000..16f3afae4
--- /dev/null
+++ b/archive/gptel/modules/ai-quick-ask.el
@@ -0,0 +1,141 @@
+;;; ai-quick-ask.el --- One-shot GPTel quick-ask -*- lexical-binding: t; coding: utf-8; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Provides `cj/gptel-quick-ask': read a single prompt in the
+;; minibuffer, stream the response into a transient *GPTel-Quick*
+;; buffer. The transient buffer is dismissible with q or escape and
+;; can be escalated with c into a full *AI-Assistant* conversation
+;; seeded with the prompt + response.
+;;
+;; Designed for impromptu help where the conversation thread doesn't
+;; matter. Doesn't touch the *AI-Assistant* side window unless the
+;; user explicitly escalates, doesn't autosave anywhere.
+
+;;; Code:
+
+(require 'cj-window-toggle-lib) ;; cj/side-window-display
+
+;; Shared *AI-Assistant* panel-width state, owned by ai-config.el. Forward-
+;; declared here so the escalation reopens the panel at the same remembered
+;; width as the F-key toggle without a circular require.
+(defvar cj/ai-assistant-window-width)
+(defvar cj/--ai-assistant-width)
+
+(defvar-local cj/gptel-quick--prompt nil
+ "Buffer-local: the prompt used for the current *GPTel-Quick* session.")
+
+(defconst cj/gptel-quick--buffer-name "*GPTel-Quick*"
+ "Buffer used for one-shot quick-ask Q&A.")
+
+(defconst cj/gptel-quick--response-marker "A: "
+ "String inserted before the response in the quick-ask buffer.")
+
+(defvar-keymap cj/gptel-quick-mode-map
+ :doc "Keymap for `cj/gptel-quick-mode'."
+ "q" #'cj/gptel-quick-dismiss
+ "<escape>" #'cj/gptel-quick-dismiss
+ "c" #'cj/gptel-quick-continue)
+
+(define-derived-mode cj/gptel-quick-mode special-mode "GPTel-Quick"
+ "Major mode for the one-shot *GPTel-Quick* buffer."
+ ;; Allow gptel-request to stream into the buffer despite the
+ ;; special-mode read-only default.
+ (setq-local buffer-read-only nil))
+
+(defun cj/gptel-quick--initial-text (prompt)
+ "Return the initial buffer body for a quick-ask of PROMPT.
+The result is \"Q: <prompt>\\n\\nA: \", with the response marker at
+the end so the streamed response lands right after it."
+ (format "Q: %s\n\n%s" prompt cj/gptel-quick--response-marker))
+
+(defun cj/gptel-quick--extract-response (text)
+ "Return the response portion of TEXT, or nil if not found.
+TEXT is the contents of a *GPTel-Quick* buffer. The response is
+everything after the first occurrence of `cj/gptel-quick--response-marker'
+on its own line. Returns nil when the marker is absent."
+ (when (string-match
+ (concat "^" (regexp-quote cj/gptel-quick--response-marker))
+ text)
+ (substring text (match-end 0))))
+
+(defun cj/gptel-quick--seed-text (prompt response)
+ "Format a *AI-Assistant* seed from PROMPT and RESPONSE.
+Matches the org-heading shape that `cj/gptel--fresh-org-prefix' and
+`cj/gptel-insert-model-heading' produce: a user heading followed by
+the prompt body, followed by an AI heading followed by the response."
+ (let ((ts (format-time-string "[%Y-%m-%d %H:%M:%S]")))
+ (format "* %s %s\n%s\n\n* AI %s\n%s\n"
+ user-login-name ts prompt
+ ts (or response ""))))
+
+;;;###autoload
+(defun cj/gptel-quick-ask (prompt)
+ "Read a one-shot PROMPT in the minibuffer and stream the answer.
+The response lands in a transient *GPTel-Quick* buffer. Press q or
+escape to dismiss, or c to escalate into a full *AI-Assistant*
+conversation seeded with the prompt and response."
+ (interactive (list (read-string "Quick ask: ")))
+ (when (string-empty-p prompt)
+ (user-error "Empty prompt"))
+ (let ((buf (get-buffer-create cj/gptel-quick--buffer-name)))
+ (with-current-buffer buf
+ (cj/gptel-quick-mode)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert (cj/gptel-quick--initial-text prompt))
+ (setq-local cj/gptel-quick--prompt prompt)))
+ (unless (featurep 'gptel)
+ (require 'gptel))
+ (when (fboundp 'cj/ensure-gptel-backends)
+ (cj/ensure-gptel-backends))
+ (gptel-request prompt
+ :buffer buf
+ :position (with-current-buffer buf (point-max))
+ :stream t)
+ (display-buffer buf
+ '((display-buffer-reuse-window
+ display-buffer-pop-up-window)
+ (window-height . 0.3)))
+ buf))
+
+(defun cj/gptel-quick-dismiss ()
+ "Kill the *GPTel-Quick* buffer if it exists."
+ (interactive)
+ (when-let ((buf (get-buffer cj/gptel-quick--buffer-name)))
+ (when-let ((win (get-buffer-window buf)))
+ (delete-window win))
+ (kill-buffer buf)))
+
+(defun cj/gptel-quick-continue ()
+ "Escalate the current quick-ask into a full *AI-Assistant* conversation.
+Reads the prompt and response from the *GPTel-Quick* buffer, seeds
+them into *AI-Assistant* under proper org headings, displays the
+side window, then dismisses the quick buffer."
+ (interactive)
+ (unless (eq major-mode 'cj/gptel-quick-mode)
+ (user-error "Not in a *GPTel-Quick* buffer"))
+ (let* ((prompt cj/gptel-quick--prompt)
+ (response (cj/gptel-quick--extract-response (buffer-string)))
+ (seed (cj/gptel-quick--seed-text prompt response)))
+ (unless prompt
+ (user-error "No prompt recorded in this buffer"))
+ ;; Ensure *AI-Assistant* exists in gptel-mode.
+ (unless (featurep 'gptel)
+ (require 'gptel))
+ (let ((ai-buf (get-buffer "*AI-Assistant*")))
+ (unless ai-buf
+ (when (fboundp 'cj/ensure-gptel-backends)
+ (cj/ensure-gptel-backends))
+ (gptel "*AI-Assistant*")
+ (setq ai-buf (get-buffer "*AI-Assistant*")))
+ (with-current-buffer ai-buf
+ (goto-char (point-max))
+ (insert seed))
+ (cj/side-window-display
+ ai-buf 'right 'cj/--ai-assistant-width cj/ai-assistant-window-width)
+ (cj/gptel-quick-dismiss))))
+
+(provide 'ai-quick-ask)
+;;; ai-quick-ask.el ends here
diff --git a/archive/gptel/modules/ai-rewrite.el b/archive/gptel/modules/ai-rewrite.el
new file mode 100644
index 000000000..fb25c1379
--- /dev/null
+++ b/archive/gptel/modules/ai-rewrite.el
@@ -0,0 +1,108 @@
+;;; ai-rewrite.el --- Directive-picker wrappers for gptel-rewrite -*- lexical-binding: t; coding: utf-8; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Adds two ergonomic wrappers around `gptel-rewrite':
+;;
+;; cj/gptel-rewrite-with-directive Pick a named directive,
+;; then rewrite the region.
+;; cj/gptel-rewrite-redo-with-different-directive
+;; Re-run the previous region
+;; with a different directive.
+;;
+;; A directive is a short system-message snippet attached to a name
+;; (e.g. "terse", "fix-grammar"). The directive body is injected
+;; into the rewrite via `gptel-rewrite-directives-hook' just for that
+;; call -- no global state changes.
+
+;;; Code:
+
+;; Declare the hook variable special so our `let'-binding below is
+;; dynamic (visible across the `call-interactively' that follows)
+;; rather than lexical when this file is byte-compiled.
+(defvar gptel-rewrite-directives-hook)
+
+(defcustom cj/gptel-rewrite-directives
+ '(("terse"
+ . "Rewrite the text to be as terse as possible without losing meaning.\nDo not add commentary. Return only the rewritten text.")
+ ("fix-grammar"
+ . "Fix grammar and spelling errors only. Do not rephrase, restructure,\nor change tone. Return only the corrected text.")
+ ("refactor-readability"
+ . "Refactor the code for readability. Improve naming, split long\nfunctions when appropriate, remove unnecessary complexity, and preserve\nbehavior exactly. Return only the refactored code.")
+ ("add-docstring"
+ . "Add or improve docstrings for every function in the region. Use the\nidiomatic docstring style for the language. Do not change executable\ncode. Return the whole region with the updated docstrings.")
+ ("explain-as-comment"
+ . "Replace the region with the original code, preceded by a concise\nblock comment explaining what the code does. Use the language's\nidiomatic comment syntax. Return code + comment, nothing else.")
+ ("shorten"
+ . "Shorten the text while preserving meaning, technical accuracy, and\nthe author's voice. Remove rhetorical padding. Return only the\nshortened text."))
+ "Named system-message directives for `cj/gptel-rewrite-with-directive'.
+Each entry is a (NAME . BODY) pair where NAME is the directive label
+presented in the completing-read prompt and BODY is the system
+message injected into the next `gptel-rewrite' call."
+ :type '(alist :key-type string :value-type string)
+ :group 'cj)
+
+(defvar-local cj/gptel-rewrite--last-region nil
+ "Cons (BEG-MARKER . END-MARKER) of the last directive-driven rewrite.")
+
+(defvar-local cj/gptel-rewrite--last-directive nil
+ "Name of the directive used in the last directive-driven rewrite.")
+
+(defun cj/gptel-rewrite--call-with-directive (directive-name beg end)
+ "Run `gptel-rewrite' over BEG..END with DIRECTIVE-NAME's system message.
+Stores the region (as markers) and directive name on buffer-local
+variables so `cj/gptel-rewrite-redo-with-different-directive' can
+revisit them."
+ (let ((body (alist-get directive-name cj/gptel-rewrite-directives
+ nil nil #'equal)))
+ (unless body
+ (user-error "Unknown rewrite directive: %s" directive-name))
+ (setq-local cj/gptel-rewrite--last-region
+ (cons (copy-marker beg) (copy-marker end)))
+ (setq-local cj/gptel-rewrite--last-directive directive-name)
+ (let ((gptel-rewrite-directives-hook
+ (cons (lambda () body) gptel-rewrite-directives-hook)))
+ (save-excursion
+ (goto-char beg)
+ (push-mark end t t)
+ (call-interactively #'gptel-rewrite)))))
+
+;;;###autoload
+(defun cj/gptel-rewrite-with-directive (directive-name)
+ "Pick DIRECTIVE-NAME from `cj/gptel-rewrite-directives' and rewrite the region.
+Requires an active region. The directive is applied only to this
+call -- it does not modify global `gptel-directives'."
+ (interactive
+ (progn
+ (unless (use-region-p)
+ (user-error "No region selected"))
+ (list (completing-read
+ "Rewrite directive: "
+ (mapcar #'car cj/gptel-rewrite-directives) nil t))))
+ (cj/gptel-rewrite--call-with-directive
+ directive-name (region-beginning) (region-end)))
+
+;;;###autoload
+(defun cj/gptel-rewrite-redo-with-different-directive ()
+ "Re-run the previous directive-driven rewrite with a different directive.
+The region is restored from the markers captured at the last call;
+the user picks a new directive from the remaining choices."
+ (interactive)
+ (unless cj/gptel-rewrite--last-region
+ (user-error "No previous rewrite to redo in this buffer"))
+ (let* ((beg-mk (car cj/gptel-rewrite--last-region))
+ (end-mk (cdr cj/gptel-rewrite--last-region))
+ (current cj/gptel-rewrite--last-directive)
+ (others (cl-remove
+ current
+ (mapcar #'car cj/gptel-rewrite-directives)
+ :test #'equal))
+ (chosen (completing-read
+ (format "Re-rewrite with (was %s): " current)
+ others nil t)))
+ (cj/gptel-rewrite--call-with-directive
+ chosen (marker-position beg-mk) (marker-position end-mk))))
+
+(provide 'ai-rewrite)
+;;; ai-rewrite.el ends here
diff --git a/archive/gptel/tests/test-ai-config--apply-model-selection.el b/archive/gptel/tests/test-ai-config--apply-model-selection.el
new file mode 100644
index 000000000..4ccd6d7a0
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config--apply-model-selection.el
@@ -0,0 +1,45 @@
+;;; test-ai-config--apply-model-selection.el --- Tests for cj/--gptel-apply-model-selection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; cj/--gptel-apply-model-selection is the apply step extracted from the
+;; interactive cj/gptel-change-model: it sets gptel-backend/gptel-model globally
+;; or buffer-locally and returns the confirmation message. The extraction also
+;; dropped a dead `(if (stringp model) ...)' branch (model is always a symbol by
+;; that point).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-config)
+
+(defvar gptel-backend)
+(defvar gptel-model)
+
+(ert-deftest test-ai-config-apply-model-global-sets-globals ()
+ "Normal: global scope assigns the global vars and reports (global)."
+ (let ((gptel-backend nil) (gptel-model nil))
+ (let ((msg (cj/--gptel-apply-model-selection "global" 'mybackend 'mymodel "MyAI")))
+ (should (eq gptel-backend 'mybackend))
+ (should (eq gptel-model 'mymodel))
+ (should (string-match-p "MyAI" msg))
+ (should (string-match-p "mymodel" msg))
+ (should (string-match-p "global" msg)))))
+
+(ert-deftest test-ai-config-apply-model-buffer-sets-buffer-locals ()
+ "Normal: buffer scope makes the vars buffer-local and reports (buffer-local)."
+ (let ((gptel-backend 'orig) (gptel-model 'origm))
+ (with-temp-buffer
+ (let ((msg (cj/--gptel-apply-model-selection "buffer" 'be 'mo "Name")))
+ (should (local-variable-p 'gptel-backend))
+ (should (local-variable-p 'gptel-model))
+ (should (eq gptel-backend 'be))
+ (should (eq gptel-model 'mo))
+ (should (string-match-p "buffer-local" msg))))
+ ;; outside the temp buffer the globals are untouched
+ (should (eq gptel-backend 'orig))
+ (should (eq gptel-model 'origm))))
+
+(provide 'test-ai-config--apply-model-selection)
+;;; test-ai-config--apply-model-selection.el ends here
diff --git a/archive/gptel/tests/test-ai-config-auth-source-secret.el b/archive/gptel/tests/test-ai-config-auth-source-secret.el
new file mode 100644
index 000000000..bab506e5f
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-auth-source-secret.el
@@ -0,0 +1,27 @@
+;;; test-ai-config-auth-source-secret.el --- Tests for the required-secret wrapper -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/auth-source-secret' is the required-secret layer over the shared
+;; `cj/auth-source-secret-value' primitive: it returns the secret, or errors
+;; when none is found. These tests stub the primitive to exercise both paths.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-config)
+
+(ert-deftest test-ai-config-auth-source-secret-returns-value ()
+ "Normal: returns the value the primitive resolves."
+ (cl-letf (((symbol-function 'cj/auth-source-secret-value) (lambda (&rest _) "sk-x")))
+ (should (equal "sk-x" (cj/auth-source-secret "api.example.com" "apikey")))))
+
+(ert-deftest test-ai-config-auth-source-secret-errors-on-miss ()
+ "Error: signals when the primitive finds no secret."
+ (cl-letf (((symbol-function 'cj/auth-source-secret-value) (lambda (&rest _) nil)))
+ (should-error (cj/auth-source-secret "api.example.com" "apikey"))))
+
+(provide 'test-ai-config-auth-source-secret)
+;;; test-ai-config-auth-source-secret.el ends here
diff --git a/archive/gptel/tests/test-ai-config-backend-and-model.el b/archive/gptel/tests/test-ai-config-backend-and-model.el
new file mode 100644
index 000000000..c03c58a2d
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-backend-and-model.el
@@ -0,0 +1,78 @@
+;;; test-ai-config-backend-and-model.el --- Tests for cj/gptel-backend-and-model -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/gptel-backend-and-model from ai-config.el.
+;;
+;; Returns a formatted string "backend: model [timestamp]" for use in
+;; org headings marking AI responses. Uses pcase to extract the display
+;; name from vector backends, falling back to "AI" otherwise.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-ai-config)
+(require 'ai-config)
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-backend-and-model-normal-vector-backend-extracts-name ()
+ "Vector backend should use element at index 1 as display name."
+ (let ((gptel-backend (vector 'cl-struct "Claude"))
+ (gptel-model "claude-opus-4-6"))
+ (let ((result (cj/gptel-backend-and-model)))
+ (should (string-match-p "^Claude:" result))
+ (should (string-match-p "claude-opus-4-6" result)))))
+
+(ert-deftest test-ai-config-backend-and-model-normal-contains-timestamp ()
+ "Result should contain a bracketed timestamp."
+ (let ((gptel-backend nil)
+ (gptel-model nil))
+ (should (string-match-p "\\[[-0-9]+ [0-9]+:[0-9]+:[0-9]+\\]"
+ (cj/gptel-backend-and-model)))))
+
+(ert-deftest test-ai-config-backend-and-model-normal-format-structure ()
+ "Result should follow 'backend: model [timestamp]' format."
+ (let ((gptel-backend (vector 'cl-struct "TestBackend"))
+ (gptel-model "test-model"))
+ (should (string-match-p "^TestBackend: test-model \\["
+ (cj/gptel-backend-and-model)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-backend-and-model-boundary-nil-backend-shows-ai ()
+ "Nil backend should fall back to \"AI\" display name."
+ (let ((gptel-backend nil)
+ (gptel-model "some-model"))
+ (should (string-match-p "^AI:" (cj/gptel-backend-and-model)))))
+
+(ert-deftest test-ai-config-backend-and-model-boundary-nil-model-shows-empty ()
+ "Nil model should produce empty string in model position."
+ (let ((gptel-backend nil)
+ (gptel-model nil))
+ (should (string-match-p "^AI: \\[" (cj/gptel-backend-and-model)))))
+
+(ert-deftest test-ai-config-backend-and-model-boundary-string-backend-shows-ai ()
+ "String backend (not vector) should fall back to \"AI\"."
+ (let ((gptel-backend "just-a-string")
+ (gptel-model "model"))
+ (should (string-match-p "^AI:" (cj/gptel-backend-and-model)))))
+
+(ert-deftest test-ai-config-backend-and-model-boundary-symbol-model-formatted ()
+ "Symbol model should be formatted as its print representation."
+ (let ((gptel-backend nil)
+ (gptel-model 'some-model))
+ (should (string-match-p "some-model" (cj/gptel-backend-and-model)))))
+
+(ert-deftest test-ai-config-backend-and-model-boundary-timestamp-reflects-today ()
+ "Timestamp should contain today's date."
+ (let ((gptel-backend nil)
+ (gptel-model nil)
+ (today (format-time-string "%Y-%m-%d")))
+ (should (string-match-p (regexp-quote today)
+ (cj/gptel-backend-and-model)))))
+
+(provide 'test-ai-config-backend-and-model)
+;;; test-ai-config-backend-and-model.el ends here
diff --git a/archive/gptel/tests/test-ai-config-build-model-list.el b/archive/gptel/tests/test-ai-config-build-model-list.el
new file mode 100644
index 000000000..827036038
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-build-model-list.el
@@ -0,0 +1,101 @@
+;;; test-ai-config-build-model-list.el --- Tests for cj/gptel--build-model-list -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/gptel--build-model-list from ai-config.el.
+;;
+;; Pure function that takes a backends alist and a model-fetching function,
+;; and produces a flat list of (DISPLAY-STRING BACKEND MODEL-STRING BACKEND-NAME)
+;; entries suitable for completing-read. Exercises the mapping and string
+;; formatting logic that was previously embedded in cj/gptel-change-model.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-ai-config)
+(require 'ai-config)
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-build-model-list-normal-single-backend-single-model ()
+ "One backend with one model should produce one entry."
+ (let* ((backend-obj 'fake-backend)
+ (backends `(("Claude" . ,backend-obj)))
+ (result (cj/gptel--build-model-list backends (lambda (_) '("opus")))))
+ (should (= 1 (length result)))
+ (should (equal (car (nth 0 result)) "Claude: opus"))
+ (should (eq (nth 1 (nth 0 result)) backend-obj))
+ (should (equal (nth 2 (nth 0 result)) "opus"))
+ (should (equal (nth 3 (nth 0 result)) "Claude"))))
+
+(ert-deftest test-ai-config-build-model-list-normal-single-backend-multiple-models ()
+ "One backend with multiple models should produce one entry per model."
+ (let* ((backends '(("Claude" . backend-a)))
+ (result (cj/gptel--build-model-list
+ backends (lambda (_) '("opus" "sonnet" "haiku")))))
+ (should (= 3 (length result)))
+ (should (equal (mapcar #'car result)
+ '("Claude: opus" "Claude: sonnet" "Claude: haiku")))))
+
+(ert-deftest test-ai-config-build-model-list-normal-multiple-backends ()
+ "Multiple backends should interleave their models in backend order."
+ (let* ((backends '(("Claude" . backend-a) ("OpenAI" . backend-b)))
+ (result (cj/gptel--build-model-list
+ backends
+ (lambda (b)
+ (if (eq b 'backend-a) '("opus") '("gpt-4o"))))))
+ (should (= 2 (length result)))
+ (should (equal (car (nth 0 result)) "Claude: opus"))
+ (should (equal (car (nth 1 result)) "OpenAI: gpt-4o"))))
+
+(ert-deftest test-ai-config-build-model-list-normal-preserves-backend-object ()
+ "Each entry should carry the original backend object for later use."
+ (let* ((obj (vector 'struct "Claude"))
+ (backends `(("Claude" . ,obj)))
+ (result (cj/gptel--build-model-list backends (lambda (_) '("opus")))))
+ (should (eq (nth 1 (nth 0 result)) obj))))
+
+(ert-deftest test-ai-config-build-model-list-normal-symbol-models-converted ()
+ "Symbol model identifiers should be converted to strings via model-to-string."
+ (let* ((backends '(("Claude" . backend-a)))
+ (result (cj/gptel--build-model-list
+ backends (lambda (_) '(opus sonnet)))))
+ (should (equal (nth 2 (nth 0 result)) "opus"))
+ (should (equal (nth 2 (nth 1 result)) "sonnet"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-build-model-list-boundary-empty-backends ()
+ "Empty backends list should produce empty result."
+ (should (null (cj/gptel--build-model-list nil (lambda (_) '("x"))))))
+
+(ert-deftest test-ai-config-build-model-list-boundary-backend-with-no-models ()
+ "Backend returning no models should contribute no entries."
+ (let* ((backends '(("Claude" . backend-a)))
+ (result (cj/gptel--build-model-list backends (lambda (_) nil))))
+ (should (null result))))
+
+(ert-deftest test-ai-config-build-model-list-boundary-mixed-empty-and-populated ()
+ "Only backends with models should produce entries."
+ (let* ((backends '(("Claude" . backend-a) ("Empty" . backend-b) ("OpenAI" . backend-c)))
+ (result (cj/gptel--build-model-list
+ backends
+ (lambda (b)
+ (cond ((eq b 'backend-a) '("opus"))
+ ((eq b 'backend-b) nil)
+ ((eq b 'backend-c) '("gpt-4o")))))))
+ (should (= 2 (length result)))
+ (should (equal (nth 3 (nth 0 result)) "Claude"))
+ (should (equal (nth 3 (nth 1 result)) "OpenAI"))))
+
+(ert-deftest test-ai-config-build-model-list-boundary-model-with-special-characters ()
+ "Model names with special characters should be preserved in display string."
+ (let* ((backends '(("Claude" . backend-a)))
+ (result (cj/gptel--build-model-list
+ backends (lambda (_) '("claude-haiku-4-5-20251001")))))
+ (should (equal (car (nth 0 result)) "Claude: claude-haiku-4-5-20251001"))))
+
+(provide 'test-ai-config-build-model-list)
+;;; test-ai-config-build-model-list.el ends here
diff --git a/archive/gptel/tests/test-ai-config-commands.el b/archive/gptel/tests/test-ai-config-commands.el
new file mode 100644
index 000000000..fed06d82b
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-commands.el
@@ -0,0 +1,160 @@
+;;; test-ai-config-commands.el --- Tests for ai-config interactive commands -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Sibling tests cover the pure helpers (model-to-string, build-model-list,
+;; current-model-selection, fresh-org-prefix, backend-and-model). This
+;; file covers the user-facing wrappers:
+;;
+;; cj/gptel--available-backends
+;; cj/gptel-change-model
+;; cj/gptel-add-file
+;; cj/gptel-add-this-buffer
+;; cj/toggle-gptel
+;; cj/gptel-context-clear
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-config)
+
+;; Top-level defvars so let-bindings reach the dynamic binding under
+;; lexical scope.
+(defvar gptel-backend nil)
+(defvar gptel-model nil)
+(defvar gptel-claude-backend nil)
+(defvar gptel-chatgpt-backend nil)
+(defvar gptel-context--alist nil)
+
+;;; cj/gptel--available-backends
+
+(ert-deftest test-ai-available-backends-returns-claude-and-chatgpt ()
+ "Normal: both backends present become alist entries."
+ (let ((gptel-claude-backend 'claude-obj)
+ (gptel-chatgpt-backend 'chatgpt-obj))
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) t))
+ ((symbol-function 'cj/ensure-gptel-backends) #'ignore))
+ (let ((result (cj/gptel--available-backends)))
+ (should (equal (assoc "Anthropic - Claude" result)
+ '("Anthropic - Claude" . claude-obj)))
+ (should (equal (assoc "OpenAI - ChatGPT" result)
+ '("OpenAI - ChatGPT" . chatgpt-obj)))))))
+
+(ert-deftest test-ai-available-backends-skips-nil-entries ()
+ "Boundary: only configured backends appear in the alist."
+ (let ((gptel-claude-backend nil)
+ (gptel-chatgpt-backend 'chatgpt-only))
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) t))
+ ((symbol-function 'cj/ensure-gptel-backends) #'ignore))
+ (let ((result (cj/gptel--available-backends)))
+ (should-not (assoc "Anthropic - Claude" result))
+ (should (assoc "OpenAI - ChatGPT" result))))))
+
+;;; cj/gptel-change-model
+
+(ert-deftest test-ai-change-model-global-sets-globals-and-messages ()
+ "Normal: choosing 'global' sets `gptel-backend' and `gptel-model'
+globally and reports via `message'."
+ (let ((gptel-backend 'old-backend)
+ (gptel-model 'old-model)
+ (gptel-claude-backend 'claude-obj)
+ (gptel-chatgpt-backend nil)
+ msg)
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) t))
+ ((symbol-function 'cj/ensure-gptel-backends) #'ignore)
+ ((symbol-function 'gptel-backend-models)
+ (lambda (_) '("claude-opus-4-7")))
+ ((symbol-function 'completing-read)
+ (lambda (prompt &rest _)
+ (if (string-prefix-p "Set model for" prompt)
+ "global"
+ "Anthropic - Claude: claude-opus-4-7")))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-change-model))
+ (should (eq gptel-backend 'claude-obj))
+ (should (eq gptel-model 'claude-opus-4-7))
+ (should (string-match-p "global" msg))))
+
+;;; cj/gptel-add-file
+
+(ert-deftest test-ai-add-file-outside-projectile-uses-read-file-name ()
+ "Normal: without projectile, add-file routes through read-file-name."
+ (let* ((target (make-temp-file "cj-ai-add-file-" nil ".org"))
+ added)
+ (unwind-protect
+ (cl-letf (((symbol-function 'featurep)
+ (lambda (sym &rest _) (not (eq sym 'projectile))))
+ ((symbol-function 'read-file-name)
+ (lambda (&rest _) target))
+ ((symbol-function 'gptel-add-file)
+ (lambda (f) (setq added f)))
+ ((symbol-function 'message) #'ignore))
+ (cj/gptel-add-file))
+ (delete-file target))
+ (should (equal added target))))
+
+;;; cj/gptel-add-this-buffer
+
+(ert-deftest test-ai-add-this-buffer-calls-gptel-add-with-prefix ()
+ "Normal: add-this-buffer calls `gptel-add' with the prefix-arg form."
+ (let (gptel-add-args msg)
+ (cl-letf (((symbol-function 'require) (lambda (&rest _) t))
+ ((symbol-function 'gptel-add)
+ (lambda (&rest args) (setq gptel-add-args args)))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-add-this-buffer))
+ (should (equal gptel-add-args '((4))))
+ (should (string-match-p "to GPTel context" msg))))
+
+;;; cj/toggle-gptel
+
+(ert-deftest test-ai-toggle-gptel-hides-when-visible ()
+ "Normal: when the AI buffer is showing in a window, toggle hides it."
+ (let ((buffer (get-buffer-create "*AI-Assistant*"))
+ deleted-window)
+ (unwind-protect
+ (cl-letf (((symbol-function 'get-buffer-window)
+ (lambda (&rest _) 'fake-window))
+ ((symbol-function 'delete-window)
+ (lambda (w) (setq deleted-window w))))
+ (cj/toggle-gptel))
+ (kill-buffer buffer))
+ (should (eq deleted-window 'fake-window))))
+
+;;; cj/gptel-context-clear
+
+(ert-deftest test-ai-context-clear-uses-remove-all-when-available ()
+ "Normal: with `gptel-context-remove-all' present, it is called."
+ (let (called msg)
+ (cl-letf (((symbol-function 'gptel-context-remove-all)
+ (lambda () (setq called t)))
+ ((symbol-function 'call-interactively)
+ (lambda (fn &rest _) (funcall fn)))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-context-clear))
+ (should called)
+ (should (string-match-p "cleared" msg))))
+
+(ert-deftest test-ai-context-clear-resets-alist-as-fallback ()
+ "Boundary: when no clear function exists but the alist does, it gets
+nilled directly."
+ (let ((gptel-context--alist '("item1" "item2"))
+ msg)
+ ;; Make sure the fboundp branches are skipped.
+ (cl-letf (((symbol-function 'fboundp)
+ (lambda (sym)
+ (not (memq sym '(gptel-context-remove-all
+ gptel-context-clear)))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-context-clear))
+ (should-not gptel-context--alist)
+ (should (string-match-p "cleared" msg))))
+
+(provide 'test-ai-config-commands)
+;;; test-ai-config-commands.el ends here
diff --git a/archive/gptel/tests/test-ai-config-current-model-selection.el b/archive/gptel/tests/test-ai-config-current-model-selection.el
new file mode 100644
index 000000000..14f9391c8
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-current-model-selection.el
@@ -0,0 +1,74 @@
+;;; test-ai-config-current-model-selection.el --- Tests for cj/gptel--current-model-selection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/gptel--current-model-selection from ai-config.el.
+;;
+;; Pure function that formats the active backend and model into a display
+;; string like "Anthropic - Claude: claude-opus-4-6". Used as the default
+;; selection in the model-switching completing-read prompt.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-ai-config)
+(require 'ai-config)
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-current-model-selection-normal-matching-backend ()
+ "When current backend is in the backends alist, use its display name."
+ (let* ((backend-obj 'my-backend)
+ (backends `(("Anthropic - Claude" . ,backend-obj))))
+ (should (equal (cj/gptel--current-model-selection backends backend-obj "opus")
+ "Anthropic - Claude: opus"))))
+
+(ert-deftest test-ai-config-current-model-selection-normal-symbol-model ()
+ "Symbol model should be converted to string in the output."
+ (let* ((backend-obj 'my-backend)
+ (backends `(("Claude" . ,backend-obj))))
+ (should (equal (cj/gptel--current-model-selection backends backend-obj 'opus)
+ "Claude: opus"))))
+
+(ert-deftest test-ai-config-current-model-selection-normal-multiple-backends ()
+ "Should find the correct backend name among multiple backends."
+ (let* ((backend-a 'backend-a)
+ (backend-b 'backend-b)
+ (backends `(("Claude" . ,backend-a) ("OpenAI" . ,backend-b))))
+ (should (equal (cj/gptel--current-model-selection backends backend-b "gpt-4o")
+ "OpenAI: gpt-4o"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-current-model-selection-boundary-nil-backend-shows-ai ()
+ "Nil backend (not in alist) should fall back to \"AI\"."
+ (should (equal (cj/gptel--current-model-selection '(("Claude" . x)) nil "opus")
+ "AI: opus")))
+
+(ert-deftest test-ai-config-current-model-selection-boundary-unknown-backend-shows-ai ()
+ "Backend not found in alist should fall back to \"AI\"."
+ (should (equal (cj/gptel--current-model-selection
+ '(("Claude" . backend-a)) 'unknown-backend "opus")
+ "AI: opus")))
+
+(ert-deftest test-ai-config-current-model-selection-boundary-nil-model ()
+ "Nil model should produce \"nil\" in the model position (symbolp nil)."
+ (let* ((backend 'my-backend)
+ (backends `(("Claude" . ,backend))))
+ (should (equal (cj/gptel--current-model-selection backends backend nil)
+ "Claude: nil"))))
+
+(ert-deftest test-ai-config-current-model-selection-boundary-empty-backends ()
+ "Empty backends alist should fall back to \"AI\" for backend name."
+ (should (equal (cj/gptel--current-model-selection nil 'anything "model")
+ "AI: model")))
+
+(ert-deftest test-ai-config-current-model-selection-boundary-both-nil ()
+ "Nil backend and nil model should produce \"AI: nil\"."
+ (should (equal (cj/gptel--current-model-selection nil nil nil)
+ "AI: nil")))
+
+(provide 'test-ai-config-current-model-selection)
+;;; test-ai-config-current-model-selection.el ends here
diff --git a/archive/gptel/tests/test-ai-config-fresh-org-prefix.el b/archive/gptel/tests/test-ai-config-fresh-org-prefix.el
new file mode 100644
index 000000000..16a3211cf
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-fresh-org-prefix.el
@@ -0,0 +1,65 @@
+;;; test-ai-config-fresh-org-prefix.el --- Tests for cj/gptel--fresh-org-prefix -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/gptel--fresh-org-prefix from ai-config.el.
+;;
+;; Generates an org-mode level-1 heading containing the user's login
+;; name and a bracketed timestamp, used as the user message prefix in
+;; gptel org-mode conversations.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-ai-config)
+(require 'ai-config)
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-fresh-org-prefix-normal-starts-with-org-heading ()
+ "Result should start with '* ' for an org level-1 heading."
+ (should (string-prefix-p "* " (cj/gptel--fresh-org-prefix))))
+
+(ert-deftest test-ai-config-fresh-org-prefix-normal-contains-username ()
+ "Result should contain the current user's login name."
+ (should (string-match-p (regexp-quote user-login-name)
+ (cj/gptel--fresh-org-prefix))))
+
+(ert-deftest test-ai-config-fresh-org-prefix-normal-contains-timestamp ()
+ "Result should contain a bracketed timestamp in YYYY-MM-DD HH:MM:SS format."
+ (should (string-match-p "\\[[-0-9]+ [0-9]+:[0-9]+:[0-9]+\\]"
+ (cj/gptel--fresh-org-prefix))))
+
+(ert-deftest test-ai-config-fresh-org-prefix-normal-ends-with-newline ()
+ "Result should end with a newline."
+ (should (string-suffix-p "\n" (cj/gptel--fresh-org-prefix))))
+
+(ert-deftest test-ai-config-fresh-org-prefix-normal-format-order ()
+ "Result should have star, then username, then timestamp in order."
+ (let ((result (cj/gptel--fresh-org-prefix)))
+ (should (string-match
+ (format "^\\* %s \\[" (regexp-quote user-login-name))
+ result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-fresh-org-prefix-boundary-timestamp-reflects-today ()
+ "Timestamp should contain today's date."
+ (let ((today (format-time-string "%Y-%m-%d")))
+ (should (string-match-p (regexp-quote today)
+ (cj/gptel--fresh-org-prefix)))))
+
+(ert-deftest test-ai-config-fresh-org-prefix-boundary-overridden-username ()
+ "Result should reflect a dynamically-bound user-login-name."
+ (let ((user-login-name "testuser"))
+ (should (string-match-p "testuser" (cj/gptel--fresh-org-prefix)))))
+
+(ert-deftest test-ai-config-fresh-org-prefix-boundary-empty-username ()
+ "Empty user-login-name should produce heading with empty name slot."
+ (let ((user-login-name ""))
+ (should (string-match-p "^\\* \\[" (cj/gptel--fresh-org-prefix)))))
+
+(provide 'test-ai-config-fresh-org-prefix)
+;;; test-ai-config-fresh-org-prefix.el ends here
diff --git a/archive/gptel/tests/test-ai-config-gptel-backend-libs.el b/archive/gptel/tests/test-ai-config-gptel-backend-libs.el
new file mode 100644
index 000000000..cbf48f444
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-gptel-backend-libs.el
@@ -0,0 +1,58 @@
+;;; test-ai-config-gptel-backend-libs.el --- Tests for gptel backend-lib loading -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Regression coverage for the "gptel-make-anthropic void" bug. The local
+;; gptel fork (:load-path "~/code/gptel", :ensure nil) ships no generated
+;; autoloads, so (require 'gptel) alone never loads gptel-anthropic /
+;; gptel-openai where the gptel-make-* constructors live. The fix is to
+;; require those backend libraries explicitly before constructing backends.
+;;
+;; These tests don't load gptel itself (it isn't reliably loadable in batch);
+;; they stub `require' and the constructors to verify the loader requires both
+;; libs and that `cj/ensure-gptel-backends' calls it before building backends.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-config)
+
+;; gptel defvars these at runtime; declare them here so the wiring test can
+;; let-bind them in a batch session where gptel itself is not loaded.
+(defvar gptel-backend)
+(defvar gptel-model)
+
+(ert-deftest test-ai-config-gptel-load-backend-libs-requires-both ()
+ "Normal: the loader requires gptel-anthropic and gptel-openai so the fork's
+make-* constructors exist despite the missing autoloads."
+ (let ((required '()))
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &rest _) (push feature required) feature)))
+ (cj/--gptel-load-backend-libs))
+ (should (memq 'gptel-anthropic required))
+ (should (memq 'gptel-openai required))))
+
+(ert-deftest test-ai-config-ensure-gptel-backends-loads-libs-first ()
+ "Regression: `cj/ensure-gptel-backends' loads the backend libs before it
+calls the constructors, so a fork without autoloads no longer signals
+`void-function gptel-make-anthropic'."
+ (let ((loaded nil)
+ (gptel-claude-backend nil)
+ (gptel-chatgpt-backend nil)
+ (gptel-backend nil)
+ (gptel-model nil))
+ (cl-letf (((symbol-function 'cj/--gptel-load-backend-libs)
+ (lambda () (setq loaded t)))
+ ((symbol-function 'gptel-make-anthropic) (lambda (&rest _) 'claude))
+ ((symbol-function 'gptel-make-openai) (lambda (&rest _) 'chatgpt))
+ ((symbol-function 'cj/anthropic-api-key) (lambda () "k"))
+ ((symbol-function 'cj/openai-api-key) (lambda () "k")))
+ (cj/ensure-gptel-backends))
+ (should loaded)
+ (should (eq gptel-claude-backend 'claude))
+ (should (eq gptel-chatgpt-backend 'chatgpt))))
+
+(provide 'test-ai-config-gptel-backend-libs)
+;;; test-ai-config-gptel-backend-libs.el ends here
diff --git a/archive/gptel/tests/test-ai-config-gptel-commands.el b/archive/gptel/tests/test-ai-config-gptel-commands.el
new file mode 100644
index 000000000..cab23572e
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-gptel-commands.el
@@ -0,0 +1,155 @@
+;;; test-ai-config-gptel-commands.el --- Tests for ai-config gptel command wrappers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Second pass on ai-config. The first batch covered the helpers
+;; (auth-source, api-key caching, add-file, clear-buffer, context-
+;; clear, insert-model-heading). This file covers the gptel command
+;; wrappers and a few small pure helpers:
+;;
+;; cj/gptel--refresh-org-prefix
+;; cj/gptel-backend-and-model
+;; cj/gptel-switch-backend
+;; cj/gptel-add-buffer-file
+;; cj/gptel-add-this-buffer
+;; cj/toggle-gptel
+;;
+;; The gptel/projectile primitives are stubbed throughout.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-config)
+
+;; Dynamic vars gptel would normally own.
+(defvar gptel-backend nil)
+(defvar gptel-model nil)
+(defvar gptel-prompt-prefix-alist nil)
+
+;;; cj/gptel--refresh-org-prefix
+
+(ert-deftest test-ai-config-refresh-org-prefix-updates-alist-entry ()
+ "Normal: the advice refreshes the org-mode entry in the prefix alist."
+ (let ((gptel-prompt-prefix-alist '((org-mode . "stale\n"))))
+ (cj/gptel--refresh-org-prefix)
+ (let ((entry (alist-get 'org-mode gptel-prompt-prefix-alist)))
+ (should (stringp entry))
+ ;; Fresh prefix includes the user-login-name + a timestamp bracket.
+ (should (string-match-p "\\[" entry)))))
+
+;;; cj/gptel-backend-and-model
+
+(ert-deftest test-ai-config-backend-and-model-formats-with-vector-backend ()
+ "Normal: a vector backend's name element comes through formatted."
+ (let ((gptel-backend [unused-slot "Claude" other])
+ (gptel-model 'claude-opus-4-6))
+ (let ((s (cj/gptel-backend-and-model)))
+ (should (string-match-p "Claude" s))
+ (should (string-match-p "claude-opus-4-6" s)))))
+
+(ert-deftest test-ai-config-backend-and-model-falls-back-to-ai-when-no-backend ()
+ "Boundary: with no backend bound, the string starts with the AI fallback."
+ (let ((gptel-backend nil)
+ (gptel-model nil))
+ (should (string-prefix-p "AI:" (cj/gptel-backend-and-model)))))
+
+;;; cj/gptel-switch-backend
+
+(ert-deftest test-ai-config-switch-backend-sets-backend-and-model ()
+ "Normal: switch picks a backend + model, then updates the gptel vars."
+ (let ((gptel-backend nil)
+ (gptel-model nil)
+ (msg nil))
+ (cl-letf (((symbol-function 'cj/gptel--available-backends)
+ (lambda ()
+ '(("Anthropic - Claude" . anthropic-backend))))
+ ((symbol-function 'gptel-backend-models)
+ (lambda (_b) '(claude-opus claude-sonnet)))
+ ((symbol-function 'completing-read)
+ (lambda (prompt collection &rest _)
+ ;; First call -> backend choice; second -> model.
+ (cond
+ ((string-match-p "backend" prompt) "Anthropic - Claude")
+ (t "claude-opus"))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-switch-backend))
+ (should (eq gptel-backend 'anthropic-backend))
+ ;; gptel-model must be a symbol, not the raw completing-read string:
+ ;; gptel's modeline calls `symbolp' on it and hangs redisplay otherwise.
+ (should (symbolp gptel-model))
+ (should (eq gptel-model 'claude-opus))
+ (should (string-match-p "Anthropic - Claude" msg))))
+
+(ert-deftest test-ai-config-switch-backend-error-invalid-choice ()
+ "Error: an unrecognized backend name signals user-error."
+ (cl-letf (((symbol-function 'cj/gptel--available-backends)
+ (lambda () '(("Anthropic - Claude" . backend-a))))
+ ((symbol-function 'completing-read)
+ (lambda (&rest _) "Something Else")))
+ (should-error (cj/gptel-switch-backend) :type 'user-error)))
+
+;;; cj/gptel-add-buffer-file
+
+(ert-deftest test-ai-config-add-buffer-file-adds-when-buffer-has-file ()
+ "Normal: a buffer that visits a file -> the file is added to context."
+ (let ((added nil))
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/sample.org")
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) (buffer-name)))
+ ((symbol-function 'cj/gptel--add-file-to-context)
+ (lambda (f) (setq added f) t))
+ ((symbol-function 'message) #'ignore))
+ (cj/gptel-add-buffer-file))
+ (setq buffer-file-name nil))
+ (should (equal added "/tmp/sample.org"))))
+
+(ert-deftest test-ai-config-add-buffer-file-messages-when-no-file ()
+ "Boundary: a buffer not visiting a file -> message, no add call."
+ (let ((added nil)
+ (msg nil))
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) (buffer-name)))
+ ((symbol-function 'cj/gptel--add-file-to-context)
+ (lambda (f) (setq added f) t))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (cj/gptel-add-buffer-file)))
+ (should-not added)
+ (should (string-match-p "not visiting" msg))))
+
+;;; cj/gptel-add-this-buffer
+
+(ert-deftest test-ai-config-add-this-buffer-calls-gptel-add-with-prefix ()
+ "Normal: `cj/gptel-add-this-buffer' calls `gptel-add' with the (4) prefix arg."
+ (let ((arg nil))
+ (cl-letf (((symbol-function 'featurep) (lambda (_ &rest _) t))
+ ((symbol-function 'gptel-add)
+ (lambda (a) (setq arg a)))
+ ((symbol-function 'message) #'ignore))
+ (with-temp-buffer
+ (cj/gptel-add-this-buffer)))
+ (should (equal arg '(4)))))
+
+;;; cj/toggle-gptel
+
+(ert-deftest test-ai-config-toggle-gptel-closes-when-window-shown ()
+ "Normal: with a window already displaying *AI-Assistant*, toggle deletes it."
+ (let* ((buf (generate-new-buffer "*AI-Assistant*"))
+ (deleted nil))
+ (unwind-protect
+ (cl-letf (((symbol-function 'get-buffer-window)
+ (lambda (_b &rest _) 'fake-window))
+ ((symbol-function 'delete-window)
+ (lambda (w) (setq deleted w))))
+ (cj/toggle-gptel))
+ (when (buffer-live-p buf) (kill-buffer buf)))
+ (should (eq deleted 'fake-window))))
+
+(provide 'test-ai-config-gptel-commands)
+;;; test-ai-config-gptel-commands.el ends here
diff --git a/archive/gptel/tests/test-ai-config-gptel-local-tools.el b/archive/gptel/tests/test-ai-config-gptel-local-tools.el
new file mode 100644
index 000000000..8d3a45ac4
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-gptel-local-tools.el
@@ -0,0 +1,57 @@
+;;; test-ai-config-gptel-local-tools.el --- Tests for local GPTel tool loading -*- lexical-binding: t; -*-
+
+;;; Commentary:
+
+;; Tests for optional local GPTel tool loading from ai-config.el.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(setq load-prefer-newer t)
+(require 'testutil-ai-config)
+(require 'ai-config)
+
+(defun test-ai-config-gptel-local-tools--write-tool (dir feature)
+ "Write a temporary tool module named FEATURE into DIR."
+ (let ((file (expand-file-name (format "%s.el" feature) dir)))
+ (write-region
+ (format ";;; %s.el --- test tool -*- lexical-binding: t; -*-\n(provide '%s)\n"
+ feature feature)
+ nil
+ file
+ nil
+ 'silent)))
+
+(ert-deftest test-ai-config-gptel-local-tools-missing-directory-is-non-fatal ()
+ "Missing optional tool directory should not signal or load anything."
+ (let ((dir (expand-file-name "missing-gptel-tools/"
+ (make-temp-file "gptel-tools-home-" t))))
+ (should-not (cj/gptel-load-local-tools dir '(test_missing_tool)))))
+
+(ert-deftest test-ai-config-gptel-local-tools-loads-present-tools ()
+ "Present tool modules should be loaded and returned in request order."
+ (let ((dir (make-temp-file "gptel-tools-" t))
+ (features '(test_gptel_tool_one test_gptel_tool_two)))
+ (dolist (feature features)
+ (test-ai-config-gptel-local-tools--write-tool dir feature))
+ (should (equal (cj/gptel-load-local-tools dir features)
+ features))
+ (dolist (feature features)
+ (should (featurep feature)))))
+
+(ert-deftest test-ai-config-gptel-local-tools-skips-missing-tool-files ()
+ "Missing optional tool files should not prevent present tools from loading."
+ (let ((dir (make-temp-file "gptel-tools-" t))
+ (present 'test_gptel_present_tool)
+ (missing 'test_gptel_missing_tool))
+ (test-ai-config-gptel-local-tools--write-tool dir present)
+ (should (equal (cj/gptel-load-local-tools dir (list present missing))
+ (list present)))
+ (should (featurep present))
+ (should-not (featurep missing))))
+
+(provide 'test-ai-config-gptel-local-tools)
+;;; test-ai-config-gptel-local-tools.el ends here
diff --git a/archive/gptel/tests/test-ai-config-gptel-magit-lazy-loading.el b/archive/gptel/tests/test-ai-config-gptel-magit-lazy-loading.el
new file mode 100644
index 000000000..6eac0d193
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-gptel-magit-lazy-loading.el
@@ -0,0 +1,151 @@
+;;; test-ai-config-gptel-magit-lazy-loading.el --- Tests for gptel-magit lazy loading -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the per-feature lazy gptel-magit integration in ai-config.el.
+;;
+;; ai-config.el uses three separate `with-eval-after-load' blocks --
+;; one per actual dependency -- to wire up its bindings:
+;; git-commit -> M-g in `git-commit-mode-map'
+;; magit-commit -> "g" suffix in the `magit-commit' transient
+;; magit-diff -> "x" suffix in the `magit-diff' transient
+;;
+;; This shape matters: `magit.el' calls `(provide 'magit)' before its
+;; `cl-eval-when (load eval) ...' block requires `magit-commit' and
+;; `magit-stash', so a single `with-eval-after-load 'magit' would fire
+;; while the transient prefixes the wiring references are still
+;; undefined. `transient-append-suffix' silently no-ops on missing
+;; prefixes, which is how that bug stayed invisible.
+;;
+;; Testing approach. In Emacs 30, `provide' does NOT fire registered
+;; `eval-after-load' callbacks in batch mode -- only an actual `load'
+;; does. Rather than work around that with disk-backed stub files, the
+;; tests inspect `after-load-alist' directly to verify which features
+;; the wiring is gated on. That's stronger evidence than running the
+;; callbacks anyway: the regression we're guarding against is "wiring
+;; hooked on `magit'," and the right shape of that check is "no entry
+;; for `magit', entries for `git-commit', `magit-commit', `magit-diff'."
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Load gptel stubs. This does NOT provide any of the magit features,
+;; so the eval-after-load blocks in ai-config stay dormant.
+(require 'testutil-ai-config)
+
+;; Stub the keymap used by the M-g binding.
+(defvar git-commit-mode-map (make-sparse-keymap)
+ "Stub keymap standing in for magit's git-commit-mode-map.")
+
+;; Stub transient-append-suffix as a recorder. We don't invoke it
+;; through provide in this test file, but the symbol must be fbound so
+;; ai-config.el byte-compiles cleanly through `(require 'ai-config)'.
+(unless (fboundp 'transient-append-suffix)
+ (defun transient-append-suffix (&rest _) nil))
+
+(require 'ai-config)
+
+;; ----------------------------- Regression check ------------------------------
+
+(ert-deftest test-ai-config-gptel-magit-regression-no-after-load-on-magit ()
+ "ai-config must NOT register a `with-eval-after-load 'magit' hook.
+`magit.el' provides itself BEFORE it loads `magit-commit' and
+`magit-stash', so wiring keyed on `magit' would fire while the
+transient prefixes are still undefined and `transient-append-suffix'
+would silently no-op. The per-feature hooks side-step the race
+entirely -- this test guards against any future regression that
+re-introduces a single `'magit' hook."
+ ;; Forge installs an after-load entry for 'magit-mode'; magit's own
+ ;; code does not register anything keyed on the bare 'magit' symbol.
+ ;; Our wiring must not either.
+ (let ((entry (assoc 'magit after-load-alist)))
+ ;; If something else (e.g. another package) registers under 'magit
+ ;; the entry will exist, but it must not contain a closure that
+ ;; refers to gptel-magit symbols. Stringify the entry and grep.
+ (when entry
+ (should-not (string-match-p "gptel-magit" (format "%s" entry))))))
+
+;; ------------------------------ Wiring registration --------------------------
+
+(ert-deftest test-ai-config-gptel-magit-lazy-loading-git-commit-hook-registered ()
+ "ai-config registers an `eval-after-load' hook keyed on `git-commit'.
+The hook body binds M-g in `git-commit-mode-map' to
+`gptel-magit-generate-message', so the printed closure mentions both."
+ (let ((entry (assoc 'git-commit after-load-alist)))
+ (should entry)
+ (let ((printed (format "%s" entry)))
+ (should (string-match-p "git-commit-mode-map" printed))
+ (should (string-match-p "gptel-magit-generate-message" printed)))))
+
+(ert-deftest test-ai-config-gptel-magit-lazy-loading-magit-commit-hook-registered ()
+ "ai-config registers an `eval-after-load' hook keyed on `magit-commit'.
+The hook body calls `transient-append-suffix' for `magit-commit', so
+the printed closure mentions both."
+ (let ((entry (assoc 'magit-commit after-load-alist)))
+ (should entry)
+ (let ((printed (format "%s" entry)))
+ (should (string-match-p "transient-append-suffix" printed))
+ (should (string-match-p "magit-commit" printed))
+ (should (string-match-p "gptel-magit-commit-generate" printed)))))
+
+(ert-deftest test-ai-config-gptel-magit-lazy-loading-magit-diff-hook-registered ()
+ "ai-config registers an `eval-after-load' hook keyed on `magit-diff'.
+The hook body calls `transient-append-suffix' for `magit-diff', so the
+printed closure mentions both."
+ (let ((entry (assoc 'magit-diff after-load-alist)))
+ (should entry)
+ (let ((printed (format "%s" entry)))
+ (should (string-match-p "transient-append-suffix" printed))
+ (should (string-match-p "magit-diff" printed))
+ (should (string-match-p "gptel-magit-diff-explain" printed)))))
+
+;;; Normal Cases — Autoloads
+
+(ert-deftest test-ai-config-gptel-magit-lazy-loading-normal-generate-message-is-autoload ()
+ "After ai-config loads, `gptel-magit-generate-message' is an autoload.
+An autoload means the function is registered but `gptel-magit.el' has
+not been loaded yet -- it loads only when the function is first
+called."
+ (should (fboundp 'gptel-magit-generate-message))
+ (should (autoloadp (symbol-function 'gptel-magit-generate-message))))
+
+(ert-deftest test-ai-config-gptel-magit-lazy-loading-normal-commit-generate-is-autoload ()
+ "After ai-config loads, `gptel-magit-commit-generate' is an autoload."
+ (should (fboundp 'gptel-magit-commit-generate))
+ (should (autoloadp (symbol-function 'gptel-magit-commit-generate))))
+
+(ert-deftest test-ai-config-gptel-magit-lazy-loading-normal-diff-explain-is-autoload ()
+ "After ai-config loads, `gptel-magit-diff-explain' is an autoload."
+ (should (fboundp 'gptel-magit-diff-explain))
+ (should (autoloadp (symbol-function 'gptel-magit-diff-explain))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-gptel-magit-lazy-loading-boundary-gptel-magit-not-loaded ()
+ "After ai-config loads, `gptel-magit' itself stays unloaded.
+The autoloads are registered so the package only loads when one of its
+entry points is invoked."
+ (should-not (featurep 'gptel-magit)))
+
+;;; Error Cases — Install behavior
+
+(ert-deftest test-ai-config-gptel-magit-declared-via-use-package ()
+ "ai-config declares gptel-magit via `use-package' so it gets installed.
+Raw `(autoload ...)' calls register the function name but leave the
+package uninstalled on machines that never ran `package-install'. The
+\\=`use-package' form inherits `use-package-always-ensure' from
+early-init, which is how every other package in this config gets
+onto `load-path' before its autoloads fire."
+ (let ((source-file (expand-file-name "modules/ai-config.el"
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents source-file)
+ (goto-char (point-min))
+ (should (re-search-forward "(use-package gptel-magit\\b" nil t)))))
+
+(provide 'test-ai-config-gptel-magit-lazy-loading)
+;;; test-ai-config-gptel-magit-lazy-loading.el ends here
diff --git a/archive/gptel/tests/test-ai-config-helpers.el b/archive/gptel/tests/test-ai-config-helpers.el
new file mode 100644
index 000000000..cdbc0f6eb
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-helpers.el
@@ -0,0 +1,183 @@
+;;; test-ai-config-helpers.el --- Tests for ai-config helper functions -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Covers helpers that don't depend on a live gptel install:
+;;
+;; cj/auth-source-secret
+;; cj/anthropic-api-key (caching wrapper)
+;; cj/openai-api-key (caching wrapper)
+;; cj/gptel--add-file-to-context
+;; cj/gptel-clear-buffer
+;; cj/gptel-context-clear
+;; cj/gptel-insert-model-heading
+;;
+;; External primitives (`auth-source-search', `gptel-add-file', etc.)
+;; are stubbed so the tests never touch the keyring or the network.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-config)
+
+;; Make `gptel-context--alist' a real dynamic variable for the fallback
+;; test below. Under lexical-binding a plain `let' is lexical, so the
+;; `setq' inside `cj/gptel-context-clear' would otherwise miss it.
+(defvar gptel-context--alist nil
+ "Dynamic stand-in for the gptel-context alist (gptel not loaded here).")
+
+;;; cj/auth-source-secret
+
+(ert-deftest test-ai-config-auth-source-secret-returns-string ()
+ "Normal: a plain-string secret comes back as-is."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) '((:secret "plaintext")))))
+ (should (equal (cj/auth-source-secret "example.com" "user")
+ "plaintext"))))
+
+(ert-deftest test-ai-config-auth-source-secret-unwraps-function ()
+ "Normal: a function secret is funcall'd to retrieve the value."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) (list (list :secret (lambda () "called"))))))
+ (should (equal (cj/auth-source-secret "example.com" "user")
+ "called"))))
+
+(ert-deftest test-ai-config-auth-source-secret-errors-when-missing ()
+ "Error: an empty result raises a clear error."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) nil)))
+ (should-error (cj/auth-source-secret "nope.example.com" "user")
+ :type 'error)))
+
+;;; cj/anthropic-api-key / cj/openai-api-key
+
+(ert-deftest test-ai-config-anthropic-api-key-caches-after-first-call ()
+ "Normal: a subsequent call returns the cached value without re-fetching."
+ (let ((cj/anthropic-api-key-cached nil)
+ (call-count 0))
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _)
+ (cl-incf call-count)
+ '((:secret "anth-key")))))
+ (should (equal (cj/anthropic-api-key) "anth-key"))
+ (should (equal (cj/anthropic-api-key) "anth-key"))
+ (should (= call-count 1)))))
+
+(ert-deftest test-ai-config-openai-api-key-caches-after-first-call ()
+ "Normal: same caching contract as the anthropic key."
+ (let ((cj/openai-api-key-cached nil)
+ (call-count 0))
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _)
+ (cl-incf call-count)
+ '((:secret "oai-key")))))
+ (should (equal (cj/openai-api-key) "oai-key"))
+ (should (equal (cj/openai-api-key) "oai-key"))
+ (should (= call-count 1)))))
+
+;;; cj/gptel--add-file-to-context
+
+(ert-deftest test-ai-config-add-file-to-context-adds-existing-file ()
+ "Normal: an existing file is added and the function returns t."
+ (let ((tmp (make-temp-file "ai-config-add-file-")))
+ (unwind-protect
+ (let ((gptel-context--alist nil)
+ (added nil))
+ (cl-letf (((symbol-function 'gptel-add-file)
+ (lambda (f) (setq added f)))
+ ((symbol-function 'message) #'ignore))
+ (should (eq (cj/gptel--add-file-to-context tmp) t))
+ (should (equal added tmp))))
+ (delete-file tmp))))
+
+(ert-deftest test-ai-config-add-file-to-context-skips-missing-file ()
+ "Boundary: a non-existent path returns nil and doesn't call gptel-add-file."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'gptel-add-file)
+ (lambda (_) (setq called t))))
+ (should-not (cj/gptel--add-file-to-context "/no/such/path"))
+ (should-not called))))
+
+(ert-deftest test-ai-config-add-file-to-context-skips-nil-path ()
+ "Boundary: a nil path returns nil without calling gptel-add-file."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'gptel-add-file)
+ (lambda (_) (setq called t))))
+ (should-not (cj/gptel--add-file-to-context nil))
+ (should-not called))))
+
+;;; cj/gptel-clear-buffer
+
+(ert-deftest test-ai-config-clear-buffer-erases-in-gptel-org-buffer ()
+ "Normal: a gptel-mode org buffer is erased and the fresh org prefix is reinserted."
+ (with-temp-buffer
+ (delay-mode-hooks (org-mode))
+ (setq-local gptel-mode t)
+ (insert "* Existing conversation\nstuff\n")
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (cj/gptel-clear-buffer))
+ (should (string-match-p "cleared" msg)))
+ ;; The fresh prefix is an org heading starting with "* ".
+ (should (string-prefix-p "* " (buffer-string)))
+ (should-not (string-match-p "Existing conversation" (buffer-string)))))
+
+(ert-deftest test-ai-config-clear-buffer-noop-when-not-gptel-org ()
+ "Boundary: in a non-gptel buffer the function messages and changes nothing."
+ (with-temp-buffer
+ (insert "untouched\n")
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (cj/gptel-clear-buffer))
+ (should (string-match-p "Not a GPTel buffer" msg))
+ (should (equal (buffer-string) "untouched\n")))))
+
+;;; cj/gptel-context-clear
+
+(ert-deftest test-ai-config-context-clear-uses-remove-all-when-available ()
+ "Normal: when `gptel-context-remove-all' is bound, it wins the cond.
+The stub must be a command because `cj/gptel-context-clear' invokes it
+via `call-interactively'."
+ (let ((called nil)
+ (msg nil))
+ (cl-letf (((symbol-function 'gptel-context-remove-all)
+ (lambda () (interactive) (setq called 'remove-all)))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-context-clear))
+ (should (eq called 'remove-all))
+ (should (string-match-p "cleared" msg))))
+
+(ert-deftest test-ai-config-context-clear-falls-back-to-alist-setq ()
+ "Boundary: when no clearing function exists, the alist is set to nil."
+ (let ((gptel-context--alist '((:dummy)))
+ (msg nil))
+ (cl-letf (((symbol-function 'fboundp)
+ (lambda (sym)
+ (not (memq sym '(gptel-context-remove-all gptel-context-clear)))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
+ (cj/gptel-context-clear))
+ (should (null gptel-context--alist))
+ (should (string-match-p "cleared" msg))))
+
+;;; cj/gptel-insert-model-heading
+
+(ert-deftest test-ai-config-insert-model-heading-inserts-at-given-position ()
+ "Normal: an Org heading is inserted at RESPONSE-BEGIN-POS."
+ (with-temp-buffer
+ (insert "response text")
+ (cl-letf (((symbol-function 'cj/gptel-backend-and-model)
+ (lambda () "Anthropic: claude-test [2026-05-13 12:00:00]")))
+ (cj/gptel-insert-model-heading (point-min) (point-max)))
+ (should (string-prefix-p "* Anthropic: claude-test" (buffer-string)))
+ (should (string-match-p "\nresponse text" (buffer-string)))))
+
+(provide 'test-ai-config-helpers)
+;;; test-ai-config-helpers.el ends here
diff --git a/archive/gptel/tests/test-ai-config-model-to-string.el b/archive/gptel/tests/test-ai-config-model-to-string.el
new file mode 100644
index 000000000..aa1149272
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-model-to-string.el
@@ -0,0 +1,60 @@
+;;; test-ai-config-model-to-string.el --- Tests for cj/gptel--model-to-string -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/gptel--model-to-string from ai-config.el.
+;;
+;; Pure function that converts a model identifier (string, symbol, or
+;; other type) to a string representation. Branches on input type:
+;; string (identity), symbol (symbol-name), fallback (format).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-ai-config)
+(require 'ai-config)
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-model-to-string-normal-string-returns-string ()
+ "String model name should be returned unchanged."
+ (should (equal (cj/gptel--model-to-string "claude-opus-4-6") "claude-opus-4-6")))
+
+(ert-deftest test-ai-config-model-to-string-normal-symbol-returns-symbol-name ()
+ "Symbol model name should return its symbol-name."
+ (should (equal (cj/gptel--model-to-string 'gpt-4o) "gpt-4o")))
+
+(ert-deftest test-ai-config-model-to-string-normal-number-returns-formatted ()
+ "Numeric input should be formatted as a string."
+ (should (equal (cj/gptel--model-to-string 42) "42")))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-model-to-string-boundary-empty-string-returns-empty ()
+ "Empty string should be returned as empty string."
+ (should (equal (cj/gptel--model-to-string "") "")))
+
+(ert-deftest test-ai-config-model-to-string-boundary-nil-returns-nil-string ()
+ "Nil is a symbol, so should return \"nil\"."
+ (should (equal (cj/gptel--model-to-string nil) "nil")))
+
+(ert-deftest test-ai-config-model-to-string-boundary-keyword-symbol-includes-colon ()
+ "Keyword symbol should return its name including the colon."
+ (should (equal (cj/gptel--model-to-string :some-model) ":some-model")))
+
+(ert-deftest test-ai-config-model-to-string-boundary-list-uses-format-fallback ()
+ "List input should hit the fallback format branch."
+ (should (equal (cj/gptel--model-to-string '(a b)) "(a b)")))
+
+(ert-deftest test-ai-config-model-to-string-boundary-vector-uses-format-fallback ()
+ "Vector input should hit the fallback format branch."
+ (should (equal (cj/gptel--model-to-string [1 2]) "[1 2]")))
+
+(ert-deftest test-ai-config-model-to-string-boundary-string-with-spaces-unchanged ()
+ "String with spaces should be returned unchanged."
+ (should (equal (cj/gptel--model-to-string "model with spaces") "model with spaces")))
+
+(provide 'test-ai-config-model-to-string)
+;;; test-ai-config-model-to-string.el ends here
diff --git a/archive/gptel/tests/test-ai-config-model-to-symbol.el b/archive/gptel/tests/test-ai-config-model-to-symbol.el
new file mode 100644
index 000000000..de6f18ff8
--- /dev/null
+++ b/archive/gptel/tests/test-ai-config-model-to-symbol.el
@@ -0,0 +1,61 @@
+;;; test-ai-config-model-to-symbol.el --- Tests for cj/gptel--model-to-symbol -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/gptel--model-to-symbol from ai-config.el.
+;;
+;; Pure function that coerces a model identifier (string, symbol, or other
+;; type) to a symbol. `gptel-model' MUST be a symbol -- gptel's modeline
+;; code calls `symbolp' on it and signals wrong-type-argument on a string,
+;; which manifests as a redisplay hang. The function's invariant is that
+;; the result is always a symbol, so a value coerced through it is safe to
+;; assign to `gptel-model'.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'testutil-ai-config)
+(require 'ai-config)
+
+;;; Normal Cases
+
+(ert-deftest test-ai-config-model-to-symbol-normal-string-interns ()
+ "Normal: a string model name is interned to the matching symbol."
+ (should (eq (cj/gptel--model-to-symbol "claude-opus-4-8") 'claude-opus-4-8)))
+
+(ert-deftest test-ai-config-model-to-symbol-normal-symbol-returns-symbol ()
+ "Normal: a symbol model name is returned unchanged."
+ (should (eq (cj/gptel--model-to-symbol 'gpt-4o) 'gpt-4o)))
+
+(ert-deftest test-ai-config-model-to-symbol-normal-result-always-symbol ()
+ "Normal: the invariant -- the result is always a symbol (the crash guard)."
+ (should (symbolp (cj/gptel--model-to-symbol "gpt-5.5")))
+ (should (symbolp (cj/gptel--model-to-symbol 'gpt-5.5))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ai-config-model-to-symbol-boundary-empty-string-is-symbol ()
+ "Boundary: empty string interns to a symbol (still satisfies the invariant)."
+ (should (symbolp (cj/gptel--model-to-symbol ""))))
+
+(ert-deftest test-ai-config-model-to-symbol-boundary-nil-returns-nil ()
+ "Boundary: nil is already a symbol, returned unchanged."
+ (should (eq (cj/gptel--model-to-symbol nil) nil))
+ (should (symbolp (cj/gptel--model-to-symbol nil))))
+
+(ert-deftest test-ai-config-model-to-symbol-boundary-string-with-spaces-interns ()
+ "Boundary: a string with spaces interns to a single symbol with that name."
+ (should (eq (cj/gptel--model-to-symbol "model with spaces")
+ (intern "model with spaces"))))
+
+;;; Error/Odd Cases
+
+(ert-deftest test-ai-config-model-to-symbol-number-formats-then-interns ()
+ "Error: a non-string, non-symbol value is formatted then interned to a symbol."
+ (should (eq (cj/gptel--model-to-symbol 42) (intern "42")))
+ (should (symbolp (cj/gptel--model-to-symbol 42))))
+
+(provide 'test-ai-config-model-to-symbol)
+;;; test-ai-config-model-to-symbol.el ends here
diff --git a/archive/gptel/tests/test-ai-conversations-browser.el b/archive/gptel/tests/test-ai-conversations-browser.el
new file mode 100644
index 000000000..d7422b096
--- /dev/null
+++ b/archive/gptel/tests/test-ai-conversations-browser.el
@@ -0,0 +1,244 @@
+;;; test-ai-conversations-browser.el --- Tests for ai-conversations-browser -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the saved-conversations browser. Pure helpers (topic
+;; parsing, header stripping, preview, rename target) are tested
+;; against fixed inputs. File-touching actions (load / delete /
+;; rename) are tested against a temp conversations directory.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+(require 'testutil-ai-config)
+;; Force real ai-conversations to override testutil's stub.
+(setq features (delq 'ai-conversations features))
+(require 'ai-conversations)
+(require 'ai-conversations-browser)
+
+;; ----------------------------- temp-dir helper
+
+(defun test-ai-conversations-browser--with-temp-dir (fn)
+ "Run FN inside a fresh conversations directory. Clean up after."
+ (let* ((dir (make-temp-file "test-ai-conversations-browser-" t))
+ (cj/gptel-conversations-directory dir))
+ (unwind-protect
+ (funcall fn dir)
+ (when (file-exists-p dir)
+ (delete-directory dir t)))))
+
+(defun test-ai-conversations-browser--write (dir name content)
+ "Write CONTENT to NAME in DIR. Return the absolute path."
+ (let ((path (expand-file-name name dir)))
+ (with-temp-file path (insert content))
+ path))
+
+;; ----------------------------- topic-from-filename
+
+(ert-deftest test-ai-conversations-browser-topic-normal ()
+ "Normal: topic slug extracted from a well-formed filename."
+ (should (equal (cj/gptel-browser--topic-from-filename
+ "my-topic_20260315-101530.gptel")
+ "my-topic")))
+
+(ert-deftest test-ai-conversations-browser-topic-error-malformed ()
+ "Boundary: malformed filename returns nil."
+ (should-not (cj/gptel-browser--topic-from-filename "garbage.gptel"))
+ (should-not (cj/gptel-browser--topic-from-filename "topic.gptel"))
+ (should-not (cj/gptel-browser--topic-from-filename "topic_20260315.gptel")))
+
+;; ----------------------------- strip-headers
+
+(ert-deftest test-ai-conversations-browser-strip-headers-normal ()
+ "Strip the two visibility headers plus the blank line after them."
+ (should (equal (cj/gptel-browser--strip-headers
+ "#+STARTUP: showeverything\n#+VISIBILITY: all\n\nrest\n")
+ "rest\n")))
+
+(ert-deftest test-ai-conversations-browser-strip-headers-no-headers ()
+ "Boundary: input without headers is unchanged."
+ (should (equal (cj/gptel-browser--strip-headers "plain body\n")
+ "plain body\n")))
+
+;; ----------------------------- last-message
+
+(ert-deftest test-ai-conversations-browser-last-message-normal ()
+ "Last-message picks the body of the last org heading."
+ (let ((text "* user [2026-01-01]\nhello there\n* AI [2026-01-01]\nthe latest reply\n"))
+ (should (equal (cj/gptel-browser--last-message text)
+ "the latest reply"))))
+
+(ert-deftest test-ai-conversations-browser-last-message-no-heading ()
+ "Boundary: text without headings returns the (collapsed) body."
+ (let ((text "just some body\nwith two lines\n"))
+ (should (equal (cj/gptel-browser--last-message text)
+ "just some body with two lines"))))
+
+;; ----------------------------- preview
+
+(ert-deftest test-ai-conversations-browser-preview-truncates ()
+ "Preview is ellipsized when the message is longer than LENGTH."
+ (let ((text "* AI\nthis is a very long response that should get truncated for the preview\n"))
+ (let ((preview (cj/gptel-browser--preview text 30)))
+ (should (= (length preview) 30))
+ (should (string-suffix-p "…" preview)))))
+
+(ert-deftest test-ai-conversations-browser-preview-short ()
+ "Preview is returned verbatim when shorter than LENGTH."
+ (let ((text "* AI\nshort\n"))
+ (should (equal (cj/gptel-browser--preview text 60) "short"))))
+
+(ert-deftest test-ai-conversations-browser-preview-empty ()
+ "Preview of empty body returns empty string."
+ (should (equal (cj/gptel-browser--preview "" 60) "")))
+
+;; ----------------------------- row-for-file
+
+(ert-deftest test-ai-conversations-browser-row-for-file-normal ()
+ "Row contains date, topic, and a preview; carries file metadata."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "alpha_20260315-101530.gptel"
+ "#+STARTUP: showeverything\n\n* AI\nresult body\n")))
+ (let ((row (cj/gptel-browser--row-for-file file dir)))
+ (should row)
+ (should (string-match-p "2026-03-15 10:15" row))
+ (should (string-match-p "alpha" row))
+ (should (string-match-p "result body" row))
+ (should (equal (get-text-property 0 'cj/gptel-browser-file row)
+ "alpha_20260315-101530.gptel")))))))
+
+(ert-deftest test-ai-conversations-browser-row-for-file-non-conversation ()
+ "Files that don't match the conversation pattern return nil."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "not-a-conversation.gptel" "body")))
+ (should-not (cj/gptel-browser--row-for-file file dir))))))
+
+;; ----------------------------- rows / render
+
+(ert-deftest test-ai-conversations-browser-rows-from-empty-dir ()
+ "Empty conversations directory yields no rows."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (should-not (cj/gptel-browser--rows)))))
+
+(ert-deftest test-ai-conversations-browser-rows-multiple-conversations ()
+ "Multiple conversations produce a row per file."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations-browser--write
+ dir "a_20260101-100000.gptel" "* AI\nfirst\n")
+ (test-ai-conversations-browser--write
+ dir "b_20260102-100000.gptel" "* AI\nsecond\n")
+ (let ((rows (cj/gptel-browser--rows)))
+ (should (= 2 (length rows)))))))
+
+(ert-deftest test-ai-conversations-browser-render-empty ()
+ "Render shows a 'no conversations' line when directory is empty."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (should (string-match-p "no saved conversations" (buffer-string)))))))
+
+(ert-deftest test-ai-conversations-browser-render-newest-first ()
+ "Render sorts rows newest first by timestamp."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations-browser--write
+ dir "old_20260101-100000.gptel" "* AI\nx\n")
+ (test-ai-conversations-browser--write
+ dir "new_20260301-100000.gptel" "* AI\ny\n")
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (let ((text (buffer-substring-no-properties (point-min) (point-max))))
+ ;; New (March) should appear before old (January) in the buffer.
+ (should (< (string-match "2026-03-01" text)
+ (string-match "2026-01-01" text))))))))
+
+;; ----------------------------- rename-target
+
+(ert-deftest test-ai-conversations-browser-rename-target-normal ()
+ "Rename-target preserves the timestamp and slugifies the new topic."
+ (should (equal (cj/gptel-browser--rename-target
+ "/tmp/old-topic_20260101-100000.gptel"
+ "Brand New Topic")
+ "/tmp/brand-new-topic_20260101-100000.gptel")))
+
+(ert-deftest test-ai-conversations-browser-rename-target-error-no-timestamp ()
+ "Rename-target errors when the filename lacks a timestamp."
+ (should-error (cj/gptel-browser--rename-target "/tmp/no-ts.gptel" "x")))
+
+;; ----------------------------- delete / rename actions
+
+(ert-deftest test-ai-conversations-browser-delete-removes-file ()
+ "Delete with y removes the file under point and re-renders."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "topic_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ ;; Point on the only data row
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t)))
+ (cj/gptel-browser-delete))
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-browser-delete-cancel-keeps-file ()
+ "Delete with n leaves the file alone."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations-browser--write
+ dir "topic_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) nil)))
+ (cj/gptel-browser-delete))
+ (should (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-browser-rename-renames-file ()
+ "Rename moves the file under a new slug while preserving timestamp."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (dir)
+ (let* ((file (test-ai-conversations-browser--write
+ dir "old-name_20260101-100000.gptel" "* AI\nx\n")))
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (forward-line 2)
+ (cl-letf (((symbol-function 'read-string)
+ (lambda (&rest _) "renamed topic")))
+ (cj/gptel-browser-rename))
+ (should-not (file-exists-p file))
+ (should (file-exists-p
+ (expand-file-name "renamed-topic_20260101-100000.gptel"
+ dir))))))))
+
+(ert-deftest test-ai-conversations-browser-rename-error-on-empty-line ()
+ "Rename errors when point is on the header/empty area."
+ (test-ai-conversations-browser--with-temp-dir
+ (lambda (_dir)
+ (with-temp-buffer
+ (cj/gptel-browser-mode)
+ (cj/gptel-browser--render)
+ (goto-char (point-min))
+ (should-error (cj/gptel-browser-rename))))))
+
+(provide 'test-ai-conversations-browser)
+;;; test-ai-conversations-browser.el ends here
diff --git a/archive/gptel/tests/test-ai-conversations.el b/archive/gptel/tests/test-ai-conversations.el
new file mode 100644
index 000000000..2d5aefd13
--- /dev/null
+++ b/archive/gptel/tests/test-ai-conversations.el
@@ -0,0 +1,564 @@
+;;; test-ai-conversations.el --- Tests for ai-conversations.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Normal / Boundary / Error tests for the save/load/delete and
+;; autosave surface in ai-conversations.el. Pure helpers are tested
+;; against fixed inputs; file-touching helpers use per-test temp
+;; directories. Interactive commands are exercised via `cl-letf'
+;; stubs on `completing-read' and `y-or-n-p'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+(require 'testutil-ai-config)
+;; testutil-ai-config provides 'ai-conversations as a stub. Force the
+;; real module to override.
+(setq features (delq 'ai-conversations features))
+(require 'ai-conversations)
+
+;; -------------------------------------------------------- temp-dir helper
+
+(defun test-ai-conversations--with-temp-dir (fn)
+ "Run FN inside a fresh conversations directory. Clean up after."
+ (let* ((dir (make-temp-file "test-ai-conversations-" t))
+ (cj/gptel-conversations-directory dir))
+ (unwind-protect
+ (funcall fn dir)
+ (when (file-exists-p dir)
+ (delete-directory dir t)))))
+
+(defun test-ai-conversations--touch (dir name)
+ "Create empty file NAME in DIR."
+ (let ((path (expand-file-name name dir)))
+ (with-temp-file path (insert ""))
+ path))
+
+;; ------------------------------------------------------ slugify-topic
+
+(ert-deftest test-ai-conversations-slugify-topic-normal ()
+ "Normal: ASCII words with spaces become hyphen-joined slug."
+ (should (equal (cj/gptel--slugify-topic "Hello World") "hello-world")))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-empty ()
+ "Boundary: empty input returns the literal \"conversation\" placeholder."
+ (should (equal (cj/gptel--slugify-topic "") "conversation"))
+ (should (equal (cj/gptel--slugify-topic nil) "conversation")))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-all-special ()
+ "Boundary: input with no slug-safe chars falls back to placeholder."
+ (should (equal (cj/gptel--slugify-topic "!!!@@@###") "conversation"))
+ (should (equal (cj/gptel--slugify-topic " ") "conversation")))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-unicode-stripped ()
+ "Boundary: non-ASCII characters drop out (only [a-z0-9] survives)."
+ (should (equal (cj/gptel--slugify-topic "Café Résumé") "caf-r-sum")))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-idempotent ()
+ "Boundary: applying twice yields the same result as once."
+ (let ((once (cj/gptel--slugify-topic "Foo Bar 2026!")))
+ (should (equal once (cj/gptel--slugify-topic once)))))
+
+(ert-deftest test-ai-conversations-slugify-topic-boundary-leading-trailing-trim ()
+ "Boundary: leading/trailing separator runs are trimmed."
+ (should (equal (cj/gptel--slugify-topic "---foo---") "foo"))
+ (should (equal (cj/gptel--slugify-topic "**foo**") "foo")))
+
+(ert-deftest test-ai-conversations-slugify-topic-normal-numbers-preserved ()
+ "Normal: digits survive the slug."
+ (should (equal (cj/gptel--slugify-topic "Project 2026 Plan")
+ "project-2026-plan")))
+
+;; ------------------------------------------------------ timestamp-from-filename
+
+(ert-deftest test-ai-conversations-timestamp-from-filename-normal ()
+ "Normal: well-formed filename decodes to a time value."
+ (let ((ts (cj/gptel--timestamp-from-filename
+ "topic_20260315-101530.gptel")))
+ (should ts)
+ (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" ts)
+ "2026-03-15 10:15:30"))))
+
+(ert-deftest test-ai-conversations-timestamp-from-filename-boundary-year-edges ()
+ "Boundary: end-of-year and start-of-year timestamps decode correctly."
+ (let ((eoy (cj/gptel--timestamp-from-filename
+ "topic_20251231-235959.gptel"))
+ (boy (cj/gptel--timestamp-from-filename
+ "topic_20260101-000000.gptel")))
+ (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" eoy)
+ "2025-12-31 23:59:59"))
+ (should (equal (format-time-string "%Y-%m-%d %H:%M:%S" boy)
+ "2026-01-01 00:00:00"))))
+
+(ert-deftest test-ai-conversations-timestamp-from-filename-error-malformed ()
+ "Error: non-matching filename returns nil."
+ (should-not (cj/gptel--timestamp-from-filename "not-a-gptel-file"))
+ (should-not (cj/gptel--timestamp-from-filename "topic.gptel"))
+ (should-not (cj/gptel--timestamp-from-filename "topic_20260315.gptel"))
+ (should-not (cj/gptel--timestamp-from-filename "topic_2026031-101530.gptel")))
+
+;; ------------------------------------------------------ existing-topics
+
+(ert-deftest test-ai-conversations-existing-topics-normal ()
+ "Normal: returns unique topic slugs across multiple-timestamped files."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260102-100000.gptel")
+ (test-ai-conversations--touch dir "bar_20260102-100000.gptel")
+ (let ((topics (cj/gptel--existing-topics)))
+ (should (member "foo" topics))
+ (should (member "bar" topics))
+ (should (= 2 (length topics)))))))
+
+(ert-deftest test-ai-conversations-existing-topics-boundary-empty-dir ()
+ "Boundary: empty conversations directory returns nil."
+ (test-ai-conversations--with-temp-dir
+ (lambda (_dir)
+ (should-not (cj/gptel--existing-topics)))))
+
+(ert-deftest test-ai-conversations-existing-topics-boundary-missing-dir ()
+ "Boundary: missing directory returns nil instead of erroring."
+ (let ((cj/gptel-conversations-directory
+ (expand-file-name (format "missing-%s" (random)) "/tmp")))
+ (should-not (cj/gptel--existing-topics))))
+
+(ert-deftest test-ai-conversations-existing-topics-boundary-ignores-non-gptel ()
+ "Boundary: files without .gptel extension are ignored."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "readme.txt")
+ (test-ai-conversations--touch dir "stray.gptel.bak")
+ (should (equal (cj/gptel--existing-topics) '("foo"))))))
+
+;; ------------------------------------------------------ latest-file-for-topic
+
+(ert-deftest test-ai-conversations-latest-file-for-topic-normal ()
+ "Normal: returns the newest file for the topic by lexical sort."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260103-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260102-100000.gptel")
+ (should (equal (cj/gptel--latest-file-for-topic "foo")
+ "foo_20260103-100000.gptel")))))
+
+(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-no-match ()
+ "Boundary: no matching topic returns nil."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "bar_20260101-100000.gptel")
+ (should-not (cj/gptel--latest-file-for-topic "foo")))))
+
+(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-missing-dir ()
+ "Boundary: missing directory returns nil."
+ (let ((cj/gptel-conversations-directory
+ (expand-file-name (format "missing-%s" (random)) "/tmp")))
+ (should-not (cj/gptel--latest-file-for-topic "foo"))))
+
+(ert-deftest test-ai-conversations-latest-file-for-topic-boundary-regex-isolation ()
+ "Boundary: prefix-overlapping topics are not falsely matched."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foobar_20260102-100000.gptel")
+ (should (equal (cj/gptel--latest-file-for-topic "foo")
+ "foo_20260101-100000.gptel")))))
+
+;; ------------------------------------------------------ conversation-candidates
+
+(ert-deftest test-ai-conversations-conversation-candidates-normal-newest-first ()
+ "Normal: candidates are sorted newest-first when configured that way."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260103-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260102-100000.gptel")
+ (let ((cj/gptel-conversations-sort-order 'newest-first))
+ (let* ((cands (cj/gptel--conversation-candidates))
+ (files (mapcar #'cdr cands)))
+ (should (equal files
+ '("foo_20260103-100000.gptel"
+ "foo_20260102-100000.gptel"
+ "foo_20260101-100000.gptel"))))))))
+
+(ert-deftest test-ai-conversations-conversation-candidates-normal-oldest-first ()
+ "Normal: candidates respect oldest-first sort order."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "foo_20260101-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260103-100000.gptel")
+ (test-ai-conversations--touch dir "foo_20260102-100000.gptel")
+ (let ((cj/gptel-conversations-sort-order 'oldest-first))
+ (let* ((cands (cj/gptel--conversation-candidates))
+ (files (mapcar #'cdr cands)))
+ (should (equal files
+ '("foo_20260101-100000.gptel"
+ "foo_20260102-100000.gptel"
+ "foo_20260103-100000.gptel"))))))))
+
+(ert-deftest test-ai-conversations-conversation-candidates-error-missing-dir ()
+ "Error: missing conversations directory signals."
+ (let ((cj/gptel-conversations-directory
+ (expand-file-name (format "missing-%s" (random)) "/tmp")))
+ (should-error (cj/gptel--conversation-candidates))))
+
+(ert-deftest test-ai-conversations-conversation-candidates-display-shape ()
+ "Display string is \"filename [YYYY-MM-DD HH:MM]\"."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (test-ai-conversations--touch dir "topic_20260315-101530.gptel")
+ (let* ((cands (cj/gptel--conversation-candidates))
+ (display (car (car cands))))
+ (should (string-match-p
+ "\\`topic_20260315-101530\\.gptel \\[2026-03-15 10:15\\]\\'"
+ display))))))
+
+;; ------------------------------------------------------ save-buffer-to-file
+
+(ert-deftest test-ai-conversations-save-buffer-to-file-normal ()
+ "Normal: writes buffer with visibility headers prepended."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (with-temp-buffer
+ (insert "hello world\n")
+ (let ((file (expand-file-name "out.gptel" dir)))
+ (cj/gptel--save-buffer-to-file (current-buffer) file)
+ (should (file-exists-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (should (string-match-p "^#\\+STARTUP: showeverything"
+ (buffer-string)))
+ (should (string-match-p "^#\\+VISIBILITY: all"
+ (buffer-string)))
+ (should (string-match-p "hello world"
+ (buffer-string)))))))))
+
+(ert-deftest test-ai-conversations-save-buffer-to-file-roundtrip-with-strip ()
+ "Round-trip: save then strip-visibility-headers yields original content."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((original "first line\nsecond line\n")
+ (file (expand-file-name "rt.gptel" dir)))
+ (with-temp-buffer
+ (insert original)
+ (cj/gptel--save-buffer-to-file (current-buffer) file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (cj/gptel--strip-visibility-headers)
+ (should (equal (buffer-string) original)))))))
+
+(ert-deftest test-ai-conversations-strip-visibility-headers-boundary-no-headers ()
+ "Boundary: buffer without headers is unchanged."
+ (with-temp-buffer
+ (insert "plain body\n")
+ (cj/gptel--strip-visibility-headers)
+ (should (equal (buffer-string) "plain body\n"))))
+
+;; ------------------------------------------------------ autosave-after-response
+
+(defmacro test-ai-conversations--with-gptel-mode (&rest body)
+ "Run BODY in a temp buffer with `gptel-mode' bound non-nil."
+ (declare (indent 0))
+ `(with-temp-buffer
+ (setq-local gptel-mode t)
+ ,@body))
+
+(ert-deftest test-ai-conversations-autosave-after-response-saves-when-enabled ()
+ "Hook saves the buffer to the autosave filepath when enabled."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "auto.gptel" dir)))
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath file)
+ (insert "autosaved body")
+ (cj/gptel--autosave-after-response)
+ (should (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-autosave-after-response-skips-when-disabled ()
+ "Hook is a no-op when `cj/gptel-autosave-enabled' is nil."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "auto.gptel" dir)))
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath file)
+ (cj/gptel--autosave-after-response)
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-autosave-after-response-skips-when-no-filepath ()
+ "Hook is a no-op when filepath is nil or empty."
+ (test-ai-conversations--with-temp-dir
+ (lambda (_dir)
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath nil)
+ ;; Should not error
+ (cj/gptel--autosave-after-response))
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "")
+ (cj/gptel--autosave-after-response)))))
+
+(ert-deftest test-ai-conversations-autosave-after-response-skips-outside-gptel-mode ()
+ "Hook is a no-op when `gptel-mode' is nil."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "auto.gptel" dir)))
+ (with-temp-buffer
+ (setq-local gptel-mode nil)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath file)
+ (cj/gptel--autosave-after-response)
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-autosave-after-send-error-is-non-fatal ()
+ "Hook surfaces a save error via `message' rather than signaling."
+ (test-ai-conversations--with-temp-dir
+ (lambda (_dir)
+ (test-ai-conversations--with-gptel-mode
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/nonexistent-dir/file.gptel")
+ ;; Must not signal even though the write will fail
+ (cj/gptel--autosave-after-send)))))
+
+;; ------------------------------------------------------ autosave timer
+
+(ert-deftest test-ai-conversations-autosave-start-timer-normal ()
+ "Normal: starting autosave creates a repeating timer for the current buffer."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (let ((calls nil))
+ (cl-letf (((symbol-function 'run-with-timer)
+ (lambda (secs repeat function &rest args)
+ (push (list secs repeat function args) calls)
+ :fake-timer)))
+ (let ((cj/gptel-autosave-interval 17))
+ (cj/gptel--autosave-start-timer)))
+ (should (eq cj/gptel-autosave--timer :fake-timer))
+ (should (equal (caar calls) 17))
+ (should (equal (cadar calls) 17))
+ (should (eq (nth 2 (car calls)) #'cj/gptel--autosave-timer-callback))
+ (should (eq (car (nth 3 (car calls))) (current-buffer))))))
+
+(ert-deftest test-ai-conversations-autosave-start-timer-idempotent ()
+ "Boundary: starting autosave twice does not create a second timer."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (setq-local cj/gptel-autosave--timer :existing-timer)
+ (let ((created 0))
+ (cl-letf (((symbol-function 'run-with-timer)
+ (lambda (&rest _)
+ (setq created (1+ created))
+ :new-timer)))
+ (cj/gptel--autosave-start-timer))
+ (should (= created 0))
+ (should (eq cj/gptel-autosave--timer :existing-timer)))))
+
+(ert-deftest test-ai-conversations-autosave-stop-timer-cancels ()
+ "Normal: stopping autosave cancels the current buffer's timer."
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel--autosave-stop-timer))
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer))))
+
+(ert-deftest test-ai-conversations-autosave-timer-callback-saves-active-buffer ()
+ "Normal: timer callback saves the live buffer when autosave is active."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (expand-file-name "timer.gptel" dir))
+ (buf (generate-new-buffer " *gptel timer test*")))
+ (unwind-protect
+ (with-current-buffer buf
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath file)
+ (insert "timer body")
+ (cj/gptel--autosave-timer-callback buf)
+ (should (file-exists-p file)))
+ (when (buffer-live-p buf)
+ (kill-buffer buf)))))))
+
+(ert-deftest test-ai-conversations-autosave-timer-callback-stops-inactive-buffer ()
+ "Boundary: timer callback cancels itself when autosave is no longer active."
+ (let ((buf (generate-new-buffer " *gptel timer inactive*")))
+ (unwind-protect
+ (with-current-buffer buf
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel--autosave-timer-callback buf))
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer)))
+ (when (buffer-live-p buf)
+ (kill-buffer buf)))))
+
+;; ------------------------------------------------------ save-conversation
+
+(ert-deftest test-ai-conversations-save-conversation-interactive-new-topic ()
+ "Save-conversation writes file, enables autosave, and starts a timer."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((ai-buffer (generate-new-buffer "*AI-Assistant*")))
+ (unwind-protect
+ (progn
+ (with-current-buffer ai-buffer
+ (setq-local gptel-mode t)
+ (insert "session content"))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "Test Topic"))
+ ((symbol-function 'y-or-n-p)
+ (lambda (&rest _) nil))
+ ((symbol-function 'run-with-timer)
+ (lambda (&rest _) :save-timer)))
+ (cj/gptel-save-conversation)
+ (let ((files (directory-files dir nil "test-topic_.*\\.gptel$")))
+ (should files)
+ (should (= 1 (length files))))
+ ;; Autosave state is set in the AI buffer
+ (with-current-buffer ai-buffer
+ (should cj/gptel-autosave-enabled)
+ (should (stringp cj/gptel-autosave-filepath))
+ (should (eq cj/gptel-autosave--timer :save-timer)))))
+ (kill-buffer ai-buffer))))))
+
+(ert-deftest test-ai-conversations-save-conversation-error-no-buffer ()
+ "Save-conversation errors when *AI-Assistant* doesn't exist."
+ (when (get-buffer "*AI-Assistant*")
+ (kill-buffer "*AI-Assistant*"))
+ (should-error (cj/gptel-save-conversation)))
+
+;; ------------------------------------------------------ delete-conversation
+
+(ert-deftest test-ai-conversations-delete-conversation-interactive ()
+ "Delete-conversation removes the chosen file after confirmation."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations--touch
+ dir "topic_20260101-100000.gptel")))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p cands &rest _) (caar cands)))
+ ((symbol-function 'y-or-n-p)
+ (lambda (&rest _) t)))
+ (cj/gptel-delete-conversation)
+ (should-not (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-delete-conversation-cancelled ()
+ "Delete-conversation preserves the file when the user declines."
+ (test-ai-conversations--with-temp-dir
+ (lambda (dir)
+ (let ((file (test-ai-conversations--touch
+ dir "topic_20260101-100000.gptel")))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p cands &rest _) (caar cands)))
+ ((symbol-function 'y-or-n-p)
+ (lambda (&rest _) nil)))
+ (cj/gptel-delete-conversation)
+ (should (file-exists-p file)))))))
+
+(ert-deftest test-ai-conversations-delete-conversation-error-empty-dir ()
+ "Delete-conversation errors when no saved conversations exist."
+ (test-ai-conversations--with-temp-dir
+ (lambda (_dir)
+ (should-error (cj/gptel-delete-conversation)))))
+
+;; ------------------------------------------------------ install-once
+
+(ert-deftest test-ai-conversations-autosave-after-response-hook-not-duplicated ()
+ "Loading ai-conversations twice does not duplicate the post-response hook."
+ (let ((gptel-post-response-functions
+ (list #'cj/gptel--autosave-after-response)))
+ ;; Re-run the install code
+ (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions)
+ (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response))
+ (should (= 1 (cl-count #'cj/gptel--autosave-after-response
+ gptel-post-response-functions)))))
+
+;; --------------------------------------------- autosave-toggle / indicator
+
+(ert-deftest test-ai-conversations-autosave-toggle-enables-with-filepath ()
+ "Toggle enables autosave when a filepath is set."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (cj/gptel-autosave-toggle)
+ (should cj/gptel-autosave-enabled)))
+
+(ert-deftest test-ai-conversations-autosave-toggle-disables ()
+ "Toggle turns autosave off and cancels the periodic timer when already on."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled t)
+ (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel")
+ (setq-local cj/gptel-autosave--timer :fake-timer)
+ (let ((cancelled nil))
+ (cl-letf (((symbol-function 'cancel-timer)
+ (lambda (timer) (setq cancelled timer))))
+ (cj/gptel-autosave-toggle))
+ (should-not cj/gptel-autosave-enabled)
+ (should (eq cancelled :fake-timer))
+ (should-not cj/gptel-autosave--timer))))
+
+(ert-deftest test-ai-conversations-autosave-toggle-prompts-when-no-filepath ()
+ "Toggle prompts to save first when no filepath is configured."
+ (with-temp-buffer
+ (setq-local gptel-mode t)
+ (setq-local cj/gptel-autosave-enabled nil)
+ (setq-local cj/gptel-autosave-filepath nil)
+ (let ((prompted nil)
+ (save-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (&rest _) (setq prompted t) nil))
+ ((symbol-function 'cj/gptel-save-conversation)
+ (lambda () (setq save-called t))))
+ (cj/gptel-autosave-toggle))
+ (should prompted)
+ (should-not save-called)
+ (should-not cj/gptel-autosave-enabled))))
+
+(ert-deftest test-ai-conversations-autosave-toggle-error-outside-gptel-mode ()
+ "Toggle signals when called outside a gptel buffer."
+ (with-temp-buffer
+ (setq-local gptel-mode nil)
+ (should-error (cj/gptel-autosave-toggle))))
+
+(ert-deftest test-ai-conversations-autosave-mode-line-format-evaluates ()
+ "Mode-line format evaluates to \" [AS]\" only when autosave is enabled."
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave-enabled t)
+ (should (equal (eval (cadr cj/gptel-autosave-mode-line-format))
+ " [AS]")))
+ (with-temp-buffer
+ (setq-local cj/gptel-autosave-enabled nil)
+ (should-not (eval (cadr cj/gptel-autosave-mode-line-format)))))
+
+(ert-deftest test-ai-conversations-install-mode-line-idempotent ()
+ "Repeated installs do not duplicate the construct in mode-line-format."
+ (with-temp-buffer
+ (setq-local mode-line-format '("base"))
+ (cj/gptel--install-autosave-mode-line)
+ (cj/gptel--install-autosave-mode-line)
+ (cj/gptel--install-autosave-mode-line)
+ (should (= 1 (cl-count 'cj/gptel-autosave-mode-line-format mode-line-format)))))
+
+(provide 'test-ai-conversations)
+;;; test-ai-conversations.el ends here
diff --git a/archive/gptel/tests/test-ai-mcp-helpers.el b/archive/gptel/tests/test-ai-mcp-helpers.el
new file mode 100644
index 000000000..5a995ff2d
--- /dev/null
+++ b/archive/gptel/tests/test-ai-mcp-helpers.el
@@ -0,0 +1,419 @@
+;;; test-ai-mcp-helpers.el --- Tests for pure helpers in ai-mcp.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Normal / Boundary / Error tests for the side-effect-free helpers in
+;; ai-mcp.el: secrets redaction, confirm-policy classifier, description
+;; normalizer, Claude-config reader (mtime-cached), env / secret-args
+;; resolution, server-alist builder. No real `~/.claude.json' reads;
+;; fixtures are written to per-test temp files. No real subprocesses
+;; or network calls.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-mcp)
+
+;; -------------------------------------------------------- fixtures
+
+(defconst test-ai-mcp--sentinel "REDACTED_TEST_SECRET"
+ "Sentinel that must never appear in any user-facing output.")
+
+(defconst test-ai-mcp--fixture-json
+ "{
+ \"mcpServers\": {
+ \"drawio\": {
+ \"type\": \"stdio\",
+ \"command\": \"npx\",
+ \"args\": [\"-y\", \"@drawio/mcp\"]
+ },
+ \"google-calendar\": {
+ \"type\": \"stdio\",
+ \"command\": \"npx\",
+ \"args\": [\"-y\", \"@cocal/google-calendar-mcp\"],
+ \"env\": {
+ \"GOOGLE_OAUTH_CREDENTIALS\": \"REDACTED_TEST_SECRET\"
+ }
+ },
+ \"google-docs-personal\": {
+ \"type\": \"stdio\",
+ \"command\": \"npx\",
+ \"args\": [\"-y\", \"@a-bonus/google-docs-mcp\"],
+ \"env\": {
+ \"GOOGLE_CLIENT_ID\": \"REDACTED_TEST_SECRET\",
+ \"GOOGLE_CLIENT_SECRET\": \"REDACTED_TEST_SECRET\",
+ \"GOOGLE_MCP_PROFILE\": \"personal\"
+ }
+ },
+ \"figma\": {
+ \"type\": \"stdio\",
+ \"command\": \"npx\",
+ \"args\": [\"-y\", \"figma-developer-mcp\", \"--figma-api-key=REDACTED_TEST_SECRET\", \"--stdio\"]
+ },
+ \"linear\": {
+ \"type\": \"http\",
+ \"url\": \"https://mcp.linear.app/mcp\"
+ },
+ \"slack-deepsat\": {
+ \"type\": \"sse\",
+ \"url\": \"http://127.0.0.1:13080/sse\"
+ }
+ }
+}"
+ "Fixture matching the shape of a real ~/.claude.json mcpServers tree.")
+
+(defun test-ai-mcp--write-fixture (&optional content)
+ "Write CONTENT (defaults to the standard fixture) to a temp file.
+Return the file path."
+ (let ((tmp (make-temp-file "test-ai-mcp-" nil ".json")))
+ (with-temp-file tmp
+ (insert (or content test-ai-mcp--fixture-json)))
+ tmp))
+
+(defmacro test-ai-mcp--with-fixture (var &rest body)
+ "Bind VAR to a fresh fixture file path and BODY-eval. Clean up after."
+ (declare (indent 1))
+ `(let ((,var (test-ai-mcp--write-fixture))
+ (cj/mcp--config-cache nil))
+ (unwind-protect (progn ,@body)
+ (when (file-exists-p ,var) (delete-file ,var)))))
+
+;; -------------------------------------------------------- redact
+
+(ert-deftest test-ai-mcp-redact-token-eq-normal ()
+ "Normal: --token=VALUE has the value replaced by ***."
+ (should (equal (cj/mcp--redact "--token=abc123") "--token=***")))
+
+(ert-deftest test-ai-mcp-redact-token-spaced-boundary ()
+ "Boundary: --token VALUE (space separator) is also redacted."
+ (should (equal (cj/mcp--redact "--token abc123") "--token ***")))
+
+(ert-deftest test-ai-mcp-redact-secret-flag-normal ()
+ "Normal: --secret=VALUE is redacted."
+ (should (equal (cj/mcp--redact "--secret=topsecret") "--secret=***")))
+
+(ert-deftest test-ai-mcp-redact-password-flag-normal ()
+ "Normal: --password=VALUE is redacted."
+ (should (equal (cj/mcp--redact "--password=hunter2") "--password=***")))
+
+(ert-deftest test-ai-mcp-redact-figma-api-key-normal ()
+ "Normal: --figma-api-key=VALUE is redacted (covers the figma case)."
+ (should (equal (cj/mcp--redact "--figma-api-key=figd_xyz")
+ "--figma-api-key=***")))
+
+(ert-deftest test-ai-mcp-redact-authorization-header-normal ()
+ "Normal: Authorization header value (scheme + token) is masked."
+ (should (equal (cj/mcp--redact "Authorization: Bearer ghp_xyz123")
+ "Authorization: ***")))
+
+(ert-deftest test-ai-mcp-redact-url-token-normal ()
+ "Normal: ?token=VALUE in a URL is masked."
+ (should (equal (cj/mcp--redact "https://api.example/v1?token=abc123&page=2")
+ "https://api.example/v1?token=***&page=2")))
+
+(ert-deftest test-ai-mcp-redact-no-secrets-boundary ()
+ "Boundary: a string with no known secrets is returned unchanged."
+ (should (equal (cj/mcp--redact "hello world, nothing secret here")
+ "hello world, nothing secret here")))
+
+(ert-deftest test-ai-mcp-redact-empty-string-boundary ()
+ "Boundary: empty string returns empty string."
+ (should (equal (cj/mcp--redact "") "")))
+
+(ert-deftest test-ai-mcp-redact-multiple-secrets-boundary ()
+ "Boundary: multiple secrets in one string are all redacted."
+ (let* ((input "--token=abc --secret=xyz --password=qwe")
+ (out (cj/mcp--redact input)))
+ (should (equal out "--token=*** --secret=*** --password=***"))))
+
+(ert-deftest test-ai-mcp-redact-nil-input-error ()
+ "Error: nil input returns nil rather than signaling."
+ (should (null (cj/mcp--redact nil))))
+
+(ert-deftest test-ai-mcp-redact-sentinel-never-leaks ()
+ "Sentinel REDACTED_TEST_SECRET is replaced wherever it lives in a secret slot."
+ (dolist (input (list (format "--token=%s" test-ai-mcp--sentinel)
+ (format "--figma-api-key=%s" test-ai-mcp--sentinel)
+ (format "Authorization: Bearer %s" test-ai-mcp--sentinel)
+ (format "https://x/y?token=%s" test-ai-mcp--sentinel)))
+ (let ((out (cj/mcp--redact input)))
+ (should-not (string-match-p test-ai-mcp--sentinel out)))))
+
+;; -------------------------------------------------------- confirm-p
+
+(ert-deftest test-ai-mcp-confirm-p-write-pattern-normal ()
+ "Normal: a write-prefixed tool name returns t."
+ (should (cj/mcp--confirm-p "mcp__linear__create_issue")))
+
+(ert-deftest test-ai-mcp-confirm-p-read-pattern-normal ()
+ "Normal: a read-prefixed tool name returns nil."
+ (should-not (cj/mcp--confirm-p "mcp__linear__list_issues")))
+
+(ert-deftest test-ai-mcp-confirm-p-unknown-fails-closed-boundary ()
+ "Boundary: a name matching neither read nor write defaults to t (fail closed)."
+ (should (cj/mcp--confirm-p "mcp__linear__frobnicate")))
+
+(ert-deftest test-ai-mcp-confirm-p-explicit-remote-name-boundary ()
+ "Boundary: REMOTE-NAME arg overrides the prefix-strip of GPTEL-NAME."
+ ;; The gptel-name claims read, but the explicit remote-name is a write
+ ;; verb, so confirm should still fire.
+ (should (cj/mcp--confirm-p "mcp__linear__list_issues" "create_issue")))
+
+(ert-deftest test-ai-mcp-confirm-p-override-wins-boundary ()
+ "Boundary: cj/mcp-tool-confirm-overrides wins over the classifier."
+ (let ((cj/mcp-tool-confirm-overrides
+ '(("mcp__linear__create_issue" . nil))))
+ (should-not (cj/mcp--confirm-p "mcp__linear__create_issue"))))
+
+;; -------------------------------------------------------- normalize-description
+
+(ert-deftest test-ai-mcp-normalize-description-read-normal ()
+ "Normal: a read tool gets the bare [SERVER] prefix."
+ (should (equal
+ (cj/mcp--normalize-description
+ "linear"
+ '(:name "list_issues" :description "List issues in a Linear team."))
+ "[linear] List issues in a Linear team.")))
+
+(ert-deftest test-ai-mcp-normalize-description-write-normal ()
+ "Normal: a write tool gets [SERVER WRITE] prefix."
+ (should (equal
+ (cj/mcp--normalize-description
+ "linear"
+ '(:name "create_issue" :description "Create a new Linear issue."))
+ "[linear WRITE] Create a new Linear issue.")))
+
+(ert-deftest test-ai-mcp-normalize-description-unknown-boundary ()
+ "Boundary: a tool matching neither classifier gets [SERVER ?] prefix."
+ (should (equal
+ (cj/mcp--normalize-description
+ "google-keep"
+ '(:name "frobnicate" :description "Do the frob thing."))
+ "[google-keep ?] Do the frob thing.")))
+
+(ert-deftest test-ai-mcp-normalize-description-missing-upstream-boundary ()
+ "Boundary: missing upstream description falls back to a placeholder."
+ (should (equal
+ (cj/mcp--normalize-description
+ "linear"
+ '(:name "list_issues"))
+ "[linear] (no description provided by server)")))
+
+;; -------------------------------------------------------- read-claude-config
+
+(ert-deftest test-ai-mcp-read-claude-config-good-fixture-normal ()
+ "Normal: parsing a well-formed fixture returns :ok t and the parsed data."
+ (test-ai-mcp--with-fixture path
+ (let ((result (cj/mcp--read-claude-config path)))
+ (should (plist-get result :ok))
+ (should (plist-get (plist-get result :data) :mcpServers)))))
+
+(ert-deftest test-ai-mcp-read-claude-config-missing-file-error ()
+ "Error: missing file returns :ok nil with :reason missing-file."
+ (let ((cj/mcp--config-cache nil)
+ (path "/nonexistent/path/never-will-exist.json"))
+ (let ((result (cj/mcp--read-claude-config path)))
+ (should-not (plist-get result :ok))
+ (should (eq (plist-get result :reason) 'missing-file)))))
+
+(ert-deftest test-ai-mcp-read-claude-config-malformed-json-error ()
+ "Error: malformed JSON returns :ok nil with :reason malformed-json and a message."
+ (let ((cj/mcp--config-cache nil)
+ (tmp (make-temp-file "test-ai-mcp-malformed-" nil ".json")))
+ (unwind-protect
+ (progn
+ (with-temp-file tmp (insert "{ this is not valid json ::: "))
+ (let ((result (cj/mcp--read-claude-config tmp)))
+ (should-not (plist-get result :ok))
+ (should (eq (plist-get result :reason) 'malformed-json))
+ (should (stringp (plist-get result :message)))))
+ (delete-file tmp))))
+
+(ert-deftest test-ai-mcp-read-claude-config-empty-object-boundary ()
+ "Boundary: an empty JSON object parses to ok with empty data plist."
+ (let ((cj/mcp--config-cache nil)
+ (tmp (make-temp-file "test-ai-mcp-empty-" nil ".json")))
+ (unwind-protect
+ (progn
+ (with-temp-file tmp (insert "{}"))
+ (let ((result (cj/mcp--read-claude-config tmp)))
+ (should (plist-get result :ok))
+ ;; :mcpServers is absent; plist-get returns nil.
+ (should-not (plist-get (plist-get result :data) :mcpServers))))
+ (delete-file tmp))))
+
+(ert-deftest test-ai-mcp-read-claude-config-cache-hit-boundary ()
+ "Boundary: a second read with the same mtime reuses the cache.
+We detect cache reuse by mutating the cached :data alist after the first
+read and verifying the second read returns the mutated value."
+ (test-ai-mcp--with-fixture path
+ (let* ((first (cj/mcp--read-claude-config path))
+ (cache cj/mcp--config-cache))
+ (should (plist-get first :ok))
+ ;; Mutate the cached :data so a cache-hit returns the marker.
+ (plist-put cache :data '(:sentinel cache-was-hit))
+ (let ((second (cj/mcp--read-claude-config path)))
+ (should (equal (plist-get second :data) '(:sentinel cache-was-hit)))))))
+
+(ert-deftest test-ai-mcp-read-claude-config-cache-invalidate-on-mtime-boundary ()
+ "Boundary: changing the file's mtime forces a reparse."
+ (test-ai-mcp--with-fixture path
+ (let* ((first (cj/mcp--read-claude-config path))
+ (cache cj/mcp--config-cache))
+ (should (plist-get first :ok))
+ ;; Poison the cache, then bump mtime; the next read should reparse.
+ (plist-put cache :data '(:sentinel cache-was-hit))
+ (set-file-times path (time-add (current-time) 2))
+ ;; Update the cache var since set-file-times changed file mtime.
+ (setq cj/mcp--config-cache cache)
+ (let ((second (cj/mcp--read-claude-config path)))
+ ;; Real reparse should give us the real data, not the sentinel.
+ (should (plist-get (plist-get second :data) :mcpServers))))))
+
+(ert-deftest test-ai-mcp-read-claude-config-missing-mcpservers-boundary ()
+ "Boundary: a valid JSON without :mcpServers parses but the subtree is nil."
+ (let ((cj/mcp--config-cache nil)
+ (tmp (make-temp-file "test-ai-mcp-no-mcp-" nil ".json")))
+ (unwind-protect
+ (progn
+ (with-temp-file tmp (insert "{\"other\": 1}"))
+ (let ((result (cj/mcp--read-claude-config tmp)))
+ (should (plist-get result :ok))
+ (should-not (plist-get (plist-get result :data) :mcpServers))))
+ (delete-file tmp))))
+
+;; -------------------------------------------------------- get-env / get-secret-arg
+
+(ert-deftest test-ai-mcp-get-env-known-server-with-env-normal ()
+ "Normal: env-bearing server returns its env plist."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (env (cj/mcp--get-env "google-calendar")))
+ (should (equal (plist-get env :GOOGLE_OAUTH_CREDENTIALS)
+ test-ai-mcp--sentinel)))))
+
+(ert-deftest test-ai-mcp-get-env-known-server-without-env-boundary ()
+ "Boundary: a server with no env subtree returns nil."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path))
+ (should-not (cj/mcp--get-env "drawio")))))
+
+(ert-deftest test-ai-mcp-get-env-unknown-server-error ()
+ "Error: unknown server returns nil without signaling."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path))
+ (should-not (cj/mcp--get-env "no-such-server")))))
+
+(ert-deftest test-ai-mcp-get-secret-arg-figma-normal ()
+ "Normal: figma's --figma-api-key= value is extracted from args."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (value (cj/mcp--get-secret-arg "figma" "--figma-api-key")))
+ (should (equal value test-ai-mcp--sentinel)))))
+
+(ert-deftest test-ai-mcp-get-secret-arg-missing-flag-error ()
+ "Error: a flag not in the server's args returns nil."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (value (cj/mcp--get-secret-arg "figma" "--no-such-flag")))
+ (should (null value)))))
+
+;; -------------------------------------------------------- build-server-alist
+
+(ert-deftest test-ai-mcp-build-server-alist-all-enabled-normal ()
+ "Normal: with default specs and all-enabled list, alist has all 9 entries."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist)))
+ (should (= (length alist) 9))
+ ;; Every name appears.
+ (dolist (name '("linear" "notion" "figma" "slack-deepsat" "drawio"
+ "google-calendar" "google-docs-personal"
+ "google-docs-work" "google-keep"))
+ (should (assoc name alist))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-filter-by-enabled-boundary ()
+ "Boundary: enabled subset of names produces a subset alist."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs
+ '("drawio" "linear"))))
+ (should (= (length alist) 2))
+ (should (assoc "drawio" alist))
+ (should (assoc "linear" alist))
+ (should-not (assoc "figma" alist)))))
+
+(ert-deftest test-ai-mcp-build-server-alist-stdio-shape-normal ()
+ "Normal: a stdio entry has :type, :command, :args (no :url)."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("drawio"))))
+ (let ((entry (cdr (assoc "drawio" alist))))
+ (should (equal (plist-get entry :type) "stdio"))
+ (should (equal (plist-get entry :command) "npx"))
+ (should (listp (plist-get entry :args)))
+ (should-not (plist-get entry :url))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-http-shape-normal ()
+ "Normal: an http entry has :type and :url (no :command)."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("linear"))))
+ (let ((entry (cdr (assoc "linear" alist))))
+ (should (equal (plist-get entry :type) "http"))
+ (should (equal (plist-get entry :url) "https://mcp.linear.app/mcp"))
+ (should-not (plist-get entry :command))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-sse-shape-normal ()
+ "Normal: an sse entry has :type and :url."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("slack-deepsat"))))
+ (let ((entry (cdr (assoc "slack-deepsat" alist))))
+ (should (equal (plist-get entry :type) "sse"))
+ (should (equal (plist-get entry :url)
+ "http://127.0.0.1:13080/sse"))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-env-merge-normal ()
+ "Normal: env-bearing server has its env plist merged into the entry."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("google-calendar"))))
+ (let* ((entry (cdr (assoc "google-calendar" alist)))
+ (env (plist-get entry :env)))
+ (should env)
+ (should (equal (plist-get env :GOOGLE_OAUTH_CREDENTIALS)
+ test-ai-mcp--sentinel))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-secret-args-splice-normal ()
+ "Normal: figma's --figma-api-key= is spliced into :args from Claude config."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("figma"))))
+ (let* ((entry (cdr (assoc "figma" alist)))
+ (args (plist-get entry :args))
+ (api-arg (cl-find-if
+ (lambda (a) (string-prefix-p "--figma-api-key=" a))
+ args)))
+ (should api-arg)
+ (should (equal api-arg (format "--figma-api-key=%s"
+ test-ai-mcp--sentinel)))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-no-mutation-boundary ()
+ "Boundary: building the alist does not mutate `cj/mcp-server-specs'."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (snapshot (copy-tree cj/mcp-server-specs)))
+ (cj/mcp--build-server-alist)
+ (should (equal cj/mcp-server-specs snapshot)))))
+
+(provide 'test-ai-mcp-helpers)
+;;; test-ai-mcp-helpers.el ends here
diff --git a/archive/gptel/tests/test-ai-quick-ask.el b/archive/gptel/tests/test-ai-quick-ask.el
new file mode 100644
index 000000000..3e1f6460f
--- /dev/null
+++ b/archive/gptel/tests/test-ai-quick-ask.el
@@ -0,0 +1,149 @@
+;;; test-ai-quick-ask.el --- Tests for ai-quick-ask -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the helpers and orchestration in ai-quick-ask.el. The
+;; quick-ask buffer is exercised via `cl-letf' stubs on
+;; `gptel-request' and friends so no network call ever happens.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+(require 'testutil-ai-config)
+;; Stub gptel-request so cj/gptel-quick-ask doesn't try to hit the network.
+(unless (fboundp 'gptel-request)
+ (defun gptel-request (&rest _args) nil))
+
+(require 'ai-quick-ask)
+
+;; The quick-ask escalation reopens *AI-Assistant* through
+;; cj/side-window-display, which reads the panel-width state ai-config owns.
+;; ai-config isn't loaded here (it would pull in gptel), so declare those vars
+;; globally to stand in for it -- a value-less defvar in the module is only
+;; file-local to the byte-compiler, so the function reads them dynamically and
+;; would otherwise hit void-variable.
+(defvar cj/ai-assistant-window-width 0.4)
+(defvar cj/--ai-assistant-width nil)
+
+;; ------------------------------ pure helpers
+
+(ert-deftest test-ai-quick-ask-initial-text-shape ()
+ "Initial text is Q: <prompt> blank line then the response marker."
+ (should (equal (cj/gptel-quick--initial-text "hello?")
+ "Q: hello?\n\nA: ")))
+
+(ert-deftest test-ai-quick-ask-extract-response-normal ()
+ "Extracts text after the response marker."
+ (should (equal (cj/gptel-quick--extract-response "Q: x\n\nA: hello world")
+ "hello world")))
+
+(ert-deftest test-ai-quick-ask-extract-response-multiline ()
+ "Multi-line response is returned in full."
+ (should (equal (cj/gptel-quick--extract-response
+ "Q: x\n\nA: first line\nsecond line\n")
+ "first line\nsecond line\n")))
+
+(ert-deftest test-ai-quick-ask-extract-response-no-marker ()
+ "Buffer without the marker returns nil."
+ (should-not (cj/gptel-quick--extract-response "no marker here")))
+
+(ert-deftest test-ai-quick-ask-extract-response-empty ()
+ "Empty buffer returns nil."
+ (should-not (cj/gptel-quick--extract-response "")))
+
+(ert-deftest test-ai-quick-ask-seed-text-shape ()
+ "Seed text has user heading, prompt, AI heading, response."
+ (let ((seed (cj/gptel-quick--seed-text "ask" "reply")))
+ (should (string-match-p "^\\* .* \\[" seed))
+ (should (string-match-p "ask" seed))
+ (should (string-match-p "^\\* AI" seed))
+ (should (string-match-p "reply" seed))))
+
+(ert-deftest test-ai-quick-ask-seed-text-nil-response ()
+ "Seed text with a nil response leaves an empty body for the AI side."
+ (let ((seed (cj/gptel-quick--seed-text "ask" nil)))
+ (should (string-match-p "^\\* AI" seed))))
+
+;; ------------------------------ ask
+
+(ert-deftest test-ai-quick-ask-creates-buffer ()
+ "Ask creates the *GPTel-Quick* buffer in cj/gptel-quick-mode."
+ (when (get-buffer cj/gptel-quick--buffer-name)
+ (kill-buffer cj/gptel-quick--buffer-name))
+ (let (request-called)
+ (cl-letf (((symbol-function 'gptel-request)
+ (lambda (&rest _) (setq request-called t)))
+ ((symbol-function 'display-buffer)
+ (lambda (&rest _) nil)))
+ (cj/gptel-quick-ask "test prompt")
+ (let ((buf (get-buffer cj/gptel-quick--buffer-name)))
+ (should buf)
+ (with-current-buffer buf
+ (should (eq major-mode 'cj/gptel-quick-mode))
+ (should (equal cj/gptel-quick--prompt "test prompt"))
+ (should (string-match-p "Q: test prompt" (buffer-string))))
+ (kill-buffer buf))
+ (should request-called))))
+
+(ert-deftest test-ai-quick-ask-error-empty-prompt ()
+ "Empty prompt signals."
+ (should-error (cj/gptel-quick-ask "")))
+
+;; ------------------------------ dismiss
+
+(ert-deftest test-ai-quick-ask-dismiss-kills-buffer ()
+ "Dismiss kills the *GPTel-Quick* buffer."
+ (let ((buf (get-buffer-create cj/gptel-quick--buffer-name)))
+ (should (buffer-live-p buf))
+ (cj/gptel-quick-dismiss)
+ (should-not (buffer-live-p buf))))
+
+(ert-deftest test-ai-quick-ask-dismiss-no-op-when-absent ()
+ "Dismiss with no quick buffer is a no-op."
+ (when (get-buffer cj/gptel-quick--buffer-name)
+ (kill-buffer cj/gptel-quick--buffer-name))
+ ;; Should not error
+ (cj/gptel-quick-dismiss))
+
+;; ------------------------------ continue
+
+(ert-deftest test-ai-quick-ask-continue-seeds-ai-assistant ()
+ "Continue seeds *AI-Assistant* with prompt + response and kills quick buffer."
+ (when (get-buffer cj/gptel-quick--buffer-name)
+ (kill-buffer cj/gptel-quick--buffer-name))
+ (when (get-buffer "*AI-Assistant*")
+ (kill-buffer "*AI-Assistant*"))
+ (let ((display-called nil))
+ (cl-letf (((symbol-function 'display-buffer-in-side-window)
+ (lambda (&rest _) (setq display-called t))))
+ ;; Prepare a quick buffer with prompt + response
+ (with-current-buffer (get-buffer-create cj/gptel-quick--buffer-name)
+ (cj/gptel-quick-mode)
+ (let ((inhibit-read-only t))
+ (insert (cj/gptel-quick--initial-text "what is X?"))
+ (insert "X is a thing."))
+ (setq-local cj/gptel-quick--prompt "what is X?")
+ ;; Provide a stub *AI-Assistant* so continue doesn't try to call gptel.
+ (get-buffer-create "*AI-Assistant*")
+ (cj/gptel-quick-continue))
+ (should display-called)
+ ;; *AI-Assistant* got the seed
+ (with-current-buffer "*AI-Assistant*"
+ (let ((body (buffer-string)))
+ (should (string-match-p "what is X?" body))
+ (should (string-match-p "X is a thing\\." body))))
+ ;; Quick buffer was dismissed
+ (should-not (get-buffer cj/gptel-quick--buffer-name))))
+ (kill-buffer "*AI-Assistant*"))
+
+(ert-deftest test-ai-quick-ask-continue-error-outside-quick-buffer ()
+ "Continue signals when called outside a quick-ask buffer."
+ (with-temp-buffer
+ (should-error (cj/gptel-quick-continue))))
+
+(provide 'test-ai-quick-ask)
+;;; test-ai-quick-ask.el ends here
diff --git a/archive/gptel/tests/test-ai-rewrite.el b/archive/gptel/tests/test-ai-rewrite.el
new file mode 100644
index 000000000..ddb831339
--- /dev/null
+++ b/archive/gptel/tests/test-ai-rewrite.el
@@ -0,0 +1,159 @@
+;;; test-ai-rewrite.el --- Tests for ai-rewrite.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the directive-picker wrappers around `gptel-rewrite'.
+;; `gptel-rewrite' itself is stubbed so the tests verify what the
+;; wrappers do (which directive body lands in the hook, which region
+;; was captured) without touching the real rewrite UI.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+(require 'testutil-ai-config)
+
+;; Stub the gptel-rewrite surface so the wrapper can dispatch to it
+;; without loading the real package. testutil-ai-config provides a
+;; non-interactive stub of `gptel-rewrite'; we override it with an
+;; interactive recorder that captures the hook-derived directive body
+;; and the active region.
+(defvar gptel-rewrite-directives-hook nil)
+(defvar test-ai-rewrite--captured-directive nil
+ "Last system-message body produced by the hook during a stub rewrite.")
+(defvar test-ai-rewrite--captured-region nil
+ "Cons (BEG . END) captured from `mark' and `point' at stub-rewrite time.")
+(defun gptel-rewrite ()
+ "Stub: capture the directive body and the active region."
+ (interactive)
+ (setq test-ai-rewrite--captured-directive
+ (run-hook-with-args-until-success 'gptel-rewrite-directives-hook))
+ (setq test-ai-rewrite--captured-region
+ (cons (region-beginning) (region-end))))
+
+(require 'ai-rewrite)
+
+;; ---------------------------- defcustom shape
+
+(ert-deftest test-ai-rewrite-directives-defcustom-has-named-entries ()
+ "Default directives include the names called out in the spec."
+ (let ((names (mapcar #'car cj/gptel-rewrite-directives)))
+ (dolist (expected '("terse" "fix-grammar" "refactor-readability"
+ "add-docstring" "explain-as-comment" "shorten"))
+ (should (member expected names)))))
+
+(ert-deftest test-ai-rewrite-directives-bodies-are-strings ()
+ "Every directive body is a non-empty string."
+ (dolist (entry cj/gptel-rewrite-directives)
+ (should (stringp (cdr entry)))
+ (should (> (length (cdr entry)) 0))))
+
+;; ---------------------------- with-directive
+
+(ert-deftest test-ai-rewrite-with-directive-normal ()
+ "Wrapper injects the directive body and runs gptel-rewrite on the region."
+ (with-temp-buffer
+ (insert "first body line\nsecond body line\n")
+ (let ((test-ai-rewrite--captured-directive nil)
+ (test-ai-rewrite--captured-region nil)
+ (cj/gptel-rewrite-directives
+ '(("test" . "BODY FOR TEST DIRECTIVE"))))
+ ;; Activate the region across both lines
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (cj/gptel-rewrite-with-directive "test")
+ (should (equal test-ai-rewrite--captured-directive
+ "BODY FOR TEST DIRECTIVE"))
+ (should test-ai-rewrite--captured-region))))
+
+(ert-deftest test-ai-rewrite-with-directive-error-no-region ()
+ "No active region signals."
+ (with-temp-buffer
+ (insert "no region")
+ (deactivate-mark)
+ (should-error (call-interactively #'cj/gptel-rewrite-with-directive))))
+
+(ert-deftest test-ai-rewrite-with-directive-error-unknown-directive ()
+ "Unknown directive name signals."
+ (with-temp-buffer
+ (insert "body")
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (let ((cj/gptel-rewrite-directives '(("known" . "x"))))
+ (should-error
+ (cj/gptel-rewrite--call-with-directive
+ "unknown" (point-min) (point-max))))))
+
+(ert-deftest test-ai-rewrite-with-directive-records-last-state ()
+ "Wrapper records the region and directive name for later redo."
+ (with-temp-buffer
+ (insert "abc\ndef\n")
+ (let ((cj/gptel-rewrite-directives
+ '(("first" . "FIRST BODY")))
+ (test-ai-rewrite--captured-directive nil))
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (cj/gptel-rewrite-with-directive "first")
+ (should (equal cj/gptel-rewrite--last-directive "first"))
+ (should (consp cj/gptel-rewrite--last-region))
+ (should (markerp (car cj/gptel-rewrite--last-region)))
+ (should (markerp (cdr cj/gptel-rewrite--last-region))))))
+
+;; ---------------------------- redo
+
+(ert-deftest test-ai-rewrite-redo-normal ()
+ "Redo replays the last region with a new directive."
+ (with-temp-buffer
+ (insert "line1\nline2\nline3\n")
+ (let* ((cj/gptel-rewrite-directives
+ '(("first" . "FIRST BODY")
+ ("second" . "SECOND BODY")))
+ (test-ai-rewrite--captured-directive nil)
+ (test-ai-rewrite--captured-region nil))
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (cj/gptel-rewrite-with-directive "first")
+ (should (equal test-ai-rewrite--captured-directive "FIRST BODY"))
+ (let ((first-region test-ai-rewrite--captured-region))
+ (setq test-ai-rewrite--captured-directive nil)
+ (setq test-ai-rewrite--captured-region nil)
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p choices &rest _) (car choices))))
+ (cj/gptel-rewrite-redo-with-different-directive))
+ (should (equal test-ai-rewrite--captured-directive "SECOND BODY"))
+ (should (equal test-ai-rewrite--captured-region first-region))))))
+
+(ert-deftest test-ai-rewrite-redo-error-no-previous ()
+ "Redo without prior rewrite signals."
+ (with-temp-buffer
+ (setq-local cj/gptel-rewrite--last-region nil)
+ (should-error (cj/gptel-rewrite-redo-with-different-directive))))
+
+(ert-deftest test-ai-rewrite-redo-excludes-current-directive ()
+ "Redo's completing-read prompt offers every directive except the last."
+ (with-temp-buffer
+ (insert "body")
+ (let ((cj/gptel-rewrite-directives
+ '(("a" . "A") ("b" . "B") ("c" . "C")))
+ (offered nil))
+ (set-mark (point-min))
+ (goto-char (point-max))
+ (activate-mark)
+ (cj/gptel-rewrite-with-directive "b")
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_p choices &rest _)
+ (setq offered choices)
+ (car choices))))
+ (cj/gptel-rewrite-redo-with-different-directive))
+ (should (equal (sort (copy-sequence offered) #'string<)
+ '("a" "c"))))))
+
+(provide 'test-ai-rewrite)
+;;; test-ai-rewrite.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-git-diff.el b/archive/gptel/tests/test-gptel-tools-git-diff.el
new file mode 100644
index 000000000..114fec293
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-git-diff.el
@@ -0,0 +1,163 @@
+;;; test-gptel-tools-git-diff.el --- Tests for git_diff gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests run against real temp git repos under HOME via `process-file'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'git_diff)
+
+;; ---------- helpers
+
+(defun test-gptel-tools-git-diff--with-repo (fn)
+ "Create a temp git repo under HOME with one committed file, call FN."
+ (let* ((name (format ".test-gptel-tools-git-diff-%s"
+ (format-time-string "%s%N")))
+ (dir (expand-file-name name "~")))
+ (unwind-protect
+ (progn
+ (make-directory dir)
+ (let ((default-directory dir))
+ (call-process "git" nil nil nil "init" "--quiet")
+ (call-process "git" nil nil nil "config" "user.email" "test@x")
+ (call-process "git" nil nil nil "config" "user.name" "Test")
+ (with-temp-file (expand-file-name "f.txt" dir)
+ (insert "original\n"))
+ (call-process "git" nil nil nil "add" "f.txt")
+ (call-process "git" nil nil nil "commit" "--quiet" "-m" "initial"))
+ (funcall fn dir))
+ (when (file-exists-p dir) (delete-directory dir t)))))
+
+;; ---------- build-args
+
+(ert-deftest test-gptel-tools-git-diff-build-args-no-refs ()
+ "Normal: no refs / no file → bare diff args."
+ (should (equal (cj/gptel-git-diff--build-args nil nil nil)
+ '("-c" "color.ui=false" "diff"))))
+
+(ert-deftest test-gptel-tools-git-diff-build-args-with-ref1 ()
+ "Normal: REF1 appended."
+ (should (equal (cj/gptel-git-diff--build-args "HEAD~1" nil nil)
+ '("-c" "color.ui=false" "diff" "HEAD~1"))))
+
+(ert-deftest test-gptel-tools-git-diff-build-args-with-both-refs ()
+ "Normal: REF1 and REF2 both appended."
+ (should (equal (cj/gptel-git-diff--build-args "HEAD~1" "HEAD" nil)
+ '("-c" "color.ui=false" "diff" "HEAD~1" "HEAD"))))
+
+(ert-deftest test-gptel-tools-git-diff-build-args-with-file ()
+ "Normal: FILE appended after `--'."
+ (should (equal (cj/gptel-git-diff--build-args nil nil "foo.txt")
+ '("-c" "color.ui=false" "diff" "--" "foo.txt"))))
+
+(ert-deftest test-gptel-tools-git-diff-build-args-boundary-empty-strings ()
+ "Boundary: empty-string REF/FILE values are ignored."
+ (should (equal (cj/gptel-git-diff--build-args "" "" "")
+ '("-c" "color.ui=false" "diff"))))
+
+;; ---------- truncate
+
+(ert-deftest test-gptel-tools-git-diff-truncate-under-cap ()
+ "Normal: short input returns unchanged."
+ (should (equal (cj/gptel-git-diff--truncate "small diff") "small diff")))
+
+(ert-deftest test-gptel-tools-git-diff-truncate-over-cap ()
+ "Boundary: output exceeding the cap is truncated with a marker."
+ (let* ((cap cj/gptel-git-diff--max-output-bytes)
+ (huge (make-string (+ cap 1000) ?x))
+ (out (cj/gptel-git-diff--truncate huge)))
+ (should (string-match-p "\\[truncated:" out))
+ (should (> (length huge) (length out)))))
+
+;; ---------- validate-path
+
+(ert-deftest test-gptel-tools-git-diff-validate-path-normal ()
+ "Normal: validator accepts a git working tree."
+ (test-gptel-tools-git-diff--with-repo
+ (lambda (dir)
+ (should (equal (cj/gptel-git-diff--validate-path dir) dir)))))
+
+(ert-deftest test-gptel-tools-git-diff-validate-path-error-outside-home ()
+ "Error: path outside HOME signals."
+ (should-error (cj/gptel-git-diff--validate-path "/etc")))
+
+(ert-deftest test-gptel-tools-git-diff-validate-path-error-not-a-repo ()
+ "Error: non-git directory signals."
+ (let ((dir (make-temp-file
+ (expand-file-name ".test-gptel-tools-git-diff-" "~") t)))
+ (unwind-protect
+ (should-error (cj/gptel-git-diff--validate-path dir))
+ (when (file-exists-p dir) (delete-directory dir t)))))
+
+(ert-deftest test-gptel-tools-git-diff-validate-path-error-not-a-directory ()
+ "Error: file paths are rejected."
+ (let ((file (make-temp-file
+ (expand-file-name ".test-gptel-tools-git-diff-file-" "~"))))
+ (unwind-protect
+ (should-error (cj/gptel-git-diff--validate-path file))
+ (when (file-exists-p file) (delete-file file)))))
+
+(ert-deftest test-gptel-tools-git-diff-validate-path-error-symlink-outside-home ()
+ "Error: symlinked directories resolving outside HOME are rejected."
+ (let ((link (expand-file-name
+ (format ".test-gptel-tools-git-diff-link-%s"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link "/tmp" link t)
+ (should-error (cj/gptel-git-diff--validate-path link)))
+ (when (file-symlink-p link) (delete-file link)))))
+
+;; ---------- run
+
+(ert-deftest test-gptel-tools-git-diff-run-no-changes ()
+ "Boundary: a clean tree with no refs returns the no-diff marker."
+ (test-gptel-tools-git-diff--with-repo
+ (lambda (dir)
+ (let ((out (cj/gptel-git-diff--run dir)))
+ (should (string-match-p "No diff" out))))))
+
+(ert-deftest test-gptel-tools-git-diff-run-unstaged-change ()
+ "Normal: an unstaged edit appears as a real diff."
+ (test-gptel-tools-git-diff--with-repo
+ (lambda (dir)
+ (with-temp-file (expand-file-name "f.txt" dir)
+ (insert "changed\n"))
+ (let ((out (cj/gptel-git-diff--run dir)))
+ (should (string-match-p "^-original" out))
+ (should (string-match-p "^\\+changed" out))))))
+
+(ert-deftest test-gptel-tools-git-diff-run-narrow-to-file ()
+ "Normal: FILE argument narrows the diff."
+ (test-gptel-tools-git-diff--with-repo
+ (lambda (dir)
+ (with-temp-file (expand-file-name "f.txt" dir)
+ (insert "changed\n"))
+ (with-temp-file (expand-file-name "g.txt" dir)
+ (insert "second file\n"))
+ (let ((out (cj/gptel-git-diff--run dir nil nil "f.txt")))
+ (should (string-match-p "f.txt" out))
+ (should-not (string-match-p "g.txt" out))))))
+
+(ert-deftest test-gptel-tools-git-diff-run-error-on-bad-ref ()
+ "Error: git diff exits other than 0/1 are surfaced."
+ (test-gptel-tools-git-diff--with-repo
+ (lambda (dir)
+ (should-error (cj/gptel-git-diff--run dir "does-not-exist")))))
+
+(provide 'test-gptel-tools-git-diff)
+;;; test-gptel-tools-git-diff.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-git-log.el b/archive/gptel/tests/test-gptel-tools-git-log.el
new file mode 100644
index 000000000..c0503039a
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-git-log.el
@@ -0,0 +1,183 @@
+;;; test-gptel-tools-git-log.el --- Tests for git_log gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests run against real temp git repos under HOME via `process-file'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'git_log)
+
+;; ---------- helpers
+
+(defun test-gptel-tools-git-log--with-repo (commit-count fn)
+ "Create a temp git repo under HOME with COMMIT-COUNT empty commits.
+Call FN with the absolute path, clean up after."
+ (let* ((name (format ".test-gptel-tools-git-log-%s"
+ (format-time-string "%s%N")))
+ (dir (expand-file-name name "~")))
+ (unwind-protect
+ (progn
+ (make-directory dir)
+ (let ((default-directory dir))
+ (call-process "git" nil nil nil "init" "--quiet")
+ (call-process "git" nil nil nil "config" "user.email" "test@x")
+ (call-process "git" nil nil nil "config" "user.name" "Test")
+ (dotimes (i commit-count)
+ (let ((process-environment
+ (append
+ (list "GIT_AUTHOR_DATE=2000-01-01T00:00:00+0000"
+ "GIT_COMMITTER_DATE=2000-01-01T00:00:00+0000")
+ process-environment)))
+ (call-process "git" nil nil nil "commit" "--allow-empty"
+ "--quiet" "-m" (format "commit %d" i)))))
+ (funcall fn dir))
+ (when (file-exists-p dir) (delete-directory dir t)))))
+
+;; ---------- effective-count
+
+(ert-deftest test-gptel-tools-git-log-effective-count-defaults-on-nil ()
+ "Boundary: nil N → default count."
+ (should (= (cj/gptel-git-log--effective-count nil)
+ cj/gptel-git-log--default-count)))
+
+(ert-deftest test-gptel-tools-git-log-effective-count-defaults-on-non-integer ()
+ "Boundary: non-integer N → default count."
+ (should (= (cj/gptel-git-log--effective-count "ten")
+ cj/gptel-git-log--default-count))
+ (should (= (cj/gptel-git-log--effective-count 0.5)
+ cj/gptel-git-log--default-count)))
+
+(ert-deftest test-gptel-tools-git-log-effective-count-clamps-low ()
+ "Boundary: N below 1 → default count."
+ (should (= (cj/gptel-git-log--effective-count 0)
+ cj/gptel-git-log--default-count))
+ (should (= (cj/gptel-git-log--effective-count -5)
+ cj/gptel-git-log--default-count)))
+
+(ert-deftest test-gptel-tools-git-log-effective-count-caps-high ()
+ "Boundary: N above max → max."
+ (should (= (cj/gptel-git-log--effective-count 1000)
+ cj/gptel-git-log--max-count)))
+
+(ert-deftest test-gptel-tools-git-log-effective-count-normal ()
+ "Normal: a valid N passes through."
+ (should (= (cj/gptel-git-log--effective-count 5) 5)))
+
+;; ---------- validate-path
+
+(ert-deftest test-gptel-tools-git-log-validate-path-normal ()
+ "Normal: validator accepts a git working tree."
+ (test-gptel-tools-git-log--with-repo
+ 1
+ (lambda (dir)
+ (should (equal (cj/gptel-git-log--validate-path dir) dir)))))
+
+(ert-deftest test-gptel-tools-git-log-validate-path-error-outside-home ()
+ "Error: path outside HOME signals."
+ (should-error (cj/gptel-git-log--validate-path "/etc")))
+
+(ert-deftest test-gptel-tools-git-log-validate-path-error-not-a-repo ()
+ "Error: directory outside any git working tree signals."
+ (let ((dir (make-temp-file
+ (expand-file-name ".test-gptel-tools-git-log-" "~") t)))
+ (unwind-protect
+ (should-error (cj/gptel-git-log--validate-path dir))
+ (when (file-exists-p dir) (delete-directory dir t)))))
+
+(ert-deftest test-gptel-tools-git-log-validate-path-error-not-a-directory ()
+ "Error: file paths are rejected."
+ (let ((file (make-temp-file
+ (expand-file-name ".test-gptel-tools-git-log-file-" "~"))))
+ (unwind-protect
+ (should-error (cj/gptel-git-log--validate-path file))
+ (when (file-exists-p file) (delete-file file)))))
+
+(ert-deftest test-gptel-tools-git-log-validate-path-error-symlink-outside-home ()
+ "Error: symlinked directories resolving outside HOME are rejected."
+ (let ((link (expand-file-name
+ (format ".test-gptel-tools-git-log-link-%s"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link "/tmp" link t)
+ (should-error (cj/gptel-git-log--validate-path link)))
+ (when (file-symlink-p link) (delete-file link)))))
+
+;; ---------- run
+
+(ert-deftest test-gptel-tools-git-log-run-default-count ()
+ "Normal: default count limits output to that many commits."
+ (test-gptel-tools-git-log--with-repo
+ 30
+ (lambda (dir)
+ (let* ((out (cj/gptel-git-log--run dir))
+ (lines (split-string (string-trim out) "\n")))
+ (should (= (length lines) cj/gptel-git-log--default-count))))))
+
+(ert-deftest test-gptel-tools-git-log-run-honors-n ()
+ "Normal: an explicit N limits output to N commits."
+ (test-gptel-tools-git-log--with-repo
+ 10
+ (lambda (dir)
+ (let* ((out (cj/gptel-git-log--run dir 3))
+ (lines (split-string (string-trim out) "\n")))
+ (should (= (length lines) 3))))))
+
+(ert-deftest test-gptel-tools-git-log-run-since-no-match ()
+ "Boundary: --since filter with no matching commits returns marker."
+ (test-gptel-tools-git-log--with-repo
+ 1
+ (lambda (dir)
+ (let ((out (cj/gptel-git-log--run dir 10 "2001-01-01")))
+ (should (string-match-p "No commits" out))))))
+
+(ert-deftest test-gptel-tools-git-log-run-error-on-git-log-failure ()
+ "Error: non-zero git log exits are surfaced."
+ (test-gptel-tools-git-log--with-repo
+ 1
+ (lambda (dir)
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (program infile destination display &rest args)
+ (if (member "log" args)
+ (progn
+ (when (bufferp destination)
+ (with-current-buffer destination (insert "bad log")))
+ 2)
+ (apply #'call-process program infile destination display args)))))
+ (should-error (cj/gptel-git-log--run dir))))))
+
+(ert-deftest test-gptel-tools-git-log-run-empty-repo ()
+ "Boundary: a repo with no commits returns the empty-result marker."
+ (let* ((name (format ".test-gptel-tools-git-log-empty-%s"
+ (format-time-string "%s%N")))
+ (dir (expand-file-name name "~")))
+ (unwind-protect
+ (progn
+ (make-directory dir)
+ (let ((default-directory dir))
+ (call-process "git" nil nil nil "init" "--quiet"))
+ ;; git log on a no-commits repo errors in some versions, but
+ ;; our wrapper turns "no commits" into the no-match marker.
+ (let ((res (ignore-errors (cj/gptel-git-log--run dir))))
+ ;; Either path is acceptable: error captured (nil) or the
+ ;; explicit "No commits matching" marker.
+ (should (or (null res)
+ (string-match-p "No commits" res)))))
+ (when (file-exists-p dir) (delete-directory dir t)))))
+
+(provide 'test-gptel-tools-git-log)
+;;; test-gptel-tools-git-log.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-git-status.el b/archive/gptel/tests/test-gptel-tools-git-status.el
new file mode 100644
index 000000000..471938535
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-git-status.el
@@ -0,0 +1,124 @@
+;;; test-gptel-tools-git-status.el --- Tests for git_status gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests run against real temp git repos under HOME via `process-file'.
+;; The tool is read-only so repos are torn down per test.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'git_status)
+
+;; ---------- helpers
+
+(defun test-gptel-tools-git-status--with-repo (fn)
+ "Create a temp git repo under HOME, call FN with its absolute path, clean up."
+ (let* ((name (format ".test-gptel-tools-git-status-%s"
+ (format-time-string "%s%N")))
+ (dir (expand-file-name name "~")))
+ (unwind-protect
+ (progn
+ (make-directory dir)
+ (let ((default-directory dir))
+ (call-process "git" nil nil nil "init" "--quiet")
+ (call-process "git" nil nil nil "config" "user.email" "test@x")
+ (call-process "git" nil nil nil "config" "user.name" "Test")
+ (call-process "git" nil nil nil "commit" "--allow-empty"
+ "--quiet" "-m" "initial"))
+ (funcall fn dir))
+ (when (file-exists-p dir) (delete-directory dir t)))))
+
+;; ---------- validate-path
+
+(ert-deftest test-gptel-tools-git-status-validate-path-normal ()
+ "Normal: validator accepts a directory inside a git working tree."
+ (test-gptel-tools-git-status--with-repo
+ (lambda (dir)
+ (should (equal (cj/gptel-git-status--validate-path dir) dir)))))
+
+(ert-deftest test-gptel-tools-git-status-validate-path-error-outside-home ()
+ "Error: path outside HOME signals."
+ (should-error (cj/gptel-git-status--validate-path "/etc")))
+
+(ert-deftest test-gptel-tools-git-status-validate-path-error-not-a-directory ()
+ "Error: path that's not a directory signals."
+ (let ((file (make-temp-file
+ (expand-file-name ".test-gptel-tools-git-status-" "~"))))
+ (unwind-protect
+ (should-error (cj/gptel-git-status--validate-path file))
+ (when (file-exists-p file) (delete-file file)))))
+
+(ert-deftest test-gptel-tools-git-status-validate-path-error-not-a-repo ()
+ "Error: directory outside any git working tree signals."
+ (let ((dir (make-temp-file
+ (expand-file-name ".test-gptel-tools-git-status-" "~") t)))
+ (unwind-protect
+ (should-error (cj/gptel-git-status--validate-path dir))
+ (when (file-exists-p dir) (delete-directory dir t)))))
+
+(ert-deftest test-gptel-tools-git-status-validate-path-error-symlink-outside-home ()
+ "Error: symlinked directories resolving outside HOME are rejected."
+ (let ((link (expand-file-name
+ (format ".test-gptel-tools-git-status-link-%s"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link "/tmp" link t)
+ (should-error (cj/gptel-git-status--validate-path link)))
+ (when (file-symlink-p link) (delete-file link)))))
+
+;; ---------- run
+
+(ert-deftest test-gptel-tools-git-status-run-clean-tree ()
+ "Normal: a clean repo returns the clean-tree marker."
+ (test-gptel-tools-git-status--with-repo
+ (lambda (dir)
+ (let ((out (cj/gptel-git-status--run dir)))
+ (should (string-match-p "Clean working tree" out))))))
+
+(ert-deftest test-gptel-tools-git-status-run-dirty-tree-includes-file ()
+ "Normal: an untracked file appears in the output."
+ (test-gptel-tools-git-status--with-repo
+ (lambda (dir)
+ (with-temp-file (expand-file-name "new.txt" dir) (insert "x"))
+ (let ((out (cj/gptel-git-status--run dir)))
+ (should (string-match-p "new.txt" out))
+ (should (string-match-p "^\\?\\?" out))))))
+
+(ert-deftest test-gptel-tools-git-status-run-includes-branch ()
+ "Normal: the `--branch' line surfaces in the output."
+ (test-gptel-tools-git-status--with-repo
+ (lambda (dir)
+ (with-temp-file (expand-file-name "f.txt" dir) (insert "x"))
+ (let ((out (cj/gptel-git-status--run dir)))
+ (should (string-match-p "^## " out))))))
+
+(ert-deftest test-gptel-tools-git-status-run-error-on-git-status-failure ()
+ "Error: non-zero git status exits are surfaced."
+ (test-gptel-tools-git-status--with-repo
+ (lambda (dir)
+ (cl-letf (((symbol-function 'process-file)
+ (lambda (program infile destination display &rest args)
+ (if (member "status" args)
+ (progn
+ (when (bufferp destination)
+ (with-current-buffer destination (insert "bad status")))
+ 2)
+ (apply #'call-process program infile destination display args)))))
+ (should-error (cj/gptel-git-status--run dir))))))
+
+(provide 'test-gptel-tools-git-status)
+;;; test-gptel-tools-git-status.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-list-directory-files.el b/archive/gptel/tests/test-gptel-tools-list-directory-files.el
new file mode 100644
index 000000000..9588ce8be
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-list-directory-files.el
@@ -0,0 +1,257 @@
+;;; test-gptel-tools-list-directory-files.el --- Tests for list_directory_files -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the helpers in list_directory_files.el.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'list_directory_files)
+
+;; -------------------------- helpers
+
+(defun test-gptel-tools-list--with-tree (fn)
+ "Create a small directory tree, call FN with its root, clean up."
+ (let ((root (make-temp-file "test-gptel-tools-list-" t)))
+ (unwind-protect
+ (progn
+ (with-temp-file (expand-file-name "a.txt" root) (insert "a"))
+ (with-temp-file (expand-file-name "b.org" root) (insert "b"))
+ (make-directory (expand-file-name "sub" root))
+ (with-temp-file (expand-file-name "sub/c.txt" root) (insert "c"))
+ (funcall fn root))
+ (delete-directory root t))))
+
+;; -------------------------- mode-to-permissions
+
+(ert-deftest test-gptel-tools-list-mode-to-permissions-regular-file ()
+ "Mode 0644 on a regular file: -rw-r--r--."
+ (should (equal (list-directory-files--mode-to-permissions #o0644)
+ "-rw-r--r--")))
+
+(ert-deftest test-gptel-tools-list-mode-to-permissions-directory ()
+ "Mode 0755 + dir bit: drwxr-xr-x."
+ (should (equal (list-directory-files--mode-to-permissions
+ (logior #o40000 #o0755))
+ "drwxr-xr-x")))
+
+(ert-deftest test-gptel-tools-list-mode-to-permissions-executable ()
+ "Mode 0700: -rwx------."
+ (should (equal (list-directory-files--mode-to-permissions #o0700)
+ "-rwx------")))
+
+;; -------------------------- get-file-info
+
+(ert-deftest test-gptel-tools-list-get-file-info-success ()
+ "Success: returns a plist with :success t and metadata."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (let ((info (list-directory-files--get-file-info
+ (expand-file-name "a.txt" root))))
+ (should (plist-get info :success))
+ (should (numberp (plist-get info :size)))
+ (should (stringp (plist-get info :permissions)))))))
+
+(ert-deftest test-gptel-tools-list-get-file-info-directory ()
+ "Directory info: :is-directory is t."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (let ((info (list-directory-files--get-file-info
+ (expand-file-name "sub" root))))
+ (should (plist-get info :is-directory))))))
+
+(ert-deftest test-gptel-tools-list-get-file-info-error ()
+ "Error: metadata failures are returned as failed info plists."
+ (cl-letf (((symbol-function 'file-attributes)
+ (lambda (&rest _args) (error "stat failed"))))
+ (let ((info (list-directory-files--get-file-info "/tmp/nope")))
+ (should-not (plist-get info :success))
+ (should (string-match-p "stat failed" (plist-get info :error))))))
+
+;; -------------------------- filter-by-extension
+
+(ert-deftest test-gptel-tools-list-filter-by-extension-keeps-match ()
+ "Filter for txt keeps txt files."
+ (let* ((filter (list-directory-files--filter-by-extension "txt"))
+ (info '(:success t :path "/x/foo.txt" :is-directory nil)))
+ (should (funcall filter info))))
+
+(ert-deftest test-gptel-tools-list-filter-by-extension-drops-non-match ()
+ "Filter for txt drops non-txt files."
+ (let* ((filter (list-directory-files--filter-by-extension "txt"))
+ (info '(:success t :path "/x/foo.org" :is-directory nil)))
+ (should-not (funcall filter info))))
+
+(ert-deftest test-gptel-tools-list-filter-by-extension-always-keeps-directories ()
+ "Filter keeps directories regardless of extension."
+ (let* ((filter (list-directory-files--filter-by-extension "txt"))
+ (info '(:success t :path "/x/sub" :is-directory t)))
+ (should (funcall filter info))))
+
+(ert-deftest test-gptel-tools-list-filter-by-extension-no-extension-is-nil ()
+ "No extension produces a nil filter (i.e. no filtering)."
+ (should-not (list-directory-files--filter-by-extension nil)))
+
+(ert-deftest test-gptel-tools-list-filter-by-extension-case-insensitive ()
+ "Boundary: extension filtering is case-insensitive."
+ (let* ((filter (list-directory-files--filter-by-extension "txt"))
+ (info '(:success t :path "/x/FOO.TXT" :is-directory nil)))
+ (should (funcall filter info))))
+
+(ert-deftest test-gptel-tools-list-filter-by-extension-drops-failed-file-info ()
+ "Boundary: failed file info entries do not pass file extension filters."
+ (let* ((filter (list-directory-files--filter-by-extension "txt"))
+ (info '(:success nil :path "/x/foo.txt" :is-directory nil)))
+ (should-not (funcall filter info))))
+
+;; -------------------------- format-file-entry
+
+(ert-deftest test-gptel-tools-list-format-file-entry-shape ()
+ "Formatted entry contains permissions, size, mtime, and relative path."
+ (let* ((info (list (cons :path "/home/u/foo.txt")
+ (cons :permissions "-rw-r--r--")
+ (cons :executable nil)
+ (cons :size 42)
+ (cons :last-modified (current-time))))
+ ;; Build as plist by flattening the cons list.
+ (info-plist (cl-loop for (k . v) in info append (list k v)))
+ (out (list-directory-files--format-file-entry info-plist "/home/u")))
+ (should (string-match-p "-rw-r--r--" out))
+ (should (string-match-p "foo.txt" out))))
+
+;; -------------------------- list-directory
+
+(ert-deftest test-gptel-tools-list-list-directory-flat ()
+ "Non-recursive listing returns only entries in the top level."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (let* ((result (list-directory-files--list-directory root nil nil))
+ (files (plist-get result :files)))
+ (should files)
+ (let ((paths (mapcar (lambda (i) (plist-get i :path)) files)))
+ (should (cl-some (lambda (p) (string-match-p "/a\\.txt\\'" p)) paths))
+ (should-not (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths)))))))
+
+(ert-deftest test-gptel-tools-list-list-directory-recursive ()
+ "Recursive listing also returns sub-directory contents."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (let* ((result (list-directory-files--list-directory root t nil))
+ (files (plist-get result :files))
+ (paths (mapcar (lambda (i) (plist-get i :path)) files)))
+ (should (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths))))))
+
+(ert-deftest test-gptel-tools-list-list-directory-max-depth ()
+ "Boundary: max-depth limits recursive traversal."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (let* ((result (list-directory-files--list-directory root t nil 0))
+ (files (plist-get result :files))
+ (paths (mapcar (lambda (i) (plist-get i :path)) files)))
+ (should-not (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths))))))
+
+(ert-deftest test-gptel-tools-list-list-directory-filtered-recursive-keeps-matching-files ()
+ "Normal: recursive extension filter returns matching nested files."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (let* ((filter (list-directory-files--filter-by-extension "txt"))
+ (result (list-directory-files--list-directory root t filter))
+ (files (plist-get result :files))
+ (paths (mapcar (lambda (i) (plist-get i :path)) files)))
+ (should (cl-some (lambda (p) (string-match-p "/a\\.txt\\'" p)) paths))
+ (should (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths))
+ (should-not (cl-some (lambda (p) (string-match-p "/b\\.org\\'" p)) paths))))))
+
+(ert-deftest test-gptel-tools-list-list-directory-records-entry-errors ()
+ "Error: per-entry metadata failures are collected."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (cl-letf (((symbol-function 'list-directory-files--get-file-info)
+ (lambda (path)
+ (if (string-match-p "/a\\.txt\\'" path)
+ (list :success nil :path path :error "denied")
+ (let* ((attrs (file-attributes path 'string))
+ (dirp (eq t (file-attribute-type attrs))))
+ (list :success t
+ :path path
+ :size 0
+ :last-modified (current-time)
+ :is-directory dirp
+ :permissions "-rw-r--r--"
+ :executable nil))))))
+ (let ((errors (plist-get (list-directory-files--list-directory root nil nil)
+ :errors)))
+ (should errors)
+ (should (string-match-p "denied" (car errors))))))))
+
+(ert-deftest test-gptel-tools-list-list-directory-error-not-a-directory ()
+ "Non-directory path returns errors entry."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (let* ((result (list-directory-files--list-directory
+ (expand-file-name "a.txt" root) nil nil))
+ (errors (plist-get result :errors)))
+ (should errors)))))
+
+(ert-deftest test-gptel-tools-list-list-directory-error-accessing-directory ()
+ "Error: directory access failures are collected."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (cl-letf (((symbol-function 'directory-files)
+ (lambda (&rest _args) (error "cannot list"))))
+ (let ((errors (plist-get (list-directory-files--list-directory root nil nil)
+ :errors)))
+ (should errors)
+ (should (string-match-p "cannot list" (car errors))))))))
+
+;; -------------------------- format-output
+
+(ert-deftest test-gptel-tools-list-format-output-has-files-section ()
+ "Format-output includes a \"Found N file(s)\" line when files present."
+ (test-gptel-tools-list--with-tree
+ (lambda (root)
+ (let* ((result (list-directory-files--list-directory root nil nil))
+ (out (list-directory-files--format-output root result)))
+ (should (string-match-p "Found [0-9]+ file" out))))))
+
+(ert-deftest test-gptel-tools-list-format-output-empty ()
+ "Empty result: \"No files found\"."
+ (let ((out (list-directory-files--format-output
+ "/nowhere" '(:files nil :errors nil))))
+ (should (string-match-p "No files found" out))))
+
+(ert-deftest test-gptel-tools-list-format-output-errors-only ()
+ "Format-output includes errors when no files are present."
+ (let ((out (list-directory-files--format-output
+ "/nowhere" '(:files nil :errors ("boom")))))
+ (should (string-match-p "Errors encountered" out))
+ (should (string-match-p "boom" out))))
+
+(ert-deftest test-gptel-tools-list-format-output-files-and-errors ()
+ "Format-output separates file listings and errors."
+ (let* ((info (list :success t
+ :path (expand-file-name "foo.txt" "~")
+ :size 1
+ :last-modified (current-time)
+ :is-directory nil
+ :permissions "-rw-r--r--"
+ :executable nil))
+ (out (list-directory-files--format-output
+ "~" (list :files (list info) :errors (list "boom")))))
+ (should (string-match-p "Found 1 file" out))
+ (should (string-match-p "Errors encountered" out))))
+
+(provide 'test-gptel-tools-list-directory-files)
+;;; test-gptel-tools-list-directory-files.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-move-to-trash.el b/archive/gptel/tests/test-gptel-tools-move-to-trash.el
new file mode 100644
index 000000000..77f886277
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-move-to-trash.el
@@ -0,0 +1,219 @@
+;;; test-gptel-tools-move-to-trash.el --- Tests for move_to_trash gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the helpers in move_to_trash.el.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'move_to_trash)
+
+;; -------------------------- helpers
+
+(defun test-gptel-tools-trash--with-tmp-tree (fn)
+ "Create a temp source dir and trash dir; run FN with both; clean up."
+ (let* ((src (make-temp-file "test-gptel-tools-trash-src-" t))
+ (trash (make-temp-file "test-gptel-tools-trash-dst-" t)))
+ (unwind-protect
+ (funcall fn src trash)
+ (when (file-exists-p src) (delete-directory src t))
+ (when (file-exists-p trash) (delete-directory trash t)))))
+
+;; -------------------------- generate-unique-name
+
+(ert-deftest test-gptel-tools-trash-generate-unique-name-no-conflict ()
+ "No conflict: returns the plain base name in trash."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (_src trash)
+ (let ((out (gptel--move-to-trash-generate-unique-name
+ "/anywhere/foo.txt" trash)))
+ (should (equal (file-name-nondirectory out) "foo.txt"))))))
+
+(ert-deftest test-gptel-tools-trash-generate-unique-name-conflict-timestamps ()
+ "Name conflict: returns a name with a timestamp suffix."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (_src trash)
+ (with-temp-file (expand-file-name "foo.txt" trash) (insert ""))
+ (let* ((out (gptel--move-to-trash-generate-unique-name
+ "/anywhere/foo.txt" trash))
+ (name (file-name-nondirectory out)))
+ (should-not (equal name "foo.txt"))
+ (should (string-match-p "\\`foo-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\.txt\\'"
+ name))))))
+
+(ert-deftest test-gptel-tools-trash-generate-unique-name-no-extension ()
+ "Conflict on a name without extension: timestamp appended to the bare name."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (_src trash)
+ (with-temp-file (expand-file-name "noext" trash) (insert ""))
+ (let* ((out (gptel--move-to-trash-generate-unique-name
+ "/anywhere/noext" trash))
+ (name (file-name-nondirectory out)))
+ (should-not (equal name "noext"))
+ (should (string-match-p "\\`noext-[0-9]" name))))))
+
+;; -------------------------- validate-path
+
+(ert-deftest test-gptel-tools-trash-validate-path-normal-home ()
+ "Normal: an existing path under HOME validates."
+ (let ((path (expand-file-name
+ (format ".test-gptel-tools-trash-home-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (with-temp-file path (insert ""))
+ (should (equal (gptel--move-to-trash-validate-path path)
+ (expand-file-name path))))
+ (when (file-exists-p path) (delete-file path)))))
+
+(ert-deftest test-gptel-tools-trash-validate-path-normal-tmp ()
+ "Normal: an existing path under /tmp validates."
+ (let ((path (make-temp-file "test-gptel-tools-trash-tmpvalidate-")))
+ (unwind-protect
+ (should (equal (gptel--move-to-trash-validate-path path)
+ (expand-file-name path)))
+ (when (file-exists-p path) (delete-file path)))))
+
+(ert-deftest test-gptel-tools-trash-validate-path-error-outside-allowed ()
+ "Error: a path outside HOME or /tmp signals."
+ (should-error (gptel--move-to-trash-validate-path "/etc/hostname")))
+
+(ert-deftest test-gptel-tools-trash-validate-path-error-tmp-prefix-trick ()
+ "Error: paths that merely start with /tmp are not treated as /tmp children."
+ (should-error (gptel--move-to-trash-validate-path "/tmpnotreally/file")))
+
+(ert-deftest test-gptel-tools-trash-validate-path-error-critical-dir ()
+ "Error: critical directories (home root, .emacs.d, .config, /tmp) signal."
+ (should-error (gptel--move-to-trash-validate-path "~"))
+ (should-error (gptel--move-to-trash-validate-path "~/.emacs.d"))
+ (should-error (gptel--move-to-trash-validate-path "~/.config"))
+ (should-error (gptel--move-to-trash-validate-path "/tmp")))
+
+(ert-deftest test-gptel-tools-trash-validate-path-error-missing ()
+ "Error: missing path signals."
+ (let ((path (expand-file-name
+ (format ".test-gptel-tools-trash-missing-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (when (file-exists-p path) (delete-file path))
+ (should-error (gptel--move-to-trash-validate-path path))))
+
+(ert-deftest test-gptel-tools-trash-validate-path-error-symlink-outside-allowed ()
+ "Error: allowed-location symlinks resolving outside allowed roots are rejected."
+ (let ((link (expand-file-name
+ (format ".test-gptel-tools-trash-outside-link-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link "/etc/hostname" link t)
+ (should-error (gptel--move-to-trash-validate-path link)))
+ (when (file-symlink-p link) (delete-file link)))))
+
+;; -------------------------- perform
+
+(ert-deftest test-gptel-tools-trash-perform-moves-file ()
+ "Perform: moves the file out of the source dir into the trash dir."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (src trash)
+ (let ((file (expand-file-name "doomed.txt" src)))
+ (with-temp-file file (insert "trash me"))
+ (let ((status (gptel--move-to-trash-perform file trash)))
+ (should (string-match-p "moved to trash" status))
+ (should-not (file-exists-p file))
+ (should (file-exists-p (expand-file-name "doomed.txt" trash))))))))
+
+(ert-deftest test-gptel-tools-trash-perform-handles-directory ()
+ "Perform: moves a directory as a unit."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (src trash)
+ (let ((dir (expand-file-name "subdir" src)))
+ (make-directory dir)
+ (with-temp-file (expand-file-name "inside.txt" dir) (insert "x"))
+ (let ((status (gptel--move-to-trash-perform dir trash)))
+ (should (string-match-p "Directory moved to trash" status))
+ (should-not (file-exists-p dir))
+ (should (file-exists-p (expand-file-name "subdir/inside.txt" trash))))))))
+
+(ert-deftest test-gptel-tools-trash-perform-handles-symlink ()
+ "Perform: moving a symlink moves the link, not its target."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (src trash)
+ (let ((target (expand-file-name "target.txt" src))
+ (link (expand-file-name "link.txt" src)))
+ (with-temp-file target (insert "target"))
+ (make-symbolic-link target link t)
+ (let ((status (gptel--move-to-trash-perform link trash)))
+ (should (string-match-p "Symlink moved to trash" status))
+ (should (file-exists-p target))
+ (should-not (file-symlink-p link))
+ (should (file-symlink-p (expand-file-name "link.txt" trash))))))))
+
+(ert-deftest test-gptel-tools-trash-perform-error-rename-failure ()
+ "Error: rename failures are reported with context."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (src trash)
+ (let ((file (expand-file-name "doomed.txt" src)))
+ (with-temp-file file (insert "trash me"))
+ (cl-letf (((symbol-function 'rename-file)
+ (lambda (&rest _args) (error "rename failed"))))
+ (should-error (gptel--move-to-trash-perform file trash)))
+ (should (file-exists-p file))))))
+
+(ert-deftest test-gptel-tools-trash-perform-error-permission-denied ()
+ "Error: permission-denied rename failures get a specific message."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (src trash)
+ (let ((file (expand-file-name "denied.txt" src)))
+ (with-temp-file file (insert "trash me"))
+ (cl-letf (((symbol-function 'rename-file)
+ (lambda (&rest _args)
+ (signal 'permission-denied '("denied")))))
+ (should-error (gptel--move-to-trash-perform file trash)
+ :type 'error))
+ (should (file-exists-p file))))))
+
+(ert-deftest test-gptel-tools-trash-perform-error-original-still-exists ()
+ "Error: post-move verification catches a source path that remains."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (src trash)
+ (let ((file (expand-file-name "still-there.txt" src)))
+ (with-temp-file file (insert "trash me"))
+ (cl-letf (((symbol-function 'rename-file)
+ (lambda (&rest _args) nil)))
+ (should-error (gptel--move-to-trash-perform file trash)))
+ (should (file-exists-p file))))))
+
+(ert-deftest test-gptel-tools-trash-perform-error-trash-missing-after-move ()
+ "Error: post-move verification catches a missing trash target."
+ (test-gptel-tools-trash--with-tmp-tree
+ (lambda (src trash)
+ (let ((file (expand-file-name "missing-trash.txt" src))
+ (real-file-exists-p (symbol-function 'file-exists-p)))
+ (with-temp-file file (insert "trash me"))
+ (cl-letf (((symbol-function 'rename-file)
+ (lambda (&rest _args) nil))
+ ((symbol-function 'file-exists-p)
+ (lambda (path)
+ (cond
+ ((equal path file) nil)
+ ((string-prefix-p trash path) nil)
+ (t (funcall real-file-exists-p path))))))
+ (should-error (gptel--move-to-trash-perform file trash)))
+ (should (funcall real-file-exists-p file))))))
+
+(provide 'test-gptel-tools-move-to-trash)
+;;; test-gptel-tools-move-to-trash.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-read-buffer.el b/archive/gptel/tests/test-gptel-tools-read-buffer.el
new file mode 100644
index 000000000..0a8548359
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-read-buffer.el
@@ -0,0 +1,74 @@
+;;; test-gptel-tools-read-buffer.el --- Tests for read_buffer gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for `cj/read-buffer--get-content', the testable helper that
+;; backs the read_buffer gptel tool.
+
+;;; Code:
+
+(require 'ert)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'read_buffer)
+
+(ert-deftest test-gptel-tools-read-buffer-normal ()
+ "Normal: returns the contents of an existing buffer."
+ (with-temp-buffer
+ (rename-buffer "test-gptel-tools-read-buffer-normal" t)
+ (insert "hello world")
+ (should (equal (cj/read-buffer--get-content (buffer-name)) "hello world"))))
+
+(ert-deftest test-gptel-tools-read-buffer-boundary-empty-buffer ()
+ "Boundary: empty buffer returns the empty string."
+ (with-temp-buffer
+ (rename-buffer "test-gptel-tools-read-buffer-empty" t)
+ (should (equal (cj/read-buffer--get-content (buffer-name)) ""))))
+
+(ert-deftest test-gptel-tools-read-buffer-boundary-buffer-object ()
+ "Boundary: accepts a buffer object as well as a name string."
+ (with-temp-buffer
+ (insert "from buffer object")
+ (should (equal (cj/read-buffer--get-content (current-buffer))
+ "from buffer object"))))
+
+(ert-deftest test-gptel-tools-read-buffer-boundary-widened-content ()
+ "Boundary: returns the whole buffer even when the buffer is narrowed."
+ (with-temp-buffer
+ (insert "visible\nhidden\n")
+ (narrow-to-region (point-min) (line-end-position))
+ (should (equal (cj/read-buffer--get-content (current-buffer))
+ "visible\nhidden\n"))))
+
+(ert-deftest test-gptel-tools-read-buffer-boundary-strips-text-properties ()
+ "Boundary: the returned string has no text properties."
+ (with-temp-buffer
+ (rename-buffer "test-gptel-tools-read-buffer-props" t)
+ (insert (propertize "fontified" 'face 'bold))
+ (let ((content (cj/read-buffer--get-content (buffer-name))))
+ (should (equal content "fontified"))
+ (should-not (text-properties-at 0 content)))))
+
+(ert-deftest test-gptel-tools-read-buffer-error-missing-buffer ()
+ "Error: nonexistent buffer name signals."
+ (when (get-buffer "test-gptel-tools-read-buffer-absent")
+ (kill-buffer "test-gptel-tools-read-buffer-absent"))
+ (should-error (cj/read-buffer--get-content
+ "test-gptel-tools-read-buffer-absent")))
+
+(ert-deftest test-gptel-tools-read-buffer-error-killed-buffer-object ()
+ "Error: a killed buffer object signals clearly."
+ (let ((buffer (generate-new-buffer "test-gptel-tools-read-buffer-killed")))
+ (kill-buffer buffer)
+ (should-error (cj/read-buffer--get-content buffer))))
+
+(provide 'test-gptel-tools-read-buffer)
+;;; test-gptel-tools-read-buffer.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-read-text-file.el b/archive/gptel/tests/test-gptel-tools-read-text-file.el
new file mode 100644
index 000000000..db3d6e7ed
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-read-text-file.el
@@ -0,0 +1,201 @@
+;;; test-gptel-tools-read-text-file.el --- Tests for read_text_file gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the helpers in read_text_file.el.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'read_text_file)
+
+;; -------------------------- helpers
+
+(defun test-gptel-tools-read-text-file--in-home (suffix content fn)
+ "Run FN with a temp file (containing CONTENT) under HOME using SUFFIX."
+ (let* ((name (format ".test-gptel-tools-read-text-file-%s-%s.tmp"
+ suffix (format-time-string "%s%N")))
+ (path (expand-file-name name "~")))
+ (unwind-protect
+ (progn
+ (with-temp-file path (insert content))
+ (funcall fn path))
+ (when (file-exists-p path) (delete-file path)))))
+
+;; -------------------------- validate-file-path
+
+(ert-deftest test-gptel-tools-read-text-file-validate-path-normal ()
+ "Normal: an existing readable file under HOME passes."
+ (test-gptel-tools-read-text-file--in-home
+ "normal" "hi"
+ (lambda (path)
+ (should (equal (cj/validate-file-path path) (file-truename path))))))
+
+(ert-deftest test-gptel-tools-read-text-file-validate-path-error-outside-home ()
+ "Error: path outside HOME signals."
+ (should-error (cj/validate-file-path "/etc/hostname")))
+
+(ert-deftest test-gptel-tools-read-text-file-validate-path-error-missing ()
+ "Error: missing file signals."
+ (let ((path (expand-file-name
+ (format ".test-gptel-tools-read-text-file-missing-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (when (file-exists-p path) (delete-file path))
+ (should-error (cj/validate-file-path path))))
+
+(ert-deftest test-gptel-tools-read-text-file-validate-path-error-directory ()
+ "Error: a directory signals."
+ (should-error (cj/validate-file-path "~")))
+
+(ert-deftest test-gptel-tools-read-text-file-validate-path-error-unreadable ()
+ "Error: unreadable files signal."
+ (test-gptel-tools-read-text-file--in-home
+ "unreadable" "secret"
+ (lambda (path)
+ (cl-letf (((symbol-function 'file-readable-p) (lambda (_) nil)))
+ (should-error (cj/validate-file-path path))))))
+
+(ert-deftest test-gptel-tools-read-text-file-validate-path-boundary-relative-home-path ()
+ "Boundary: relative paths resolve under HOME."
+ (test-gptel-tools-read-text-file--in-home
+ "relative" "hi"
+ (lambda (path)
+ (let ((relative (file-relative-name path (expand-file-name "~"))))
+ (should (equal (cj/validate-file-path relative)
+ (file-truename path)))))))
+
+(ert-deftest test-gptel-tools-read-text-file-validate-path-boundary-symlink-inside-home ()
+ "Boundary: symlinks inside HOME resolving inside HOME are accepted."
+ (test-gptel-tools-read-text-file--in-home
+ "symlink-target" "hi"
+ (lambda (target)
+ (let ((link (expand-file-name
+ (format ".test-gptel-tools-read-text-file-link-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link target link t)
+ (should (equal (cj/validate-file-path link)
+ (file-truename target))))
+ (when (file-symlink-p link) (delete-file link)))))))
+
+(ert-deftest test-gptel-tools-read-text-file-validate-path-error-symlink-outside-home ()
+ "Error: symlinks inside HOME pointing outside HOME are rejected."
+ (let ((outside (make-temp-file "test-gptel-tools-read-text-file-outside-"))
+ (link (expand-file-name
+ (format ".test-gptel-tools-read-text-file-outside-link-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link outside link t)
+ (should-error (cj/validate-file-path link)))
+ (when (file-exists-p outside) (delete-file outside))
+ (when (file-symlink-p link) (delete-file link)))))
+
+;; -------------------------- get-file-metadata
+
+(ert-deftest test-gptel-tools-read-text-file-get-metadata-shape ()
+ "Returns a plist with :size and :string keys."
+ (test-gptel-tools-read-text-file--in-home
+ "meta" "abc"
+ (lambda (path)
+ (let ((meta (cj/get-file-metadata path)))
+ (should (plist-get meta :size))
+ (should (= 3 (plist-get meta :size)))
+ (should (stringp (plist-get meta :string)))
+ (should (string-match-p "modified" (plist-get meta :string)))))))
+
+;; -------------------------- check-file-size-limits
+
+(ert-deftest test-gptel-tools-read-text-file-size-limits-normal ()
+ "Small size below warning limit is a no-op."
+ (should-not (cj/check-file-size-limits 1024 nil)))
+
+(ert-deftest test-gptel-tools-read-text-file-size-limits-error-hard-cap ()
+ "Sizes above 100MB always signal."
+ (should-error (cj/check-file-size-limits (* 101 1024 1024) t))
+ (should-error (cj/check-file-size-limits (* 101 1024 1024) nil)))
+
+(ert-deftest test-gptel-tools-read-text-file-size-limits-warning-with-no-confirm ()
+ "Above 10MB but below 100MB with no-confirm passes through silently."
+ (should-not (cj/check-file-size-limits (* 11 1024 1024) t)))
+
+(ert-deftest test-gptel-tools-read-text-file-size-limits-warning-user-accepts ()
+ "Above warning limit proceeds when the user accepts."
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) t)))
+ (should-not (cj/check-file-size-limits (* 11 1024 1024) nil))))
+
+(ert-deftest test-gptel-tools-read-text-file-size-limits-warning-user-declines ()
+ "Above warning limit signals when the user declines."
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil)))
+ (should-error (cj/check-file-size-limits (* 11 1024 1024) nil))))
+
+;; -------------------------- detect-binary-file
+
+(ert-deftest test-gptel-tools-read-text-file-detect-binary-text-file ()
+ "Text file: detect-binary returns nil."
+ (test-gptel-tools-read-text-file--in-home
+ "text" "plain ascii content"
+ (lambda (path)
+ (should-not (cj/detect-binary-file path)))))
+
+(ert-deftest test-gptel-tools-read-text-file-detect-binary-with-null-byte ()
+ "File with NUL in first 1024 bytes returns truthy."
+ (test-gptel-tools-read-text-file--in-home
+ "bin" (concat "head\0tail")
+ (lambda (path)
+ (should (cj/detect-binary-file path)))))
+
+;; -------------------------- handle-special-file-types
+
+(ert-deftest test-gptel-tools-read-text-file-handle-special-epub-error ()
+ "EPUB special-type handler signals \"not yet implemented\"."
+ (should-error (cj/handle-special-file-types "/tmp/foo.epub" t)))
+
+(ert-deftest test-gptel-tools-read-text-file-handle-special-epub-cancel ()
+ "EPUB special-type handler signals when user declines extraction."
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil)))
+ (should-error (cj/handle-special-file-types "/tmp/foo.epub" nil))))
+
+(ert-deftest test-gptel-tools-read-text-file-handle-special-pdf-cancel ()
+ "PDF special-type handler signals when user declines extraction."
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil)))
+ (should-error (cj/handle-special-file-types "/tmp/foo.pdf" nil))))
+
+(ert-deftest test-gptel-tools-read-text-file-handle-special-pdf-empty-extraction ()
+ "PDF special-type handler signals when extraction returns empty text."
+ (cl-letf (((symbol-function 'shell-command-to-string) (lambda (_cmd) "")))
+ (should-error (cj/handle-special-file-types "/tmp/foo.pdf" t))))
+
+(ert-deftest test-gptel-tools-read-text-file-handle-special-pdf-text ()
+ "PDF special-type handler returns extracted text."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) "pdf text\n")))
+ (should (equal (cj/handle-special-file-types "/tmp/foo.pdf" t)
+ "pdf text\n"))))
+
+(ert-deftest test-gptel-tools-read-text-file-handle-special-binary-cancel ()
+ "Generic binary handler signals when user declines."
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil)))
+ (should-error (cj/handle-special-file-types "/tmp/foo.bin" nil))))
+
+(ert-deftest test-gptel-tools-read-text-file-handle-special-binary-returns-nil ()
+ "Generic binary file with no-confirm returns nil to indicate normal read."
+ (should-not (cj/handle-special-file-types "/tmp/foo.bin" t)))
+
+(provide 'test-gptel-tools-read-text-file)
+;;; test-gptel-tools-read-text-file.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-web-fetch.el b/archive/gptel/tests/test-gptel-tools-web-fetch.el
new file mode 100644
index 000000000..10abe6eba
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-web-fetch.el
@@ -0,0 +1,230 @@
+;;; test-gptel-tools-web-fetch.el --- Tests for web_fetch gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Validators and helpers tested directly. The orchestrator's network
+;; call is stubbed via `cl-letf' on `url-retrieve-synchronously' / the
+;; module's `--retrieve' helper; HTML stripping runs against real
+;; pandoc / w3m (both are installed in this dev environment, and
+;; verifying they don't mangle inputs is the point).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'web_fetch)
+
+;; ---------- validate-url
+
+(ert-deftest test-gptel-tools-web-fetch-validate-url-http ()
+ "Normal: http URL passes."
+ (should (equal (cj/gptel-web-fetch--validate-url "http://example.com")
+ "http://example.com")))
+
+(ert-deftest test-gptel-tools-web-fetch-validate-url-https ()
+ "Normal: https URL passes."
+ (should (equal (cj/gptel-web-fetch--validate-url "https://example.com/path")
+ "https://example.com/path")))
+
+(ert-deftest test-gptel-tools-web-fetch-validate-url-error-non-string ()
+ "Error: non-string URL signals."
+ (should-error (cj/gptel-web-fetch--validate-url nil))
+ (should-error (cj/gptel-web-fetch--validate-url 42)))
+
+(ert-deftest test-gptel-tools-web-fetch-validate-url-error-empty ()
+ "Error: empty URL signals."
+ (should-error (cj/gptel-web-fetch--validate-url "")))
+
+(ert-deftest test-gptel-tools-web-fetch-validate-url-error-non-http-scheme ()
+ "Error: schemes other than http/https are rejected."
+ (should-error (cj/gptel-web-fetch--validate-url "file:///etc/hostname"))
+ (should-error (cj/gptel-web-fetch--validate-url "ftp://example.com"))
+ (should-error (cj/gptel-web-fetch--validate-url "javascript:alert(1)"))
+ (should-error (cj/gptel-web-fetch--validate-url "example.com"))) ; no scheme
+
+;; ---------- effective-max-bytes
+
+(ert-deftest test-gptel-tools-web-fetch-max-bytes-default-on-nil ()
+ "Boundary: nil falls back to the default cap."
+ (should (= (cj/gptel-web-fetch--effective-max-bytes nil)
+ cj/gptel-web-fetch--default-max-bytes)))
+
+(ert-deftest test-gptel-tools-web-fetch-max-bytes-clamp-low ()
+ "Boundary: zero / negative fall back to the default."
+ (should (= (cj/gptel-web-fetch--effective-max-bytes 0)
+ cj/gptel-web-fetch--default-max-bytes))
+ (should (= (cj/gptel-web-fetch--effective-max-bytes -1)
+ cj/gptel-web-fetch--default-max-bytes)))
+
+(ert-deftest test-gptel-tools-web-fetch-max-bytes-cap-high ()
+ "Boundary: values above the hard cap are clamped."
+ (should (= (cj/gptel-web-fetch--effective-max-bytes (* 10 1024 1024))
+ cj/gptel-web-fetch--hard-max-bytes)))
+
+(ert-deftest test-gptel-tools-web-fetch-max-bytes-normal ()
+ "Normal: a sensible value passes through."
+ (should (= (cj/gptel-web-fetch--effective-max-bytes 50000) 50000)))
+
+;; ---------- truncate
+
+(ert-deftest test-gptel-tools-web-fetch-truncate-under-cap ()
+ "Normal: small input returns unchanged."
+ (should (equal (cj/gptel-web-fetch--truncate "short" 1000) "short")))
+
+(ert-deftest test-gptel-tools-web-fetch-truncate-at-cap ()
+ "Boundary: input exactly at cap returns unchanged."
+ (let ((s (make-string 10 ?x)))
+ (should (equal (cj/gptel-web-fetch--truncate s 10) s))))
+
+(ert-deftest test-gptel-tools-web-fetch-truncate-over-cap ()
+ "Boundary: oversize input is truncated and marked."
+ (let* ((s (make-string 1000 ?x))
+ (out (cj/gptel-web-fetch--truncate s 100)))
+ (should (string-match-p "\\[truncated:" out))
+ (should (string-match-p "1000 bytes total" out))))
+
+;; ---------- html-to-text
+
+(ert-deftest test-gptel-tools-web-fetch-html-to-text-strips-tags ()
+ "Normal: pandoc / w3m strip HTML tags from real markup."
+ (let ((out (cj/gptel-web-fetch--html-to-text
+ "<html><body><h1>Hello</h1><p>World</p></body></html>")))
+ (should (string-match-p "Hello" out))
+ (should (string-match-p "World" out))
+ (should-not (string-match-p "<h1>" out))
+ (should-not (string-match-p "<p>" out))))
+
+(ert-deftest test-gptel-tools-web-fetch-html-to-text-error-when-neither-on-path ()
+ "Error: when neither pandoc nor w3m is on PATH, signals user-error."
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
+ (should-error (cj/gptel-web-fetch--html-to-text "<p>x</p>"))))
+
+(ert-deftest test-gptel-tools-web-fetch-html-to-text-error-on-tool-failure ()
+ "Error: a failing HTML stripping command is reported."
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (program &rest _) (and (equal program "pandoc") "/bin/pandoc")))
+ ((symbol-function 'call-process-region)
+ (lambda (&rest _args) 9)))
+ (should-error (cj/gptel-web-fetch--html-to-text "<p>x</p>"))))
+
+(ert-deftest test-gptel-tools-web-fetch-html-to-text-falls-back-to-w3m ()
+ "Boundary: w3m is used when pandoc is unavailable."
+ (let (called-program)
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (program &rest _) (and (equal program "w3m") "/bin/w3m")))
+ ((symbol-function 'call-process-region)
+ (lambda (start end program delete output display &rest _args)
+ (setq called-program program)
+ (should delete)
+ (should output)
+ (should-not display)
+ (delete-region start end)
+ (insert "w3m text")
+ 0)))
+ (should (equal (cj/gptel-web-fetch--html-to-text "<p>x</p>")
+ "w3m text"))
+ (should (equal called-program "w3m")))))
+
+;; ---------- retrieve
+
+(ert-deftest test-gptel-tools-web-fetch-retrieve-normal-crlf-headers ()
+ "Normal: retrieval parses status and body after CRLF headers."
+ (let ((buffer (generate-new-buffer " *web-fetch-crlf*")))
+ (with-current-buffer buffer
+ (insert "HTTP/1.1 201 Created\r\nContent-Type: text/plain\r\n\r\nhello"))
+ (cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (&rest _args) buffer)))
+ (should (equal (cj/gptel-web-fetch--retrieve "https://example.com")
+ '(201 . "hello"))))
+ (should-not (buffer-live-p buffer))))
+
+(ert-deftest test-gptel-tools-web-fetch-retrieve-boundary-lf-headers ()
+ "Boundary: retrieval also handles LF-only headers."
+ (let ((buffer (generate-new-buffer " *web-fetch-lf*")))
+ (with-current-buffer buffer
+ (insert "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nhello"))
+ (cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (&rest _args) buffer)))
+ (should (equal (cj/gptel-web-fetch--retrieve "https://example.com")
+ '(200 . "hello"))))))
+
+(ert-deftest test-gptel-tools-web-fetch-retrieve-boundary-no-header-separator ()
+ "Boundary: unseparated responses return the full buffer as body."
+ (let ((buffer (generate-new-buffer " *web-fetch-no-separator*")))
+ (with-current-buffer buffer
+ (insert "not an http response"))
+ (cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (&rest _args) buffer)))
+ (should (equal (cj/gptel-web-fetch--retrieve "https://example.com")
+ '(nil . "not an http response"))))))
+
+(ert-deftest test-gptel-tools-web-fetch-retrieve-error-no-response ()
+ "Error: nil retrieval buffer signals network failure."
+ (cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (&rest _args) nil)))
+ (should-error (cj/gptel-web-fetch--retrieve "https://example.com"))))
+
+;; ---------- run (orchestrator)
+
+(ert-deftest test-gptel-tools-web-fetch-run-normal-strips-html ()
+ "Normal: orchestrator returns stripped text by default."
+ (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve)
+ (lambda (_url)
+ (cons 200 "<html><body><p>fetched</p></body></html>"))))
+ (let ((out (cj/gptel-web-fetch--run "https://example.com")))
+ (should (string-match-p "fetched" out))
+ (should-not (string-match-p "<p>" out)))))
+
+(ert-deftest test-gptel-tools-web-fetch-run-raw-returns-body-verbatim ()
+ "Normal: raw=t returns the response body without HTML stripping."
+ (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve)
+ (lambda (_url)
+ (cons 200 "<html><body><p>raw</p></body></html>"))))
+ (let ((out (cj/gptel-web-fetch--run "https://example.com" t)))
+ (should (string-match-p "<p>raw</p>" out)))))
+
+(ert-deftest test-gptel-tools-web-fetch-run-error-on-4xx ()
+ "Error: HTTP 4xx response signals."
+ (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve)
+ (lambda (_url) (cons 404 "not found"))))
+ (should-error (cj/gptel-web-fetch--run "https://example.com"))))
+
+(ert-deftest test-gptel-tools-web-fetch-run-error-on-5xx ()
+ "Error: HTTP 5xx response signals."
+ (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve)
+ (lambda (_url) (cons 503 "service unavailable"))))
+ (should-error (cj/gptel-web-fetch--run "https://example.com"))))
+
+(ert-deftest test-gptel-tools-web-fetch-run-boundary-nil-status ()
+ "Boundary: an unparseable status line does not trigger HTTP error handling."
+ (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve)
+ (lambda (_url) (cons nil "raw body"))))
+ (should (equal (cj/gptel-web-fetch--run "https://example.com" t)
+ "raw body"))))
+
+(ert-deftest test-gptel-tools-web-fetch-run-truncates-oversized-body ()
+ "Boundary: an oversize body is truncated by the run wrapper."
+ (let ((big (concat "<html><body>"
+ (make-string 1000 ?x)
+ "</body></html>")))
+ (cl-letf (((symbol-function 'cj/gptel-web-fetch--retrieve)
+ (lambda (_url) (cons 200 big))))
+ (let ((out (cj/gptel-web-fetch--run "https://example.com" t 200)))
+ (should (string-match-p "\\[truncated:" out))))))
+
+(ert-deftest test-gptel-tools-web-fetch-run-error-on-bad-scheme ()
+ "Error: non-http URL fails fast at the validator."
+ (should-error (cj/gptel-web-fetch--run "file:///etc/passwd")))
+
+(provide 'test-gptel-tools-web-fetch)
+;;; test-gptel-tools-web-fetch.el ends here
diff --git a/archive/gptel/tests/test-gptel-tools-write-text-file.el b/archive/gptel/tests/test-gptel-tools-write-text-file.el
new file mode 100644
index 000000000..14bcb2a51
--- /dev/null
+++ b/archive/gptel/tests/test-gptel-tools-write-text-file.el
@@ -0,0 +1,223 @@
+;;; test-gptel-tools-write-text-file.el --- Tests for write_text_file gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for `cj/write-text-file--run' and its helpers.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'write_text_file)
+
+;; ------------------------------------------------------- helpers
+
+(defun test-gptel-tools-write-text-file--in-home (suffix fn)
+ "Run FN with a fresh path under HOME using SUFFIX. Clean up after."
+ (let* ((name (format ".test-gptel-tools-write-text-file-%s-%s.tmp"
+ suffix (format-time-string "%s%N")))
+ (path (expand-file-name name "~")))
+ (unwind-protect
+ (funcall fn path)
+ (when (file-exists-p path) (delete-file path))
+ (dolist (b (file-expand-wildcards (concat path "-*.bak")))
+ (when (file-exists-p b) (delete-file b))))))
+
+;; --------------------------------------------- validate-path
+
+(ert-deftest test-gptel-tools-write-text-file-validate-path-normal ()
+ "Normal: returns the expanded path for a HOME-relative input."
+ (let ((result (cj/write-text-file--validate-path "foo.txt")))
+ (should (string-prefix-p (expand-file-name "~") result))
+ (should (string-suffix-p "/foo.txt" result))))
+
+(ert-deftest test-gptel-tools-write-text-file-validate-path-error-outside-home ()
+ "Error: a path outside HOME signals."
+ (should-error (cj/write-text-file--validate-path "/etc/hostname")))
+
+(ert-deftest test-gptel-tools-write-text-file-validate-path-boundary-absolute-home-path ()
+ "Boundary: absolute HOME paths are accepted."
+ (test-gptel-tools-write-text-file--in-home
+ "absolute"
+ (lambda (path)
+ (should (equal (cj/write-text-file--validate-path path) path)))))
+
+(ert-deftest test-gptel-tools-write-text-file-validate-path-error-existing-symlink-outside-home ()
+ "Error: an existing symlink inside HOME pointing outside HOME is rejected."
+ (let ((outside (make-temp-file "test-gptel-tools-write-text-file-outside-"))
+ (link (expand-file-name
+ (format ".test-gptel-tools-write-text-file-outside-link-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link outside link t)
+ (should-error (cj/write-text-file--validate-path link)))
+ (when (file-exists-p outside) (delete-file outside))
+ (when (file-symlink-p link) (delete-file link)))))
+
+(ert-deftest test-gptel-tools-write-text-file-validate-path-error-parent-symlink-outside-home ()
+ "Error: a parent symlink inside HOME pointing outside HOME is rejected."
+ (let ((outside-dir (make-temp-file "test-gptel-tools-write-text-file-outside-dir-" t))
+ (link-dir (expand-file-name
+ (format ".test-gptel-tools-write-text-file-outside-dir-link-%s"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link outside-dir link-dir t)
+ (should-error
+ (cj/write-text-file--validate-path
+ (expand-file-name "child.txt" link-dir))))
+ (when (file-symlink-p link-dir) (delete-file link-dir))
+ (when (file-exists-p outside-dir) (delete-directory outside-dir t)))))
+
+;; --------------------------------------------- backup-name
+
+(ert-deftest test-gptel-tools-write-text-file-backup-name-shape ()
+ "Backup names append a YYYY-MM-DD-HHMMSS suffix and .bak."
+ (let ((name (cj/write-text-file--backup-name "/home/user/foo.txt")))
+ (should (string-prefix-p "/home/user/foo.txt-" name))
+ (should (string-suffix-p ".bak" name))
+ (should (string-match-p "-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{6\\}\\.bak\\'"
+ name))))
+
+;; --------------------------------------------- ensure-parent
+
+(ert-deftest test-gptel-tools-write-text-file-ensure-parent-creates-missing ()
+ "Normal: creates missing parent directories."
+ (let* ((base (make-temp-file "test-gptel-tools-write-text-file-" t))
+ (deep (expand-file-name "a/b/c/file.txt" base)))
+ (unwind-protect
+ (progn
+ (cj/write-text-file--ensure-parent deep)
+ (should (file-directory-p (file-name-directory deep))))
+ (delete-directory base t))))
+
+(ert-deftest test-gptel-tools-write-text-file-ensure-parent-error-unwritable ()
+ "Error: an unwritable parent signals."
+ (let* ((parent (make-temp-file "test-gptel-tools-write-text-file-ro-" t))
+ (target (expand-file-name "child.txt" parent)))
+ (unwind-protect
+ (progn
+ (set-file-modes parent #o500)
+ (should-error (cj/write-text-file--ensure-parent target)))
+ (set-file-modes parent #o700)
+ (delete-directory parent t))))
+
+(ert-deftest test-gptel-tools-write-text-file-ensure-parent-error-create-fails ()
+ "Error: directory creation failures are wrapped with context."
+ (cl-letf (((symbol-function 'make-directory)
+ (lambda (&rest _args) (error "boom"))))
+ (should-error
+ (cj/write-text-file--ensure-parent
+ (expand-file-name "missing/child.txt" temporary-file-directory)))))
+
+;; --------------------------------------------- run
+
+(ert-deftest test-gptel-tools-write-text-file-run-normal ()
+ "Normal: writes new content and returns a status string."
+ (test-gptel-tools-write-text-file--in-home
+ "new"
+ (lambda (path)
+ (let ((result (cj/write-text-file--run
+ (file-name-nondirectory path) "hello\n" nil)))
+ (should (string-match-p "Successfully wrote" result))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "hello\n")))))))
+
+(ert-deftest test-gptel-tools-write-text-file-run-error-existing-no-overwrite ()
+ "Error: existing file without overwrite signals."
+ (test-gptel-tools-write-text-file--in-home
+ "existing"
+ (lambda (path)
+ (with-temp-file path (insert "old content\n"))
+ (should-error (cj/write-text-file--run
+ (file-name-nondirectory path) "new content\n" nil))
+ ;; File preserved
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "old content\n"))))))
+
+(ert-deftest test-gptel-tools-write-text-file-run-overwrite-creates-backup ()
+ "Overwrite path makes a timestamped backup before writing."
+ (test-gptel-tools-write-text-file--in-home
+ "overwrite"
+ (lambda (path)
+ (with-temp-file path (insert "old content\n"))
+ (cj/write-text-file--run
+ (file-name-nondirectory path) "new content\n" t)
+ ;; New content landed
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "new content\n")))
+ ;; Backup exists with old content
+ (let ((backups (file-expand-wildcards (concat path "-*.bak"))))
+ (should (= 1 (length backups)))
+ (with-temp-buffer
+ (insert-file-contents (car backups))
+ (should (equal (buffer-string) "old content\n")))))))
+
+(ert-deftest test-gptel-tools-write-text-file-run-boundary-empty-content ()
+ "Boundary: nil content writes an empty file."
+ (test-gptel-tools-write-text-file--in-home
+ "empty"
+ (lambda (path)
+ (cj/write-text-file--run (file-name-nondirectory path) nil nil)
+ (should (file-exists-p path))
+ (should (= 0 (file-attribute-size (file-attributes path)))))))
+
+(ert-deftest test-gptel-tools-write-text-file-run-large-user-accepts ()
+ "Boundary: large writes proceed when the user accepts."
+ (test-gptel-tools-write-text-file--in-home
+ "large-accept"
+ (lambda (path)
+ (let ((cj/write-text-file--size-limit 3))
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) t)))
+ (cj/write-text-file--run (file-name-nondirectory path) "abcdef" nil)))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "abcdef"))))))
+
+(ert-deftest test-gptel-tools-write-text-file-run-large-user-declines ()
+ "Error: large writes cancel cleanly when the user declines."
+ (test-gptel-tools-write-text-file--in-home
+ "large-decline"
+ (lambda (path)
+ (let ((cj/write-text-file--size-limit 3))
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil)))
+ (should-error
+ (cj/write-text-file--run (file-name-nondirectory path) "abcdef" nil))))
+ (should-not (file-exists-p path)))))
+
+(ert-deftest test-gptel-tools-write-text-file-run-error-overwrite-backup-failure-preserves-file ()
+ "Error: backup failure prevents overwrite and preserves existing file."
+ (test-gptel-tools-write-text-file--in-home
+ "backup-fails"
+ (lambda (path)
+ (with-temp-file path (insert "old\n"))
+ (cl-letf (((symbol-function 'copy-file)
+ (lambda (&rest _args) (error "copy failed"))))
+ (should-error
+ (cj/write-text-file--run (file-name-nondirectory path) "new\n" t)))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "old\n"))))))
+
+(ert-deftest test-gptel-tools-write-text-file-run-error-outside-home ()
+ "Error: a path outside HOME signals."
+ (should-error (cj/write-text-file--run "/etc/test-write.txt" "x" nil)))
+
+(provide 'test-gptel-tools-write-text-file)
+;;; test-gptel-tools-write-text-file.el ends here
diff --git a/archive/gptel/tests/test-update-text-file.el b/archive/gptel/tests/test-update-text-file.el
new file mode 100644
index 000000000..fc4f8c36a
--- /dev/null
+++ b/archive/gptel/tests/test-update-text-file.el
@@ -0,0 +1,473 @@
+;;; test-update-text-file.el --- Tests for update_text_file gptel tool -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Normal / Boundary / Error tests for each operation in
+;; gptel-tools/update_text_file.el, plus file-level wrapper tests.
+;; The pure-string helpers carry most of the coverage; the wrapper
+;; only adds the I/O surface (backup, write, validation).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+ (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory))
+ (setq load-prefer-newer t)
+ ;; Stub gptel so the tool file can be loaded without the real package.
+ (unless (featurep 'gptel)
+ (defvar gptel-tools nil)
+ (defun gptel-make-tool (&rest _args) nil)
+ (defun gptel-get-tool (&rest _args) nil)
+ (provide 'gptel)))
+
+(require 'update_text_file)
+
+;; ----------------------------------------------------- helpers
+
+(defun test-update-text-file--with-temp (content fn)
+ "Write CONTENT to a temp file, call FN with its path, then delete."
+ (let ((path (make-temp-file "test-update-text-file-")))
+ (unwind-protect
+ (progn
+ (with-temp-file path (insert content))
+ (funcall fn path))
+ (when (file-exists-p path) (delete-file path)))))
+
+;; ----------------------------------------------------- replace
+
+(ert-deftest test-update-text-file-replace-normal ()
+ "Normal: replace all occurrences of the literal pattern."
+ (should (equal (cj/update-text-file--replace "foo bar foo" "foo" "BAZ")
+ "BAZ bar BAZ")))
+
+(ert-deftest test-update-text-file-replace-boundary-no-match ()
+ "Boundary: pattern absent returns content unchanged."
+ (should (equal (cj/update-text-file--replace "abc" "xyz" "QQ") "abc")))
+
+(ert-deftest test-update-text-file-replace-boundary-special-chars ()
+ "Boundary: regex metacharacters in pattern are treated as literals."
+ (should (equal (cj/update-text-file--replace "a.b.c" "." "-") "a-b-c"))
+ (should (equal (cj/update-text-file--replace "(x)(y)" "(x)" "_") "_(y)"))
+ (should (equal (cj/update-text-file--replace "a$b" "$" "S") "aSb")))
+
+(ert-deftest test-update-text-file-replace-boundary-unicode ()
+ "Boundary: unicode in both pattern and replacement."
+ (should (equal (cj/update-text-file--replace "café résumé" "café" "thé")
+ "thé résumé")))
+
+(ert-deftest test-update-text-file-replace-boundary-replacement-with-backref-like ()
+ "Boundary: replacement strings with \\1 etc. are literal, not back-refs."
+ (should (equal (cj/update-text-file--replace "foo" "foo" "\\1bar")
+ "\\1bar")))
+
+(ert-deftest test-update-text-file-replace-error-empty-pattern ()
+ "Error: empty pattern signals."
+ (should-error (cj/update-text-file--replace "abc" "" "x")))
+
+(ert-deftest test-update-text-file-replace-error-nil-pattern ()
+ "Error: nil pattern signals."
+ (should-error (cj/update-text-file--replace "abc" nil "x")))
+
+(ert-deftest test-update-text-file-replace-error-nil-replacement ()
+ "Error: nil replacement signals."
+ (should-error (cj/update-text-file--replace "abc" "a" nil)))
+
+;; ----------------------------------------------------- append
+
+(ert-deftest test-update-text-file-append-normal ()
+ "Normal: append adds text plus a trailing newline."
+ (should (equal (cj/update-text-file--append "line1\n" "line2")
+ "line1\nline2\n")))
+
+(ert-deftest test-update-text-file-append-boundary-no-trailing-newline ()
+ "Boundary: appends still produce a newline when content has none."
+ (should (equal (cj/update-text-file--append "abc" "def")
+ "abc\ndef\n")))
+
+(ert-deftest test-update-text-file-append-boundary-empty-content ()
+ "Boundary: appending to empty content yields just the new text + newline."
+ (should (equal (cj/update-text-file--append "" "hello") "hello\n")))
+
+(ert-deftest test-update-text-file-append-boundary-text-with-trailing-newline ()
+ "Boundary: text that already ends in newline isn't duplicated."
+ (should (equal (cj/update-text-file--append "a\n" "b\n") "a\nb\n")))
+
+(ert-deftest test-update-text-file-append-error-empty-text ()
+ "Error: empty text signals."
+ (should-error (cj/update-text-file--append "foo" "")))
+
+(ert-deftest test-update-text-file-append-error-nil-text ()
+ "Error: nil text signals."
+ (should-error (cj/update-text-file--append "foo" nil)))
+
+;; ----------------------------------------------------- prepend
+
+(ert-deftest test-update-text-file-prepend-normal ()
+ "Normal: prepend adds text plus a separator newline."
+ (should (equal (cj/update-text-file--prepend "line1\n" "line0")
+ "line0\nline1\n")))
+
+(ert-deftest test-update-text-file-prepend-boundary-empty-content ()
+ "Boundary: prepending to empty content keeps just the new text + sep."
+ (should (equal (cj/update-text-file--prepend "" "hello") "hello\n")))
+
+(ert-deftest test-update-text-file-prepend-boundary-text-with-trailing-newline ()
+ "Boundary: text already terminated by newline is not double-broken."
+ (should (equal (cj/update-text-file--prepend "rest" "first\n")
+ "first\nrest")))
+
+(ert-deftest test-update-text-file-prepend-error-empty-text ()
+ "Error: empty text signals."
+ (should-error (cj/update-text-file--prepend "foo" "")))
+
+(ert-deftest test-update-text-file-prepend-error-nil-text ()
+ "Error: nil text signals."
+ (should-error (cj/update-text-file--prepend "foo" nil)))
+
+;; ----------------------------------------------------- insert-at-line
+
+(ert-deftest test-update-text-file-insert-at-line-normal ()
+ "Normal: insert before line 2 of a 3-line file."
+ (should (equal (cj/update-text-file--insert-at-line "a\nb\nc\n" 2 "X")
+ "a\nX\nb\nc\n")))
+
+(ert-deftest test-update-text-file-insert-at-line-boundary-first-line ()
+ "Boundary: inserting at line 1 prepends."
+ (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 1 "X")
+ "X\na\nb\n")))
+
+(ert-deftest test-update-text-file-insert-at-line-boundary-one-past-end ()
+ "Boundary: inserting one past the last line appends."
+ (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 3 "X")
+ "a\nb\nX\n")))
+
+(ert-deftest test-update-text-file-insert-at-line-boundary-no-trailing-newline ()
+ "Boundary: works on content without a trailing newline."
+ (should (equal (cj/update-text-file--insert-at-line "a\nb" 2 "X")
+ "a\nX\nb")))
+
+(ert-deftest test-update-text-file-insert-at-line-boundary-text-with-trailing-newline ()
+ "Boundary: inserted text that ends in newline is not double-terminated."
+ (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 2 "X\n")
+ "a\nX\nb\n")))
+
+(ert-deftest test-update-text-file-insert-at-line-boundary-multiline-text ()
+ "Boundary: multi-line inserted text is inserted as a block."
+ (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 2 "X\nY")
+ "a\nX\nY\nb\n")))
+
+(ert-deftest test-update-text-file-insert-at-line-boundary-empty-file-line-1 ()
+ "Boundary: inserting at line 1 in an empty file works."
+ (should (equal (cj/update-text-file--insert-at-line "" 1 "X")
+ "X\n")))
+
+(ert-deftest test-update-text-file-insert-at-line-error-empty-file-line-2 ()
+ "Error: line 2 is out of range for an empty file."
+ (should-error (cj/update-text-file--insert-at-line "" 2 "X")))
+
+(ert-deftest test-update-text-file-insert-at-line-error-out-of-range ()
+ "Error: line number beyond file length signals."
+ (should-error (cj/update-text-file--insert-at-line "a\nb\n" 5 "X")))
+
+(ert-deftest test-update-text-file-insert-at-line-error-zero ()
+ "Error: line number 0 signals."
+ (should-error (cj/update-text-file--insert-at-line "a\n" 0 "X")))
+
+(ert-deftest test-update-text-file-insert-at-line-error-negative ()
+ "Error: negative line number signals."
+ (should-error (cj/update-text-file--insert-at-line "a\n" -1 "X")))
+
+(ert-deftest test-update-text-file-insert-at-line-error-empty-text ()
+ "Error: empty text signals."
+ (should-error (cj/update-text-file--insert-at-line "a\n" 1 "")))
+
+;; ----------------------------------------------------- delete-lines
+
+(ert-deftest test-update-text-file-delete-lines-normal ()
+ "Normal: removes lines containing the literal pattern."
+ (should (equal (cj/update-text-file--delete-lines "keep\nkill me\nkeep\n" "kill")
+ "keep\nkeep\n")))
+
+(ert-deftest test-update-text-file-delete-lines-boundary-no-match ()
+ "Boundary: pattern matches nothing returns content unchanged."
+ (should (equal (cj/update-text-file--delete-lines "a\nb\nc\n" "z")
+ "a\nb\nc\n")))
+
+(ert-deftest test-update-text-file-delete-lines-boundary-all-lines-match ()
+ "Boundary: every line removed yields the empty string."
+ (should (equal (cj/update-text-file--delete-lines "x\nx\nx\n" "x") "")))
+
+(ert-deftest test-update-text-file-delete-lines-boundary-special-chars-literal ()
+ "Boundary: regex metacharacters in pattern are treated as literals."
+ (should (equal (cj/update-text-file--delete-lines "a.b\naxb\n" ".")
+ "axb\n")))
+
+(ert-deftest test-update-text-file-delete-lines-boundary-no-trailing-newline ()
+ "Boundary: content without trailing newline keeps that shape."
+ (should (equal (cj/update-text-file--delete-lines "keep\ndrop" "drop")
+ "keep")))
+
+(ert-deftest test-update-text-file-delete-lines-boundary-empty-file ()
+ "Boundary: deleting from an empty file returns the empty string."
+ (should (equal (cj/update-text-file--delete-lines "" "anything") "")))
+
+(ert-deftest test-update-text-file-delete-lines-boundary-backslash-literal ()
+ "Boundary: backslashes in the pattern are literal."
+ (should (equal (cj/update-text-file--delete-lines "keep\npath\\name\n" "\\")
+ "keep\n")))
+
+(ert-deftest test-update-text-file-delete-lines-error-empty-pattern ()
+ "Error: empty pattern signals."
+ (should-error (cj/update-text-file--delete-lines "a\nb\n" "")))
+
+(ert-deftest test-update-text-file-delete-lines-error-nil-pattern ()
+ "Error: nil pattern signals."
+ (should-error (cj/update-text-file--delete-lines "a\nb\n" nil)))
+
+;; ----------------------------------------------------- apply-operation
+
+(ert-deftest test-update-text-file-apply-operation-dispatch ()
+ "Each operation name dispatches to its transform."
+ (should (equal (cj/update-text-file--apply-operation "abc" "replace" "b" "B" nil)
+ "aBc"))
+ (should (equal (cj/update-text-file--apply-operation "a" "append" "b" nil nil)
+ "a\nb\n"))
+ (should (equal (cj/update-text-file--apply-operation "a" "prepend" "b" nil nil)
+ "b\na"))
+ (should (equal (cj/update-text-file--apply-operation "a\nb\n" "insert-at-line" "X" nil 2)
+ "a\nX\nb\n"))
+ (should (equal (cj/update-text-file--apply-operation "a\nb\n" "delete-lines" "a" nil nil)
+ "b\n")))
+
+(ert-deftest test-update-text-file-apply-operation-error-unknown ()
+ "Unknown operation signals."
+ (should-error (cj/update-text-file--apply-operation "x" "frobnicate" nil nil nil)))
+
+;; ----------------------------------------------------- validate-path
+
+(ert-deftest test-update-text-file-validate-path-normal ()
+ "Normal: an existing readable+writable file under HOME passes."
+ (let* ((file (make-temp-file "test-update-text-file-")))
+ (unwind-protect
+ (progn
+ ;; make-temp-file may land in /tmp; rebase to HOME for the test.
+ (let* ((home-file (expand-file-name
+ (concat ".test-update-text-file-" (format-time-string "%s") ".tmp")
+ "~")))
+ (unwind-protect
+ (progn
+ (copy-file file home-file t)
+ (should (equal (cj/update-text-file--validate-path home-file)
+ (file-truename home-file))))
+ (when (file-exists-p home-file) (delete-file home-file)))))
+ (when (file-exists-p file) (delete-file file)))))
+
+(ert-deftest test-update-text-file-validate-path-error-missing ()
+ "Error: a missing file under HOME signals."
+ (let ((path (expand-file-name
+ (concat ".test-update-text-file-missing-"
+ (format-time-string "%s") ".tmp")
+ "~")))
+ (when (file-exists-p path) (delete-file path))
+ (should-error (cj/update-text-file--validate-path path))))
+
+(ert-deftest test-update-text-file-validate-path-error-outside-home ()
+ "Error: a path outside HOME signals."
+ (should-error (cj/update-text-file--validate-path "/etc/hostname")))
+
+(ert-deftest test-update-text-file-validate-path-error-directory ()
+ "Error: a directory signals."
+ (should-error (cj/update-text-file--validate-path "~")))
+
+(ert-deftest test-update-text-file-validate-path-error-unreadable ()
+ "Error: an unreadable file signals."
+ (test-update-text-file--in-home
+ "unreadable" "secret\n"
+ (lambda (path)
+ (cl-letf (((symbol-function 'file-readable-p) (lambda (_) nil)))
+ (should-error (cj/update-text-file--validate-path path))))))
+
+(ert-deftest test-update-text-file-validate-path-error-unwritable ()
+ "Error: an unwritable file signals."
+ (test-update-text-file--in-home
+ "unwritable" "locked\n"
+ (lambda (path)
+ (cl-letf (((symbol-function 'file-writable-p) (lambda (_) nil)))
+ (should-error (cj/update-text-file--validate-path path))))))
+
+(ert-deftest test-update-text-file-validate-path-boundary-relative-home-path ()
+ "Boundary: a relative path resolves under HOME."
+ (test-update-text-file--in-home
+ "relative" "ok\n"
+ (lambda (path)
+ (let ((relative (file-relative-name path (expand-file-name "~"))))
+ (should (equal (cj/update-text-file--validate-path relative)
+ (file-truename path)))))))
+
+(ert-deftest test-update-text-file-validate-path-boundary-symlink-inside-home ()
+ "Boundary: a symlink inside HOME resolving inside HOME is accepted."
+ (test-update-text-file--in-home
+ "symlink-target" "ok\n"
+ (lambda (target)
+ (let ((link (expand-file-name
+ (format ".test-update-text-file-link-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link target link t)
+ (should (equal (cj/update-text-file--validate-path link)
+ (file-truename target))))
+ (when (file-symlink-p link) (delete-file link)))))))
+
+(ert-deftest test-update-text-file-validate-path-error-symlink-outside-home ()
+ "Error: a symlink inside HOME pointing outside HOME is rejected."
+ (let ((outside (make-temp-file "test-update-text-file-outside-"))
+ (link (expand-file-name
+ (format ".test-update-text-file-outside-link-%s.tmp"
+ (format-time-string "%s%N"))
+ "~")))
+ (unwind-protect
+ (progn
+ (make-symbolic-link outside link t)
+ (should-error (cj/update-text-file--validate-path link)))
+ (when (file-exists-p outside) (delete-file outside))
+ (when (file-symlink-p link) (delete-file link)))))
+
+;; ----------------------------------------------------- backup-name
+
+(ert-deftest test-update-text-file-backup-name-shape ()
+ "Backup names append a timestamped .bak suffix."
+ (let ((name (cj/update-text-file--backup-name "/home/user/foo.txt")))
+ (should (string-prefix-p "/home/user/foo.txt-" name))
+ (should (string-suffix-p ".bak" name))
+ ;; Format is YYYY-MM-DD-HHMMSS.
+ (should (string-match-p "-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{6\\}\\.bak\\'"
+ name))))
+
+;; ----------------------------------------------------- file-level wrapper
+
+(defun test-update-text-file--in-home (suffix content fn)
+ "Write CONTENT to a temp file under HOME with SUFFIX, call FN, then delete.
+Backups (path-TS.bak) are cleaned up after FN returns."
+ (let* ((name (format ".test-update-text-file-%s-%s.tmp"
+ suffix (format-time-string "%s%N")))
+ (path (expand-file-name name "~")))
+ (unwind-protect
+ (progn
+ (with-temp-file path (insert content))
+ (funcall fn path))
+ (when (file-exists-p path) (delete-file path))
+ (dolist (b (file-expand-wildcards (concat path "-*.bak")))
+ (when (file-exists-p b) (delete-file b))))))
+
+(ert-deftest test-update-text-file-run-replace-normal ()
+ "Wrapper: replace operation rewrites the file and creates a backup."
+ (test-update-text-file--in-home
+ "replace" "alpha bravo alpha\n"
+ (lambda (path)
+ (let ((result (cj/update-text-file--run path "replace" "alpha" "GAMMA" nil)))
+ (should (string-match-p "Updated" result))
+ (should (string-match-p "backup:" result))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "GAMMA bravo GAMMA\n")))
+ (let ((backup (car (file-expand-wildcards (concat path "-*.bak")))))
+ (should backup)
+ (with-temp-buffer
+ (insert-file-contents backup)
+ (should (equal (buffer-string) "alpha bravo alpha\n"))))))))
+
+(ert-deftest test-update-text-file-run-no-change-no-backup ()
+ "Wrapper: no-op operation leaves the file untouched and creates no backup."
+ (test-update-text-file--in-home
+ "noop" "abc\n"
+ (lambda (path)
+ (let ((result (cj/update-text-file--run path "replace" "zzz" "QQ" nil)))
+ (should (string-match-p "No changes" result))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "abc\n")))
+ (should-not (file-expand-wildcards (concat path "-*.bak")))))))
+
+(ert-deftest test-update-text-file-run-append-normal ()
+ "Wrapper: append operation adds a line to the file."
+ (test-update-text-file--in-home
+ "append" "first\n"
+ (lambda (path)
+ (cj/update-text-file--run path "append" "second" nil nil)
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "first\nsecond\n"))))))
+
+(ert-deftest test-update-text-file-run-insert-at-line-normal ()
+ "Wrapper: insert-at-line inserts and rewrites the file."
+ (test-update-text-file--in-home
+ "insert" "a\nb\nc\n"
+ (lambda (path)
+ (cj/update-text-file--run path "insert-at-line" "X" nil 2)
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "a\nX\nb\nc\n"))))))
+
+(ert-deftest test-update-text-file-run-delete-lines-normal ()
+ "Wrapper: delete-lines removes matching lines."
+ (test-update-text-file--in-home
+ "delete" "keep1\nkill\nkeep2\nkill\n"
+ (lambda (path)
+ (cj/update-text-file--run path "delete-lines" "kill" nil nil)
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "keep1\nkeep2\n"))))))
+
+(ert-deftest test-update-text-file-run-error-transform-leaves-file-unchanged ()
+ "Wrapper: transform errors create no backup and leave the file unchanged."
+ (test-update-text-file--in-home
+ "transform-error" "abc\n"
+ (lambda (path)
+ (should-error (cj/update-text-file--run path "replace" "" "x" nil))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "abc\n")))
+ (should-not (file-expand-wildcards (concat path "-*.bak"))))))
+
+(ert-deftest test-update-text-file-run-error-unknown-operation-leaves-file-unchanged ()
+ "Wrapper: unknown operations create no backup and leave the file unchanged."
+ (test-update-text-file--in-home
+ "unknown-operation" "abc\n"
+ (lambda (path)
+ (should-error (cj/update-text-file--run path "frobnicate" "x" nil nil))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "abc\n")))
+ (should-not (file-expand-wildcards (concat path "-*.bak"))))))
+
+(ert-deftest test-update-text-file-run-error-too-large-leaves-file-unchanged ()
+ "Wrapper: the size guard errors before backup/write."
+ (test-update-text-file--in-home
+ "too-large" "abcdef\n"
+ (lambda (path)
+ (let ((cj/update-text-file--size-limit 3))
+ (should-error (cj/update-text-file--run path "append" "x" nil nil)))
+ (with-temp-buffer
+ (insert-file-contents path)
+ (should (equal (buffer-string) "abcdef\n")))
+ (should-not (file-expand-wildcards (concat path "-*.bak"))))))
+
+(ert-deftest test-update-text-file-run-error-missing-file ()
+ "Wrapper: missing file signals."
+ (let ((path (expand-file-name
+ (concat ".test-update-text-file-absent-"
+ (format-time-string "%s") ".tmp")
+ "~")))
+ (when (file-exists-p path) (delete-file path))
+ (should-error (cj/update-text-file--run path "append" "x" nil nil))))
+
+(ert-deftest test-update-text-file-run-error-outside-home ()
+ "Wrapper: path outside home signals."
+ (should-error (cj/update-text-file--run "/etc/hostname" "append" "x" nil nil)))
+
+(provide 'test-update-text-file)
+;;; test-update-text-file.el ends here
diff --git a/archive/gptel/tests/testutil-ai-config.el b/archive/gptel/tests/testutil-ai-config.el
new file mode 100644
index 000000000..c74862226
--- /dev/null
+++ b/archive/gptel/tests/testutil-ai-config.el
@@ -0,0 +1,81 @@
+;;; testutil-ai-config.el --- Test stubs for ai-config.el tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Provides gptel and dependency stubs so ai-config.el can be loaded in
+;; batch mode without the real gptel package. Must be required BEFORE
+;; ai-config so stubs are in place when use-package :config runs.
+
+;;; Code:
+
+(setq load-prefer-newer t)
+
+;; Keep ai-config tests isolated from personal optional GPTel tool files.
+(defvar cj/gptel-tools-directory (make-temp-file "gptel-tools-empty-" t))
+(defvar cj/gptel-local-tool-features nil)
+
+;; Pre-cache API keys so auth-source is never consulted
+(defvar cj/anthropic-api-key-cached "test-anthropic-key")
+(defvar cj/openai-api-key-cached "test-openai-key")
+
+;; Stub gptel variables (must exist before use-package :custom runs)
+(defvar gptel-backend nil)
+(defvar gptel-model nil)
+(defvar gptel-mode nil)
+(defvar gptel-prompt-prefix-alist nil)
+(defvar gptel--debug nil)
+(defvar gptel-default-mode nil)
+(defvar gptel-expert-commands nil)
+(defvar gptel-track-media nil)
+(defvar gptel-include-reasoning nil)
+(defvar gptel-log-level nil)
+(defvar gptel-confirm-tool-calls nil)
+(defvar gptel-directives nil)
+(defvar gptel--system-message nil)
+(defvar gptel-context--alist nil)
+(defvar gptel-mode-map (make-sparse-keymap))
+(defvar gptel-post-response-functions nil)
+
+;; Stub gptel functions
+(defun gptel-make-anthropic (name &rest _args)
+ "Stub: return a vector mimicking a gptel backend struct."
+ (vector 'cl-struct-gptel-backend name))
+
+(defun gptel-make-openai (name &rest _args)
+ "Stub: return a vector mimicking a gptel backend struct."
+ (vector 'cl-struct-gptel-backend name))
+
+(defun gptel-send (&rest _) "Stub." nil)
+(defun gptel-menu (&rest _) "Stub." nil)
+(defun gptel (&rest _) "Stub." nil)
+(defun gptel-system-prompt (&rest _) "Stub." nil)
+(defun gptel-rewrite (&rest _) "Stub." nil)
+(defun gptel-add-file (&rest _) "Stub." nil)
+(defun gptel-add (&rest _) "Stub." nil)
+(defun gptel-backend-models (_backend) "Stub." nil)
+
+(provide 'gptel)
+(provide 'gptel-context)
+
+;; Stub custom keymap (defined in user's keybinding config)
+(defvar cj/custom-keymap (make-sparse-keymap))
+
+;; Stub which-key
+(unless (fboundp 'which-key-add-key-based-replacements)
+ (defun which-key-add-key-based-replacements (&rest _) "Stub." nil))
+(provide 'which-key)
+
+;; Stub gptel-prompts
+(defun gptel-prompts-update (&rest _) "Stub." nil)
+(defun gptel-prompts-add-update-watchers (&rest _) "Stub." nil)
+(provide 'gptel-prompts)
+
+;; NOTE: gptel-magit is NOT stubbed here. ai-config.el now uses
+;; with-eval-after-load 'magit instead of use-package gptel-magit,
+;; so the magit integration only activates when magit is provided.
+;; See test-ai-config-gptel-magit-lazy-loading.el for magit stub tests.
+
+;; Stub ai-conversations
+(provide 'ai-conversations)
+
+(provide 'testutil-ai-config)
+;;; testutil-ai-config.el ends here
diff --git a/archive/gptel/tests/testutil-filesystem.el b/archive/gptel/tests/testutil-filesystem.el
new file mode 100644
index 000000000..b1970b62d
--- /dev/null
+++ b/archive/gptel/tests/testutil-filesystem.el
@@ -0,0 +1,180 @@
+;;; testutil-filesystem.el --- -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; This library provides reusable helper functions for GPTel filesystem tools.
+;;
+;; It uses f.el and core Emacs libraries for path manipulation, directory listing,
+;; file info retrieval, filtering, and recursive traversal.
+;;
+;; Designed to be used by multiple tools that operate on the filesystem.
+;;
+;;; Code:
+
+(require 'f)
+(require 'cl-lib)
+(require 'subr-x)
+
+;; Get directory entries in PATH. Returns list of absolute paths.
+;; Default excludes hidden files and directories (name begins with dot).
+;; Optional INCLUDE-HIDDEN to include hidden entries.
+;; Optional FILTER-PREDICATE is a function called on each absolute path to filter.
+(defun cj/get--directory-entries (path &optional include-hidden filter-predicate)
+ "Return a list of entries (absolute paths) in directory PATH.
+Entries exclude '.' and '..'.
+By default, hidden entries (starting with '.') are excluded unless
+INCLUDE-HIDDEN is non-nil. FILTER-PREDICATE, if non-nil, is a predicate
+function called on each entry's absolute path; only entries where it returns
+non-nil are included."
+ ;; Convert 'path' to an absolute filename string
+ (let* ((expanded-path (expand-file-name path))
+ ;; get absolute paths in expanded directory
+ (entries (directory-files expanded-path t nil t))
+ ;; remove "." ".." entries
+ (filtered-entries
+ (cl-remove-if
+ (lambda (entry)
+ (or (member (f-filename entry) '("." ".."))
+ ;; and hidden files include-hidden is non-nil.
+ (and (not include-hidden)
+ (string-prefix-p "." (f-filename entry)))))
+ entries)))
+ ;; apply filtered predicate if provided
+ (if filter-predicate
+ (seq-filter filter-predicate filtered-entries)
+ ;; retun filtered-entries
+ filtered-entries)))
+
+(defun cj/get-file-info (path)
+ "Get file information for PATH.
+Returned plist keys:
+:success t or nil
+:error string error message if :success is nil
+:path absolute file path (string)
+:size file size (integer)
+:last-modified last modification time (time value)
+:directory boolean: t if a directory
+:permissions string with symbolic permissions, e.g. \"drwxr-xr-x\"
+:executable boolean: t if executable file
+:owner string: owner name or UID if name unavailable
+:group string: group name or GID if name unavailable"
+ ;; handle errors during evaluation
+ (condition-case err
+ (let* ((expanded-path (expand-file-name path)))
+ (if (not (file-readable-p expanded-path))
+ ;; Explicit permission denied check
+ (list :success nil :path expanded-path :error
+ (format "Permission denied: %s" expanded-path))
+ (let*
+ ;; t = return string names for uid/gid
+ ((attrs (file-attributes expanded-path t))
+ (size (file-attribute-size attrs))
+ (mod (file-attribute-modification-time attrs))
+ (dirp (eq t (file-attribute-type attrs)))
+ (modes (file-modes expanded-path))
+ (perm (cj/-mode-to-permissions modes))
+ (execp (file-executable-p expanded-path))
+ (owner (file-attribute-user-id attrs)) ; Get owner
+ (group (file-attribute-group-id attrs))) ; Get group
+ (list :success t :path expanded-path :size size :last-modified mod
+ :directory dirp :permissions perm :executable execp
+ :owner (or owner "unknown")
+ :group (or group "unknown")))))
+ ;; if error, return failure plist with error info
+ (error (list :success nil :path path :error (error-message-string err)))))
+
+(defun cj/format-file-info (file-info base-path)
+ "Format FILE-INFO plist relative to BASE-PATH as a string.
+Handles missing keys gracefully by supplying default values."
+ (let ((permissions (or (plist-get file-info :permissions) ""))
+ (executable (if (plist-get file-info :executable) "*" " "))
+ (size (file-size-human-readable (or (plist-get file-info :size) 0)))
+ (last-modified (or (plist-get file-info :last-modified) (current-time)))
+ (path (or (plist-get file-info :path) base-path)))
+ (format " %s%s %10s %s %s"
+ permissions
+ executable
+ size
+ (format-time-string "%Y-%m-%d %H:%M" last-modified)
+ (file-relative-name path base-path))))
+
+;; Convert file mode bits integer to string like ls -l, e.g. drwxr-xr-x
+(defun cj/-mode-to-permissions (mode)
+ "Convert file MODE (returned by `file-modes') to symbolic permission string."
+ (concat
+ (if (eq (logand #o40000 mode) #o40000) "d" "-")
+ (mapconcat
+ (lambda (bits)
+ (concat (if (/= 0 (logand bits 4)) "r" "-")
+ (if (/= 0 (logand bits 2)) "w" "-")
+ (if (/= 0 (logand bits 1)) "x" "-")))
+ (list (logand (/ mode 64) 7)
+ (logand (/ mode 8) 7)
+ (logand mode 7))
+ "")))
+
+;; Filter a list of file info plists by extension (case insensitive).
+;; Always includes directories.
+(defun cj/filter-by-extension (file-info-list extension)
+ "Keep only directories and files with EXTENSION from FILE-INFO-LIST.
+EXTENSION should not include leading dot, e.g. \"org\"."
+ ;; return full list if no extension
+ (if (not extension)
+ file-info-list
+ (cl-remove-if-not
+ (lambda (fi)
+ ;; always keep directories
+ (or (plist-get fi :directory)
+ ;; and successful file entries
+ (and (plist-get fi :success)
+ ;; and file extensions that match case-insensitively
+ (string-suffix-p (concat "." extension)
+ (f-filename (plist-get fi :path))
+ t))))
+ file-info-list)))
+
+(defun cj/list-directory-recursive (path &optional include-hidden filter-predicate max-depth)
+ "Recursively list files under PATH applying FILTER-PREDICATE.
+PATH is the directory to list.
+INCLUDE-HIDDEN if non-nil, includes hidden files (those starting with '.').
+FILTER-PREDICATE, if non-nil, is a function called on file info plist and
+returns non-nil to include file.
+MAX-DEPTH limits recursion depth (nil or 0 = unlimited)."
+ ;; set up cl-recursive function with path and current depth
+ (cl-labels ((recurse (path depth)
+ (let ((expanded-path (expand-file-name path))
+ ;; empty list to accumulate file info plists
+ (file-info-list '()))
+ ;; ensure we're working with directories only
+ (when (not (file-directory-p expanded-path))
+ (error "Not a directory: %s" expanded-path))
+
+ ;; loop over each file in the path
+ (dolist (file-entry
+ (cj/get--directory-entries expanded-path include-hidden))
+ ;; get the metadata for the file
+ (let ((file-metadata (cj/get-file-info file-entry)))
+ ;; if retrieving metadata was successful
+ (when (and file-metadata (plist-get file-metadata :success))
+ ;; if there's no custom filter or it matches, add it to the list
+ (when (or (not filter-predicate)
+ (funcall filter-predicate file-metadata))
+ (push file-metadata file-info-list))
+ ;; if it's a directory and we're not at the max-depth
+ (when (and (plist-get file-metadata :directory)
+ (or (not max-depth) (< depth (1- max-depth))))
+ ;; gather all the files and recurse with that file
+ (setq file-info-list
+ (nconc file-info-list (recurse file-entry (1+ depth)))))
+ ;; warn if recursion returned received both a success and error
+ (when (and (plist-get file-metadata :success)
+ (plist-get file-metadata :error))
+ (message "Warning: %s" (plist-get file-metadata :error))))))
+ ;; restore the file order (as they were pushed into reverse order)
+ (nreverse file-info-list))))
+ ;; start recursion at the top level
+ (recurse path 0)))
+
+(provide 'testutil-filesystem)
+;;; testutil-filesystem.el ends here.