Skip to content

Repository files navigation

taskly – an Optique playground

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.

Feature coverage

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)

Setup

pnpm install

Tasks 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

Try it out

# 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

Derived defaults

--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.

The bindConfig default trap

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.

Shell completion

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, writing

Completed 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

Source priority per option

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" }), { ... })

Project layout

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

Notes

  • src/temporal-setup.ts installs globalThis.Temporal as a module side effect rather than in cli.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 | jq clean even at -vvv.
  • remove without --force refuses to run when stdin is not a TTY, because Clack would otherwise abort with an opaque exit code in pipes and CI.

Support

If you find this project useful, consider buying me a coffee:

Buy Me A Coffee

License

Released under the MIT License. Copyright (c) 2026 Phillipp Bertram.

About

A local-first task manager CLI that demonstrates the full Optique ecosystem: Clack prompts, Valibot validation, Temporal dates, env/config cascades, derived defaults, LogTape logging and dynamic shell completion.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages