Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 160 additions & 6 deletions agent-shell-cursor.el
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,48 @@
(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")
(autoload 'agent-shell-make-agent-config "agent-shell")
(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.
Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -67,24 +118,127 @@ 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."
(interactive)
(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."
Expand Down
45 changes: 37 additions & 8 deletions agent-shell.el
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions tests/agent-shell-cursor-tests.el
Original file line number Diff line number Diff line change
@@ -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