Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ refresh:

log:
level: info

skin: dracula
terminal: ghostty
```

Cache TTL, cleanup interval, and refresh interval are specified in **seconds**.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/resource/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,8 @@ type CommandCompletedMsg struct {
LoadID uint64
RefreshOnCompletion bool
}

type LaunchTerminalMsg struct {
Command string
Args []string
}
3 changes: 3 additions & 0 deletions internal/resources/containers/coe/clusters/clusters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
}

Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions internal/resources/containers/coe/clusters/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
}
}
112 changes: 112 additions & 0 deletions internal/terminal/terminal.go
Original file line number Diff line number Diff line change
@@ -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, "'", "'\\''") + "'"
}
19 changes: 15 additions & 4 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading