Astro-Hotline (Linux-Magazin, August 2022)

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.

Wahrscheinlicher Ablauf

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.

Protokollführer

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.

Listing 1: zshrc.sh

    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.

Listing 2: myhist.log

    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.

Oller Trick

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.

Bash-Upgrade

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.

Listing 3: bashrc.sh

    1 [[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh

Aus Geschichte lernen

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.

Listing 4: history.go

    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.

Wahrsager mit Stütze

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.

Listing 5: predict.go

    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!

Listing 6: pick.go

    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.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/07/snapshot/

[2]

Michael Schilli, "Geschichte schreiben": Linux-Magazin 12/2020, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2020/12/snapshot-32/#/2020/12<U>

[3]

Michael Schilli, "Pfadfinder": Linux-Magazin 09/2019, S.xxx <U>https://www.linux-magazin.de/ausgaben/2019/09/snapshot-18/<U>

[4]

Bash Preexec, Zsh-style preexec hooks for bash, https://github.com/rcaloras/bash-preexec

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc