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
13 changes: 13 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
# Skipper pre-commit hook: runs the linter before allowing a commit.
# Enable with `make hooks` (sets core.hooksPath to .githooks).

set -e

if ! command -v golangci-lint >/dev/null 2>&1; then
echo "pre-commit: golangci-lint not found on PATH — install it or skip with 'git commit --no-verify'" >&2
exit 1
fi

echo "pre-commit: running make lint..."
make lint
21 changes: 21 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,34 @@ on:
push:
branches:
- main
- "feat/**"
- "fix/**"
- "docs/**"
pull_request:
branches:
- main

jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.25.4"
cache: true

- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: latest

test-and-build:
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down
38 changes: 38 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
version: "2"
run:
timeout: 5m
linters:
default: none
enable:
- copyloopvar
- errcheck
- errorlint
- goconst
- gocritic
- gosec
- govet
- ineffassign
- misspell
- revive
- staticcheck
- unparam
- unused
exclusions:
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- path: _test\.go$
linters:
- errcheck
- gosec
formatters:
enable:
- gofmt
- gofumpt
issues:
max-issues-per-linter: 0
max-same-issues: 0
52 changes: 52 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# AGENTS.md

Guidance for AI coding agents contributing to Skipper. Derived from `CONTRIBUTING.md` — follow both.

## Environment

- Requires Go 1.25+.
- Build and run locally with `make run`.
- Run `make hooks` once after cloning to enable the pre-commit lint hook (`.githooks/pre-commit`).

## Before you push

Always run these and make sure they pass:

```bash
make fmt
make lint
go test ./...
```

## Making changes

- Branch off `main` using prefixes like `feat/`, `fix/`, or `docs/` (e.g. `feat/search-improvements`).
- Keep each PR focused on one logical change.
- Follow existing code style — do not reformat unrelated code.
- Every feature must ship with tests covering it; no feature lands without test coverage.
- Add or update tests whenever behavior changes, and keep `go test ./...` green.
- Update `README.md` when user-facing behavior or flags change.

## Commit messages

Short, imperative mood, lowercase. Examples:

```
add fuzzy match scoring
fix crash when SSH config is missing
```

## Pull requests

- Open against `main`.
- Describe *what* changed and *why*; link any related issue.
- Ensure CI is green before requesting review.

## Bug reports / feature requests

Use the issue templates. Include expected vs. actual behavior, reproduction steps, and OS + `skipper --version`.

## Conduct and licensing

- Abide by the [Code of Conduct](./CODE_OF_CONDUCT.md).
- Contributions are licensed under the [Apache License 2.0](./LICENSE).
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ lint:
fmt:
golangci-lint fmt

.PHONY: all build run lint fmt
hooks:
git config core.hooksPath .githooks
@echo "Git hooks path set to .githooks"

.PHONY: all build run lint fmt hooks
all:
golangci-lint fmt && go build -o skipper && go run .
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ skipper [flags]

| Flag | Description |
|------|-------------|
| `-a, --add <alias> <target>` | Add a host entry to the SSH config using a target like `user@host[:port]` |
| `-a, --add <alias> <user@host[:port]>` | Add a host entry to the SSH config under the given alias |
| `-c, --config <path>` | 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 |
Expand Down Expand Up @@ -110,4 +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 |
| `make all` | Format + Build + Run |
10 changes: 6 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import (
"github.com/spf13/cobra"
)

var configPath string
var addAlias string
var findQuery string
var (
configPath string
addAlias string
findQuery string
)

var version = "dev"

Expand Down Expand Up @@ -162,7 +164,7 @@ func Execute() {

func init() {
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(&addAlias, "add", "a", "", "add a host alias using `<alias>` 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")
Expand Down
10 changes: 6 additions & 4 deletions internal/connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ import (
"github.com/jerryagbesi/skipper/internal/sshconfig"
)

type Commander func(name string, args ...string) *exec.Cmd //allows for mocking exec.Command
// Commander allows for mocking exec.Command.
type Commander func(name string, args ...string) *exec.Cmd

func Connect(host *sshconfig.Host, commander Commander) error {
target := host.Alias

var cmd *exec.Cmd

if target != "" {
switch {
case target != "":
cmd = commander("ssh", host.Alias)
} else if host.Hostname != "" {
case host.Hostname != "":
target = host.Hostname
cmd = commander("ssh", host.User+"@"+host.Hostname, "-p", strconv.Itoa(host.Port))
} else {
default:
return fmt.Errorf("no target specified")
}

Expand Down
28 changes: 2 additions & 26 deletions internal/connect/connect_test.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,12 @@
package connect

import (
"fmt"
"os"
"os/exec"
"strconv"
"testing"

"github.com/jerryagbesi/skipper/internal/sshconfig"
)

func fakeComander(exitCode int) Commander {
return func(name string, args ...string) *exec.Cmd {
cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess")
cmd.Env = append(os.Environ(), "TEST_SSH_STUB=1",
fmt.Sprintf("TEST_SSH_EXIT_CODE=%d", exitCode))

return cmd
}
}

func TestHelperProcess(t *testing.T) {
if os.Getenv("TEST_SSH_STUB") != "1" {
return
}

exitCode, _ := strconv.Atoi(os.Getenv("TEST_SSH_EXIT_CODE"))
os.Exit(exitCode)
}

func TestConnect_WithAlias_BuildsCorrectCommand(t *testing.T) {
host := &sshconfig.Host{
Alias: "bastion",
Expand All @@ -41,7 +19,6 @@ func TestConnect_WithAlias_BuildsCorrectCommand(t *testing.T) {
fakeCommander := func(name string, args ...string) *exec.Cmd {
capturedName = name
capturedArgs = args
// return a no-op command
return exec.Command("true")
}

Expand All @@ -56,7 +33,6 @@ func TestConnect_WithAlias_BuildsCorrectCommand(t *testing.T) {
}

func TestConnect_NoAlias_BuildsCorrectCommand(t *testing.T) {

host := &sshconfig.Host{
Alias: "",
Hostname: "10.0.0.1",
Expand All @@ -66,7 +42,7 @@ func TestConnect_NoAlias_BuildsCorrectCommand(t *testing.T) {

var capturedArgs []string

fakeCommander := func(name string, args ...string) *exec.Cmd {
fakeCommander := func(_ string, args ...string) *exec.Cmd {
capturedArgs = args
return exec.Command("true")
}
Expand All @@ -84,7 +60,7 @@ func TestConnect_NoAlias_BuildsCorrectCommand(t *testing.T) {
func TestConnect_EmptyHost_ReturnsError(t *testing.T) {
host := &sshconfig.Host{}

fakeCommander := func(name string, args ...string) *exec.Cmd {
fakeCommander := func(_ string, _ ...string) *exec.Cmd {
t.Error("commander should not be called for empty host")
return nil
}
Expand Down
4 changes: 3 additions & 1 deletion internal/connect/target.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package connect

import (
"errors"
"fmt"
"net"
"strconv"
Expand Down Expand Up @@ -47,7 +48,8 @@ func parseHostPort(rawHost string) (string, int, error) {
return strings.Trim(hostname, "[]"), port, nil
}

if addrErr, ok := err.(*net.AddrError); ok && strings.Contains(addrErr.Err, "missing port in address") {
var addrErr *net.AddrError
if errors.As(err, &addrErr) && 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]")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/connect/target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,4 @@ func TestParseTarget_Invalid(t *testing.T) {
}
})
}
}
}
11 changes: 5 additions & 6 deletions internal/sshconfig/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ type Host struct {
}

func DefaultConfigPath() (string, error) {
if home, err := os.UserHomeDir(); err != nil {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("error resolving home directory: %w", err)
} else {
return filepath.Join(home, ".ssh", "config"), nil
}
return filepath.Join(home, ".ssh", "config"), nil
}

func ParseHosts(path string) ([]Host, error) {
Expand All @@ -44,7 +44,8 @@ func ParseHosts(path string) ([]Host, error) {
var hosts []Host

for _, host := range cfg.Hosts {
for _, pattern := range host.Patterns { //incase a user has multiple patterns for a host eg. Host bastion jump-box *.staging
// A single Host block can list multiple patterns (e.g. `Host bastion jump-box *.staging`) — iterate each.
for _, pattern := range host.Patterns {
alias := pattern.String()

if alias == "*" {
Expand All @@ -71,10 +72,8 @@ func ParseHosts(path string) ([]Host, error) {
Port: port,
IdentityFile: identityFile,
})

}
}

return hosts, nil

}
3 changes: 0 additions & 3 deletions internal/sshconfig/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (

func TestStandardConfigParse(t *testing.T) {
hosts, err := ParseHosts("../../testdata/config1")

if err != nil {
t.Fatal(err)
}
Expand All @@ -18,7 +17,6 @@ func TestStandardConfigParse(t *testing.T) {

func TestEmptyConfig(t *testing.T) {
hosts, err := ParseHosts("../../testdata/config")

if err != nil {
t.Fatal(err)
}
Expand All @@ -30,7 +28,6 @@ func TestEmptyConfig(t *testing.T) {

func TestFileNotFound(t *testing.T) {
hosts, err := ParseHosts("404file")

if err == nil {
t.Fatalf("expected error, got nil")
}
Expand Down
Loading
Loading