Weil Shell-Kommandos sich als Sequenzen wiederholen, minimieren intelligente Vorhersagen die Tipparbeit. Zuerst lassen wir die Shell Protokoll führen, anschließend liest uns ein Go-Programm jeden Wunsch von den Augen ab.
Beim Entwickeln neuer Snapshot-Artikel ertappe ich mich regelmäßig dabei, immer wieder dieselben Kommandos ins Terminal zu tippen. Mit Vi modifizierte Text- oder Codedateien wandern mit »git add foo.go« in den Staging-Bereich, »git commit« speist sie in den örtlichen Repository-Klon ein, und »git push origin v1:v1« sichert sie auf dem Server. Neue Builds der Go-Sourcen tritt der Befehl »go build foo.go bar.go« los und so weiter. Ein solcher Haufen Tipparbeit gehört automatisiert. Dinosaurier der Softwareentwicklung wie ich sträuben sich bekanntlich gegen IDEs, also muss ein Rezept nach Hausmacherart her.
Die Shell-History findet alte Kommandos zwar, aber in dieser Riesenliste den gewünschten Befehl aufzustöbern und erneut auszuführen, erfordert Handarbeit. Die lohnt sich selten, weil das neuerliche Eingeben schneller geht, als zehn Einträge nach oben zu spulen oder einen Suchstring zu nutzen. Dabei tippt man Shell-Kommandos meist in einer vorbestimmten Reihenfolge: So editiert »vi« zunächst eine Datei, »git« sichert das Ergebnis, und »go build« kompiliert es. Entsprechend könnte ein intelligentes Tool durchaus feststellen, was üblicherweise als Nächstes kommt. Außerdem scheinen genutzte Kommandosequenzen vom Verzeichnis abzuhängen, in dem man sie ausführt. In einem Go-Projekt kommen die obigen Kommandos zum Einsatz, in einem Textprojekt wie einem Snapshot-Artikel vielleicht andere wie »make publish« zum Erzeugen von HTML- oder PDF-Dateien.
Hätte nun ein Tool Zugriff auf die historische Abfolge bereits abgeschickter Kommandos und auf die Verzeichnisse, aus denen der User sie angestoßen hat, könnte es schon eine gute Vorauswahl anbieten. Daraus ließe sich in 90 Prozent der Fälle innerhalb von zwei, drei Tastendrücken das nächste Kommando finden und erneut abspulen. Eine Prise künstliche Intelligenz beschleunigt und verbessert die Sache zusätzlich. Abbildung 1 zeigt ein beispielhaftes Ablaufdiagramm einer Shell-Session. Die Kanten im Graph markieren die Übergänge zwischen den Kommandos, die Prozentzahlen daneben die aus der History-Datei ermittelte Wahrscheinlichkeit, dass ein bestimmter Übergang stattfindet. Alle von einem Zustand abgehenden Pfeile addieren sich also zu 100 Prozent.
Protokollant und Orakel
Um zu analysieren, welche Kommandosequenzen der User bislang in der Shell getippt hat, braucht es erst einmal einen Prozess, der sie laufend mitschreibt. Der »history«-Mechanismus der Bash oder Z-Shell genügt dafür nicht, da er selbst im besten Fall lediglich den jeweiligen Befehl mit Datum aufzeichnet [1]. Immerhin soll das Tool später das Verzeichnis, aus dem das Kommando aufgerufen wurde, in die generierten Vorschläge miteinbeziehen.
Die neuere Z-Shell bietet dazu den Hook »preexec()« an, dem wir wie in der vierten Zeile in Listing 1 einen Funktionsrumpf zuweisen. Ihn stößt die Shell immer kurz vor dem Ausführen einer Kommandozeile an und gibt ihr als ersten Parameter deren Inhalt als String mit. Der »preexec()«-Hook ruft wiederum die direkt davor definierte Funktion »cmdhook()« auf. Sie reiht die aktuelle Uhrzeit und das gegenwärtige Verzeichnis aneinander, setzt die Kommandozeile dahinter, trennt die drei Komponenten durch Leerzeichen und hängt das Ganze als neue Zeile ans Ende der Datei »myhist.log« im Home-Verzeichnis an. Listing 2 demonstriert Einträge, die sich dort nach einiger Zeit beim Schreiben dieses Artikels angesammelt haben.
Listing 1
zshrc.sh
cmdhook() {
echo "$(date +%s) $(pwd) $1" >>~/.myhist.log;
}
preexec() { cmdhook "$1"; }
function g() {
cmd=$(pick 3>&1 1>&2 2>&3);
cmdhook "$cmd";
eval $cmd;
}
Listing 2
myhist.log
1653801083 /home/mschilli vi .zshrc 1653801106 /home/mschilli/git/articles/predict vi t.pnd 1653801863 /home/mschilli/git/articles/predict ls eg 1653801870 /home/mschilli/git/articles/predict vi ~/.myhist.log
Die fünfte Zeile in Listing 1 definiert die Shell-Funktion »g()«, die wir aufrufen, um von der Shell Vorschläge für das nächste auszuführende Kommando zu erhalten. Das Kommando soll zugunsten der Tippeffizienz nur einen Buchstaben lang sein: “g” bietet sich für “Go” an.
Setzen wir »g()« mit dem Kommando »g« gefolgt von der Eingabetaste in Bewegung, ruft die Shell-Funktion das Kommando »pick« auf (Zeile 6). Dabei handelt es sich um ein im Folgenden ab Listing 4 vorgestelltes Go-Programm, das die Protokolldatei »myhist.log« durchforstet und nach einem Algorithmus eine Liste der wahrscheinlichsten Folgekommandos ermittelt.
Daraus wählen wir mit den Pfeiltasten, den Vi-Mappings [J]+ und [K] sowie einem Druck auf die Eingabetaste das gewünschte Kommando aus (Abbildung 2). Anschließend führt die Shell das Kommando direkt aus – fingerschonender geht es kaum. Die Shell-Funktion nimmt dazu in »g()« den von »pick« zurückgegebenen Kommando-String entgegen und führt ihn über die eingebaute Funktion »eval« aus.
Ein billiger Trick
Mit einem ollen Trick aus dem Snapshot von vor drei Jahren [2] 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 einen Punkt auswählen und spuckt ihn schließlich auf Stderr aus (File Deskriptor Nummer 2). Dort muss ihn die Shell-Funktion »g()« in Listing 1 abholen. Die abenteuerliche Konstruktion »3>&1 1>&2 2>&3« (Zeile 6) lenkt dabei Stderr (Nummer 2) wieder auf Stdout (Nummer 1) um, sodass die auszuführende Kommandozeile in der Shell-Variablen »cmd« landet (Zeile 7). Zu guter Letzt nimmt dann »eval« die Variable entgegen und führt den dort enthaltenen String aus (Zeile 8).
Abbildung 2 zeigt das wahrsagende Shell-Tool in Aktion. Aus historischen Gründen schreibe ich Artikel immer noch in dem leicht an Perls POD-Format (plain old documentation) angelehnten PND-Format (plain new documentation). Nach dem Editieren des Artikeltexts in »t.pnd« rufe ich »g« auf, das basierend auf der aus »myhist.log« gelernten Shell-Historie die am wahrscheinlichsten folgenden Kommandos zur Auswahl anbietet. Das sind ein »git add« der Textdatei, ein »make« (bei mir ein Alias namens »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.
Traditionell findet sich allerdings in Linux-Distributionen statt der Z-Shell eher die Bash, 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 die Bash zu portieren [3]. Dazu installieren wir im ersten Schritt das auf Github abgelegte Shell-Skript und lassen es die Bash-Shell beim Login ausführen, indem wir die erste Zeile in Listing 3 in der Datei ».bash_profile« ablegen. Im zweiten Schritt lädt das Shell-Skript das im Home-Verzeichnis liegende Skript ».bash-preexec.sh« und führt es aus.
Listing 3
bashrc.sh
[[ -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 Hook »preexec()« in »myhist.log« mitgeschrieben hat. Listing 4 durchläuft in der Funktion »history()« die Log-Datei und macht aus jeder Zeile einen Eintrag vom Typ »HistEntry«. Diese ab Zeile 8 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 Ausführens stand).
Listing 4
history.go
package main
import (
"bufio"
"os"
"regexp"
"strings"
)
type HistEntry struct {
Cwd string
Cmd string
}
func history(histFile string) []HistEntry{
f, err := os.Open(histFile)
if err != nil {
panic(err)
}
defer f.Close()
hist := []HistEntry{}
scanner := bufio.NewScanner(f)
cmdSane := regexp.MustCompile(`^\S`)
for scanner.Scan() {
// epoch cwd cmd
flds := strings.SplitN(scanner.Text(), " ", 3)
if len(flds) != 3 ||
!cmdSane.MatchString(flds[2]) ||
flds[2] == "g" {
continue
}
hist = append(hist, HistEntry{
Cwd: flds[1], Cmd: flds[2]
})
}
if err := scanner.Err(); err != nil {
panic(err)
}
return hist
}
Der Scanner aus dem Paket bufio liest in der For-Schleife ab Zeile 21 die Zeilen der Log-Datei ein, lässt den Zeitstempel in der ersten Spalte außer Acht und prüft, ob das Kommando in der dritten Spalte ordentlich aussieht. Zusätzlich ignoriert die Schleife alle nur aus dem Kürzel »g« bestehenden Kommandos, die »preexec« zwar mitloggt, die aber letztendlich als Aufrufe des Orakels selbst nicht beim Wahrsagen weiterhelfen.
Schleicht sich ein leeres Kommando in die Log-Datei, zum Beispiel, weil der Benutzer das Orakel mit [Strg]+[C] abgebrochen hat, verwirft »continue« die fragliche Zeile. Gültige Einträge hängt »history()« als Variablen vom Typ »HistEntry« ans Ende des Array-Slices »hist« an, das »return hist« in Zeile 36 abschließend an den Aufrufer zurückgibt.
Gedächtnisstütze
Auf Basis dieser historischen Daten ermittelt nun der Vorhersager in Listing 5 in der Funktion »predict()« das im aktuellen Verzeichnis »cwd« wahrscheinlich nächste gewünschte Kommando. Es nimmt das Array-Slice mit »HistEntry«-Strukturen entgegen und arbeitet sie in der For-Schleife ab Zeile 8 der Reihe nach durch.
Listing 5
predict.go
package main
import (
"sort"
)
func predict(hist []HistEntry, cwd string) []string {
lastCmd := ""
followMap := map[string]map[string]int{}
for _, h := range hist {
if h.Cwd != cwd {
continue
}
if lastCmd == "" {
lastCmd = h.Cmd
continue
}
cmdMap, ok := followMap[lastCmd]
if !ok {
cmdMap = map[string]int{}
followMap[lastCmd] = cmdMap
}
cmdMap[h.Cmd] += 1
lastCmd = h.Cmd
}
if lastCmd == "" {
// first time in this dir
return []string{"ls"}
}
items := []string{}
follows, ok := followMap[lastCmd]
if !ok {
// no follow defined, just
// return all cmds known
for from, _ := range followMap {
items = append(items, from)
}
return items
}
// Return best-scoring follows
type score struct {
to string
weight int
}
scores := []score{}
for to, v := range follows {
scores = append(scores, score{to: to, weight: v})
}
sort.Slice(scores, func(i, j int) bool {
return scores[i].weight > scores[j].weight
})
for _, score := range scores {
items = append(items, score.to)
}
return items
}
In jeder Runde speichert es das aktuell bearbeitete und in »h.Cmd« vorliegende Shell-Kommando in der Variablen »lastCmd« ab, damit die nächste Runde der Schleife auf den Vorgänger zugreifen kann. Ab der zweiten Runde sichert der Code ab Zeile 16 in der zweistufigen Hash-Map »followMap« Informationen darüber, welches Kommando auf welches vorhergehende folgt, und zählt im dazugehörigen Integer-Wert jeweils um eins hoch. Damit steht am Ende der For-Schleife fest, wie oft Kommando B auf Kommando A folgte. Dementsprechend hoch bewertet der Algorithmus die Wahrscheinlichkeit, dass sich an die Anweisung A der Befehl B anschließt.
Steht in der mitgeschriebenen Historie nur ein einziges Kommando für das aktuelle Verzeichnis, kann der Algorithmus nicht viel machen und schlägt in Zeile 26 diplomatisch einfach »ls« vor. Führt »followMap« jedoch einige Befehle auf, die üblicherweise auf das in »lastCmd« gespeicherte Vorgängerkommando folgen, packt der Algorithmus die Folgekommandos jeweils in eine Struktur mit einem Zähler, der ihre Häufigkeit reflektiert. Er sortiert dann mit »sort.Slice()« ein Array-Slice dieser Strukturen ab Zeile 47 absteigend nach dem Zähler. So eine Hash-Map nach ihren numerischen Werten zu ordnen, wäre in einer Skriptsprache wie Python ein Klacks, aber Go verlangt 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. Das Programm »pick« aus Listing 6 bietet sie uns schließlich an.
Listing 6
pick.go
package main
import (
"fmt"
"github.com/manifoldco/promptui"
"os"
"os/user"
"path"
)
func main() {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
usr, _ := user.Current()
logFile := path.Join(usr.HomeDir, ".myhist.log")
hist := history(logFile)
items := predict(hist, cwd)
prompt := promptui.Select{
Label: "Pick next command",
Items: items,
Size: 10,
}
_, result, err := prompt.Run()
if err == nil {
fmt.Fprintf(os.Stderr, "%s\n", result)
}
}
Herausgepickt
Das Hauptprogramm in Listing 6 muss nur noch die Datei »~/.myhist.log« mit den bislang getippten samt Zeitstempel und Verzeichnis mitgeschriebenen Kommandos an »history()« aus Listing 4 übergeben und die zurückkommenden Einträge an den Vorhersager »predict()« aus Listing 5 weiterreichen. Prompt kommt eine priorisierte Liste heraus, die uns das Paket promptui (auf Github zu finden) grafisch ansprechend anzeigt.
Die Paketfunktion »Run()« interagiert mit dem User, lässt ihn mit den Pfeiltasten oder über 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 abläuft, also der User nicht etwa mit [Strg]+[C] ausgebüchst ist, gibt Zeile 25 das gewählte Kommando auf Stderr aus, von wo es die Shell-Funktion »g« aus Listing 1 aufschnappt, mitschreibt und ausführt.
Fazit
Mit dem Kommandoorakel Marke Eigenbau minimieren wir die Tipparbeit beim Entwickeln erheblich. Darüber hinaus 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-Ketten aufgemotzt zu werden. Lassen Sie ihrer Kreativität einfach freien Lauf. (csi/jlu)
Infos
- Snapshot: Mike Schilli, “Geschichte schreiben”, LM 12/2020, S. 84, https://www.lm-online.de/44612
- Snapshot: Mike Schilli, “Pfadfinder”, LM 09/2019, S. 82, https://www.lm-online.de/43120
- Bash Preexec: https://github.com/rcaloras/bash-preexec







