diff --git a/gen-go-code.py b/gen-go-code.py index 023505f00..128e6e0b7 100755 --- a/gen-go-code.py +++ b/gen-go-code.py @@ -594,12 +594,13 @@ var RefMap = map[string]string{serialize_go_dict(ref_map['ref'])} var DocTitleMap = map[string]string{serialize_go_dict(ref_map['doc'])} var AllowedShellIntegrationValues = []string{{ {str(sorted(allowed_shell_integration_values))[1:-1].replace("'", '"')} }} var KittyConfigDefaults = struct {{ -Term, Shell_integration, Select_by_word_characters string +Term, Shell_integration, Select_by_word_characters, Shell string Wheel_scroll_multiplier int Url_prefixes []string }}{{ Term: "{Options.term}", Shell_integration: "{' '.join(Options.shell_integration)}", Url_prefixes: []string{{ {url_prefixes} }}, Select_by_word_characters: `{Options.select_by_word_characters}`, Wheel_scroll_multiplier: {Options.wheel_scroll_multiplier}, +Shell: "{Options.shell}", }} ''' # }}} @@ -812,7 +813,7 @@ def generate_ssh_kitten_data() -> None: for f in filenames: path = os.path.join(dirpath, f) files.add(path.replace(os.sep, '/')) - dest = 'kittens/ssh/data_generated.bin' + dest = 'tools/tui/shell_integration/data_generated.bin' def normalize(t: tarfile.TarInfo) -> tarfile.TarInfo: t.uid = t.gid = 0 diff --git a/kittens/ssh/main.go b/kittens/ssh/main.go index f0e478c26..6645d5a91 100644 --- a/kittens/ssh/main.go +++ b/kittens/ssh/main.go @@ -30,6 +30,7 @@ import ( "kitty/tools/tty" "kitty/tools/tui" "kitty/tools/tui/loop" + "kitty/tools/tui/shell_integration" "kitty/tools/utils" "kitty/tools/utils/secrets" "kitty/tools/utils/shlex" @@ -299,14 +300,14 @@ func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool) } return nil } - add_entries := func(prefix string, items ...Entry) error { + add_entries := func(prefix string, items ...shell_integration.Entry) error { for _, item := range items { err := add( &tar.Header{ - Typeflag: item.metadata.Typeflag, Name: path.Join(prefix, path.Base(item.metadata.Name)), Format: tar.FormatPAX, - Size: int64(len(item.data)), Mode: item.metadata.Mode, ModTime: item.metadata.ModTime, - AccessTime: item.metadata.AccessTime, ChangeTime: item.metadata.ChangeTime, - }, item.data) + Typeflag: item.Metadata.Typeflag, Name: path.Join(prefix, path.Base(item.Metadata.Name)), Format: tar.FormatPAX, + Size: int64(len(item.Data)), Mode: item.Metadata.Mode, ModTime: item.Metadata.ModTime, + AccessTime: item.Metadata.AccessTime, ChangeTime: item.Metadata.ChangeTime, + }, item.Data) if err != nil { return err } @@ -316,16 +317,16 @@ func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool) } add_data(fe{"data.sh", utils.UnsafeStringToBytes(env_script)}) if cd.script_type == "sh" { - add_data(fe{"bootstrap-utils.sh", Data()[path.Join("shell-integration/ssh/bootstrap-utils.sh")].data}) + add_data(fe{"bootstrap-utils.sh", shell_integration.Data()[path.Join("shell-integration/ssh/bootstrap-utils.sh")].Data}) } if ksi != "" { - for _, fname := range Data().files_matching( + for _, fname := range shell_integration.Data().FilesMatching( "shell-integration/", "shell-integration/ssh/.+", // bootstrap files are sent as command line args "shell-integration/zsh/kitty.zsh", // backward compat file not needed by ssh kitten ) { arcname := path.Join("home/", rd, "/", path.Dir(fname)) - err = add_entries(arcname, Data()[fname]) + err = add_entries(arcname, shell_integration.Data()[fname]) if err != nil { return nil, err } @@ -338,15 +339,15 @@ func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool) return nil, err } for _, x := range []string{"kitty", "kitten"} { - err = add_entries(path.Join(arcname, "bin"), Data()[path.Join("shell-integration", "ssh", x)]) + err = add_entries(path.Join(arcname, "bin"), shell_integration.Data()[path.Join("shell-integration", "ssh", x)]) if err != nil { return nil, err } } } - err = add_entries(path.Join("home", ".terminfo"), Data()["terminfo/kitty.terminfo"]) + err = add_entries(path.Join("home", ".terminfo"), shell_integration.Data()["terminfo/kitty.terminfo"]) if err == nil { - err = add_entries(path.Join("home", ".terminfo", "x"), Data()["terminfo/x/xterm-kitty"]) + err = add_entries(path.Join("home", ".terminfo", "x"), shell_integration.Data()["terminfo/x/xterm-kitty"]) } if err == nil { err = tw.Close() @@ -470,7 +471,7 @@ func bootstrap_script(cd *connection_data) (err error) { } maps.Copy(replacements, sensitive_data) cd.replacements = replacements - cd.bootstrap_script = utils.UnsafeBytesToString(Data()["shell-integration/ssh/bootstrap."+cd.script_type].data) + cd.bootstrap_script = utils.UnsafeBytesToString(shell_integration.Data()["shell-integration/ssh/bootstrap."+cd.script_type].Data) cd.bootstrap_script = prepare_script(cd.bootstrap_script, sd) return err } @@ -584,7 +585,7 @@ func change_colors(color_scheme string) (ans string, err error) { } func run_ssh(ssh_args, server_args, found_extra_args []string) (rc int, err error) { - go Data() + go shell_integration.Data() go RelevantKittyOpts() defer func() { if data_shm != nil { diff --git a/tools/cmd/run_shell/main.go b/tools/cmd/run_shell/main.go new file mode 100644 index 000000000..6efddc75a --- /dev/null +++ b/tools/cmd/run_shell/main.go @@ -0,0 +1,54 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package run_shell + +import ( + "fmt" + + "kitty/tools/cli" + "kitty/tools/tui" +) + +var _ = fmt.Print + +type Options struct { + Shell string + ShellIntegration string +} + +func main(args []string, opts *Options) (rc int, err error) { + if len(args) > 0 { + tui.RunCommandRestoringTerminalToSaneStateAfter(args) + } + err = tui.RunShell(tui.ResolveShell(opts.Shell), tui.ResolveShellIntegration(opts.ShellIntegration)) + if err != nil { + rc = 1 + } + return +} + +func EntryPoint(root *cli.Command) *cli.Command { + sc := root.AddSubCommand(&cli.Command{ + Name: "run-shell", + Usage: "[options] [optional cmd to run before running the shell ...]", + ShortDescription: "Run the user's shell with shell integration enabled", + HelpText: "Run the users's configured shell. If the shell supports shell integration, enable it based on the user's configured shell_integration setting.", + Run: func(cmd *cli.Command, args []string) (ret int, err error) { + opts := &Options{} + err = cmd.GetOptionValues(opts) + if err != nil { + return 1, err + } + return main(args, opts) + }, + }) + sc.Add(cli.OptionSpec{ + Name: "--shell-integration", + Help: "Specify a value for the shell_integration option, overriding the one from kitty.conf.", + }) + sc.Add(cli.OptionSpec{ + Name: "--shell", + Help: "Specify the shell command to run. If not specified the value of the shell option from kitty.conf is used.", + }) + return sc +} diff --git a/tools/cmd/tool/main.go b/tools/cmd/tool/main.go index c20d8cbe8..ebdcc5149 100644 --- a/tools/cmd/tool/main.go +++ b/tools/cmd/tool/main.go @@ -19,6 +19,7 @@ import ( "kitty/tools/cmd/at" "kitty/tools/cmd/edit_in_kitty" "kitty/tools/cmd/pytest" + "kitty/tools/cmd/run_shell" "kitty/tools/cmd/update_self" "kitty/tools/tui" ) @@ -55,6 +56,8 @@ func KittyToolEntryPoints(root *cli.Command) { // themes themes.EntryPoint(root) themes.ParseEntryPoint(root) + // run-shell + run_shell.EntryPoint(root) // __pytest__ pytest.EntryPoint(root) // __hold_till_enter__ diff --git a/tools/tui/run.go b/tools/tui/run.go new file mode 100644 index 000000000..3714f2ac8 --- /dev/null +++ b/tools/tui/run.go @@ -0,0 +1,182 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package tui + +import ( + "fmt" + "kitty" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/sys/unix" + + "kitty/tools/config" + "kitty/tools/tty" + "kitty/tools/tui/loop" + "kitty/tools/tui/shell_integration" + "kitty/tools/utils" + "kitty/tools/utils/shlex" +) + +var _ = fmt.Print + +type KittyOpts struct { + Shell, Shell_integration string +} + +func read_relevant_kitty_opts(path string) KittyOpts { + ans := KittyOpts{Shell: kitty.KittyConfigDefaults.Shell, Shell_integration: kitty.KittyConfigDefaults.Shell_integration} + handle_line := func(key, val string) error { + switch key { + case "shell": + ans.Shell = strings.TrimSpace(val) + case "shell_integration": + ans.Shell_integration = strings.TrimSpace(val) + } + return nil + } + cp := config.ConfigParser{LineHandler: handle_line} + cp.ParseFiles(path) + if ans.Shell == "" { + ans.Shell = kitty.KittyConfigDefaults.Shell + } + return ans +} + +func get_effective_ksi_env_var(x string) string { + parts := strings.Split(strings.TrimSpace(strings.ToLower(x)), " ") + current := utils.NewSetWithItems(parts...) + if current.Has("disabled") { + return "" + } + allowed := utils.NewSetWithItems(kitty.AllowedShellIntegrationValues...) + if !current.IsSubsetOf(allowed) { + return relevant_kitty_opts().Shell_integration + } + return x +} + +var relevant_kitty_opts = utils.Once(func() KittyOpts { + return read_relevant_kitty_opts(filepath.Join(utils.ConfigDir(), "kitty.conf")) +}) + +func ResolveShell(shell string) []string { + if shell == "" { + shell = relevant_kitty_opts().Shell + if shell == "." { + s, e := utils.LoginShellForCurrentUser() + if e != nil { + shell = "/bin/sh" + } else { + shell = s + } + } + } + shell_cmd, err := shlex.Split(shell) + if err != nil { + shell_cmd = []string{shell} + } + exe := utils.FindExe(shell_cmd[0]) + if unix.Access(exe, unix.X_OK) != nil { + shell_cmd = []string{"/bin/sh"} + } + return shell_cmd +} + +func ResolveShellIntegration(shell_integration string) string { + if shell_integration == "" { + shell_integration = relevant_kitty_opts().Shell_integration + } + return get_effective_ksi_env_var(shell_integration) +} + +func get_shell_name(argv0 string) (ans string) { + ans = filepath.Base(argv0) + if strings.HasSuffix(strings.ToLower(ans), ".exe") { + ans = ans[:len(ans)-4] + } + if strings.HasPrefix(ans, "-") { + ans = ans[1:] + } + return +} + +func rc_modification_allowed(ksi string) bool { + for _, x := range strings.Split(ksi, " ") { + switch x { + case "disabled", "no-rc": + return false + } + } + return ksi != "" +} + +func RunShell(shell_cmd []string, shell_integration_env_var_val string) (err error) { + shell_name := get_shell_name(shell_cmd[0]) + var shell_env map[string]string + if rc_modification_allowed(shell_integration_env_var_val) && shell_integration.IsSupportedShell(shell_name) { + oenv := os.Environ() + env := make(map[string]string, len(oenv)) + for _, x := range oenv { + if k, v, found := strings.Cut(x, "="); found { + env[k] = v + } + } + argv, env, err := shell_integration.Setup(shell_name, shell_cmd, env) + if err != nil { + return err + } + shell_cmd = argv + shell_env = env + } + exe := shell_cmd[0] + if runtime.GOOS == "darwin" { + // ensure shell runs in login mode + shell_cmd[0] = "-" + filepath.Base(shell_cmd[0]) + } + var env []string + if shell_env != nil { + env := make([]string, 0, len(shell_env)) + for k, v := range shell_env { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + } else { + env = os.Environ() + } + return unix.Exec(utils.FindExe(exe), shell_cmd, env) +} + +func RunCommandRestoringTerminalToSaneStateAfter(cmd []string) { + exe := utils.FindExe(cmd[0]) + c := exec.Command(exe, cmd[1:]...) + c.Stdout = os.Stdout + c.Stdin = os.Stdin + c.Stderr = os.Stderr + term, err := tty.OpenControllingTerm() + if err == nil { + var state_before unix.Termios + if term.Tcgetattr(&state_before) == nil { + term.WriteString(loop.SAVE_PRIVATE_MODE_VALUES) + defer func() { + term.WriteString(strings.Join([]string{ + loop.RESTORE_PRIVATE_MODE_VALUES, + "\x1b[=u", // reset kitty keyboard protocol to legacy + "\x1b[1 q", // blinking block cursor + loop.DECTCEM.EscapeCodeToSet(), // cursor visible + "\x1b]112\a", // reset cursor color + }, "")) + term.Tcsetattr(tty.TCSANOW, &state_before) + term.Close() + }() + } else { + defer term.Close() + } + } + err = c.Run() + if err != nil { + fmt.Fprintln(os.Stderr, cmd[0], "failed with error:", err) + } +} diff --git a/tools/tui/shell_integration/api.go b/tools/tui/shell_integration/api.go new file mode 100644 index 000000000..d51e42a3e --- /dev/null +++ b/tools/tui/shell_integration/api.go @@ -0,0 +1,71 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package shell_integration + +import ( + "archive/tar" + "fmt" + "os" + "path/filepath" +) + +var _ = fmt.Print + +type integration_setup_func = func(argv []string, env map[string]string) ([]string, map[string]string, error) + +func extract_shell_integration_for(shell_name string, dest_dir string) (err error) { + d := Data() + for _, fname := range d.FilesMatching("shell-integration/" + shell_name + "/") { + entry := d[fname] + dest := filepath.Join(dest_dir, fname) + ddir := filepath.Dir(dest) + if err = os.MkdirAll(ddir, 0o755); err != nil { + return + } + switch entry.Metadata.Typeflag { + case tar.TypeDir: + if err = os.MkdirAll(dest, 0o755); err != nil { + return + } + case tar.TypeSymlink: + if err = os.Symlink(entry.Metadata.Linkname, dest); err != nil { + return + } + case tar.TypeReg: + if err = os.WriteFile(dest, entry.Data, 0o644); err != nil { + return + } + } + } + return +} + +func zsh_setup_func(argv []string, env map[string]string) (final_argv []string, final_env map[string]string, err error) { + return +} + +func fish_setup_func(argv []string, env map[string]string) (final_argv []string, final_env map[string]string, err error) { + return +} + +func bash_setup_func(argv []string, env map[string]string) (final_argv []string, final_env map[string]string, err error) { + return +} + +func setup_func_for_shell(shell_name string) integration_setup_func { + switch shell_name { + case "zsh": + return zsh_setup_func + case "fish": + return fish_setup_func + case "bash": + return bash_setup_func + } + return nil +} + +func IsSupportedShell(shell_name string) bool { return setup_func_for_shell(shell_name) != nil } + +func Setup(shell_name string, argv []string, env map[string]string) ([]string, map[string]string, error) { + return setup_func_for_shell(shell_name)(argv, env) +} diff --git a/kittens/ssh/data.go b/tools/tui/shell_integration/data.go similarity index 88% rename from kittens/ssh/data.go rename to tools/tui/shell_integration/data.go index 77d17f3ae..0210f4e4c 100644 --- a/kittens/ssh/data.go +++ b/tools/tui/shell_integration/data.go @@ -1,6 +1,6 @@ // License: GPLv3 Copyright: 2023, Kovid Goyal, -package ssh +package shell_integration import ( "archive/tar" @@ -19,8 +19,8 @@ var _ = fmt.Print var embedded_data string type Entry struct { - metadata *tar.Header - data []byte + Metadata *tar.Header + Data []byte } type Container map[string]Entry @@ -45,7 +45,7 @@ var Data = utils.Once(func() Container { return ans }) -func (self Container) files_matching(prefix string, exclude_patterns ...string) []string { +func (self Container) FilesMatching(prefix string, exclude_patterns ...string) []string { ans := make([]string, 0, len(self)) patterns := make([]*regexp.Regexp, len(exclude_patterns)) for i, exp := range exclude_patterns {