From 832f31c06a0a88bf9481b8ca7cf4d0949b885797 Mon Sep 17 00:00:00 2001 From: Kyriakos Akriotis Date: Thu, 22 Oct 2026 07:02:36 +0200 Subject: [PATCH 1/4] open k9s in new terminal (draft) #181 --- .../containers/coe/clusters/clusters.go | 3 + .../resources/containers/coe/clusters/k9s.go | 56 ++++++++++++++++ internal/terminal/k9s.go | 66 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 internal/resources/containers/coe/clusters/k9s.go create mode 100644 internal/terminal/k9s.go diff --git a/internal/resources/containers/coe/clusters/clusters.go b/internal/resources/containers/coe/clusters/clusters.go index f5de7ba..e73b0d7 100644 --- a/internal/resources/containers/coe/clusters/clusters.go +++ b/internal/resources/containers/coe/clusters/clusters.go @@ -88,6 +88,7 @@ func (r *Resource) Commands() []resource.Command { {Key: "shift-n", Description: "Node Groups"}, {Key: "k", Description: "Kubeconfig"}, {Key: "ctrl+k", Description: "Get Kubeconfig", StatusLabel: "Downloading"}, + {Key: "9", Description: "Open K9s", StatusLabel: "Launching"}, } } @@ -103,6 +104,8 @@ func (r *Resource) Execute(command resource.Command, row resource.Row) tea.Cmd { return r.kubeconfig(row.ID) case "ctrl+k": return r.getKubeconfig(row) + case "9": + return r.openK9s(row) } return nil diff --git a/internal/resources/containers/coe/clusters/k9s.go b/internal/resources/containers/coe/clusters/k9s.go new file mode 100644 index 0000000..556976d --- /dev/null +++ b/internal/resources/containers/coe/clusters/k9s.go @@ -0,0 +1,56 @@ +package clusters + +import ( + "context" + "fmt" + "os" + + "github.com/akyriako/o7k/internal/resource" + "github.com/akyriako/o7k/internal/terminal" + tea "github.com/charmbracelet/bubbletea" +) + +func (r *Resource) openK9s(row resource.Row) tea.Cmd { + return func() tea.Msg { + if err := terminal.CheckK9s(); err != nil { + return resource.DetailsMsg{Err: err} + } + + data, err := r.getKubeconfigData(context.Background(), row.ID) + if err != nil { + return resource.DetailsMsg{Err: err} + } + + f, err := os.CreateTemp("", "o7k-generated_kubeconfig-*.yaml") + if err != nil { + return resource.DetailsMsg{ + Err: fmt.Errorf("creating temporary kubeconfig failed: %w", err), + } + } + + path := f.Name() + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(path) + return resource.DetailsMsg{ + Err: fmt.Errorf("writing temporary kubeconfig failed: %w", err), + } + } + + if err := f.Close(); err != nil { + os.Remove(path) + return resource.DetailsMsg{ + Err: fmt.Errorf("closing temporary kubeconfig failed: %w", err), + } + } + + if err := terminal.OpenK9s(path); err != nil { + os.Remove(path) + return resource.DetailsMsg{ + Err: fmt.Errorf("launching K9s failed: %w", err), + } + } + + return nil + } +} diff --git a/internal/terminal/k9s.go b/internal/terminal/k9s.go new file mode 100644 index 0000000..dadc615 --- /dev/null +++ b/internal/terminal/k9s.go @@ -0,0 +1,66 @@ +package terminal + +import ( + "fmt" + "os/exec" + "runtime" + "strings" +) + +func CheckK9s() error { + if _, err := exec.LookPath("k9s"); err != nil { + return fmt.Errorf("k9s executable not found in PATH: %w", err) + } + return nil +} + +func OpenK9s(kubeconfig string) error { + k9s, err := exec.LookPath("k9s") + if err != nil { + return fmt.Errorf("finding k9s failed: %w", err) + } + + switch runtime.GOOS { + case "linux": + candidates := [][]string{ + {"gnome-terminal", "--", k9s, "--kubeconfig", kubeconfig}, + {"konsole", "-e", k9s, "--kubeconfig", kubeconfig}, + {"kitty", "--", k9s, "--kubeconfig", kubeconfig}, + {"alacritty", "-e", k9s, "--kubeconfig", kubeconfig}, + {"xterm", "-e", k9s, "--kubeconfig", kubeconfig}, + } + + for _, args := range candidates { + if _, err := exec.LookPath(args[0]); err == nil { + return exec.Command(args[0], args[1:]...).Start() + } + } + + return fmt.Errorf( + "no supported terminal found", + ) + + case "darwin": + command := shellQuote(k9s) + " --kubeconfig " + shellQuote(kubeconfig) + script := `on run argv + tell application "Terminal" + activate + do script (item 1 of argv) + end tell +end run` + + out, err := exec.Command("osascript", "-e", script, command).CombinedOutput() + if err != nil { + return fmt.Errorf("opening Terminal.app: %w: %s", err, strings.TrimSpace(string(out))) + } + + return nil + + default: + return fmt.Errorf("unsupported OS: %s", runtime.GOOS) + } +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} From 22a3d1773fefb0da57a6d29edcbda814844a6c8a Mon Sep 17 00:00:00 2001 From: Kyriakos Akriotis Date: Thu, 22 Oct 2026 07:29:27 +0200 Subject: [PATCH 2/4] added configuration value for terminal, made terminal generic not k9s specific #181 --- internal/config/config.go | 1 + internal/resource/commands.go | 5 ++ .../containers/coe/clusters/clusters.go | 2 +- .../containers/coe/clusters/commands.go | 32 ++++++++ .../resources/containers/coe/clusters/k9s.go | 56 -------------- internal/terminal/k9s.go | 66 ---------------- internal/terminal/terminal.go | 77 +++++++++++++++++++ internal/ui/app.go | 19 ++++- 8 files changed, 131 insertions(+), 127 deletions(-) delete mode 100644 internal/resources/containers/coe/clusters/k9s.go delete mode 100644 internal/terminal/k9s.go create mode 100644 internal/terminal/terminal.go diff --git a/internal/config/config.go b/internal/config/config.go index eff4dd8..51a1e8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { Refresh Refresh `yaml:"refresh"` Skin string `yaml:"skin"` SkinColors Skin `yaml:"-"` + Terminal *string `yaml:"terminal"` } type Cache struct { diff --git a/internal/resource/commands.go b/internal/resource/commands.go index f3636be..70162ee 100644 --- a/internal/resource/commands.go +++ b/internal/resource/commands.go @@ -37,3 +37,8 @@ type CommandCompletedMsg struct { LoadID uint64 RefreshOnCompletion bool } + +type LaunchTerminalMsg struct { + Command string + Args []string +} diff --git a/internal/resources/containers/coe/clusters/clusters.go b/internal/resources/containers/coe/clusters/clusters.go index e73b0d7..7e344b2 100644 --- a/internal/resources/containers/coe/clusters/clusters.go +++ b/internal/resources/containers/coe/clusters/clusters.go @@ -105,7 +105,7 @@ func (r *Resource) Execute(command resource.Command, row resource.Row) tea.Cmd { case "ctrl+k": return r.getKubeconfig(row) case "9": - return r.openK9s(row) + return r.k9s(row) } return nil diff --git a/internal/resources/containers/coe/clusters/commands.go b/internal/resources/containers/coe/clusters/commands.go index 728f357..0f74663 100644 --- a/internal/resources/containers/coe/clusters/commands.go +++ b/internal/resources/containers/coe/clusters/commands.go @@ -219,3 +219,35 @@ func (r *Resource) getKubeconfig(row resource.Row) tea.Cmd { return nil } } + +func (r *Resource) k9s(row resource.Row) tea.Cmd { + return func() tea.Msg { + data, err := r.getKubeconfigData(context.Background(), row.ID) + if err != nil { + return resource.DetailsMsg{Err: err} + } + + f, err := os.CreateTemp("", "o7k-generated_kubeconfig-*.yaml") + if err != nil { + return resource.DetailsMsg{Err: fmt.Errorf("creating temporary kubeconfig failed: %w", err)} + } + + path := f.Name() + + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(path) + return resource.DetailsMsg{Err: fmt.Errorf("writing temporary kubeconfig failed: %w", err)} + } + + if err := f.Close(); err != nil { + os.Remove(path) + return resource.DetailsMsg{Err: fmt.Errorf("closing temporary kubeconfig failed: %w", err)} + } + + return resource.LaunchTerminalMsg{ + Command: "k9s", + Args: []string{"--kubeconfig", path}, + } + } +} diff --git a/internal/resources/containers/coe/clusters/k9s.go b/internal/resources/containers/coe/clusters/k9s.go deleted file mode 100644 index 556976d..0000000 --- a/internal/resources/containers/coe/clusters/k9s.go +++ /dev/null @@ -1,56 +0,0 @@ -package clusters - -import ( - "context" - "fmt" - "os" - - "github.com/akyriako/o7k/internal/resource" - "github.com/akyriako/o7k/internal/terminal" - tea "github.com/charmbracelet/bubbletea" -) - -func (r *Resource) openK9s(row resource.Row) tea.Cmd { - return func() tea.Msg { - if err := terminal.CheckK9s(); err != nil { - return resource.DetailsMsg{Err: err} - } - - data, err := r.getKubeconfigData(context.Background(), row.ID) - if err != nil { - return resource.DetailsMsg{Err: err} - } - - f, err := os.CreateTemp("", "o7k-generated_kubeconfig-*.yaml") - if err != nil { - return resource.DetailsMsg{ - Err: fmt.Errorf("creating temporary kubeconfig failed: %w", err), - } - } - - path := f.Name() - if _, err := f.Write(data); err != nil { - f.Close() - os.Remove(path) - return resource.DetailsMsg{ - Err: fmt.Errorf("writing temporary kubeconfig failed: %w", err), - } - } - - if err := f.Close(); err != nil { - os.Remove(path) - return resource.DetailsMsg{ - Err: fmt.Errorf("closing temporary kubeconfig failed: %w", err), - } - } - - if err := terminal.OpenK9s(path); err != nil { - os.Remove(path) - return resource.DetailsMsg{ - Err: fmt.Errorf("launching K9s failed: %w", err), - } - } - - return nil - } -} diff --git a/internal/terminal/k9s.go b/internal/terminal/k9s.go deleted file mode 100644 index dadc615..0000000 --- a/internal/terminal/k9s.go +++ /dev/null @@ -1,66 +0,0 @@ -package terminal - -import ( - "fmt" - "os/exec" - "runtime" - "strings" -) - -func CheckK9s() error { - if _, err := exec.LookPath("k9s"); err != nil { - return fmt.Errorf("k9s executable not found in PATH: %w", err) - } - return nil -} - -func OpenK9s(kubeconfig string) error { - k9s, err := exec.LookPath("k9s") - if err != nil { - return fmt.Errorf("finding k9s failed: %w", err) - } - - switch runtime.GOOS { - case "linux": - candidates := [][]string{ - {"gnome-terminal", "--", k9s, "--kubeconfig", kubeconfig}, - {"konsole", "-e", k9s, "--kubeconfig", kubeconfig}, - {"kitty", "--", k9s, "--kubeconfig", kubeconfig}, - {"alacritty", "-e", k9s, "--kubeconfig", kubeconfig}, - {"xterm", "-e", k9s, "--kubeconfig", kubeconfig}, - } - - for _, args := range candidates { - if _, err := exec.LookPath(args[0]); err == nil { - return exec.Command(args[0], args[1:]...).Start() - } - } - - return fmt.Errorf( - "no supported terminal found", - ) - - case "darwin": - command := shellQuote(k9s) + " --kubeconfig " + shellQuote(kubeconfig) - script := `on run argv - tell application "Terminal" - activate - do script (item 1 of argv) - end tell -end run` - - out, err := exec.Command("osascript", "-e", script, command).CombinedOutput() - if err != nil { - return fmt.Errorf("opening Terminal.app: %w: %s", err, strings.TrimSpace(string(out))) - } - - return nil - - default: - return fmt.Errorf("unsupported OS: %s", runtime.GOOS) - } -} - -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -} diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go new file mode 100644 index 0000000..e39f171 --- /dev/null +++ b/internal/terminal/terminal.go @@ -0,0 +1,77 @@ +package terminal + +import ( + "fmt" + "os/exec" + "runtime" + "strings" +) + +func Open(command string, args []string, preferred *string) error { + executable, err := exec.LookPath(command) + if err != nil { + return fmt.Errorf("executable %q not found: %w", command, err) + } + + switch runtime.GOOS { + case "linux": + candidates := [][]string{ + {"gnome-terminal", "--", executable}, + {"konsole", "-e", executable}, + {"kitty", "--", executable}, + {"alacritty", "-e", executable}, + {"xterm", "-e", executable}, + } + + if preferred != nil { + for _, candidate := range candidates { + if candidate[0] == *preferred { + if _, err := exec.LookPath(candidate[0]); err != nil { + return fmt.Errorf("configured terminal %q not found: %w", *preferred, err) + } + return exec.Command(candidate[0], append(candidate[1:], args...)...).Start() + } + } + return fmt.Errorf("unsupported terminal %q on Linux", *preferred) + } + + for _, candidate := range candidates { + if _, err := exec.LookPath(candidate[0]); err == nil { + return exec.Command(candidate[0], append(candidate[1:], args...)...).Start() + } + } + + return fmt.Errorf("no supported terminal found") + + case "darwin": + if preferred != nil && *preferred != "Terminal" { + return fmt.Errorf("unsupported terminal %q on macOS", *preferred) + } + + parts := append([]string{executable}, args...) + for i, part := range parts { + parts[i] = shellQuote(part) + } + + script := `on run argv + tell application "Terminal" + activate + do script (item 1 of argv) + end tell +end run` + + out, err := exec.Command("osascript", "-e", script, strings.Join(parts, " ")).CombinedOutput() + if err != nil { + return fmt.Errorf("opening Terminal.app failed: %w: %s", err, strings.TrimSpace(string(out))) + } + + return nil + + default: + return fmt.Errorf("unsupported OS: %s", runtime.GOOS) + } +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/internal/ui/app.go b/internal/ui/app.go index f9bdd95..023e1b7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -14,6 +14,7 @@ import ( "github.com/akyriako/o7k/internal/resource" "github.com/akyriako/o7k/internal/resources/contexts" pluginresource "github.com/akyriako/o7k/internal/resources/plugins" + "github.com/akyriako/o7k/internal/terminal" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" @@ -26,8 +27,9 @@ import ( const refreshInterval = 30 * time.Second type Model struct { - version string - skin config.Skin + version string + skin config.Skin + terminal *string registry *resource.Registry resource resource.Resource @@ -141,8 +143,9 @@ func New(registry *resource.Registry, openstackContext *openstack.Context, plugi ttc := time.Duration(cfg.Cache.CleanupInterval) * time.Second m := Model{ - version: version, - skin: cfg.SkinColors, + version: version, + skin: cfg.SkinColors, + terminal: cfg.Terminal, registry: registry, resource: r, @@ -690,6 +693,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) return m, clearStatus(m.status) + + case resource.LaunchTerminalMsg: + return m, func() tea.Msg { + if err := terminal.Open(msg.Command, msg.Args, m.terminal); err != nil { + return resource.ErrorMsg{Err: err} + } + return nil + } } var cmd tea.Cmd From de22017bf594fca79ea5aea2c901b1900682dc30 Mon Sep 17 00:00:00 2001 From: Kyriakos Akriotis Date: Thu, 22 Oct 2026 07:42:52 +0200 Subject: [PATCH 3/4] added iterm and ghostty #181 --- internal/terminal/terminal.go | 57 ++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index e39f171..3310b4b 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -21,6 +21,7 @@ func Open(command string, args []string, preferred *string) error { {"kitty", "--", executable}, {"alacritty", "-e", executable}, {"xterm", "-e", executable}, + {"ghostty", "-e", executable}, } if preferred != nil { @@ -44,28 +45,62 @@ func Open(command string, args []string, preferred *string) error { return fmt.Errorf("no supported terminal found") case "darwin": - if preferred != nil && *preferred != "Terminal" { - return fmt.Errorf("unsupported terminal %q on macOS", *preferred) - } + candidates := []string{"Terminal", "iTerm"} - parts := append([]string{executable}, args...) - for i, part := range parts { - parts[i] = shellQuote(part) + if preferred != nil { + candidates = []string{*preferred} } - script := `on run argv + for _, candidate := range candidates { + if candidate != "Terminal" && candidate != "iTerm" { + return fmt.Errorf("unsupported terminal %q on macOS", candidate) + } + + if err := exec.Command("open", "-Ra", candidate).Run(); err != nil { + if preferred != nil { + return fmt.Errorf("configured terminal %q not found: %w", candidate, err) + } + continue + } + + parts := append([]string{executable}, args...) + for i, part := range parts { + parts[i] = shellQuote(part) + } + command := strings.Join(parts, " ") + + var script string + + switch candidate { + case "Terminal": + script = `on run argv tell application "Terminal" activate do script (item 1 of argv) end tell end run` - out, err := exec.Command("osascript", "-e", script, strings.Join(parts, " ")).CombinedOutput() - if err != nil { - return fmt.Errorf("opening Terminal.app failed: %w: %s", err, strings.TrimSpace(string(out))) + case "iTerm": + script = `on run argv + tell application "iTerm" + activate + create window with default profile + tell current session of current window + write text (item 1 of argv) + end tell + end tell +end run` + } + + out, err := exec.Command("osascript", "-e", script, command).CombinedOutput() + if err != nil { + return fmt.Errorf("opening %s failed: %w: %s", candidate, err, strings.TrimSpace(string(out))) + } + + return nil } - return nil + return fmt.Errorf("no supported terminal found on macOS") default: return fmt.Errorf("unsupported OS: %s", runtime.GOOS) From e4e07f6a5a00445ffc85fd92c066065b10b6aaf9 Mon Sep 17 00:00:00 2001 From: Kyriakos Akriotis Date: Thu, 22 Oct 2026 07:48:16 +0200 Subject: [PATCH 4/4] added readme segment #181 --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index e36d0a6..81883b5 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,9 @@ refresh: log: level: info + +skin: dracula +terminal: ghostty ``` Cache TTL, cleanup interval, and refresh interval are specified in **seconds**. @@ -144,6 +147,19 @@ Custom skins can be placed in `~/.config/o7k/skins/` and selected by their filen skin: my-custom-theme ``` +### Terminal + +**o7k** supports launching external terminal applications for commands such as K9s or opening an SSH session. You can +specify your preferred terminal in `~/.config/o7k/config.yaml`: + +```yaml +terminal: kitty +``` + +If the preferred terminal is unavailable or fails to launch, o7k automatically falls back to another supported terminal. +When the setting is omitted, **o7k** selects the first available terminal. Supported terminals include **GNOME Terminal, +Konsole, kitty, Alacritty, Ghostty** and **xterm** on Linux, and **Terminal** and **iTerm2** on macOS. + ## Usage **o7k** uses the standard OpenStack `clouds.yaml` configuration and automatically discovers the