Work on code to run shells from a kitten with shell integration

This commit is contained in:
Kovid Goyal 2023-06-25 19:27:43 +05:30
parent 51aaea03bf
commit 092e0fba2c
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
7 changed files with 331 additions and 19 deletions

View file

@ -0,0 +1,54 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
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
}

View file

@ -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__

182
tools/tui/run.go Normal file
View file

@ -0,0 +1,182 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
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)
}
}

View file

@ -0,0 +1,71 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
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)
}

View file

@ -0,0 +1,69 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package shell_integration
import (
"archive/tar"
_ "embed"
"errors"
"fmt"
"io"
"kitty/tools/utils"
"regexp"
"strings"
)
var _ = fmt.Print
//go:embed data_generated.bin
var embedded_data string
type Entry struct {
Metadata *tar.Header
Data []byte
}
type Container map[string]Entry
var Data = utils.Once(func() Container {
tr := tar.NewReader(utils.ReaderForCompressedEmbeddedData(embedded_data))
ans := make(Container, 64)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
panic(err)
}
data, err := utils.ReadAll(tr, int(hdr.Size))
if err != nil {
panic(err)
}
ans[hdr.Name] = Entry{hdr, data}
}
return ans
})
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 {
patterns[i] = regexp.MustCompile(exp)
}
for name := range self {
if strings.HasPrefix(name, prefix) {
excluded := false
for _, pat := range patterns {
if matched := pat.FindString(name); matched != "" {
excluded = true
break
}
}
if !excluded {
ans = append(ans, name)
}
}
}
return ans
}