diff --git a/agent-shell-cursor.el b/agent-shell-cursor.el index ee2d3c1c..0603e49a 100644 --- a/agent-shell-cursor.el +++ b/agent-shell-cursor.el @@ -29,6 +29,7 @@ (require 'cl-lib)) (require 'shell-maker) (require 'acp) +(require 'json) (declare-function agent-shell--indent-string "agent-shell") (declare-function agent-shell-make-agent-config "agent-shell") @@ -36,6 +37,40 @@ (declare-function agent-shell--make-acp-client "agent-shell") (declare-function agent-shell--dwim "agent-shell") +(cl-defun agent-shell-cursor-make-authentication (&key api-key login) + "Create Cursor authentication configuration. + +API-KEY is the Cursor API key string or function that returns it. +When set, the key is injected into the CLI as --api-key per +https://cursor.com/docs/cli/acp. +LOGIN when non-nil indicates to use login-based authentication." + (when (> (seq-count #'identity (list api-key login)) 1) + (error "Cannot specify multiple authentication methods - choose one")) + (unless (> (seq-count #'identity (list api-key login)) 0) + (error "Must specify one of :api-key, :login")) + (cond + (api-key `((:api-key . ,api-key))) + (login `((:login . t))))) + +(defcustom agent-shell-cursor-authentication + (agent-shell-cursor-make-authentication :login t) + "Configuration for Cursor authentication. + +For login-based authentication (default, run \"agent login\" first): + + (setq agent-shell-cursor-authentication + (agent-shell-cursor-make-authentication :login t)) + +For API key (injected into CLI as --api-key): + + (setq agent-shell-cursor-authentication + (agent-shell-cursor-make-authentication :api-key \"your-key\")) + + (setq agent-shell-cursor-authentication + (agent-shell-cursor-make-authentication :api-key (lambda () (getenv \"CURSOR_API_KEY\"))))" + :type 'alist + :group 'agent-shell) + (defcustom agent-shell-cursor-acp-command '("cursor-agent-acp") "Command and parameters for the Cursor agent client. @@ -44,6 +79,15 @@ The first element is the command name, and the rest are command parameters." :type '(repeat string) :group 'agent-shell) +(defcustom agent-shell-cursor-default-session-mode-id + nil + "Default Cursor session mode ID. + +Must be one of the session ID's displayed under \"Available modes\" +when starting a new shell." + :type '(choice (const nil) string) + :group 'agent-shell) + (defcustom agent-shell-cursor-environment nil "Environment variables for the Cursor agent client. @@ -53,6 +97,13 @@ starting the Cursor agent process." :type '(repeat string) :group 'agent-shell) +(defcustom agent-shell-cursor--todos-icon "☑️" + "Icon displayed during the AI's thought process. + +You may use \"􁷘\" as an SF Symbol on macOS." + :type 'string + :group 'agent-shell) + (defun agent-shell-cursor-make-agent-config () "Create a Cursor agent configuration. @@ -67,7 +118,12 @@ Returns an agent configuration alist using `agent-shell-make-agent-config'." :welcome-function #'agent-shell-cursor--welcome-message :client-maker (lambda (buffer) (agent-shell-cursor-make-client :buffer buffer)) - :install-instructions "Install with: npm install -g @blowmage/cursor-agent-acp\nSee https://github.com/blowmage/cursor-agent-acp-npm for details.")) + :needs-authentication t + :authenticate-request-maker (lambda () + (acp-make-authenticate-request :method-id "cursor_login")) + :default-session-mode-id (lambda () agent-shell-cursor-default-session-mode-id) + :install-instructions "See https://cursor.com/docs/cli/acp for installation" + :request-handlers '(("_cursor/create_plan" . agent-shell-cursor--on-create-plan)))) (defun agent-shell-cursor-start-agent () "Start an interactive Cursor agent shell." @@ -75,16 +131,114 @@ Returns an agent configuration alist using `agent-shell-make-agent-config'." (agent-shell--dwim :config (agent-shell-cursor-make-agent-config) :new-shell t)) +(defun agent-shell-cursor-api-key () + "Get the Cursor API key from `agent-shell-cursor-authentication'." + (cond ((stringp (map-elt agent-shell-cursor-authentication :api-key)) + (map-elt agent-shell-cursor-authentication :api-key)) + ((functionp (map-elt agent-shell-cursor-authentication :api-key)) + (condition-case _err + (funcall (map-elt agent-shell-cursor-authentication :api-key)) + (error + (error "Cursor API key not found. Check `agent-shell-cursor-authentication'")))) + (t + nil))) + (cl-defun agent-shell-cursor-make-client (&key buffer) - "Create a Cursor agent ACP client with BUFFER as context." + "Create a Cursor agent ACP client with BUFFER as context. + +When :api-key is set in `agent-shell-cursor-make-authentication', injects +--api-key and the key into the CLI command per https://cursor.com/docs/cli/acp." (unless buffer (error "Missing required argument: :buffer")) (when (and (boundp 'agent-shell-cursor-command) agent-shell-cursor-command) (user-error "Please migrate to use agent-shell-cursor-acp-command and eval (setq agent-shell-cursor-command nil)")) - (agent-shell--make-acp-client :command (car agent-shell-cursor-acp-command) - :command-params (cdr agent-shell-cursor-acp-command) - :environment-variables agent-shell-cursor-environment - :context-buffer buffer)) + (cond + ((map-elt agent-shell-cursor-authentication :api-key) + (let ((api-key (agent-shell-cursor-api-key))) + (unless api-key + (user-error "Please set your `agent-shell-cursor-authentication' with :api-key")) + (let ((command-list (append (list (car agent-shell-cursor-acp-command) "--api-key" api-key) + (cdr agent-shell-cursor-acp-command)))) + (agent-shell--make-acp-client :command (car command-list) + :command-params (cdr command-list) + :environment-variables agent-shell-cursor-environment + :context-buffer buffer)))) + ((map-elt agent-shell-cursor-authentication :login) + (agent-shell--make-acp-client :command (car agent-shell-cursor-acp-command) + :command-params (cdr agent-shell-cursor-acp-command) + :environment-variables agent-shell-cursor-environment + :context-buffer buffer)) + (t + (error "Invalid authentication configuration. Set `agent-shell-cursor-authentication'")))) + +(defun agent-shell-cursor--format-todos-as-markdown (todos) + "Format TODOS list (from cursor/update_todos params) as human-readable markdown. + +Each todo has id, content, status. Status: pending, in_progress, completed." + (mapconcat + (lambda (todo) + (let* ((content (or (map-elt todo 'content) "")) + (status (or (map-elt todo 'status) "pending")) + (marker (cond + ((member status '("completed" "done")) "- [x] ") + ((equal status "in_progress") "- [~] ") + (t "- [ ] "))) + (status-label (cond + ((member status '("completed" "done")) "done") + ((equal status "in_progress") "in progress") + (t nil)))) + (format "%s%s%s" + marker + content + (when status-label + (format " *(%s)*" status-label))))) + todos + "\n")) + +(defun agent-shell-cursor--on-update-todos-display (state acp-message) + "Display todos from ACP-MESSAGE in agent shell as a fragment. +STATE and ACP-MESSAGE are the handler arguments (request or notification)." + (let* ((params (or (map-elt acp-message 'params) (list))) + (todos (map-elt params 'todos)) + (body (or (map-elt params 'body) (map-elt params 'message))) + (content (condition-case _err + (cond + ((and (seqp todos) (seq-length todos)) + (agent-shell-cursor--format-todos-as-markdown todos)) + (body (if (stringp body) body (json-encode body))) + (t (json-encode params))) + (error (json-encode params)))) + (block-id (format "cursor-todos-%s" + (or (map-elt params 'toolCallId) + (format-time-string "%s"))))) + (agent-shell--update-fragment + :state state + :block-id block-id + :label-left (concat + agent-shell-cursor--todos-icon + " " + (propertize "Todos" 'font-lock-face 'font-lock-doc-markup-face)) + :body content + :create-new t + :expanded t + :navigation 'never))) + +(defun agent-shell-cursor--on-update-todos (state acp-request) + "Handle cursor/update_todos ACP request. + +STATE and ACP-REQUEST are as per `agent-shell-make-agent-config' :request-handlers. +Sends success response immediately; displays todos as a fragment in the agent shell." + (agent-shell--send-unhandled-request-response state acp-request) + (agent-shell-cursor--on-update-todos-display state acp-request) + t) + +(defun agent-shell-cursor--on-create-plan (state acp-request) + "Handle _cursor/create_plan ACP request. + +Plan display is already handled via session/update notifications. +This handler only sends a method-not-found error response." + (agent-shell--send-unhandled-request-response state acp-request) + t) (defun agent-shell-cursor--welcome-message (config) "Return Cursor welcome message using `shell-maker' CONFIG." diff --git a/agent-shell.el b/agent-shell.el index 4f18fa62..cd6e3682 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -378,7 +378,8 @@ Each element can be: default-model-id default-session-mode-id icon-name - install-instructions) + install-instructions + request-handlers) "Create an agent configuration alist. Keyword arguments: @@ -395,6 +396,10 @@ Keyword arguments: - DEFAULT-SESSION-MODE-ID: Default session mode ID (function returning value). - ICON-NAME: Name of the icon to use - INSTALL-INSTRUCTIONS: Instructions to show when executable is not found +- REQUEST-HANDLERS: Alist of (method-string . handler-function) for custom ACP + request methods. Handler receives (state acp-request), must call + `acp-send-response' with a valid JSON-RPC response, and return non-nil if + handled. Return nil to fall through to built-in handlers. Returns an alist with all specified values." `((:identifier . ,identifier) @@ -409,7 +414,8 @@ Returns an alist with all specified values." (:default-model-id . ,default-model-id) ;; function (:default-session-mode-id . ,default-session-mode-id) ;; function (:icon-name . ,icon-name) - (:install-instructions . ,install-instructions))) + (:install-instructions . ,install-instructions) + (:request-handlers . ,request-handlers))) (defun agent-shell--make-default-agent-configs () "Create a list of default agent configs. @@ -1472,8 +1478,27 @@ COMMAND, when present, may be a shell command string or an argv vector." :navigation 'never) (map-put! state :last-entry-type nil)))) +(cl-defun agent-shell--send-unhandled-request-response (&key state acp-request) + "Send a method-not-found (-32601) response for unhandled ACP-REQUEST. + +STATE and ACP-REQUEST as per `agent-shell--on-request'." + (let ((request-id (or (map-elt acp-request 'id) (map-elt acp-request "id"))) + (method (or (map-elt acp-request 'method) (map-elt acp-request "method") "unknown"))) + (when request-id + (acp-send-response + :client (map-elt state :client) + :response `((:request-id . ,request-id) + (:error . ,(acp-make-error + :code -32601 + :message (format "Method not found: %s" method)))))))) + (cl-defun agent-shell--on-request (&key state acp-request) "Handle incoming ACP-REQUEST using STATE." + (let ((method (map-elt acp-request 'method)) + (handlers (map-nested-elt (map-elt state :agent-config) '(:request-handlers)))) + (when-let ((handler (and handlers (alist-get method handlers nil nil #'string=)))) + (when (funcall handler state acp-request) + (cl-return-from agent-shell--on-request)))) (cond ((equal (map-elt acp-request 'method) "session/request_permission") (agent-shell--save-tool-call state (map-nested-elt acp-request '(params toolCall toolCallId)) @@ -1520,12 +1545,16 @@ COMMAND, when present, may be a shell command string or an argv vector." :state state :acp-request acp-request)) (t - (agent-shell--update-fragment - :state state - :block-id "Unhandled Incoming Request" - :body (format "⚠ Unhandled incoming request: \"%s\"" (map-elt acp-request 'method)) - :create-new t - :navigation 'never) + (progn + (agent-shell--update-fragment + :state state + :block-id "Unhandled Incoming Request" + :body (format "⚠ Unhandled incoming request: \"%s\"" (map-elt acp-request 'method)) + :create-new t + :navigation 'never) + (agent-shell--send-unhandled-request-response + :state state + :acp-request acp-request)) (map-put! state :last-entry-type nil)))) (cl-defun agent-shell--extract-buffer-text (&key buffer line limit) diff --git a/tests/agent-shell-cursor-tests.el b/tests/agent-shell-cursor-tests.el new file mode 100644 index 00000000..65a1eb81 --- /dev/null +++ b/tests/agent-shell-cursor-tests.el @@ -0,0 +1,67 @@ +;;; agent-shell-cursor-tests.el --- Tests for agent-shell-cursor -*- lexical-binding: t; -*- + +(require 'ert) +(require 'agent-shell) +(require 'agent-shell-cursor) + +(ert-deftest agent-shell-cursor-make-client-api-key-test () + "Test API key auth injects --api-key into CLI command." + (cl-letf (((symbol-function 'executable-find) + (lambda (_) "/usr/bin/cursor-agent"))) + (let* ((agent-shell-cursor-authentication (agent-shell-cursor-make-authentication :api-key "test-api-key")) + (agent-shell-cursor-acp-command '("cursor-agent" "acp")) + (agent-shell-cursor-environment '("DEBUG=1")) + (test-buffer (get-buffer-create "*test-cursor-buffer*")) + (client (agent-shell-cursor-make-client :buffer test-buffer))) + (unwind-protect + (progn + (should (listp client)) + (should (equal (map-elt client :command) "cursor-agent")) + (should (equal (map-elt client :command-params) '("--api-key" "test-api-key" "acp"))) + (should (member "DEBUG=1" (map-elt client :environment-variables)))) + (when (buffer-live-p test-buffer) + (kill-buffer test-buffer)))))) + +(ert-deftest agent-shell-cursor-make-client-api-key-function-test () + "Test function-based API key injection." + (cl-letf (((symbol-function 'executable-find) + (lambda (_) "/usr/bin/cursor-agent"))) + (let* ((agent-shell-cursor-authentication (agent-shell-cursor-make-authentication :api-key (lambda () "dynamic-key"))) + (agent-shell-cursor-acp-command '("cursor-agent" "acp")) + (agent-shell-cursor-environment '()) + (test-buffer (get-buffer-create "*test-cursor-buffer*")) + (client (agent-shell-cursor-make-client :buffer test-buffer))) + (unwind-protect + (should (equal (map-elt client :command-params) '("--api-key" "dynamic-key" "acp"))) + (when (buffer-live-p test-buffer) + (kill-buffer test-buffer)))))) + +(ert-deftest agent-shell-cursor-make-client-login-test () + "Test login-based authentication." + (cl-letf (((symbol-function 'executable-find) + (lambda (_) "/usr/bin/cursor-agent"))) + (let* ((agent-shell-cursor-authentication (agent-shell-cursor-make-authentication :login t)) + (agent-shell-cursor-acp-command '("cursor-agent" "acp")) + (agent-shell-cursor-environment '("DEBUG=1")) + (test-buffer (get-buffer-create "*test-cursor-buffer*")) + (client (agent-shell-cursor-make-client :buffer test-buffer))) + (unwind-protect + (progn + (should (equal (map-elt client :command-params) '("acp"))) + (should (member "DEBUG=1" (map-elt client :environment-variables)))) + (when (buffer-live-p test-buffer) + (kill-buffer test-buffer)))))) + +(ert-deftest agent-shell-cursor-make-client-invalid-auth-test () + "Test error on invalid authentication configuration." + (let* ((agent-shell-cursor-authentication '()) + (agent-shell-cursor-acp-command '("cursor-agent" "acp")) + (test-buffer (get-buffer-create "*test-cursor-buffer*"))) + (unwind-protect + (should-error (agent-shell-cursor-make-client :buffer test-buffer) + :type 'error) + (when (buffer-live-p test-buffer) + (kill-buffer test-buffer))))) + +(provide 'agent-shell-cursor-tests) +;;; agent-shell-cursor-tests.el ends here