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 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 f5de7ba..7e344b2 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.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/terminal/terminal.go b/internal/terminal/terminal.go new file mode 100644 index 0000000..3310b4b --- /dev/null +++ b/internal/terminal/terminal.go @@ -0,0 +1,112 @@ +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}, + {"ghostty", "-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": + candidates := []string{"Terminal", "iTerm"} + + if preferred != nil { + candidates = []string{*preferred} + } + + 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` + + 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 fmt.Errorf("no supported terminal found on macOS") + + 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