feat: sssh — gestionnaire de connexions SSH (CLI + TUI)
- sssh <nom> : connexion directe avec résolution par préfixe unique - sssh : TUI Bubble Tea (filtre, connexion, add/edit/delete via huh) - add/rm/edit/list/export/import en CLI, stockage TOML atomique - fusion lecture seule des hosts de ~/.ssh/config - complétion fish, démos VHS, README Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
19 files changed
+1256 −0
A
.gitignore
+3 −0
A
README.md
+91 −0
A
completions/sssh.fish
+17 −0
A
go.mod
+41 −0
A
go.sum
+86 −0
A
internal/.DS_Store
Binary file — not shown.
A
internal/connect/connect.go
+23 −0
A
internal/host/host.go
+113 −0
A
internal/sshconf/sshconf.go
+63 −0
A
internal/store/store.go
+137 −0
A
internal/tui/form.go
+100 −0
A
internal/tui/list.go
+124 −0
A
main.go
+353 −0
A
vhs/add.gif
Binary file — not shown.
A
vhs/add.tape
+31 −0
A
vhs/demo-hosts.toml
+27 −0
A
vhs/demo.gif
Binary file — not shown.
A
vhs/demo.tape
+36 −0
A
vhs/fakebin/ssh
+11 −0
.gitignore
@@ -0,0 +1,3 @@
+/sssh
+/dist/
+vhs/fakebin/sssh
README.md
@@ -0,0 +1,91 @@
+# sssh — simple-ssh
+
+Un gestionnaire de connexions SSH minimaliste : un nom court par serveur, un TUI pour choisir, et c'est tout.
+
+
+
+## Fonctionnalités
+
+- `sssh <nom>` se connecte directement — un préfixe unique suffit (`sssh ino` → `inoval`)
+- `sssh` sans argument ouvre un TUI avec filtre instantané
+- Ajout en une ligne ou via formulaire dans le TUI
+- Les hosts de `~/.ssh/config` apparaissent aussi (lecture seule)
+- Support port, clé privée, ProxyJump (qui peut référencer un autre host sssh) et tags
+- Export / import pour synchroniser entre machines
+- La connexion délègue au `ssh` système (`exec`) : agent, TTY, config… tout marche comme un ssh normal
+
+## Installation
+
+```sh
+git clone ssh://git@git.neuromancer.ovh:2222/bastien-mrq/sssh.git
+cd sssh
+go build -o sssh .
+mv sssh ~/.local/bin/ # ou n'importe où dans ton PATH
+```
+
+Complétion fish : `cp completions/sssh.fish ~/.config/fish/completions/`
+
+## Usage
+
+
+
+```sh
+sssh # TUI de sélection
+sssh serveur-1 # connexion directe
+sssh serv # préfixe unique accepté
+sssh vps-ovh uptime # exécute une commande distante
+
+sssh add vps-ovh debian@203.0.113.21 # ajout en une ligne
+sssh add api deploy@192.0.2.7:2222 -i ~/.ssh/id_deploy -J bastion -t client,prod
+sssh edit vps-ovh # formulaire d'édition
+sssh rm vps-ovh
+sssh list
+
+sssh export > hosts.toml # sauvegarde / partage
+sssh import hosts.toml # fusionne (--force pour écraser)
+```
+
+### Raccourcis du TUI
+
+| Touche | Action |
+|---|---|
+| `↑↓` / `jk` | naviguer |
+| `/` puis texte | filtrer |
+| `entrée` | se connecter |
+| `a` | ajouter un host |
+| `e` | éditer |
+| `d` | supprimer (avec confirmation) |
+| `q` | quitter |
+
+## Configuration
+
+Les hosts vivent dans `~/.config/sssh/hosts.toml` (surchargeable avec `$SSSH_CONFIG`) :
+
+```toml
+[hosts.serveur-1]
+user = "root"
+host = "192.0.2.11"
+port = 22 # optionnel
+identity = "~/.ssh/id_prod" # optionnel
+jump = "bastion" # optionnel — nom sssh ou [user@]host[:port]
+tags = ["client", "prod"] # optionnel
+```
+
+C'est un simple fichier : pour synchroniser deux machines, copie-le, ou versionne `~/.config/sssh/` dans un repo git.
+
+Les alias concrets de `~/.ssh/config` sont affichés à côté des hosts sssh, marqués `(ssh_config)`, et la connexion leur passe simplement l'alias (`ssh <alias>`), donc leur configuration s'applique telle quelle. Ils ne sont jamais modifiés par sssh. En cas de nom identique, le host sssh gagne.
+
+## Développement
+
+```sh
+go build ./...
+go vet ./...
+```
+
+Les GIFs de démo sont générés avec [VHS](https://github.com/charmbracelet/vhs) :
+
+```sh
+go build -o vhs/fakebin/sssh . && vhs vhs/demo.tape && vhs vhs/add.tape
+```
+
+Construit avec [Bubble Tea](https://github.com/charmbracelet/bubbletea), [huh](https://github.com/charmbracelet/huh) et [go-toml](https://github.com/pelletier/go-toml).
completions/sssh.fish
@@ -0,0 +1,17 @@
+# Complétion fish pour sssh — à copier dans ~/.config/fish/completions/
+complete -c sssh -f
+
+# Noms de hosts en premier argument
+complete -c sssh -n '__fish_use_subcommand' -a '(sssh list --names 2>/dev/null)' -d 'host SSH'
+
+# Sous-commandes
+complete -c sssh -n '__fish_use_subcommand' -a add -d 'Ajouter un host'
+complete -c sssh -n '__fish_use_subcommand' -a edit -d 'Éditer un host'
+complete -c sssh -n '__fish_use_subcommand' -a rm -d 'Supprimer un host'
+complete -c sssh -n '__fish_use_subcommand' -a list -d 'Lister les hosts'
+complete -c sssh -n '__fish_use_subcommand' -a export -d 'Exporter hosts.toml'
+complete -c sssh -n '__fish_use_subcommand' -a import -d 'Importer un hosts.toml'
+complete -c sssh -n '__fish_use_subcommand' -a help -d 'Aide'
+
+# Noms de hosts pour edit/rm
+complete -c sssh -n '__fish_seen_subcommand_from edit rm' -a '(sssh list --names 2>/dev/null)'
go.mod
@@ -0,0 +1,41 @@
+module git.neuromancer.ovh/bastien-mrq/sssh
+
+go 1.25.4
+
+require (
+ github.com/charmbracelet/bubbles v1.0.0
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/huh v1.0.0
+ github.com/charmbracelet/lipgloss v1.1.0
+ github.com/kevinburke/ssh_config v1.6.0
+ github.com/pelletier/go-toml/v2 v2.4.3
+)
+
+require (
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/catppuccin/go v0.3.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sahilm/fuzzy v0.1.1 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.23.0 // indirect
+)
go.sum
@@ -0,0 +1,86 @@
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
+github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
+github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
+github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
internal/.DS_Store
internal/connect/connect.go
@@ -0,0 +1,23 @@
+// Package connect lance la connexion en remplaçant le processus par ssh.
+package connect
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "syscall"
+
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/host"
+)
+
+// Exec remplace le processus courant par ssh (TTY natif, agent, etc.).
+// Ne revient jamais en cas de succès.
+func Exec(h host.Host, extra []string) error {
+ path, err := exec.LookPath("ssh")
+ if err != nil {
+ return fmt.Errorf("binaire ssh introuvable : %w", err)
+ }
+ argv := append([]string{"ssh"}, h.SSHArgs(extra...)...)
+ fmt.Fprintf(os.Stderr, "→ %s\n", h.Describe())
+ return syscall.Exec(path, argv, os.Environ())
+}
internal/host/host.go
@@ -0,0 +1,113 @@
+// Package host définit le modèle d'un host SSH enregistré.
+package host
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// Host représente une entrée SSH, provenant de hosts.toml ou de ~/.ssh/config.
+type Host struct {
+ Name string `toml:"-"`
+ User string `toml:"user,omitempty"`
+ Host string `toml:"host"`
+ Port int `toml:"port,omitempty"`
+ Identity string `toml:"identity,omitempty"`
+ Jump string `toml:"jump,omitempty"`
+ Tags []string `toml:"tags,omitempty"`
+
+ // ReadOnly indique un host issu de ~/.ssh/config, non modifiable par sssh.
+ ReadOnly bool `toml:"-"`
+}
+
+// Parse construit un Host depuis une cible de la forme [user@]host[:port].
+func Parse(name, target string) (Host, error) {
+ h := Host{Name: name}
+ if i := strings.LastIndex(target, "@"); i >= 0 {
+ h.User = target[:i]
+ target = target[i+1:]
+ }
+ if i := strings.LastIndex(target, ":"); i >= 0 && !strings.Contains(target, "]") {
+ port, err := strconv.Atoi(target[i+1:])
+ if err != nil {
+ return h, fmt.Errorf("port invalide dans %q", target)
+ }
+ h.Port = port
+ target = target[:i]
+ }
+ if target == "" {
+ return h, errors.New("hôte vide")
+ }
+ h.Host = target
+ return h, nil
+}
+
+// Target rend la cible ssh, ex. "inoval@51.210.14.32".
+func (h Host) Target() string {
+ t := h.Host
+ if h.User != "" {
+ t = h.User + "@" + t
+ }
+ return t
+}
+
+// JumpSpec rend la forme [user@]host[:port] utilisable après -J.
+func (h Host) JumpSpec() string {
+ s := h.Target()
+ if h.Port != 0 && h.Port != 22 {
+ s += ":" + strconv.Itoa(h.Port)
+ }
+ return s
+}
+
+// SSHArgs construit les arguments à passer au binaire ssh.
+// Pour un host de ~/.ssh/config, on passe juste son alias : ssh applique
+// lui-même sa configuration.
+func (h Host) SSHArgs(extra ...string) []string {
+ if h.ReadOnly {
+ return append([]string{h.Name}, extra...)
+ }
+ var args []string
+ if h.Port != 0 && h.Port != 22 {
+ args = append(args, "-p", strconv.Itoa(h.Port))
+ }
+ if h.Identity != "" {
+ args = append(args, "-i", ExpandTilde(h.Identity))
+ }
+ if h.Jump != "" {
+ args = append(args, "-J", h.Jump)
+ }
+ args = append(args, h.Target())
+ return append(args, extra...)
+}
+
+// Describe rend une description courte pour les listes (TUI, sssh list).
+func (h Host) Describe() string {
+ d := h.Target()
+ if h.Port != 0 && h.Port != 22 {
+ d += ":" + strconv.Itoa(h.Port)
+ }
+ if len(h.Tags) > 0 {
+ d += " #" + strings.Join(h.Tags, " #")
+ }
+ if h.ReadOnly {
+ d += " (ssh_config)"
+ }
+ return d
+}
+
+// ExpandTilde remplace un préfixe ~/ par le home de l'utilisateur.
+func ExpandTilde(p string) string {
+ if p == "~" || strings.HasPrefix(p, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return p
+ }
+ return filepath.Join(home, strings.TrimPrefix(p[1:], "/"))
+ }
+ return p
+}
internal/sshconf/sshconf.go
@@ -0,0 +1,63 @@
+// Package sshconf lit ~/.ssh/config en lecture seule pour afficher
+// les hosts existants à côté de ceux gérés par sssh.
+package sshconf
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ ssh_config "github.com/kevinburke/ssh_config"
+
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/host"
+)
+
+// Hosts rend les alias concrets (sans jokers) déclarés dans ~/.ssh/config.
+// Toute erreur de lecture ou de parsing donne une liste vide : ce fichier
+// n'appartient pas à sssh, on ne bloque jamais dessus.
+func Hosts() []host.Host {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil
+ }
+ f, err := os.Open(filepath.Join(home, ".ssh", "config"))
+ if err != nil {
+ return nil
+ }
+ defer f.Close()
+
+ cfg, err := ssh_config.Decode(f)
+ if err != nil {
+ return nil
+ }
+
+ var hosts []host.Host
+ for _, block := range cfg.Hosts {
+ kv := map[string]string{}
+ for _, node := range block.Nodes {
+ if n, ok := node.(*ssh_config.KV); ok {
+ kv[strings.ToLower(n.Key)] = n.Value
+ }
+ }
+ for _, p := range block.Patterns {
+ name := p.String()
+ if strings.ContainsAny(name, "*?!") {
+ continue
+ }
+ h := host.Host{Name: name, ReadOnly: true}
+ h.Host = kv["hostname"]
+ if h.Host == "" {
+ h.Host = name
+ }
+ h.User = kv["user"]
+ if v := kv["port"]; v != "" {
+ h.Port, _ = strconv.Atoi(v)
+ }
+ h.Identity = kv["identityfile"]
+ h.Jump = kv["proxyjump"]
+ hosts = append(hosts, h)
+ }
+ }
+ return hosts
+}
internal/store/store.go
@@ -0,0 +1,137 @@
+// Package store gère la persistance des hosts dans hosts.toml.
+package store
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/pelletier/go-toml/v2"
+
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/host"
+)
+
+type fileFormat struct {
+ Hosts map[string]host.Host `toml:"hosts"`
+}
+
+// Store est le contenu chargé de hosts.toml.
+type Store struct {
+ path string
+ Hosts map[string]host.Host
+}
+
+// Path rend le chemin du fichier de configuration, surchargé par $SSSH_CONFIG.
+func Path() string {
+ if p := os.Getenv("SSSH_CONFIG"); p != "" {
+ return p
+ }
+ base, err := os.UserConfigDir()
+ if err != nil {
+ base = filepath.Join(os.Getenv("HOME"), ".config")
+ }
+ return filepath.Join(base, "sssh", "hosts.toml")
+}
+
+// Load lit hosts.toml ; un fichier absent donne un store vide.
+func Load() (*Store, error) {
+ s := &Store{path: Path(), Hosts: map[string]host.Host{}}
+ data, err := os.ReadFile(s.path)
+ if os.IsNotExist(err) {
+ return s, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var f fileFormat
+ if err := toml.Unmarshal(data, &f); err != nil {
+ return nil, fmt.Errorf("%s : %w", s.path, err)
+ }
+ if f.Hosts != nil {
+ s.Hosts = f.Hosts
+ }
+ return s, nil
+}
+
+// Save écrit le fichier de façon atomique (fichier temporaire puis rename).
+func (s *Store) Save() error {
+ data, err := s.Marshal()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
+ return err
+ }
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}
+
+// Marshal sérialise le store au format hosts.toml.
+func (s *Store) Marshal() ([]byte, error) {
+ return toml.Marshal(fileFormat{Hosts: s.Hosts})
+}
+
+// Get rend le host nommé, avec son Name renseigné.
+func (s *Store) Get(name string) (host.Host, bool) {
+ h, ok := s.Hosts[name]
+ if ok {
+ h.Name = name
+ }
+ return h, ok
+}
+
+// Set ajoute ou remplace un host.
+func (s *Store) Set(h host.Host) {
+ s.Hosts[h.Name] = h
+}
+
+// Remove supprime un host ; rend false s'il n'existait pas.
+func (s *Store) Remove(name string) bool {
+ if _, ok := s.Hosts[name]; !ok {
+ return false
+ }
+ delete(s.Hosts, name)
+ return true
+}
+
+// List rend les hosts triés par nom.
+func (s *Store) List() []host.Host {
+ hosts := make([]host.Host, 0, len(s.Hosts))
+ for name, h := range s.Hosts {
+ h.Name = name
+ hosts = append(hosts, h)
+ }
+ sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name })
+ return hosts
+}
+
+// Resolve cherche name parmi hosts : correspondance exacte d'abord,
+// sinon préfixe unique. En cas d'ambiguïté, l'erreur liste les candidats.
+func Resolve(hosts []host.Host, name string) (host.Host, error) {
+ var candidates []host.Host
+ for _, h := range hosts {
+ if h.Name == name {
+ return h, nil
+ }
+ if strings.HasPrefix(h.Name, name) {
+ candidates = append(candidates, h)
+ }
+ }
+ switch len(candidates) {
+ case 1:
+ return candidates[0], nil
+ case 0:
+ return host.Host{}, fmt.Errorf("host inconnu : %q (voir sssh list)", name)
+ default:
+ names := make([]string, len(candidates))
+ for i, h := range candidates {
+ names[i] = h.Name
+ }
+ return host.Host{}, fmt.Errorf("%q est ambigu : %s", name, strings.Join(names, ", "))
+ }
+}
internal/tui/form.go
@@ -0,0 +1,100 @@
+package tui
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/charmbracelet/huh"
+
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/host"
+)
+
+// ErrAborted est rendu quand l'utilisateur annule un formulaire.
+var ErrAborted = errors.New("annulé")
+
+// HostForm ouvre le formulaire d'ajout/édition et modifie h en place.
+// oldName est vide pour un ajout ; exists vérifie l'unicité du nom.
+func HostForm(h *host.Host, oldName string, exists func(string) bool) error {
+ name := h.Name
+ port := ""
+ if h.Port != 0 {
+ port = strconv.Itoa(h.Port)
+ }
+ tags := strings.Join(h.Tags, ",")
+
+ title := "Nouveau host"
+ if oldName != "" {
+ title = "Édition de " + oldName
+ }
+
+ form := huh.NewForm(huh.NewGroup(
+ huh.NewInput().Title("Nom").Description(title).Value(&name).
+ Validate(func(s string) error {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return errors.New("le nom est obligatoire")
+ }
+ if strings.ContainsAny(s, " \t") {
+ return errors.New("pas d'espaces dans le nom")
+ }
+ if s != oldName && exists(s) {
+ return fmt.Errorf("%q existe déjà", s)
+ }
+ return nil
+ }),
+ huh.NewInput().Title("Hôte (IP ou domaine)").Value(&h.Host).
+ Validate(func(s string) error {
+ if strings.TrimSpace(s) == "" {
+ return errors.New("l'hôte est obligatoire")
+ }
+ return nil
+ }),
+ huh.NewInput().Title("Utilisateur").Value(&h.User),
+ huh.NewInput().Title("Port (défaut 22)").Value(&port).
+ Validate(func(s string) error {
+ if strings.TrimSpace(s) == "" {
+ return nil
+ }
+ if _, err := strconv.Atoi(strings.TrimSpace(s)); err != nil {
+ return errors.New("port numérique attendu")
+ }
+ return nil
+ }),
+ huh.NewInput().Title("Clé privée (optionnel)").Placeholder("~/.ssh/id_ed25519").Value(&h.Identity),
+ huh.NewInput().Title("Jump host / ProxyJump (optionnel)").Value(&h.Jump),
+ huh.NewInput().Title("Tags, séparés par des virgules (optionnel)").Value(&tags),
+ ))
+
+ if err := form.Run(); err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ return ErrAborted
+ }
+ return err
+ }
+
+ h.Name = strings.TrimSpace(name)
+ h.Host = strings.TrimSpace(h.Host)
+ h.User = strings.TrimSpace(h.User)
+ h.Identity = strings.TrimSpace(h.Identity)
+ h.Jump = strings.TrimSpace(h.Jump)
+ h.Port = 0
+ if p := strings.TrimSpace(port); p != "" {
+ h.Port, _ = strconv.Atoi(p)
+ }
+ h.Tags = nil
+ for _, t := range strings.Split(tags, ",") {
+ if t = strings.TrimSpace(t); t != "" {
+ h.Tags = append(h.Tags, t)
+ }
+ }
+ return nil
+}
+
+// Confirm pose une question oui/non ; rend false si l'utilisateur annule.
+func Confirm(question string) bool {
+ ok := false
+ err := huh.NewConfirm().Title(question).Affirmative("Oui").Negative("Non").Value(&ok).Run()
+ return err == nil && ok
+}
internal/tui/list.go
@@ -0,0 +1,124 @@
+// Package tui fournit la liste interactive et les formulaires de sssh.
+package tui
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/host"
+)
+
+// Action est ce que l'utilisateur a demandé en quittant la liste.
+type Action int
+
+const (
+ ActionQuit Action = iota
+ ActionConnect
+ ActionAdd
+ ActionEdit
+ ActionDelete
+)
+
+// Result porte l'action choisie et le host concerné le cas échéant.
+type Result struct {
+ Action Action
+ Host host.Host
+}
+
+type item struct{ h host.Host }
+
+func (i item) Title() string { return i.h.Name }
+func (i item) Description() string { return i.h.Describe() }
+func (i item) FilterValue() string {
+ v := i.h.Name + " " + i.h.Host
+ for _, t := range i.h.Tags {
+ v += " " + t
+ }
+ return v
+}
+
+var docStyle = lipgloss.NewStyle().Margin(1, 2)
+
+type model struct {
+ list list.Model
+ result Result
+}
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ fw, fh := docStyle.GetFrameSize()
+ m.list.SetSize(msg.Width-fw, msg.Height-fh)
+
+ case tea.KeyMsg:
+ if m.list.FilterState() == list.Filtering {
+ break
+ }
+ switch msg.String() {
+ case "enter":
+ if it, ok := m.list.SelectedItem().(item); ok {
+ m.result = Result{Action: ActionConnect, Host: it.h}
+ return m, tea.Quit
+ }
+ case "a":
+ m.result = Result{Action: ActionAdd}
+ return m, tea.Quit
+ case "e", "d":
+ it, ok := m.list.SelectedItem().(item)
+ if !ok {
+ break
+ }
+ if it.h.ReadOnly {
+ return m, m.list.NewStatusMessage("host en lecture seule (~/.ssh/config)")
+ }
+ action := ActionEdit
+ if msg.String() == "d" {
+ action = ActionDelete
+ }
+ m.result = Result{Action: action, Host: it.h}
+ return m, tea.Quit
+ case "q", "ctrl+c":
+ m.result = Result{Action: ActionQuit}
+ return m, tea.Quit
+ }
+ }
+
+ var cmd tea.Cmd
+ m.list, cmd = m.list.Update(msg)
+ return m, cmd
+}
+
+func (m model) View() string {
+ return docStyle.Render(m.list.View())
+}
+
+// Run affiche la liste des hosts et rend l'action choisie.
+func Run(hosts []host.Host) (Result, error) {
+ items := make([]list.Item, len(hosts))
+ for i, h := range hosts {
+ items[i] = item{h}
+ }
+
+ l := list.New(items, list.NewDefaultDelegate(), 0, 0)
+ l.Title = "sssh"
+ l.SetStatusBarItemName("host", "hosts")
+ l.AdditionalShortHelpKeys = func() []key.Binding {
+ return []key.Binding{
+ key.NewBinding(key.WithKeys("enter"), key.WithHelp("entrée", "connexion")),
+ key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "ajouter")),
+ key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "éditer")),
+ key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "supprimer")),
+ }
+ }
+
+ p := tea.NewProgram(model{list: l}, tea.WithAltScreen())
+ out, err := p.Run()
+ if err != nil {
+ return Result{}, err
+ }
+ return out.(model).result, nil
+}
main.go
@@ -0,0 +1,353 @@
+// sssh — simple-ssh : un gestionnaire de connexions SSH minimaliste.
+//
+// sssh TUI de sélection
+// sssh <nom> connexion directe (préfixe unique accepté)
+// sssh add ... ajout en une ligne
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "text/tabwriter"
+
+ "github.com/pelletier/go-toml/v2"
+
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/connect"
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/host"
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/sshconf"
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/store"
+ "git.neuromancer.ovh/bastien-mrq/sssh/internal/tui"
+)
+
+const version = "0.1.0"
+
+const usage = `sssh — simple-ssh
+
+Usage :
+ sssh ouvre le TUI de sélection
+ sssh <nom> [cmd...] se connecte au host (préfixe unique accepté)
+ sssh add <nom> <[user@]host[:port]> [-p port] [-i clé] [-J jump] [-t tags]
+ sssh edit <nom> édite un host (formulaire)
+ sssh rm <nom> supprime un host
+ sssh list liste les hosts (--names : noms seuls)
+ sssh export [fichier] exporte hosts.toml (stdout par défaut)
+ sssh import <fichier> importe en fusionnant (--force pour écraser)
+ sssh help | version
+
+Fichier : ` + "`$SSSH_CONFIG`" + ` ou ~/.config/sssh/hosts.toml
+Les hosts de ~/.ssh/config apparaissent aussi (lecture seule).`
+
+func main() {
+ if err := run(os.Args[1:]); err != nil {
+ fmt.Fprintln(os.Stderr, "sssh :", err)
+ os.Exit(1)
+ }
+}
+
+func run(args []string) error {
+ if len(args) == 0 {
+ return runTUI()
+ }
+ switch args[0] {
+ case "add":
+ return cmdAdd(args[1:])
+ case "rm", "remove":
+ return cmdRm(args[1:])
+ case "edit":
+ return cmdEdit(args[1:])
+ case "list", "ls":
+ return cmdList(args[1:])
+ case "export":
+ return cmdExport(args[1:])
+ case "import":
+ return cmdImport(args[1:])
+ case "help", "-h", "--help":
+ fmt.Println(usage)
+ return nil
+ case "version", "-V", "--version":
+ fmt.Println("sssh", version)
+ return nil
+ default:
+ return cmdConnect(args[0], args[1:])
+ }
+}
+
+// allHosts fusionne les hosts du store et ceux de ~/.ssh/config
+// (le store gagne en cas de nom identique), triés par nom.
+func allHosts(st *store.Store) []host.Host {
+ hosts := st.List()
+ for _, h := range sshconf.Hosts() {
+ if _, ok := st.Get(h.Name); !ok {
+ hosts = append(hosts, h)
+ }
+ }
+ sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name })
+ return hosts
+}
+
+// resolveJump remplace un jump qui référence un host sssh par sa cible réelle.
+func resolveJump(st *store.Store, h *host.Host) {
+ if h.Jump == "" || h.ReadOnly {
+ return
+ }
+ if j, ok := st.Get(h.Jump); ok {
+ h.Jump = j.JumpSpec()
+ }
+}
+
+func cmdConnect(name string, extra []string) error {
+ st, err := store.Load()
+ if err != nil {
+ return err
+ }
+ h, err := store.Resolve(allHosts(st), name)
+ if err != nil {
+ return err
+ }
+ resolveJump(st, &h)
+ return connect.Exec(h, extra)
+}
+
+func runTUI() error {
+ for {
+ st, err := store.Load()
+ if err != nil {
+ return err
+ }
+ res, err := tui.Run(allHosts(st))
+ if err != nil {
+ return err
+ }
+
+ switch res.Action {
+ case tui.ActionQuit:
+ return nil
+
+ case tui.ActionConnect:
+ h := res.Host
+ resolveJump(st, &h)
+ return connect.Exec(h, nil)
+
+ case tui.ActionAdd:
+ h := host.Host{}
+ if err := editForm(st, &h, ""); err != nil {
+ return err
+ }
+
+ case tui.ActionEdit:
+ h := res.Host
+ if err := editForm(st, &h, res.Host.Name); err != nil {
+ return err
+ }
+
+ case tui.ActionDelete:
+ if tui.Confirm(fmt.Sprintf("Supprimer %s (%s) ?", res.Host.Name, res.Host.Target())) {
+ st.Remove(res.Host.Name)
+ if err := st.Save(); err != nil {
+ return err
+ }
+ }
+ }
+ }
+}
+
+// editForm ouvre le formulaire, puis persiste (gère aussi le renommage).
+// Une annulation du formulaire n'est pas une erreur.
+func editForm(st *store.Store, h *host.Host, oldName string) error {
+ exists := func(name string) bool { _, ok := st.Get(name); return ok }
+ if err := tui.HostForm(h, oldName, exists); err != nil {
+ if errors.Is(err, tui.ErrAborted) {
+ return nil
+ }
+ return err
+ }
+ if oldName != "" && oldName != h.Name {
+ st.Remove(oldName)
+ }
+ st.Set(*h)
+ return st.Save()
+}
+
+func cmdAdd(args []string) error {
+ if len(args) < 2 {
+ return errors.New("usage : sssh add <nom> <[user@]host[:port]> [-p port] [-i clé] [-J jump] [-t tags]")
+ }
+ name, target := args[0], args[1]
+
+ fs := flag.NewFlagSet("add", flag.ContinueOnError)
+ port := fs.Int("p", 0, "port")
+ identity := fs.String("i", "", "clé privée")
+ jump := fs.String("J", "", "jump host (ProxyJump)")
+ tags := fs.String("t", "", "tags séparés par des virgules")
+ if err := fs.Parse(args[2:]); err != nil {
+ return err
+ }
+
+ st, err := store.Load()
+ if err != nil {
+ return err
+ }
+ if _, ok := st.Get(name); ok {
+ return fmt.Errorf("%q existe déjà (sssh edit %s pour le modifier)", name, name)
+ }
+
+ h, err := host.Parse(name, target)
+ if err != nil {
+ return err
+ }
+ if *port != 0 {
+ h.Port = *port
+ }
+ h.Identity = *identity
+ h.Jump = *jump
+ for _, t := range strings.Split(*tags, ",") {
+ if t = strings.TrimSpace(t); t != "" {
+ h.Tags = append(h.Tags, t)
+ }
+ }
+
+ st.Set(h)
+ if err := st.Save(); err != nil {
+ return err
+ }
+ fmt.Printf("✓ %s ajouté (%s)\n", h.Name, h.Describe())
+ return nil
+}
+
+func cmdRm(args []string) error {
+ if len(args) != 1 {
+ return errors.New("usage : sssh rm <nom>")
+ }
+ st, err := store.Load()
+ if err != nil {
+ return err
+ }
+ if !st.Remove(args[0]) {
+ return fmt.Errorf("host inconnu : %q", args[0])
+ }
+ if err := st.Save(); err != nil {
+ return err
+ }
+ fmt.Printf("✓ %s supprimé\n", args[0])
+ return nil
+}
+
+func cmdEdit(args []string) error {
+ if len(args) != 1 {
+ return errors.New("usage : sssh edit <nom>")
+ }
+ st, err := store.Load()
+ if err != nil {
+ return err
+ }
+ h, ok := st.Get(args[0])
+ if !ok {
+ return fmt.Errorf("host inconnu : %q", args[0])
+ }
+ return editForm(st, &h, args[0])
+}
+
+func cmdList(args []string) error {
+ st, err := store.Load()
+ if err != nil {
+ return err
+ }
+ hosts := allHosts(st)
+
+ if len(args) > 0 && args[0] == "--names" {
+ for _, h := range hosts {
+ fmt.Println(h.Name)
+ }
+ return nil
+ }
+
+ if len(hosts) == 0 {
+ fmt.Println("Aucun host. Ajoute-en un : sssh add <nom> <user@host>")
+ return nil
+ }
+ w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "NOM\tCIBLE\tPORT\tTAGS\tSOURCE")
+ for _, h := range hosts {
+ port := ""
+ if h.Port != 0 && h.Port != 22 {
+ port = fmt.Sprint(h.Port)
+ }
+ source := "sssh"
+ if h.ReadOnly {
+ source = "ssh_config"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
+ h.Name, h.Target(), port, strings.Join(h.Tags, ","), source)
+ }
+ return w.Flush()
+}
+
+func cmdExport(args []string) error {
+ st, err := store.Load()
+ if err != nil {
+ return err
+ }
+ data, err := st.Marshal()
+ if err != nil {
+ return err
+ }
+ if len(args) > 0 {
+ if err := os.WriteFile(args[0], data, 0o600); err != nil {
+ return err
+ }
+ fmt.Printf("✓ %d hosts exportés vers %s\n", len(st.Hosts), args[0])
+ return nil
+ }
+ _, err = os.Stdout.Write(data)
+ return err
+}
+
+func cmdImport(args []string) error {
+ force := false
+ var path string
+ for _, a := range args {
+ if a == "--force" || a == "-f" {
+ force = true
+ } else {
+ path = a
+ }
+ }
+ if path == "" {
+ return errors.New("usage : sssh import <fichier> [--force]")
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var in struct {
+ Hosts map[string]host.Host `toml:"hosts"`
+ }
+ if err := toml.Unmarshal(data, &in); err != nil {
+ return fmt.Errorf("%s : %w", path, err)
+ }
+
+ st, err := store.Load()
+ if err != nil {
+ return err
+ }
+ added, skipped := 0, 0
+ for name, h := range in.Hosts {
+ if _, ok := st.Get(name); ok && !force {
+ skipped++
+ continue
+ }
+ h.Name = name
+ st.Set(h)
+ added++
+ }
+ if err := st.Save(); err != nil {
+ return err
+ }
+ fmt.Printf("✓ %d importés, %d ignorés (déjà présents, --force pour écraser)\n", added, skipped)
+ return nil
+}
vhs/add.gif
vhs/add.tape
@@ -0,0 +1,31 @@
+# Démo one-liner : add, list, connexion directe par préfixe.
+# Générer depuis la racine du repo : go build -o vhs/fakebin/sssh . && vhs vhs/add.tape
+Output vhs/add.gif
+
+Set Shell "bash"
+Set FontSize 18
+Set Width 1000
+Set Height 560
+Set Padding 16
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+
+Hide
+Type `export PATH="$PWD/vhs/fakebin:$PATH" SSSH_CONFIG="$(mktemp -d)/hosts.toml" HOME="$(mktemp -d)" PS1='$ ' && sssh import vhs/demo-hosts.toml >/dev/null && clear`
+Enter
+Show
+
+Type "sssh add api deploy@203.0.113.7:2222 -t client,prod"
+Sleep 300ms
+Enter
+Sleep 1.2s
+
+Type "sssh list"
+Sleep 300ms
+Enter
+Sleep 2s
+
+Type "sssh api"
+Sleep 400ms
+Enter
+Sleep 4s
vhs/demo-hosts.toml
@@ -0,0 +1,27 @@
+[hosts]
+[hosts.bastion]
+user = "admin"
+host = "203.0.113.10"
+tags = ["infra"]
+
+[hosts.pi-home]
+user = "pi"
+host = "192.168.1.42"
+port = 2222
+
+[hosts.serveur-1]
+user = "root"
+host = "192.0.2.11"
+tags = ["client", "prod"]
+
+[hosts.serveur-2]
+user = "deploy"
+host = "192.0.2.12"
+jump = "bastion"
+tags = ["staging"]
+
+[hosts.vps-ovh]
+user = "debian"
+host = "203.0.113.21"
+identity = "~/.ssh/id_deploy"
+tags = ["perso"]
vhs/demo.gif
vhs/demo.tape
@@ -0,0 +1,36 @@
+# Démo principale : TUI, filtre, connexion.
+# Générer depuis la racine du repo : go build -o vhs/fakebin/sssh . && vhs vhs/demo.tape
+Output vhs/demo.gif
+
+Set Shell "bash"
+Set FontSize 18
+Set Width 1000
+Set Height 560
+Set Padding 16
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 80ms
+
+Hide
+Type `export PATH="$PWD/vhs/fakebin:$PATH" SSSH_CONFIG="$PWD/vhs/demo-hosts.toml" HOME="$(mktemp -d)" PS1='$ ' && clear`
+Enter
+Show
+
+Type "sssh"
+Sleep 500ms
+Enter
+Sleep 1.5s
+
+Down
+Sleep 600ms
+Down
+Sleep 800ms
+
+Type "/"
+Sleep 400ms
+Type "vps"
+Sleep 800ms
+Enter
+Sleep 600ms
+
+Enter
+Sleep 4s
vhs/fakebin/ssh
@@ -0,0 +1,11 @@
+#!/bin/sh
+# Faux ssh pour les démos VHS : simule une connexion sans réseau.
+for a in "$@"; do target="$a"; done
+sleep 0.5
+echo "Linux vps-01 6.1.0-18-amd64 #1 SMP Debian x86_64"
+echo ""
+echo "Last login: Wed Aug 12 09:14:02 2026 from 192.0.2.50"
+sleep 0.3
+printf '%s:~$ ' "${target%%@*}@vps-01"
+sleep 1.5
+echo ""