diff --git a/.gitignore b/.gitignore
index c1876b3..c2e9046 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,2 @@
-release/
 *.sw*
-*/build/
-pit-cli/lib/libpit-*.jar
+nimcache/
diff --git a/pit b/pit
new file mode 100755
index 0000000..48368d3
Binary files /dev/null and b/pit differ
diff --git a/pit.nimble b/pit.nimble
new file mode 100644
index 0000000..b08d5a7
--- /dev/null
+++ b/pit.nimble
@@ -0,0 +1,12 @@
+# Package
+
+version       = "4.0.0"
+author        = "Jonathan Bernard"
+description   = "Personal issue tracker."
+license       = "MIT"
+srcDir        = "src"
+bin           = @["pit"]
+
+# Dependencies
+
+requires @["nim >= 0.18.1", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.2"]
diff --git a/src/pit.nim b/src/pit.nim
new file mode 100644
index 0000000..a1d7054
--- /dev/null
+++ b/src/pit.nim
@@ -0,0 +1,203 @@
+## Personal Issue Tracker
+## ======================
+## 
+
+import cliutils, docopt, json, logging, os, ospaths, sequtils, strutils,
+  tables, times, uuids
+
+import pit/private/libpit
+export libpit
+
+type
+  CliContext = ref object
+    tasksDir*: string
+    contexts*: TableRef[string, string]
+    issues*: TableRef[IssueState, seq[Issue]]
+    cfg*: CombinedConfig
+
+proc initContext(args: Table[string, Value]): CliContext =
+  let pitrcLocations = @[
+    if args["--config"]: $args["--config"] else: "",
+    ".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
+
+  var pitrcFilename: string =
+    foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
+
+  if not existsFile(pitrcFilename):
+    warn "pit: could not find .pitrc file: " & pitrcFilename
+    if isNilOrWhitespace(pitrcFilename):
+      pitrcFilename = $getEnv("HOME") & "/.pitrc"
+    var cfgFile: File
+    try:
+      cfgFile = open(pitrcFilename, fmWrite)
+      cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
+    except: warn "pit: could not write default .pitrc to " & pitrcFilename
+    finally: close(cfgFile)
+
+  var cfgJson: JsonNode
+  try: cfgJson = parseFile(pitrcFilename)
+  except: raise newException(IOError,
+    "unable to read config file: " & pitrcFilename &
+    "\x0D\x0A" & getCurrentExceptionMsg())
+
+  let cfg = CombinedConfig(docopt: args, json: cfgJson)
+
+  result = CliContext(
+    cfg: cfg,
+    tasksDir: cfg.getVal("tasks-dir", ""),
+    contexts: newTable[string,string](),
+    issues: newTable[IssueState, seq[Issue]]())
+
+  if cfgJson.hasKey("contexts"):
+    for k, v in cfgJson["contexts"]:
+      result.contexts[k] = v.getStr()
+
+  if isNilOrWhitespace(result.tasksDir):
+    raise newException(Exception, "no tasks directory configured")
+
+  if not existsDir(result.tasksDir):
+    raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
+
+proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
+  if not ctx.contexts.hasKey(context): return context.capitalize()
+  return ctx.contexts[context]
+
+proc formatIssue(ctx: CliContext, issue: Issue, state: IssueState,
+                 width: int, indent: string, topPadded: bool): string =
+  var showDetails = not issue.details.isNilOrWhitespace
+  var lines: seq[string] = @[]
+
+  if showDetails and not topPadded: lines.add("")
+
+  var wrappedSummary = issue.summary.wordWrap(width - 2).indent(2)
+  wrappedSummary = "*" & wrappedSummary[1..^1]
+  lines.add(wrappedSummary.indent(indent.len))
+
+  if state == Pending and issue.properties.hasKey("pending"):
+    let startIdx = "Pending: ".len
+    var pendingText = issue["pending"].wordWrap(width - startIdx - 2).indent(startIdx)
+    pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
+    lines.add(pendingText)
+
+  if showDetails: lines.add(issue.details.indent(indent.len + 2))
+
+  return lines.join("\n")
+
+proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
+                   width = 80, indent = "  "): string =
+  let innerWidth = width - (indent.len * 2)
+  var lines: seq[string] = @[]
+
+  lines.add(indent & ".".repeat(innerWidth))
+  lines.add(state.displayName.center(width))
+  lines.add("")
+
+  var topPadded = true
+  var showDetails = false
+
+  let issuesByContext = issues.groupBy("context")
+
+  if issues.len > 5 and issuesByContext.len > 1:
+    for context, ctxIssues in issuesByContext:
+      lines.add(indent & ctx.getIssueContextDisplayName(context) & ":")
+      lines.add("")
+
+      for i in ctxIssues:
+        lines.add(ctx.formatIssue(i, state, innerWidth - 2, indent & "  ", topPadded))
+        topPadded = not i.details.isNilOrWhitespace
+
+      if not topPadded: lines.add("")
+
+  else:
+    for i in issues:
+      lines.add(ctx.formatIssue(i, state, innerWidth, indent, topPadded))
+      topPadded = not i.details.isNilOrWhitespace
+
+  lines.add("")
+  return lines.join("\n")
+
+proc loadIssues(ctx: CliContext, state: IssueState): seq[Issue] =
+  result = loadIssues(joinPath(ctx.tasksDir, $state))
+
+proc loadAllIssues(ctx: CliContext) =
+  for state in IssueState:
+    ctx.issues[state] = loadIssues(ctx, state)
+
+proc sameDay(a, b: DateTime): bool =
+  result = a.year == b.year and a.yearday == b.yearday
+
+when isMainModule:
+
+ try:
+  let doc = """
+Usage:
+  pit new <state> <summary> [options]
+  pit list [<state>] [options]
+  pit today
+  pit start
+  pit done
+  pit pending
+  pit edit
+
+Options:
+
+  -t, --tags <tags>         Specify tags for an issue.
+  -p, --properties <props>  Specify properties for an issue. Formatted as "key:val;key:val"
+  -C, --config <cfgFile>    Location of the config file (defaults to $HOME/.pitrc)
+  -h, --help                Print this usage information.
+  -T, --today               Limit to today's issues.
+  -E, --echo-args           Echo arguments (for debug purposes).
+  --tasks-dir               Path to the tasks directory (defaults to the value
+                            configured in the .pitrc file)
+"""
+
+  logging.addHandler(newConsoleLogger())
+
+  # Parse arguments
+  let args = docopt(doc, version = "ptk 0.12.1")
+
+  if args["--echo-args"]: echo $args
+
+  if args["--help"]:
+    echo doc
+    quit()
+
+  let now = getTime().local
+
+  let ctx = initContext(args)
+
+  ## Actual command runners
+  if args["list"]:
+
+    ctx.loadAllIssues()
+
+    let fullWidth = 80
+    let innerWidth = fullWidth - 4
+
+    # Today's items
+    echo '_'.repeat(fullWidth)
+    echo "Today".center(fullWidth)
+    echo '~'.repeat(fullWidth)
+    echo ""
+
+    for s in [Current, TodoToday]:
+      echo ctx.formatSection(ctx.issues[s], s)
+
+    echo ctx.formatSection(
+      ctx.issues[Done].filterIt(
+        it.properties.hasKey("completed") and
+        sameDay(now, it.getDateTime("completed"))), Done)
+
+    # Future items
+    echo '_'.repeat(fullWidth)
+    echo "Future".center(fullWidth)
+    echo '~'.repeat(fullWidth)
+    echo ""
+
+    for s in [Pending, Todo]:
+      echo ctx.formatSection(ctx.issues[s], s)
+
+ except:
+  fatal "pit: " & getCurrentExceptionMsg()
+  #raise getCurrentException()
+  quit(QuitFailure)
diff --git a/src/pit/private/libpit.nim b/src/pit/private/libpit.nim
new file mode 100644
index 0000000..7e89651
--- /dev/null
+++ b/src/pit/private/libpit.nim
@@ -0,0 +1,120 @@
+import cliutils, options, os, ospaths, sequtils, strutils, tables, times, uuids
+
+from nre import re, match
+type
+  Issue* = ref object
+    id*: UUID
+    filepath*: string
+    summary*, details*: string
+    properties*: TableRef[string, string]
+    tags*: seq[string]
+
+  IssueState* = enum
+    Current = "current",
+    TodoToday = "todo-today",
+    Pending = "pending",
+    Done = "done",
+    Todo = "todo"
+
+const ISO8601Format* = "yyyy:MM:dd'T'HH:mm:sszzz"
+let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt"
+
+proc displayName*(s: IssueState): string =
+  case s
+  of Current: result = "Current"
+  of Pending: result = "Pending"
+  of Done: result = "Done"
+  of Todo: result = "Todo"
+  of TodoToday: result = "Todo"
+
+## Allow issue properties to be accessed as if the issue was a table
+proc `[]`*(issue: Issue, key: string): string =
+  return issue.properties[key]
+
+proc `[]=`*(issue: Issue, key: string, value: string) =
+  issue.properties[key] = value
+
+proc getDateTime*(issue: Issue, key: string): DateTime =
+  return parse(issue.properties[key], ISO8601Format)
+
+proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
+  issue.properties[key] = format(dt, ISO8601Format)
+
+## Parse and format issues
+proc fromStorageFormat*(id: string, issueTxt: string): Issue =
+  type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails
+
+  result = Issue(
+    id: parseUUID(id),
+    properties: newTable[string,string](),
+    tags: @[])
+
+  var parseState = ReadingSummary
+  var detailLines: seq[string] = @[]
+
+  for line in issueTxt.splitLines():
+    if line.startsWith("#"): continue   # ignore lines starting with '#'
+
+    case parseState
+
+    of ReadingSummary:
+      result.summary = line.strip()
+      parseState = ReadingProps
+
+    of ReadingProps:
+      # Ignore empty lines
+      if line.isNilOrWhitespace: continue
+
+      # Look for the sentinal to start parsing as detail lines
+      if line == "--------":
+        parseState = ReadingDetails
+        continue
+
+
+      let parts = line.split({':'}, 1).mapIt(it.strip())
+      if parts.len != 2:
+        raise newException(ValueError, "unable to parse property line: " & line)
+
+      # Take care of special properties: `tags`
+      if parts[0] == "tags": result.tags = parts[1].split({','}).mapIt(it.strip())
+      else: result[parts[0]] = parts[1]
+
+    of ReadingDetails:
+      detailLines.add(line)
+
+  result.details = if detailLines.len > 0: detailLines.join("\n") else: ""
+
+proc toStorageFormat*(issue: Issue): string =
+  var lines = @[issue.summary]
+  for key, val in issue.properties: lines.add(key & ": " & val)
+  if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
+  if not isNilOrWhitespace(issue.details):
+    lines.add("--------")
+    lines.add(issue.details)
+
+  result = lines.join("\n")
+  
+## Load and store from filesystem
+proc loadIssue*(filePath: string): Issue =
+  result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
+  result.filepath = filePath
+
+proc storeIssue*(dirPath: string, issue: Issue) =
+  issue.filepath = joinPath(dirPath, $issue.id & ".txt")
+  writeFile(issue.filepath, toStorageFormat(issue))
+
+proc loadIssues*(path: string): seq[Issue] =
+  result = @[]
+  for path in walkDirRec(path):
+    if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
+      result.add(loadIssue(path))
+
+## Utilities for working with issue collections.
+proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
+  result = newTable[string, seq[Issue]]()
+  for i in issues:
+    let key = if i.properties.hasKey(propertyKey): i[propertyKey] else: ""
+    if not result.hasKey(key): result[key] = newSeq[Issue]()
+    result[key].add(i)
+
+
diff --git a/tests/test1.nim b/tests/test1.nim
new file mode 100644
index 0000000..ba13c99
--- /dev/null
+++ b/tests/test1.nim
@@ -0,0 +1 @@
+doAssert(1 + 1 == 2)
diff --git a/tests/test1.nims b/tests/test1.nims
new file mode 100644
index 0000000..3bb69f8
--- /dev/null
+++ b/tests/test1.nims
@@ -0,0 +1 @@
+switch("path", "$projectDir/../src")
\ No newline at end of file