Beim Entwickeln neuer Snapshot-Artikel ertappe ich mich regelmäßig dabei, immer wieder die gleichen Kommandos ins Terminal zu tippen. Mit vi
modifizierte Text- oder Code-Dateien wandern mit git add foo.go
in den Staging-Bereich, werden mit git commit
in den örtlichen Repository-Klon eingespeist, und mit git push origin v1:v1
auf dem Server gesichert. Neue Builds der .go
-Sourcen tritt der Befehl go build foo.go bar.go
los, und so weiter, das ist ein Haufen Tipparbeit, die automatisiert gehört! Dinosaurier der Softwareentwicklung wie ich sträuben sich bekanntlich gegen IDEs, also muss ein Rezept nach Hausmacherart ran.
Die Shell-History findet alte Kommandos zwar, und lässt den User sie erneut abspielen, aber in dieser Riesenliste das gewünschte Kommando aufzustöbern und erneut auszuführen erfordert wiederum Tipparbeit, die sich oft nicht lohnt, weil das neuerliche Eintippen schneller geht als zehn Einträge nach oben zu spulen oder einen Suchstring einzugeben.
Dabei tippt der User Shell-Kommandos meist in einer vorbestimmten Reihenfolge. So editiert vi
zunächst eine Datei, dann sichert git
das Ergebnis, und go build
kompiliert, also könnte ein intelligentes Tool durchaus feststellen, was üblicherweise als Nächstes kommt, und langes Federlesen anbieten, das passende Kommando auszuführen. Auch scheinen genutzte Kommandosequenzen vom dem Verzeichnis abzuhängen, in denen der User sie ausführt. In einem Go-Projekt kommen die obigen Kommandos zum Einsatz, in einem Textprojekt wie einem Snapshot-Artikel vielleicht andere, wie zum Beispiel make publish
zum Erzeugen von HTML- oder PDF-Dateien.
Hätte nun ein Tool Zugriff auf die historische Abfolge bereits abgeschickter Kommandos und auch auf die Verzeichnisse, aus denen der User sie abgefeuert hat, könnte es schon mal eine gute Vorauswahl anbieten, aus der der User in 90% der Fälle innerhalb von zwei, drei Tastendrücken das nächste Kommando finden und erneut abspulen kann. Vielleicht hülfe auch noch eine Prise künstlicher Intelligenz zur Geschmackverbesserung, wer weiß! Abbildung 1 zeigt ein beispielhaftes Ablaufdiagramm einer Shell-Session. Die Kanten im Graph zeigen die Übergänge zwischen den Kommandos, die Prozentzahlen daneben die aus der History-Datei ermittelte Wahrscheinlichkeit, dass ein bestimmter Übergang stattfindet. Alle abgehenden Pfeile von einem Zustand addieren sich also zu 100 Prozent.
Abbildung 1: Typischer Workflow im Terminal während der Softwareentwicklung. |
Um zu analysieren, welche Kommandosequenzen der User bislang in der Shell getippt hat, muss erstmal ein Prozess ran, um sie laufend mitzuschreiben. Der typischen Shells with bash
oder zsh
eigene history
-Mechanismus reicht dazu nicht aus, da dieser bestenfalls nur den jeweiligen Befehl mit Datum mitschreibt ([2]), aber das Vorschlags-Tool soll später auch das Verzeichnis, aus dem das Kommando aufgerufen wurde, mit in seine generierten Vorschläge einbeziehen.
1 cmdhook() { echo "$(date +%s) $(pwd) $1" >>~/.myhist.log; } 2 preexec() { cmdhook "$1"; } 3 function g() { cmd=$(pick 3>&1 1>&2 2>&3); cmdhook "$cmd"; eval $cmd; }
Die neuere zsh
-Shell bietet dazu den Hook preexec()
an, dem der User wie in der zweiten Zeile in Listing 1 einen Funktionsrumpf zuweist, den die Shell immer kurz vor dem Abfeuern einer Kommandozeile ausführt, und ihr als ersten Parameter letztere als String mitgibt. In Listing 1 ruft der preexec()
-Hook wiederum die direkt darüber definierte Funktion cmdhook()
auf, die die aktuelle Uhrzeit und das gegenwärtige Verzeichnis aneinanderreiht, die Kommandozeile dahintersetzt und die drei Komponenten durch Leerzeichen getrennt als neue Zeile ans Ende der Datei myhist.log
im Home-Verzeichnis hängt. Listing 2 zeigt einige Einträge, die sich dort nach einiger Zeit beim Schreiben dieses Artikels angesammelt haben.
1 1653801083 /home/mschilli vi .zshrc 2 1653801106 /home/mschilli/git/articles/predict vi t.pnd 3 1653801863 /home/mschilli/git/articles/predict ls eg 4 1653801870 /home/mschilli/git/articles/predict vi ~/.myhist.log
Die dritte Zeile in Listing 1 definiert die Shell-Funktion g()
, die der User aufruft, falls er von der Shell Vorschläge für das nächste auszuführende Kommando möchte. Das Kommando soll aus Tippeffizienz nur einen Buchstaben lang sein, und "g" für "Go" bot sich an. Setzt der User g()
mit dem Kommando g
gefolgt von der Return-Taste in Bewegung, ruft die Shell-Funktion nach Listing 1 das Kommando pick
auf. Hierbei handelt es sich um das weiter unten vorgestellte Go-Programm (ab Listing 4), das die Protokolldatei myhist.log
durchforstet und nach einem weiter unten erläuterten Algorithmus eine Liste der wahrscheinlichsten Folgekommandos ermittelt. Sie bietet dem User die Vorschläge zur Auswahl an, dieser selektiert mit den Cursor-Tasten (oder vi-Mappings "j" und "k") sowie einem Druck auf die Enter-Taste das gewünschte Kommando (Abbildung 2). Hierauf führt die Shell das Kommando sogar gleich aus, fingerschonender geht es kaum. Die Shell-Funktion nimmt hierzu in g()
den von pick
zurückgegebenen Kommandostring entgegen, und bringt ihn mit der eingebauten Funktion eval
zur Ausführung.
Mit einem ollen Trick aus dem Snapshot vor drei Jahren ([3]) gibt das kompilierte Go-Programm pick
das User-Menü auf Stdout aus (File Deskriptor Nummer 1, weil die verwendete Go-Library promptui
das nicht anders kann), lässt den User eines auswählen und spuckt es anschließend auf Stderr aus (File Deskriptor Nummer 2). Von dort muss es die Shell-Funktion g()
in Listing 1 abholen, und die abenteuerliche Konstruktion 3>&1 1>&2 2>&3
biegt Stderr (Nummer 2) wieder auf Stdout (Nummer 1) um, sodass die gleich auszuführende Kommandozeile in der Shell-Variablen cmd
landet. Am Ende der Zeile frisst dann eval
die Variable und führt den dort enthaltenen String aus.
Abbildung 2 zeigt das wahrsagende Shell-Helferlein in Aktion. Aus historischen Gründen schreibe ich Artikel immer noch in dem leicht an Perls .pod
-Format ("plain old documentation") angelehntes .pnd
-Format ("plain new documentation"). Nach dem Editieren des Artikeltextes in t.pnd
ruft der User g
auf, das basierend auf der aus myhist.log
gelernten Shell-Historie die am wahrscheinlichsten folgenden Kommandos zur Auswahl anbietet: Ein "git add" der Textdatei, ein "make" (bei mir ein Alias namesn m
), um daraus einen Artikel zu generieren, ein erneutes Editieren der Datei mit vi
und schließlich das von mir oft genutzte Kommando git add -p .
, das geänderte Dateiinhalte interaktiv in den Staging-Bereich befördert.
Abbildung 2: Die Shell-Funktion g bietet eine Liste mit wahrscheinlich aufgerufenen Kommandos zur Auswahl an. |
Traditionell findet sich allerdings in Linux-Distributionen statt zsh
aber eher die ehrwürdige Bash-Shell, die den vom Protokolldienst genutzten Hook preexec
nicht anbietet. Zum Glück hat sich jedoch auf Github jemand die Mühe gemacht, diese nützliche Funktion auf bash
zu portieren ([4]). Dazu installiert der Admin das auf Github abgelegte Shell-Skript und lässt es die Bash-Shell beim Login ausführen, indem er die erste Zeile in Listing 3 in der Datei .bash_profile
ablegt, das das im Home-Verzeichnis liegende Skript .bash-preexec.sh
von Github lädt und ausführt.
1 [[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh
Der Algorithmus, der das wahrscheinlich nächste User-Kommando voraussagt, lernt aus der Reihenfolge vorher abgegebener Shell-Befehle, die der preexec-Hook in .myhist.log
mitgeschrieben hat. Listing 1 geht in der Funktion history()
durch die Logdatei und macht aus jeder Zeile einen Eintrag vom Typ HistEntry
. Diese ab Zeile 10 definierte Struktur enthält jeweils ein Attribut für die Felder Cmd
, das vom User abgesetzte Kommando, und Cwd
, das Verzeichnis, in dem die Shell zum Zeitpunkt des Abfeuerns stand. Der Scanner aus dem bufio
-Paket liest in der for
-Schleife ab Zeile 27 die Zeilen der Logdatei ein, ignoriert den Zeitstempel in der ersten Spalte, und prüft, ob das Kommando in der dritten Spalte ordentlich aussieht.
01 package main 02 03 import ( 04 "bufio" 05 "os" 06 "regexp" 07 "strings" 08 ) 09 10 type HistEntry struct { 11 Cwd string 12 Cmd string 13 } 14 15 func history(histFile string) []HistEntry { 16 f, err := os.Open(histFile) 17 if err != nil { 18 panic(err) 19 } 20 defer f.Close() 21 22 hist := []HistEntry{} 23 24 scanner := bufio.NewScanner(f) 25 cmdSane := regexp.MustCompile(`^\S`) 26 27 for scanner.Scan() { 28 // epoch cwd cmd 29 flds := strings.SplitN(scanner.Text(), " ", 3) 30 if len(flds) != 3 || 31 !cmdSane.MatchString(flds[2]) || 32 flds[2] == "g" { 33 continue 34 } 35 36 hist = append(hist, HistEntry{ 37 Cwd: flds[1], Cmd: flds[2]}) 38 } 39 40 if err := scanner.Err(); err != nil { 41 panic(err) 42 } 43 44 return hist 45 }
Weiter ignoriert die Schleife alle nur aus dem Kürzel g
bestehenden Kommandos, die der preexec
-Hook zwar mitloggt, aber die letztendlich -- als Aufrufe des Vorhersagers -- selbst nicht beim Wahrsagen weiterhelfen. Schleicht sich ein leeres Kommando in die Logdatei, zum Beispiel weil der User den Vorhersager mit Ctrl-C abgebrochen hat, verwirft Zeile 33 die Zeile, denn auch hier gibt es nichts zu lernen. Gültige Einträge hängt history()
als Variablen vom Typ HistEntry
ans Ende des Array-Slices hist
an, das Zeile 44 abschließend an den Aufrufer zurückreicht.
Gestützt auf diese historischen Daten kann nun der Vorhersager in Listing 5 in der Funktion predict()
das im aktuellen Verzeichnis cwd
das wahrscheinlich als Nächstes gewünschte Kommando ermitteln. Es nimmt das Array-Slice mit HistEntry
-Strukturen entgegen und arbeitet sie in der for
-Schleife ab Zeile 11 der Reihe nach durch.
01 package main 02 03 import ( 04 "sort" 05 ) 06 07 func predict(hist []HistEntry, cwd string) []string { 08 lastCmd := "" 09 followMap := map[string]map[string]int{} 10 11 for _, h := range hist { 12 if h.Cwd != cwd { 13 continue 14 } 15 if lastCmd == "" { 16 lastCmd = h.Cmd 17 continue 18 } 19 20 cmdMap, ok := followMap[lastCmd] 21 if !ok { 22 cmdMap = map[string]int{} 23 followMap[lastCmd] = cmdMap 24 } 25 26 cmdMap[h.Cmd] += 1 27 lastCmd = h.Cmd 28 } 29 30 if lastCmd == "" { 31 // first time in this dir 32 return []string{"ls"} 33 } 34 35 items := []string{} 36 follows, ok := followMap[lastCmd] 37 if !ok { 38 // no follow defined, just 39 // return all cmds known 40 for from, _ := range followMap { 41 items = append(items, from) 42 } 43 return items 44 } 45 46 // Return best-scoring follows 47 type score struct { 48 to string 49 weight int 50 } 51 scores := []score{} 52 for to, v := range follows { 53 scores = append(scores, score{to: to, weight: v}) 54 } 55 56 sort.Slice(scores, func(i, j int) bool { 57 return scores[i].weight > scores[j].weight 58 }) 59 60 for _, score := range scores { 61 items = append(items, score.to) 62 } 63 64 return items 65 }
In jeder Runde speichert es das aktuell bearbeitete und in h.Cmd
vorliegende Shell-Kommando in der Variablen lastCmd
ab, damit auch die nächste Runde der Schleife auf den Vorgänger zugreifen kann. Ab der zweiten Runde speichert der Code ab Zeile 20 in der zweistufigen Hash-Map followMap
Informationen darüber ab, welches Kommando auf welches vorhergehende folgt, und zählt in dem dazugehörigen Integerwert jeweils um Eins hoch. Damit steht am Ende der for
-Schleife fest, wie oft Kommando B auf Kommando A folgte, und dementsprechend hoch bewertet der Algorithmus die Wahrscheinlichkeit, dass auf A das Kommando B folgt.
Steht in der mitgeschriebenen Historie allerdings nur ein einziges Kommando für das aktuelle Verzeichnis, kann der Algorithmus nicht viel machen, und schlägt in Zeile 32 diplomatisch einfach "ls" vor. Führt followMap
allerdings einige Kommandos auf, die auf das Vorgängerkommando, das in lastCmd
liegt, üblicherweise folgen, packt der Algorithmus die Folgekommandos ab Zeile 47 jeweils in eine Struktur mit einem Zähler, der ihre Häufigkeit reflektiert, und sortiert ein Array-Slice dieser Strukturen mit sort.Slice()
ab Zeile 56 absteigend nach dem Zähler. So eine Hash-Map nach ihren numerischen Werten zu sortieren wäre in einer Scriptsprache with Python freilich ein Klacks, aber Go fordert wegen seiner strengen Typprüfung deutlich mehr Aufwand.
Heraus kommt am Ende der Funktion predict()
in der Variablen items
ein Array-Slice mit den Kommandos, die entsprechend ihrer Reihenfolge am wahrscheinlichsten auf das letzte Shell-Kommandos folgen könnten, und das Programm pick
in Listing 6 bietet sie genau so dem User zur Auswahl an. Hoffentlich ist für jeden Geschmack etwas dabei!
01 package main 02 03 import ( 04 "fmt" 05 "github.com/manifoldco/promptui" 06 "os" 07 "os/user" 08 "path" 09 ) 10 11 func main() { 12 cwd, err := os.Getwd() 13 if err != nil { 14 panic(err) 15 } 16 17 usr, _ := user.Current() 18 logFile := path.Join(usr.HomeDir, ".myhist.log") 19 20 hist := history(logFile) 21 items := predict(hist, cwd) 22 23 prompt := promptui.Select{ 24 Label: "Pick next command", 25 Items: items, 26 Size: 10, 27 } 28 _, result, err := prompt.Run() 29 if err == nil { 30 fmt.Fprintf(os.Stderr, "%s\n", result) 31 } 32 }
Das Hauptprogramm in Listing 6 muss nun nur noch die Datei ~/.myhist.log
mit den bislang erfolgten mitsamt Zeitstempel und Verzeichnis mitgeschriebenen Kommandos an history()
aus Listing 4 übergeben, die zurückkommenden Einträge an den Vorhersager predict()
aus Listing 5 reichen, und prompt kommt eine priorisierte Liste heraus, die das Paket promptui
von Github dem User grafisch ansprechend zur Auswahl anbietet. Die Paket-Funktion Run()
interagiert mit dem User, lässt ihn mit Cursortasten oder vi-Mappings einen Eintrag auswählen, räumt das Menü wieder sauber auf, und gibt den auserkorenen Befehl in der Variablen result
zurück. Falls alles fehlerfrei abgelaufen ist, also der User nicht etwa mit ctrl-c
ausgebüchst ist, gibt Zeile 30 das gewählte Kommando auf Stderr aus, von wo es die Shell-Funktion g
aus Listing 1 aufgschnappt, mitschreibt, und zur Ausführung bringt.
Schon wieder Tipparbeit gespart! Natürlich sind der Fantasie bei DIY-Projekten wie diesem keine Grenzen gesetzt. Der Algorithmus in predict()
ist noch ausgesprochen simpel und schreit geradezu danach, mit KI-Instrumenten wie Markov-Chains aufgemotzt zu werden. Lassen sie ihrer Fantasie freien Lauf.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/07/snapshot/
Michael Schilli, "Geschichte schreiben": Linux-Magazin 12/2020, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2020/12/snapshot-32/#/2020/12<U>
Michael Schilli, "Pfadfinder": Linux-Magazin 09/2019, S.xxx <U>https://www.linux-magazin.de/ausgaben/2019/09/snapshot-18/<U>
Bash Preexec, Zsh-style preexec hooks for bash, https://github.com/rcaloras/bash-preexec
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc