diff --git a/docs/changelog.rst b/docs/changelog.rst index 476d11a84..aa73323a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -53,6 +53,8 @@ Detailed list of changes 0.35.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- kitten @ run: A new remote control command to run a process on the machine kitty is running on and get its output (:iss:`7429`) + - :opt:`notify_on_cmd_finish`: Show the actual command that was finished (:iss:`7420`) - Shell integration: Make the currently executing cmdline available as a window variable in kitty diff --git a/kitty/launch.py b/kitty/launch.py index 25f806694..e14029588 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -30,9 +30,30 @@ class LaunchSpec(NamedTuple): args: List[str] +remote_control_password_docs = '''\ +type=list +Restrict the actions remote control is allowed to take. This works like +:opt:`remote_control_password`. You can specify a password and list of actions +just as for :opt:`remote_control_password`. For example:: + + --remote-control-password '"my passphrase" get-* set-colors' + +This password will be in effect for this window only. +Note that any passwords you have defined for :opt:`remote_control_password` +in :file:`kitty.conf` are also in effect. You can override them by using the same password here. +You can also disable all :opt:`remote_control_password` global passwords for this window, by using:: + + --remote-control-password '!' + +This option only takes effect if :option:`--allow-remote-control` +is also specified. Can be specified multiple times to create multiple passwords. +This option was added to kitty in version 0.26.0 +''' + + @run_once def options_spec() -> str: - return ''' + return f''' --window-title --title The title to set for the new window. By default, title is controlled by the child process. The special value :code:`current` will copy the title from the @@ -171,24 +192,7 @@ remote control. --remote-control-password -type=list -Restrict the actions remote control is allowed to take. This works like -:opt:`remote_control_password`. You can specify a password and list of actions -just as for :opt:`remote_control_password`. For example:: - - --remote-control-password '"my passphrase" get-* set-colors' - -This password will be in effect for this window only. -Note that any passwords you have defined for :opt:`remote_control_password` -in :file:`kitty.conf` are also in effect. You can override them by using the same password here. -You can also disable all :opt:`remote_control_password` global passwords for this window, by using:: - - --remote-control-password '!' - -This option only takes effect if :option:`--allow-remote-control` -is also specified. Can be specified multiple times to create multiple passwords. -This option was added to kitty in version 0.26.0 - +{remote_control_password_docs} --stdin-source type=choices diff --git a/kitty/rc/run.py b/kitty/rc/run.py new file mode 100644 index 000000000..ee021c236 --- /dev/null +++ b/kitty/rc/run.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2020, Kovid Goyal + +import sys +from base64 import standard_b64decode, standard_b64encode +from typing import TYPE_CHECKING, Optional + +from kitty.launch import remote_control_password_docs +from kitty.types import AsyncResponse + +from .base import ( + ArgsType, + Boss, + CmdGenerator, + ParsingOfArgsFailed, + PayloadGetType, + PayloadType, + RCOptions, + RemoteCommand, + ResponseType, + Window, +) + +if TYPE_CHECKING: + from kitty.cli_stub import RunRCOptions as CLIOptions + + +class Run(RemoteCommand): + protocol_spec = __doc__ = ''' + data+/str: Chunk of STDIN data, base64 encoded no more than 4096 bytes. Must send an empty chunk to indicate end of data. + cmdline+/list.str: The command line to run + allow_remote_control/bool: A boolean indicating whether to allow remote control + remote_control_password/list.str: A list of remote control passwords + ''' + + short_desc = 'Run a program on the computer in which kitty is running and get the output' + desc = ( + 'Run the specified program on the computer in which kitty is running. When STDIN is not a TTY it is forwarded' + ' to the program as its STDIN. STDOUT and STDERR from the the program are forwarded here. The exit status of this' + ' invocation will be the exit status of the executed program. If you wish to just run a program without wiating for a response, ' + ' use @ launch --type=background instead.' + ) + + options_spec = f'''\n +--allow-remote-control +type=bool-set +The executed program will have privileges to run remote control commands in kitty. + + +--remote-control-password +{remote_control_password_docs} +''' + args = RemoteCommand.Args( + spec='CMD ...', json_field='data', special_parse='+cmdline:!read_run_data(io_data, args, &payload)', minimum_count=1, + completion=RemoteCommand.CompletionSpec.from_string('type:special group:cli.CompleteExecutableFirstArg') + ) + reads_streaming_data = True + is_asynchronous = True + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if not args: + self.fatal('Must specify command to run') + import secrets + ret = { + 'stream_id': secrets.token_urlsafe(), + 'cmdline': args, + 'allow_remote_control': opts.allow_remote_control, + 'remote_control_password': opts.remote_control_password, + 'data': '', + } + def pipe() -> CmdGenerator: + if sys.stdin.isatty(): + yield ret + else: + limit = 4096 + while True: + data = sys.stdin.buffer.read(limit) + if not data: + break + ret['data'] = standard_b64encode(data).decode("ascii") + yield ret + return pipe() + + def response_from_kitty(self, boss: Boss, window: Optional[Window], payload_get: PayloadGetType) -> ResponseType: + import os + import tempfile + data = payload_get('data') + q = self.handle_streamed_data(standard_b64decode(data) if data else b'', payload_get) + if isinstance(q, AsyncResponse): + return q + stdin_data = q.getvalue() + from kitty.launch import parse_remote_control_passwords + cmdline = payload_get('cmdline') + allow_remote_control = payload_get('allow_remote_control') + pw = payload_get('remote_control_password') + rcp = parse_remote_control_passwords(allow_remote_control, pw) + if not cmdline: + raise ParsingOfArgsFailed('No cmdline to run specified') + responder = self.create_async_responder(payload_get, window) + stdout, stderr = tempfile.TemporaryFile(), tempfile.TemporaryFile() + + def on_death(exit_status: int, err: Optional[Exception]) -> None: + with stdout, stderr: + if err: + responder.send_error(f'Failed to run: {cmdline} with err: {err}') + else: + exit_code = os.waitstatus_to_exitcode(exit_status) + stdout.seek(0) + stderr.seek(0) + responder.send_data({ + 'stdout': standard_b64encode(stdout.read()).decode('ascii'), + 'stderr': standard_b64encode(stderr.read()).decode('ascii'), + 'exit_code': exit_code, 'exit_status': exit_status, + }) + + boss.run_background_process( + cmdline, stdin=stdin_data, stdout=stdout.fileno(), stderr=stderr.fileno(), notify_on_death=on_death, + remote_control_passwords=rcp, allow_remote_control=allow_remote_control + ) + return AsyncResponse() + + +run = Run() diff --git a/tools/cmd/at/run.go b/tools/cmd/at/run.go new file mode 100644 index 000000000..cbb1f5774 --- /dev/null +++ b/tools/cmd/at/run.go @@ -0,0 +1,80 @@ +package at + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + + "kitty/tools/tty" +) + +var _ = fmt.Print + +type run_response_data struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Exit_code int `json:"exit_code"` + Exit_status int `json:"exit_status"` +} + +func run_handle_response(data []byte) error { + var r run_response_data + if err := json.Unmarshal(data, &r); err != nil { + return err + } + if stdout, err := base64.StdEncoding.DecodeString(r.Stdout); err == nil { + _, _ = os.Stdout.Write(stdout) + } else { + return err + } + if stderr, err := base64.StdEncoding.DecodeString(r.Stderr); err == nil { + _, _ = os.Stderr.Write(stderr) + } else { + return err + } + if r.Exit_code != 0 { + return &exit_error{r.Exit_code} + } + return nil +} + +func read_run_data(io_data *rc_io_data, args []string, payload *run_json_type) (func(io_data *rc_io_data) (bool, error), error) { + is_first_call := true + is_tty := tty.IsTerminal(os.Stdin.Fd()) + buf := make([]byte, 4096) + cmdline := make([]escaped_string, len(args)) + for i, s := range args { + cmdline[i] = escaped_string(s) + } + payload.Cmdline = cmdline + io_data.handle_response = run_handle_response + + return func(io_data *rc_io_data) (bool, error) { + if is_first_call { + is_first_call = false + } else { + io_data.rc.Stream = false + } + buf = buf[:cap(buf)] + var n int + var err error + if is_tty { + buf = buf[:0] + err = io.EOF + } else { + n, err = os.Stdin.Read(buf) + if err != nil && err != io.EOF { + return false, err + } + buf = buf[:n] + } + set_payload_data(io_data, base64.StdEncoding.EncodeToString(buf)) + if err == io.EOF { + return true, nil + } + return false, nil + }, nil + +}