flake-update-20260505
1;;; goose.el --- Integrate Goose CLI via vterm -*- lexical-binding: t; -*-
2
3;; Author: Daisuke Terada <pememo@gmail.com>
4;; Package-Requires: ((emacs "29") (vterm "0.0.2") (transient "0.9.1") (consult "2.5"))
5;; Version: 0.1.0
6;; Keywords: tools, convenience, ai
7;; URL: https://github.com/aq2bq/goose.el
8
9;;; Commentary:
10;; Seamless integration of the Goose CLI within Emacs using the `vterm` terminal emulator.
11;;
12;; Provides:
13;; - Intuitive session management: start and restart Goose CLI sessions with easy labeling (name or timestamp)
14;; - Immediate context injection (file path, buffer, region, template, text) into the Goose prompt, sent directly (no internal queuing)
15;; - Prompt templates (consult-based), auto-detected from ~/.config/goose/prompts/
16;; - Customizable context formatting, prompt directory, and keybinding (transient menu)
17;; - Designed for rapid AI prompt iteration and CLI-interactive workflows from Emacs
18;;
19;; Usage example:
20;; M-x goose-start-session
21;; M-x goose-add-context-buffer ; send current buffer to the Goose session
22;;
23
24;; The MIT License (MIT)
25;;
26;; Copyright (c) 2025 Daisuke Terada
27;;
28;; Permission is hereby granted, free of charge, to any person obtaining a copy
29;; of this software and associated documentation files (the "Software"), to deal
30;; in the Software without restriction, including without limitation the rights
31;; to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
32;; copies of the Software, and to permit persons to whom the Software is
33;; furnished to do so, subject to the following conditions:
34;;
35;; The above copyright notice and this permission notice shall be included in all
36;; copies or substantial portions of the Software.
37;;
38;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
39;; IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
40;; FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
41;; AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
42;; LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
43;; OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
44;; SOFTWARE.
45
46;;; Code:
47(require 'vterm)
48(require 'transient)
49(require 'consult)
50
51(define-derived-mode goose-mode vterm-mode "Goose"
52 "Major mode for Goose terminal (inherits `vterm-mode').
53This mode provides `goose-mode-hook` for customizations.
54Do not call this mode interactively. Use `goose-start-session` instead."
55 (when (called-interactively-p 'interactive)
56 (user-error "`goose-mode` is an internal mode. Use `M-x goose-start-session` instead")))
57
58;; Prevent interactive use from misapplying to the current buffer
59(put 'goose-mode 'function-documentation
60 "Goose terminal mode. Do not call directly. Use `goose-start-session` instead.")
61(put 'goose-mode 'interactive-form
62 '(progn (user-error "`goose-mode` is not meant to be called interactively. Use `M-x goose-start-session` instead")))
63
64
65(defgroup goose nil
66 "Goose CLI integration using vterm."
67 :group 'tools)
68
69(defcustom goose-program-name "goose"
70 "Name or path of the Goose CLI executable."
71 :type 'string
72 :group 'goose)
73
74(defcustom goose-default-buffer-name "*goose*"
75 "Default buffer name prefix for Goose sessions."
76 :type 'string
77 :group 'goose)
78
79(defcustom goose-prompt-directory (expand-file-name "~/.config/goose/prompts/")
80 "Directory containing prompt templates for Goose integration."
81 :type 'directory
82 :group 'goose)
83
84(defcustom goose-context-format "%s"
85 "Format string applied to CONTEXT text before sending to Goose.
86Use %s as placeholder for the raw text."
87 :type 'string
88 :group 'goose)
89
90(defcustom goose-context-file-path-prefix "File from path: %s"
91 "Prefix format for inserting file path into Goose. %s will be replaced by the file path."
92 :type 'string
93 :group 'goose)
94
95(defcustom goose-context-buffer-prefix "File: %s\n%s"
96 "Prefix format for inserting buffer content into Goose. %s will be replaced by file path and buffer content."
97 :type 'string
98 :group 'goose)
99
100(defcustom goose-context-region-prefix "File: %s\nRegion:\n%s"
101 "Prefix format for inserting region content into Goose. %s will be replaced by file path and region."
102 :type 'string
103 :group 'goose)
104
105(defvar goose--last-args nil
106 "Last Goose CLI argument list for restart.")
107
108(defvar goose--last-label nil
109 "Last session label for restart.")
110
111(defun goose--session-label (name)
112 "Return session label for NAME, or timestamp string if NAME is empty."
113 (if (and name (not (string-empty-p name)))
114 (shell-quote-argument name)
115 (format-time-string "%Y%m%d-%H%M%S")))
116
117(defun goose--build-args (name)
118 "Construct Goose CLI argument list for 'session --name NAME'."
119 (let* ((label (goose--session-label name))
120 (base-args (list "session" "--name" label)))
121 base-args))
122
123(defun goose--run-session (label args)
124 "Start or restart a Goose session buffer labeled LABEL with ARGS list."
125 (let ((bufname (format "%s<%s>" goose-default-buffer-name label)))
126 (when (get-buffer bufname)
127 (kill-buffer bufname))
128 (let* ((proj (when (fboundp 'project-current) (project-current)))
129 (root (when proj (project-root proj)))
130 (default-directory (or root default-directory))
131 (vterm-buffer (generate-new-buffer bufname)))
132 (with-current-buffer vterm-buffer
133 (let ((vterm-shell "/bin/bash"))
134 (goose-mode)
135 (rename-buffer bufname t)
136 (vterm-send-key "l" nil nil :ctrl) ;; suppress any previous output
137 (vterm-send-string
138 (mapconcat #'identity (cons goose-program-name args) " "))
139 (vterm-send-return)))
140 (setq goose--last-label label
141 goose--last-args args)
142 (pop-to-buffer vterm-buffer)
143 (message "Goose session started in buffer %s (dir: %s)" bufname default-directory))))
144
145;;;###autoload
146(defun goose-start-session (&optional name)
147 "Start a new Goose session with optional NAME, or switch if exists."
148 (interactive "sSession name (optional): ")
149 (let ((label (goose--session-label name))
150 (args (goose--build-args name)))
151 (goose--run-session label args)))
152
153;;;###autoload
154(defun goose-restart-session ()
155 "Restart the last Goose session using previous NAME and ARGS, with confirmation."
156 (interactive)
157 (unless (and goose--last-label goose--last-args)
158 (error "No Goose session to restart"))
159 (when (yes-or-no-p (format "Restart Goose session <%s>? " goose--last-label))
160 (goose--run-session goose--last-label goose--last-args)))
161
162(defun goose--session-buffer-name ()
163 "Return the current Goose session buffer name."
164 (format "%s<%s>" goose-default-buffer-name goose--last-label))
165
166(defun goose--insert-context (text)
167 "Send TEXT as input to the current Goose session, deferring execution until RET.
168Applies `goose-context-format` to TEXT before sending.
169If the session is not started, starts it automatically."
170 (let* ((bufname (goose--session-buffer-name))
171 (buf (get-buffer bufname)))
172 (unless buf
173 (goose-start-session)
174 (setq buf (get-buffer (goose--session-buffer-name))))
175 (with-current-buffer buf
176 (vterm-send-string (format goose-context-format text))
177 (vterm-send-key "j" nil nil :ctrl))))
178
179;;;###autoload
180(defun goose-add-context-file-path ()
181 "Insert the current buffer's file path into the Goose prompt."
182 (interactive)
183 (unless (buffer-file-name) (error "Buffer is not visiting a file"))
184 (goose--insert-context (format goose-context-file-path-prefix (buffer-file-name)))
185 (message "Inserted file path into prompt"))
186
187;;;###autoload
188(defun goose-add-context-buffer ()
189 "Insert the current buffer's content and file path into the Goose prompt."
190 (interactive)
191 (goose--insert-context
192 (format goose-context-buffer-prefix
193 (or (buffer-file-name) "<no file>")
194 (buffer-string)))
195 (message "Inserted buffer content into prompt"))
196
197;;;###autoload
198(defun goose-add-context-region ()
199 "Insert the active region's content and file path into the Goose prompt."
200 (interactive)
201 (unless (use-region-p) (error "No region selected"))
202 (goose--insert-context
203 (format goose-context-region-prefix
204 (or (buffer-file-name) "<no file>")
205 (buffer-substring-no-properties
206 (region-beginning)
207 (region-end))))
208 (message "Inserted region into prompt"))
209
210;;;###autoload
211(defun goose-add-context-template ()
212 "Insert a prompt template from `goose-prompt-directory' into the Goose prompt."
213 (interactive)
214 (unless (file-directory-p goose-prompt-directory)
215 (error "Prompt directory %s does not exist" goose-prompt-directory))
216 (let* ((files (directory-files goose-prompt-directory nil "^[^.].*"))
217 (choice (consult--read files :prompt "Choose template: "))
218 (content (with-temp-buffer
219 (insert-file-contents
220 (expand-file-name choice goose-prompt-directory))
221 (buffer-string))))
222 (goose--insert-context content)
223 (message "Inserted template %s into prompt" choice)))
224
225;;;###autoload
226(defun goose-add-context-text (text)
227 "Prompt for and insert arbitrary TEXT into the Goose prompt."
228 (interactive "sText to insert: ")
229 (goose--insert-context text)
230 (message "Inserted text into prompt"))
231
232;;;###autoload
233(transient-define-prefix goose-transient ()
234 "Transient interface for Goose commands."
235 ["Goose Session"
236 ("s" "Start session" goose-start-session)
237 ("r" "Restart session" goose-restart-session)]
238 ["Insert Context"
239 ("f" "File path" goose-add-context-file-path)
240 ("b" "Buffer" goose-add-context-buffer)
241 ("e" "Region" goose-add-context-region)
242 ("t" "Template" goose-add-context-template)
243 ("x" "Text" goose-add-context-text)])
244
245
246(provide 'goose)
247;;; goose.el ends here