A local-first task manager CLI built with Optique, written as a hands-on tour of the framework and its integrations. Tasks are persisted to a plain JSON file rather than mocked, so every command can be exercised end-to-end.
The table below maps each Optique feature to the file where you can read a working example of it.
| Optique feature | Used in |
|---|---|
Subcommands via command() + or() |
src/parser.ts, src/commands/*.ts |
| Nested subcommands | config (path / show) |
multiple({ min: 1 }) positional arguments |
done, reopen, remove |
@optique/valibot value parsers |
src/schemas.ts (title, tag) |
@optique/temporal plainDate() |
--due, --due-before |
@optique/env (bindEnv) |
--data-dir, --priority |
@optique/config (bindConfig + Valibot) |
--priority |
@optique/derived-defaults |
--due derived from --priority |
@optique/clack text prompt |
add (--title) |
@optique/clack confirm prompt |
remove (without --force) |
@optique/logtape verbosity ladder |
-v / -vv / -vvv, --log-output |
Custom suggest() for shell completion |
src/completions.ts (task IDs, tag names) |
pnpm installTasks are stored in tasks.json in the current working directory, so each
project keeps its own list — no global state to clean up. Override with
--data-dir DIR or $TASKLY_DATA_DIR when you want one shared list:
export TASKLY_DATA_DIR=~/tasks # one list everywhere
pnpm taskly --data-dir /tmp/scratch list # or per-invocation# help (note the auto-generated "Logging options" group)
pnpm taskly --help
pnpm taskly help add
# add tasks — --due is derived from --priority when omitted
pnpm taskly add --title "Buy milk" --priority high --tag groceries
pnpm taskly add --title "Write report" --priority med --tag work --tag writing
pnpm taskly add --title "Learn Optique" --priority low --tag learning
# list and filter
pnpm taskly list
pnpm taskly list --status all
pnpm taskly list --tag work
pnpm taskly list --due-before 2026-08-15
pnpm taskly list --format json | jq '.[].title'
# mutate
pnpm taskly done 1
pnpm taskly reopen 1
pnpm taskly edit 3 --priority high --add-tag urgent --due 2026-08-20
pnpm taskly remove 2 --force
# interactive: omitting --title opens a Clack text prompt
pnpm taskly add
# verbosity ladder (LogTape → stderr)
pnpm taskly list -v # info
pnpm taskly list -vv # debug
pnpm taskly list -vvv # trace
# inspect where config and data live
pnpm taskly config path
pnpm taskly config show--due is not a fixed default; it is computed from the resolved --priority
(high → tomorrow, med → +7 days, low → +30 days). See src/contexts.ts:
export const derived = createDerivedDefaults({
due: (parsed: AddSeed) => {
const priority = parsed.command?.priority;
if (priority !== "low" && priority !== "med" && priority !== "high") {
return undefined;
}
return Temporal.Now.plainDateISO().add({
days: DEFAULT_DUE_OFFSET_DAYS[priority],
});
},
});Measured behaviour (today = 2026-08-07):
| Priority source | Stored priority | Derived due |
|---|---|---|
CLI --priority high |
high | 2026-08-08 ✓ |
env TASKLY_DEFAULT_PRIORITY=high |
high | 2026-08-08 ✓ |
config defaultPriority: "high" |
high | 2026-08-14 ⚠ |
| nothing (static default) | med | 2026-08-14 ✓ |
⚠ Ordering caveat worth knowing: the derived-default resolver's seed sees
CLI and environment values, but not values resolved by bindConfig(). With
the priority coming only from the config file, the task is stored as high
while the due date is still computed from the med static default. If a
derived value must always track the final resolved value, compute it in the
handler instead.
Put the static fallback on bindConfig() itself, not in an outer
withDefault():
// ✗ fails with "Missing required configuration value" when no config file exists
withDefault(bindConfig(option("-p", "--priority", choice([...])), { context, key }), "med")
// ✓ falls through correctly
bindConfig(option("-p", "--priority", choice([...])), { context, key, default: "med" })bindConfig() errors out before an outer withDefault() is ever consulted, so
the outer default silently never runs.
src/completions.ts defines value parsers whose suggest() generators read the
real task database, so Tab offers IDs and tags that actually exist:
# install (bash)
pnpm taskly completion bash > ~/.bashrc.d/taskly.bash && source ~/.bashrc.d/taskly.bash
# or test the runtime protocol directly — args exclude the program name
pnpm taskly completion bash done "" # → open task IDs
pnpm taskly completion bash list --tag "" # → existing tag names
pnpm taskly completion bash list --tag w # → work, writingCompleted task IDs are filtered out of done suggestions. Both generators use
loadDbQuietly() and never throw — an exception during completion would break
the user's shell session.
A suggest() generator cannot see sibling options. It receives only the
current prefix, so it has no way to learn that the user passed --data-dir.
Left alone, taskly --data-dir other/ done <TAB> would silently suggest IDs
from the current directory. completionDataFile() in src/completions.ts
works around this by scanning process.argv itself, reproducing the runtime
priority --data-dir > $TASKLY_DATA_DIR > cwd:
cd projA
pnpm taskly completion bash done "" # → projA's IDs
pnpm taskly completion bash --data-dir ../projB done "" # → projB's IDs| Option | Resolution order |
|---|---|
--title |
CLI → Clack text prompt |
--priority |
CLI → $TASKLY_DEFAULT_PRIORITY → config file → "med" |
--due |
CLI → derived from --priority → none |
--data-dir |
CLI → $TASKLY_DATA_DIR → current working directory |
Wrapping order determines priority, and the outermost wrapper wins:
bindEnv(bindConfig(option("-p", "--priority", ...), { ..., default: "med" }), { ... })src/
├── cli.ts # entrypoint: runAsync + LogTape configure + dispatch
├── parser.ts # global options + or(command...) + defineProgram
├── contexts.ts # env, config and derived-default contexts
├── schemas.ts # @optique/valibot value parsers
├── completions.ts # custom suggest() parsers backed by the task DB
├── prompt-once.ts # Clack text prompt that survives the two-pass parse
├── storage.ts # JSON persistence, Valibot-validated
├── temporal-setup.ts # installs the Temporal polyfill as a side effect
├── handlers.ts # the actual task operations
└── commands/
├── add.ts # ⭐ hero: prompt + env + config + derived default
├── list.ts # multiple() filters + Temporal bound
├── mutate.ts # done / reopen / edit / remove
└── config.ts # nested subcommands
src/temporal-setup.tsinstallsglobalThis.Temporalas a module side effect rather than incli.ts's body. Module top-level code runs before the importing module's body, so command modules can build Temporal values while being defined.- LogTape is configured to write to stderr, which keeps
taskly list --format json | jqclean even at-vvv. removewithout--forcerefuses to run when stdin is not a TTY, because Clack would otherwise abort with an opaque exit code in pipes and CI.
If you find this project useful, consider buying me a coffee:
Released under the MIT License. Copyright (c) 2026 Phillipp Bertram.