From 130171073f0da8c5289ef966560a0ef06011df9a Mon Sep 17 00:00:00 2001
From: Rexford Machu <43356170+machugram@users.noreply.github.com>
Date: Wed, 15 Apr 2026 20:47:22 +0100
Subject: [PATCH 1/7] Install Script
---
README.md | 5 +++-
install.sh | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 76 insertions(+), 1 deletion(-)
create mode 100644 install.sh
diff --git a/README.md b/README.md
index d717cb6..4e68fdc 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,10 @@
# Skipper
-A CLI tool for managing SSH connections with an interactive terminal UI. Skipper reads your `~/.ssh/config` file and lets you browse, search, and connect to hosts without memorizing aliases.
+A CLI tool for managing SSH connections with an interactive terminal UI. Skipper reads your `~/.ssh/config` file and lets you browse, search, and connect to hosts without memorizing aliases.You can fuzzy-search aliases and connection details, then connect immediately from the same screen — or skip the UI entirely when there's only one match.
+
+
+
## Features
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..c039911
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env sh
+set -e
+
+REPO="JerryAgbesi/skipper"
+BIN_NAME="skipper"
+INSTALL_DIR="/usr/local/bin"
+
+# Detect OS
+OS=$(uname -s | tr '[:upper:]' '[:lower:]')
+case "$OS" in
+ linux) OS="linux" ;;
+ darwin) OS="darwin" ;;
+ *)
+ echo "Unsupported OS: $OS"
+ exit 1
+ ;;
+esac
+
+# Detect architecture
+ARCH=$(uname -m)
+case "$ARCH" in
+ x86_64) ARCH="amd64" ;;
+ aarch64|arm64) ARCH="arm64" ;;
+ *)
+ echo "Unsupported architecture: $ARCH"
+ exit 1
+ ;;
+esac
+
+# Fetch the latest release tag from GitHub
+LATEST=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
+ | grep '"tag_name"' \
+ | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
+
+if [ -z "$LATEST" ]; then
+ echo "Could not determine the latest release."
+ exit 1
+fi
+
+# Sanity-check: version must look like v0.1.2 or 0.1.2
+case "$LATEST" in
+ v[0-9]*.[0-9]*.[0-9]*|[0-9]*.[0-9]*.[0-9]*) ;;
+ *)
+ echo "Unexpected version string: '$LATEST'. Aborting."
+ exit 1
+ ;;
+esac
+
+ARCHIVE="${BIN_NAME}_${LATEST}_${OS}_${ARCH}.tar.gz"
+URL="https://github.com/${REPO}/releases/download/${LATEST}/${ARCHIVE}"
+
+TMP_DIR=$(mktemp -d)
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+echo "Downloading ${BIN_NAME} ${LATEST} (${OS}/${ARCH})..."
+curl -fsSL "$URL" -o "${TMP_DIR}/${ARCHIVE}"
+
+tar -xzf "${TMP_DIR}/${ARCHIVE}" -C "$TMP_DIR"
+
+echo "Installing to ${INSTALL_DIR}/${BIN_NAME} ..."
+if install -m 755 "${TMP_DIR}/${BIN_NAME}" "${INSTALL_DIR}/${BIN_NAME}" 2>/dev/null; then
+ :
+elif command -v sudo >/dev/null 2>&1; then
+ echo "(requires sudo)"
+ sudo install -m 755 "${TMP_DIR}/${BIN_NAME}" "${INSTALL_DIR}/${BIN_NAME}"
+else
+ echo "Permission denied. Re-run as root or install manually:"
+ echo " sudo install -m 755 ${TMP_DIR}/${BIN_NAME} ${INSTALL_DIR}/${BIN_NAME}"
+ exit 1
+fi
+
+"${INSTALL_DIR}/${BIN_NAME}" --version
From 39b306b2558bfe7c81fe9ddd29ccbbc1f98f6259 Mon Sep 17 00:00:00 2001
From: Rexford Machu <43356170+machugram@users.noreply.github.com>
Date: Wed, 15 Apr 2026 21:06:44 +0100
Subject: [PATCH 2/7] Add Image for Skipper
Updated the image in the README to a new asset.
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 4e68fdc..78c471a 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
A CLI tool for managing SSH connections with an interactive terminal UI. Skipper reads your `~/.ssh/config` file and lets you browse, search, and connect to hosts without memorizing aliases.You can fuzzy-search aliases and connection details, then connect immediately from the same screen — or skip the UI entirely when there's only one match.
-
+
## Features
From 2e276979c45d6e21dafba177dc329deb3e0775e0 Mon Sep 17 00:00:00 2001
From: Rex <43356170+machugram@users.noreply.github.com>
Date: Thu, 16 Apr 2026 14:28:47 +0100
Subject: [PATCH 3/7] add--find and --add flags
---
README.md | 9 ++
cmd/root.go | 168 ++++++++++++++++++++-------
cmd/root_test.go | 217 +++++++++++++++++++++++++++++++++++
internal/connect/target.go | 59 ++++++++++
internal/sshconfig/writer.go | 109 ++++++++++++++++++
internal/ui/model.go | 29 ++---
6 files changed, 533 insertions(+), 58 deletions(-)
create mode 100644 cmd/root_test.go
create mode 100644 internal/connect/target.go
create mode 100644 internal/sshconfig/writer.go
diff --git a/README.md b/README.md
index 78c471a..e2f2fa7 100644
--- a/README.md
+++ b/README.md
@@ -63,10 +63,19 @@ skipper [flags]
| Flag | Description |
|------|-------------|
+| `-a, --add ` | Add a host entry to the SSH config using a target like `user@host[:port]` |
| `-c, --config ` | Path to SSH config file (default: `~/.ssh/config`) |
+| `-f, --find [term]` | Open directly in find mode, or pre-filter hosts when a search term is provided |
| `-v, --version` | Print version |
| `-h, --help` | Show help |
+Examples:
+
+```bash
+skipper --add devone user@ipaddress:9000
+skipper --add bastion admin@10.0.0.5
+```
+
### Keyboard Controls
| Key | Action |
diff --git a/cmd/root.go b/cmd/root.go
index e34be87..1d4959e 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
+ "strings"
"github.com/jerryagbesi/skipper/internal/connect"
"github.com/jerryagbesi/skipper/internal/sshconfig"
@@ -13,55 +14,143 @@ import (
)
var configPath string
+var addAlias string
+var findQuery string
var version = "dev"
var rootCmd = &cobra.Command{
- Use: "skipper [flags]",
- Version: version,
- Short: "skipper is a cli tool for managing ssh connections",
- Example: "skipper --version",
- Run: func(cmd *cobra.Command, args []string) {
- if configPath == "" {
- var err error
- configPath, err = sshconfig.DefaultConfigPath()
- if err != nil {
- fmt.Println(err)
- os.Exit(1)
- }
- }
- hosts, err := sshconfig.ParseHosts(configPath)
- if err != nil {
- fmt.Println(err)
- os.Exit(1)
- }
+ Use: "skipper [flags]",
+ Version: version,
+ Short: "skipper is a cli tool for managing ssh connections",
+ Example: "skipper --version",
+ RunE: runRoot,
+ SilenceErrors: true,
+ Long: `skipper is a cli tool for managing ssh connections, It allows you to select your preferred ssh host alias, connect to it, and execute commands.`,
+}
- if len(hosts) == 0 {
- fmt.Println("no hosts found in config file")
- os.Exit(1)
- }
+func runRoot(cmd *cobra.Command, args []string) error {
+ path, err := resolveConfigPath(configPath)
+ if err != nil {
+ return err
+ }
- // Render bubbletea UI and get selected host
- result, err := ui.Run(hosts)
+ if cmd.Flags().Changed("add") {
+ host, err := addHost(path, addAlias, args)
if err != nil {
- fmt.Println(err)
- os.Exit(1)
+ return err
}
- if result.Cancelled {
- return
+ fmt.Printf("added host %q for %s\n", host.Alias, hostTarget(host))
+ return nil
+ }
+
+ hosts, err := sshconfig.ParseHosts(path)
+ if err != nil {
+ return err
+ }
+
+ if len(hosts) == 0 {
+ return fmt.Errorf("no hosts found in config file")
+ }
+
+ options, hosts, err := prepareHostSelection(cmd, hosts)
+ if err != nil {
+ return err
+ }
+
+ result, err := ui.Run(hosts, options)
+ if err != nil {
+ return err
+ }
+
+ if result.Cancelled {
+ return nil
+ }
+
+ return connect.Connect(result.Host, exec.Command)
+}
+
+func resolveConfigPath(path string) (string, error) {
+ if path != "" {
+ return path, nil
+ }
+
+ return sshconfig.DefaultConfigPath()
+}
+
+func prepareHostSelection(cmd *cobra.Command, hosts []sshconfig.Host) (ui.RunOptions, []sshconfig.Host, error) {
+ options := ui.RunOptions{}
+ if !cmd.Flags().Changed("find") {
+ return options, hosts, nil
+ }
+
+ options.StartFiltering = findQuery == ""
+ if findQuery == "" {
+ return options, hosts, nil
+ }
+
+ filtered := filterHosts(hosts, findQuery)
+ if len(filtered) == 0 {
+ return ui.RunOptions{}, nil, fmt.Errorf("no hosts found matching %q", findQuery)
+ }
+
+ return options, filtered, nil
+}
+
+func addHost(path, alias string, args []string) (*sshconfig.Host, error) {
+ alias = strings.TrimSpace(alias)
+ if alias == "" {
+ return nil, fmt.Errorf("--add requires an alias")
+ }
+
+ if len(args) != 1 {
+ return nil, fmt.Errorf("--add requires exactly one target in the format user@host[:port]")
+ }
+
+ host, err := connect.ParseTarget(args[0])
+ if err != nil {
+ return nil, err
+ }
+
+ host.Alias = alias
+ return sshconfig.AddHost(path, *host)
+}
+
+func filterHosts(hosts []sshconfig.Host, query string) []sshconfig.Host {
+ query = strings.TrimSpace(strings.ToLower(query))
+ if query == "" {
+ return hosts
+ }
+
+ filtered := make([]sshconfig.Host, 0, len(hosts))
+ for _, host := range hosts {
+ if hostMatchesQuery(host, query) {
+ filtered = append(filtered, host)
}
+ }
- // Connect to selected host
- err = connect.Connect(result.Host, exec.Command)
- if err != nil {
- fmt.Println(err)
- os.Exit(1)
+ return filtered
+}
+
+func hostMatchesQuery(host sshconfig.Host, query string) bool {
+ fields := []string{host.Alias, host.Hostname, host.User, host.IdentityFile}
+ for _, field := range fields {
+ if strings.Contains(strings.ToLower(field), query) {
+ return true
}
+ }
- },
- SilenceErrors: true,
- Long: `skipper is a cli tool for managing ssh connections, It allows you to select your preferred ssh host alias, connect to it, and execute commands.`,
+ return host.Port != 0 && strings.Contains(fmt.Sprintf("%d", host.Port), query)
+}
+
+func hostTarget(host *sshconfig.Host) string {
+ target := host.User + "@" + host.Hostname
+ if host.Port > 0 {
+ return fmt.Sprintf("%s:%d", target, host.Port)
+ }
+
+ return target
}
func Execute() {
@@ -72,10 +161,9 @@ func Execute() {
}
func init() {
- // rootCmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
- // return fmt.Errorf("invalid flag: %w \n please run 'skipper --help' for usage information", err)
- // })
-
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to ssh config file, defaults to ~/.ssh/config")
+ rootCmd.Flags().StringVarP(&addAlias, "add", "a", "", "add a host alias using a target like user@host[:port]")
+ rootCmd.Flags().StringVarP(&findQuery, "find", "f", "", "start in find mode or pre-filter hosts by a search term")
+ rootCmd.Flags().Lookup("find").NoOptDefVal = ""
rootCmd.Flags().BoolP("version", "v", false, "print version information")
}
diff --git a/cmd/root_test.go b/cmd/root_test.go
new file mode 100644
index 0000000..f28af87
--- /dev/null
+++ b/cmd/root_test.go
@@ -0,0 +1,217 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/jerryagbesi/skipper/internal/sshconfig"
+ "github.com/jerryagbesi/skipper/internal/ui"
+ "github.com/spf13/cobra"
+)
+
+func TestFilterHostsReturnsOriginalListForBlankQuery(t *testing.T) {
+ hosts := []sshconfig.Host{{Alias: "dev"}, {Alias: "prod"}}
+
+ filtered := filterHosts(hosts, " ")
+
+ if len(filtered) != len(hosts) {
+ t.Fatalf("expected %d hosts, got %d", len(hosts), len(filtered))
+ }
+}
+
+func TestFilterHostsMatchesAliasHostnameUserAndPort(t *testing.T) {
+ hosts := []sshconfig.Host{
+ {Alias: "dev-api", Hostname: "10.0.0.4", User: "ubuntu", Port: 22},
+ {Alias: "prod-db", Hostname: "db.internal", User: "postgres", Port: 5432},
+ }
+
+ tests := []struct {
+ name string
+ query string
+ expected string
+ }{
+ {name: "matches alias", query: "DEV", expected: "dev-api"},
+ {name: "matches hostname", query: "internal", expected: "prod-db"},
+ {name: "matches user", query: "ubuntu", expected: "dev-api"},
+ {name: "matches port", query: "5432", expected: "prod-db"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ filtered := filterHosts(hosts, test.query)
+ if len(filtered) != 1 {
+ t.Fatalf("expected 1 host, got %d", len(filtered))
+ }
+
+ if filtered[0].Alias != test.expected {
+ t.Fatalf("expected %q, got %q", test.expected, filtered[0].Alias)
+ }
+ })
+ }
+}
+
+func TestPrepareHostSelectionStartsFilteringWhenFindHasNoTerm(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().String("find", "", "")
+ if err := cmd.Flags().Set("find", ""); err != nil {
+ t.Fatalf("expected flag set to succeed, got %v", err)
+ }
+
+ findQuery = ""
+ hosts := []sshconfig.Host{{Alias: "dev"}}
+
+ options, filtered, err := prepareHostSelection(cmd, hosts)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if !options.StartFiltering {
+ t.Fatal("expected filtering mode to start")
+ }
+
+ if len(filtered) != 1 || filtered[0].Alias != "dev" {
+ t.Fatalf("unexpected filtered hosts: %+v", filtered)
+ }
+}
+
+func TestPrepareHostSelectionReturnsErrorWhenNoMatch(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().String("find", "", "")
+ if err := cmd.Flags().Set("find", "prod"); err != nil {
+ t.Fatalf("expected flag set to succeed, got %v", err)
+ }
+
+ findQuery = "staging"
+ _, _, err := prepareHostSelection(cmd, []sshconfig.Host{{Alias: "dev"}})
+ if err == nil {
+ t.Fatal("expected error when no hosts match")
+ }
+}
+
+func TestAddHostWritesAliasAndTarget(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ host, err := addHost(configPath, "devone", []string{"user@10.0.0.8:9000"})
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if host.Alias != "devone" {
+ t.Fatalf("expected alias devone, got %q", host.Alias)
+ }
+
+ hosts, err := sshconfig.ParseHosts(configPath)
+ if err != nil {
+ t.Fatalf("expected config to parse, got %v", err)
+ }
+
+ if len(hosts) != 1 {
+ t.Fatalf("expected 1 host, got %d", len(hosts))
+ }
+
+ if hosts[0].Alias != "devone" || hosts[0].User != "user" || hosts[0].Hostname != "10.0.0.8" || hosts[0].Port != 9000 {
+ t.Fatalf("unexpected host written: %+v", hosts[0])
+ }
+}
+
+func TestAddHostIsIdempotentForSameAliasAndTarget(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ firstHost, err := addHost(configPath, "devone", []string{"user@10.0.0.8:9000"})
+ if err != nil {
+ t.Fatalf("expected first add to succeed, got %v", err)
+ }
+
+ secondHost, err := addHost(configPath, "devone", []string{"user@10.0.0.8:9000"})
+ if err != nil {
+ t.Fatalf("expected second add to succeed, got %v", err)
+ }
+
+ if secondHost.Alias != firstHost.Alias || secondHost.User != firstHost.User || secondHost.Hostname != firstHost.Hostname || secondHost.Port != firstHost.Port {
+ t.Fatalf("expected same host back, got first=%+v second=%+v", firstHost, secondHost)
+ }
+
+ hosts, err := sshconfig.ParseHosts(configPath)
+ if err != nil {
+ t.Fatalf("expected config to parse, got %v", err)
+ }
+
+ if len(hosts) != 1 {
+ t.Fatalf("expected 1 host after duplicate add, got %d", len(hosts))
+ }
+
+ content, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("expected config to be readable, got %v", err)
+ }
+
+ if strings.Count(string(content), "Host devone\n") != 1 {
+ t.Fatalf("expected single host entry, got content:\n%s", string(content))
+ }
+}
+
+func TestAddHostRejectsDuplicateAliasWithDifferentTarget(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ if _, err := addHost(configPath, "devone", []string{"user@10.0.0.8:9000"}); err != nil {
+ t.Fatalf("expected first add to succeed, got %v", err)
+ }
+
+ _, err := addHost(configPath, "devone", []string{"user@10.0.0.9:9000"})
+ if err == nil {
+ t.Fatal("expected duplicate alias with different target to fail")
+ }
+
+ if !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("expected duplicate alias error, got %v", err)
+ }
+}
+
+func TestAddHostRequiresAlias(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ _, err := addHost(configPath, " ", []string{"user@10.0.0.8:9000"})
+ if err == nil {
+ t.Fatal("expected error for missing alias")
+ }
+}
+
+func TestAddHostRequiresExactlyOneTarget(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ _, err := addHost(configPath, "devone", nil)
+ if err == nil {
+ t.Fatal("expected error for missing target")
+ }
+}
+
+func TestAddHostRejectsInvalidTarget(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ _, err := addHost(configPath, "devone", []string{"invalid-target"})
+ if err == nil {
+ t.Fatal("expected error for invalid target")
+ }
+}
+
+func TestResolveConfigPathReturnsExplicitPath(t *testing.T) {
+ explicitPath := filepath.Join(t.TempDir(), "config")
+
+ path, err := resolveConfigPath(explicitPath)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if path != explicitPath {
+ t.Fatalf("expected %q, got %q", explicitPath, path)
+ }
+}
+
+func TestRunOptionsZeroValueDoesNotStartFiltering(t *testing.T) {
+ options := ui.RunOptions{}
+ if options.StartFiltering {
+ t.Fatal("expected zero-value run options to keep filtering disabled")
+ }
+}
diff --git a/internal/connect/target.go b/internal/connect/target.go
new file mode 100644
index 0000000..f7f0b18
--- /dev/null
+++ b/internal/connect/target.go
@@ -0,0 +1,59 @@
+package connect
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+
+ "github.com/jerryagbesi/skipper/internal/sshconfig"
+)
+
+func ParseTarget(target string) (*sshconfig.Host, error) {
+ user, rawHost, ok := strings.Cut(strings.TrimSpace(target), "@")
+ if !ok || user == "" || rawHost == "" || strings.Contains(rawHost, "@") {
+ return nil, fmt.Errorf("target must be in the format user@host[:port]")
+ }
+
+ hostname, port, err := parseHostPort(rawHost)
+ if err != nil {
+ return nil, err
+ }
+
+ return &sshconfig.Host{
+ User: user,
+ Hostname: hostname,
+ Port: port,
+ }, nil
+}
+
+func parseHostPort(rawHost string) (string, int, error) {
+ rawHost = strings.TrimSpace(rawHost)
+ if rawHost == "" {
+ return "", 0, fmt.Errorf("target must include a host")
+ }
+
+ if strings.HasPrefix(rawHost, "[") && strings.HasSuffix(rawHost, "]") {
+ return strings.Trim(rawHost, "[]"), 0, nil
+ }
+
+ hostname, portText, err := net.SplitHostPort(rawHost)
+ if err == nil {
+ port, err := strconv.Atoi(portText)
+ if err != nil || port <= 0 || port > 65535 {
+ return "", 0, fmt.Errorf("port must be between 1 and 65535")
+ }
+
+ return strings.Trim(hostname, "[]"), port, nil
+ }
+
+ if addrErr, ok := err.(*net.AddrError); ok && strings.Contains(addrErr.Err, "missing port in address") {
+ return strings.Trim(rawHost, "[]"), 0, nil
+ }
+
+ if strings.Contains(rawHost, ":") {
+ return "", 0, fmt.Errorf("target must be in the format user@host[:port]")
+ }
+
+ return rawHost, 0, nil
+}
diff --git a/internal/sshconfig/writer.go b/internal/sshconfig/writer.go
new file mode 100644
index 0000000..e816637
--- /dev/null
+++ b/internal/sshconfig/writer.go
@@ -0,0 +1,109 @@
+package sshconfig
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func AddHost(path string, host Host) (*Host, error) {
+ if strings.TrimSpace(host.Hostname) == "" {
+ return nil, fmt.Errorf("host name is required")
+ }
+
+ if strings.TrimSpace(host.User) == "" {
+ return nil, fmt.Errorf("user is required")
+ }
+
+ host.Alias = resolveAlias(host)
+
+ existingHosts, err := readExistingHosts(path)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, existingHost := range existingHosts {
+ if existingHost.Alias != host.Alias {
+ continue
+ }
+
+ if existingHost.Hostname == host.Hostname && existingHost.User == host.User && existingHost.Port == host.Port {
+ return &existingHost, nil
+ }
+
+ return nil, fmt.Errorf("host %q already exists in %s with different settings", host.Alias, path)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return nil, fmt.Errorf("failed to create config directory: %w", err)
+ }
+
+ currentContent, err := os.ReadFile(path)
+ if err != nil && !os.IsNotExist(err) {
+ return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
+ }
+
+ file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open config file %q: %w", path, err)
+ }
+ defer file.Close()
+
+ if len(currentContent) > 0 && !strings.HasSuffix(string(currentContent), "\n") {
+ if _, err := file.WriteString("\n"); err != nil {
+ return nil, fmt.Errorf("failed to prepare config file %q: %w", path, err)
+ }
+ }
+
+ if len(strings.TrimSpace(string(currentContent))) > 0 {
+ if _, err := file.WriteString("\n"); err != nil {
+ return nil, fmt.Errorf("failed to separate config entries in %q: %w", path, err)
+ }
+ }
+
+ if _, err := file.WriteString(formatHostEntry(host)); err != nil {
+ return nil, fmt.Errorf("failed to write host %q to %s: %w", host.Alias, path, err)
+ }
+
+ return &host, nil
+}
+
+func readExistingHosts(path string) ([]Host, error) {
+ if _, err := os.Stat(path); err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+
+ return nil, fmt.Errorf("failed to access config file %q: %w", path, err)
+ }
+
+ return ParseHosts(path)
+}
+
+func formatHostEntry(host Host) string {
+ var builder strings.Builder
+ builder.WriteString(fmt.Sprintf("Host %s\n", host.Alias))
+ builder.WriteString(fmt.Sprintf(" HostName %s\n", host.Hostname))
+ builder.WriteString(fmt.Sprintf(" User %s\n", host.User))
+ if host.Port > 0 {
+ builder.WriteString(fmt.Sprintf(" Port %d\n", host.Port))
+ }
+ if host.IdentityFile != "" {
+ builder.WriteString(fmt.Sprintf(" IdentityFile %s\n", host.IdentityFile))
+ }
+
+ return builder.String()
+}
+
+func resolveAlias(host Host) string {
+ if alias := strings.TrimSpace(host.Alias); alias != "" {
+ return alias
+ }
+
+ if host.Port > 0 {
+ return fmt.Sprintf("%s-%d", host.Hostname, host.Port)
+ }
+
+ return host.Hostname
+}
diff --git a/internal/ui/model.go b/internal/ui/model.go
index 1b71dc9..30569a0 100644
--- a/internal/ui/model.go
+++ b/internal/ui/model.go
@@ -14,20 +14,6 @@ func (i item) Title() string { return i.host.Alias }
func (i item) Description() string { return i.host.Hostname }
func (i item) FilterValue() string { return i.host.Alias + " " + i.host.Hostname }
-// func hostDescription(host sshconfig.Host) string {
-// desc := host.Hostname
-
-// if host.User != "" {
-// desc = host.User + "@" + host.Hostname
-// }
-
-// if host.Port != 0 {
-// desc += fmt.Sprintf(":%d", host.Port)
-// }
-
-// return desc
-// }
-
type Model struct {
list list.Model
selectedHost *sshconfig.Host
@@ -39,7 +25,11 @@ type Result struct {
Cancelled bool
}
-func NewModel(hosts []sshconfig.Host) *Model {
+type RunOptions struct {
+ StartFiltering bool
+}
+
+func NewModel(hosts []sshconfig.Host, options RunOptions) *Model {
items := make([]list.Item, len(hosts))
for i, h := range hosts {
items[i] = item{host: h}
@@ -49,10 +39,13 @@ func NewModel(hosts []sshconfig.Host) *Model {
l.Title = "Select a Host"
l.SetFilteringEnabled(true)
l.SetShowStatusBar(true)
+ if options.StartFiltering {
+ l.SetFilterState(list.Filtering)
+ }
return &Model{list: l}
}
-func (m Model) Init() tea.Cmd { // Do nothing on start up. The list of host would have been loaded by the time the UI starts.
+func (m Model) Init() tea.Cmd {
return nil
}
@@ -95,8 +88,8 @@ func (m Model) View() string {
return m.list.View()
}
-func Run(hosts []sshconfig.Host) (Result, error) {
- model := NewModel(hosts)
+func Run(hosts []sshconfig.Host, options RunOptions) (Result, error) {
+ model := NewModel(hosts, options)
if len(hosts) == 1 {
return Result{Host: &hosts[0]}, nil
From c5d4d5f44cbf4798a7820e42508f8c0e060db40a Mon Sep 17 00:00:00 2001
From: Rex <43356170+machugram@users.noreply.github.com>
Date: Thu, 16 Apr 2026 14:35:22 +0100
Subject: [PATCH 4/7] fix url
---
install.sh | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/install.sh b/install.sh
index c039911..365c428 100644
--- a/install.sh
+++ b/install.sh
@@ -46,7 +46,8 @@ case "$LATEST" in
;;
esac
-ARCHIVE="${BIN_NAME}_${LATEST}_${OS}_${ARCH}.tar.gz"
+VERSION="${LATEST#v}"
+ARCHIVE="${BIN_NAME}_${VERSION}_${OS}_${ARCH}.tar.gz"
URL="https://github.com/${REPO}/releases/download/${LATEST}/${ARCHIVE}"
TMP_DIR=$(mktemp -d)
From 705b17283f4cdf20e3079989299bb00eaef5ede9 Mon Sep 17 00:00:00 2001
From: Rex <43356170+machugram@users.noreply.github.com>
Date: Thu, 16 Apr 2026 18:04:28 +0100
Subject: [PATCH 5/7] Add curl bash
---
README.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/README.md b/README.md
index e2f2fa7..8957d81 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,14 @@ A CLI tool for managing SSH connections with an interactive terminal UI. Skipper
- **Seamless connection** -- selects a host and drops you straight into an SSH session
## Installation
+### Quick Install
+
+Easily install Skipper using the provided install script, or download a release binary for your platform.
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/JerryAgbesi/skipper/main/install.sh | sh
+```
+
### Download a release binary
From 15c20acec3b059f398171ed909cb39d7f72af8d2 Mon Sep 17 00:00:00 2001
From: Rex <43356170+machugram@users.noreply.github.com>
Date: Fri, 17 Apr 2026 19:13:20 +0100
Subject: [PATCH 6/7] validate ssh fields & rejecting trailing colons
---
Makefile | 3 +
README.md | 18 +++++
internal/connect/target.go | 3 +
internal/connect/target_test.go | 78 +++++++++++++++++++++
internal/sshconfig/writer.go | 33 +++++++++
internal/sshconfig/writer_test.go | 113 ++++++++++++++++++++++++++++++
6 files changed, 248 insertions(+)
create mode 100644 internal/connect/target_test.go
create mode 100644 internal/sshconfig/writer_test.go
diff --git a/Makefile b/Makefile
index f19fc80..688f208 100644
--- a/Makefile
+++ b/Makefile
@@ -3,6 +3,9 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev
build:
go build -ldflags "-s -w -X github.com/jerryagbesi/skipper/cmd.version=$(VERSION)" -o skipper
+test:
+ go test ./...
+
run:
go run .
diff --git a/README.md b/README.md
index 8957d81..db5ffb9 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,14 @@ cd skipper
make run
```
+Run the test suite with:
+
+```bash
+make test
+```
+
+Unit tests live alongside the Go packages they exercise under `cmd/` and `internal/`.
+
## Usage
```
@@ -98,7 +106,17 @@ skipper --add bastion admin@10.0.0.5
| Target | Description |
|--------|-------------|
| `make build` | Compile the `skipper` binary |
+| `make test` | Run the full Go test suite |
| `make run` | Build and run |
| `make lint` | Run golangci-lint |
| `make fmt` | Format code |
| `make all` | Format + Build + Run |
+*** Add File: /Users/rexfordmachu/Documents/africa/skipper/tests/README.md
+# Tests
+
+This folder is the central place for repository-level and integration-style tests.
+
+Unit tests remain next to the packages they exercise in `cmd/` and `internal/`.
+That layout is intentional: several current tests need access to unexported package helpers, which would break if they were moved into a single shared directory.
+
+Use this folder for tests that validate behavior across package boundaries, CLI flows, or future end-to-end scenarios.
diff --git a/internal/connect/target.go b/internal/connect/target.go
index f7f0b18..3472216 100644
--- a/internal/connect/target.go
+++ b/internal/connect/target.go
@@ -48,6 +48,9 @@ func parseHostPort(rawHost string) (string, int, error) {
}
if addrErr, ok := err.(*net.AddrError); ok && strings.Contains(addrErr.Err, "missing port in address") {
+ if strings.HasSuffix(rawHost, ":") {
+ return "", 0, fmt.Errorf("target must be in the format user@host[:port]")
+ }
return strings.Trim(rawHost, "[]"), 0, nil
}
diff --git a/internal/connect/target_test.go b/internal/connect/target_test.go
new file mode 100644
index 0000000..fa8629f
--- /dev/null
+++ b/internal/connect/target_test.go
@@ -0,0 +1,78 @@
+package connect
+
+import (
+ "testing"
+)
+
+func TestParseTarget_TrailingColon(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ }{
+ {"trailing colon plain host", "user@host:"},
+ {"trailing colon IPv6", "user@[::1]:"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := ParseTarget(tc.input)
+ if err == nil {
+ t.Errorf("expected error for input %q, got host %+v", tc.input, got)
+ }
+ })
+ }
+}
+
+func TestParseTarget_Valid(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ wantUser string
+ wantHostname string
+ wantPort int
+ }{
+ {"host only", "alice@example.com", "alice", "example.com", 0},
+ {"host with port", "alice@example.com:2222", "alice", "example.com", 2222},
+ {"IPv6 bracketed no port", "alice@[::1]", "alice", "::1", 0},
+ {"IPv6 bracketed with port", "alice@[::1]:22", "alice", "::1", 22},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := ParseTarget(tc.input)
+ if err != nil {
+ t.Fatalf("unexpected error for input %q: %v", tc.input, err)
+ }
+ if got.User != tc.wantUser {
+ t.Errorf("user: got %q, want %q", got.User, tc.wantUser)
+ }
+ if got.Hostname != tc.wantHostname {
+ t.Errorf("hostname: got %q, want %q", got.Hostname, tc.wantHostname)
+ }
+ if got.Port != tc.wantPort {
+ t.Errorf("port: got %d, want %d", got.Port, tc.wantPort)
+ }
+ })
+ }
+}
+
+func TestParseTarget_Invalid(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ }{
+ {"no at-sign", "hostonly"},
+ {"empty user", "@host"},
+ {"empty host", "user@"},
+ {"double at-sign", "user@host@extra"},
+ {"port out of range", "user@host:99999"},
+ {"port zero", "user@host:0"},
+ {"non-numeric port", "user@host:abc"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := ParseTarget(tc.input)
+ if err == nil {
+ t.Errorf("expected error for input %q, got host %+v", tc.input, got)
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/internal/sshconfig/writer.go b/internal/sshconfig/writer.go
index e816637..efb7353 100644
--- a/internal/sshconfig/writer.go
+++ b/internal/sshconfig/writer.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
+ "unicode"
)
func AddHost(path string, host Host) (*Host, error) {
@@ -17,6 +18,9 @@ func AddHost(path string, host Host) (*Host, error) {
}
host.Alias = resolveAlias(host)
+ if err := validateHostFields(host); err != nil {
+ return nil, err
+ }
existingHosts, err := readExistingHosts(path)
if err != nil {
@@ -107,3 +111,32 @@ func resolveAlias(host Host) string {
return host.Hostname
}
+
+func validateHostFields(host Host) error {
+ fields := []struct {
+ name string
+ value string
+ required bool
+ }{
+ {name: "alias", value: host.Alias, required: true},
+ {name: "host name", value: host.Hostname, required: true},
+ {name: "user", value: host.User, required: true},
+ {name: "identity file", value: host.IdentityFile},
+ }
+
+ for _, field := range fields {
+ if field.required && strings.TrimSpace(field.value) == "" {
+ return fmt.Errorf("%s is required", field.name)
+ }
+
+ if containsUnsafeWhitespace(field.value) {
+ return fmt.Errorf("%s cannot contain whitespace", field.name)
+ }
+ }
+
+ return nil
+}
+
+func containsUnsafeWhitespace(value string) bool {
+ return strings.ContainsFunc(value, unicode.IsSpace)
+}
diff --git a/internal/sshconfig/writer_test.go b/internal/sshconfig/writer_test.go
new file mode 100644
index 0000000..98bbb26
--- /dev/null
+++ b/internal/sshconfig/writer_test.go
@@ -0,0 +1,113 @@
+package sshconfig
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestAddHostRejectsUnsafeWhitespaceInWrittenFields(t *testing.T) {
+ tests := []struct {
+ name string
+ host Host
+ wantMessage string
+ }{
+ {
+ name: "alias with space",
+ host: Host{
+ Alias: "jump box",
+ Hostname: "example.com",
+ User: "alice",
+ },
+ wantMessage: "alias cannot contain whitespace",
+ },
+ {
+ name: "hostname with newline",
+ host: Host{
+ Alias: "jump-box",
+ Hostname: "example.com\nProxyCommand yes",
+ User: "alice",
+ },
+ wantMessage: "host name cannot contain whitespace",
+ },
+ {
+ name: "user with tab",
+ host: Host{
+ Alias: "jump-box",
+ Hostname: "example.com",
+ User: "alice\tadmin",
+ },
+ wantMessage: "user cannot contain whitespace",
+ },
+ {
+ name: "identity file with space",
+ host: Host{
+ Alias: "jump-box",
+ Hostname: "example.com",
+ User: "alice",
+ IdentityFile: "/Users/test/.ssh/my key",
+ },
+ wantMessage: "identity file cannot contain whitespace",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ _, err := AddHost(configPath, test.host)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ if !strings.Contains(err.Error(), test.wantMessage) {
+ t.Fatalf("expected error containing %q, got %v", test.wantMessage, err)
+ }
+
+ content, readErr := os.ReadFile(configPath)
+ if !os.IsNotExist(readErr) {
+ t.Fatalf("expected config file to not be created, readErr=%v content=%q", readErr, string(content))
+ }
+ })
+ }
+}
+
+func TestAddHostRejectsUnsafeWhitespaceInResolvedAlias(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ _, err := AddHost(configPath, Host{
+ Hostname: "example host",
+ User: "alice",
+ })
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "alias cannot contain whitespace") {
+ t.Fatalf("expected resolved alias validation error, got %v", err)
+ }
+}
+
+func TestAddHostWritesSafeIdentityFile(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ _, err := AddHost(configPath, Host{
+ Alias: "jump-box",
+ Hostname: "example.com",
+ User: "alice",
+ IdentityFile: "/Users/test/.ssh/id_ed25519",
+ })
+ if err != nil {
+ t.Fatalf("expected host to be added, got %v", err)
+ }
+
+ content, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("expected config to be readable, got %v", err)
+ }
+
+ if !strings.Contains(string(content), " IdentityFile /Users/test/.ssh/id_ed25519\n") {
+ t.Fatalf("expected identity file to be written, got content:\n%s", string(content))
+ }
+}
From deb48682806772bbd804d9a2ed9b73c492c1bb2a Mon Sep 17 00:00:00 2001
From: Rex <43356170+machugram@users.noreply.github.com>
Date: Fri, 17 Apr 2026 19:47:01 +0100
Subject: [PATCH 7/7] fix parser errors
---
README.md | 11 +----------
internal/sshconfig/parser.go | 2 +-
internal/sshconfig/writer.go | 28 ++++++++++++++++++++++++----
internal/sshconfig/writer_test.go | 28 ++++++++++++++++++++++++++++
4 files changed, 54 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index db5ffb9..748b5d9 100644
--- a/README.md
+++ b/README.md
@@ -110,13 +110,4 @@ skipper --add bastion admin@10.0.0.5
| `make run` | Build and run |
| `make lint` | Run golangci-lint |
| `make fmt` | Format code |
-| `make all` | Format + Build + Run |
-*** Add File: /Users/rexfordmachu/Documents/africa/skipper/tests/README.md
-# Tests
-
-This folder is the central place for repository-level and integration-style tests.
-
-Unit tests remain next to the packages they exercise in `cmd/` and `internal/`.
-That layout is intentional: several current tests need access to unexported package helpers, which would break if they were moved into a single shared directory.
-
-Use this folder for tests that validate behavior across package boundaries, CLI flows, or future end-to-end scenarios.
+| `make all` | Format + Build + Run |
\ No newline at end of file
diff --git a/internal/sshconfig/parser.go b/internal/sshconfig/parser.go
index 9d17039..d193bb1 100644
--- a/internal/sshconfig/parser.go
+++ b/internal/sshconfig/parser.go
@@ -53,7 +53,7 @@ func ParseHosts(path string) ([]Host, error) {
hostname, _ := cfg.Get(alias, "Hostname")
user, _ := cfg.Get(alias, "User")
- identityFile := ssh_config.Get(alias, "IdentityFile")
+ identityFile, _ := cfg.Get(alias, "IdentityFile")
var port int
if raw, _ := cfg.Get(alias, "Port"); raw != "" {
diff --git a/internal/sshconfig/writer.go b/internal/sshconfig/writer.go
index efb7353..38f4b1b 100644
--- a/internal/sshconfig/writer.go
+++ b/internal/sshconfig/writer.go
@@ -8,7 +8,7 @@ import (
"unicode"
)
-func AddHost(path string, host Host) (*Host, error) {
+func AddHost(path string, host Host) (addedHost *Host, err error) {
if strings.TrimSpace(host.Hostname) == "" {
return nil, fmt.Errorf("host name is required")
}
@@ -32,7 +32,7 @@ func AddHost(path string, host Host) (*Host, error) {
continue
}
- if existingHost.Hostname == host.Hostname && existingHost.User == host.User && existingHost.Port == host.Port {
+ if sameHostSettings(existingHost, host) {
return &existingHost, nil
}
@@ -52,7 +52,15 @@ func AddHost(path string, host Host) (*Host, error) {
if err != nil {
return nil, fmt.Errorf("failed to open config file %q: %w", path, err)
}
- defer file.Close()
+ defer func() {
+ if cerr := file.Close(); cerr != nil {
+ if err == nil {
+ err = fmt.Errorf("failed to close config file %q: %w", path, cerr)
+ } else {
+ err = fmt.Errorf("%v; close error for %q: %w", err, path, cerr)
+ }
+ }
+ }()
if len(currentContent) > 0 && !strings.HasSuffix(string(currentContent), "\n") {
if _, err := file.WriteString("\n"); err != nil {
@@ -70,7 +78,8 @@ func AddHost(path string, host Host) (*Host, error) {
return nil, fmt.Errorf("failed to write host %q to %s: %w", host.Alias, path, err)
}
- return &host, nil
+ addedHost = &host
+ return addedHost, nil
}
func readExistingHosts(path string) ([]Host, error) {
@@ -112,6 +121,17 @@ func resolveAlias(host Host) string {
return host.Hostname
}
+func sameHostSettings(existingHost, requestedHost Host) bool {
+ return existingHost.Hostname == requestedHost.Hostname &&
+ existingHost.User == requestedHost.User &&
+ existingHost.Port == requestedHost.Port &&
+ normalizeIdentityFile(existingHost.IdentityFile) == normalizeIdentityFile(requestedHost.IdentityFile)
+}
+
+func normalizeIdentityFile(identityFile string) string {
+ return strings.TrimSpace(identityFile)
+}
+
func validateHostFields(host Host) error {
fields := []struct {
name string
diff --git a/internal/sshconfig/writer_test.go b/internal/sshconfig/writer_test.go
index 98bbb26..037b1cc 100644
--- a/internal/sshconfig/writer_test.go
+++ b/internal/sshconfig/writer_test.go
@@ -111,3 +111,31 @@ func TestAddHostWritesSafeIdentityFile(t *testing.T) {
t.Fatalf("expected identity file to be written, got content:\n%s", string(content))
}
}
+
+func TestAddHostRejectsDuplicateAliasWithDifferentIdentityFile(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config")
+
+ _, err := AddHost(configPath, Host{
+ Alias: "jump-box",
+ Hostname: "example.com",
+ User: "alice",
+ IdentityFile: "/Users/test/.ssh/id_ed25519",
+ })
+ if err != nil {
+ t.Fatalf("expected first host to be added, got %v", err)
+ }
+
+ _, err = AddHost(configPath, Host{
+ Alias: "jump-box",
+ Hostname: "example.com",
+ User: "alice",
+ IdentityFile: "/Users/test/.ssh/id_rsa",
+ })
+ if err == nil {
+ t.Fatal("expected duplicate alias with different identity file to fail")
+ }
+
+ if !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("expected duplicate alias error, got %v", err)
+ }
+}