Skip to content

Commit 4b933b1

Browse files
Merge pull request #2 from Benjamin-Connelly/feat/ssh-remote
feat: SSH remote file browsing
2 parents a21a5d3 + 7e46b8b commit 4b933b1

File tree

12 files changed

+1815
-5
lines changed

12 files changed

+1815
-5
lines changed

CLAUDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ internal/
4747
git/
4848
git.go # go-git: repo, status, branches, log, remotes
4949
permalink.go # URL generation (GitHub/GitLab/Bitbucket/Gitea/Codeberg)
50+
remote/
51+
remote.go # SCP-style path parsing, Target type
52+
conn.go # SSH connection (ssh-agent, key files, ~/.ssh/config)
53+
sync.go # SFTP sync/cache with periodic polling
5054
export/export.go # Markdown → HTML with Chroma highlighting
5155
doctor/doctor.go # 8 environment checks with colored output
5256
plugin/plugin.go # YAML hook system (prepend/append/replace)
@@ -84,6 +88,12 @@ go build -o lookit ./cmd/lookit
8488
./lookit serve [path]
8589
./lookit serve --port 3000 --open
8690

91+
# Remote browsing (SSH)
92+
./lookit myhost:/path/to/docs # SCP-style remote path
93+
./lookit user@host:/path # with explicit user
94+
./lookit --remote myhost /path # flag-style alternative
95+
./lookit @docs # named remote from config
96+
8797
# Utilities
8898
./lookit cat README.md # render markdown to terminal
8999
./lookit export --format html # export markdown to HTML
@@ -117,3 +127,6 @@ GOOS=darwin GOARCH=arm64 go build -o lookit-darwin-arm64 ./cmd/lookit
117127
- Permalink generation detects forge style from remote URL (GitHub/GitLab/Bitbucket/Gitea/Codeberg).
118128
- Plugin hooks loaded from `~/.config/lookit/plugins/*.yaml`.
119129
- Task extraction recognizes `!high`/`!medium`/`!low` priority, `#tag`, `@due(YYYY-MM-DD)`.
130+
- Remote mode caches files to `~/.cache/lookit/remote/<hash>/`. Git features disabled for remote.
131+
- SSH auth: ssh-agent → key files → ~/.ssh/config. TOFU for unknown host keys.
132+
- Remote polling interval: 15 seconds. No real-time change notification (SFTP limitation).

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Requires Go 1.24+. Pure Go, no CGO — cross-compiles to linux/darwin on amd64/a
2626
```bash
2727
lookit # TUI mode — browse current directory
2828
lookit ~/docs # TUI mode — browse specific directory
29+
lookit myhost:~/docs # SSH remote — browse files on a remote host
2930
lookit serve # Web mode — localhost:7777
3031
lookit serve --port 3000 --open # Web mode — custom port, auto-open browser
3132
lookit cat README.md # Render markdown to terminal
@@ -38,6 +39,7 @@ lookit doctor # Environment diagnostics
3839
| Feature | `glow` | `mdcat` | `frogmouth` | **lookit** |
3940
|---------|:------:|:-------:|:-----------:|:----------:|
4041
| TUI file browser | Stash only | No | Single-pane | **Split-pane tree + preview** |
42+
| SSH remote browsing | No | No | No | **`host:/path` — browse remote docs** |
4143
| Full-text search | No | No | No | **Bleve BM25 index** |
4244
| Inter-document links | No | No | No | **History, backlinks, `[[wikilinks]]`** |
4345
| Broken link detection | No | No | No | **Files + `#heading` anchors** |
@@ -92,6 +94,32 @@ Lightweight HTTP server with live reload.
9294
- **Security headers** — CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy
9395
- **ETag caching** — MD5-based for HTML, size+mtime for static
9496

97+
### SSH Remote Browsing
98+
99+
Browse files on remote hosts over SSH — no installation required on the remote machine.
100+
101+
```bash
102+
lookit myhost:~/docs # SSH config alias with ~ expansion
103+
lookit user@192.168.1.50:/var/docs # Explicit user and IP
104+
lookit --remote myhost ~/docs # Flag-style alternative
105+
lookit @docs # Named remote from config
106+
```
107+
108+
- **Zero remote setup** — uses SFTP, nothing to install on the server
109+
- **SSH config support** — respects `~/.ssh/config` (Host aliases, Hostname, User, Port, IdentityFile)
110+
- **Auth chain** — ssh-agent → key files → SSH config (TOFU for unknown host keys)
111+
- **Sync/cache model** — files cached locally at `~/.cache/lookit/remote/`, polled every 15s for changes
112+
- **Status bar** — shows connection state (Connected/Reconnecting/Disconnected) and last sync time
113+
- **Auto-reconnect** — exponential backoff on connection loss
114+
- **Named remotes** — configure aliases in `config.yaml`:
115+
```yaml
116+
remotes:
117+
docs:
118+
host: myserver
119+
user: deploy
120+
path: /home/deploy/docs
121+
```
122+
95123
### Shared
96124
97125
- **Full-text search** — Bleve persistent index with BM25 scoring, field boosting, and highlighted snippets
@@ -185,6 +213,10 @@ Lightweight HTTP server with live reload.
185213

186214
```
187215
lookit [path] # TUI mode (default)
216+
lookit host:/path # SSH remote (SCP-style)
217+
lookit @alias # Named remote from config
218+
--remote <host> # Remote host (SSH config alias or user@host)
219+
--remote-port <port> # Remote SSH port
188220
--keymap vim|emacs|default # Keybinding preset
189221
--theme dark|light|auto|ascii # Color theme
190222
--no-color # Alias for --theme ascii
@@ -231,6 +263,12 @@ git:
231263
ignore:
232264
- "*.tmp"
233265
- "vendor/"
266+
267+
remotes: # Named remote hosts for SSH browsing
268+
docs:
269+
host: myserver # SSH config alias or hostname
270+
user: deploy # SSH user (optional)
271+
path: /home/deploy/docs
234272
```
235273

236274
**Per-project config:** Place `.lookit.toml` or `.lookit.yaml` in your project root. Lookit walks up from the current directory and merges the first one found over the global config.
@@ -298,6 +336,12 @@ Lookit exists because generous people write extraordinary software and give it a
298336
- [D3.js](https://d3js.org) — the gold standard for data visualization on the web
299337
- [Mermaid](https://mermaid.js.org) — diagrams from text, rendered beautifully in the browser
300338

339+
**SSH & Networking**
340+
- [x/crypto/ssh](https://pkg.go.dev/golang.org/x/crypto/ssh) — pure Go SSH client from the Go team
341+
- [sftp](https://github.com/pkg/sftp) — SFTP client that makes remote file access feel local
342+
- [ssh_config](https://github.com/kevinburke/ssh_config) — OpenSSH config parser (maintained by Tailscale)
343+
- [knownhosts](https://github.com/skeema/knownhosts) — SSH host key verification with known_hosts support
344+
301345
**Utilities**
302346
- [clipboard](https://github.com/atotto/clipboard) — cross-platform clipboard access
303347
- [fsnotify](https://github.com/fsnotify/fsnotify) — cross-platform file system notifications

cmd/lookit/main.go

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10+
"time"
1011

1112
tea "github.com/charmbracelet/bubbletea"
1213
"github.com/spf13/cobra"
@@ -17,6 +18,7 @@ import (
1718
"github.com/Benjamin-Connelly/lookit/internal/doctor"
1819
"github.com/Benjamin-Connelly/lookit/internal/export"
1920
"github.com/Benjamin-Connelly/lookit/internal/index"
21+
"github.com/Benjamin-Connelly/lookit/internal/remote"
2022
"github.com/Benjamin-Connelly/lookit/internal/render"
2123
"github.com/Benjamin-Connelly/lookit/internal/tui"
2224
"github.com/Benjamin-Connelly/lookit/internal/web"
@@ -32,7 +34,12 @@ var rootCmd = &cobra.Command{
3234
Version: version,
3335
Long: `Lookit is a dual-mode markdown navigator that provides both TUI and web
3436
interfaces for browsing code, markdown, and files. Features inter-document
35-
link navigation with history, backlinks, and broken link detection.`,
37+
link navigation with history, backlinks, and broken link detection.
38+
39+
Supports browsing remote files over SSH:
40+
lookit myhost:/path/to/docs
41+
lookit user@host:/path
42+
lookit --remote myhost /path`,
3643
Args: cobra.MaximumNArgs(1),
3744
PersistentPreRunE: loadConfig,
3845
RunE: func(cmd *cobra.Command, args []string) error {
@@ -57,6 +64,23 @@ link navigation with history, backlinks, and broken link detection.`,
5764
return nil
5865
}
5966

67+
// Check for remote path (host:/path syntax or --remote flag)
68+
if remoteHost, _ := cmd.Flags().GetString("remote"); remoteHost != "" {
69+
remotePath := "."
70+
if len(args) > 0 {
71+
remotePath = args[0]
72+
}
73+
remotePort, _ := cmd.Flags().GetInt("remote-port")
74+
target := &remote.Target{Host: remoteHost, Path: remotePath, Port: remotePort}
75+
return runRemote(target)
76+
}
77+
if len(args) > 0 {
78+
target := resolveRemoteTarget(args[0])
79+
if target != nil {
80+
return runRemote(target)
81+
}
82+
}
83+
6084
root, err := resolveRoot(args)
6185
if err != nil {
6286
return err
@@ -476,6 +500,8 @@ func init() {
476500
rootCmd.PersistentFlags().Bool("no-color", false, "disable colors (ascii theme)")
477501

478502
rootCmd.Flags().String("keymap", "", "keybinding preset (default|vim|emacs)")
503+
rootCmd.Flags().String("remote", "", "remote host (SSH config alias or user@host)")
504+
rootCmd.Flags().Int("remote-port", 0, "remote SSH port (default: from ssh config or 22)")
479505

480506
serveCmd.Flags().IntP("port", "p", 0, "server port")
481507
serveCmd.Flags().Bool("open", false, "open browser after starting")
@@ -557,6 +583,146 @@ func resolveRoot(args []string) (string, error) {
557583
return absRoot, nil
558584
}
559585

586+
// resolveRemoteTarget checks if the arg is a remote path spec or a named
587+
// remote from config. Returns nil if the arg is a local path.
588+
func resolveRemoteTarget(arg string) *remote.Target {
589+
// Try SCP-style parsing first (host:/path)
590+
if target := remote.ParseTarget(arg); target != nil {
591+
return target
592+
}
593+
594+
// Check named remotes in config (e.g. @docs)
595+
if strings.HasPrefix(arg, "@") && cfg.Remotes != nil {
596+
name := arg[1:]
597+
if rc, ok := cfg.Remotes[name]; ok {
598+
return &remote.Target{
599+
Host: rc.Host,
600+
User: rc.User,
601+
Port: rc.Port,
602+
Path: rc.Path,
603+
}
604+
}
605+
}
606+
607+
return nil
608+
}
609+
610+
// runRemote handles the full lifecycle of browsing a remote host.
611+
func runRemote(target *remote.Target) error {
612+
// Check minimum terminal size
613+
if w, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
614+
if w < 80 || h < 24 {
615+
return fmt.Errorf("terminal too small (%dx%d). Lookit requires at least 80x24", w, h)
616+
}
617+
}
618+
619+
fmt.Fprintf(os.Stderr, "Connecting to %s...\n", target.Display())
620+
621+
// Establish SSH connection
622+
conn := remote.NewConn(*target)
623+
if err := conn.Connect(); err != nil {
624+
return fmt.Errorf("connecting to %s: %w", target.Display(), err)
625+
}
626+
defer conn.Close()
627+
628+
// Use the resolved target (~ expanded, relative paths resolved)
629+
resolved := conn.Target()
630+
631+
fmt.Fprintf(os.Stderr, "Connected to %s. Syncing files...\n", resolved.Display())
632+
633+
// Set up local cache
634+
cacheDir, err := remote.CachePath(resolved)
635+
if err != nil {
636+
return fmt.Errorf("cache path: %w", err)
637+
}
638+
639+
// Sync remote files to local cache
640+
syncer := remote.NewSyncer(conn, cacheDir)
641+
if err := syncer.InitialSync(); err != nil {
642+
return fmt.Errorf("initial sync from %s: %w", resolved.Display(), err)
643+
}
644+
645+
fmt.Fprintf(os.Stderr, "Sync complete. Starting TUI...\n")
646+
647+
// Build index from cached files
648+
idx := index.New(syncer.CacheDir())
649+
if err := idx.Build(); err != nil {
650+
return fmt.Errorf("building index: %w", err)
651+
}
652+
653+
// Build fulltext search index
654+
fulltextDir, _ := os.UserCacheDir()
655+
if fulltextDir != "" {
656+
fulltextDir = filepath.Join(fulltextDir, "lookit")
657+
}
658+
if err := idx.BuildFulltext(fulltextDir); err != nil {
659+
fmt.Fprintf(os.Stderr, "warning: fulltext index unavailable: %v\n", err)
660+
}
661+
defer idx.CloseFulltext()
662+
663+
links := index.NewLinkGraph()
664+
links.BuildFromIndex(idx)
665+
666+
// Set up file watcher on local cache (catches sync updates)
667+
watcher, err := index.NewWatcher(idx, links, nil)
668+
if err != nil {
669+
return fmt.Errorf("starting watcher: %w", err)
670+
}
671+
defer watcher.Close()
672+
if err := watcher.Start(); err != nil {
673+
return fmt.Errorf("watching files: %w", err)
674+
}
675+
676+
// Start background polling for remote changes
677+
syncer.SetOnChange(func() {
678+
// Watcher will pick up changes via fsnotify on the cache dir
679+
})
680+
syncer.StartPolling()
681+
defer syncer.Stop()
682+
683+
// Create TUI with remote info
684+
model := tui.New(cfg, idx, links)
685+
model.SetRemoteInfo(&tui.RemoteInfo{
686+
Display: resolved.Display(),
687+
State: conn.State().String(),
688+
})
689+
690+
// Start a goroutine to update remote status periodically
691+
done := make(chan struct{})
692+
defer close(done)
693+
go func() {
694+
ticker := time.NewTicker(1 * time.Second)
695+
defer ticker.Stop()
696+
for {
697+
select {
698+
case <-ticker.C:
699+
status := syncer.Status()
700+
elapsed := time.Since(status.LastSync).Truncate(time.Second)
701+
syncText := ""
702+
switch status.State {
703+
case remote.SyncRunning:
704+
syncText = "syncing..."
705+
default:
706+
if !status.LastSync.IsZero() {
707+
syncText = fmt.Sprintf("synced %s ago", elapsed)
708+
}
709+
}
710+
model.SetRemoteInfo(&tui.RemoteInfo{
711+
Display: resolved.Display(),
712+
State: conn.State().String(),
713+
LastSync: syncText,
714+
})
715+
case <-done:
716+
return
717+
}
718+
}
719+
}()
720+
721+
p := tea.NewProgram(model, tea.WithAltScreen())
722+
_, err = p.Run()
723+
return err
724+
}
725+
560726
func main() {
561727
if err := rootCmd.Execute(); err != nil {
562728
os.Exit(1)

go.mod

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
github.com/yuin/goldmark v1.7.8
1919
github.com/yuin/goldmark-emoji v1.0.5
2020
go.yaml.in/yaml/v3 v3.0.4
21+
golang.org/x/crypto v0.45.0
2122
golang.org/x/term v0.37.0
2223
)
2324

@@ -69,7 +70,8 @@ require (
6970
github.com/inconshreveable/mousetrap v1.1.0 // indirect
7071
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
7172
github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede // indirect
72-
github.com/kevinburke/ssh_config v1.2.0 // indirect
73+
github.com/kevinburke/ssh_config v1.2.0
74+
github.com/kr/fs v0.1.0 // indirect
7375
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
7476
github.com/mattn/go-isatty v0.0.20 // indirect
7577
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -82,11 +84,12 @@ require (
8284
github.com/muesli/termenv v0.16.0 // indirect
8385
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
8486
github.com/pjbgf/sha1cd v0.3.2 // indirect
87+
github.com/pkg/sftp v1.13.10
8588
github.com/rivo/uniseg v0.4.7 // indirect
8689
github.com/russross/blackfriday/v2 v2.1.0 // indirect
8790
github.com/sagikazarmark/locafero v0.11.0 // indirect
8891
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
89-
github.com/skeema/knownhosts v1.3.1 // indirect
92+
github.com/skeema/knownhosts v1.3.1
9093
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
9194
github.com/spf13/afero v1.15.0 // indirect
9295
github.com/spf13/cast v1.10.0 // indirect
@@ -95,7 +98,6 @@ require (
9598
github.com/xanzy/ssh-agent v0.3.3 // indirect
9699
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
97100
go.etcd.io/bbolt v1.4.0 // indirect
98-
golang.org/x/crypto v0.45.0 // indirect
99101
golang.org/x/net v0.47.0 // indirect
100102
golang.org/x/sys v0.38.0 // indirect
101103
golang.org/x/text v0.31.0 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede h1:YrgBGwxMRK0Vq0
145145
github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
146146
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
147147
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
148+
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
149+
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
148150
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
149151
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
150152
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -183,6 +185,8 @@ github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
183185
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
184186
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
185187
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
188+
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
189+
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
186190
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
187191
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
188192
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=

0 commit comments

Comments
 (0)