A DSL for managing computers, oriented around running commands and executing programs over SSH.
Pitcrew files use the .pit extension. Programs are a sequence of top-level statements. There is no main function — execution begins at the first statement.
name := "world"
print("hello " + name)
print() is a built-in function that writes to stderr. It accepts any number of arguments.
Use := to declare a variable. The type is inferred from the right-hand side.
x := 42
name := "Alice"
active := true
Use var to declare with an explicit type, optionally with a default value.
var count int
var label string = "default"
Constants are immutable and declared with const.
const MAX = 100
const PREFIX string = "v"
Reassign with =.
x := 1
x = 2
Regular strings with support for #{} interpolation.
greeting := "hello"
msg := "Hi #{name}, you are #{age} years old"
full := "hello" + " " + "world"
Backtick strings execute shell commands and return stdout as a string. They also support interpolation.
files := `ls -la`
result := `echo #{value}`
Interpolated values in backtick strings are automatically shell-escaped for safety.
a := 10 + 5 # addition
b := 10 - 5 # subtraction
c := 10 * 5 # multiplication
d := 10 / 5 # division
e := 10 % 3 # modulo
x == y # equal
x != y # not equal
x < y # less than
x > y # greater than
x <= y # less than or equal
x >= y # greater than or equal
a && b # and
a || b # or
i++
i--
The pipe operator |> passes the left-hand value as the first argument to the right-hand function call. See the Pipes section for details.
"hello" |> len # same as len("hello")
|> (pipe)||&&== != < > <= >=+ -* / %if x == 1 {
print("one")
} else if x == 2 {
print("two")
} else {
print("other")
}
if returns the value of its last expression, so it can be used on the right-hand side of a declaration.
label := if count == 1 {
"item"
} else {
"items"
}
i := 0
while i < 5 {
print(i)
i++
}
for i := 0; i < 10; i++ {
print(i)
}
for i := 0; i < 10; i++ {
if i == 3 {
continue
}
if i == 7 {
break
}
print(i)
}
fn add(a int, b int) int {
a + b
}
result := add(3, 4)
The last expression in a function body is its return value. No return keyword needed.
fn check(x int) string {
if x == 0 {
return "zero"
}
"nonzero"
}
Arguments after the positional arguments can be named with name: type syntax in the definition.
fn greet(msg string, loud: bool) string {
if loud {
msg + "!"
} else {
msg
}
}
greet("hello", loud: true)
Functions capture outer variables as read-only constants. Outer variables cannot be modified from inside a function.
factor := 10
fn scale(x int) int {
x * factor # factor is read-only here
}
struct person {
name string
age int
}
Create instances using the struct name as a function, with named arguments.
p := person(name: "Alice", age: 30)
print(p.name)
print(p.age)
Create a new struct from an existing one, overriding specific fields. The original is not modified.
older := person(p..., age: p.age + 1)
struct config {
port int = 8080
host string = "localhost"
}
c := config() # uses defaults
int — 64-bit integerstring — textbool — true or falseAppend ? to mark a type as nullable.
fn find(id int) string? {
# may return a string or nil
}
Use square brackets around the element type.
fn sum(nums [int]) int {
# ...
}
type UserId int
type Name string
Pitcrew uses static type checking before execution. All types are verified at compile time.
Pitcrew uses typed errors instead of exceptions. Functions declare error types with ! after the error type.
fn parse(s string) string! int {
if s == "" {
error "empty input"
}
42
}
The syntax is error_type! return_type. Here the function can return an int or raise a string error.
Use catch after a function call to handle errors.
result := parse("") catch {|err|
print("Error: " + err)
0
}
The type checker enforces that all errors are either caught or propagated. You cannot ignore an error.
nums := [1, 2, 3, 4]
first := nums[0]
nums[0] = 10 # mutation
ages := {"alice": 30, "bob": 25}
a := ages["alice"]
Backtick strings execute shell commands and return their stdout as a string.
hostname := `hostname`
files := `ls -la /tmp`
Values interpolated into backtick strings are automatically shell-escaped:
dir := "/path with spaces"
listing := `ls #{dir}`
Use $ before a backtick command to get a struct with stdout, stderr, and status instead of just stdout.
result := $`ls /nonexistent`
print(result.status) # exit code (e.g. 2)
print(result.stderr) # error message
print(result.stdout) # stdout output
Without $, a non-zero exit code raises a runtime error. With $, you get the full result and can handle errors yourself.
Commands run in the local shell. To run commands on remote machines, see the SSH section.
Use >> at the start of a line to write multi-line string content. Each continuation line must start with >> at the same column.
config :=
>> server {
>> listen 80;
>> server_name #{domain};
>> }
Heredoc strings support #{} interpolation. Quotes and backticks are treated as regular content — no escaping needed.
html :=
>> <div class="container">
>> <h1>#{title}</h1>
>> </div>
All >> markers in a heredoc must be aligned to the same column. Misaligned lines produce a parse error.
SSH is a language primitive. The ssh keyword sends a function call to a remote machine for execution.
fn get_uptime() string {
`uptime`
}
result := ssh get_uptime(), host: "10.0.0.1", user: "deploy"
print(result)
When an ssh expression runs:
The function and all values it captures are serialized. Outer variables become read-only constants on the remote side.
The pipe operator |> passes the left-hand value as the first argument to the right-hand function call. This enables readable left-to-right chaining instead of deeply nested calls.
Parentheses are optional when there are no additional arguments.
# These are equivalent:
len("hello")
"hello" |> len
When the function takes more arguments, the piped value is prepended before them.
# These are equivalent:
join([1, 2, 3], ", ")
[1, 2, 3] |> join(", ")
Pipes are left-associative, so chains read naturally from left to right.
# Nested style:
join(map([1, 2, 3]) {|x| x + 1}, ", ")
# Piped style:
[1, 2, 3] |> map {|x| x + 1} |> join(", ") # "2, 3, 4"
Block arguments work naturally with pipes. Parentheses are optional when the only argument is the piped value.
[1, 2, 3] |> map {|x| x * 2} # [2, 4, 6]
" hello world " |> trim |> split(" ") |> len # 2
The pipe operator has the lowest precedence of all operators, so 1 + 2 |> f pipes 3 into f.
Pitcrew supports asynchronous execution with async and resolve. Use async to run an expression concurrently, producing a promise. Use resolve to wait for the result.
promise := async `long-running-command`
# do other work while it runs...
result := resolve(promise) # wait for completion
Start multiple tasks concurrently, then wait for all of them.
a := async `curl http://api1.example.com`
b := async `curl http://api2.example.com`
c := async `curl http://api3.example.com`
r1 := resolve(a)
r2 := resolve(b)
r3 := resolve(c)
Use sync.limiter to control how many async tasks run at the same time.
import "sync"
limit := sync.limiter(3) # max 3 concurrent tasks
promises := ["host1", "host2", "host3", "host4", "host5"] |> map {|host|
async(limit) `ping -c 1 #{host}`
}
Promises cannot be passed across SSH boundaries. Resolve them before sending values to a remote host.
Regex literals use forward-slash syntax, similar to Ruby. The regex module provides matching and searching.
pattern := /[0-9]+/
import "regex"
matched := regex.match("hello123", /[0-9]+/) # true
found := regex.find("hello123", /[0-9]+/) # "123"
all := regex.find_all("a1b2c3", /[0-9]/) # ["1", "2", "3"]
replaced := regex.replace("hello", /l/, "r") # "herro"
Use the cd keyword to change directories and pwd() to get the current directory.
The directory is restored when the block exits, even on error.
cd "/tmp" {
print(pwd()) # /tmp
files := `ls`
}
# back to original directory
cd "/var/log"
print(pwd()) # /var/log
These functions are available globally without any import.
Write values to stderr. Accepts any number of arguments.
print("hello", name, 42)
Return the length of a string (Unicode-aware) or array.
len("hello") # 5
len("héllo") # 5 (counts runes, not bytes)
len([1, 2, 3]) # 3
Split a string by a separator.
split("a,b,c", ",") # ["a", "b", "c"]
Join an array with a separator. Elements are converted to strings.
join(["x", "y"], "-") # "x-y"
join([1, 2, 3], ", ") # "1, 2, 3"
Remove leading and trailing whitespace.
trim(" hello ") # "hello"
Replace all occurrences of a substring.
replace("hello", "l", "r") # "herro"
Check if a string contains a substring, or if an array contains an element.
contains("hello", "ell") # true
contains([1, 2, 3], 2) # true
contains(["a", "b"], "c") # false
Check if a string starts with a prefix.
starts_with("hello", "he") # true
Check if a string ends with a suffix.
ends_with("hello", "lo") # true
Transform each element of an array using a block. Returns a new array.
map([1, 2, 3]) {|x| x * 2} # [2, 4, 6]
map(["a", "b"]) {|s| len(s)} # [1, 1]
Works well with pipes:
[1, 2, 3] |> map {|x| x + 1} # [2, 3, 4]
Convert a string to an integer. Returns an error if the string is not a valid number.
n := to_int("42") catch {|err| 0} # 42
Wait for an async promise to complete and return its value. See Async.
p := async `sleep 1`
result := resolve(p)
Assertions for testing. They raise an error on failure.
assert(x > 0) catch {|err| print(err)}
assert_eq(x, 42) catch {|err| print(err)}
assert(true, msg: "should be true") catch {|err| print(err)}
Returns the current working directory. See Directories.
dir := pwd()
| Function | Signature | Description |
|---|---|---|
print | (args... any) void | Print values to stderr |
len | (v any) int | Length of string (runes) or array |
split | (s string, sep string) [string] | Split string by separator |
join | (arr [any], sep string) string | Join array elements with separator |
trim | (s string) string | Remove leading/trailing whitespace |
replace | (s string, old string, new string) string | Replace all occurrences |
contains | (collection any, element any) bool | Check if string contains substring or array contains element |
starts_with | (s string, prefix string) bool | Check if string starts with prefix |
ends_with | (s string, suffix string) bool | Check if string ends with suffix |
to_int | (s string) string! int | Parse string as integer |
resolve | (promise T?) T | Wait for async promise |
assert | (cond bool, msg: string?) string! void | Assert condition is true |
assert_eq | (a any, b any, msg: string?) string! void | Assert two values are equal |
map | (arr [T], block) [U] | Transform array elements |
pwd | () string | Current working directory |
import "utils/helpers"
result := helpers.do_thing()
import "utils/helpers" as h
result := h.do_thing()
Pitcrew includes a standard library of .pit modules embedded in the binary. Import them by name — no file paths needed.
Filesystem operations.
import "fs"
files := fs.list(".") # list files in directory
f := files[0]
print(f.name, f.size, f.is_dir)
Each element returned by list is a FileInfo struct with the following fields:
| Field | Type | Description |
|---|---|---|
name | string | File name |
size | int | File size in bytes |
is_dir | bool | Whether the entry is a directory |
mtime | int | Last modification time (Unix seconds) |
atime | int | Last access time (Unix seconds) |
ctime | int | Status change time (Unix seconds) |
| Function | Signature | Description |
|---|---|---|
list | (path string) [FileInfo] | List files in a directory with metadata |
read | (path string) string! string | Read file contents |
write | (path string, content string) string! void | Write content to file |
stat | (path string) string! FileInfo | Get file metadata |
exists | (path string) bool | Check if file exists |
is_dir | (path string) bool | Check if path is a directory |
copy | (src string, dst string) string! void | Copy file |
move | (src string, dst string) string! void | Move file |
chmod | (path string, mode int) string! void | Change file permissions |
chown | (path string, uid int, gid int) string! void | Change file ownership |
JSON parsing and serialization.
import "json"
data := json.parse('{"name": "Alice", "age": 30}')
print(data.name) # Alice
text := json.emit(data) # back to JSON string
keys := json.keys(data) # ["name", "age"]
t := json.typeof(data.name) # "string"
Regular expression matching and replacement. See the Regex section.
Process management.
import "ps"
pid := ps.start("nginx") catch {|err| print(err); 0}
info := ps.status(pid) catch {|err| print(err)}
ps.stop(pid) catch {|err| print(err)}
ps.signal(pid, "HUP") catch {|err| print(err)}
HTTP requests and network diagnostics.
import "net"
response := net.get("https://api.example.com/health")
open := net.port_open("localhost", 8080)
ips := net.dns_lookup("example.com")
Environment variable management.
import "env"
home := env.get("HOME")
env.set("MY_VAR", "value")
all := env.list()
Cross-platform package management (detects apt, dnf/yum, brew).
import "pkg"
pkg.install("nginx")
installed := pkg.installed("nginx") # true/false
print(pkg.manager()) # "apt", "dnf", or "brew"
Service management (systemd, launchd).
import "svc"
svc.start("nginx")
svc.enable("nginx") # start on boot
running := svc.running("nginx") # true/false
Simple string templating with {{key}} placeholders.
import "tpl"
config := tpl.render("Hello {{name}}, port {{port}}", {"name": "Alice", "port": "8080"})
User and group management.
import "user"
user.create("deploy")
user.add_to_group("deploy", "sudo")
exists := user.exists("deploy")
Concurrency control. See Async.
import "sync"
limit := sync.limiter(5)
Wrappers around cloud CLI tools: aws, gcp, azure.
import "aws"
print(aws.whoami())
files := aws.s3_list("s3://my-bucket")
secret := aws.ssm_get("/prod/db-password")
Run pitcrew doc to generate searchable API documentation for all stdlib modules from their embedded docstrings.
Comments
Line comments start with
#.