Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist/
of

.clawdhub/
.env
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ All notable changes to this project are documented here. The format follows

### Added

- AI features through [OpenRouter](https://openrouter.ai/) (`OPENROUTER_API_KEY`, optional
`~/.config/omnifocus-cli/config.json` with `ai.apiKey`/`ai.model`, `--model` per run,
`$OF_AI_MODEL` globally; default model `google/gemini-3.8-flash`). Nothing else in the CLI
needs a key.
- `task breakdown <ref>` (`of t b`): splits a task into granular, AuDHD-friendly nano
subtasks using structured output, with full context (parents, project, existing and
completed subtasks, siblings, tags) and optional `--context` text. Human mode previews the
tree and loops apply / revise-with-feedback / quit; applying creates the whole nested tree,
estimates, tags and sequential/parallel flags in one OmniFocus round-trip. `--json` prints
the plan and changes nothing; `--json --apply` applies and reports per item.
- `task why [ref]` (`of t w`): an interactive "five whys" coaching session about an avoided
task, streamed turn by turn, ending only on Esc, Ctrl-C, Ctrl-D or `/quit`.
- System prompts are Markdown files in `src/prompts/`, embedded in the binary and
overridable per user via `~/.config/omnifocus-cli/prompts/<name>.md` or `$OF_PROMPTS_DIR`.
- Bridge ops `task.context` and `task.createTree` (also accepts a `projectId` target).

- `of fc` as a shortcut for `of forecast`. Standalone root commands can now carry a short
alias of their own; `fc` rather than `f` because `f` is the `folder` noun.
- `task search --id <id>` looks a single task up by id instead of by keyword, accepting
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ src/jxa/bridge.js (single JXA script) ──→ OmniFocus.app

Presentation sits beside that pipeline, not inside it: `src/core/output.ts` is the entity renderer (task/project/tag formatting, JSON/error/warning emitters) and `src/core/ui/` is the entity-agnostic terminal toolkit (`colors.ts` ANSI primitives, `terminal.ts` interactivity detection, `progress.ts` spinner decorator). In `src/index.ts` the real client is wrapped as `withProgress(createClient())` before `buildProgram`, so every bridge round-trip gets a stderr spinner in human mode without any command knowing.

- **Program assembly** (`src/program.ts`): `buildProgram(client)` assembles the Commander program (version comes from package.json — never hardcode it elsewhere). It also installs the `preAction` hook that calls `setProgressEnabled(resolveFormat(json) === "human")`, the single switch that allows UI chrome for the current invocation. `src/index.ts` is the thin executable entry: create client → buildProgram → parseAsync with global error handling. Tests import `buildProgram`, never `index.ts` (which parses argv at import time).
- **Program assembly** (`src/program.ts`): `buildProgram(client, ai = createAIClient())` assembles the Commander program (version comes from package.json — never hardcode it elsewhere). It also installs the `preAction` hook that calls `setProgressEnabled(resolveFormat(json) === "human")`, the single switch that allows UI chrome for the current invocation. `src/index.ts` is the thin executable entry: create client → buildProgram → parseAsync with global error handling. Tests import `buildProgram`, never `index.ts` (which parses argv at import time).
- **CLI layer** (`src/commands/`): Thin wrappers — parse args → call client → format output → catch errors. Nothing imports from `commands/`; it only imports from `src/core/`. Organized noun-verb: each noun (task, project, tag, folder, inbox, bulk) is a directory with an `index.ts` registering verb files. Standalone commands (forecast, review, stats, collect, completion) attach to the root program; they sit outside the noun/verb alias system, so a short alias for one is declared inline with `.alias()` on its own command and may be more than one letter when the letter is taken (`forecast` is `fc` — `f` is the `folder` noun). **Nouns are declared, not hand-registered.** Each `src/commands/<noun>/index.ts` is a `defineNoun({ name, alias, description, verbs })` literal (`src/commands/noun.ts`); noun aliases are one stable letter (`t p g f i b`), verb aliases are declared per mount point in the same literal via `verbAliases: { complete: "c", ... }` (never inside the verb file, because `task add` is also mounted as `inbox add`, and a letter only has to be unique within one noun — `defineNoun` throws on collisions, unknown verbs, or multi-character letters), a nested noun (`notification: "n"`) is aliased as a verb of its parent, and the root gets no verb shortcuts. Letters are fixed choices, not prefixes: `search` is `f`, `tag` is `g` (mirroring the noun), and `process-many` has none. Every verb wraps its handler in `runAction()` and declares shared flags through the option groups in `src/commands/options/` (`taskRefArgument`, `taskCreateOptions`/`taskEditOptions`, `listQueryOptions`, `limitOption`, `confirmOption` + `requireConfirm`). A verb file contains only what is specific to that verb; if a flag or argument is needed by two verbs it belongs in `options/`. Shell completions are generated from the live Commander tree (`generateCompletionScript`), not hardcoded — a parity test enforces coverage.
- **Client layer** (`src/core/client.ts`): `createClient()` returns an `OmniFocusClient`. Each method builds a `BridgeCommand { op, params }` (e.g. `"task.create"`, `"task.notification.add"`, `"forecast"`) and calls `executeBridge()`. Timeouts scale by op weight: 30s default, 60s for forecast/review/stats, 120s for bulk.
- **AI layer** (`src/core/ai/`): the second injected seam. `AIClient` (`types.ts`: `chat`, `stream`, `structured`) is threaded through `buildProgram(client, ai)` and `Register = (parent, client, ai)` exactly like the OmniFocus client; verbs that need no model ignore the third parameter. `createAIClient()` (`client.ts`) is lazy — config (`config.ts`: `--model` > `$OF_AI_MODEL` > `~/.config/omnifocus-cli/config.json` `ai.model` > `DEFAULT_MODEL`; key `$OPENROUTER_API_KEY` > config `ai.apiKey`, missing → `AIError("missing-key")` with setup text) and the SDK are resolved on the first call. `openrouter.ts` is the **only** file that may mention `@openrouter/sdk`, and only via `await import()` (a static-scan test enforces both); it maps SDK errors to `AIError.kind`s and implements structured output as strict `json_schema` + `provider.requireParameters` with one validation-repair retry. System prompts are Markdown files in `src/prompts/` (`prompts.ts` embeds them via text import; `$OF_PROMPTS_DIR` / `<config dir>/prompts/<name>.md` override at runtime). `context.ts` renders the `task.context` payload into the Markdown the model sees; `plan.ts` is the breakdown contract (flat list + `parentKey`, never a recursive schema — strict-mode `$ref` is not portable across providers). Tests inject `createFakeAI()` (`test/fixtures/fake-ai.ts`); `test/preload.ts` isolates `OF_CONFIG_DIR`/`OF_PROMPTS_DIR` and unsets the key/model env vars.
- **Bridge/transport** (`src/core/bridge.ts` + `src/jxa/bridge.js`): JSON command in, JSON response out. Response is always `{ ok: true, data }` or `{ ok: false, error, candidates? }`. `unwrapBridgeResponse()` turns `{ ok: false }` into a thrown `BridgeError` (preserving disambiguation candidates), mapping known environment failures (Apple Events permission -1743, app not found) to actionable messages via `matchKnownBridgeFailure()`. Timeout/empty/malformed responses surface as `JXAExecutionError`. Command JSON over 128KB is piped through child stdin with the `@stdin` sentinel argument (ARG_MAX safety); `executeBridge` throws a clear error on non-macOS platforms. The osascript binary is resolved per-call from `OF_BRIDGE_BIN` (test seam; defaults to `/usr/bin/osascript`).

### Dependency injection & the test seam

`OmniFocusClient` (interface in `src/core/types.ts`) is the seam. `createClient()` is called once in `src/index.ts` and threaded into every `register*Commands(program, client)`. Tests inject mock clients — **no OmniFocus or macOS required to run the suite**. Integration tests (`test/integration/`) verify the full parse-to-output flow against mocks.
`OmniFocusClient` (interface in `src/core/types.ts`) is the seam, and `AIClient` (`src/core/ai/types.ts`) is its twin for the model. `createClient()` and `createAIClient()` are called once in `src/index.ts` and threaded into every `register*Commands(program, client, ai)`. Tests inject a mock client and a scripted fake AI (`createFakeAI()`) — **no OmniFocus, macOS or network required to run the suite**. Integration tests (`test/integration/`) verify the full parse-to-output flow against mocks.

`src/jxa/bridge.js` has its own, lower-level test seam: `test/jxa/` evaluates the real script source against a stubbed JXA `Application` global (see `test/jxa/bridge-harness.ts`), exercising op handlers (`task.list`, `stats`, ...) directly — still no OmniFocus or macOS required.

Expand All @@ -75,6 +76,7 @@ You must touch all three layers, in this order:
- **Dates are resolved by OmniFocus itself.** `resolveDate()` in the bridge sends anything that is not an exact ISO form (`YYYY-MM-DD`, `YYYY-MM-DDTHH:mm`) to OmniFocus's own parser via Omni Automation (`Formatter.Date` + the app's `DefaultDueTime`/`DefaultStartTime`/`DefaultPlannedTime` settings), so `--due tomorrow`, `fri 5pm`, `2d`, `10.9.` work everywhere dates are accepted. ISO forms keep byte-identical local parsing (a bare ISO date stays at midnight) so scripts never change behavior. `setDateProp()` reads every date back after writing and throws when OmniFocus did not store it — never report a date change you have not verified. `of task move <ref> [due] [--defer] [--planned]` (`src/commands/task/move.ts`) is a thin verb over `task.update`; with `--id` a sole positional is the date.
- **`--json` is a root option only.** Never declare it on a verb — Commander recognises it after the subcommand and `runAction` reads it via `optsWithGlobals()`.
- **One creator.** `task add` handles inbox tasks, project tasks (`--project`) and subtasks (`--parent`/`--parent-id`); `inbox add` mounts the same register function. The bridge's `createTaskRecord()` is shared by `task.create` and `bulk.create`.
- **AI verbs live under `task`** (`breakdown|b`, `why|w`), not under an `ai` noun. `task breakdown` is plan/apply: human mode previews (`outputPlanTree`) and loops `[a]pply/[r]evise/[q]uit` through `createPrompter()`; JSON mode prints the plan and applies nothing unless `--apply`. Applying is one bridge op (`task.createTree`), never N `task.create` calls, so a partial failure is reported per item and descendants of a failed item are skipped rather than reparented. `task why` refuses `--json` and non-TTY stdin/stdout. Interactive input goes through `src/core/ui/prompt.ts` only — it owns every quit path (Esc as a lone raw `\x1b` chunk, since Bun's readline never flushes a lone escape; Ctrl-C via readline `SIGINT`; Ctrl-D via `close`; `/quit`). Prompts are written to stderr. Long model calls use `withSpinner()` from `ui/progress.ts` (same gates as the client spinner).
- **Short-id aliases are human-mode only.** `src/core/short-ids.ts` caches a small persistent `OmniFocus id → number` map so `of task list` output like `42 Buy milk` can be referenced later as `of task complete 42`. Resolution happens entirely in the TS layer via `resolveTaskRef()` (an all-digit positional matching a cached alias resolves to the real id before the bridge ever sees it) — the bridge and its ops know nothing about aliases. JSON/piped output must never surface a short id, only the real OmniFocus id. Tests must never touch the real cache file: `bunfig.toml` + `test/preload.ts` redirect `OF_SHORT_ID_CACHE` to a temp directory for every test run, so this doesn't need to be handled per-test.
- Test helpers that mutate process state (`withEnv`, `withStreamTTY`) live in `test/helpers/env.ts` — reuse them rather than re-implementing save/restore per file. `withEnv` is promise-aware.
- Use `parseIntOption()` for integer options, never `parseInt` directly — Commander's `(value, previous)` parser signature collides with `parseInt(string, radix)`.
Expand Down
98 changes: 92 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A TypeScript CLI for managing OmniFocus from the terminal. Built on Bun + Comman
- macOS (uses Apple Events via `osascript`) — on other platforms the CLI exits with a clear error
- [Bun](https://bun.sh/) >= 1.0
- OmniFocus installed and running
- For the AI commands only: an [OpenRouter](https://openrouter.ai/) API key (see [AI features](#ai-features))

### First run: Automation permission

Expand Down Expand Up @@ -90,8 +91,8 @@ Every task shown in a human-readable listing (`task list`, `task search`, `task

```
$ of task list
42 ⚑ Buy milk [Errands] due:2026-09-01
127 Call the dentist [Health]
42 ⚑ Buy milk Errands [shopping] due:2026-09-01
127 Call the dentist Health
```

Any command that takes a task reference accepts that number in place of a name or the
Expand Down Expand Up @@ -262,6 +263,79 @@ Bulk commands (and `inbox process-many`) read their JSON payload from stdin and
immediately with a usage example if nothing is piped. Arbitrarily large payloads are safe —
oversized commands are streamed to the bridge instead of passed as process arguments.

### AI features

Two verbs talk to a language model through [OpenRouter](https://openrouter.ai/). They need an
API key, and nothing else in the CLI does — every other command works without one.

```bash
export OPENROUTER_API_KEY=sk-or-... # or put it in the config file below
export OF_AI_MODEL=openai/gpt-4.1-mini # optional; default is google/gemini-3.8-flash
```

Config file: `~/.config/omnifocus-cli/config.json` (`$XDG_CONFIG_HOME` respected):

```json
{ "ai": { "apiKey": "sk-or-...", "model": "google/gemini-3.8-flash" } }
```

Precedence is `--model` flag > `$OF_AI_MODEL` > config file > default; the key comes from
`$OPENROUTER_API_KEY`, else the config file. Any model id OpenRouter routes works
(`openrouter/auto`, `:nitro`/`:floor` suffixes included), but `task breakdown` needs a model
that supports strict JSON-schema output.

**Break a task into nano tasks** — granular, single-action subtasks designed for people for
whom starting is the hard part (the prompt is AuDHD-aware: an ignition step first, one
observable action per task, 2–10 minutes each, implicit prep made explicit, no vague verbs):

```bash
of task breakdown 42 # or `of t b 42`
of task breakdown 42 --context "I only have the evenings this week"
```

The model sees the whole picture — the task, its parents, its project, subtasks that already
exist (completed ones included), its siblings and your tag list — and answers with a
structured plan. You get a preview:

```
Plan for: File the tax return — new subtasks in order
Ignition first, then the portal.

1 Open the tax portal in the browser 1min
2 Find last year's return PDF in ~/Documents/Taxes 3min [@computer]
3 Log in with the ID card app (in order) 5min
3.1 Plug in the card reader 1min
3.2 Enter the PIN 1min

5 tasks, ~11 min total

[a]pply, [r]evise or [q]uit:
```

`r` asks what should change and sends your feedback back with the full conversation, as often
as you like; `a` creates the whole tree in one OmniFocus round-trip (nesting, estimates, tags,
sequential/parallel on every level, and the target task's own ordering); `q`, Esc or Ctrl-C
changes nothing. `--apply` skips the preview.

For scripts and agents: `of task breakdown 42 --json` prints `{ target, model, plan,
applied: null }` and never touches OmniFocus; add `--apply` to create the tasks and get
`applied` (the per-item result, exit 1 if any item failed).

**Work out why you are avoiding something** — a "five whys" coaching session:

```bash
of task why 42 # or `of t w 42`
of task why # no task, start from "what are you avoiding?"
```

The coach asks one question at a time, adapts to your answers, and keeps going until you leave
with Esc, Ctrl-C, Ctrl-D or `/quit`. It is a terminal-only session: it refuses `--json` and
piped stdin.

**Prompts are plain Markdown** in [`src/prompts/`](src/prompts/) (`why.md`, `breakdown.md`).
They are embedded in the binary, and any of them can be overridden without rebuilding by
putting a file of the same name in `~/.config/omnifocus-cli/prompts/` (or `$OF_PROMPTS_DIR`).

### Shell completions

```bash
Expand Down Expand Up @@ -298,6 +372,8 @@ of collect --days 14 # recently completed tasks
| `task notification delete` | Delete a task notification |
| `task notification clear` | Clear all task notifications (requires `--confirm`) |
| `task tag` | Apply tags to a task |
| `task breakdown` | AI: split a task into nano subtasks, preview, revise, apply (`--json` prints the plan) |
| `task why` | AI: interactive five-whys session about an avoided task |
| `project add` | Create a new project |
| `project list` | List projects |
| `project show` | Show project details |
Expand Down Expand Up @@ -328,7 +404,7 @@ Verb aliases, per noun (`of <noun> --help` lists them):

| Noun | Verb aliases |
|------|--------------|
| `task` | `a`dd `l`ist `s`how `f` search `u`pdate `m`ove `c`omplete `g` tag `d`elete `n`otification |
| `task` | `a`dd `l`ist `s`how `f` search `u`pdate `m`ove `c`omplete `g` tag `d`elete `n`otification `b`reakdown `w`hy |
| `task notification` | `l`ist `a`dd `u`pdate `d`elete `c`lear |
| `project` | `a`dd `l`ist `s`how `u`pdate `r`ename `d`elete |
| `tag` | `a`dd `l`ist `t`asks `r`ename `d`elete |
Expand Down Expand Up @@ -363,12 +439,22 @@ Three clean layers:

Human-mode presentation is split from those layers: `src/core/output.ts` renders OmniFocus
entities, and `src/core/ui/` holds entity-agnostic terminal primitives (ANSI colors,
interactivity detection, the progress spinner). The spinner is a decorator over the client
(`withProgress`) wired once in `src/index.ts`, so commands never know it exists.
interactivity detection, the progress spinner, the interactive prompter). The spinner is a
decorator over the client (`withProgress`) wired once in `src/index.ts`, so commands never
know it exists.

The language model is a second injected seam beside the OmniFocus client: `src/core/ai/`
defines an `AIClient` interface (`chat`, `stream`, `structured`), config resolution, the
prompt loader and the OpenRouter adapter — the only module that imports the SDK, and only
lazily, so runs that never use a model never load it. `buildProgram(client, ai)` threads both
clients into every verb.

### Testing

Tests use mocked `OmniFocusClient` implementations -- no OmniFocus required. Integration tests verify the full command-parse-to-output flow.
Tests use mocked `OmniFocusClient` implementations and a scripted fake `AIClient` -- no
OmniFocus and no network required. Integration tests verify the full command-parse-to-output
flow, including the interactive preview/revise loop through a fake terminal. The OpenRouter
adapter is tested with the real SDK against a local fake HTTP endpoint.

```bash
bun test # all tests
Expand Down
Loading
Loading