Repository: samuell/scicommander (language: python)

Issue:
Automatic detection of input- and output-files
Based on discussions with people at [SBW2023](http://sbw2023.eu/), we figured that in order to avoid the need to mark input- and output files with `i:` and `o:`, we should be able to do the following:

1. Check all substrings (separated by spaces) of current command if they correspond to an existing file.
2. If such a file exists and a corresponding audit file exists with the same command as the current one, identify it as an existing output file and skip running the current command. (i.e. skip the rest of the points below).
3. If the file instead lacks an audit file or has an audit file with a different command, identify it as an input file.
4. Create a temporary directory where the command can run.
5. Link all input files identified in 3) into the directory with symlinks
6. Run the command in the directory
7. Run a glob command in the directory to find all newly created files, and identify these as new output files
8. Create audit files for all of these (with the executed command and other info)
9. Move all output files to their final paths
10. Clear the temporary directory

Done.


Discussion / hints:
One caveat here, since output files are only detected after the command has run:

A pre-existing file with a different command in it's audit file might be mistakenly treated as an input file when in fact it might in reality be more like a pre-existing output file.

The way one seems to be able to treat this is to make all the symlinks to input files write protected, so that pre-existing files are not overwritten by accident... since it seems they really should not be, if the command is not exactly the same. 

Fix patch:
diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml
new file mode 100644
index 0000000..940626c
--- /dev/null
+++ b/.github/workflows/go-ci.yml
@@ -0,0 +1,19 @@
+name: Go Build and Test
+on: [push]
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+      - name: Setup Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: '1.21.x'
+      - name: Install dependencies
+        run: go get ./...
+      - name: Build
+        run: go build -v ./...
+      - name: Test all Go code
+        run: go test ./...
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f8b4990
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,10 @@
+.PHONY := build
+
+build:
+	go build ./...
+
+build-tiny:
+	tinygo build --no-debug -o $(BINARY_NAME).tiny
+
+test:
+	go test ./...
diff --git a/README.md b/README.md
index 8331f5b..90d59bf 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,24 @@
 SciCommander
 ============
 
-[![Run Python Tests](https://github.com/samuell/scicommander/actions/workflows/python-run-tests.yml/badge.svg)](https://github.com/samuell/scicommander/actions/workflows/python-run-tests.yml)
+[![Build and test Go code](https://github.com/samuell/scicommander/actions/workflows/go-build-and-test.yml/badge.svg)](https://github.com/samuell/scicommander/actions/workflows/go-build-and-test.yml)
 
 This is a small tool that executes single shell commands in a scientifically
 more reproducible and robust way, by doing the following things:
 
+## Features
+
 - Auditing: Creating an audit log of most output files
 - Caching: Skipping executions where output files already exist
-- (Coming soon): Atomic writes - Writes files to a temporary location until
-  command is finished
+
+## Roadmap
+
+There are also some further features that are planned to be introduced further
+down the road, such as:
+
+- Atomic writes - Writes files to a temporary location (such as a sub-folder)
+  until command is finished, so that they are never placed at their final
+  destination before completely finished.
 
 ## Current status
 
@@ -20,6 +29,9 @@ more reproducible and robust way, by doing the following things:
 
 ## News
 
+- **Aug 16, 2024:** Version 0.4 released, which is a complete rewrite of the
+  tool in Go, with some cool new features such as the ability to detect output
+  files automatically.
 - **Nov 9, 2023:** Version 0.3.3 released, with a new command, `scishell`, that
   allows you to run commands more like in a normal shell (only adding  `i:`
   before input files and `o:` before output files), instead of running it
@@ -30,59 +42,59 @@ more reproducible and robust way, by doing the following things:
 
 - A unix like operating system such as Linux or Mac OS (On Windows you can use
   [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) or [MSYS2](https://www.msys2.org/))
-- Python 3.6 or higher
-- A bash shell
+- A Bash shell
 - For graph plotting for the HTML report, you need
   [GraphViz](https://graphviz.org/) and its `dot` command.
 
 ## Installation
 
+### Using Go
+
+This method assumes that you have [installed the Go toolchain](https://go.dev/doc/install).
+
 ```bash
-pip install scicommander
+go install github.com/samuell/scicommander@latest
 ```
 
-This will install the `scicmd` command into your `PATH` variable, so that it
+This will install the `sci` command into your `PATH` variable, so that it
 should be executable from your shell.
 
+(Other installation options to be added shortly)
+
 ## Usage
 
-To view the options of the `scicmd` command, execute:
+To view the options of the `sci` command, execute:
 
 ```bash
-scicmd -h
+sci -h
 ```
 
 To get the benefits from SciCommander, do the following:
 
-1. Prepend all your shell commands with the `scicmd -c` command.
+1. Prepend all your shell commands with the `sci run` command.
 2. Wrap the command itself in quotes, either `""` or `''`. This is not strictly
    required always, but will be required for example if using redirection using
    `>` or piping with `|` (Alternatively one can just add quotes around those).
-3. Wrap definitions of input fields in `{i:INPATH}` and output files in
-   `{o:OUTPATH}` for output paths.
-4. You can also just prepend input paths with `i:` and output paths with `o:`,
-   but this is a slightly less robust method, that might fail to wrap the
-   correct number of characters in some situations.
-5. Then run your script as usual.
+3. Then run your script as usual.
 
 Now you will notice that if you run your script again, it will skip all
 commands that have already finished and produced output files.
 
-You will also have files with the extension `.au.json` for every output that
-you decorated with the syntax above.
+You will also have files with the extension `.au` for every output that you
+decorated with the syntax above.
 
-To convert such an audit report into a nice HTML-report, run the following:
+To convert such an audit report into a nice HTML-report, you can run the
+following:
 
 ```bash
-scicmd --to-html <audit-file>
-
+sci to-html <audit-file>
 ```
 
 ## Example
 
 To demonstrate how you can use SciCommander, imagine that you want to write the
-following little bioinformatics pipeline, that writes some DNA and converts its
-reverse complement, as a shell script, `my_pipeline.sh`:
+following little toy bioinformatics pipeline, that writes some DNA and converts
+its reverse complement, as a shell script, `my_pipeline.sh`:
 
 ```bash
 #!/bin/bash
@@ -101,18 +113,30 @@ script like this:
 #!/bin/bash
 
 # Create a fasta file with some DNA
-scicmd -c echo AAAGCCCGTGGGGGACCTGTTC '>' o:dna.fa
+sci run echo AAAGCCCGTGGGGGACCTGTTC '>' o:dna.fa
 # Compute the complement sequence
-scicmd -c cat i:dna.fa '|' tr ACGT TGCA '>' o:dna.compl.fa
+sci run cat i:dna.fa '|' tr ACGT TGCA '>' dna.compl.fa
 # Reverse the DNA string
-scicmd -c cat i:dna.compl.fa '|' rev '>' o:dna.compl.rev.fa
+sci run cat dna.compl.fa '|' rev '>' dna.compl.rev.fa
 ```
 
-Notice how all input paths are prepended with `i:` and output paths with `o:`,
-and also that we had to wrap all pipe characters (`|`) and redirection
-characters (`>`) in quotes. This is so that they are not grabbed by bash
-immediately, but instead passed with the command to SciCommander, and executed
-as part of its execution.
+Notice that we had to wrap all pipe characters (`|`) and redirection characters
+(`>`) in quotes. This is so that they are not grabbed by bash immediately but
+instead passed with the command to SciCommander, and executed as part of its
+execution.
+
+An alternative is to encapsulate the full commands in `''`:
+
+```bash
+#!/bin/bash
+
+# Create a fasta file with some DNA
+sci run 'echo AAAGCCCGTGGGGGACCTGTTC > o:dna.fa'
+# Compute the complement sequence
+sci run 'cat i:dna.fa | tr ACGT TGCA > dna.compl.fa'
+# Reverse the DNA string
+sci run 'cat dna.compl.fa | rev > dna.compl.rev.fa'
+```
 
 Now you can run the script as usual, e.g. with:
 
@@ -124,33 +148,31 @@ Now, the files in your folder will look like this, if you list them with `ls -tr
 
 ```bash
 my_pipeline.sh
-dna.fa.au.json
+dna.fa.au
 dna.fa
-dna.compl.fa.au.json
+dna.compl.fa.au
 dna.compl.fa
-dna.compl.rev.fa.au.json
+dna.compl.rev.fa.au
 dna.compl.rev.fa
 ```
 
-Now, you see that the last `.au.json` file is `dna.compl.rev.fa.au.json`.
+Now, you see that the last `.au` file is `dna.compl.rev.fa.au`.
 
 To convert this file to HTML and view it in a browser, you can do:
 
 ```bash
-scicmd --to-html dna.compl.rev.fa.au.json
+sci to-html dna.compl.rev.fa.au
 ```
 
-Then you will see [an HTML page like this](https://htmlpreview.github.io/?https://github.com/samuell/scicommander/blob/main/python/examples/dna.compl.rev.fa.au.html)
-
 ## Experimental: Bash integration
 
-There is very early and experimental support for running SciCommander commands
-in bash, without needing to run them via the `scicmd -c` command.
+There is experimental support for running SciCommander commands in bash,
+without needing to run them via the `sci run` command.
 
 To do this, start the SciCommander shell with the following command:
 
 ```bash
-scishell
+sci shell
 ```
 
 And then, you can run the example commands above as follows:
@@ -159,16 +181,15 @@ And then, you can run the example commands above as follows:
 # Create a fasta file with some DNA
 echo AAAGCCCGTGGGGGACCTGTTC > o:dna.fa
 # Compute the complement sequence
-cat i:dna.fa | tr ACGT TGCA > o:dna.compl.fa
+cat dna.fa | tr ACGT TGCA > o:dna.compl.fa
 # Reverse the DNA string
-cat i:dna.compl.fa | rev > o:dna.compl.rev.fa
+cat dna.compl.fa | rev > o:dna.compl.rev.fa
 ```
 
-In other words, only the `i:` and `o:` markers are now needed, and no extra
-syntax.
+In other words, no extra syntax is needed.
 
 ## Notes
 
 [1] Although Nextflow and Snakemake already take care of some of the benefits,
 such as atomic writes, SciCommander adds additional features such as detailed
-per-output audit logs.
+per-output audit logs. It can thus be a great complement to these tools.
diff --git a/cmd/sci/audit.go b/cmd/sci/audit.go
new file mode 100644
index 0000000..aaff58f
--- /dev/null
+++ b/cmd/sci/audit.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+	"strings"
+	"time"
+)
+
+func NewAuditInfo(cmd string, inputs []string, outputs []string) *AuditInfo {
+	return &AuditInfo{
+		Inputs:  inputs,
+		Outputs: outputs,
+		Executors: []*Executor{&Executor{
+			Image:   "None",
+			Command: strings.Split(cmd, " "),
+		}},
+		Tags:     &SciTags{},
+		Upstream: []AuditInfo{},
+	}
+}
+
+type AuditInfo struct {
+	Inputs    []string    `json:"inputs"`
+	Outputs   []string    `json:"outputs"`
+	Executors []*Executor `json:"executors"`
+	Tags      *SciTags    `json:"tags"`
+	Upstream  []AuditInfo `json:"upstream"`
+}
+
+type Executor struct {
+	Image   string   `json:"image"`
+	Command []string `json:"command"`
+}
+
+type SciTags struct {
+	StartTime   time.Time     `json:"start_time"`
+	EndTime     time.Time     `json:"end_time"`
+	Duration    time.Duration `json:"duration"`
+	DurationSec int           `json:"duration_s"`
+}
diff --git a/cmd/sci/main.go b/cmd/sci/main.go
new file mode 100644
index 0000000..ecfc5b9
--- /dev/null
+++ b/cmd/sci/main.go
@@ -0,0 +1,356 @@
+package main
+
+import (
+	"bytes"
+	"cmp"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"slices"
+	"strings"
+	"syscall"
+	"time"
+)
+
+func init() {
+	flag.Usage = func() {
+		flag.PrintDefaults()
+		fmt.Println(`Usage:
+sci run <command>
+sci to-html <html-file>
+sci shell`)
+	}
+}
+
+func main() {
+	flag.NewFlagSet("run", flag.ExitOnError)
+	flag.NewFlagSet("to-html", flag.ExitOnError)
+	flag.NewFlagSet("shell", flag.ExitOnError)
+	flag.Parse()
+
+	if len(os.Args) < 2 {
+		fmt.Println("ERROR: No command supplied")
+		flag.Usage()
+		os.Exit(2)
+		return
+	}
+
+	switch os.Args[1] {
+	case "run":
+		cmdStr := strings.Join(os.Args[2:], " ")
+		executeCommand(cmdStr)
+	case "to-html":
+		auditPath := strings.Join(os.Args[2:], " ")
+		toHtml(auditPath)
+	case "shell":
+		runShell()
+	default:
+		fmt.Println("ERROR: Expected run or to-html")
+	}
+}
+
+func executeCommand(cmdStr string) {
+	cmdParts := strings.Split(cmdStr, " ")
+	cmdArgs := cmdParts[1:]
+
+	inFiles, _ := detectFiles(cmdArgs)
+
+	filesBefore, err := filepath.Glob("./*")
+	checkMsg(err, "Could not glob folder before executing command!")
+
+	// Execute the command
+	timeBefore := time.Now()
+
+	cmd := exec.Command("bash", "-c", cmdStr)
+
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	err = cmd.Run()
+
+	timeAfter := time.Now()
+	commandDuration := timeAfter.Sub(timeBefore)
+	errMsg := f("Could not run command: %s\nSTDERR: %s\nSTDOUT: %s", cmdStr, cmd.Stderr, cmd.Stdout)
+	checkMsg(err, errMsg)
+
+	filesAfter, err := filepath.Glob("./*")
+	checkMsg(err, "Could not glob folder after executing command!")
+
+	newFiles := []string{}
+	for _, file := range filesAfter {
+		if !slices.Contains(filesBefore, file) {
+			newFiles = append(newFiles, file)
+		}
+	}
+
+	for _, newFile := range newFiles {
+		newAuditFile := newFile + ".au"
+		auditInfo := NewAuditInfo(cmdStr, inFiles, newFiles)
+		auditInfo.Tags.StartTime = timeBefore
+		auditInfo.Tags.EndTime = timeAfter
+		auditInfo.Tags.Duration = commandDuration
+
+		auditJson, jsonErr := json.MarshalIndent(auditInfo, "", "    ")
+		checkMsg(jsonErr, "Could not marshall JSON")
+
+		writeErr := ioutil.WriteFile(newAuditFile, auditJson, 0644)
+		checkMsg(writeErr, f("Could not write audit file: %v", newAuditFile))
+	}
+}
+
+func detectFiles(strs []string) ([]string, []string) {
+	inFiles := []string{}
+	outFiles := []string{}
+
+	filtered := []string{}
+	nonPaths := []string{">", "|", ">>", ">>>", "<", "<<", "<<<"}
+	for _, s := range strs {
+		if !slices.Contains(nonPaths, s) {
+			filtered = append(filtered, s)
+		}
+	}
+
+	for _, str := range filtered {
+		if _, err := os.Stat(str); os.IsNotExist(err) {
+			outFiles = append(outFiles, str)
+		} else {
+			auditPath := str + ".au"
+			if _, err := os.Stat(auditPath); os.IsNotExist(err) {
+				inFiles = append(inFiles, str)
+			} else {
+				inFiles = append(inFiles, str)
+			}
+		}
+	}
+	return inFiles, outFiles
+}
+
+func toHtml(auditPath string) {
+	auditInfos := getAllUpstreamAuditInfos(auditPath)
+
+	outPath := auditPath[0 : len(auditPath)-3]
+	dotPath := auditPath + ".dot"
+	svgPath := auditPath + ".svg"
+
+	dotStr := generateDot(auditInfos)
+	os.WriteFile(dotPath, []byte(dotStr), 0644)
+
+	dotCmdStr := f("dot -Tsvg %s > %s", dotPath, svgPath)
+	executeExternalCommand(dotCmdStr)
+
+	html := auditInfosToHTML(auditInfos, outPath, svgPath)
+	htmlPath := auditPath + ".html"
+	writeErr := ioutil.WriteFile(htmlPath, []byte(html), 0644)
+	checkMsg(writeErr, f("Error writing file %s", htmlPath))
+	fmt.Printf("Wrote HTML file to %s\n", htmlPath)
+}
+
+func runShell() {
+	exe, lookErr := exec.LookPath("bash")
+	checkMsg(lookErr, "Could not find command \"bash\"")
+
+	tempScriptPath := ".scishell.bash"
+
+	shellCode := `#!/bin/bash -l
+echo "Starting SciCommander Shell"
+echo "(Exit by pressing Ctrl+C)"
+echo "------------------------------------------------"
+while true; do
+    read -ep "sci> " cmd
+    if [[ $cmd == $'\04' ]]; then
+        exit
+    elif [[ $cmd =~ (ls|ll|pwd|cd|vim|emacs|nano|less|more).* ]]; then
+        $cmd
+    elif [[ $cmd == "" ]]; then
+        echo "Command was emtpy. Did you want to exit?"
+        echo "Exit by pressing: Ctrl+C"
+    else
+        echo "Executing command via SciCommander: $cmd"
+        sci run "$cmd"
+    fi
+done;
+echo "Exited SciCommander Shell"
+`
+	wrtErr := os.WriteFile(tempScriptPath, []byte(shellCode), 0644)
+	checkMsg(wrtErr, f("Could not write %s", tempScriptPath))
+
+	args := []string{"-i", tempScriptPath}
+
+	env := os.Environ()
+	execErr := syscall.Exec(exe, args, env)
+	checkMsg(execErr, f("Could not execute command: %s %v", exe, args))
+}
+
+// getAllUpstreamAuditInfos takes a path to an audit info file and returns the
+// auditinfo object it corresponds to, together with all auditinfo objects from
+// upstream tasks
+func getAllUpstreamAuditInfos(auditPath string) []AuditInfo {
+	baseDir := filepath.Dir(auditPath)
+	auditInfoMap := getInputAuditInfos(auditPath, baseDir)
+
+	auditInfos := []AuditInfo{}
+	for _, auditInfo := range auditInfoMap {
+		auditInfos = append(auditInfos, auditInfo)
+	}
+
+	return auditInfos
+}
+
+func getInputAuditInfos(auditPath string, baseDir string) map[string]AuditInfo {
+	auditInfos := map[string]AuditInfo{}
+	auditInfo := unmarshalAuditInfo(auditPath)
+	auditInfos[auditPath] = auditInfo
+
+	// Recursively call this same method
+	for _, inputPath := range auditInfo.Inputs {
+		fullInputPath := filepath.Join(baseDir, inputPath+".au")
+		inputAuditInfos := getInputAuditInfos(fullInputPath, baseDir)
+		for inputPath, inputAuditInfo := range inputAuditInfos {
+			auditInfos[inputPath] = inputAuditInfo
+		}
+	}
+	return auditInfos
+}
+
+func generateDot(auditInfos []AuditInfo) string {
+	nodes, edges := generateGraph(auditInfos)
+
+	dot := "DIGRAPH G {\n"
+	dot += "  node [shape=box, style=filled, fillcolor=lightgrey, fontname=monospace, penwidth=0];\n"
+	for _, node := range nodes {
+		dot += f("  \"%s\"\n", node)
+	}
+	for _, edge := range edges {
+		dot += f("  \"%s\" -> \"%s\"\n", edge.a, edge.b)
+	}
+	dot += "}"
+
+	return dot
+}
+
+type StringTuple struct {
+	a string
+	b string
+}
+
+func (st StringTuple) String() string {
+	return st.a + "," + st.b
+}
+
+func generateGraph(auditInfos []AuditInfo) (nodes []string, edges []StringTuple) {
+	nodesSet := map[string]interface{}{}
+	edgesSet := map[string]StringTuple{}
+	for _, auditInfo := range auditInfos {
+		commandStr := strings.ReplaceAll(strings.Join(auditInfo.Executors[0].Command, " "), "\"", "\\\"")
+		nodesSet[commandStr] = nil
+		if len(auditInfo.Inputs) > 0 {
+			for _, input := range auditInfo.Inputs {
+				edge := StringTuple{string(input), commandStr}
+				edgesSet[edge.String()] = edge
+				nodesSet[string(input)] = nil
+			}
+		}
+		if len(auditInfo.Outputs) > 0 {
+			for _, output := range auditInfo.Outputs {
+				edge := StringTuple{commandStr, string(output)}
+				edgesSet[edge.String()] = edge
+				nodesSet[string(output)] = nil
+			}
+		}
+	}
+	for node, _ := range nodesSet {
+		nodes = append(nodes, node)
+	}
+	for _, edge := range edgesSet {
+		edges = append(edges, edge)
+	}
+	return
+}
+
+func unmarshalAuditInfo(auditPath string) AuditInfo {
+	auditJson, readErr := ioutil.ReadFile(auditPath)
+	checkMsg(readErr, f("Error reading file %s", auditPath))
+	var auditInfo AuditInfo
+	err := json.Unmarshal([]byte(auditJson), &auditInfo)
+	checkMsg(err, "Failed to unmarshal JSON file "+auditPath)
+	return auditInfo
+}
+
+func auditInfosToHTML(auditInfos []AuditInfo, outPath string, svgPath string) (html string) {
+	svgRaw, err := os.ReadFile(svgPath)
+	svg := string(svgRaw)
+	checkMsg(err, "Could not read SVG file: "+svgPath)
+	html = "<html>\n"
+	html += "<head>\n"
+	html += "<style>\n"
+	html += `
+body{
+	font-family:monospace, courier new;
+	max-width: 1024px;
+	margin: 0 auto;
+	box-shadow: 2px 2px 10px #ccc;
+	padding: 1em;\n
+}
+hr {
+	border: 2px solid #eee;
+}
+table {
+	borders: none;
+}
+table td {
+	padding: 1em;
+}
+`
+	html += "</style>\n"
+	html += "</head>\n"
+	html += "<body>\n"
+	html += f("<h1>SciCommander Audit Report for %s<h1>\n", outPath)
+	html += "<hr>\n"
+	html += "<table>\n"
+	html += "<tr><th>Start time</th><th>Command</th><th>Duration</th></tr>\n"
+
+	slices.SortFunc(auditInfos, func(a, b AuditInfo) int {
+		return cmp.Compare(a.Tags.StartTime.Format(time.RFC3339), b.Tags.StartTime.Format(time.RFC3339))
+	})
+
+	for _, auditInfo := range auditInfos {
+		command := strings.Join(auditInfo.Executors[0].Command, " ")
+		html += f("<tr><td>%s</td>"+
+			"<td style='background: #efefef;'>%s</td>"+
+			"<td>%d ms</tr>"+
+			"\n", auditInfo.Tags.StartTime.Format(time.RFC3339), command, auditInfo.Tags.Duration.Milliseconds())
+	}
+	html += "</table>"
+	html += "<hr>\n"
+	html += svg + "\n"
+	html += "<hr>\n"
+	html += "</body>\n"
+	html += "</html>\n"
+	return html
+}
+
+func executeExternalCommand(cmd string) {
+	out, err := exec.Command("bash", "-c", cmd).CombinedOutput()
+	checkMsg(err, f("Command failed!\nCommand:\n%s\n\nOutput:\n%s", cmd, out))
+}
+
+func checkMsg(err error, message string) {
+	if err != nil {
+		fmt.Println(message)
+		fmt.Println(err)
+		os.Exit(126)
+	}
+}
+
+func f(s string, v ...interface{}) string {
+	return fmt.Sprintf(s, v...)
+}
+
+func out(s string, v ...interface{}) {
+	fmt.Printf(s+"\n", v...)
+}
diff --git a/python/examples/dna.compl.rev.fa.au.html b/examples/dna.compl.rev.fa.au.html
similarity index 85%
rename from python/examples/dna.compl.rev.fa.au.html
rename to examples/dna.compl.rev.fa.au.html
index ebc186a..46042b9 100644
--- a/python/examples/dna.compl.rev.fa.au.html
+++ b/examples/dna.compl.rev.fa.au.html
@@ -1,13 +1,35 @@
 <html>
-<body style='font-family:monospace, courier new'>
-<h1>SciCommander Audit Report<h1>
+<head>
+<style>
+
+body{
+	font-family:monospace, courier new;
+	max-width: 1024px;
+	margin: 0 auto;
+	box-shadow: 2px 2px 10px #ccc;
+	padding: 1em;\n
+}
+hr {
+	border: 2px solid #eee;
+}
+table {
+	borders: none;
+}
+table td {
+	padding: 1em;
+}
+</style>
+</head>
+<body>
+<h1>SciCommander Audit Report for dna.compl.rev.fa<h1>
 <hr>
-<table borders='none' cellpadding='8px'>
+<table>
 <tr><th>Start time</th><th>Command</th><th>Duration</th></tr>
-<tr><td>2023-08-12T20:16:01.490396Z</td><td style='background: #efefef;'>echo AAAGCCCGTGGGGGACCTGTTC > dna.fa</td><td>0-00:00:00.000849</tr>
-<tr><td>2023-08-12T20:16:01.513834Z</td><td style='background: #efefef;'>cat dna.fa | tr ACGT TGCA > dna.compl.fa</td><td>0-00:00:00.001332</tr>
-<tr><td>2023-08-12T20:16:01.538348Z</td><td style='background: #efefef;'>cat dna.compl.fa | rev > dna.compl.rev.fa</td><td>0-00:00:00.001290</tr>
-</table><hr><?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<tr><td>2024-08-16T17:49:03+02:00</td><td style='background: #efefef;'>cat dna.compl.fa | rev > dna.compl.rev.fa</td><td>4 ms</tr>
+<tr><td>2024-08-16T17:49:03+02:00</td><td style='background: #efefef;'>cat dna.fa | tr ACGT TGCA > dna.compl.fa</td><td>5 ms</tr>
+<tr><td>2024-08-16T17:49:03+02:00</td><td style='background: #efefef;'>echo AAAGCCCGTGGGGGACCTGTTC > dna.fa</td><td>2 ms</tr>
+</table><hr>
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
 <!-- Generated by graphviz version 2.43.0 (0)
@@ -18,23 +40,11 @@ <h1>SciCommander Audit Report<h1>
 <g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 400)">
 <title>G</title>
 <polygon fill="white" stroke="transparent" points="-4,4 -4,-400 359,-400 359,4 -4,4"/>
-<!-- echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa -->
+<!-- dna.compl.rev.fa -->
 <g id="node1" class="node">
-<title>echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa</title>
-<polygon fill="lightgrey" stroke="black" stroke-width="0" points="334.5,-396 20.5,-396 20.5,-360 334.5,-360 334.5,-396"/>
-<text text-anchor="middle" x="177.5" y="-374.3" font-family="monospace" font-size="14.00">echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa</text>
-</g>
-<!-- dna.fa -->
-<g id="node3" class="node">
-<title>dna.fa</title>
-<polygon fill="lightgrey" stroke="black" stroke-width="0" points="210.5,-324 144.5,-324 144.5,-288 210.5,-288 210.5,-324"/>
-<text text-anchor="middle" x="177.5" y="-302.3" font-family="monospace" font-size="14.00">dna.fa</text>
-</g>
-<!-- echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa&#45;&gt;dna.fa -->
-<g id="edge3" class="edge">
-<title>echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa&#45;&gt;dna.fa</title>
-<path fill="none" stroke="black" d="M177.5,-359.7C177.5,-351.98 177.5,-342.71 177.5,-334.11"/>
-<polygon fill="black" stroke="black" points="181,-334.1 177.5,-324.1 174,-334.1 181,-334.1"/>
+<title>dna.compl.rev.fa</title>
+<polygon fill="lightgrey" stroke="black" stroke-width="0" points="252,-36 103,-36 103,0 252,0 252,-36"/>
+<text text-anchor="middle" x="177.5" y="-14.3" font-family="monospace" font-size="14.00">dna.compl.rev.fa</text>
 </g>
 <!-- cat dna.fa | tr ACGT TGCA &gt; dna.compl.fa -->
 <g id="node2" class="node">
@@ -43,49 +53,62 @@ <h1>SciCommander Audit Report<h1>
 <text text-anchor="middle" x="177.5" y="-230.3" font-family="monospace" font-size="14.00">cat dna.fa | tr ACGT TGCA &gt; dna.compl.fa</text>
 </g>
 <!-- dna.compl.fa -->
-<g id="node4" class="node">
+<g id="node6" class="node">
 <title>dna.compl.fa</title>
 <polygon fill="lightgrey" stroke="black" stroke-width="0" points="235.5,-180 119.5,-180 119.5,-144 235.5,-144 235.5,-180"/>
 <text text-anchor="middle" x="177.5" y="-158.3" font-family="monospace" font-size="14.00">dna.compl.fa</text>
 </g>
 <!-- cat dna.fa | tr ACGT TGCA &gt; dna.compl.fa&#45;&gt;dna.compl.fa -->
-<g id="edge4" class="edge">
+<g id="edge2" class="edge">
 <title>cat dna.fa | tr ACGT TGCA &gt; dna.compl.fa&#45;&gt;dna.compl.fa</title>
 <path fill="none" stroke="black" d="M177.5,-215.7C177.5,-207.98 177.5,-198.71 177.5,-190.11"/>
 <polygon fill="black" stroke="black" points="181,-190.1 177.5,-180.1 174,-190.1 181,-190.1"/>
 </g>
+<!-- dna.fa -->
+<g id="node3" class="node">
+<title>dna.fa</title>
+<polygon fill="lightgrey" stroke="black" stroke-width="0" points="210.5,-324 144.5,-324 144.5,-288 210.5,-288 210.5,-324"/>
+<text text-anchor="middle" x="177.5" y="-302.3" font-family="monospace" font-size="14.00">dna.fa</text>
+</g>
 <!-- dna.fa&#45;&gt;cat dna.fa | tr ACGT TGCA &gt; dna.compl.fa -->
 <g id="edge1" class="edge">
 <title>dna.fa&#45;&gt;cat dna.fa | tr ACGT TGCA &gt; dna.compl.fa</title>
 <path fill="none" stroke="black" d="M177.5,-287.7C177.5,-279.98 177.5,-270.71 177.5,-262.11"/>
 <polygon fill="black" stroke="black" points="181,-262.1 177.5,-252.1 174,-262.1 181,-262.1"/>
 </g>
+<!-- echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa -->
+<g id="node4" class="node">
+<title>echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa</title>
+<polygon fill="lightgrey" stroke="black" stroke-width="0" points="334.5,-396 20.5,-396 20.5,-360 334.5,-360 334.5,-396"/>
+<text text-anchor="middle" x="177.5" y="-374.3" font-family="monospace" font-size="14.00">echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa</text>
+</g>
+<!-- echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa&#45;&gt;dna.fa -->
+<g id="edge3" class="edge">
+<title>echo AAAGCCCGTGGGGGACCTGTTC &gt; dna.fa&#45;&gt;dna.fa</title>
+<path fill="none" stroke="black" d="M177.5,-359.7C177.5,-351.98 177.5,-342.71 177.5,-334.11"/>
+<polygon fill="black" stroke="black" points="181,-334.1 177.5,-324.1 174,-334.1 181,-334.1"/>
+</g>
 <!-- cat dna.compl.fa | rev &gt; dna.compl.rev.fa -->
-<g id="node6" class="node">
+<g id="node5" class="node">
 <title>cat dna.compl.fa | rev &gt; dna.compl.rev.fa</title>
 <polygon fill="lightgrey" stroke="black" stroke-width="0" points="355,-108 0,-108 0,-72 355,-72 355,-108"/>
 <text text-anchor="middle" x="177.5" y="-86.3" font-family="monospace" font-size="14.00">cat dna.compl.fa | rev &gt; dna.compl.rev.fa</text>
 </g>
-<!-- dna.compl.fa&#45;&gt;cat dna.compl.fa | rev &gt; dna.compl.rev.fa -->
-<g id="edge2" class="edge">
-<title>dna.compl.fa&#45;&gt;cat dna.compl.fa | rev &gt; dna.compl.rev.fa</title>
-<path fill="none" stroke="black" d="M177.5,-143.7C177.5,-135.98 177.5,-126.71 177.5,-118.11"/>
-<polygon fill="black" stroke="black" points="181,-118.1 177.5,-108.1 174,-118.1 181,-118.1"/>
-</g>
-<!-- dna.compl.rev.fa -->
-<g id="node5" class="node">
-<title>dna.compl.rev.fa</title>
-<polygon fill="lightgrey" stroke="black" stroke-width="0" points="252,-36 103,-36 103,0 252,0 252,-36"/>
-<text text-anchor="middle" x="177.5" y="-14.3" font-family="monospace" font-size="14.00">dna.compl.rev.fa</text>
-</g>
 <!-- cat dna.compl.fa | rev &gt; dna.compl.rev.fa&#45;&gt;dna.compl.rev.fa -->
 <g id="edge5" class="edge">
 <title>cat dna.compl.fa | rev &gt; dna.compl.rev.fa&#45;&gt;dna.compl.rev.fa</title>
 <path fill="none" stroke="black" d="M177.5,-71.7C177.5,-63.98 177.5,-54.71 177.5,-46.11"/>
 <polygon fill="black" stroke="black" points="181,-46.1 177.5,-36.1 174,-46.1 181,-46.1"/>
 </g>
+<!-- dna.compl.fa&#45;&gt;cat dna.compl.fa | rev &gt; dna.compl.rev.fa -->
+<g id="edge4" class="edge">
+<title>dna.compl.fa&#45;&gt;cat dna.compl.fa | rev &gt; dna.compl.rev.fa</title>
+<path fill="none" stroke="black" d="M177.5,-143.7C177.5,-135.98 177.5,-126.71 177.5,-118.11"/>
+<polygon fill="black" stroke="black" points="181,-118.1 177.5,-108.1 174,-118.1 181,-118.1"/>
+</g>
 </g>
 </svg>
+
 <hr>
 </body>
 </html>
diff --git a/examples/reverse_complement.sh b/examples/reverse_complement.sh
new file mode 100755
index 0000000..0ed024f
--- /dev/null
+++ b/examples/reverse_complement.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+# Create a fasta file with some DNA
+sci run echo AAAGCCCGTGGGGGACCTGTTC '>' dna.fa
+
+# Compute the complement sequence
+sci run cat dna.fa '|' tr ACGT TGCA '>' dna.compl.fa
+
+# Reverse the DNA string
+sci run cat dna.compl.fa '|' rev '>' dna.compl.rev.fa
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..bbfcce4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/samuell/scicommander
+
+go 1.22.1
diff --git a/python/Makefile b/python/Makefile
deleted file mode 100644
index b725e1e..0000000
--- a/python/Makefile
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/bin/bash
-build: setup.py
-	python setup.py sdist bdist_wheel
-
-upload: dist
-	twine upload dist/*
-
-clean:
-	rm -rf dist/
diff --git a/python/examples/reverse_complement.sh b/python/examples/reverse_complement.sh
deleted file mode 100755
index 23dbccc..0000000
--- a/python/examples/reverse_complement.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/bash
-
-# Create a fasta file with some DNA
-scicmd -c echo AAAGCCCGTGGGGGACCTGTTC '>' o:dna.fa
-
-# Compute the complement sequence
-scicmd -c cat i:dna.fa '|' tr ACGT TGCA '>' o:dna.compl.fa
-
-# Reverse the DNA string
-scicmd -c cat i:dna.compl.fa '|' rev '>' o:dna.compl.rev.fa
diff --git a/python/poetry.lock b/python/poetry.lock
deleted file mode 100644
index 28728ae..0000000
--- a/python/poetry.lock
+++ /dev/null
@@ -1,841 +0,0 @@
-[[package]]
-name = "appnope"
-version = "0.1.3"
-description = "Disable App Nap on macOS >= 10.9"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
-name = "asttokens"
-version = "2.2.1"
-description = "Annotate AST trees with source code positions"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-six = "*"
-
-[package.extras]
-test = ["astroid", "pytest"]
-
-[[package]]
-name = "backcall"
-version = "0.2.0"
-description = "Specifications for callback functions passed in to an API"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
-name = "bleach"
-version = "6.0.0"
-description = "An easy safelist-based HTML-sanitizing tool."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-six = ">=1.9.0"
-webencodings = "*"
-
-[package.extras]
-css = ["tinycss2 (>=1.1.0,<1.2)"]
-
-[[package]]
-name = "build"
-version = "1.0.3"
-description = "A simple, correct Python build frontend"
-category = "dev"
-optional = false
-python-versions = ">= 3.7"
-
-[package.dependencies]
-colorama = {version = "*", markers = "os_name == \"nt\""}
-importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""}
-packaging = ">=19.0"
-pyproject_hooks = "*"
-tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
-
-[package.extras]
-docs = ["furo (>=2023.08.17)", "sphinx (>=7.0,<8.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)", "sphinx-issues (>=3.0.0)"]
-test = ["filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "wheel (>=0.36.0)", "setuptools (>=42.0.0)", "setuptools (>=56.0.0)", "setuptools (>=56.0.0)", "setuptools (>=67.8.0)"]
-typing = ["importlib-metadata (>=5.1)", "mypy (>=1.5.0,<1.6.0)", "tomli", "typing-extensions (>=3.7.4.3)"]
-virtualenv = ["virtualenv (>=20.0.35)"]
-
-[[package]]
-name = "certifi"
-version = "2023.7.22"
-description = "Python package for providing Mozilla's CA Bundle."
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-
-[[package]]
-name = "cffi"
-version = "1.15.1"
-description = "Foreign Function Interface for Python calling C code."
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-pycparser = "*"
-
-[[package]]
-name = "charset-normalizer"
-version = "3.2.0"
-description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-category = "dev"
-optional = false
-python-versions = ">=3.7.0"
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-description = "Cross-platform colored terminal text."
-category = "dev"
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
-
-[[package]]
-name = "cryptography"
-version = "41.0.3"
-description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-cffi = ">=1.12"
-
-[package.extras]
-docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
-docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
-nox = ["nox"]
-pep8test = ["black", "ruff", "mypy", "check-sdist"]
-sdist = ["build"]
-ssh = ["bcrypt (>=3.1.5)"]
-test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist", "pretend"]
-test-randomorder = ["pytest-randomly"]
-
-[[package]]
-name = "decorator"
-version = "5.1.1"
-description = "Decorators for Humans"
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-
-[[package]]
-name = "distlib"
-version = "0.3.7"
-description = "Distribution utilities"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
-name = "docutils"
-version = "0.20.1"
-description = "Docutils -- Python Documentation Utilities"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[[package]]
-name = "exceptiongroup"
-version = "1.1.2"
-description = "Backport of PEP 654 (exception groups)"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.extras]
-test = ["pytest (>=6)"]
-
-[[package]]
-name = "executing"
-version = "1.2.0"
-description = "Get the currently executing AST node of a frame, and other information"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.extras]
-tests = ["asttokens", "pytest", "littleutils", "rich"]
-
-[[package]]
-name = "filelock"
-version = "3.12.2"
-description = "A platform independent file lock."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.extras]
-docs = ["furo (>=2023.5.20)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)", "pytest (>=7.3.1)"]
-
-[[package]]
-name = "idna"
-version = "3.4"
-description = "Internationalized Domain Names in Applications (IDNA)"
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-
-[[package]]
-name = "importlib-metadata"
-version = "6.7.0"
-description = "Read metadata from Python packages"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
-zipp = ">=0.5"
-
-[package.extras]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"]
-perf = ["ipython"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-ruff", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
-
-[[package]]
-name = "importlib-resources"
-version = "5.12.0"
-description = "Read resources from Python packages"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
-
-[package.extras]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"]
-
-[[package]]
-name = "iniconfig"
-version = "2.0.0"
-description = "brain-dead simple config-ini parsing"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[[package]]
-name = "ipdb"
-version = "0.13.13"
-description = "IPython-enabled pdb"
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-
-[package.dependencies]
-decorator = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\" or python_version >= \"3.11\""}
-ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\" and python_version < \"3.11\" or python_version >= \"3.11\""}
-tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""}
-
-[[package]]
-name = "ipython"
-version = "8.14.0"
-description = "IPython: Productive Interactive Computing"
-category = "dev"
-optional = false
-python-versions = ">=3.9"
-
-[package.dependencies]
-appnope = {version = "*", markers = "sys_platform == \"darwin\""}
-backcall = "*"
-colorama = {version = "*", markers = "sys_platform == \"win32\""}
-decorator = "*"
-jedi = ">=0.16"
-matplotlib-inline = "*"
-pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""}
-pickleshare = "*"
-prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0"
-pygments = ">=2.4.0"
-stack-data = "*"
-traitlets = ">=5"
-typing-extensions = {version = "*", markers = "python_version < \"3.10\""}
-
-[package.extras]
-all = ["black", "ipykernel", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "docrepr", "matplotlib", "stack-data", "pytest (<7)", "typing-extensions", "pytest (<7.1)", "pytest-asyncio", "testpath", "nbconvert", "nbformat", "ipywidgets", "notebook", "ipyparallel", "qtconsole", "curio", "matplotlib (!=3.2.0)", "numpy (>=1.21)", "pandas", "trio"]
-black = ["black"]
-doc = ["ipykernel", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "docrepr", "matplotlib", "stack-data", "pytest (<7)", "typing-extensions", "pytest (<7.1)", "pytest-asyncio", "testpath"]
-kernel = ["ipykernel"]
-nbconvert = ["nbconvert"]
-nbformat = ["nbformat"]
-notebook = ["ipywidgets", "notebook"]
-parallel = ["ipyparallel"]
-qtconsole = ["qtconsole"]
-test = ["pytest (<7.1)", "pytest-asyncio", "testpath"]
-test_extra = ["pytest (<7.1)", "pytest-asyncio", "testpath", "curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "trio"]
-
-[[package]]
-name = "jaraco.classes"
-version = "3.2.3"
-description = "Utility functions for Python class constructs"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-more-itertools = "*"
-
-[package.extras]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
-
-[[package]]
-name = "jedi"
-version = "0.18.2"
-description = "An autocompletion tool for Python that can be used for text editors."
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-
-[package.dependencies]
-parso = ">=0.8.0,<0.9.0"
-
-[package.extras]
-docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx-rtd-theme (==0.4.3)", "sphinx (==1.8.5)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"]
-qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
-testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
-
-[[package]]
-name = "jeepney"
-version = "0.8.0"
-description = "Low-level, pure Python DBus protocol wrapper."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.extras]
-test = ["pytest", "pytest-trio", "pytest-asyncio (>=0.17)", "testpath", "trio", "async-timeout"]
-trio = ["trio", "async-generator"]
-
-[[package]]
-name = "keyring"
-version = "24.1.1"
-description = "Store and access your passwords safely."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""}
-importlib-resources = {version = "*", markers = "python_version < \"3.9\""}
-"jaraco.classes" = "*"
-jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""}
-pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
-SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
-
-[package.extras]
-completion = ["shtab"]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-ruff", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
-
-[[package]]
-name = "markdown-it-py"
-version = "2.2.0"
-description = "Python port of markdown-it. Markdown parsing, done right!"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-mdurl = ">=0.1,<1.0"
-typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
-
-[package.extras]
-benchmarking = ["psutil", "pytest", "pytest-benchmark"]
-code_style = ["pre-commit (>=3.0,<4.0)"]
-compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
-linkify = ["linkify-it-py (>=1,<3)"]
-plugins = ["mdit-py-plugins"]
-profiling = ["gprof2dot"]
-rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx-book-theme"]
-testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
-
-[[package]]
-name = "matplotlib-inline"
-version = "0.1.6"
-description = "Inline Matplotlib backend for Jupyter"
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-
-[package.dependencies]
-traitlets = "*"
-
-[[package]]
-name = "mdurl"
-version = "0.1.2"
-description = "Markdown URL utilities"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[[package]]
-name = "more-itertools"
-version = "9.1.0"
-description = "More routines for operating on iterables, beyond itertools"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[[package]]
-name = "packaging"
-version = "23.1"
-description = "Core utilities for Python packages"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[[package]]
-name = "parso"
-version = "0.8.3"
-description = "A Python Parser"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-
-[package.extras]
-qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
-testing = ["docopt", "pytest (<6.0.0)"]
-
-[[package]]
-name = "pexpect"
-version = "4.8.0"
-description = "Pexpect allows easy control of interactive console applications."
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-ptyprocess = ">=0.5"
-
-[[package]]
-name = "pickleshare"
-version = "0.7.5"
-description = "Tiny 'shelve'-like database with concurrency support"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
-name = "pkginfo"
-version = "1.9.6"
-description = "Query metadata from sdists / bdists / installed packages."
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-
-[package.extras]
-testing = ["pytest", "pytest-cov"]
-
-[[package]]
-name = "platformdirs"
-version = "3.11.0"
-description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.8\""}
-
-[package.extras]
-docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.24)", "sphinx (>=7.1.1)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest (>=7.4)"]
-
-[[package]]
-name = "pluggy"
-version = "1.2.0"
-description = "plugin and hook calling mechanisms for python"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
-
-[package.extras]
-dev = ["pre-commit", "tox"]
-testing = ["pytest", "pytest-benchmark"]
-
-[[package]]
-name = "prompt-toolkit"
-version = "3.0.38"
-description = "Library for building powerful interactive command lines in Python"
-category = "dev"
-optional = false
-python-versions = ">=3.7.0"
-
-[package.dependencies]
-wcwidth = "*"
-
-[[package]]
-name = "ptyprocess"
-version = "0.7.0"
-description = "Run a subprocess in a pseudo terminal"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
-name = "pure-eval"
-version = "0.2.2"
-description = "Safely evaluate AST nodes without side effects"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.extras]
-tests = ["pytest"]
-
-[[package]]
-name = "pycparser"
-version = "2.21"
-description = "C parser in Python"
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-
-[[package]]
-name = "pygments"
-version = "2.15.1"
-description = "Pygments is a syntax highlighting package written in Python."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.extras]
-plugins = ["importlib-metadata"]
-
-[[package]]
-name = "pyproject-hooks"
-version = "1.0.0"
-description = "Wrappers to call pyproject.toml-based build backend hooks."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
-
-[[package]]
-name = "pytest"
-version = "7.4.0"
-description = "pytest: simple powerful testing with Python"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-colorama = {version = "*", markers = "sys_platform == \"win32\""}
-exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
-importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
-iniconfig = "*"
-packaging = "*"
-pluggy = ">=0.12,<2.0"
-tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
-
-[package.extras]
-testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
-
-[[package]]
-name = "pywin32-ctypes"
-version = "0.2.2"
-description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-
-[[package]]
-name = "readme-renderer"
-version = "37.3"
-description = "readme_renderer is a library for rendering \"readme\" descriptions for Warehouse"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-bleach = ">=2.1.0"
-docutils = ">=0.13.1"
-Pygments = ">=2.5.1"
-
-[package.extras]
-md = ["cmarkgfm (>=0.8.0)"]
-
-[[package]]
-name = "requests"
-version = "2.31.0"
-description = "Python HTTP for Humans."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-certifi = ">=2017.4.17"
-charset-normalizer = ">=2,<4"
-idna = ">=2.5,<4"
-urllib3 = ">=1.21.1,<3"
-
-[package.extras]
-socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
-
-[[package]]
-name = "requests-toolbelt"
-version = "1.0.0"
-description = "A utility belt for advanced users of python-requests"
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-
-[package.dependencies]
-requests = ">=2.0.1,<3.0.0"
-
-[[package]]
-name = "rfc3986"
-version = "2.0.0"
-description = "Validating URI References per RFC 3986"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.extras]
-idna2008 = ["idna"]
-
-[[package]]
-name = "rich"
-version = "13.5.3"
-description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
-category = "dev"
-optional = false
-python-versions = ">=3.7.0"
-
-[package.dependencies]
-markdown-it-py = ">=2.2.0"
-pygments = ">=2.13.0,<3.0.0"
-typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
-
-[package.extras]
-jupyter = ["ipywidgets (>=7.5.1,<9)"]
-
-[[package]]
-name = "secretstorage"
-version = "3.3.3"
-description = "Python bindings to FreeDesktop.org Secret Service API"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-
-[package.dependencies]
-cryptography = ">=2.0"
-jeepney = ">=0.6"
-
-[[package]]
-name = "six"
-version = "1.16.0"
-description = "Python 2 and 3 compatibility utilities"
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
-
-[[package]]
-name = "stack-data"
-version = "0.6.2"
-description = "Extract data from python stack frames and tracebacks for informative displays"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-asttokens = ">=2.1.0"
-executing = ">=1.2.0"
-pure-eval = "*"
-
-[package.extras]
-tests = ["pytest", "typeguard", "pygments", "littleutils", "cython"]
-
-[[package]]
-name = "tomli"
-version = "2.0.1"
-description = "A lil' TOML parser"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[[package]]
-name = "traitlets"
-version = "5.9.0"
-description = "Traitlets Python configuration system"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.extras]
-docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
-test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"]
-
-[[package]]
-name = "twine"
-version = "4.0.2"
-description = "Collection of utilities for publishing packages on PyPI"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-importlib-metadata = ">=3.6"
-keyring = ">=15.1"
-pkginfo = ">=1.8.1"
-readme-renderer = ">=35.0"
-requests = ">=2.20"
-requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0"
-rfc3986 = ">=1.4.0"
-rich = ">=12.0.0"
-urllib3 = ">=1.26.0"
-
-[[package]]
-name = "typing-extensions"
-version = "4.8.0"
-description = "Backported and Experimental Type Hints for Python 3.8+"
-category = "dev"
-optional = false
-python-versions = ">=3.8"
-
-[[package]]
-name = "urllib3"
-version = "2.0.4"
-description = "HTTP library with thread-safe connection pooling, file post, and more."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
-secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
-socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
-zstd = ["zstandard (>=0.18.0)"]
-
-[[package]]
-name = "virtualenv"
-version = "20.24.6"
-description = "Virtual Python Environment builder"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.dependencies]
-distlib = ">=0.3.7,<1"
-filelock = ">=3.12.2,<4"
-importlib-metadata = {version = ">=6.6", markers = "python_version < \"3.8\""}
-platformdirs = ">=3.9.1,<4"
-
-[package.extras]
-docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx-argparse (>=0.4)", "sphinx (>=7.1.2)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage-enable-subprocess (>=1)", "coverage (>=7.2.7)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "pytest (>=7.4)", "setuptools (>=68)", "time-machine (>=2.10)"]
-
-[[package]]
-name = "wcwidth"
-version = "0.2.6"
-description = "Measures the displayed width of unicode strings in a terminal"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
-name = "webencodings"
-version = "0.5.1"
-description = "Character encoding aliases for legacy web content"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
-name = "zipp"
-version = "3.15.0"
-description = "Backport of pathlib-compatible object wrapper for zip files"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-
-[package.extras]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "jaraco.functools", "more-itertools", "big-o", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"]
-
-[metadata]
-lock-version = "1.1"
-python-versions = ">=3.7"
-content-hash = "c0a64129d55e23754886fa61602367ed844ef1990f622eb006372c17458edfc4"
-
-[metadata.files]
-appnope = []
-asttokens = []
-backcall = []
-bleach = []
-build = []
-certifi = []
-cffi = []
-charset-normalizer = []
-colorama = []
-cryptography = []
-decorator = []
-distlib = []
-docutils = []
-exceptiongroup = []
-executing = []
-filelock = []
-idna = []
-importlib-metadata = []
-importlib-resources = []
-iniconfig = []
-ipdb = []
-ipython = []
-"jaraco.classes" = []
-jedi = []
-jeepney = []
-keyring = []
-markdown-it-py = []
-matplotlib-inline = []
-mdurl = []
-more-itertools = []
-packaging = []
-parso = []
-pexpect = []
-pickleshare = []
-pkginfo = []
-platformdirs = []
-pluggy = []
-prompt-toolkit = []
-ptyprocess = []
-pure-eval = []
-pycparser = []
-pygments = []
-pyproject-hooks = []
-pytest = []
-pywin32-ctypes = []
-readme-renderer = []
-requests = []
-requests-toolbelt = []
-rfc3986 = []
-rich = []
-secretstorage = []
-six = []
-stack-data = []
-tomli = []
-traitlets = []
-twine = []
-typing-extensions = []
-urllib3 = []
-virtualenv = []
-wcwidth = []
-webencodings = []
-zipp = []
diff --git a/python/pyproject.toml b/python/pyproject.toml
deleted file mode 100644
index 361f573..0000000
--- a/python/pyproject.toml
+++ /dev/null
@@ -1,20 +0,0 @@
-[tool.poetry]
-name = "scicommander"
-version = "0.3.4"
-description = ""
-authors = ["Samuel Lampa <samuel.lampa@scilifelab.se>"]
-
-[tool.poetry.dependencies]
-python = ">=3.7"
-
-[tool.poetry.dev-dependencies]
-ipython = "^8.14.0"
-ipdb = "^0.13.13"
-pytest = "^7.4.0"
-twine = "^4.0.2"
-build = "^1.0.3"
-virtualenv = "^20.24.6"
-
-[build-system]
-requires = ["poetry-core>=1.0.0"]
-build-backend = "poetry.core.masonry.api"
diff --git a/python/scicommander/__init__.py b/python/scicommander/__init__.py
deleted file mode 100644
index a7162ca..0000000
--- a/python/scicommander/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-# Indicating that this folder is a python package
diff --git a/python/scicommander/scicmd.py b/python/scicommander/scicmd.py
deleted file mode 100644
index c13282b..0000000
--- a/python/scicommander/scicmd.py
+++ /dev/null
@@ -1,283 +0,0 @@
-#!/usr/bin/env python
-import argparse
-import datetime as dt
-import json
-import sys
-import os
-import re
-import subprocess as sub
-
-# Define commandline arguments
-argp = argparse.ArgumentParser()
-argp.add_argument(
-    "--merge-audit-files",
-    "-m",
-    action="store_true",
-    help="Whether to merge in upstream audit files, to create fully self-contained audit trails",
-)
-argp.add_argument("--command", "-c", nargs="...")
-argp.add_argument("--to-html", "-th", metavar="AUDIT_FILE")
-args = argp.parse_args()
-
-
-def main():
-    if not args.command and not args.to_html:
-        argp.print_usage()
-        print("Either --command or --to-html must be specified!")
-        return
-
-    # Only write HTML report and finish
-    if args.to_html:
-        audit_path = args.to_html
-        tasks = collect_audit_info(audit_path)
-        write_html_report(tasks, audit_path)
-        return
-
-    command = " ".join(args.command)
-    parse_and_execute(command)
-
-
-def parse_and_execute(command):
-    # Capture input paths
-    input_paths = []
-    input_matches = get_input_matches(command)
-    for ph, path in input_matches:
-        input_paths.append(path)
-
-    # Capture output paths
-    output_paths = []
-    output_matches = get_output_matches(command)
-    for ph, path in output_matches:
-        output_paths.append(path)
-
-    # Check if paths already exist
-    for path in output_paths:
-        if os.path.exists(path):
-            print(f"Skipping: {command} (Output exists: {path})")
-            return
-
-    # Replace placeholders with only the path
-    matches = input_matches + output_matches
-    for ph, path in matches:
-        command = command.replace(ph, path)
-
-    # Execute command
-    start_time = dt.datetime.now()
-    stdout, stderr, retcode = execute_command(command)
-    end_time = dt.datetime.now()
-
-    # Write AuditInfo file(s)
-    write_audit_files(
-        command,
-        input_paths,
-        output_paths,
-        start_time,
-        end_time,
-        args.merge_audit_files,
-    )
-
-
-def generate_dot_graph(tasks):
-    nodes, edges = generate_graph(tasks)
-
-    dot = "DIGRAPH G {\n"
-    dot += "  node [shape=box, style=filled, fillcolor=lightgrey, fontname=monospace, penwidth=0];"
-    for node in nodes:
-        dot += f' "{node}"\n'
-    for edge in edges:
-        dot += f'  "{edge[0]}" -> "{edge[1]}"\n'
-    dot += "}"
-
-    return dot
-
-
-def generate_graph(tasks):
-    nodes = []
-    edges = []
-    for task in tasks:
-        # fmt: off
-        command = " ".join(task["executors"][0]["command"]).replace('"', '\\"')
-        # fmt: on
-        nodes.append(command)
-        for out_info in task["outputs"]:
-            edges.append((command, out_info["url"]))
-            nodes.append(out_info["url"])
-        for in_info in task["inputs"]:
-            edges.append((in_info["url"], command))
-            nodes.append(in_info["url"])
-    nodes = set(nodes)
-    edges = set(edges)
-    return nodes, edges
-
-
-def collect_audit_info(audit_path):
-    with open(audit_path) as aifile:
-        ai = json.load(aifile)
-    tasks = []
-
-    def add_input_audit_files(ai):
-        for in_info in ai["inputs"]:
-            input_audit_path = f"{in_info['url']}.au.json"
-            if not os.path.isfile(input_audit_path):
-                return
-
-            with open(input_audit_path) as iaupath:
-                upstream = json.load(iaupath)
-            tasks.append(upstream)
-            add_input_audit_files(upstream)
-
-    tasks.append(ai)
-    add_input_audit_files(ai)
-    tasks.sort(key=lambda x: x["tags"]["start_time"])
-
-    # Remove duplicate tasks
-    seen_commands = set()
-    unique_tasks = []
-    for task in tasks:
-        command = " ".join(task["executors"][0]["command"])
-        if command not in seen_commands:
-            seen_commands.add(command)
-            unique_tasks.append(task)
-
-    return unique_tasks
-
-
-def write_html_report(tasks, audit_path):
-    dot = generate_dot_graph(tasks)
-    dot_path = audit_path.replace(".au.json", ".au.dot")
-    svg_path = dot_path.replace(".dot", ".svg")
-
-    with open(dot_path, "w") as dotfile:
-        dotfile.write(dot)
-
-    sub.run(f"dot -Tsvg {dot_path} > {svg_path}", shell=True)
-
-    with open(svg_path) as svg_file:
-        svg = svg_file.read().strip()
-
-    html = "<html>\n"
-    html += "<body style='font-family:monospace, courier new'>\n"
-    html += "<h1>SciCommander Audit Report<h1>\n"
-    html += "<hr>\n"
-    html += "<table borders='none' cellpadding='8px'>\n"
-    html += "<tr><th>Start time</th><th>Command</th><th>Duration</th></tr>\n"
-    for task in tasks:
-        command = " ".join(task["executors"][0]["command"])
-        html += f"<tr><td>{task['tags']['start_time']}</td><td style='background: #efefef;'>{command}</td><td>{task['tags']['duration']}</tr>\n"
-    html += "</table>"
-    html += "<hr>"
-    html += svg + "\n"
-    html += "<hr>\n"
-    html += "</body>\n"
-    html += "</html>\n"
-
-    html_path = audit_path.replace(".au.json", ".au.html")
-    with open(html_path, "w") as htmlfile:
-        htmlfile.write(html)
-
-    # Open html file in browser
-    print(f"Trying to open HTML file in browser: {html_path} ...")
-    sub.run(f"open {html_path}", shell=True)
-
-
-def write_audit_files(
-    command, input_paths, output_paths, start_time, end_time, merge_audit_files
-):
-    audit_extension = ".au.json"
-
-    dur = end_time - start_time
-    d = int(dur.days)
-    h, rem = divmod(dur.seconds, 3600)
-    m, rem = divmod(rem, 60)
-    s = rem
-    mus = int(dur.microseconds)
-
-    s_float = float(f"{dur.seconds}.{dur.microseconds:06d}")
-
-    iso_datetime_fmt = "%Y-%m-%dT%H:%M:%S.%fZ"
-
-    inputs = [{"url": inpath, "path": None} for inpath in input_paths]
-    outputs = [{"url": outpath, "path": None} for outpath in output_paths]
-
-    audit_info = {
-        "inputs": inputs,
-        "outputs": outputs,
-        "executors": [
-            {
-                "image": None,
-                "command": command.split(" "),
-            }
-        ],
-        "tags": {
-            "start_time": start_time.strftime(iso_datetime_fmt),
-            "end_time": end_time.strftime(iso_datetime_fmt),
-            "duration": f"{d}-{h:02d}:{m:02d}:{s:02d}.{mus:06d}",
-            "duration_s": s_float,
-        },
-        "upstream": {},
-    }
-
-    # Merge input audits into the final one
-    if merge_audit_files:
-        for path in input_paths:
-            audit_path = f"{path}.au.json"
-            if os.path.exists(path):
-                with open(audit_path) as audit_f:
-                    upstream_audit_info = json.load(audit_f)
-                audit_info["upstream"][path] = upstream_audit_info
-
-    for path in output_paths:
-        audit_path = f"{path}.au.json"
-        with open(audit_path, "w") as audit_f:
-            json.dump(audit_info, audit_f, indent=2)
-
-
-def execute_command(command):
-    print(f"Executing: {command} ...")
-    out = sub.run(
-        command,
-        shell=True,
-        stdout=sub.PIPE,
-        stderr=sub.PIPE,
-        text=True,
-        check=True,
-    )
-    if out.stdout:
-        print(f"OUTPUT: {out.stdout}")
-    if out.stderr:
-        print(f"ERRORS: {out.stderr}")
-    if out.returncode != 0:
-        raise Exception(f"Command failed with returncode {out.returncode}: {command}")
-    return out.stdout.strip(), out.stderr.strip(), out.returncode
-
-
-def get_input_matches(text):
-    pattern = re.compile("(\{i\:([^\{\}]+)\}|i\:([^\s\(\)]+))")
-    matches = pattern.findall(text)
-    inputs = []
-    for m in matches:
-        inp = tuple(m[0])
-        if m[1]:
-            inp = m[0], m[1]
-        elif m[2]:
-            inp = m[0], m[2]
-        inputs.append(inp)
-    return inputs
-
-
-def get_output_matches(text):
-    pattern = re.compile("(\{o\:([^\{\}]+)\}|o\:([^\s\(\)]+))")
-    matches = pattern.findall(text)
-    outputs = []
-    for m in matches:
-        outp = tuple(m[0])
-        if m[1]:
-            outp = m[0], m[1]
-        elif m[2]:
-            outp = m[0], m[2]
-        outputs.append(outp)
-    return outputs
-
-
-if __name__ == "__main__":
-    main()
diff --git a/python/scripts/scishell b/python/scripts/scishell
deleted file mode 100644
index 3ff5add..0000000
--- a/python/scripts/scishell
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/bash
-cat > .scishell.tmp <<EOF
-lastcmdfile=".scishell.last";
-export PS1="\033[32mscish > \033[39m"
-shopt -s extdebug
-function preexec () {
-    cmd="\$1";
-    if [[ \$cmd == cd* || \$cmd == ls* || \$cmd == rm* || \$cmd == compopt* || \$cmd == \[\[* ]]; then
-        \$cmd;
-    else
-        lastcmd_recentfile="\$(find -mmin -1 -name \$lastcmdfile; true)";
-        # Skip executing the command if the exact same command was executed within a minute
-        if [[ -f \$lastcmd_recentfile ]]; then
-            lastcmd="\$(cat \$lastcmd_recentfile; true)"
-            if [[ "\$cmd" == "\$lastcmd" ]]; then
-                return;
-            fi;
-        fi;
-        echo "\$cmd" > \$lastcmdfile;
-        scicmd -c "\$cmd"
-    fi;
-}
-function preexec_invoke_exec () {
-    local cmd=\$(HISTTIMEFORMAT=; history 1 | sed -r 's/^[ ]+[0-9]+[ ]+//g');
-    preexec "\$cmd";
-    false; # Make sure the command is not executed once more
-}
-trap 'preexec_invoke_exec' DEBUG
-EOF
-bash --rcfile .scishell.tmp
diff --git a/python/setup.py b/python/setup.py
deleted file mode 100644
index 56af038..0000000
--- a/python/setup.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import os
-import sys
-
-try:
-    from setuptools import setup
-except:
-    from distutils.core import setup
-
-with open("../README.md") as fobj:
-    long_description = fobj.read()
-
-setup(
-    name="scicommander",
-    version="0.3.4",
-    description="A small library for executing shell commands in a reproducible way.",
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    author="Samuel Lampa",
-    author_email="samuel.lampa@scilifelab.se",
-    url="https://github.com/samuell/scicommander",
-    license="MIT",
-    keywords=["reproducibility", "shell", "bash"],
-    packages=[
-        "scicommander",
-    ],
-    classifiers=[
-        "Development Status :: 4 - Beta",
-        "Environment :: Console",
-        "Intended Audience :: Science/Research",
-        "License :: OSI Approved :: MIT License",
-        "Natural Language :: English",
-        "Operating System :: POSIX :: Linux",
-        "Programming Language :: Python",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.7",
-        "Topic :: Scientific/Engineering",
-        "Topic :: Scientific/Engineering :: Bio-Informatics",
-    ],
-    entry_points={
-        "console_scripts": [
-            "scicmd=scicommander.scicmd:main",
-        ],
-    },
-    scripts=["scripts/scishell"]
-)
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000..2665018
--- /dev/null
+++ b/todo.md
@@ -0,0 +1,20 @@
+# Coding to do
+ 
+- [x] Add basic tests
+- [x] Check all substrings (separated by spaces) of current command if they
+      correspond to an existing file.
+- [=] If such a file exists and a corresponding audit file exists with the same
+      command as the current one, identify it as an existing output file and
+      skip running the current command. (i.e. skip the rest of the points
+      below).
+- [=] If the file instead lacks an audit file or has an audit file with a
+      different command, identify it as an input file.
+- [=] Create a temporary directory where the command can run.
+- [=] Link all input files identified in 3) into the directory with symlinks
+- [x] Run the command in the directory
+- [x] Run a glob command in the directory to find all newly created files, and
+      identify these as new output files
+- [x] Create audit files for all of these (with the executed command and other
+      info)
+- [=] Move all output files to their final paths
+- [=] Clear the temporary directory


Test patch:
diff --git a/.github/workflows/python-run-tests.yml b/.github/workflows/python-run-tests.yml
deleted file mode 100644
index a93dbae..0000000
--- a/.github/workflows/python-run-tests.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-# This workflow will install Python dependencies, run tests and lint with a single version of Python
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
-
-name: Python Tests
-
-on:
-  push:
-    branches: [ "main", "dev" ]
-  pull_request:
-    branches: [ "main" ]
-
-permissions:
-  contents: read
-
-jobs:
-  build:
-
-    runs-on: ubuntu-latest
-
-    steps:
-    - uses: actions/checkout@v3
-    - name: Set up Python 3.10
-      uses: actions/setup-python@v3
-      with:
-        python-version: "3.10"
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        pip install flake8 pytest
-        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
-    - name: Lint with flake8
-      run: |
-        # stop the build if there are Python syntax errors or undefined names
-        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
-        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
-    - name: Test with pytest
-      run: |
-        cd python && pytest
diff --git a/cmd/sci/main_test.go b/cmd/sci/main_test.go
new file mode 100644
index 0000000..711620c
--- /dev/null
+++ b/cmd/sci/main_test.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+)
+
+func TestDetectFiles(t *testing.T) {
+	tmpDir := t.TempDir()
+	fmt.Printf("Moving into %v ...\n", tmpDir)
+	os.Chdir(tmpDir)
+
+	// Arrange
+	wantInFiles := []string{"foo.txt", filepath.Join("bar", "baz.xyz"), filepath.Join("bar", "xyz.abc")}
+	for _, f := range wantInFiles {
+		createDirAndFile(f)
+	}
+
+	wantOutFiles := []string{"out.png"}
+
+	stringsToCheck := []string{
+		"foo.txt",
+		"out.png",
+		">",
+		"|",
+		filepath.Join("bar", "baz.xyz"),
+		filepath.Join("bar", "xyz.abc"),
+	}
+
+	// Act
+	haveInFiles, haveOutFiles := detectFiles(stringsToCheck)
+
+	// Assert
+	if !reflect.DeepEqual(haveInFiles, wantInFiles) {
+		t.Fatalf("Wanted infiles %v but got %v\n", wantInFiles, haveInFiles)
+	}
+	if !reflect.DeepEqual(haveOutFiles, wantOutFiles) {
+		t.Fatalf("Wanted outfiles %v but got %v\n", wantOutFiles, haveOutFiles)
+	}
+}
+
+func createDirAndFile(filePath string) {
+	baseDir := filepath.Dir(filePath)
+	if _, err := os.Stat(baseDir); os.IsNotExist(err) {
+		err := os.MkdirAll(baseDir, 0744)
+		checkMsg(err, "Could not create dir: "+baseDir)
+	}
+	_, err := os.Create(filePath)
+	checkMsg(err, "Could not create file: "+filePath)
+}
diff --git a/python/tests/test_scicmd.py b/python/tests/test_scicmd.py
deleted file mode 100644
index 457ea2e..0000000
--- a/python/tests/test_scicmd.py
+++ /dev/null
@@ -1,114 +0,0 @@
-from pytest import fail
-import json
-import os
-import subprocess
-
-base_cmd = "python scicommander/scicmd.py"
-
-
-def test_create_file():
-    tmpdir = ".tmp.scicmd-test-create-file"
-    os.makedirs(tmpdir, exist_ok=True)
-    cmd = f"{base_cmd} -c 'echo hej > o:{tmpdir}/hej.txt'"
-    run_command(cmd)
-
-    # Make sure everything is as it should
-    assert os.path.isfile(f"{tmpdir}/hej.txt")
-
-    # Clean up
-    os.remove(f"{tmpdir}/hej.txt")
-    os.remove(f"{tmpdir}/hej.txt.au.json")
-    os.removedirs(tmpdir)
-
-
-def test_create_two_files():
-    tmpdir = ".tmp.scicmd-test-create-two-files"
-    os.makedirs(tmpdir, exist_ok=True)
-    cmd1 = f"{base_cmd} -c 'echo hej > o:{tmpdir}/hej.txt'"
-    run_command(cmd1)
-    cmd2 = f"{base_cmd} -c 'echo $(cat i:{tmpdir}/hej.txt) da > o:{tmpdir}/hej.da.txt'"
-    run_command(cmd2)
-
-    # Make sure everything is as it should
-    assert os.path.isfile(f"{tmpdir}/hej.txt")
-    assert os.path.isfile(f"{tmpdir}/hej.da.txt")
-
-    with open(f"{tmpdir}/hej.txt") as outfile1:
-        content = outfile1.read().strip()
-        assert content == "hej"
-
-    with open(f"{tmpdir}/hej.da.txt") as outfile2:
-        content = outfile2.read().strip()
-        assert content == "hej da"
-
-    def compare_some_audit_fields(have, want):
-        for field in ["executors", "inputs", "outputs", "upstream"]:
-            assert have[field] == want[field]
-
-    with open(f"{tmpdir}/hej.txt.au.json") as aufile1:
-        audit_info1 = json.load(aufile1)
-        want_dict1 = {
-            "inputs": [],
-            "outputs": [{"url": f"{tmpdir}/hej.txt", "path": None}],
-            "executors": [
-                {
-                    "image": None,
-                    "command": ["echo", "hej", ">", f"{tmpdir}/hej.txt"],
-                }
-            ],
-            "upstream": {},
-        }
-        compare_some_audit_fields(audit_info1, want_dict1)
-
-    with open(f"{tmpdir}/hej.da.txt.au.json") as aufile2:
-        audit_info2 = json.load(aufile2)
-        want_dict2 = {
-            "inputs": [{"url": f"{tmpdir}/hej.txt", "path": None}],
-            "outputs": [{"url": f"{tmpdir}/hej.da.txt", "path": None}],
-            "executors": [
-                {
-                    "image": None,
-                    "command": [
-                        "echo",
-                        "$(cat",
-                        f"{tmpdir}/hej.txt)",
-                        "da",
-                        ">",
-                        f"{tmpdir}/hej.da.txt",
-                    ],
-                }
-            ],
-            "upstream": {},
-        }
-        compare_some_audit_fields(audit_info2, want_dict2)
-
-    # Clean up
-    os.remove(f"{tmpdir}/hej.txt")
-    os.remove(f"{tmpdir}/hej.txt.au.json")
-    os.remove(f"{tmpdir}/hej.da.txt")
-    os.remove(f"{tmpdir}/hej.da.txt.au.json")
-    os.removedirs(tmpdir)
-
-
-def run_command(command):
-    try:
-        # Run the command and capture stdout, stderr, and return code
-        result = subprocess.run(
-            command,
-            shell=True,
-            stdout=subprocess.PIPE,
-            stderr=subprocess.PIPE,
-            text=True,
-        )
-
-        # Get the stdout, stderr, and return code
-        stdout = result.stdout.strip()
-        stderr = result.stderr.strip()
-        return_code = result.returncode
-
-        return stdout, stderr, return_code
-
-    except subprocess.CalledProcessError as e:
-        # If there's an error with the command, handle it here if needed
-        print(f"Error occurred: {e}")
-        return "", str(e), e.returncode