- Rust 99.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| docs | ||
| skills/things-cli | ||
| src | ||
| tests | ||
| .gitignore | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
| rustfmt.toml | ||
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 untypeddelete "<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" withthings delete task taskor by UUID.delete area|tag <selector> --yesis 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--forceis 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):
todayandanytimeinclude 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. anytimeleaves out tasks whose start date is still ahead (they are inupcoming) 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
upcomingrather thansomedaybefore its first instance exists. Templates never appear intodayoranytime. - Tasks under a heading count as part of the heading's project:
--projectfilters include them, and they are hidden when that project is completed or trashed. edit --projectmoves a task out of its heading,edit --headingfiles it under the heading only (the heading implies the project), and--clear-projectalso leaves the heading. Go left the old link in place, so a moved task could show in two projects.--clear-headingkeeps the task in the heading's project, and--headingis looked up within--projectwhen both are given (also forcreate).- Environment settings other than
THINGS_DEBUGare taken verbatim:THINGS_CLI_CACHE=0123is0123, not123. logbook --page 0is 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--jsonas 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/-hwork as before. - Usage errors (unknown command or flag, missing or extra arguments,
conflicting flags such as
--whenwith--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=falseare 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 withthings completion <shell>. Candidates are otherwise the same (entity titles from the cache,--whenkeywords); an empty word also offers flags, and ambiguous titles complete to their UUID with the title as the description.
Output and messages:
--jsonoutput isserde_jsonpretty-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)andat least one change flag is required. Go-specific texts such asEOF,unexpected newline, andunexpected end of JSON inputare 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'sGet "...": 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.tomlin the config directory. An explicit--configfile must be YAML, JSON, or TOML by extension (Viper also read HCL, INI, properties, and dotenv). - Config keys are case-sensitive (
endpoint:, notEndpoint:); Viper lowercased them. THINGS_DEBUG(anddebug:) accepttrue/false(any case) or1/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.jsonin 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.
- Prior Go SDK and original CLI (protocol research and attribution): https://github.com/arthursoares/things-cloud-sdk
- Things Cloud MCP built on the SDK: https://github.com/mattydsmith/things-cloud-mcp
- Python protocol research: https://github.com/disrupted/things-cloud-api
- Reddit discussion: https://www.reddit.com/r/thingsapp/comments/1r5u6mo/a_things_3_cloud_api_go_sdk_for_my_openclaw/