Skip to content

Commit 092e0fb

Browse files
committed
Work on code to run shells from a kitten with shell integration
1 parent 51aaea0 commit 092e0fb

File tree

7 files changed

+331
-19
lines changed

7 files changed

+331
-19
lines changed

gen-go-code.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -594,12 +594,13 @@ def generate_constants() -> str:
594594
var DocTitleMap = map[string]string{serialize_go_dict(ref_map['doc'])}
595595
var AllowedShellIntegrationValues = []string{{ {str(sorted(allowed_shell_integration_values))[1:-1].replace("'", '"')} }}
596596
var KittyConfigDefaults = struct {{
597-
Term, Shell_integration, Select_by_word_characters string
597+
Term, Shell_integration, Select_by_word_characters, Shell string
598598
Wheel_scroll_multiplier int
599599
Url_prefixes []string
600600
}}{{
601601
Term: "{Options.term}", Shell_integration: "{' '.join(Options.shell_integration)}", Url_prefixes: []string{{ {url_prefixes} }},
602602
Select_by_word_characters: `{Options.select_by_word_characters}`, Wheel_scroll_multiplier: {Options.wheel_scroll_multiplier},
603+
Shell: "{Options.shell}",
603604
}}
604605
''' # }}}
605606

@@ -812,7 +813,7 @@ def generate_ssh_kitten_data() -> None:
812813
for f in filenames:
813814
path = os.path.join(dirpath, f)
814815
files.add(path.replace(os.sep, '/'))
815-
dest = 'kittens/ssh/data_generated.bin'
816+
dest = 'tools/tui/shell_integration/data_generated.bin'
816817

817818
def normalize(t: tarfile.TarInfo) -> tarfile.TarInfo:
818819
t.uid = t.gid = 0

kittens/ssh/main.go

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"kitty/tools/tty"
3131
"kitty/tools/tui"
3232
"kitty/tools/tui/loop"
33+
"kitty/tools/tui/shell_integration"
3334
"kitty/tools/utils"
3435
"kitty/tools/utils/secrets"
3536
"kitty/tools/utils/shlex"
@@ -299,14 +300,14 @@ func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool)
299300
}
300301
return nil
301302
}
302-
add_entries := func(prefix string, items ...Entry) error {
303+
add_entries := func(prefix string, items ...shell_integration.Entry) error {
303304
for _, item := range items {
304305
err := add(
305306
&tar.Header{
306-
Typeflag: item.metadata.Typeflag, Name: path.Join(prefix, path.Base(item.metadata.Name)), Format: tar.FormatPAX,
307-
Size: int64(len(item.data)), Mode: item.metadata.Mode, ModTime: item.metadata.ModTime,
308-
AccessTime: item.metadata.AccessTime, ChangeTime: item.metadata.ChangeTime,
309-
}, item.data)
307+
Typeflag: item.Metadata.Typeflag, Name: path.Join(prefix, path.Base(item.Metadata.Name)), Format: tar.FormatPAX,
308+
Size: int64(len(item.Data)), Mode: item.Metadata.Mode, ModTime: item.Metadata.ModTime,
309+
AccessTime: item.Metadata.AccessTime, ChangeTime: item.Metadata.ChangeTime,
310+
}, item.Data)
310311
if err != nil {
311312
return err
312313
}
@@ -316,16 +317,16 @@ func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool)
316317
}
317318
add_data(fe{"data.sh", utils.UnsafeStringToBytes(env_script)})
318319
if cd.script_type == "sh" {
319-
add_data(fe{"bootstrap-utils.sh", Data()[path.Join("shell-integration/ssh/bootstrap-utils.sh")].data})
320+
add_data(fe{"bootstrap-utils.sh", shell_integration.Data()[path.Join("shell-integration/ssh/bootstrap-utils.sh")].Data})
320321
}
321322
if ksi != "" {
322-
for _, fname := range Data().files_matching(
323+
for _, fname := range shell_integration.Data().FilesMatching(
323324
"shell-integration/",
324325
"shell-integration/ssh/.+", // bootstrap files are sent as command line args
325326
"shell-integration/zsh/kitty.zsh", // backward compat file not needed by ssh kitten
326327
) {
327328
arcname := path.Join("home/", rd, "/", path.Dir(fname))
328-
err = add_entries(arcname, Data()[fname])
329+
err = add_entries(arcname, shell_integration.Data()[fname])
329330
if err != nil {
330331
return nil, err
331332
}
@@ -338,15 +339,15 @@ func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool)
338339
return nil, err
339340
}
340341
for _, x := range []string{"kitty", "kitten"} {
341-
err = add_entries(path.Join(arcname, "bin"), Data()[path.Join("shell-integration", "ssh", x)])
342+
err = add_entries(path.Join(arcname, "bin"), shell_integration.Data()[path.Join("shell-integration", "ssh", x)])
342343
if err != nil {
343344
return nil, err
344345
}
345346
}
346347
}
347-
err = add_entries(path.Join("home", ".terminfo"), Data()["terminfo/kitty.terminfo"])
348+
err = add_entries(path.Join("home", ".terminfo"), shell_integration.Data()["terminfo/kitty.terminfo"])
348349
if err == nil {
349-
err = add_entries(path.Join("home", ".terminfo", "x"), Data()["terminfo/x/xterm-kitty"])
350+
err = add_entries(path.Join("home", ".terminfo", "x"), shell_integration.Data()["terminfo/x/xterm-kitty"])
350351
}
351352
if err == nil {
352353
err = tw.Close()
@@ -470,7 +471,7 @@ func bootstrap_script(cd *connection_data) (err error) {
470471
}
471472
maps.Copy(replacements, sensitive_data)
472473
cd.replacements = replacements
473-
cd.bootstrap_script = utils.UnsafeBytesToString(Data()["shell-integration/ssh/bootstrap."+cd.script_type].data)
474+
cd.bootstrap_script = utils.UnsafeBytesToString(shell_integration.Data()["shell-integration/ssh/bootstrap."+cd.script_type].Data)
474475
cd.bootstrap_script = prepare_script(cd.bootstrap_script, sd)
475476
return err
476477
}
@@ -584,7 +585,7 @@ func change_colors(color_scheme string) (ans string, err error) {
584585
}
585586

586587
func run_ssh(ssh_args, server_args, found_extra_args []string) (rc int, err error) {
587-
go Data()
588+
go shell_integration.Data()
588589
go RelevantKittyOpts()
589590
defer func() {
590591
if data_shm != nil {

tools/cmd/run_shell/main.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
2+
3+
package run_shell
4+
5+
import (
6+
"fmt"
7+
8+
"kitty/tools/cli"
9+
"kitty/tools/tui"
10+
)
11+
12+
var _ = fmt.Print
13+
14+
type Options struct {
15+
Shell string
16+
ShellIntegration string
17+
}
18+
19+
func main(args []string, opts *Options) (rc int, err error) {
20+
if len(args) > 0 {
21+
tui.RunCommandRestoringTerminalToSaneStateAfter(args)
22+
}
23+
err = tui.RunShell(tui.ResolveShell(opts.Shell), tui.ResolveShellIntegration(opts.ShellIntegration))
24+
if err != nil {
25+
rc = 1
26+
}
27+
return
28+
}
29+
30+
func EntryPoint(root *cli.Command) *cli.Command {
31+
sc := root.AddSubCommand(&cli.Command{
32+
Name: "run-shell",
33+
Usage: "[options] [optional cmd to run before running the shell ...]",
34+
ShortDescription: "Run the user's shell with shell integration enabled",
35+
HelpText: "Run the users's configured shell. If the shell supports shell integration, enable it based on the user's configured shell_integration setting.",
36+
Run: func(cmd *cli.Command, args []string) (ret int, err error) {
37+
opts := &Options{}
38+
err = cmd.GetOptionValues(opts)
39+
if err != nil {
40+
return 1, err
41+
}
42+
return main(args, opts)
43+
},
44+
})
45+
sc.Add(cli.OptionSpec{
46+
Name: "--shell-integration",
47+
Help: "Specify a value for the shell_integration option, overriding the one from kitty.conf.",
48+
})
49+
sc.Add(cli.OptionSpec{
50+
Name: "--shell",
51+
Help: "Specify the shell command to run. If not specified the value of the shell option from kitty.conf is used.",
52+
})
53+
return sc
54+
}

tools/cmd/tool/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"kitty/tools/cmd/at"
2020
"kitty/tools/cmd/edit_in_kitty"
2121
"kitty/tools/cmd/pytest"
22+
"kitty/tools/cmd/run_shell"
2223
"kitty/tools/cmd/update_self"
2324
"kitty/tools/tui"
2425
)
@@ -55,6 +56,8 @@ func KittyToolEntryPoints(root *cli.Command) {
5556
// themes
5657
themes.EntryPoint(root)
5758
themes.ParseEntryPoint(root)
59+
// run-shell
60+
run_shell.EntryPoint(root)
5861
// __pytest__
5962
pytest.EntryPoint(root)
6063
// __hold_till_enter__

tools/tui/run.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
2+
3+
package tui
4+
5+
import (
6+
"fmt"
7+
"kitty"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
13+
14+
"golang.org/x/sys/unix"
15+
16+
"kitty/tools/config"
17+
"kitty/tools/tty"
18+
"kitty/tools/tui/loop"
19+
"kitty/tools/tui/shell_integration"
20+
"kitty/tools/utils"
21+
"kitty/tools/utils/shlex"
22+
)
23+
24+
var _ = fmt.Print
25+
26+
type KittyOpts struct {
27+
Shell, Shell_integration string
28+
}
29+
30+
func read_relevant_kitty_opts(path string) KittyOpts {
31+
ans := KittyOpts{Shell: kitty.KittyConfigDefaults.Shell, Shell_integration: kitty.KittyConfigDefaults.Shell_integration}
32+
handle_line := func(key, val string) error {
33+
switch key {
34+
case "shell":
35+
ans.Shell = strings.TrimSpace(val)
36+
case "shell_integration":
37+
ans.Shell_integration = strings.TrimSpace(val)
38+
}
39+
return nil
40+
}
41+
cp := config.ConfigParser{LineHandler: handle_line}
42+
cp.ParseFiles(path)
43+
if ans.Shell == "" {
44+
ans.Shell = kitty.KittyConfigDefaults.Shell
45+
}
46+
return ans
47+
}
48+
49+
func get_effective_ksi_env_var(x string) string {
50+
parts := strings.Split(strings.TrimSpace(strings.ToLower(x)), " ")
51+
current := utils.NewSetWithItems(parts...)
52+
if current.Has("disabled") {
53+
return ""
54+
}
55+
allowed := utils.NewSetWithItems(kitty.AllowedShellIntegrationValues...)
56+
if !current.IsSubsetOf(allowed) {
57+
return relevant_kitty_opts().Shell_integration
58+
}
59+
return x
60+
}
61+
62+
var relevant_kitty_opts = utils.Once(func() KittyOpts {
63+
return read_relevant_kitty_opts(filepath.Join(utils.ConfigDir(), "kitty.conf"))
64+
})
65+
66+
func ResolveShell(shell string) []string {
67+
if shell == "" {
68+
shell = relevant_kitty_opts().Shell
69+
if shell == "." {
70+
s, e := utils.LoginShellForCurrentUser()
71+
if e != nil {
72+
shell = "/bin/sh"
73+
} else {
74+
shell = s
75+
}
76+
}
77+
}
78+
shell_cmd, err := shlex.Split(shell)
79+
if err != nil {
80+
shell_cmd = []string{shell}
81+
}
82+
exe := utils.FindExe(shell_cmd[0])
83+
if unix.Access(exe, unix.X_OK) != nil {
84+
shell_cmd = []string{"/bin/sh"}
85+
}
86+
return shell_cmd
87+
}
88+
89+
func ResolveShellIntegration(shell_integration string) string {
90+
if shell_integration == "" {
91+
shell_integration = relevant_kitty_opts().Shell_integration
92+
}
93+
return get_effective_ksi_env_var(shell_integration)
94+
}
95+
96+
func get_shell_name(argv0 string) (ans string) {
97+
ans = filepath.Base(argv0)
98+
if strings.HasSuffix(strings.ToLower(ans), ".exe") {
99+
ans = ans[:len(ans)-4]
100+
}
101+
if strings.HasPrefix(ans, "-") {
102+
ans = ans[1:]
103+
}
104+
return
105+
}
106+
107+
func rc_modification_allowed(ksi string) bool {
108+
for _, x := range strings.Split(ksi, " ") {
109+
switch x {
110+
case "disabled", "no-rc":
111+
return false
112+
}
113+
}
114+
return ksi != ""
115+
}
116+
117+
func RunShell(shell_cmd []string, shell_integration_env_var_val string) (err error) {
118+
shell_name := get_shell_name(shell_cmd[0])
119+
var shell_env map[string]string
120+
if rc_modification_allowed(shell_integration_env_var_val) && shell_integration.IsSupportedShell(shell_name) {
121+
oenv := os.Environ()
122+
env := make(map[string]string, len(oenv))
123+
for _, x := range oenv {
124+
if k, v, found := strings.Cut(x, "="); found {
125+
env[k] = v
126+
}
127+
}
128+
argv, env, err := shell_integration.Setup(shell_name, shell_cmd, env)
129+
if err != nil {
130+
return err
131+
}
132+
shell_cmd = argv
133+
shell_env = env
134+
}
135+
exe := shell_cmd[0]
136+
if runtime.GOOS == "darwin" {
137+
// ensure shell runs in login mode
138+
shell_cmd[0] = "-" + filepath.Base(shell_cmd[0])
139+
}
140+
var env []string
141+
if shell_env != nil {
142+
env := make([]string, 0, len(shell_env))
143+
for k, v := range shell_env {
144+
env = append(env, fmt.Sprintf("%s=%s", k, v))
145+
}
146+
} else {
147+
env = os.Environ()
148+
}
149+
return unix.Exec(utils.FindExe(exe), shell_cmd, env)
150+
}
151+
152+
func RunCommandRestoringTerminalToSaneStateAfter(cmd []string) {
153+
exe := utils.FindExe(cmd[0])
154+
c := exec.Command(exe, cmd[1:]...)
155+
c.Stdout = os.Stdout
156+
c.Stdin = os.Stdin
157+
c.Stderr = os.Stderr
158+
term, err := tty.OpenControllingTerm()
159+
if err == nil {
160+
var state_before unix.Termios
161+
if term.Tcgetattr(&state_before) == nil {
162+
term.WriteString(loop.SAVE_PRIVATE_MODE_VALUES)
163+
defer func() {
164+
term.WriteString(strings.Join([]string{
165+
loop.RESTORE_PRIVATE_MODE_VALUES,
166+
"\x1b[=u", // reset kitty keyboard protocol to legacy
167+
"\x1b[1 q", // blinking block cursor
168+
loop.DECTCEM.EscapeCodeToSet(), // cursor visible
169+
"\x1b]112\a", // reset cursor color
170+
}, ""))
171+
term.Tcsetattr(tty.TCSANOW, &state_before)
172+
term.Close()
173+
}()
174+
} else {
175+
defer term.Close()
176+
}
177+
}
178+
err = c.Run()
179+
if err != nil {
180+
fmt.Fprintln(os.Stderr, cmd[0], "failed with error:", err)
181+
}
182+
}

0 commit comments

Comments
 (0)