From 40964dc3fce995a64ba01b1cc200977745539d86 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:01:46 -0400 Subject: [PATCH 01/26] feat(expert-shell): add package --- expert-shell/package.lisp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 expert-shell/package.lisp diff --git a/expert-shell/package.lisp b/expert-shell/package.lisp new file mode 100644 index 00000000..6cc2d000 --- /dev/null +++ b/expert-shell/package.lisp @@ -0,0 +1,35 @@ +(uiop:define-package :star.expert.shell + (:use :cl) + (:export + #:shell-session + #:shell-session-client + #:shell-session-engine + #:shell-session-last-result + #:shell-session-last-plan + #:shell-session-trace + #:shell-request + #:shell-request-verb + #:shell-request-resource + #:shell-request-qualifier + #:shell-request-args + #:shell-request-confirmed-p + #:shell-request-raw + #:shell-plan + #:shell-plan-operation + #:shell-plan-risk + #:shell-plan-rule-name + #:shell-plan-reason + #:shell-result + #:shell-result-success-p + #:shell-result-operation + #:shell-result-code + #:shell-result-value + #:shell-result-message + #:make-shell-session + #:parse-command + #:run-command + #:render-result + #:start-repl + #:main)) + +(in-package :star.expert.shell) From 915e7270e8a845b4553c576e5da03e7d158de2b0 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:01:57 -0400 Subject: [PATCH 02/26] feat(expert-shell): add session and fact model --- expert-shell/model.lisp | 110 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 expert-shell/model.lisp diff --git a/expert-shell/model.lisp b/expert-shell/model.lisp new file mode 100644 index 00000000..f0ef54fc --- /dev/null +++ b/expert-shell/model.lisp @@ -0,0 +1,110 @@ +(in-package :star.expert.shell) + +(defclass shell-session () + ((client + :initarg :client + :reader shell-session-client) + (engine + :initarg :engine + :reader shell-session-engine) + (last-result + :initform nil + :accessor shell-session-last-result) + (last-plan + :initform nil + :accessor shell-session-last-plan) + (trace + :initform nil + :accessor shell-session-trace))) + +(defclass shell-request () + ((verb + :initarg :verb + :reader shell-request-verb) + (resource + :initarg :resource + :reader shell-request-resource) + (qualifier + :initarg :qualifier + :initform nil + :reader shell-request-qualifier) + (args + :initarg :args + :initform nil + :reader shell-request-args) + (confirmed-p + :initarg :confirmed-p + :initform nil + :reader shell-request-confirmed-p) + (raw + :initarg :raw + :initform nil + :reader shell-request-raw))) + +(defclass shell-plan () + ((operation + :initarg :operation + :reader shell-plan-operation) + (risk + :initarg :risk + :reader shell-plan-risk) + (rule-name + :initarg :rule-name + :reader shell-plan-rule-name) + (reason + :initarg :reason + :reader shell-plan-reason) + (args + :initarg :args + :initform nil + :reader shell-plan-args) + (confirmed-p + :initarg :confirmed-p + :initform nil + :reader shell-plan-confirmed-p))) + +(defclass shell-result () + ((success-p + :initarg :success-p + :reader shell-result-success-p) + (operation + :initarg :operation + :initform nil + :reader shell-result-operation) + (code + :initarg :code + :initform nil + :reader shell-result-code) + (value + :initarg :value + :initform nil + :reader shell-result-value) + (message + :initarg :message + :initform nil + :reader shell-result-message))) + +(defvar *current-session* nil) + +(defun trace-event (event &rest fields) + (when *current-session* + (push (list* :event event fields) + (shell-session-trace *current-session*)))) + +(defun finish-result (result) + (unless *current-session* + (error "No active StarIntel expert-shell session")) + (setf (shell-session-last-result *current-session*) result) + (trace-event :result + :success-p (shell-result-success-p result) + :operation (shell-result-operation result) + :code (shell-result-code result)) + result) + +(defun make-result (&key success-p operation code value message) + (make-instance 'shell-result + :success-p success-p + :operation operation + :code code + :value value + :message message)) From 77868c9d4fffa975a32c8dab54d651859e85bda9 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:02:21 -0400 Subject: [PATCH 03/26] feat(expert-shell): add Lisa planning rules --- expert-shell/rules.lisp | 227 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 expert-shell/rules.lisp diff --git a/expert-shell/rules.lisp b/expert-shell/rules.lisp new file mode 100644 index 00000000..e79698be --- /dev/null +++ b/expert-shell/rules.lisp @@ -0,0 +1,227 @@ +(in-package :star.expert.shell) + +(defun plan-request (request operation risk rule-name reason) + (let ((plan (make-instance 'shell-plan + :operation operation + :risk risk + :rule-name rule-name + :reason reason + :args (copy-tree (shell-request-args request)) + :confirmed-p (shell-request-confirmed-p request)))) + (setf (shell-session-last-plan *current-session*) plan) + (trace-event :planned + :rule rule-name + :operation operation + :risk risk + :reason reason) + (lisa:assert-instance plan) + (lisa:retract request) + plan)) + +(defun block-plan (plan) + (trace-event :blocked + :operation (shell-plan-operation plan) + :risk (shell-plan-risk plan) + :reason :confirmation-required) + (finish-result + (make-result + :success-p nil + :operation (shell-plan-operation plan) + :code :confirmation-required + :message "This operation mutates StarIntel state. Re-run it with --yes to confirm.")) + (lisa:retract plan) + nil) + +(defun unsupported-request (request) + (trace-event :unsupported + :verb (shell-request-verb request) + :resource (shell-request-resource request) + :qualifier (shell-request-qualifier request)) + (finish-result + (make-result + :success-p nil + :code :unsupported-command + :message (format nil "No expert rule matched: ~a" + (or (shell-request-raw request) + (list (shell-request-verb request) + (shell-request-resource request) + (shell-request-qualifier request)))))) + (lisa:retract request) + nil) + +(defun execute-planned-operation (plan) + (unwind-protect + (handler-case + (progn + (trace-event :execute + :operation (shell-plan-operation plan) + :rule (shell-plan-rule-name plan)) + (finish-result + (make-result + :success-p t + :operation (shell-plan-operation plan) + :code :ok + :value (perform-operation *current-session* plan)))) + (error (condition) + (trace-event :error + :operation (shell-plan-operation plan) + :condition (princ-to-string condition)) + (finish-result + (make-result + :success-p nil + :operation (shell-plan-operation plan) + :code :operation-failed + :message (princ-to-string condition))))) + (ignore-errors (lisa:retract plan)))) + +(defparameter *shell-rule-forms* + '((lisa:defrule plan-health (:salience 30) + (?request (shell-request (verb :health) (resource :server))) + => + (plan-request ?request :health :read 'plan-health + "Probe the StarIntel server health endpoint.")) + + (lisa:defrule plan-server-info (:salience 30) + (?request (shell-request (verb :info) (resource :server))) + => + (plan-request ?request :server-info :read 'plan-server-info + "Read StarIntel server metadata and protocol information.")) + + (lisa:defrule plan-document-get (:salience 30) + (?request (shell-request (verb :get) (resource :document))) + => + (plan-request ?request :document-get :read 'plan-document-get + "Fetch one persisted document by identifier.")) + + (lisa:defrule plan-document-search (:salience 30) + (?request (shell-request (verb :search) (resource :document))) + => + (plan-request ?request :document-search :read 'plan-document-search + "Search indexed StarIntel documents.")) + + (lisa:defrule plan-document-submit (:salience 30) + (?request (shell-request (verb :submit) (resource :document))) + => + (plan-request ?request :document-submit :write 'plan-document-submit + "Create a new StarIntel document.")) + + (lisa:defrule plan-document-delete (:salience 30) + (?request (shell-request (verb :delete) (resource :document))) + => + (plan-request ?request :document-delete :destructive 'plan-document-delete + "Delete a persisted StarIntel document.")) + + (lisa:defrule plan-target-list (:salience 30) + (?request (shell-request (verb :list) (resource :target))) + => + (plan-request ?request :target-list :read 'plan-target-list + "List persisted targets for an actor.")) + + (lisa:defrule plan-target-get (:salience 30) + (?request (shell-request (verb :get) (resource :target))) + => + (plan-request ?request :target-get :read 'plan-target-get + "Fetch a target document by identifier.")) + + (lisa:defrule plan-target-create (:salience 30) + (?request (shell-request (verb :create) (resource :target))) + => + (plan-request ?request :target-create :write 'plan-target-create + "Submit a target to an actor.")) + + (lisa:defrule plan-dataset-size (:salience 30) + (?request (shell-request (verb :size) (resource :dataset))) + => + (plan-request ?request :dataset-size :read 'plan-dataset-size + "Read the current size of a dataset.")) + + (lisa:defrule plan-groups (:salience 30) + (?request (shell-request (verb :list) (resource :groups))) + => + (plan-request ?request :groups :read 'plan-groups + "List message groups and channels.")) + + (lisa:defrule plan-messages-user (:salience 30) + (?request (shell-request (verb :list) (resource :messages) (qualifier :user))) + => + (plan-request ?request :messages-by-user :read 'plan-messages-user + "Query message documents by user.")) + + (lisa:defrule plan-messages-platform (:salience 30) + (?request (shell-request (verb :list) (resource :messages) (qualifier :platform))) + => + (plan-request ?request :messages-by-platform :read 'plan-messages-platform + "Query message documents by platform.")) + + (lisa:defrule plan-messages-group (:salience 30) + (?request (shell-request (verb :list) (resource :messages) (qualifier :group))) + => + (plan-request ?request :messages-by-group :read 'plan-messages-group + "Query grouped message documents.")) + + (lisa:defrule plan-social-user (:salience 30) + (?request (shell-request (verb :list) (resource :social) (qualifier :user))) + => + (plan-request ?request :social-by-user :read 'plan-social-user + "Query social-media posts by user.")) + + (lisa:defrule plan-raw-get (:salience 30) + (?request (shell-request (verb :get) (resource :raw))) + => + (plan-request ?request :raw-get :read 'plan-raw-get + "Perform a raw GET through the StarIntel client boundary.")) + + (lisa:defrule plan-raw-post (:salience 30) + (?request (shell-request (verb :post) (resource :raw))) + => + (plan-request ?request :raw-post :write 'plan-raw-post + "Perform a raw POST through the StarIntel client boundary.")) + + (lisa:defrule plan-raw-put (:salience 30) + (?request (shell-request (verb :put) (resource :raw))) + => + (plan-request ?request :raw-put :write 'plan-raw-put + "Perform a raw PUT through the StarIntel client boundary.")) + + (lisa:defrule plan-raw-delete (:salience 30) + (?request (shell-request (verb :delete) (resource :raw))) + => + (plan-request ?request :raw-delete :destructive 'plan-raw-delete + "Perform a raw DELETE through the StarIntel client boundary.")) + + (lisa:defrule block-unconfirmed-write (:salience 20) + (?plan (shell-plan (risk :write) (confirmed-p nil))) + => + (block-plan ?plan)) + + (lisa:defrule block-unconfirmed-destructive (:salience 20) + (?plan (shell-plan (risk :destructive) (confirmed-p nil))) + => + (block-plan ?plan)) + + (lisa:defrule execute-read-plan (:salience 10) + (?plan (shell-plan (risk :read))) + => + (execute-planned-operation ?plan)) + + (lisa:defrule execute-confirmed-write-plan (:salience 10) + (?plan (shell-plan (risk :write) (confirmed-p t))) + => + (execute-planned-operation ?plan)) + + (lisa:defrule execute-confirmed-destructive-plan (:salience 10) + (?plan (shell-plan (risk :destructive) (confirmed-p t))) + => + (execute-planned-operation ?plan)) + + (lisa:defrule unsupported-shell-request (:salience -100) + (?request (shell-request)) + => + (unsupported-request ?request)))) + +(defun install-shell-rules (engine) + (lisa:with-inference-engine (engine) + (let ((*package* (find-package :star.expert.shell))) + (dolist (form *shell-rule-forms*) + (eval form)))) + engine) From fea41823f409714a32a718afde517992cbd2e299 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:03:58 -0400 Subject: [PATCH 04/26] feat(expert-shell): implement Lisa-backed operator shell --- expert-shell/shell.lisp | 616 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 expert-shell/shell.lisp diff --git a/expert-shell/shell.lisp b/expert-shell/shell.lisp new file mode 100644 index 00000000..febd55bd --- /dev/null +++ b/expert-shell/shell.lisp @@ -0,0 +1,616 @@ +(in-package :star.expert.shell) + +(defun whitespace-char-p (character) + (find character " \t\n\r" :test #'char=)) + +(defun tokenize-command-line (line) + "Split LINE like a small shell. Quotes group tokens; backslash escapes one char." + (let ((tokens '()) + (buffer '()) + (quote-char nil) + (escaped-p nil)) + (labels ((flush () + (when buffer + (push (coerce (nreverse buffer) 'string) tokens) + (setf buffer nil)))) + (loop for character across line do + (cond + (escaped-p + (push character buffer) + (setf escaped-p nil)) + ((char= character #\\) + (setf escaped-p t)) + (quote-char + (if (char= character quote-char) + (setf quote-char nil) + (push character buffer))) + ((or (char= character #\") + (char= character #\')) + (setf quote-char character)) + ((whitespace-char-p character) + (flush)) + (t + (push character buffer)))) + (when escaped-p + (push #\\ buffer)) + (when quote-char + (error "Unterminated quoted string")) + (flush) + (nreverse tokens)))) + +(defun token-name (token) + (string-downcase + (etypecase token + (string token) + (symbol (symbol-name token)) + (integer (princ-to-string token))))) + +(defun token= (token name) + (string= (token-name token) (string-downcase name))) + +(defun yes-marker-p (token) + (or (and (stringp token) (string= token "--yes")) + (eq token :yes))) + +(defun transient-marker-p (token) + (or (and (stringp token) (string= token "--transient")) + (eq token :transient))) + +(defun option-marker-p (token name) + (or (and (stringp token) + (string= token (format nil "--~a" name))) + (and (keywordp token) + (string-equal (symbol-name token) name)))) + +(defun option-value (items name &optional default) + (loop for tail on items + for item = (first tail) + when (option-marker-p item name) + do (return (or (second tail) default)) + finally (return default))) + +(defun positional-items (items option-names &key flags) + (let ((result '())) + (loop while items do + (let ((item (pop items))) + (cond + ((some (lambda (name) (option-marker-p item name)) option-names) + (when items (pop items))) + ((some (lambda (predicate) (funcall predicate item)) flags) + nil) + (t + (push item result))))) + (nreverse result))) + +(defun stringify (value) + (etypecase value + (string value) + (symbol (string-downcase (symbol-name value))) + (integer (princ-to-string value)))) + +(defun join-items (items) + (format nil "~{~a~^ ~}" (mapcar #'stringify items))) + +(defun parse-integer-option (value option-name) + (cond + ((null value) nil) + ((integerp value) value) + ((stringp value) + (or (parse-integer value :junk-allowed t) + (error "~a must be an integer" option-name))) + (t + (error "~a must be an integer" option-name)))) + +(defun command-form (line) + (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) line))) + (when (zerop (length trimmed)) + (error "Empty command")) + (if (char= (char trimmed 0) #\() + (let ((*read-eval* nil)) + (multiple-value-bind (form position) + (read-from-string trimmed nil nil) + (unless form + (error "Empty command form")) + (unless (zerop (length (string-trim '(#\Space #\Tab #\Newline #\Return) + (subseq trimmed position)))) + (error "Unexpected input after command form")) + (unless (listp form) + (error "Command form must be a list")) + form)) + (tokenize-command-line trimmed)))) + +(defun make-request (verb resource raw &key qualifier args confirmed-p) + (make-instance 'shell-request + :verb verb + :resource resource + :qualifier qualifier + :args args + :confirmed-p confirmed-p + :raw raw)) + +(defun require-positional (items index description) + (or (nth index items) + (error "Missing ~a" description))) + +(defun parse-command (line) + "Parse LINE into a SHELL-REQUEST. No input is EVALed." + (let* ((items (command-form line)) + (head (first items)) + (confirmed-p (some #'yes-marker-p items))) + (unless head + (error "Empty command")) + (cond + ((or (token= head "health") (token= head "status")) + (make-request :health :server line)) + + ((token= head "info") + (make-request :info :server line)) + + ((and (token= head "server") + (second items) + (token= (second items) "info")) + (make-request :info :server line)) + + ((or (token= head "doc") (token= head "document")) + (let* ((action (require-positional items 1 "document action")) + (tail (cddr items))) + (cond + ((token= action "get") + (make-request :get :document line + :args (list :id (stringify + (require-positional tail 0 "document id"))))) + ((token= action "search") + (let* ((limit (or (parse-integer-option (option-value tail "limit") "--limit") 25)) + (bookmark (option-value tail "bookmark")) + (sort (option-value tail "sort")) + (positionals (positional-items tail '("limit" "bookmark" "sort")))) + (unless positionals + (error "Missing search query")) + (make-request :search :document line + :args (list :query (join-items positionals) + :limit limit + :bookmark (and bookmark (stringify bookmark)) + :sort (and sort (stringify sort)))))) + ((or (token= action "submit") (token= action "create")) + (let* ((positionals (positional-items tail '() :flags (list #'yes-marker-p))) + (dtype (stringify (require-positional positionals 0 "document type"))) + (json-parts (rest positionals))) + (unless json-parts + (error "Missing document JSON")) + (make-request :submit :document line + :confirmed-p confirmed-p + :args (list :dtype dtype :json (join-items json-parts))))) + ((token= action "delete") + (let ((positionals (positional-items tail '() :flags (list #'yes-marker-p)))) + (make-request :delete :document line + :confirmed-p confirmed-p + :args (list :id (stringify + (require-positional positionals 0 "document id")))))) + (t + (make-request :unknown :document line))))) + + ((token= head "search") + (let* ((tail (rest items)) + (limit (or (parse-integer-option (option-value tail "limit") "--limit") 25)) + (positionals (positional-items tail '("limit")))) + (unless positionals + (error "Missing search query")) + (make-request :search :document line + :args (list :query (join-items positionals) + :limit limit)))) + + ((token= head "get") + (make-request :get :document line + :args (list :id (stringify + (require-positional (rest items) 0 "document id"))))) + + ((or (token= head "target") (token= head "targets")) + (let* ((action (require-positional items 1 "target action")) + (tail (cddr items))) + (cond + ((token= action "list") + (make-request :list :target line + :args (list :actor (stringify + (require-positional tail 0 "actor"))))) + ((token= action "get") + (make-request :get :target line + :args (list :id (stringify + (require-positional tail 0 "target id"))))) + ((or (token= action "create") (token= action "submit")) + (let* ((positionals (positional-items tail '() + :flags (list #'yes-marker-p + #'transient-marker-p))) + (actor (stringify (require-positional positionals 0 "actor"))) + (json-parts (rest positionals))) + (unless json-parts + (error "Missing target JSON")) + (make-request :create :target line + :confirmed-p confirmed-p + :args (list :actor actor + :json (join-items json-parts) + :transient (some #'transient-marker-p tail))))) + (t + (make-request :unknown :target line))))) + + ((token= head "dataset") + (let ((action (require-positional items 1 "dataset action")) + (tail (cddr items))) + (if (token= action "size") + (make-request :size :dataset line + :args (list :dataset + (stringify + (require-positional tail 0 "dataset name")))) + (make-request :unknown :dataset line)))) + + ((token= head "groups") + (let* ((tail (rest items)) + (limit (or (parse-integer-option (option-value tail "limit") "--limit") 50))) + (make-request :list :groups line :args (list :limit limit)))) + + ((token= head "messages") + (let* ((qualifier-token (require-positional items 1 "messages qualifier")) + (tail (cddr items)) + (limit (or (parse-integer-option (option-value tail "limit") "--limit") 50)) + (positionals (positional-items tail '("limit")))) + (cond + ((token= qualifier-token "user") + (make-request :list :messages line + :qualifier :user + :args (list :user (stringify + (require-positional positionals 0 "user")) + :limit limit))) + ((token= qualifier-token "platform") + (make-request :list :messages line + :qualifier :platform + :args (list :platform (stringify + (require-positional positionals 0 "platform")) + :limit limit))) + ((token= qualifier-token "group") + (make-request :list :messages line + :qualifier :group + :args (list :limit limit))) + (t + (make-request :unknown :messages line))))) + + ((token= head "social") + (let* ((qualifier-token (require-positional items 1 "social qualifier")) + (tail (cddr items)) + (limit (or (parse-integer-option (option-value tail "limit") "--limit") 50)) + (positionals (positional-items tail '("limit")))) + (if (token= qualifier-token "user") + (make-request :list :social line + :qualifier :user + :args (list :user (stringify + (require-positional positionals 0 "user")) + :limit limit)) + (make-request :unknown :social line)))) + + ((or (token= head "raw") (token= head "api")) + (let* ((method-token (require-positional items 1 "HTTP method")) + (tail (cddr items)) + (positionals (positional-items tail '() :flags (list #'yes-marker-p))) + (path (stringify (require-positional positionals 0 "API path"))) + (body-parts (rest positionals))) + (cond + ((token= method-token "get") + (make-request :get :raw line :args (list :path path))) + ((token= method-token "post") + (make-request :post :raw line + :confirmed-p confirmed-p + :args (list :path path + :body (and body-parts (join-items body-parts))))) + ((token= method-token "put") + (make-request :put :raw line + :confirmed-p confirmed-p + :args (list :path path + :body (and body-parts (join-items body-parts))))) + ((token= method-token "delete") + (make-request :delete :raw line + :confirmed-p confirmed-p + :args (list :path path))) + (t + (make-request :unknown :raw line)))))) + + (t + (make-request :unknown :unknown line))))) + +(defun require-plan-arg (plan key) + (let ((marker (gensym "MISSING"))) + (let ((value (getf (shell-plan-args plan) key marker))) + (if (eq value marker) + (error "Planner omitted required argument ~s for ~s" + key (shell-plan-operation plan)) + value)))) + +(defun perform-operation (session plan) + (let ((client (shell-session-client session))) + (case (shell-plan-operation plan) + (:health + (star.api.client:health client)) + (:server-info + (star.api.client:server-info client)) + (:document-get + (star.api.client:get-document client (require-plan-arg plan :id))) + (:document-search + (star.api.client:fts + client + :q (require-plan-arg plan :query) + :limit (or (getf (shell-plan-args plan) :limit) 25) + :bookmark (getf (shell-plan-args plan) :bookmark) + :sort (getf (shell-plan-args plan) :sort))) + (:document-submit + (star.api.client:submit-document + client + (require-plan-arg plan :json) + (require-plan-arg plan :dtype))) + (:document-delete + (star.api.client:api-request + client + (format nil "/document/~a" (require-plan-arg plan :id)) + :method :delete)) + (:target-list + (star.api.client:get-targets client (require-plan-arg plan :actor))) + (:target-get + (star.api.client:get-document client (require-plan-arg plan :id))) + (:target-create + (star.api.client:new-target + client + (require-plan-arg plan :json) + (require-plan-arg plan :actor) + (not (null (getf (shell-plan-args plan) :transient))))) + (:dataset-size + (star.api.client:dataset-size client (require-plan-arg plan :dataset))) + (:groups + (star.api.client:groups + client :limit (or (getf (shell-plan-args plan) :limit) 50))) + (:messages-by-user + (star.api.client:messages-by-user + client + :user (require-plan-arg plan :user) + :limit (or (getf (shell-plan-args plan) :limit) 50))) + (:messages-by-platform + (star.api.client:messages-by-platform + client + :platform (require-plan-arg plan :platform) + :limit (or (getf (shell-plan-args plan) :limit) 50))) + (:messages-by-group + (star.api.client:messages-by-group + client :limit (or (getf (shell-plan-args plan) :limit) 50))) + (:social-by-user + (star.api.client:social-posts-by-user + client + :user (require-plan-arg plan :user) + :limit (or (getf (shell-plan-args plan) :limit) 50))) + (:raw-get + (star.api.client:api-request client (require-plan-arg plan :path))) + (:raw-post + (star.api.client:api-request client (require-plan-arg plan :path) + :method :post + :content (getf (shell-plan-args plan) :body))) + (:raw-put + (star.api.client:api-request client (require-plan-arg plan :path) + :method :put + :content (getf (shell-plan-args plan) :body))) + (:raw-delete + (star.api.client:api-request client (require-plan-arg plan :path) + :method :delete)) + (otherwise + (error "No executor for operation ~s" (shell-plan-operation plan)))))) + +(defun make-shell-session (&key client + (base-url "http://127.0.0.1:5000") + api-key) + (let* ((client (or client + (let ((base (star.api.client:make-star-client + :base-url base-url))) + (if api-key + (star.api.client:client-with-api-key base api-key) + base)))) + (engine (lisa:make-inference-engine)) + (session (make-instance 'shell-session + :client client + :engine engine))) + (install-shell-rules engine) + session)) + +(defparameter *operation-catalog* + '("health | status" + "info | server info" + "doc get ID" + "doc search QUERY [--limit N] [--bookmark B] [--sort FIELD]" + "doc submit DTYPE JSON --yes" + "doc delete ID --yes" + "target list ACTOR" + "target get ID" + "target create ACTOR JSON [--transient] --yes" + "dataset size DATASET" + "groups [--limit N]" + "messages user USER [--limit N]" + "messages platform PLATFORM [--limit N]" + "messages group [--limit N]" + "social user USER [--limit N]" + "raw get PATH" + "raw post PATH [JSON] --yes" + "raw put PATH [JSON] --yes" + "raw delete PATH --yes" + "why" + "rules" + "help" + "quit | exit")) + +(defun help-text () + (with-output-to-string (stream) + (format stream "StarIntel Lisa expert shell~%~%") + (format stream "Commands:~%") + (dolist (entry *operation-catalog*) + (format stream " ~a~%" entry)) + (format stream "~%Lisp syntax is also accepted, e.g. (doc search \"alice\" :limit 10).~%") + (format stream "Mutation rules require --yes (or :yes in Lisp syntax). Input forms are read as data with *READ-EVAL* disabled.~%"))) + +(defun last-explanation (session) + (let ((plan (shell-session-last-plan session))) + (if plan + (list :rule (shell-plan-rule-name plan) + :operation (shell-plan-operation plan) + :risk (shell-plan-risk plan) + :reason (shell-plan-reason plan) + :trace (copy-tree (shell-session-trace session))) + (list :message "No Lisa plan has run in this session yet.")))) + +(defun meta-command-result (session line) + (let ((trimmed (string-downcase + (string-trim '(#\Space #\Tab #\Newline #\Return) line)))) + (cond + ((member trimmed '("help" "?") :test #'string=) + (make-result :success-p t :operation :help :code :ok :value (help-text))) + ((string= trimmed "rules") + (make-result :success-p t :operation :rules :code :ok + :value (copy-list *operation-catalog*))) + ((string= trimmed "why") + (make-result :success-p t :operation :why :code :ok + :value (last-explanation session))) + (t nil)))) + +(defun run-command (session line) + "Run one LINE through the Lisa planner and return a SHELL-RESULT." + (or (meta-command-result session line) + (handler-case + (let ((request (parse-command line))) + (setf (shell-session-last-result session) nil + (shell-session-last-plan session) nil + (shell-session-trace session) nil) + (let ((*current-session* session)) + (trace-event :request + :verb (shell-request-verb request) + :resource (shell-request-resource request) + :qualifier (shell-request-qualifier request) + :confirmed-p (shell-request-confirmed-p request)) + (lisa:with-inference-engine ((shell-session-engine session)) + (lisa:assert-instance request) + (lisa:run)) + (setf (shell-session-trace session) + (nreverse (shell-session-trace session))) + (or (shell-session-last-result session) + (make-result :success-p nil + :code :no-result + :message "Lisa reached quiescence without producing a result.")))) + (error (condition) + (let ((result (make-result :success-p nil + :code :invalid-command + :message (princ-to-string condition)))) + (setf (shell-session-last-result session) result) + result))))) + +(defun json-ish-p (value) + (and (consp value) + (member (first value) '(:obj :array) :test #'eq))) + +(defun print-value (value stream) + (cond + ((null value) + nil) + ((stringp value) + (write-string value stream) + (unless (and (plusp (length value)) + (char= (char value (1- (length value))) #\Newline)) + (terpri stream))) + ((json-ish-p value) + (write-line (jsown:to-json value) stream)) + (t + (pprint value stream)))) + +(defun result-json (result) + (jsown:to-json + (jsown:new-js + ("ok" (if (shell-result-success-p result) :true :false)) + ("operation" (and (shell-result-operation result) + (string-downcase + (symbol-name (shell-result-operation result))))) + ("code" (and (shell-result-code result) + (string-downcase (symbol-name (shell-result-code result))))) + ("message" (shell-result-message result)) + ("value" (let ((value (shell-result-value result))) + (cond + ((or (null value) (stringp value) (numberp value)) value) + ((json-ish-p value) value) + (t (prin1-to-string value))))))))) + +(defun render-result (result &key (stream *standard-output*) json) + (if json + (write-line (result-json result) stream) + (if (shell-result-success-p result) + (if (shell-result-value result) + (print-value (shell-result-value result) stream) + (format stream "ok~%")) + (format stream "error[~(~a~)]: ~a~%" + (or (shell-result-code result) :error) + (or (shell-result-message result) "operation failed")))) + result) + +(defun quit-command-p (line) + (member (string-downcase + (string-trim '(#\Space #\Tab #\Newline #\Return) line)) + '("quit" "exit" ":q") + :test #'string=)) + +(defun start-repl (session &key (input *standard-input*) (output *standard-output*)) + (format output "StarIntel expert shell (Lisa). Type 'help' for commands.~%") + (loop + (format output "star> ") + (force-output output) + (let ((line (read-line input nil :eof))) + (when (eq line :eof) + (return t)) + (when (quit-command-p line) + (return t)) + (unless (zerop (length (string-trim '(#\Space #\Tab #\Newline #\Return) line))) + (render-result (run-command session line) :stream output))))) + +(defun default-base-url () + (or (uiop:getenv "STAR_SERVER_URL") + "http://127.0.0.1:5000")) + +(defun parse-main-args (args) + (let ((base-url (default-base-url)) + (api-key (uiop:getenv "STAR_API_KEY")) + (command nil) + (json nil) + (help nil) + (positionals '())) + (loop while args do + (let ((arg (pop args))) + (cond + ((member arg '("--url" "-u") :test #'string=) + (setf base-url (or (pop args) (error "--url requires a value")))) + ((member arg '("--api-key" "-k") :test #'string=) + (setf api-key (or (pop args) (error "--api-key requires a value")))) + ((member arg '("--command" "-c") :test #'string=) + (setf command (or (pop args) (error "--command requires a value")))) + ((string= arg "--json") + (setf json t)) + ((member arg '("--help" "-h") :test #'string=) + (setf help t)) + (t + (push arg positionals))))) + (when (and (null command) positionals) + (setf command (format nil "~{~a~^ ~}" (nreverse positionals)))) + (values base-url api-key command json help))) + +(defun main () + (handler-case + (multiple-value-bind (base-url api-key command json help) + (parse-main-args (uiop:command-line-arguments)) + (when help + (write-string (help-text)) + (uiop:quit 0)) + (let ((session (make-shell-session :base-url base-url :api-key api-key))) + (if command + (let ((result (run-command session command))) + (render-result result :json json) + (uiop:quit (if (shell-result-success-p result) 0 2))) + (progn + (start-repl session) + (uiop:quit 0))))) + (error (condition) + (format *error-output* "star-expert: ~a~%" condition) + (uiop:quit 2)))) From e12819fb8bb060a28fdadc28f39641a61e57c01a Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:05:08 -0400 Subject: [PATCH 05/26] feat(expert-shell): add ASDF system --- starintel-expert-shell.asd | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 starintel-expert-shell.asd diff --git a/starintel-expert-shell.asd b/starintel-expert-shell.asd new file mode 100644 index 00000000..32413f83 --- /dev/null +++ b/starintel-expert-shell.asd @@ -0,0 +1,20 @@ +(asdf:defsystem :starintel-expert-shell + :version "0.1.0" + :description "Lisa-backed expert operator shell for StarIntel." + :author "nsaspy@airmail.cc" + :license "GPL-3.0-or-later" + :serial t + :build-operation program-op + :build-pathname "star-expert" + :entry-point "star.expert.shell:main" + :depends-on (#:starintel-gserver-client + #:lisa + #:jsown) + :components + ((:module "expert-shell" + :serial t + :components + ((:file "package") + (:file "model") + (:file "rules") + (:file "shell"))))) From 0581e6fa7e3e2b655128ddddf61251d6fe0398c7 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:05:13 -0400 Subject: [PATCH 06/26] build: add Lisa expert-system dependency --- qlfile | 1 + 1 file changed, 1 insertion(+) diff --git a/qlfile b/qlfile index 4e5176c0..fb1dacb6 100644 --- a/qlfile +++ b/qlfile @@ -22,6 +22,7 @@ github mdbergmann/cl-gserver github lokedhs/cl-rabbit github jolby/cl-ulid github atlas-engineer/nhooks +github youngde811/Lisa # GitLab repository git cms-ulid https://gitlab.com/colinstrickland/cms-ulid.git From a84ab6419ae215db580f71d1bfd5cb968890b65d Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:05:41 -0400 Subject: [PATCH 07/26] build: lock Lisa dependency --- qlfile.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qlfile.lock b/qlfile.lock index 235bbf10..cfa1f8b2 100644 --- a/qlfile.lock +++ b/qlfile.lock @@ -86,6 +86,10 @@ (:class qlot/source/github:source-github :initargs (:repos "atlas-engineer/nhooks" :ref nil :branch nil :tag nil) :version "github-3847bc749a6f6eb1103bc21f8ef3b4f6b301e822")) +("Lisa" . + (:class qlot/source/github:source-github + :initargs (:repos "youngde811/Lisa" :ref nil :branch nil :tag nil) + :version "github-ae3715d74548e451bb08b432c5d25ec93e2a02c1")) ("cms-ulid" . (:class qlot/source/git:source-git :initargs (:remote-url "https://gitlab.com/colinstrickland/cms-ulid.git") From df23f30cf0eba1c3d239f3476eaceab5e3f9da80 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:06:05 -0400 Subject: [PATCH 08/26] test(expert-shell): cover Lisa planning and safety gates --- t/expert-shell-test.lisp | 121 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 t/expert-shell-test.lisp diff --git a/t/expert-shell-test.lisp b/t/expert-shell-test.lisp new file mode 100644 index 00000000..7764c6f4 --- /dev/null +++ b/t/expert-shell-test.lisp @@ -0,0 +1,121 @@ +(in-package :star-server-tests) + +(def-suite expert-shell-tests + :description "Lisa-backed StarIntel operator expert shell") + +(in-suite expert-shell-tests) + +(defun expert-shell-fake-json-response (&optional (body "{\"status\":\"ok\"}")) + (star.api.client::make-client-response + :status 200 + :headers '(("content-type" . "application/json")) + :body body + :uri "http://example.test" + :correlation-id "expert-shell-test" + :content-type "application/json")) + +(defun make-expert-shell-test-session (&optional request-hook) + (let ((transport + (star.api.client:make-function-transport + (lambda (request) + (when request-hook + (funcall request-hook request)) + (expert-shell-fake-json-response))))) + (star.expert.shell:make-shell-session + :client (star.api.client:make-star-client + :base-url "http://example.test" + :transport transport)))) + +(test expert-shell-parses-text-and-lisp-commands-as-data + (let ((request (star.expert.shell:parse-command + "doc search \"alice smith\" --limit 10"))) + (is (eq :search (star.expert.shell:shell-request-verb request))) + (is (eq :document (star.expert.shell:shell-request-resource request))) + (is (string= "alice smith" + (getf (star.expert.shell:shell-request-args request) :query))) + (is (= 10 (getf (star.expert.shell:shell-request-args request) :limit)))) + (let ((request (star.expert.shell:parse-command + "(target create github \"{\\\"repo\\\":\\\"x/y\\\"}\" :transient :yes)"))) + (is (eq :create (star.expert.shell:shell-request-verb request))) + (is (eq :target (star.expert.shell:shell-request-resource request))) + (is-true (star.expert.shell:shell-request-confirmed-p request)) + (is-true (getf (star.expert.shell:shell-request-args request) :transient)))) + +(test expert-shell-read-operation-is-planned-and-executed-by-lisa + (let ((requests '())) + (let* ((session + (make-expert-shell-test-session + (lambda (request) (push request requests)))) + (result (star.expert.shell:run-command session "health"))) + (is-true (star.expert.shell:shell-result-success-p result)) + (is (eq :health (star.expert.shell:shell-result-operation result))) + (is (= 1 (length requests))) + (let ((plan (star.expert.shell:shell-session-last-plan session))) + (is (eq :health (star.expert.shell:shell-plan-operation plan))) + (is (eq :read (star.expert.shell:shell-plan-risk plan))) + (is (eq 'star.expert.shell::plan-health + (star.expert.shell:shell-plan-rule-name plan))))))) + +(test expert-shell-unconfirmed-mutation-never-reaches-transport + (let ((calls 0)) + (let* ((session + (make-expert-shell-test-session + (lambda (request) + (declare (ignore request)) + (incf calls)))) + (result (star.expert.shell:run-command session "doc delete deadbeef"))) + (is-false (star.expert.shell:shell-result-success-p result)) + (is (eq :confirmation-required + (star.expert.shell:shell-result-code result))) + (is (zerop calls)) + (is (eq :destructive + (star.expert.shell:shell-plan-risk + (star.expert.shell:shell-session-last-plan session))))))) + +(test expert-shell-confirmed-mutation-reaches-transport-once + (let ((calls 0) + (captured nil)) + (let* ((session + (make-expert-shell-test-session + (lambda (request) + (incf calls) + (setf captured request)))) + (result + (star.expert.shell:run-command + session + "raw delete /document/deadbeef --yes"))) + (is-true (star.expert.shell:shell-result-success-p result)) + (is (= 1 calls)) + (is (eq :delete (star.api.client:client-request-method captured))) + (is (search "/document/deadbeef" + (star.api.client:client-request-uri captured)))))) + +(test expert-shell-fallback-is-a-lisa-rule + (let* ((session (make-expert-shell-test-session)) + (result (star.expert.shell:run-command session "frobnicate everything"))) + (is-false (star.expert.shell:shell-result-success-p result)) + (is (eq :unsupported-command + (star.expert.shell:shell-result-code result))) + (is (some (lambda (event) + (eq :unsupported (getf event :event))) + (star.expert.shell:shell-session-trace session))))) + +(test expert-shell-why-exposes-rule-and-risk + (let ((session (make-expert-shell-test-session))) + (star.expert.shell:run-command session "doc delete deadbeef") + (let* ((result (star.expert.shell:run-command session "why")) + (value (star.expert.shell:shell-result-value result))) + (is-true (star.expert.shell:shell-result-success-p result)) + (is (eq :document-delete (getf value :operation))) + (is (eq :destructive (getf value :risk)))))) + +(test expert-shell-reader-disables-read-time-evaluation + (let ((*read-eval* t)) + (let ((result + (handler-case + (progn + (star.expert.shell:parse-command + "(doc search #.(error \"must-not-run\"))") + :parsed) + (error () :rejected)))) + (is (eq :rejected result))))) From c2b1eac63c8c2c5dbdabc7a2b617d9944bc00a2a Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:06:17 -0400 Subject: [PATCH 09/26] test(expert-shell): register expert shell system and suite --- starintel-gserver-tests.asd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/starintel-gserver-tests.asd b/starintel-gserver-tests.asd index 8b8387d7..64f06a45 100644 --- a/starintel-gserver-tests.asd +++ b/starintel-gserver-tests.asd @@ -7,6 +7,7 @@ :depends-on (#:starintel-gserver #:starintel-gserver-client + #:starintel-expert-shell #:star-cli #:star-ui #:star-migrations @@ -57,6 +58,7 @@ (:file "http-contract-documents-test") (:file "runtime-lifecycle-test") (:file "observability-test") + (:file "expert-shell-test") (:file "run-tests")))) :perform (test-op (operation component) @@ -68,4 +70,4 @@ ;;;; Canonical unit entry point: ;;;; (asdf:test-system :starintel-gserver-tests) ;;;; Service-backed coverage is isolated in -;;;; :starintel-gserver-integration-tests. \ No newline at end of file +;;;; :starintel-gserver-integration-tests. From 99ff333e7046bbe12be274b81a4b6bf880cd4938 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:06:23 -0400 Subject: [PATCH 10/26] test(expert-shell): require expert shell suite --- t/run-tests.lisp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/t/run-tests.lisp b/t/run-tests.lisp index 1f421fa6..13fb7fae 100644 --- a/t/run-tests.lisp +++ b/t/run-tests.lisp @@ -26,10 +26,11 @@ gserver-client-tests http-contract-documents-tests runtime-lifecycle-tests - observability-tests)) + observability-tests + expert-shell-tests)) (defun run-all-gserver-tests () "Run every hermetic unit suite and fail on empty, skipped, or failed tests." (format t "~&StarIntel Gserver unit tests~%") (run-required-suites *required-unit-suites*) - t) \ No newline at end of file + t) From 6c3f48a0e543ab765bff37847fa249361668b4f7 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:10:30 -0400 Subject: [PATCH 11/26] docs(expert-shell): document Lisa operator shell --- doc/expert-shell.org | 133 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 doc/expert-shell.org diff --git a/doc/expert-shell.org b/doc/expert-shell.org new file mode 100644 index 00000000..7259d42a --- /dev/null +++ b/doc/expert-shell.org @@ -0,0 +1,133 @@ +#+title: StarIntel Lisa Expert Shell +#+author: StarIntel + +* Purpose + +=starintel-expert-shell= is a Common Lisp operator shell that routes common +StarIntel operations through a Lisa forward-chaining inference engine before +calling the normal StarIntel HTTP client boundary. + +The shell is intentionally not a privileged back door. Authentication, +authorization, tenant handling, rate limits, and server-side validation remain +owned by the existing StarIntel API. + +* Model + +Each shell session owns its own Lisa inference engine and four useful values: + +- a =shell-request= fact parsed from operator input; +- a =shell-plan= fact inferred by Lisa; +- a =shell-result= returned by the client boundary; +- an append-only in-memory trace describing request, rule, risk, and result. + +A request is classified as one of three risk levels: + +| Risk | Behavior | +|------+----------| +| =:read= | Executes immediately | +| =:write= | Requires explicit confirmation | +| =:destructive= | Requires explicit confirmation | + +The confirmation gate is itself expressed as Lisa rules. It is not merely a +check hidden in the command parser. + +* Build + +With Qlot dependencies installed: + +#+begin_src sh +qlot exec sbcl --non-interactive \ + --eval '(require :asdf)' \ + --eval '(asdf:make :starintel-expert-shell)' +#+end_src + +The ASDF program output is =star-expert=. + +The client defaults to =http://127.0.0.1:5000=. Set =STAR_SERVER_URL= and +=STAR_API_KEY= or pass =--url= and =--api-key= explicitly. + +* Interactive use + +#+begin_src sh +./star-expert +#+end_src + +Example session: + +#+begin_example +star> health +star> info +star> doc search "alice smith" --limit 10 +star> target list userhunt +star> dataset size social-posts +star> why +#+end_example + +* One-shot use + +#+begin_src sh +./star-expert health +./star-expert doc get 01HXYZ +./star-expert doc search "alice smith" --limit 10 +./star-expert --json health +#+end_src + +Mutating operations require =--yes=: + +#+begin_src sh +./star-expert doc submit person '{"name":"Alice"}' --yes +./star-expert target create userhunt '{"username":"alice"}' --yes +./star-expert doc delete 01HXYZ --yes +#+end_src + +Without =--yes=, Lisa produces a =:confirmation-required= result and the HTTP +transport is never invoked. + +* Commands + +| Command | Inferred operation | Risk | +|---------+--------------------+------| +| =health=, =status= | server health | read | +| =info=, =server info= | server metadata | read | +| =doc get ID= | fetch document | read | +| =doc search QUERY= | full-text search | read | +| =doc submit TYPE JSON= | submit document | write | +| =doc delete ID= | delete document | destructive | +| =target list ACTOR= | list actor targets | read | +| =target get ID= | fetch target document | read | +| =target create ACTOR JSON= | submit target | write | +| =dataset size NAME= | dataset size | read | +| =groups= | message groups/channels | read | +| =messages user USER= | messages by user | read | +| =messages platform PLATFORM= | messages by platform | read | +| =messages group= | grouped messages | read | +| =social user USER= | social posts by user | read | +| =raw get PATH= | raw client GET | read | +| =raw post PATH BODY= | raw client POST | write | +| =raw put PATH BODY= | raw client PUT | write | +| =raw delete PATH= | raw client DELETE | destructive | + +=raw= is an operator escape hatch but still uses =star.api.client:api-request= +and therefore the normal StarIntel HTTP boundary. + +* Lisp command syntax + +Commands may also be written as data-oriented Lisp forms: + +#+begin_src lisp +(doc search "alice smith" :limit 10) +(target create userhunt "{\"username\":\"alice\"}" :transient :yes) +#+end_src + +These forms are *read as data*. They are never passed to =eval=, and the +reader binds =*read-eval*= to =nil= so =#.=-style read-time execution is +rejected. + +* Explanation + +After a command, =why= reports the Lisa rule that selected the operation, its +risk class, the human-readable reason, and the session trace. + +The intent is to make this a stable reasoning surface for Quasar and Zara later: +those front ends can submit the same structured request facts and consume the +same plans/results without scraping terminal output. From c6f16081aa5c4b3e90e1cbaba123c1c46ac9293c Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:11:28 -0400 Subject: [PATCH 12/26] test(expert-shell): keep shell tests isolated from server test closure --- starintel-gserver-tests.asd | 2 -- 1 file changed, 2 deletions(-) diff --git a/starintel-gserver-tests.asd b/starintel-gserver-tests.asd index 64f06a45..2f3ca192 100644 --- a/starintel-gserver-tests.asd +++ b/starintel-gserver-tests.asd @@ -7,7 +7,6 @@ :depends-on (#:starintel-gserver #:starintel-gserver-client - #:starintel-expert-shell #:star-cli #:star-ui #:star-migrations @@ -58,7 +57,6 @@ (:file "http-contract-documents-test") (:file "runtime-lifecycle-test") (:file "observability-test") - (:file "expert-shell-test") (:file "run-tests")))) :perform (test-op (operation component) From f9059a36008bdaeae01c6c5e6d57c5d68c62cbd1 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:11:36 -0400 Subject: [PATCH 13/26] test(expert-shell): keep server required suites unchanged --- t/run-tests.lisp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/t/run-tests.lisp b/t/run-tests.lisp index 13fb7fae..d462bfeb 100644 --- a/t/run-tests.lisp +++ b/t/run-tests.lisp @@ -26,8 +26,7 @@ gserver-client-tests http-contract-documents-tests runtime-lifecycle-tests - observability-tests - expert-shell-tests)) + observability-tests)) (defun run-all-gserver-tests () "Run every hermetic unit suite and fail on empty, skipped, or failed tests." From 08f66d4000ea1998d402a4cff94edc4d66828026 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:11:56 -0400 Subject: [PATCH 14/26] test(expert-shell): make suite standalone --- t/expert-shell-test.lisp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/t/expert-shell-test.lisp b/t/expert-shell-test.lisp index 7764c6f4..773fb311 100644 --- a/t/expert-shell-test.lisp +++ b/t/expert-shell-test.lisp @@ -1,4 +1,8 @@ -(in-package :star-server-tests) +(uiop:define-package :star.expert.shell.tests + (:use :cl :fiveam) + (:export #:run-expert-shell-tests)) + +(in-package :star.expert.shell.tests) (def-suite expert-shell-tests :description "Lisa-backed StarIntel operator expert shell") @@ -119,3 +123,9 @@ :parsed) (error () :rejected)))) (is (eq :rejected result))))) + +(defun run-expert-shell-tests () + "Run the standalone expert-shell suite and signal on failure." + (unless (run! 'expert-shell-tests) + (error "StarIntel expert-shell tests failed")) + t) From 01bbcdb923efa9dfc8f7ef89692374fe3b0fb047 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:12:03 -0400 Subject: [PATCH 15/26] test(expert-shell): add standalone ASDF test system --- starintel-expert-shell-tests.asd | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 starintel-expert-shell-tests.asd diff --git a/starintel-expert-shell-tests.asd b/starintel-expert-shell-tests.asd new file mode 100644 index 00000000..dd6536bb --- /dev/null +++ b/starintel-expert-shell-tests.asd @@ -0,0 +1,19 @@ +(asdf:defsystem :starintel-expert-shell-tests + :version "0.1.0" + :description "Tests for the Lisa-backed StarIntel expert shell." + :author "nsaspy@airmail.cc" + :license "GPL-3.0-or-later" + :serial t + :depends-on (#:starintel-expert-shell + #:fiveam) + :components + ((:module "t" + :serial t + :components + ((:file "expert-shell-test")))) + :perform + (test-op (operation component) + (declare (ignore operation component)) + (uiop:symbol-call + :star.expert.shell.tests + :run-expert-shell-tests))) From 3a0fbf1d63615c499f9ab68b3b46d66d242ff4df Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:16:25 -0400 Subject: [PATCH 16/26] chore: drop unrelated test-system diff --- starintel-gserver-tests.asd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/starintel-gserver-tests.asd b/starintel-gserver-tests.asd index 2f3ca192..8b8387d7 100644 --- a/starintel-gserver-tests.asd +++ b/starintel-gserver-tests.asd @@ -68,4 +68,4 @@ ;;;; Canonical unit entry point: ;;;; (asdf:test-system :starintel-gserver-tests) ;;;; Service-backed coverage is isolated in -;;;; :starintel-gserver-integration-tests. +;;;; :starintel-gserver-integration-tests. \ No newline at end of file From 334bc6e4049763407aa37dbc96c55592dd2ade3d Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:16:33 -0400 Subject: [PATCH 17/26] chore: drop unrelated unit-runner diff --- t/run-tests.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/run-tests.lisp b/t/run-tests.lisp index d462bfeb..1f421fa6 100644 --- a/t/run-tests.lisp +++ b/t/run-tests.lisp @@ -32,4 +32,4 @@ "Run every hermetic unit suite and fail on empty, skipped, or failed tests." (format t "~&StarIntel Gserver unit tests~%") (run-required-suites *required-unit-suites*) - t) + t) \ No newline at end of file From 577034ce1f9a68221a24fdac187cd0562cff321d Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:17:38 -0400 Subject: [PATCH 18/26] ci(expert-shell): run Lisa shell tests and build --- .github/workflows/expert-shell.yml | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/expert-shell.yml diff --git a/.github/workflows/expert-shell.yml b/.github/workflows/expert-shell.yml new file mode 100644 index 00000000..63842e29 --- /dev/null +++ b/.github/workflows/expert-shell.yml @@ -0,0 +1,61 @@ +name: Expert Shell + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +concurrency: + group: expert-shell-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lisa-shell: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Common Lisp build prerequisites + run: | + sudo apt-get update + sudo apt-get install --yes \ + sbcl \ + libssl-dev \ + libffi-dev \ + librabbitmq-dev \ + pkg-config + + - name: Install Qlot + shell: bash + run: | + set -euo pipefail + curl --fail --location --show-error https://qlot.tech/installer | sh + echo "$HOME/.qlot/bin" >> "$GITHUB_PATH" + "$HOME/.qlot/bin/qlot" --version + + - name: Install locked Lisp dependencies + run: qlot install + + - name: Run expert-shell tests + shell: bash + run: | + set -euo pipefail + qlot exec sbcl --non-interactive --no-userinit --no-sysinit \ + --eval '(require :asdf)' \ + --eval '(asdf:test-system :starintel-expert-shell-tests)' + + - name: Build star-expert executable + shell: bash + run: | + set -euo pipefail + qlot exec sbcl --non-interactive --no-userinit --no-sysinit \ + --eval '(require :asdf)' \ + --eval '(asdf:make :starintel-expert-shell)' + test -x star-expert From 213ef571b09385ffe0e4d21d1ed01a0c95b20746 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:22:53 -0400 Subject: [PATCH 19/26] ci(expert-shell): bootstrap Lisp with setup-lisp --- .github/workflows/expert-shell.yml | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/.github/workflows/expert-shell.yml b/.github/workflows/expert-shell.yml index 63842e29..751a5ece 100644 --- a/.github/workflows/expert-shell.yml +++ b/.github/workflows/expert-shell.yml @@ -17,44 +17,35 @@ jobs: lisa-shell: runs-on: ubuntu-latest timeout-minutes: 20 + env: + LISP: sbcl-bin steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Common Lisp build prerequisites + - name: Install native build prerequisites run: | sudo apt-get update sudo apt-get install --yes \ - sbcl \ libssl-dev \ libffi-dev \ librabbitmq-dev \ pkg-config - - name: Install Qlot - shell: bash - run: | - set -euo pipefail - curl --fail --location --show-error https://qlot.tech/installer | sh - echo "$HOME/.qlot/bin" >> "$GITHUB_PATH" - "$HOME/.qlot/bin/qlot" --version - - - name: Install locked Lisp dependencies - run: qlot install + - name: Set up Common Lisp and locked dependencies + uses: 40ants/setup-lisp@v4 - name: Run expert-shell tests - shell: bash + shell: lispsh -eo pipefail {0} run: | - set -euo pipefail qlot exec sbcl --non-interactive --no-userinit --no-sysinit \ --eval '(require :asdf)' \ --eval '(asdf:test-system :starintel-expert-shell-tests)' - name: Build star-expert executable - shell: bash + shell: lispsh -eo pipefail {0} run: | - set -euo pipefail qlot exec sbcl --non-interactive --no-userinit --no-sysinit \ --eval '(require :asdf)' \ --eval '(asdf:make :starintel-expert-shell)' From 81ec0ec853444f6375a496de73e98846d2717a4b Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:25:35 -0400 Subject: [PATCH 20/26] ci(expert-shell): preload shell test system --- .github/workflows/expert-shell.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/expert-shell.yml b/.github/workflows/expert-shell.yml index 751a5ece..77a545a6 100644 --- a/.github/workflows/expert-shell.yml +++ b/.github/workflows/expert-shell.yml @@ -35,6 +35,8 @@ jobs: - name: Set up Common Lisp and locked dependencies uses: 40ants/setup-lisp@v4 + with: + asdf-system: starintel-expert-shell-tests - name: Run expert-shell tests shell: lispsh -eo pipefail {0} From 4302f5e7e517e8f6d74a6c376b0288691fdfaa4d Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:28:29 -0400 Subject: [PATCH 21/26] ci(expert-shell): use Qlot 1.8 installer with fixed bin path --- .github/workflows/expert-shell.yml | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/expert-shell.yml b/.github/workflows/expert-shell.yml index 77a545a6..e2117fe9 100644 --- a/.github/workflows/expert-shell.yml +++ b/.github/workflows/expert-shell.yml @@ -17,37 +17,48 @@ jobs: lisa-shell: runs-on: ubuntu-latest timeout-minutes: 20 - env: - LISP: sbcl-bin steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install native build prerequisites + - name: Install Common Lisp build prerequisites run: | sudo apt-get update sudo apt-get install --yes \ + sbcl \ libssl-dev \ libffi-dev \ librabbitmq-dev \ pkg-config - - name: Set up Common Lisp and locked dependencies - uses: 40ants/setup-lisp@v4 - with: - asdf-system: starintel-expert-shell-tests + - name: Install Qlot 1.8 + shell: bash + run: | + set -euo pipefail + curl --fail --location --show-error https://qlot.tech/installer \ + | QLOT_BIN_DIR="$HOME/.qlot/bin" sh + echo "$HOME/.qlot/bin" >> "$GITHUB_PATH" + "$HOME/.qlot/bin/qlot" --version + + - name: Install locked Lisp dependencies + shell: bash + run: | + set -euo pipefail + qlot install - name: Run expert-shell tests - shell: lispsh -eo pipefail {0} + shell: bash run: | + set -euo pipefail qlot exec sbcl --non-interactive --no-userinit --no-sysinit \ --eval '(require :asdf)' \ --eval '(asdf:test-system :starintel-expert-shell-tests)' - name: Build star-expert executable - shell: lispsh -eo pipefail {0} + shell: bash run: | + set -euo pipefail qlot exec sbcl --non-interactive --no-userinit --no-sysinit \ --eval '(require :asdf)' \ --eval '(asdf:make :starintel-expert-shell)' From 2544c743d2f88bd9b13786057e4912202ad00897 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:31:00 -0400 Subject: [PATCH 22/26] ci(expert-shell): let qlot install be the health check --- .github/workflows/expert-shell.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/expert-shell.yml b/.github/workflows/expert-shell.yml index e2117fe9..71653f94 100644 --- a/.github/workflows/expert-shell.yml +++ b/.github/workflows/expert-shell.yml @@ -38,8 +38,8 @@ jobs: set -euo pipefail curl --fail --location --show-error https://qlot.tech/installer \ | QLOT_BIN_DIR="$HOME/.qlot/bin" sh + test -x "$HOME/.qlot/bin/qlot" echo "$HOME/.qlot/bin" >> "$GITHUB_PATH" - "$HOME/.qlot/bin/qlot" --version - name: Install locked Lisp dependencies shell: bash From 0c8848bde023d8678e10f74de92f7524ebfe819f Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:33:42 -0400 Subject: [PATCH 23/26] feat(expert-shell): expand read plans and preserve client error semantics --- expert-shell/rules.lisp | 76 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/expert-shell/rules.lisp b/expert-shell/rules.lisp index e79698be..bad66f0f 100644 --- a/expert-shell/rules.lisp +++ b/expert-shell/rules.lisp @@ -49,6 +49,39 @@ (lisa:retract request) nil) +(defun client-condition-code (condition) + (cond + ((typep condition 'star.api.client:client-authentication-error) + :authentication-error) + ((typep condition 'star.api.client:client-authorization-error) + :authorization-error) + ((typep condition 'star.api.client:client-not-found-error) + :not-found) + ((typep condition 'star.api.client:client-conflict-error) + :conflict) + ((typep condition 'star.api.client:client-validation-error) + :validation-error) + ((typep condition 'star.api.client:client-rate-limit-error) + :rate-limited) + ((typep condition 'star.api.client:client-server-unavailable-error) + :server-unavailable) + ((typep condition 'star.api.client:client-timeout-error) + :timeout) + ((typep condition 'star.api.client:client-connection-error) + :connection-error) + ((typep condition 'star.api.client:client-protocol-error) + :protocol-error) + ((typep condition 'star.api.client:star-client-error) + :client-error) + (t :operation-failed))) + +(defun client-condition-details (condition) + (when (typep condition 'star.api.client:client-http-error) + (list :status (star.api.client:client-http-error-status condition) + :server-code (star.api.client:client-http-error-code condition) + :correlation-id (star.api.client:client-http-error-correlation-id condition) + :operation-id (star.api.client:client-http-error-operation-id condition)))) + (defun execute-planned-operation (plan) (unwind-protect (handler-case @@ -63,15 +96,18 @@ :code :ok :value (perform-operation *current-session* plan)))) (error (condition) - (trace-event :error - :operation (shell-plan-operation plan) - :condition (princ-to-string condition)) - (finish-result - (make-result - :success-p nil - :operation (shell-plan-operation plan) - :code :operation-failed - :message (princ-to-string condition))))) + (let ((code (client-condition-code condition))) + (trace-event :error + :operation (shell-plan-operation plan) + :code code + :condition (princ-to-string condition)) + (finish-result + (make-result + :success-p nil + :operation (shell-plan-operation plan) + :code code + :value (client-condition-details condition) + :message (princ-to-string condition)))))) (ignore-errors (lisa:retract plan)))) (defparameter *shell-rule-forms* @@ -87,6 +123,24 @@ (plan-request ?request :server-info :read 'plan-server-info "Read StarIntel server metadata and protocol information.")) + (lisa:defrule plan-auth-context (:salience 30) + (?request (shell-request (verb :context) (resource :auth))) + => + (plan-request ?request :auth-context :read 'plan-auth-context + "Read the authenticated StarIntel principal and authorization context.")) + + (lisa:defrule plan-openapi (:salience 30) + (?request (shell-request (verb :openapi) (resource :server))) + => + (plan-request ?request :openapi :read 'plan-openapi + "Fetch the server OpenAPI contract.")) + + (lisa:defrule plan-client-manifest (:salience 30) + (?request (shell-request (verb :manifest) (resource :server))) + => + (plan-request ?request :client-manifest :read 'plan-client-manifest + "Fetch the machine-readable StarIntel client manifest.")) + (lisa:defrule plan-document-get (:salience 30) (?request (shell-request (verb :get) (resource :document))) => @@ -219,6 +273,10 @@ => (unsupported-request ?request)))) +(defun shell-rule-names () + "Return the names of rules installed by the StarIntel expert shell." + (mapcar #'second *shell-rule-forms*)) + (defun install-shell-rules (engine) (lisa:with-inference-engine (engine) (let ((*package* (find-package :star.expert.shell))) From c87a451b20d0fc1738fda74c910ea53b24a4f65e Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:35:26 -0400 Subject: [PATCH 24/26] fix(expert-shell): harden parsing state and one-shot argv semantics --- expert-shell/shell.lisp | 571 +++++++++++++++++++++++++++++----------- 1 file changed, 413 insertions(+), 158 deletions(-) diff --git a/expert-shell/shell.lisp b/expert-shell/shell.lisp index febd55bd..9a7208a5 100644 --- a/expert-shell/shell.lisp +++ b/expert-shell/shell.lisp @@ -4,33 +4,41 @@ (find character " \t\n\r" :test #'char=)) (defun tokenize-command-line (line) - "Split LINE like a small shell. Quotes group tokens; backslash escapes one char." + "Split LINE like a small shell while preserving quoted empty tokens." (let ((tokens '()) (buffer '()) (quote-char nil) - (escaped-p nil)) + (escaped-p nil) + (token-started-p nil)) (labels ((flush () - (when buffer + (when token-started-p (push (coerce (nreverse buffer) 'string) tokens) - (setf buffer nil)))) + (setf buffer nil + token-started-p nil)))) (loop for character across line do (cond (escaped-p (push character buffer) - (setf escaped-p nil)) + (setf escaped-p nil + token-started-p t)) ((char= character #\\) - (setf escaped-p t)) + (setf escaped-p t + token-started-p t)) (quote-char (if (char= character quote-char) (setf quote-char nil) - (push character buffer))) + (progn + (push character buffer) + (setf token-started-p t)))) ((or (char= character #\") (char= character #\')) - (setf quote-char character)) + (setf quote-char character + token-started-p t)) ((whitespace-char-p character) (flush)) (t - (push character buffer)))) + (push character buffer) + (setf token-started-p t)))) (when escaped-p (push #\\ buffer)) (when quote-char @@ -48,36 +56,55 @@ (defun token= (token name) (string= (token-name token) (string-downcase name))) +(defun marker-name (token) + (cond + ((keywordp token) + (string-downcase (symbol-name token))) + ((and (stringp token) + (> (length token) 2) + (string= token "--" :end1 2 :end2 2)) + (string-downcase (subseq token 2))) + (t nil))) + (defun yes-marker-p (token) - (or (and (stringp token) (string= token "--yes")) - (eq token :yes))) + (string= (or (marker-name token) "") "yes")) (defun transient-marker-p (token) - (or (and (stringp token) (string= token "--transient")) - (eq token :transient))) + (string= (or (marker-name token) "") "transient")) (defun option-marker-p (token name) - (or (and (stringp token) - (string= token (format nil "--~a" name))) - (and (keywordp token) - (string-equal (symbol-name token) name)))) + (string= (or (marker-name token) "") (string-downcase name))) + +(defun require-option-value (tail option-name) + (let ((value (second tail))) + (when (or (null value) (marker-name value)) + (error "--~a requires a value" option-name)) + value)) (defun option-value (items name &optional default) (loop for tail on items for item = (first tail) when (option-marker-p item name) - do (return (or (second tail) default)) + do (return (require-option-value tail name)) finally (return default))) (defun positional-items (items option-names &key flags) (let ((result '())) (loop while items do - (let ((item (pop items))) + (let* ((item (pop items)) + (marker (marker-name item))) (cond - ((some (lambda (name) (option-marker-p item name)) option-names) - (when items (pop items))) ((some (lambda (predicate) (funcall predicate item)) flags) nil) + (marker + (if (member marker option-names :test #'string=) + (progn + (unless items + (error "--~a requires a value" marker)) + (when (marker-name (first items)) + (error "--~a requires a value" marker)) + (pop items)) + (error "Unknown option --~a" marker))) (t (push item result))))) (nreverse result))) @@ -96,11 +123,20 @@ ((null value) nil) ((integerp value) value) ((stringp value) - (or (parse-integer value :junk-allowed t) - (error "~a must be an integer" option-name))) + (multiple-value-bind (number position) + (parse-integer value :junk-allowed t) + (unless (and number (= position (length value))) + (error "~a must be an integer" option-name)) + number)) (t (error "~a must be an integer" option-name)))) +(defun parse-limit-option (value option-name default) + (let ((limit (or (parse-integer-option value option-name) default))) + (unless (plusp limit) + (error "~a must be greater than zero" option-name)) + limit)) + (defun command-form (line) (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) line))) (when (zerop (length trimmed)) @@ -111,8 +147,10 @@ (read-from-string trimmed nil nil) (unless form (error "Empty command form")) - (unless (zerop (length (string-trim '(#\Space #\Tab #\Newline #\Return) - (subseq trimmed position)))) + (unless (zerop + (length + (string-trim '(#\Space #\Tab #\Newline #\Return) + (subseq trimmed position)))) (error "Unexpected input after command form")) (unless (listp form) (error "Command form must be a list")) @@ -132,8 +170,29 @@ (or (nth index items) (error "Missing ~a" description))) +(defun require-no-extra-positionals (items description) + (when items + (error "Unexpected ~a: ~{~a~^ ~}" description items))) + +(defun require-json-object-string (value description) + (let ((text (stringify value))) + (handler-case + (let ((parsed (jsown:parse text))) + (unless (and (consp parsed) (eq (first parsed) :obj)) + (error "~a must be a JSON object" description)) + text) + (error (condition) + (error "Invalid ~a: ~a" description condition))))) + +(defun require-api-path (value) + (let ((path (stringify value))) + (unless (and (plusp (length path)) + (char= (char path 0) #\/)) + (error "Raw API paths must begin with /")) + path)) + (defun parse-command (line) - "Parse LINE into a SHELL-REQUEST. No input is EVALed." + "Parse LINE into a SHELL-REQUEST. Input is data only and is never EVALed." (let* ((items (command-form line)) (head (first items)) (confirmed-p (some #'yes-marker-p items))) @@ -151,19 +210,44 @@ (token= (second items) "info")) (make-request :info :server line)) + ((token= head "whoami") + (make-request :context :auth line)) + + ((and (token= head "auth") + (second items) + (token= (second items) "context")) + (make-request :context :auth line)) + + ((token= head "openapi") + (make-request :openapi :server line)) + + ((token= head "manifest") + (make-request :manifest :server line)) + + ((and (token= head "client") + (second items) + (token= (second items) "manifest")) + (make-request :manifest :server line)) + ((or (token= head "doc") (token= head "document")) (let* ((action (require-positional items 1 "document action")) (tail (cddr items))) (cond ((token= action "get") - (make-request :get :document line - :args (list :id (stringify - (require-positional tail 0 "document id"))))) + (let ((positionals (positional-items tail '()))) + (require-no-extra-positionals (rest positionals) "document arguments") + (make-request :get :document line + :args (list :id + (stringify + (require-positional positionals 0 "document id")))))) + ((token= action "search") - (let* ((limit (or (parse-integer-option (option-value tail "limit") "--limit") 25)) + (let* ((limit (parse-limit-option + (option-value tail "limit") "--limit" 25)) (bookmark (option-value tail "bookmark")) (sort (option-value tail "sort")) - (positionals (positional-items tail '("limit" "bookmark" "sort")))) + (positionals + (positional-items tail '("limit" "bookmark" "sort")))) (unless positionals (error "Missing search query")) (make-request :search :document line @@ -171,27 +255,41 @@ :limit limit :bookmark (and bookmark (stringify bookmark)) :sort (and sort (stringify sort)))))) + ((or (token= action "submit") (token= action "create")) - (let* ((positionals (positional-items tail '() :flags (list #'yes-marker-p))) - (dtype (stringify (require-positional positionals 0 "document type"))) + (let* ((positionals + (positional-items tail '() :flags (list #'yes-marker-p))) + (dtype + (stringify + (require-positional positionals 0 "document type"))) (json-parts (rest positionals))) (unless json-parts (error "Missing document JSON")) (make-request :submit :document line :confirmed-p confirmed-p - :args (list :dtype dtype :json (join-items json-parts))))) + :args (list :dtype dtype + :json + (require-json-object-string + (join-items json-parts) + "document JSON"))))) + ((token= action "delete") - (let ((positionals (positional-items tail '() :flags (list #'yes-marker-p)))) + (let ((positionals + (positional-items tail '() :flags (list #'yes-marker-p)))) + (require-no-extra-positionals (rest positionals) "document arguments") (make-request :delete :document line :confirmed-p confirmed-p - :args (list :id (stringify - (require-positional positionals 0 "document id")))))) + :args (list :id + (stringify + (require-positional positionals 0 "document id")))))) + (t (make-request :unknown :document line))))) ((token= head "search") (let* ((tail (rest items)) - (limit (or (parse-integer-option (option-value tail "limit") "--limit") 25)) + (limit (parse-limit-option + (option-value tail "limit") "--limit" 25)) (positionals (positional-items tail '("limit")))) (unless positionals (error "Missing search query")) @@ -200,35 +298,55 @@ :limit limit)))) ((token= head "get") - (make-request :get :document line - :args (list :id (stringify - (require-positional (rest items) 0 "document id"))))) + (let ((positionals (positional-items (rest items) '()))) + (require-no-extra-positionals (rest positionals) "document arguments") + (make-request :get :document line + :args (list :id + (stringify + (require-positional positionals 0 "document id")))))) ((or (token= head "target") (token= head "targets")) (let* ((action (require-positional items 1 "target action")) (tail (cddr items))) (cond ((token= action "list") - (make-request :list :target line - :args (list :actor (stringify - (require-positional tail 0 "actor"))))) + (let ((positionals (positional-items tail '()))) + (require-no-extra-positionals (rest positionals) "target arguments") + (make-request :list :target line + :args (list :actor + (stringify + (require-positional positionals 0 "actor")))))) + ((token= action "get") - (make-request :get :target line - :args (list :id (stringify - (require-positional tail 0 "target id"))))) + (let ((positionals (positional-items tail '()))) + (require-no-extra-positionals (rest positionals) "target arguments") + (make-request :get :target line + :args (list :id + (stringify + (require-positional positionals 0 "target id")))))) + ((or (token= action "create") (token= action "submit")) - (let* ((positionals (positional-items tail '() - :flags (list #'yes-marker-p - #'transient-marker-p))) - (actor (stringify (require-positional positionals 0 "actor"))) + (let* ((positionals + (positional-items tail '() + :flags (list #'yes-marker-p + #'transient-marker-p))) + (actor + (stringify + (require-positional positionals 0 "actor"))) (json-parts (rest positionals))) (unless json-parts (error "Missing target JSON")) (make-request :create :target line :confirmed-p confirmed-p :args (list :actor actor - :json (join-items json-parts) - :transient (some #'transient-marker-p tail))))) + :json + (require-json-object-string + (join-items json-parts) + "target JSON") + :transient + (not (null + (some #'transient-marker-p tail))))))) + (t (make-request :unknown :target line))))) @@ -236,36 +354,50 @@ (let ((action (require-positional items 1 "dataset action")) (tail (cddr items))) (if (token= action "size") - (make-request :size :dataset line - :args (list :dataset - (stringify - (require-positional tail 0 "dataset name")))) + (let ((positionals (positional-items tail '()))) + (require-no-extra-positionals (rest positionals) "dataset arguments") + (make-request :size :dataset line + :args (list :dataset + (stringify + (require-positional + positionals 0 "dataset name"))))) (make-request :unknown :dataset line)))) ((token= head "groups") (let* ((tail (rest items)) - (limit (or (parse-integer-option (option-value tail "limit") "--limit") 50))) + (limit (parse-limit-option + (option-value tail "limit") "--limit" 50)) + (positionals (positional-items tail '("limit")))) + (require-no-extra-positionals positionals "group arguments") (make-request :list :groups line :args (list :limit limit)))) ((token= head "messages") - (let* ((qualifier-token (require-positional items 1 "messages qualifier")) + (let* ((qualifier-token + (require-positional items 1 "messages qualifier")) (tail (cddr items)) - (limit (or (parse-integer-option (option-value tail "limit") "--limit") 50)) + (limit (parse-limit-option + (option-value tail "limit") "--limit" 50)) (positionals (positional-items tail '("limit")))) (cond ((token= qualifier-token "user") + (require-no-extra-positionals (rest positionals) "message arguments") (make-request :list :messages line :qualifier :user - :args (list :user (stringify - (require-positional positionals 0 "user")) + :args (list :user + (stringify + (require-positional positionals 0 "user")) :limit limit))) ((token= qualifier-token "platform") + (require-no-extra-positionals (rest positionals) "message arguments") (make-request :list :messages line :qualifier :platform - :args (list :platform (stringify - (require-positional positionals 0 "platform")) + :args (list :platform + (stringify + (require-positional + positionals 0 "platform")) :limit limit))) ((token= qualifier-token "group") + (require-no-extra-positionals positionals "message arguments") (make-request :list :messages line :qualifier :group :args (list :limit limit))) @@ -273,43 +405,55 @@ (make-request :unknown :messages line))))) ((token= head "social") - (let* ((qualifier-token (require-positional items 1 "social qualifier")) + (let* ((qualifier-token + (require-positional items 1 "social qualifier")) (tail (cddr items)) - (limit (or (parse-integer-option (option-value tail "limit") "--limit") 50)) + (limit (parse-limit-option + (option-value tail "limit") "--limit" 50)) (positionals (positional-items tail '("limit")))) (if (token= qualifier-token "user") - (make-request :list :social line - :qualifier :user - :args (list :user (stringify - (require-positional positionals 0 "user")) - :limit limit)) + (progn + (require-no-extra-positionals (rest positionals) "social arguments") + (make-request :list :social line + :qualifier :user + :args (list :user + (stringify + (require-positional positionals 0 "user")) + :limit limit))) (make-request :unknown :social line)))) ((or (token= head "raw") (token= head "api")) (let* ((method-token (require-positional items 1 "HTTP method")) (tail (cddr items)) - (positionals (positional-items tail '() :flags (list #'yes-marker-p))) - (path (stringify (require-positional positionals 0 "API path"))) + (positionals + (positional-items tail '() :flags (list #'yes-marker-p))) + (path + (require-api-path + (require-positional positionals 0 "API path"))) (body-parts (rest positionals))) (cond ((token= method-token "get") + (require-no-extra-positionals body-parts "GET arguments") (make-request :get :raw line :args (list :path path))) ((token= method-token "post") (make-request :post :raw line :confirmed-p confirmed-p :args (list :path path - :body (and body-parts (join-items body-parts))))) + :body (and body-parts + (join-items body-parts))))) ((token= method-token "put") (make-request :put :raw line :confirmed-p confirmed-p :args (list :path path - :body (and body-parts (join-items body-parts))))) + :body (and body-parts + (join-items body-parts))))) ((token= method-token "delete") + (require-no-extra-positionals body-parts "DELETE arguments") (make-request :delete :raw line :confirmed-p confirmed-p :args (list :path path))) (t - (make-request :unknown :raw line)))))) + (make-request :unknown :raw line))))) (t (make-request :unknown :unknown line))))) @@ -329,6 +473,12 @@ (star.api.client:health client)) (:server-info (star.api.client:server-info client)) + (:auth-context + (star.api.client:auth-context client)) + (:openapi + (star.api.client:fetch-openapi-document client)) + (:client-manifest + (star.api.client:fetch-client-manifest client)) (:document-get (star.api.client:get-document client (require-plan-arg plan :id))) (:document-search @@ -384,38 +534,44 @@ (:raw-get (star.api.client:api-request client (require-plan-arg plan :path))) (:raw-post - (star.api.client:api-request client (require-plan-arg plan :path) - :method :post - :content (getf (shell-plan-args plan) :body))) + (star.api.client:api-request + client (require-plan-arg plan :path) + :method :post + :content (getf (shell-plan-args plan) :body))) (:raw-put - (star.api.client:api-request client (require-plan-arg plan :path) - :method :put - :content (getf (shell-plan-args plan) :body))) + (star.api.client:api-request + client (require-plan-arg plan :path) + :method :put + :content (getf (shell-plan-args plan) :body))) (:raw-delete - (star.api.client:api-request client (require-plan-arg plan :path) - :method :delete)) + (star.api.client:api-request + client (require-plan-arg plan :path) + :method :delete)) (otherwise (error "No executor for operation ~s" (shell-plan-operation plan)))))) (defun make-shell-session (&key client (base-url "http://127.0.0.1:5000") api-key) - (let* ((client (or client - (let ((base (star.api.client:make-star-client - :base-url base-url))) - (if api-key - (star.api.client:client-with-api-key base api-key) - base)))) + (let* ((client + (or client + (let ((base + (star.api.client:make-star-client :base-url base-url))) + (if api-key + (star.api.client:client-with-api-key base api-key) + base)))) (engine (lisa:make-inference-engine)) - (session (make-instance 'shell-session - :client client - :engine engine))) + (session + (make-instance 'shell-session :client client :engine engine))) (install-shell-rules engine) session)) (defparameter *operation-catalog* '("health | status" "info | server info" + "whoami | auth context" + "openapi" + "manifest | client manifest" "doc get ID" "doc search QUERY [--limit N] [--bookmark B] [--sort FIELD]" "doc submit DTYPE JSON --yes" @@ -435,7 +591,8 @@ "raw delete PATH --yes" "why" "rules" - "help" + "facts" + "commands | help" "quit | exit")) (defun help-text () @@ -444,71 +601,146 @@ (format stream "Commands:~%") (dolist (entry *operation-catalog*) (format stream " ~a~%" entry)) - (format stream "~%Lisp syntax is also accepted, e.g. (doc search \"alice\" :limit 10).~%") - (format stream "Mutation rules require --yes (or :yes in Lisp syntax). Input forms are read as data with *READ-EVAL* disabled.~%"))) + (format stream + "~%Lisp syntax is accepted, e.g. (doc search \"alice\" :limit 10).~%") + (format stream + "Mutation rules require --yes (or :yes in Lisp syntax). Input forms are data with *READ-EVAL* disabled.~%"))) (defun last-explanation (session) - (let ((plan (shell-session-last-plan session))) + (let ((plan (shell-session-last-plan session)) + (trace (copy-tree (shell-session-trace session)))) (if plan (list :rule (shell-plan-rule-name plan) :operation (shell-plan-operation plan) :risk (shell-plan-risk plan) :reason (shell-plan-reason plan) - :trace (copy-tree (shell-session-trace session))) - (list :message "No Lisa plan has run in this session yet.")))) + :trace trace) + (list :message "No Lisa plan is associated with the last command." + :trace trace)))) + +(defun session-facts (session) + (lisa:with-inference-engine ((shell-session-engine session)) + (mapcar #'prin1-to-string + (lisa:get-fact-list (lisa:inference-engine))))) (defun meta-command-result (session line) - (let ((trimmed (string-downcase - (string-trim '(#\Space #\Tab #\Newline #\Return) line)))) + (let ((trimmed + (string-downcase + (string-trim '(#\Space #\Tab #\Newline #\Return) line)))) (cond - ((member trimmed '("help" "?") :test #'string=) - (make-result :success-p t :operation :help :code :ok :value (help-text))) + ((member trimmed '("help" "?" "commands") :test #'string=) + (make-result :success-p t + :operation :help + :code :ok + :value (help-text))) ((string= trimmed "rules") - (make-result :success-p t :operation :rules :code :ok - :value (copy-list *operation-catalog*))) + (make-result :success-p t + :operation :rules + :code :ok + :value (mapcar (lambda (name) + (string-downcase (symbol-name name))) + (shell-rule-names)))) + ((string= trimmed "facts") + (make-result :success-p t + :operation :facts + :code :ok + :value (session-facts session))) ((string= trimmed "why") - (make-result :success-p t :operation :why :code :ok + (make-result :success-p t + :operation :why + :code :ok :value (last-explanation session))) (t nil)))) +(defun reset-command-state (session) + (setf (shell-session-last-result session) nil + (shell-session-last-plan session) nil + (shell-session-trace session) nil) + session) + +(defun finalize-trace (session) + (setf (shell-session-trace session) + (nreverse (shell-session-trace session))) + session) + (defun run-command (session line) "Run one LINE through the Lisa planner and return a SHELL-RESULT." (or (meta-command-result session line) - (handler-case - (let ((request (parse-command line))) - (setf (shell-session-last-result session) nil - (shell-session-last-plan session) nil - (shell-session-trace session) nil) - (let ((*current-session* session)) - (trace-event :request - :verb (shell-request-verb request) - :resource (shell-request-resource request) - :qualifier (shell-request-qualifier request) - :confirmed-p (shell-request-confirmed-p request)) - (lisa:with-inference-engine ((shell-session-engine session)) - (lisa:assert-instance request) - (lisa:run)) - (setf (shell-session-trace session) - (nreverse (shell-session-trace session))) - (or (shell-session-last-result session) - (make-result :success-p nil - :code :no-result - :message "Lisa reached quiescence without producing a result.")))) - (error (condition) - (let ((result (make-result :success-p nil - :code :invalid-command - :message (princ-to-string condition)))) - (setf (shell-session-last-result session) result) - result))))) + (progn + (reset-command-state session) + (let ((*current-session* session)) + (let ((request + (handler-case + (parse-command line) + (error (condition) + (trace-event :parse-error + :condition (princ-to-string condition)) + (let ((result + (finish-result + (make-result + :success-p nil + :code :invalid-command + :message (princ-to-string condition))))) + (finalize-trace session) + (return-from run-command result)))))) + (trace-event :request + :verb (shell-request-verb request) + :resource (shell-request-resource request) + :qualifier (shell-request-qualifier request) + :confirmed-p (shell-request-confirmed-p request)) + (handler-case + (lisa:with-inference-engine ((shell-session-engine session)) + (lisa:assert-instance request) + (lisa:run)) + (error (condition) + (trace-event :inference-error + :condition (princ-to-string condition)) + (unless (shell-session-last-result session) + (finish-result + (make-result + :success-p nil + :code :inference-error + :message (princ-to-string condition)))))) + (finalize-trace session) + (or (shell-session-last-result session) + (make-result + :success-p nil + :code :no-result + :message "Lisa reached quiescence without producing a result."))))))) (defun json-ish-p (value) (and (consp value) - (member (first value) '(:obj :array) :test #'eq))) + (eq (first value) :obj))) + +(defun plist-value-p (value) + (and (listp value) + (evenp (length value)) + (loop for (key ignored) on value by #'cddr + always (progn + (declare (ignore ignored)) + (keywordp key))))) + +(defun json-safe-value (value) + (cond + ((null value) :null) + ((member value '(:true :false :null) :test #'eq) value) + ((or (stringp value) (numberp value)) value) + ((json-ish-p value) value) + ((keywordp value) (string-downcase (symbol-name value))) + ((symbolp value) (string-downcase (symbol-name value))) + ((plist-value-p value) + (cons :obj + (loop for (key item) on value by #'cddr + collect + (cons (string-downcase (symbol-name key)) + (json-safe-value item))))) + ((listp value) + (mapcar #'json-safe-value value)) + (t (prin1-to-string value)))) (defun print-value (value stream) (cond - ((null value) - nil) + ((null value) nil) ((stringp value) (write-string value stream) (unless (and (plusp (length value)) @@ -523,17 +755,17 @@ (jsown:to-json (jsown:new-js ("ok" (if (shell-result-success-p result) :true :false)) - ("operation" (and (shell-result-operation result) - (string-downcase - (symbol-name (shell-result-operation result))))) - ("code" (and (shell-result-code result) - (string-downcase (symbol-name (shell-result-code result))))) - ("message" (shell-result-message result)) - ("value" (let ((value (shell-result-value result))) - (cond - ((or (null value) (stringp value) (numberp value)) value) - ((json-ish-p value) value) - (t (prin1-to-string value))))))))) + ("operation" + (if (shell-result-operation result) + (string-downcase + (symbol-name (shell-result-operation result))) + :null)) + ("code" + (if (shell-result-code result) + (string-downcase (symbol-name (shell-result-code result))) + :null)) + ("message" (or (shell-result-message result) :null)) + ("value" (json-safe-value (shell-result-value result)))))) (defun render-result (result &key (stream *standard-output*) json) (if json @@ -548,12 +780,15 @@ result) (defun quit-command-p (line) - (member (string-downcase - (string-trim '(#\Space #\Tab #\Newline #\Return) line)) - '("quit" "exit" ":q") - :test #'string=)) - -(defun start-repl (session &key (input *standard-input*) (output *standard-output*)) + (member + (string-downcase + (string-trim '(#\Space #\Tab #\Newline #\Return) line)) + '("quit" "exit" ":q") + :test #'string=)) + +(defun start-repl (session &key + (input *standard-input*) + (output *standard-output*)) (format output "StarIntel expert shell (Lisa). Type 'help' for commands.~%") (loop (format output "star> ") @@ -563,13 +798,29 @@ (return t)) (when (quit-command-p line) (return t)) - (unless (zerop (length (string-trim '(#\Space #\Tab #\Newline #\Return) line))) + (unless (zerop + (length + (string-trim '(#\Space #\Tab #\Newline #\Return) line))) (render-result (run-command session line) :stream output))))) (defun default-base-url () (or (uiop:getenv "STAR_SERVER_URL") "http://127.0.0.1:5000")) +(defun escaped-command-token (token) + "Quote one argv TOKEN so reparsing preserves its bytes exactly." + (with-output-to-string (stream) + (write-char #\" stream) + (loop for character across token do + (when (or (char= character #\\) + (char= character #\")) + (write-char #\\ stream)) + (write-char character stream)) + (write-char #\" stream))) + +(defun command-from-argv (arguments) + (format nil "~{~a~^ ~}" (mapcar #'escaped-command-token arguments))) + (defun parse-main-args (args) (let ((base-url (default-base-url)) (api-key (uiop:getenv "STAR_API_KEY")) @@ -581,11 +832,14 @@ (let ((arg (pop args))) (cond ((member arg '("--url" "-u") :test #'string=) - (setf base-url (or (pop args) (error "--url requires a value")))) + (setf base-url + (or (pop args) (error "--url requires a value")))) ((member arg '("--api-key" "-k") :test #'string=) - (setf api-key (or (pop args) (error "--api-key requires a value")))) + (setf api-key + (or (pop args) (error "--api-key requires a value")))) ((member arg '("--command" "-c") :test #'string=) - (setf command (or (pop args) (error "--command requires a value")))) + (setf command + (or (pop args) (error "--command requires a value")))) ((string= arg "--json") (setf json t)) ((member arg '("--help" "-h") :test #'string=) @@ -593,7 +847,7 @@ (t (push arg positionals))))) (when (and (null command) positionals) - (setf command (format nil "~{~a~^ ~}" (nreverse positionals)))) + (setf command (command-from-argv (nreverse positionals)))) (values base-url api-key command json help))) (defun main () @@ -603,7 +857,8 @@ (when help (write-string (help-text)) (uiop:quit 0)) - (let ((session (make-shell-session :base-url base-url :api-key api-key))) + (let ((session + (make-shell-session :base-url base-url :api-key api-key))) (if command (let ((result (run-command session command))) (render-result result :json json) From 57ea3bed50d95195ab5b9991715c163d45b00519 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:40:54 -0400 Subject: [PATCH 25/26] fix(expert-shell): repair plist JSON serializer --- expert-shell/shell.lisp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/expert-shell/shell.lisp b/expert-shell/shell.lisp index 9a7208a5..b1c027f6 100644 --- a/expert-shell/shell.lisp +++ b/expert-shell/shell.lisp @@ -715,10 +715,8 @@ (defun plist-value-p (value) (and (listp value) (evenp (length value)) - (loop for (key ignored) on value by #'cddr - always (progn - (declare (ignore ignored)) - (keywordp key))))) + (loop for tail on value by #'cddr + always (keywordp (first tail))))) (defun json-safe-value (value) (cond From 45bbd72c7ec3b5057a901f300fd649e3c672c295 Mon Sep 17 00:00:00 2001 From: nsaspy <104283403+lost-rob0t@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:41:58 -0400 Subject: [PATCH 26/26] test(expert-shell): cover hardened parser and operator semantics --- t/expert-shell-test.lisp | 206 +++++++++++++++++++++++++++++++++++---- 1 file changed, 189 insertions(+), 17 deletions(-) diff --git a/t/expert-shell-test.lisp b/t/expert-shell-test.lisp index 773fb311..1de1c75e 100644 --- a/t/expert-shell-test.lisp +++ b/t/expert-shell-test.lisp @@ -9,47 +9,73 @@ (in-suite expert-shell-tests) -(defun expert-shell-fake-json-response (&optional (body "{\"status\":\"ok\"}")) +(defun expert-shell-fake-json-response + (&key + (status 200) + (body "{\"status\":\"ok\"}") + (correlation-id "expert-shell-test") + (content-type "application/json")) + "Construct a CLIENT-RESPONSE for isolated expert-shell transport tests." (star.api.client::make-client-response - :status 200 - :headers '(("content-type" . "application/json")) + :status status + :headers (append + (list (cons "content-type" content-type)) + (when correlation-id + (list (cons "x-correlation-id" correlation-id)))) :body body :uri "http://example.test" - :correlation-id "expert-shell-test" - :content-type "application/json")) + :correlation-id correlation-id + :content-type content-type)) -(defun make-expert-shell-test-session (&optional request-hook) +(defun make-expert-shell-test-session (&key request-hook responder) + "Create an expert-shell session with a deterministic in-memory transport." (let ((transport (star.api.client:make-function-transport (lambda (request) (when request-hook (funcall request-hook request)) - (expert-shell-fake-json-response))))) + (if responder + (funcall responder request) + (expert-shell-fake-json-response)))))) (star.expert.shell:make-shell-session :client (star.api.client:make-star-client :base-url "http://example.test" :transport transport)))) +(defun command-rejected-p (command) + "Return true when COMMAND is rejected by the shell parser." + (handler-case + (progn + (star.expert.shell:parse-command command) + nil) + (error () t))) + (test expert-shell-parses-text-and-lisp-commands-as-data - (let ((request (star.expert.shell:parse-command - "doc search \"alice smith\" --limit 10"))) + (let ((request + (star.expert.shell:parse-command + "doc search \"alice smith\" --limit 10"))) (is (eq :search (star.expert.shell:shell-request-verb request))) (is (eq :document (star.expert.shell:shell-request-resource request))) (is (string= "alice smith" (getf (star.expert.shell:shell-request-args request) :query))) - (is (= 10 (getf (star.expert.shell:shell-request-args request) :limit)))) - (let ((request (star.expert.shell:parse-command - "(target create github \"{\\\"repo\\\":\\\"x/y\\\"}\" :transient :yes)"))) + (is (= 10 + (getf (star.expert.shell:shell-request-args request) :limit)))) + (let ((request + (star.expert.shell:parse-command + "(target create github \"{\\\"repo\\\":\\\"x/y\\\"}\" :transient :yes)"))) (is (eq :create (star.expert.shell:shell-request-verb request))) (is (eq :target (star.expert.shell:shell-request-resource request))) (is-true (star.expert.shell:shell-request-confirmed-p request)) - (is-true (getf (star.expert.shell:shell-request-args request) :transient)))) + (is-true + (getf (star.expert.shell:shell-request-args request) :transient)))) (test expert-shell-read-operation-is-planned-and-executed-by-lisa (let ((requests '())) (let* ((session (make-expert-shell-test-session - (lambda (request) (push request requests)))) + :request-hook + (lambda (request) + (push request requests)))) (result (star.expert.shell:run-command session "health"))) (is-true (star.expert.shell:shell-result-success-p result)) (is (eq :health (star.expert.shell:shell-result-operation result))) @@ -60,14 +86,37 @@ (is (eq 'star.expert.shell::plan-health (star.expert.shell:shell-plan-rule-name plan))))))) +(test expert-shell-whoami-uses-auth-context-plan + (let ((captured nil)) + (let* ((session + (make-expert-shell-test-session + :request-hook (lambda (request) (setf captured request)) + :responder + (lambda (request) + (declare (ignore request)) + (expert-shell-fake-json-response + :body "{\"principal_id\":\"alice\"}")))) + (result (star.expert.shell:run-command session "whoami"))) + (is-true (star.expert.shell:shell-result-success-p result)) + (is (eq :auth-context + (star.expert.shell:shell-result-operation result))) + (is (search "/auth/context" + (star.api.client:client-request-uri captured))) + (is (eq :read + (star.expert.shell:shell-plan-risk + (star.expert.shell:shell-session-last-plan session))))))) + (test expert-shell-unconfirmed-mutation-never-reaches-transport (let ((calls 0)) (let* ((session (make-expert-shell-test-session + :request-hook (lambda (request) (declare (ignore request)) (incf calls)))) - (result (star.expert.shell:run-command session "doc delete deadbeef"))) + (result + (star.expert.shell:run-command + session "doc delete deadbeef"))) (is-false (star.expert.shell:shell-result-success-p result)) (is (eq :confirmation-required (star.expert.shell:shell-result-code result))) @@ -81,6 +130,7 @@ (captured nil)) (let* ((session (make-expert-shell-test-session + :request-hook (lambda (request) (incf calls) (setf captured request)))) @@ -90,13 +140,135 @@ "raw delete /document/deadbeef --yes"))) (is-true (star.expert.shell:shell-result-success-p result)) (is (= 1 calls)) - (is (eq :delete (star.api.client:client-request-method captured))) + (is (eq :delete + (star.api.client:client-request-method captured))) (is (search "/document/deadbeef" (star.api.client:client-request-uri captured)))))) +(test expert-shell-parser-rejects-bad-options-strictly + (is-true + (command-rejected-p "doc search alice --limit 10wat")) + (is-true + (command-rejected-p "doc search alice --limit")) + (is-true + (command-rejected-p "doc search alice --bogus 10")) + (is-true + (command-rejected-p "groups --limit 0"))) + +(test expert-shell-parse-errors-clear-stale-why-state + (let ((session (make-expert-shell-test-session))) + (star.expert.shell:run-command session "health") + (let ((bad + (star.expert.shell:run-command + session "doc search alice --limit 10wat"))) + (is-false (star.expert.shell:shell-result-success-p bad)) + (is (eq :invalid-command + (star.expert.shell:shell-result-code bad)))) + (let* ((why (star.expert.shell:run-command session "why")) + (value (star.expert.shell:shell-result-value why))) + (is (null (getf value :operation))) + (is (search "No Lisa plan" + (getf value :message))) + (is (some (lambda (event) + (eq :parse-error (getf event :event))) + (getf value :trace)))))) + +(test expert-shell-one-shot-argv-preserves-json-bytes + (let* ((json "{\"name\":\"Alice Smith\",\"n\":1}") + (command + (star.expert.shell::command-from-argv + (list "doc" "submit" "person" json "--yes"))) + (request (star.expert.shell:parse-command command))) + (is-true (star.expert.shell:shell-request-confirmed-p request)) + (is (string= json + (getf (star.expert.shell:shell-request-args request) + :json))))) + +(test expert-shell-invalid-json-is-rejected-before-transport + (let ((calls 0)) + (let* ((session + (make-expert-shell-test-session + :request-hook + (lambda (request) + (declare (ignore request)) + (incf calls)))) + (result + (star.expert.shell:run-command + session "doc submit person '{broken' --yes"))) + (is-false (star.expert.shell:shell-result-success-p result)) + (is (eq :invalid-command + (star.expert.shell:shell-result-code result))) + (is (zerop calls))))) + +(test expert-shell-raw-operation-is-contained-to-server-paths + (let ((calls 0)) + (let* ((session + (make-expert-shell-test-session + :request-hook + (lambda (request) + (declare (ignore request)) + (incf calls)))) + (result + (star.expert.shell:run-command + session "raw get https://example.invalid/escape"))) + (is-false (star.expert.shell:shell-result-success-p result)) + (is (eq :invalid-command + (star.expert.shell:shell-result-code result))) + (is (zerop calls))))) + +(test expert-shell-preserves-typed-client-errors + (let* ((session + (make-expert-shell-test-session + :responder + (lambda (request) + (declare (ignore request)) + (expert-shell-fake-json-response + :status 403 + :body "{\"status\":\"error\",\"msg\":\"Denied\",\"code\":\"missing_scope\",\"correlation_id\":\"corr-denied\"}" + :correlation-id "corr-denied")))) + (result (star.expert.shell:run-command session "whoami"))) + (is-false (star.expert.shell:shell-result-success-p result)) + (is (eq :authorization-error + (star.expert.shell:shell-result-code result))) + (is (= 403 (getf (star.expert.shell:shell-result-value result) :status))) + (is (string= "missing_scope" + (getf (star.expert.shell:shell-result-value result) + :server-code))) + (is (string= "corr-denied" + (getf (star.expert.shell:shell-result-value result) + :correlation-id))))) + +(test expert-shell-rules-introspects-installed-rulebase + (let* ((session (make-expert-shell-test-session)) + (result (star.expert.shell:run-command session "rules")) + (rules (star.expert.shell:shell-result-value result))) + (is-true (star.expert.shell:shell-result-success-p result)) + (is (member "plan-health" rules :test #'string=)) + (is (member "plan-auth-context" rules :test #'string=)) + (is (member "block-unconfirmed-write" rules :test #'string=)))) + +(test expert-shell-json-output-keeps-structured-error-details + (let* ((result + (star.expert.shell::make-result + :success-p nil + :operation :auth-context + :code :authorization-error + :value (list :status 403 + :server-code "missing_scope" + :correlation-id "corr-denied"))) + (object + (jsown:parse (star.expert.shell::result-json result))) + (value (jsown:val object "value"))) + (is (eq :false (jsown:val object "ok"))) + (is (= 403 (jsown:val value "status"))) + (is (string= "missing_scope" + (jsown:val value "server-code"))))) + (test expert-shell-fallback-is-a-lisa-rule (let* ((session (make-expert-shell-test-session)) - (result (star.expert.shell:run-command session "frobnicate everything"))) + (result + (star.expert.shell:run-command + session "frobnicate everything"))) (is-false (star.expert.shell:shell-result-success-p result)) (is (eq :unsupported-command (star.expert.shell:shell-result-code result)))