Build

Build a command-line tool

Build todo, a command-line task list that keeps its data in a JSON file, then compile it into a program you can run from anywhere.

6 steps · about 20 minutes · uses fs, json, process

You'll build todo: todo add buy milk, todo list, todo done 1. Tasks live in a JSON file, mistakes get a message and an exit code, and at the end it's a single program on your PATH.

What you'll have at the end
$ todo add buy milk
added #1
exit 0
$ todo add walk dog
added #2
exit 0
$ todo add write docs
added #3
exit 0
$ todo done 2
finished: walk dog
exit 0
$ todo list
[ ] 1 buy milk
[x] 2 walk dog
[ ] 3 write docs
exit 0
$ todo bogus
usage: todo add <text> | list | done <n>
exit 2
Step 2 of 6

Add tasks with a function

Commands will be functions: add appends a task and list prints them. The calls move to the try-it tab, so the program reads as the tool itself.

Try changing the try-it tab: add a third task before list().

Your turn

Only the open ones

Make list print only tasks that aren't done when onlyOpen is true. Keep each task's own number.

main.osl
Ctrl Enter runs
Expected output
[ ] 1 buy milk
[ ] 3 write docs
struct Task (
  string text
  bool done
)

Task[] tasks = []

def add(string text) (
  tasks.append(Task(text, false))
  log "added #" ++ tasks.len
)

def list() (
  for i, t in tasks (
    string mark = if t.done then "[x]" else "[ ]"
    log mark i t.text
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 3 of 6

Remember tasks in a file

Right now the list vanishes when the program ends. save turns the array into text with json.stringify and writes it with fs.writeFile; load reads it back with json.parse<Task[]>. A missing file just means an empty list. A broken file is a real problem: say why and exit with status 1. add and list now go through load and save.

The catch blocks end in process.exit(1), which never returns, so the code after catch only sees good values. See errors. The fs.remove in the try-it tab only makes each run start clean.

Quick check

Why can the line after fs.readFile(file) catch (...) use the text as a plain string?

import "std:fs"
import "std:json"
import "std:process"

struct Task (
  string text
  bool done
)

string file = "tasks.json"

def load() Task[] (
  if !fs.exists(file) return []
  string text = fs.readFile(file) catch (e) (
    log "cannot read " ++ file ++ ": " ++ e.message
    process.exit(1)
  )
  return json.parse<Task[]>(text) catch (e) (
    log file ++ " is not valid: " ++ e.message
    process.exit(1)
  )
)

def save(Task[] tasks) (
  fs.writeFile(file, json.stringify(tasks)) catch (e) (
    log "cannot write " ++ file ++ ": " ++ e.message
    process.exit(1)
  )
)

def add(string text) (
  Task[] tasks = load()
  tasks.append(Task(text, false))
  save(tasks)
  log "added #" ++ tasks.len
)

def list() (
  for i, t in load() (
    string mark = if t.done then "[x]" else "[ ]"
    log mark i t.text
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 4 of 6

Finish a task

finish can go wrong two ways: the argument isn't a number, or there's no such task. Each gets a message and an exit code: 2 means the command was used wrongly, 1 means something failed while doing the work. tryAs<int> returns a result, so catch handles "not a number" with an early return 2.

Try changing the numbers in the try-it tab to hit each message.

Your turn

Don't finish twice

Make finish refuse a task that's already done: print already done: and its text, and return 1.

main.osl
Ctrl Enter runs
Expected output
finished: walk dog
0
already done: walk dog
1
import "std:fs"
import "std:json"
import "std:process"

struct Task (
  string text
  bool done
)

string file = "tasks.json"

def load() Task[] (
  if !fs.exists(file) return []
  string text = fs.readFile(file) catch (e) (
    log "cannot read " ++ file ++ ": " ++ e.message
    process.exit(1)
  )
  return json.parse<Task[]>(text) catch (e) (
    log file ++ " is not valid: " ++ e.message
    process.exit(1)
  )
)

def save(Task[] tasks) (
  fs.writeFile(file, json.stringify(tasks)) catch (e) (
    log "cannot write " ++ file ++ ": " ++ e.message
    process.exit(1)
  )
)

def add(string text) (
  Task[] tasks = load()
  tasks.append(Task(text, false))
  save(tasks)
  log "added #" ++ tasks.len
)

def list() (
  for i, t in load() (
    string mark = if t.done then "[x]" else "[ ]"
    log mark i t.text
  )
)


def finish(string input) int (
  int n = input.tryAs<int> catch (e) (
    log "not a number: " ++ input
    return 2
  )
  Task[] tasks = load()
  if n < 1 or n > tasks.len (
    log "no task #" ++ n
    return 1
  )
  tasks[n].done = true
  save(tasks)
  log "finished: " ++ tasks[n].text
  return 0
)
Click the code to edit it

Highlighted lines are new in this step.

Step 5 of 6

One entry point

run takes the arguments as an array and returns an exit code: it switches on the first one and hands the rest to the right function. add now takes the words and checks it got some. Because run takes arguments, the try-it tab can call it with made-up ones.

The real command line goes at the bottom: process.args() is what was typed after the program name, and process.exit ends with the code run returned. That only happens when arguments were given, so on this page the try-it code runs instead.

Quick check

todo bogus prints the usage line. Which exit code fits, and why?

Your turn

Add a version command

Add a version command that prints todo 1.0 and returns 0.

main.osl
Ctrl Enter runs
Expected output
(no tasks)
0
todo 1.0
0
usage: todo list | version
2
import "std:fs"
import "std:json"
import "std:process"

struct Task (
  string text
  bool done
)

string file = "tasks.json"

def load() Task[] (
  if !fs.exists(file) return []
  string text = fs.readFile(file) catch (e) (
    log "cannot read " ++ file ++ ": " ++ e.message
    process.exit(1)
  )
  return json.parse<Task[]>(text) catch (e) (
    log file ++ " is not valid: " ++ e.message
    process.exit(1)
  )
)

def save(Task[] tasks) (
  fs.writeFile(file, json.stringify(tasks)) catch (e) (
    log "cannot write " ++ file ++ ": " ++ e.message
    process.exit(1)
  )
)

string usage = "usage: todo add <text> | list | done <n>"

def add(string[] words) int (
  if words.len == 0 (
    log usage
    return 2
  )
  Task[] tasks = load()
  tasks.append(Task(words.join(" "), false))
  save(tasks)
  log "added #" ++ tasks.len
  return 0
)

def list() int (
  Task[] tasks = load()
  if tasks.len == 0 (
    log "nothing to do"
  )
  for i, t in tasks (
    string mark = if t.done then "[x]" else "[ ]"
    log mark i t.text
  )
  return 0
)

def finish(string input) int (
  int n = input.tryAs<int> catch (e) (
    log "not a number: " ++ input
    return 2
  )
  Task[] tasks = load()
  if n < 1 or n > tasks.len (
    log "no task #" ++ n
    return 1
  )
  tasks[n].done = true
  save(tasks)
  log "finished: " ++ tasks[n].text
  return 0
)


def run(string[] args) int (
  if args.len == 0 (
    log usage
    return 2
  )
  string[] rest = args.trim(2, args.len)
  switch args[1] (
    case "add"
      return add(rest)
    case "list"
      return list()
    case "done"
      if rest.len == 0 (
        log usage
        return 2
      )
      return finish(rest[1])
    default
      log usage
      return 2
  )
)

if process.args().len > 0 (
  process.exit(run(process.args()))
)
Click the code to edit it

Highlighted lines are new in this step.

Step 6 of 6

Build it and put it on your PATH

Save the program as todo.osl. osl run builds and runs it, and everything after -- goes to your program:

Terminal
osl run todo.osl -- add buy milk

osl compile builds a native executable once. It's a single file that needs neither OSL nor Go to run:

Terminal
osl compile todo.osl -o todo
./todo add buy milk
./todo list
mkdir -p ~/.local/bin && mv todo ~/.local/bin/
todo done 1

The tasks live in tasks.json in the directory you run it from. For one global list, change file to fs.joinPath(env.get("HOME"), ".todo.json") (with import "std:env"). Next: a JSON web API, or the fs, json and process pages.