Skip to content

hshell

A bash competitor that lets you write shell-like scripts in Kotlin, with many other useful features that bash doesn't provide.

Download for your OS

Warning

This site provides docs for an unreleased product! PROCEED WITH CAUTION AND LOW EXPECTATIONS.

Additionally, these docs are incomplete and do not reflect the full API available. They're intended as a brief guide to be read in combination with in-house API docs and IDE auto completion.

Let's get started

demo.shell.kts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env hshell

// Read a file and print it as Markdown to the console.
cat("README.md")

echo()

// Echo markdown to the console.
echomd("## Notices and word wrapping")

// With emoji.
echo(Info("Some information. More text."))
echo(Warning("A warning. More text."))
echo(Error("An error. More text."))

echo()

echo("Roses are ${red("red")}, violets are ${blue("blue")}")

Run hshell demo.shell.kts or chmod +x demo.shell.kts; ./demo.shell.kts.

Example of printing Markdown to the console

List directories and print tables

Print the current directory contents as colored ls style output, and a table:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
val files /* List<Path> */ = ls()

echo(files)
echo()
echo()
echo(table {
    header { row("File name", "Permissions") }
    body {
        for (file in files)
            row(file.name, file.permissionsAsString)
    }
})

Example of files and tables

Find the path to the script, and its containing directory:

1
2
echo(scriptDirPath)
echo(scriptFilePath)

Shell operations

For mv, cp, find etc see the Shell API. For wget see downloading things.

asciicast

Other string utilities

1
echo("{one,two-{b,c},three}".braceExpand().bulletedList())

Example of bullets and braces

File ops

See shell API.

Dependencies

Brace expansion in coordinates works:

1
2
3
4
5
6
@file:DependsOn("io.ktor:ktor-client-{core,cio}-jvm:2.0.2")

import io.ktor.client.*
import io.ktor.client.engine.cio.*

val client = HttpClient(CIO)

XML and JSON

Parsing

The xml function takes a string and tries to parse it as a URL, or a file path, or as literal XML (in that order) and returns a DOM Document. The json function does the same but returns a JsonElement.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
val dom1 = xml("""
    <root>
        <child>text</child>
    </root>
""")

echo(dom.getElementsByTagName("child").item(0).textContent)

val dom2 = xml("foo.xml")
val dom3 = xml("https://www.google.com/sitemap.xml")

val el: JsonElement = json("""
    { "foo": "bar" }
""")
val el2: JsonElement = json("https://www.example.com/example.json")
val el3: JsonElement = json("./foo.json")

They can also be given a Path or URI object.

There is a path extension function on JsonElement and JsonObject that support a simple navigation language:

1
2
3
4
import kotlinx.serialization.json.*

val el: JsonElement = json("https://data.services.jetbrains.com/products/releases?code=IIU&latest=true&type=release")
echo(el.path("IIU[0].build")!!.jsonPrimitive.content)

Keys are separated by dots, and array indexing is supported. If the path isn't found then null is returned. The result can be cast using jsonObject, jsonArray or jsonPrimitive as per usual when working with Kotlin serialization.

Document has an extension function xpath that evaluates the given XPath expression and returns the result assuming it's a string.

Cleaning up

Instead of writing foo.use { foo -> foo.whatever() } to clean things up, you can write foo.closeAtEnd().whatever():

1
val bytes = (dir / "foo").inputStreamWithProgress(progressTracker).closeAtEnd().readBytes()

The closeAtEnd() function uses defer to register a code block to run when the script finishes:

1
2
defer { echo("Good bye!") }
val something = Something().andDefer { methodOnSomething() }

The last block deferred is run first.

Use exit(exitCode) to terminate early. Don't call exitProcess unless you're sure you want immediate termination without any finally or deferred blocks running.

Disk caching

A LocalDiskCache will give you a directory associated with an arbitrary string key, passing it to a handler to fill with content if the key misses in the cache:

1
2
3
4
5
val cache = LocalDiskCache(dir / "cache-dir").closeAtEnd()

val dir = cache.get("Some useful stuff") { cacheEntry: Path ->
    // Fill the cache entry.
}

The cache will automatically keep its size in check, is thread safe and can be configured in various ways. You can also force cache misses and edit content in place.

Subshells and concurrency

Shell commands can be modified by global state, to avoid long repetitive argument lists (copyOptions, diffOptions etc). subshell yields a new shell with a copy of all the current shell's state, which can then be used safely from another thread.

1
2
3
4
5
6
7
8
for (entry in ls()) {
    val shell = subshell()
    thread {
        with(shell) {
            // use it
        }
    }
}

A better way is like this:

1
2
3
parallelWalk(dir) { path: Path ->
    // this block executes concurrently for every sub-path under dir recursively.
}

If the path is a file this function just invokes operation on it and returns. Otherwise, it walks over the given directory and executes operation on each entry in parallel on a thread pool, blocking whilst the entire operation completes.

The operation is invoked for all entries of a directory before the directory itself, meaning you can delete directories, sum their sizes etc. Or in other words the operation is invoked deepest first and path comes last.

The symLinkFollowing mode is respected. If it's set to SymlinkFollowing.FOLLOW_EXTERNAL or SymlinkFollowing.ALWAYS_FOLLOW that means you may be passed paths that are not children of path, or be passed the same paths more than once, so be ready for that.

WARNING: Circular symlinks aren't currently detected.

Sockets, mmapping files etc

hshell doesn't provide anything specific for this, but the whole Java API is available so you can simply use that instead.

Logging things

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import org.tinylog.kotlin.Logger.info
import org.tinylog.kotlin.Logger.trace
import org.tinylog.kotlin.Logger.warn

// Just log
trace { "Trace log" }
info { "Info log" }
warn { "Warning log"}
Logger.error("Log an error but continue")

// Print and log
echo(Info("A tip"))
echo(Warning("A warning"))
echo(Error("An error"))

warnOnce("Deduplicated warning")
warnOnce("Deduplicated warning")

throw UserError("Halt the program with an error.")

On the console:

Logging output

When running hshell --show-log:

Log viewer

Logging how long a code block took is easy, and you can give a time threshold below which no log line is made:

1
2
3
4
val file = path("bigfile")
val result = traceElapsed("Uppercasing $file", thresholdMsec = 20) {
    readLines(file).map { it.uppercase() }
}

Credential storage

The passwords object supports get/set operators accessible using square brackets notation (passwords["foo"] = "bar"). This provides simple access to the system keychain. It's not really secure because the credentials will be stored under the identity of hshell itself not the script, but in future this might be improved and it's a convenient place to store passwords. On macOS the user will be prompted to allow access and the credential will be encrypted using the hardware security chip.