Human-friendly Go CLI for Things 3 Cloud — full task management from the terminal, with JSON output for scripting.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Katie Mulliken 08cabf0913 Break up CLI output, dispatch, and help text
format_human dispatches to one function per result kind instead of
inlining every rendering in a single match. Help examples use an
`examples!` macro with one literal per example, and the long_about texts
wrap like their rendered output, replacing strings of up to 250 columns.

`delete` and `purge` are helpers beside `view` and `create`, building
their requests without positional tuples. `auth set` reads as prompt,
prompt, save, and load_credentials returns Credentials directly. The glob
import of `things::*`, which needed a `use std::result::Result` to undo
its shadowing, is replaced with explicit imports. The cache and
credential code share their private-directory helpers.

No behavior change: every help screen is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:44:35 -04:00
docs Document typed delete and the Trash and deletion wire behavior 2026-10-01 15:19:41 +00:00
skills/things-cli Document typed delete and the Trash and deletion wire behavior 2026-10-01 15:19:41 +00:00
src Break up CLI output, dispatch, and help text 2026-10-06 00:44:35 -04:00
tests Delete projects, headings, areas, and tags like Things does 2026-10-01 15:19:41 +00:00
.gitignore Replace the Go implementation with the Rust crate at the repo root 2026-09-30 04:38:09 +02:00
build.rs Add --version with the build's git commit 2026-10-01 15:19:41 +00:00
Cargo.lock Take environment settings verbatim except THINGS_DEBUG 2026-09-30 23:49:32 +02:00
Cargo.toml Take environment settings verbatim except THINGS_DEBUG 2026-09-30 23:49:32 +02:00
LICENSE Replace the Go implementation with the Rust crate at the repo root 2026-09-30 04:38:09 +02:00
Makefile Replace the Go implementation with the Rust crate at the repo root 2026-09-30 04:38:09 +02:00
README.md Document typed delete and the Trash and deletion wire behavior 2026-10-01 15:19:41 +00:00
rustfmt.toml Replace the Go implementation with the Rust crate at the repo root 2026-09-30 04:38:09 +02:00

things-cli

A human-friendly CLI for Things 3 Cloud, written in Rust. clap provides the command interface, figment layers non-secret configuration, and the repository-owned thingscloud module talks to Things Cloud without AppleScript or an external SDK.

Setup

From the repository root:

make install        # cargo build --release, then copy to ~/.local/bin/things
things auth set
things today

auth set prompts privately and stores the email/password as separate mode-600 files under ~/.config/things-cli/. THINGS_USERNAME and THINGS_PASSWORD override those files; set both or neither so credentials cannot be mixed between sources.

Useful commands

things inbox
things today
things upcoming
things anytime
things someday
things logbook                 # newest 50 entries
things logbook --page 2        # next 50 entries
things trash                   # items in Trash
things search "rent"               # matches tasks, projects, areas, and tags
things list projects
things list tasks --area "Home"
things --json today
things --ids today
things create task "Buy groceries" --when today
things create task "Book flight" --when 2026-08-01   # or --when tomorrow
things create task "Pack" --checklist-item Passport --checklist-item Charger
things create project "Home projects" --when anytime
things create heading "Next actions" --project "Home projects"
things create area "Home"
things create tag "Errands" --shorthand err
things add "Buy milk"                      # alias of 'create task'
things show "Buy groceries"        # task, project, or area, whichever matches first
things open                        # bring the Things app forward (macOS)
things open "Buy groceries"        # reveal it in the Things app (macOS)
things open "Buy groceries" --no-activate   # reveal it without stealing focus
things checklist add "Buy groceries" Milk Eggs
things checklist complete "Buy groceries" Milk
things edit "Buy groceries" --deadline 2026-07-31 --tag Errands
things edit "Buy groceries" --clear-deadline --clear-tags
things edit "Buy groceries" --clear-when                  # back to Anytime
things complete "Buy groceries"
things cancel "Old task"
things delete "Old task"                   # move a task to Trash
things delete project "Home projects"      # to Trash, with its to-dos and headings
things delete heading "Next actions"       # to Trash, with the to-dos under it
things delete area "Old job" --yes         # permanent; --force if it has open items
things delete tag "Unused tag" --yes       # permanent; removed from every item
things restore "Old task"                  # un-trash, or reopen if completed/canceled
things purge task "Old task" --yes         # permanent; task must be in Trash
things purge trash --yes                   # permanently empty Trash
things completion zsh
things help
things --version                # e.g. things 0.1.0 (e27328c)

The primary read commands mirror the Things sidebar. things list remains available for all open tasks and structural resources such as projects, areas, and tags.

Commands that identify an existing task accept either its UUID (including a unique prefix) or an exact, case-insensitive title. Relationship flags such as --project, --heading, --area, repeated --tag, and --parent accept titles too. Duplicate titles fail safely and ask for a UUID. Checklist item titles are scoped to the selected task.

open drives the local Things app over AppleScript and is macOS-only. With no selector it just activates Things; with one it resolves the same way show does and reveals that task, project, or area. It is the only command that needs Things installed locally; with no selector it also needs no credentials. --no-activate reveals the item without pulling Things in front of whatever you are working in. If AppleScript fails — most often because macOS Automation permission was never granted — open retries over the things:// URL scheme, which needs no such permission.

show resolves its selector against tasks first, then projects, then areas, and renders whichever kind matches (mirroring the Things GUI's project and area screens, including headings and notes). search is Quick-Find-style: it matches tasks, projects, areas, and tags by substring and labels each result's type.

--when mirrors the GUI's single When picker: inbox, today, evening (Today's This Evening section), anytime, someday, tomorrow, or an explicit YYYY-MM-DD date (Upcoming). --when and --clear-when are mutually exclusive.

--version (-V) prints the package version and the git commit the binary was built from, with -dirty when tracked files had uncommitted changes, so you can tell which build is installed on which machine. A build outside a git checkout (for example from a source tarball) prints just the version.

trash is a read-only view. delete does what deleting does in Things:

  • delete task|project|heading <selector> moves the item to Trash. A trashed project or heading takes its contents with it: they disappear from every view and come back when it is restored. The original untyped delete "<task title or uuid>" still works and, as before, also accepts a project or heading. A selector that is itself a kind name (task, project, heading, area, tag) is read as the kind, so delete a task titled "task" with things delete task task or by UUID.
  • delete area|tag <selector> --yes is permanent, because Things has no Trash for areas and tags. Deleting an area moves its open projects and to-dos to Trash and leaves its completed items in the Logbook without an area; an area with open projects or to-dos is refused unless --force is also given. Deleting a tag removes it from every task, project, and area, and its child tags move up a level.

cancel marks a task canceled in place. restore reverses delete or cancel: it un-trashes a trashed task, project, or heading, or reopens a completed/canceled task back to pending (a task that is both trashed and closed is un-trashed, matching Things' own "Put Back" behavior, without also reopening it). Permanent deletion of anything uses the typed purge task|area|tag commands and always requires --yes; tasks must already be in Trash unless --force is explicit, and purging a project or heading also deletes what it contains. purge trash --yes empties the entire Trash at once, including the contents of trashed projects and headings, as Things does. purge area|tag deletes only the area or tag itself; prefer delete area|tag, which also cleans up the items that refer to it.

Normal output follows Things' lists:

Today
○ Clean the litter boxes
  ✓ Downstairs
  ○ Upstairs

Add --ids to include short IDs in human lists. Add --json when a script or agent needs structured fields; list results use a stable object with view, items, total, and pagination metadata where applicable. Optional non-secret settings are read from ~/.config/things-cli/config.yaml (or config.yml, config.json, config.toml); that directory is $XDG_CONFIG_HOME/things-cli when XDG_CONFIG_HOME is set, and ~/.config/things-cli otherwise on every OS, macOS included. Flags and THINGS_CLI_CACHE, THINGS_ENDPOINT, or THINGS_DEBUG override the file. The mode-600 state cache defaults to things-cli/state.json in the OS cache directory (~/.cache on Linux, ~/Library/Caches on macOS).

Shell completion is dynamic: it uses that cache to suggest task, project, area, tag, heading, and checklist-item titles without making a Cloud request. Register it once per shell:

things completion bash > ~/.local/share/bash-completion/completions/things
things completion zsh > ~/.zfunc/_things
things completion fish > ~/.config/fish/completions/things.fish

Rust API

The CLI is a thin adapter over the library's things module. Callers supply credentials, transport, and cache dependencies explicitly; creating a service does not read credential files, mutate environment variables, or make a network request.

use things_cli::things::{self, Credentials, ListRequest, Options, Service};

let service = Service::new(Options {
    credentials: Credentials {
        username: std::env::var("THINGS_USERNAME")?,
        password: std::env::var("THINGS_PASSWORD")?,
    },
    ..Options::default()
})?;

let today = service.tasks(&ListRequest { view: things::VIEW_TODAY.into(), ..ListRequest::default() })?;

Operations are synchronous, return typed data, and use typed errors (things::Error) for validation, missing or ambiguous references, and unconfirmed destructive requests. Implement things::Cache to reuse synchronized state or provide offline completion values outside the CLI.

Development

make test                                     # cargo test: unit and offline integration tests
cargo fmt --check
cargo clippy --all-targets -- -D warnings
make live                                     # live and destructive, see below

make install builds with cargo build --release and copies target/release/things to ~/.local/bin/things.

The offline integration tests (tests/cloud.rs, tests/config.rs) run the things binary against a fake Things Cloud (tests/common) in a temporary home directory: request sequences and commit payloads, cache reuse and recovery, error surfaces, completion, and settings precedence.

make live runs THINGS_LIVE_TEST=1 cargo test --test live -- --ignored against the real account configured for the CLI. It covers all read views, JSON schemas, project/task creation, search, project filtering, edits, deadlines, Today, completion, trash, and purge. It uses an isolated temporary cache and does best-effort cleanup if a check fails. Checklist wire behavior stays in the local wire tests because the live API does not return generated checklist IDs for reliable cleanup.

Layout

Path Role
build.rs Stamps the git commit into --version
src/main.rs Process entry point; answers completion requests
src/cli/args.rs clap derive definitions: commands, flags, arguments
src/cli/commands.rs Dispatch parsed commands to the service
src/cli/validation.rs Input checks that run before credentials
src/cli/config.rs figment layering of flags, environment, config file
src/cli/completion.rs clap_complete registration and entity candidates
src/cli/ (rest) Credentials, cache file, output, open
src/things/ Typed high-level API (Service)
src/thingscloud/ HTTP client, wire payloads, IDs, models, reducer
tests/cloud.rs, tests/config.rs Offline end-to-end tests against a fake cloud
tests/live.rs Live destructive test (ignored)

There is no things-cloud-sdk dependency.

Crates

Concern Crate
Command line, help, usage errors clap (derive)
Shell completion clap_complete (dynamic CompleteEnv engine, unstable-dynamic feature)
Settings (flag > env > file > default; YAML, JSON, TOML) figment
JSON (output, wire payloads, cache) serde, serde_json (float_roundtrip for correctly rounded wire timestamps, raw_value for commit payloads)
Timestamps (cache, scheduling) chrono (with serde)
Things Base58 identifiers bs58
URL path escaping percent-encoding
User cache / home directory dirs
HTTP reqwest (blocking, rustls)
Other uuid, sha2, crc32fast, base64, hex, tempfile, rpassword; tiny_http for tests

Changes from the Go version

things-cli was previously written in Go. The Rust binary keeps what scripts depend on: the same binary name, commands, aliases, flags and arguments, exit codes, --json output (field names, values, structure), human-readable output, config file, environment variables, credential files, and Things Cloud protocol. If you used the Go binary, note:

Fixes (Go behaved incorrectly):

  • today and anytime include deferred tasks whose start date has arrived, as the Things app does. Go hid them from every view until a Things client marked them started.
  • A task created and scheduled within the same sync batch keeps its scheduled date. Go's replay could drop it, which removed the task from every view. Explicit clears of deadlines, completion dates, and reminders are also replayed in history order now.
  • Overdue deadlines stay in today, as in the Things app. Go showed a deadline only on its day, so an overdue one vanished from every view.
  • anytime leaves out tasks whose start date is still ahead (they are in upcoming) and tasks of projects that are in Someday or not started yet.
  • Repeating templates are recognized by their repeat rule, so a new repeating task shows in upcoming rather than someday before its first instance exists. Templates never appear in today or anytime.
  • Tasks under a heading count as part of the heading's project: --project filters include them, and they are hidden when that project is completed or trashed.
  • edit --project moves a task out of its heading, edit --heading files it under the heading only (the heading implies the project), and --clear-project also leaves the heading. Go left the old link in place, so a moved task could show in two projects. --clear-heading keeps the task in the heading's project, and --heading is looked up within --project when both are given (also for create).
  • Environment settings other than THINGS_DEBUG are taken verbatim: THINGS_CLI_CACHE=0123 is 0123, not 123.
  • logbook --page 0 is an error, like negative pages; Go treated it as page 1.
  • edit <task> needs at least one edit flag; Go accepted a global flag such as --json as the change and failed later.
  • A malformed config file only affects commands that talk to Things Cloud; auth, completion, help, and input validation work without it.

Interface (by design):

  • Help output is clap's layout (Usage:/Commands:/Options:), not Cobra's. things help <command> and --help/-h work as before.
  • Usage errors (unknown command or flag, missing or extra arguments, conflicting flags such as --when with --clear-when) are worded by clap and followed by a usage line. They still exit with status 1, like every other failure.
  • Boolean flags no longer accept an explicit value (--json=false, --debug=false, --help=false are usage errors); omit the flag instead.
  • Shell completion uses clap_complete's protocol instead of Cobra's __complete. Completion scripts installed from the Go binary (or earlier Rust builds) must be regenerated with things completion <shell>. Candidates are otherwise the same (entity titles from the cache, --when keywords); an empty word also offers flags, and ambiguous titles complete to their UUID with the title as the description.

Output and messages:

  • --json output is serde_json pretty-printed: <, >, & are no longer \u003c-escaped and floats use Rust's shortest form. Parsed values are identical.
  • Quoted values in messages use Rust's escapes for control and invisible characters ("a\u{2028}b", "\u{1}") where Go printed "a\u2028b", "\x01". Printable text, including non-ASCII, is unchanged.
  • Validation and input errors are worded the same by the CLI and the library, e.g. invalid --deadline "x" (want YYYY-MM-DD) and at least one change flag is required. Go-specific texts such as EOF, unexpected newline, and unexpected end of JSON input are replaced by plain descriptions (no input, email must not be empty, missing payload, empty response body).
  • File-system errors use Rust's wording, e.g. open /x/config.yaml: No such file or directory (os error 2) instead of ... no such file or directory.
  • Task lists (today, upcoming, anytime, list, search, ...) are ordered by a fixed rule: tasks with a start date first, by that date, then tasks without one; ties by title, then UUID. Go sorted with a non-transitive comparator over random map order, so its order of mixed dated and undated tasks varied between runs.
  • Network errors are worded by reqwest, e.g. login: GET http://127.0.0.1:1/...: client error (Connect): tcp connect error: Connection refused (os error 111) instead of Go's Get "...": dial tcp ...: connect: connection refused.
  • Case-insensitive title matching and search use Rust's Unicode lowercasing instead of Go's per-rune simple folding; results differ only for rare characters whose lowercase form is several characters long (e.g. İ).

Configuration:

  • Default config files are looked up as config.yaml, config.yml, config.json, config.toml in the config directory. An explicit --config file must be YAML, JSON, or TOML by extension (Viper also read HCL, INI, properties, and dotenv).
  • Config keys are case-sensitive (endpoint:, not Endpoint:); Viper lowercased them.
  • THINGS_DEBUG (and debug:) accept true/false (any case) or 1/0; other values are an error instead of silently meaning false.
  • Config and parse error details come from figment / the YAML, JSON, or TOML parser.

State cache:

  • The state cache (things-cli/state.json in the OS cache directory) uses cache format version 3, written compactly with RFC 3339 timestamps and [] for empty lists. Caches written by the Go binary (version 1) were built by its buggy replay, and version 2 caches from earlier Rust builds lack the repeat rule, so the first run discards either and does one full sync. Any unreadable or version-mismatched cache is handled the same way (the Go binary failed instead).

Library:

  • The API is synchronous and has no context.Context; cancellation is process-level.
  • Go decodes JSON object keys case-insensitively; the Rust decoder matches keys exactly (Things Cloud and the cache use exact field names).

License and attribution

The Things Cloud client and the things service were informed by the MIT-licensed upstream Things Cloud SDK; its license notice is kept in LICENSE.

Caveat and sources

Things Cloud has no official public API. This uses a reverse-engineered protocol, so keep normal Things backups and consider a separate account for destructive testing.

See Things Cloud protocol notes for the verified HTTP flow, event envelope, field meanings, cache behavior, and known unknowns in the owned client.