-
Notifications
You must be signed in to change notification settings - Fork 1
/
cli2eli.el
378 lines (339 loc) · 15.6 KB
/
cli2eli.el
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
;;; cli2eli.el --- CLI to Emacs Lauch Interface Generator -*- lexical-binding: t; -*-
;; Author: nohzafk
;; Version: 0.1
;; Package-Requires: ((emacs "26.1"))
;; Keywords: tools, convenience
;; URL: https://github.com/nohzafk/cli2eli
;;; Commentary:
;; CLI2ELI (Command Line Interface to Emacs Lauch Interface) is a package
;; that generates Emacs Lisp functions from JSON configuration files
;; describing CLI tools.
;;
;; It allows users to interact with command-line tools directly from within
;; Emacs, providing a seamless integration between Emacs and various CLI
;; utilities.
;;; Code:
(require 'ansi-color)
(require 'cl-lib)
(require 'json)
(require 'term)
(eval-when-compile
(defvar cli2eli-use-eat
(condition-case nil
(progn (require 'eat) t)
(error nil))
"Whether to use eat instead of term."))
(defgroup cli2eli nil
"Command line interface to Emacs Lauch interface."
:group 'eamcs)
(defcustom cli2eli-output-buffer-name "*CLI2ELI Output*"
"Buffer name for dedicated buffer."
:group 'cli2eli
:type 'string)
(defcustom cli2eli-output-buffer-display-option
'(display-buffer-at-bottom . ((window-height . 0.3)))
"Display options for dedicated buffer."
:group 'cli2eli
:type '(choice
(cons :tag "Display function and alist"
(function :tag "Display function")
(alist :key-type symbol :value-type sexp))
(function :tag "Display function")))
(defvar cli2eli--generated-functions nil
"List of functions generated by CLI2ELI.")
(defun cli2eli--remove-comments-and-schema (json-string)
"Remove JSON5-style comments and $schema field from JSON-STRING."
(with-temp-buffer
(insert json-string)
(goto-char (point-min))
;; Remove comments
(while (re-search-forward "//.*$" nil t)
(replace-match ""))
;; Remove $schema line
(goto-char (point-min))
(when (re-search-forward "^\\s-*\"\\$schema\".*$" nil t)
(delete-region (line-beginning-position) (1+ (line-end-position))))
;; Remove any trailing commas that might be left after removing $schema
(goto-char (point-min))
(while (re-search-forward ",\\s-*}" nil t)
(replace-match "}"))
(buffer-string)))
(defvar cli2eli--current-tool nil
"Store the current tool configuration.")
(defun cli2eli-remove-generated-functions ()
"Remove all previously generated CLI2ELI functions."
(interactive)
(dolist (func cli2eli--generated-functions)
(fmakunbound func))
(setq cli2eli--generated-functions nil))
(defun cli2eli--generate-function-name (tool-name cmd-name)
"Generate function name by TOOL-NAME CMD-NAME."
(cli2eli--sanitize-function-name (concat tool-name "-" cmd-name)))
(defun cli2eli--remove-replaced-functions (tool)
"Remove functions that will be replaced by the new tool configuration."
(let* ((tool-name (alist-get 'tool tool))
(commands-vector (alist-get 'commands tool))
(commands (append commands-vector nil))) ; Convert vector to list
(dolist (cmd commands)
(let* ((cmd-name (alist-get 'name cmd))
(func-name (intern (cli2eli--generate-function-name tool-name cmd-name))))
(when (fboundp func-name)
(fmakunbound func-name)
(setq cli2eli--generated-functions (delete func-name cli2eli--generated-functions)))))))
(defun cli2eli-load-tool (json-file)
"Load a CLI tool configuration from JSON-FILE.
If RELATIVE-P is non-nil, treat JSON-FILE as relative to the package directory."
(interactive "fSelect JSON configuration file: ")
(condition-case err
(let* ((file-path (expand-file-name json-file))
(json-object-type 'alist)
(json-array-type 'vector)
(json-key-type 'symbol)
(json-string (with-temp-buffer
(insert-file-contents file-path)
(buffer-string)))
(cleaned-json-string (cli2eli--remove-comments-and-schema json-string))
(new-tool (json-read-from-string cleaned-json-string)))
;; Remove functions that will be replaced
(cli2eli--remove-replaced-functions new-tool)
;; Load new tool configuration
(setq cli2eli--current-tool new-tool)
(cli2eli--generate-functions cli2eli--current-tool)
(message "[CLI2ELI] Successfully loaded tool configuration from %s" json-file))
(error
(message "[CLI2ELI] Error loading tool configuration: %s" (error-message-string err))
nil)))
(defun cli2eli--value-to-bool (value)
(cond
((null value) nil)
((string= value "") nil)
((string= value "false") nil)
((eq value 0) nil)
((eq value json-false) nil)
(t value)))
(defun cli2eli--generate-functions (tool)
"Generate Emacs functions for the CLI TOOL.
TOOL is an alist containing the tool configuration."
(let* ((tool-name (alist-get 'tool tool))
(commands-vector (alist-get 'commands tool))
(commands (append commands-vector nil))) ; Convert vector to list
(message "[CLI2ELI] Generating emacs functions for tool: %S" tool-name)
(dolist (cmd commands)
(let ((cmd-name (alist-get 'name cmd))
(cmd-command (alist-get 'command cmd))
(cmd-desc (or (alist-get 'description cmd) ""))
(cmd-extra-arguments (cli2eli--value-to-bool (alist-get 'extra_arguments cmd)))
(args (append (alist-get 'arguments cmd) nil)) ; Convert arguments vector to list
(chain-call (alist-get 'chain-call cmd))
(chain-pass (alist-get 'chain-pass cmd)))
(cli2eli--define-command tool-name cmd-name cmd-command cmd-desc cmd-extra-arguments args chain-call chain-pass)))))
(defun cli2eli--sanitize-function-name (name)
"Replace invalid characters in NAME for use in Emacs function names."
(replace-regexp-in-string
"[^a-zA-Z0-9-]"
"-"
(downcase name)))
(defun cli2eli--define-command (tool-name cmd-name cmd-command cmd-desc cmd-extra-arguments args chain-call chain-pass)
"Define an Emacs function for a CLI command.
TOOL-NAME is the name of the CLI tool.
CMD-NAME is the name of the specific command.
CMD-COMMAND is the actual command.
CMD-DESC is the description of the command.
CMD-EXTRA-ARGUMENTS is whether command need additional arguments input.
ARGS is a list of argument specifications.
CHAIN-CALL is the next executed interactive command.
CHAIN-PASS is whethe pass the result to CHAIN-CALL command."
(unless cmd-command
(error "Command of %s %s is nil" tool-name cmd-name))
(let* ((generated-function-name (cli2eli--generate-function-name tool-name cmd-name))
(func-name (intern generated-function-name))
(interactive-spec (cli2eli--generate-interactive-spec args cmd-extra-arguments)))
(message "[CLI2ELI] Generating function: %s" func-name)
(fset func-name
`(lambda (&rest arg-values)
,(concat "" cmd-desc)
(interactive ,interactive-spec)
(let* ((required-args
(cl-subseq arg-values 0 ,(length args)))
(additional-args
,(if cmd-extra-arguments
`(nth ,(length args) arg-values)
nil))
(processed-args
(string-trim
(concat
(mapconcat
#'identity
(cl-remove-if
#'string-empty-p
(cl-mapcar
(lambda (arg arg-value)
(let ((arg-name (alist-get 'name arg)))
(if (or (not arg-value) (string-empty-p arg-value))
""
(if (string-match-p "\\$\\$" arg-name)
(replace-regexp-in-string "\\$\\$" arg-value arg-name)
(format "%s %s" arg-name arg-value)))))
',args
required-args))
" ")
" "
additional-args))))
(let ((chain-result (cli2eli--run-command ,cmd-command processed-args)))
,(when chain-call
`(let* ((next-func (intern ,(concat tool-name "-" (cli2eli--sanitize-function-name chain-call))))
(next-func-args (if ,chain-pass (list chain-result) nil)))
(apply #'call-interactively next-func next-func-args)))
chain-result))))
;; Add the newly generated function to the list
(push func-name cli2eli--generated-functions)))
(defun cli2eli--argument-prompt (arg-name arg-desc)
(if (cli2eli--value-to-bool arg-desc)
(format "%s (%s): " arg-name arg-desc)
(format "%s: " arg-name)))
(defun cli2eli--generate-interactive-spec (args cmd-extra-arguments)
"Generate the interactive specification for command arguments.
ARGS is a list of argument specifications.
CMD-EXTRA-ARGUMENTS is a boolean indicating whether extra arguments are needed."
`(list
,@(mapcar
(lambda (arg)
(let* ((arg-name (alist-get 'name arg))
(arg-desc (replace-regexp-in-string "\n" " " (or (alist-get 'description arg) "")))
(arg-type (alist-get 'type arg))
(choices (alist-get 'choices arg)))
(cond
((string= arg-type "directory")
`(directory-file-name
(file-truename
(expand-file-name
(read-directory-name ,(cli2eli--argument-prompt arg-name arg-desc))))))
((string= arg-type "current-file")
`(or (buffer-file-name) ""))
(choices
`(let ((completion-ignore-case t)
(choices (mapcar (lambda (choice)
(cond
((eq choice t) "true")
((eq choice json-false) "false")
(t (format "%s" choice))))
',choices)))
(completing-read ,(cli2eli--argument-prompt arg-name arg-desc)
choices
nil t)))
((string= arg-type "dynamic-select")
`(cli2eli--dynamic-select
',(alist-get 'command arg)
,(or (alist-get 'prompt arg) "")
',(alist-get 'transform arg)))
(t
`(read-string ,(cli2eli--argument-prompt arg-name arg-desc))))))
args)
,@(when cmd-extra-arguments
'((read-string "Extra arguments: ")))))
(defun cli2eli--dynamic-select (command prompt transform)
"Run COMMAND, present results for selection with PROMPT, and apply TRANSFORM.
Always execute command on the local host by calling execute-local-comand,
regardless of editing a local file or a remote file through Tramp."
(let* ((working-directory (cli2eli--get-working-directory))
(output (execute-local-command command working-directory))
(lines (split-string output "\n" t))
(selection (completing-read prompt lines nil t))
(transformed (if transform
(execute-local-command
(format "echo %s | %s" (shell-quote-argument selection) transform)
working-directory)
selection)))
(string-trim transformed)))
(defun execute-local-command (command &optional directory)
"Execute COMMAND on the local host and return its output as a string.
If DIRECTORY is provided, execute the command in that directory."
(with-temp-buffer
(let ((default-directory (or directory default-directory)))
(call-process-shell-command command nil (current-buffer) nil)
(buffer-string))))
(defun cli2eli--get-working-directory ()
"Get the working directory."
(let ((cwd (alist-get 'cwd cli2eli--current-tool)))
(cond
((null cwd) (cli2eli--get-default-directory))
((string= cwd "") (cli2eli--get-default-directory))
((string= cwd "default") (cli2eli--get-default-directory))
((string= cwd "git-root") (locate-dominating-file
(or (cli2eli--get-default-directory) ".")
".git"))
(t cwd))))
(defun cli2eli--get-default-directory ()
"Get the appropriate default directory, handling Docker container cases."
(if (and (file-remote-p default-directory)
(string-prefix-p "/docker:" default-directory))
(cli2eli--get-docker-local-folder)
default-directory))
(defun cli2eli--get-docker-local-folder ()
"Get the local folder path for a Docker container."
(let* ((file-name (tramp-dissect-file-name default-directory))
(container-name (tramp-file-name-host file-name))
(inspect-command (format "docker inspect -f '{{ index .Config.Labels \"devcontainer.local_folder\" }}' %s"
container-name))
(local-folder (string-trim (execute-local-command inspect-command))))
(if (string-empty-p local-folder)
default-directory
local-folder)))
(defun cli2eli--run-command (cmd-command &optional processed-args)
"Run a CLI command asynchronously and display output in a dedicated buffer.
CMD-COMMAND is the specific command.
PROCESSED-ARGS is an optional string of additional arguments."
(let* ((output-buffer (get-buffer-create cli2eli-output-buffer-name))
(processed-args (or processed-args ""))
(command (format "%s %s" cmd-command processed-args))
(cwd (expand-file-name (cli2eli--get-working-directory)))
(existing-window (get-buffer-window output-buffer))
(shell (or (alist-get 'shell cli2eli--current-tool) "/bin/bash")))
(message "[CLI2ELI] Working Directory: %s" (cli2eli--get-working-directory))
(message "[CLI2ELI] Running command: %s" command)
(with-current-buffer output-buffer
(let ((inhibit-read-only t))
(if cli2eli-use-eat
(eat-mode)
(term-mode))
(erase-buffer)
(setq default-directory cwd)
(insert (format "Working Directory: %s\nRunning: %s\n\n"
(cli2eli--get-working-directory)
command))
(if cli2eli-use-eat
(eat-exec output-buffer
(format "\"%s\"" command)
shell
nil
(list "-c" (format "cd %s && %s"
(shell-quote-argument cwd)
command)))
(term-exec output-buffer
(format "\"%s\"" command)
shell
nil
(list "-c" (format "cd %s && %s"
(shell-quote-argument cwd)
command))))))
(if existing-window
(select-window existing-window)
(display-buffer output-buffer cli2eli-output-buffer-display-option)
(select-window (get-buffer-window output-buffer)))
(set-window-point (get-buffer-window output-buffer) (point-max))
(cli2eli--scroll-to-bottom)))
(defun cli2eli--process-sentinel (process event)
"Handle the finish event of the CLI process."
(when (string= event "finished\n")
(with-current-buffer (process-buffer process)
(goto-char (point-max))
(insert "\n\nProcess finished")
(cli2eli--scroll-to-bottom))))
(defun cli2eli--scroll-to-bottom ()
"Scroll the CLI2ELI output buffer to the bottom."
(let ((win (get-buffer-window (current-buffer))))
(when win
(with-selected-window win
(goto-char (point-max))))))
(provide 'cli2eli)
;;; cli2eli.el ends here