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. + +![Skipper UI](https://github.com/user-attachments/assets/12d818ce-61ec-45c2-8af9-104f5bc8c00f) + ## 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. -![Skipper UI](https://github.com/user-attachments/assets/12d818ce-61ec-45c2-8af9-104f5bc8c00f) +image ## 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) + } +}