system-manager-wakasu
  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