chroncal

DouglasdeMouraMIT活跃维护

终端里的日历、待办和日记管理器,支持 iCalendar 标准与 CalDAV 同步,数据存本地 SQLite,适合喜欢命令行和 TUI 的人管理日程。

Terminal-first calendar, todo, and journal manager with iCalendar (RFC 5545) support and CalDAV sync — CLI + TUI, local SQLite storage

v0.17.5发布于 2026-11-07 15:00 UTC241star +2 / 7 天11fork同步于 2026-11-11 00:01 UTC

怎么上手

由 README 提炼,执行前请确认
curl -fsSL https://raw.githubusercontent.com/DouglasdeMoura/chroncal/master/scripts/install.sh | sh
硬件要求按 README 说明

README 未说明;仅要求跨平台(Linux、macOS、Windows),无 GPU 或显存要求

下载:源码 zip·GitHub Releases(安装包)本站不镜像安装包

项目说明

由 README 自动翻译提炼

终端优先的日历、待办与日志管理器,支持 iCal 与 CalDAV 同步,数据存本地 SQLite

终端里的日历、待办和日记管理器,支持 iCalendar 标准与 CalDAV 同步,数据存本地 SQLite,适合喜欢命令行和 TUI 的人管理日程。

核心能力

  • 提供月、周、日、议程视图的交互式 TUI
  • 完整 CLI 支持事件、待办、日志、闹钟与空闲查询
  • 支持 RFC 5545 的 iCal 导入导出
  • CalDAV 同步含冲突解决与 Google 重新认证
  • 用 RRULE、RDATE、EXDATE 处理重复事件与待办

容易踩的坑

用 go install 需 Go 1.25+,且二进制在 $(go env GOPATH)/bin 需加入 PATH;Linux 上用 Homebrew 安装需 Homebrew 4.5+ 才支持 cask;CalDAV 账号添加需通过 CHRONCAL_PASSWORD 环境变量传密码

内容由模型根据 README 生成,可能不完整或不准确,以官方文档为准。看原文 README

最近发版

5 个版本v0.17.5
  1. v0.17.5最新正式版2026-11-07 · 2026-11-07 15:00 UTC

    📦 Install: see the README. Prebuilt binaries and checksums.txt are attached below.

    Changelog

    🚀 Features

    • 20868c9e99ef547fdb31368879ee68f945aa3c53 feat(tui): add Notion parity speed wedge and local share

    Full Changelog: https://github.com/DouglasdeMoura/chroncal/compare/v0.17.4...v0.17.5

  2. v0.17.42026-10-30 · 2026-10-30 19:25 UTC
    查看更新说明

    📦 Install: see the README. Prebuilt binaries and checksums.txt are attached below.

    Changelog

    🐛 Bug fixes

    • bad5ec1c56fc3c774803179b849155e7f4a7480c fix(caldav): give the CalDAV client its own HTTP transport
    • 16844261e96bf11466840ea6facbe89b6ed2bff6 fix(tui): round a time to the nearest slot in the time grid

    Full Changelog: https://github.com/DouglasdeMoura/chroncal/compare/v0.17.3...v0.17.4

  3. v0.17.32026-10-26 · 2026-10-26 19:49 UTC
    查看更新说明

    📦 Install: see the README. Prebuilt binaries and checksums.txt are attached below.

    Changelog

    🚀 Features

    • 04e2c894e00ea1484ce7539f0ccedc7c514f7e30 feat(journal): edit or delete one occurrence of a recurring journal

    🐛 Bug fixes

    • 076e0375c82035605259fcac4843f694a3f52528 fix(calendar): check the login of a relink and of an account calendar
    • 44b434fbecaeca7dcbef4604ad5e0ac0ea1c1576 fix(calendar): refuse a link with the login of an account of the user
    • 92402136f2522dbf1a3ee68e4f3b850701b8e3e8 fix(cli): refuse a --recurrence-id of spaces only
    • b72b949679522173e4be6b54c4d8ca43466b83d8 fix(cli): trim --recurrence-id before the override lookup
    • 192957922c6617039e8487cc2ab1ffa1a299a274 fix(cli, tui): fix the wording and alignment items of the live test
    • 07870e79ca8bd43f8001744bf9dd5cbd1222e915 fix(ical): give an override the RECURRENCE-ID type of its series
    • cf037aecf318f2eb7f3450b569d2d1b2ab64cc5b fix(ical): give the CONFERENCE property its own Params map
    • 4515c2571333704b57ac070bba658b9035df9b1d fix(ical): scope the series of a RECURRENCE-ID by calendar, keep its date
    • 0973eccdc82413202171fa91af217032186f4dbc fix(sync): close an earlier create conflict when the create links
    • aba558fdfa4399ee96cf6bbcfc7819ae59ed95a4 fix(sync): link a refused create to an object with the same item
    • c7a208d52e01ece8f4e88d63eb16e1c053d2d159 fix(todo, journal): restore only the rows of the series delete
    • 533606bc132c958e883fdeb059759cad6f05848a fix(todo, journal): restore the rows of each later delete of the series
    • 6ce8c2898510c8be847a077dafe56addb283b3c7 fix(tui): draw a danger button of the title line in the danger style

    Full Changelog: https://github.com/DouglasdeMoura/chroncal/compare/v0.17.2...v0.17.3

  4. v0.17.22026-10-25 · 2026-10-25 00:44 UTC
    查看更新说明

    📦 Install: see the README. Prebuilt binaries and checksums.txt are attached below.

    Changelog

    🐛 Bug fixes

    • 0d5d8cec2ffd3c096160a9cb423a5e69e1539c1e fix(alarm): make an alarm UID unique in its item, not in its table
    • 220fde38a8b31989ec4af8f7b263c68dedf14cb7 fix(alarm): mark the alarm UIDs of older builds for the minted-UID repair
    • 05d5d58cf485cec3ae22cce888498c7d8d96dfd4 fix(alarm): repair a minted alarm UID on the evidence of its collision
    • 1350f835f79f1acdc4c7cf928dafde8ec3815241 fix(alarm): repair minted alarm UIDs in a migration, not in each write

    Full Changelog: https://github.com/DouglasdeMoura/chroncal/compare/v0.17.1...v0.17.2

  5. v0.17.12026-10-24 · 2026-10-24 11:20 UTC
    查看更新说明

    📦 Install: see the README. Prebuilt binaries and checksums.txt are attached below.

    Changelog

    🐛 Bug fixes

    • 229196ad3ff6ca63256f1ace37dced5327987c22 fix(alarm): pair the alarms with one shared rule
    • 3937f94aea609b58465691724f67d2a68dee6713 fix(alarm): treat a refused alarm UID as no UID in the alarm write
    • 9b58c30ab22ae0932113c76ac7980129f9a610b0 fix(import): keep the SEQUENCE when a file alarm UID belongs to another owner
    • 8e02015408c2f34af0669fe86ccd8d18084350cf fix(import): pair the alarms and cover each case of a refused alarm UID

    Full Changelog: https://github.com/DouglasdeMoura/chroncal/compare/v0.17.0...v0.17.1

用户点评

登录用户的真实使用记录,可按版本和硬件筛选
共 0 条

    还没有人点评。第一个说出真实体验的人最有价值。

    README 原文

    未翻译

    chroncal

    CI Release Go Reference License: MIT

    chroncal is a terminal calendar. SQLite stores the data. The program supports full iCal import and export, and CalDAV sync. Launch the TUI for an interactive calendar. Use the CLI to script access to events, todos, journals, alarms, free/busy queries, and calendars.

    chroncal keeps your calendar data local and portable. The data follows the calendar standards.

    Features

    • Interactive TUI with month, week, day, and agenda views. Launch it on a given event with chroncal --event.
    • Omarchy menu bar through Chroncal Bar
    • Full CLI for scripts and automation
    • iCal import/export with broad RFC 5545 coverage (VEVENT, VTODO, VJOURNAL, VALARM, VTIMEZONE)
    • CalDAV sync with collection discovery, selective imports, access metadata, conflict resolution, sync health, and Google re-auth
    • Free/busy queries from local data or remote CalDAV VFREEBUSY reports
    • Recurring events and todos via RRULE, RDATE, and EXDATE
    • Recurring journals via RRULE, RDATE, and EXDATE
    • Alarm notifications with desktop alerts, sound, and email
    • Multiple calendars with color codes
    • Full-text search across events, todos, and journals
    • Attendees, attachments, comments, contacts, resources, and relations
    • SQLite storage with automatic migrations
    • Cross-platform (Linux, macOS, Windows)
    • Two output formats: text for humans, JSON for scripts and LLMs

    Installation

    MethodPlatformsBest for
    Install scriptLinux, macOS, FreeBSD, OpenBSDPrebuilt binary users who want one command
    HomebrewmacOS, LinuxManaged installs and upgrades
    Go installAny platform with Go 1.25+Go users and contributors
    misemacOS, LinuxUsers who already manage tools with mise
    NixLinux, macOSnix run and profile installs
    ScoopWindowsManaged Windows installs
    AURArch Linuxyay/paru users (binary or source package)
    Build from sourceAny platform with Go 1.25+Contributors and packagers

    Install script (Linux / macOS / BSD)

    You do not need a Go toolchain. The installer detects your OS and architecture. It downloads the latest release archive. It verifies the archive against checksums.txt. It installs chroncal to /usr/local/bin when possible. If sudo is not available, it uses ~/.local/bin.

    curl -fsSL https://raw.githubusercontent.com/DouglasdeMoura/chroncal/master/scripts/install.sh | sh
    

    Pin an exact version:

    curl -fsSL https://raw.githubusercontent.com/DouglasdeMoura/chroncal/master/scripts/install.sh | env VERSION=v0.2.3 sh
    

    Install somewhere else:

    curl -fsSL https://raw.githubusercontent.com/DouglasdeMoura/chroncal/master/scripts/install.sh | env INSTALL_DIR="$HOME/.local/bin" sh
    

    Do not set VERIFY_CHECKSUM=0 unless your environment cannot run checksum tools. Checksum verification is the recommended path.

    Homebrew (macOS / Linux)

    brew tap douglasdemoura/tap && brew install chroncal
    

    Upgrade:

    brew update && brew upgrade chroncal
    

    Uninstall:

    brew uninstall chroncal
    

    chroncal ships as a Homebrew cask (prebuilt binary). On Linux this needs Homebrew 4.5+ (April 2025). That version added cask support. GoReleaser pushes the cask to DouglasdeMoura/homebrew-tap on each release when the HOMEBREW_TAP_TOKEN repository secret is set. Installs from the old formula move to the cask on brew upgrade.

    If Homebrew is not available for a new release, use the install script, mise, Nix, or go install.

    Go install

    You need Go 1.25+.

    go install github.com/douglasdemoura/chroncal/cmd/chroncal@latest
    

    Pin an exact release:

    go install github.com/douglasdemoura/chroncal/cmd/[email protected]
    

    go install puts the binary in $(go env GOPATH)/bin/chroncal. Put that directory on your PATH.

    mise

    Install the latest GitHub release globally:

    mise use -g github:DouglasdeMoura/chroncal
    

    Pin an exact release globally:

    mise use -g github:DouglasdeMoura/[email protected]
    

    If a new release does not appear yet, clear the mise GitHub release cache first:

    mise cache clear
    mise ls-remote github:DouglasdeMoura/chroncal
    

    Nix

    Run without an install:

    nix run github:DouglasdeMoura/chroncal
    

    Install to your profile:

    nix profile install github:DouglasdeMoura/chroncal
    

    Build the package from a clone:

    nix build .#chroncal
    

    The flake exposes packages.default, packages.chroncal, apps.default, homeModules.chroncal, homeModules.default, and a developer shell with Go, GoReleaser, golangci-lint, govulncheck, and sqlc.

    home-manager

    The flake also exposes a home-manager module. The module installs the program and writes $XDG_CONFIG_HOME/chroncal/config.toml.

    Add the input to your flake.nix:

    {
      inputs = {
        nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
        home-manager.url = "github:nix-community/home-manager";
        chroncal.url = "github:DouglasdeMoura/chroncal";
      };
    }
    

    Then import the module in your home-manager configuration:

    { inputs, ... }:
    {
      imports = [ inputs.chroncal.homeModules.default ];
    
      programs.chroncal = {
        enable = true;
        settings = {
          product_id = "-//example//chroncal//EN";
          ui = {
            theme = "default";
            week_start = "monday";
          };
          soft_delete.purge_days = 30;
          sync.conflict_strategy = "prompt";
          calendars = [
            { name = "Fastmail"; path = "~/calendars/fastmail"; }
            { name = "Work"; color = "#0F766E"; }
          ];
        };
      };
    }
    

    The calendars list declares the local calendars. See Declared calendars.

    The module has three options:

    OptionTypeDescription
    programs.chroncal.enablebooleanInstalls the package and writes the config file.
    programs.chroncal.packagepackage or nullThe package to install. The default is the package of this flake for the system of the host. Set it to null to install the program with another method.
    programs.chroncal.settingsattribute setThe content of config.toml. See Config keys for each key.

    Do not put a secret in settings. The Nix store is readable for every user of the machine. Pass a secret through an environment variable, for example CHRONCAL_SMTP_PASSWORD.

    On macOS the module writes the file to ~/Library/Application Support/chroncal/config.toml, because the program reads that directory. With xdg.enable = true the module writes the file to xdg.configHome instead.

    Scoop (Windows)

    Windows users can install with:

    scoop bucket add chroncal https://github.com/DouglasdeMoura/scoop-bucket
    scoop install chroncal
    

    Upgrade:

    scoop update chroncal
    

    GoReleaser generates the manifest. It pushes the manifest to DouglasdeMoura/scoop-bucket on each release.

    Arch Linux AUR

    The project publishes two AUR packages:

    yay -S chroncal-bin  # prebuilt Linux binary from GitHub Releases
    yay -S chroncal      # builds from source with your local Go toolchain
    

    chroncal-bin is fastest for x86_64 and aarch64 users. Use chroncal when you want to build locally or use another Arch-supported CPU target. GoReleaser generates both packages (aurs and aur_sources in .goreleaser.yml). It pushes them to the AUR on each release.

    Build from source

    git clone https://github.com/DouglasdeMoura/chroncal.git
    cd chroncal
    make build
    ./chroncal version
    

    Run the test suite before you send changes:

    go test ./...
    

    Maintainer checklist

    Before you create a release:

    1. Make sure CI passes on master.
    2. Bump the VERSION file to the new version (no v prefix). The release workflow does not run if the file does not match the tag.
    3. Run goreleaser check locally if GoReleaser is installed.
    4. Create a v* tag. Push the tag.
    5. Confirm the GitHub Release includes archives, checksums.txt, and install snippets.
    6. Confirm the install script works for the new tag.
    7. Confirm brew tap douglasdemoura/tap && brew install chroncal works after the Homebrew tap update.
    8. Confirm scoop update chroncal sees the new Scoop manifest.
    9. Confirm the AUR packages exist at https://aur.archlinux.org/packages/chroncal and https://aur.archlinux.org/packages/chroncal-bin.
    10. Confirm go install github.com/douglasdemoura/chroncal/cmd/chroncal@<tag> works.
    11. Confirm mise use -g github:DouglasdeMoura/chroncal@<version> resolves the release.

    Do not rerun a failed release job while old assets still exist. GoReleaser does not overwrite release assets that already exist. The rerun fails with already_exists before it reaches the package publishers.

    Delete the assets first. Then rerun the failed job. The release object and its notes stay. All channels then get hashes that match the new assets:

    for a in $(gh release view vX.Y.Z --json assets --jq '.assets[].name'); do
      gh release delete-asset vX.Y.Z "$a" -y
    done
    gh run rerun <run-id> --failed
    

    Required repository secrets:

    SecretPurposeRequired
    GITHUB_TOKENGitHub Actions token. Publishes release assets.Yes
    HOMEBREW_TAP_TOKENPersonal access token with write access to DouglasdeMoura/homebrew-tapNo. Homebrew updates need this token.
    SCOOP_BUCKET_TOKENPersonal access token with write access to DouglasdeMoura/scoop-bucketNo. If it is absent, Scoop updates use HOMEBREW_TAP_TOKEN when that token has access.
    AUR_KEYUnencrypted SSH private key for the AUR maintainer accountNo. Without it, the workflow skips AUR publish.

    The flake reads its version from the VERSION file. Nix needs no per-release edits. When go.mod or go.sum changes, the flake vendorHash must change with it. The Nix CI workflow builds the flake on any PR that changes those files. The build fails on a mismatch.

    To fix a mismatch, run nix build .#chroncal. Copy the got: hash into flake.nix. Then run the build again. The update-flake-lock workflow opens a PR that refreshes the flake inputs each month, on demand, and after a successful release workflow.

    GoReleaser publishes all of these in one run: release assets, the Homebrew cask, the Scoop manifest, and both AUR packages. There are no manual package steps.

    A later release can add .deb and .rpm assets with GoReleaser nFPM. Do this after the primary package manager channels are stable.

    Quick start

    # Launch the interactive TUI
    chroncal
    
    # Open the TUI on a specific event (optional occurrence time)
    chroncal --event 42
    chroncal --event 42 --at 2026-04-17T14:00:00Z
    
    # Create a calendar
    chroncal calendar create "Work" --color "#3B82F6"
    
    # Add an event
    chroncal event add "Team standup" --date 2026-04-01 --time 09:00 --duration 30m --calendar Work
    
    # Add an event with a relative date (today, tomorrow, a weekday, or +7d)
    chroncal event add "Birthday party" --date tomorrow --time 17:00 --calendar "Personal"
    
    # Add a recurring event
    chroncal event add "Weekly review" --date 2026-04-04 --time 14:00 --duration 1h --rrule "FREQ=WEEKLY;BYDAY=FR"
    
    # Add a todo
    chroncal todo add "Write quarterly report" --due 2026-04-15 --priority 1
    
    # Add a journal entry
    chroncal journal add "Weekly notes" --date 2026-04-04 --calendar Work
    
    # List upcoming events
    chroncal event list --from 2026-04-01 --to 2026-04-30
    
    # List this week with relative dates
    chroncal event list --from today --to +7d
    
    # Search
    chroncal event search "standup"
    
    # Import from iCal (one file, or a directory of .ics files)
    chroncal ical import calendar.ics --calendar Work
    chroncal ical import ~/.calendars/work --calendar Work
    
    # Export to iCal
    chroncal ical export --calendar Work -f work.ics
    
    # Add one CalDAV account; every usable calendar is discovered, imported, and synced
    CHRONCAL_PASSWORD="…" chroncal account add "Work server" \
        --server https://cal.example.com/dav/ --username alice --auth basic
    chroncal account calendars list "Work server"
    
    # Run sync and inspect status
    chroncal sync run --calendar Work
    chroncal sync status
    
    # Compute local free/busy for a range
    chroncal freebusy --calendar Work --from 2026-04-01 --to 2026-04-30
    

    CLI reference

    Events

    chroncal event list           [--from DATE] [--to DATE] [--calendar NAME] [--status STATUS] [--include-deleted] [--compact | --detail | --verbose]
    chroncal event get            <id|uid> [--recurrence-id ID]
    chroncal event search         <query> [--calendar NAME] [--from DATE] [--to DATE] [--status STATUS]
    chroncal event add            "<title>" [flags]
    chroncal event update         <id|uid> [flags] [--recurrence-id ID]
    chroncal event rsvp           <id|uid> --status ACCEPTED|DECLINED|TENTATIVE
    chroncal event delete         <id|uid> [--recurrence-id RFC3339] [--following RFC3339] [--calendar NAME] [--series] [--yes]
    chroncal event restore        <id|uid>
    chroncal event purge          <id> [--yes]
    chroncal event purge-deleted  [--older-than DURATION] [--yes]
    

    Event flags: --date, --time, --end-time, --duration, --timezone, --location, --description, --calendar, --status, --class, --transparency, --priority, --url, --categories, --geo, --rrule, --exdate, --rdate, --attach, --alarm, --attendee, --organizer, --contact, --resource, --comment, --related-to

    event list --verbose shows each event on a time rail, with its time, title, location, and description. A | starts each line of the description. The colors match the --compact table. Chroncal shows colors only on a terminal. Set NO_COLOR to turn them off.

    event update --calendar NAME moves an event to another calendar. A series moves as one unit: the master and all its overrides go together. Chroncal refuses --calendar together with --recurrence-id, because one occurrence cannot leave its series. The error names the ID of the series to move instead. One exception: with a UID reference, --calendar NAME names the calendar of the series when that calendar holds the series. Use it when two calendars hold the UID. Chroncal also refuses a move when the target calendar holds a copy of the same UID, live or in the trash. On a remote calendar, sync creates the object in the target calendar first. Then it deletes the object in the source calendar. todo update and journal update use the same rules.

    event delete supports three scopes for a recurring series. Pass the series by ID or UID:

    • --recurrence-id <RFC3339> deletes one occurrence. chroncal writes an EXDATE on the master and deletes any override at that time.
    • --following <RFC3339> deletes that occurrence and every following one. chroncal truncates the series at that date.
    • --series deletes the master and all overrides.

    The three flags are mutually exclusive.

    Two calendars can hold a series with the same UID, for example after a sync of two collections. A bare UID then names no single series. Pass the numeric ID of the master, or pass --calendar NAME to name the calendar of the event. The flag works with every scope, with --series, and with a plain delete. Chroncal refuses a numeric ID together with a --calendar that does not hold that row.

    event restore <id> with the ID of a master reverses the series delete. The master and the overrides that the series delete hid come back. An occurrence that you deleted on its own before stays deleted. event restore <uid> reverses a series delete of the UID. An occurrence that you deleted on its own does not count, and neither does a series with only truncated rows. Restore such a row by its ID. When two calendars hold a series delete of the UID, restore the series by the ID of its master.

    event rsvp sets your RSVP status on an event:

    chroncal event rsvp 42 --status ACCEPTED   # aliases: yes/y, no/n, maybe/m
    

    The calendar needs an owner email (calendar update --email). You must be an invited attendee, not the organizer. The command writes the local attendee row. It does not send an iTIP reply.

    Todos

    chroncal todo list           [--calendar NAME] [--status STATUS] [--all] [--from DATE] [--to DATE] [--include-deleted]
    chroncal todo get            <id|uid> [--recurrence-id ID]
    chroncal todo search         <query> [--calendar NAME] [--status STATUS] [--completed] [--incomplete]
    chroncal todo add            "<summary>" [flags]
    chroncal todo update         <id|uid> [flags] [--recurrence-id ID]
    chroncal todo complete       <id|uid> [--recurrence-id ID]
    chroncal todo delete         <id|uid> [--recurrence-id ID] [--series] [--yes]
    chroncal todo restore        <id|uid>
    chroncal todo purge          <id> [--yes]
    chroncal todo purge-deleted  [--older-than DURATION] [--yes]
    

    Todo flags: --due, --start, --duration, --location, --description, --calendar, --status, --progress, --class, --priority, --url, --categories, --geo, --rrule, --exdate, --rdate, --attach, --alarm, --attendee, --organizer, --contact, --resource, --comment, --related-to

    --recurrence-id <RFC3339 or YYYY-MM-DD> with the UID of a recurring todo names one occurrence:

    • todo update edits that occurrence. todo complete completes it. When the occurrence has no override yet, chroncal creates one from the occurrence. The series and the other occurrences do not change.
    • todo delete deletes that occurrence. chroncal writes an EXDATE on the master and puts the override in the trash. todo restore <id> brings the occurrence back.
    • An occurrence stays in the calendar of its series. --recurrence-rule, --exception-date-times, and --recurrence-date-times apply to the series, so todo update refuses them on a new override.
    • A new override takes the alarms, the attendees, and the other child rows of the series. A flag such as --alarm then replaces them on the override only.
    • A time that is not an occurrence of the series gives an error that says so. A todo that does not recur gives an error too.

    Journals

    chroncal journal list           [--from DATE] [--to DATE] [--calendar NAME] [--status STATUS] [--all] [--include-deleted]
    chroncal journal get            <id|uid> [--recurrence-id ID]
    chroncal journal search         <query> [--calendar NAME] [--from DATE] [--to DATE] [--status STATUS]
    chroncal journal add            "<summary>" [flags]
    chroncal journal update         <id|uid> [flags] [--recurrence-id ID]
    chroncal journal delete         <id|uid> [--recurrence-id ID] [--series] [--yes]
    chroncal journal restore        <id|uid>
    chroncal journal purge          <id> [--yes]
    chroncal journal purge-deleted  [--older-than DURATION] [--yes]
    

    Journal flags: --date, --description, --calendar, --status, --class, --url, --categories, --rrule, --exdate, --rdate, --attach, --attendee, --organizer, --contact, --comment, --related-to

    --recurrence-id <RFC3339 or YYYY-MM-DD> with the UID of a recurring journal entry names one occurrence:

    • journal update edits that occurrence. When the occurrence has no override yet, chroncal creates one from the occurrence. The series and the other occurrences do not change.
    • journal delete deletes that occurrence. chroncal writes an EXDATE on the master and puts the override in the trash. journal restore <id> brings the occurrence back.
    • An occurrence stays in the calendar of its series. --rrule, --exdate, and --rdate apply to the series, so journal update refuses them on a new override.
    • A new override takes the attendees, the attachments, and the other child rows of the series. A flag such as --attendee then replaces them on the override only.
    • A time that is not an occurrence of the series gives an error that says so. A journal entry that does not recur gives an error too.

    CalDAV accounts

    chroncal account add              "<name>" --server URL --username USER [--auth {basic,bearer,oauth2}] [--password-cmd CMD] [--oauth-client-id ID] [--allow-insecure]
    chroncal account get              <name|id>
    chroncal account list
    chroncal account update           <name|id> --name NAME
    chroncal account credentials      <name|id> [--password-cmd CMD]
    chroncal account reauth           <name|id> [--oauth-client-id ID]
    chroncal account calendars list   <name|id>
    chroncal account calendars add    <name|id> (--calendar NAME_OR_URL ... | --all)
    chroncal account calendars set    <name|id> (--calendar NAME_OR_URL ... | --all | --none) [--default ID_OR_NAME_OR_URL] [--yes]
    chroncal account remove           <name|id> [--yes]
    

    An account stores one credential. It discovers every CalDAV calendar collection that the credential can reach. account add imports every collection that supports VEVENT, VTODO, or VJOURNAL. It completes an initial sync before it returns.

    account calendars list refreshes the complete inventory. The list includes selected, read-only, unsupported, and no-longer-available collections. account calendars add is additive and idempotent for collections that appear later. Use --calendar more than once to add chosen collections. Use --all to add every usable collection. The command syncs new collections before it returns.

    account calendars set replaces the exact local selection. After confirmation, Chroncal deletes deselected calendars and their downloaded data. It never deletes the remote collections. If you deselect the current default calendar, pass --default with a retained local calendar or a new remote collection. The command syncs new collections before it returns.

    One remote collection links to one local calendar only. account calendars list shows a collection that another local calendar links as linked by <calendar>. account add and --all skip such a collection. When other calendars link each usable collection, account add stops with an error and removes the new account. --calendar refuses such a collection with an error. Remove the other link first, for example with calendar update <calendar> --disconnect-remote. The check compares the collection URL and the user name of the account. It ignores the case of the user name.

    If you select none, the command also removes the empty account and credential. account remove deletes the credential and remote links. It keeps downloaded calendars as local copies. A later account add of the same server links these calendars again. An edit, a delete, or a restore of a synced item in that time goes to the server on the first sync after the link. Until these changes are on the server, sync uses the prompt strategy for the calendar, also when it is otherwise server-wins. When the server changed the item too, the sync records an open conflict and keeps your edit. Resolve it with chroncal sync resolve. A read-only collection cannot take the edit, so the server version replaces it.

    account credentials rotates the stored secret of a basic or bearer account. It reads the new value from CHRONCAL_PASSWORD (basic) or CHRONCAL_BEARER_TOKEN (bearer). For a basic account, --password-cmd stores a command instead of a password. See Command-retrieved passwords. account reauth repeats the Google OAuth consent flow for an oauth2 account; the client secret comes from GOOGLE_CLIENT_SECRET or the stored value, and --oauth-client-id replaces the stored client ID. Neither command accepts a secret as a CLI flag.

    You can open read-only collections locally and sync them pull-only. Chroncal does not send metadata changes, resources, or tombstones to them.

    Credential storage

    Chroncal keeps an account credential in the OS keyring: GNOME Keyring or KWallet on Linux, the Keychain on macOS, the Credential Manager on Windows. This needs no setup on a full desktop.

    Many terminal setups run no keyring. A tiling window manager such as sway is a common example. You have three options on such a host.

    Option 1 — Use a password command (no opt-in). The credential holds the command, never the password. The command must get the password from your password manager. Chroncal runs the command at each sync and reads the password from your password manager. This needs no keyring and no flag:

    chroncal account add "Nextcloud" \
        --server https://cloud.example.com/remote.php/dav/ --username scott --auth basic \
        --password-cmd "pass show caldav/nextcloud"
    

    The TUI offers the same field. Open Calendars, then + Add, then Add Account…, and fill Password cmd instead of Password. See Command-retrieved passwords.

    Option 2 — Install a keyring provider. On Linux, install libsecret and gnome-keyring (or KWallet). Start the daemon with your session. Chroncal then finds the keyring with no configuration.

    Option 3 — Opt in to plaintext storage. Chroncal writes the credential to a 0600-mode file under ~/.config/chroncal/credentials/. Set the key once, so the TUI and every command agree:

    [security]
    allow_plaintext = true
    

    --allow-plaintext and CHRONCAL_SECURITY_ALLOW_PLAINTEXT=true do the same for one run. Both apply to the TUI: start it with chroncal --allow-plaintext. Prefer the config key. It holds for every run, and a TUI session that already runs cannot take a flag.

    The TUI can also set the key for you. When Add Account… or Update Credentials refuses a password or a token, the TUI shows a prompt with the trade-off below. Cancel has the focus, so a quick Enter writes nothing. Select Store in Plaintext to agree. The TUI then writes allow_plaintext = true to the [security] table of the config file, and it keeps the other lines of the file. It saves the secret that you typed, with no restart. If the TUI cannot edit the file safely, it changes nothing and shows the error. Then set the key by hand.

    Read the trade-off first. The 0600 mode blocks a casual cat. It does not block backups, filesystem snapshots, or sync tools (Dropbox, iCloud, rsync) that ignore Unix permissions. A password command keeps the secret out of the file, so prefer option 1 when your password manager supports it.

    CHRONCAL_SECURITY_DISABLE_KEYRING=1 turns the OS keyring off for the process that has it in its environment. Chroncal then behaves as if no keyring exists: it falls back to the file store under the same allow_plaintext rule. Set it on a host where the keyring is locked or absent but the session still runs, for example over SSH. The test suite sets it too, so tests never read or write the real keyring. It is an environment variable only. No config key has the same function.

    Use the switch for all runs or for no runs. With the switch, chroncal does not read the credentials that the keyring holds. An account with a keyring credential then fails to sync. A new or refreshed credential goes to the file store, and a run without the switch does not read it. account remove with the switch leaves the keyring entry in place.

    What needs the opt-in, and what does not:

    CredentialKeyring absent, no opt-in
    Basic auth with --password-cmdWorks. The file holds the command, not the password.
    Basic auth with a passwordRefused for a new account.
    Bearer tokenRefused for a new account.
    OAuth 2.0 (Google)Refused for a new account. Chroncal does not open the browser.
    A credential an earlier run wroteRead and rewritten in place. Sync, discovery, and free/busy keep working.

    The last row wins over the three above it. An account whose file already holds a secret keeps that permission: you can rotate its password, reconnect it, and refresh its OAuth token with no keyring and no opt-in. Chroncal refuses a secret only where it would put a new one on the disk.

    Each refusal names the three remedies above. Chroncal refuses before it does the work, but the two front ends ask you for the secret at different times:

    • The CLI stops before the prompt.
    • The TUI takes the dialog first. It stops when you submit the dialog. It does not contact the server and it does not add the account. For a password or a token, it then offers the plaintext opt-in that option 3 describes.

    A read is always permitted, so an account you added once with --allow-plaintext keeps syncing on a later run without the flag. Chroncal also permits a rewrite of that same file. An OAuth access token expires after about an hour. Chroncal then writes the new token to the file it already wrote, and the account keeps working. The same applies to a credential that an older version of chroncal wrote: chroncal moves it to the current location and keeps it usable.

    Calendars

    chroncal calendar list
    chroncal calendar get     <id>
    chroncal calendar create  "<name>" [--color HEX] [--description TEXT] [--email ADDR] [--path DIR] [--default-alarm SPEC]... [remote flags]
    chroncal calendar update  <id|name> [--name NAME] [--color HEX] [--description TEXT] [--email ADDR] [--path DIR] [--default-alarm SPEC]... [--clear-default-alarms] [remote flags] [--disconnect-remote]
    chroncal calendar delete  <id>
    chroncal calendar set-default <id|name>
    chroncal calendar hide     <id|name>
    chroncal calendar show     <id|name>
    

    set-default makes a calendar the default. New events, todos, and journals without an explicit --calendar go there. Exactly one calendar is the default at any time.

    calendar hide opts a calendar out of the TUI sidebar without deleting it: events stay in the database and still sync. calendar show reverses it. Calendar JSON (--output json) carries the link and sync state per calendar: account_id, account_name, remote_url, remote_access, last_sync_at, last_sync_error, last_sync_ok, and hidden.

    You can still use remote flags with create or update to attach one local calendar to a known CalDAV collection. Prefer chroncal account when one credential exposes multiple collections:

    --remote-url <href>
    --username <user>
    --auth {basic,bearer,oauth2}
    --oauth-client-id <id>
    --allow-insecure
    

    For script setup, the commands read credentials from environment variables, not from prompts: CHRONCAL_PASSWORD (basic), CHRONCAL_BEARER_TOKEN (bearer), and GOOGLE_CLIENT_SECRET (oauth2). The commands do not accept these values as CLI flags.

    A basic account also accepts --password-cmd or CHRONCAL_PASSWORD_CMD. The value is a command, not a secret, so a flag is safe. See Command-retrieved passwords.

    Pass --disconnect-remote on update to remove the remote link of a calendar.

    --remote-url refuses a collection that another local calendar links with the same user. Two links of one collection show each item twice. The link makes a new account for the calendar. It does not reuse the account of the other calendar.

    --remote-url also refuses the login of an account that you added: the same server, user name, and auth type. This applies to a new link, to a calendar of that account, and to a relink that changes its login. A relink with the same login, for example a new password, passes. One login then keeps one account. The error names the account. Add the collection through that account with chroncal account calendars add <account> --calendar <name>. The link does not reuse that account: a removal of the account would unlink the calendar, and account calendars set could delete it.

    calendar create --default-alarm and calendar update --default-alarm set the per-calendar default alarms. See Default alarms.

    --path names a directory of .ics files that feeds the calendar. A sync tool such as vdirsyncer or pimsync writes one .ics file for each event there. calendar get shows the directory on the path row, and the TUI shows it on the Location row of the calendar detail. chroncal ical import without a path then imports the calendar from that directory. Pass --path "" on update to remove the directory. The [[calendars]] table in config.toml sets the same field declaratively. See Declared calendars.

    iCal import/export

    chroncal ical import  [file.ics|directory] [--calendar NAME]
    chroncal ical export  [--calendar NAME] [--from DATE] [--to DATE] [--category TEXT] [--status TEXT] [-f FILE] [--events] [--todos] [--journals] [--skip-unreadable]
    

    The import path is one .ics file or one directory. For a directory, chroncal reads every .ics file in that directory and in its subdirectories, and imports them into one calendar. A tool such as vdirsyncer writes one .ics file for each event, so point the import at the collection directory:

    # One collection
    chroncal ical import ~/.calendars/work --calendar Work
    
    # Every collection below one parent directory, merged into one calendar
    chroncal ical import ~/.calendars --calendar Personal
    

    Directory rules:

    • chroncal skips every file and directory whose name starts with a dot. A version-control directory and a partial file from a sync tool stay out of the import.
    • chroncal skips every file without an .ics extension, for example the displayname and color files that vdirsyncer writes.
    • A file that fails to parse becomes a warning. The rest of the directory still lands.
    • chroncal reads at most 10000 .ics files in one run.
    • The import does not follow a symbolic link that the path names. It also skips a symbolic link inside the directory. A stored directory is an exception. See the batch rules below.

    Entries are matched by UID, so a second import of the same path updates the existing items. An item that did not change keeps its row as it is. Its SEQUENCE stays, and the summary counts it as unchanged, not as updated. The comparison ignores DTSTAMP, CREATED, LAST-MODIFIED, and the order of the alarms. It also ignores the UID of an alarm in two cases:

    • The file gives the alarm no UID, so the store gives one.
    • An earlier alarm of the same item has the UID of the file. An alarm UID is unique in its item, so the store keeps a different UID for the second alarm.

    A change of any other alarm UID counts as a change of the item.

    Without a path, the command imports every calendar that has a directory. Set the directory with calendar create --path, calendar update --path, or a [[calendars]] entry in config.toml. With --calendar, the command imports only that calendar from its directory. Run the command after each pass of the sync tool, for example from a timer.

    The JSON output is one object when the command names a path or a calendar. It is an array in the batch mode without both. The batch text output names each calendar.

    The command follows a symbolic link at the root of a stored directory. A vdirsyncer layout often links the collection directory to a mounted drive. A stored directory that holds no .ics file imports nothing and succeeds without a warning.

    In the batch mode, a stored directory that does not exist yet imports nothing and gives a warning. A timer job then does not fail before the first sync. With --calendar, a missing directory is an error. Another failure in one calendar becomes an error on stderr, and the other calendars still land.

    Examples:

    # Every calendar with a directory
    chroncal ical import
    
    # One calendar from its directory
    chroncal ical import --calendar Fastmail
    

    An event UID that another calendar holds gets its own copy in the import calendar. A todo or a journal cannot have a copy, because its UID is unique across all calendars. The import never moves such an item and never writes it into another calendar:

    • When the UID lives in another local calendar, the import skips the item. A warning names both calendars, and the summary prints Skipped N item(s) that live in another calendar. The JSON output has skipped_todos and skipped_journals. A skipped item is not a failure, so a directory import from a timer does not fail. After todo update --calendar, the old directory still holds the file, and each run skips it.
    • When the UID lives in another synced calendar, live or in the trash, the import refuses the item, and the item counts as failed. The warning names the calendar. The other items of the file still land.
    • The rules take any row of the UID: an override of a series, or a master with overrides, follows the calendar of the other rows. The import then never splits a series.

    Imports have size limits to reduce resource exhaustion from untrusted calendar data. chroncal ical import rejects .ics payloads larger than 8 MiB. It also rejects inline base64 attachments larger than 1 MiB decoded. The limit applies to each file, not to a whole directory.

    The export aborts when one relation read fails. It writes no file, so no incomplete backup exists. Pass --skip-unreadable to continue past records with unreadable relations. The file then carries a comment header that names each incomplete record. The command also lists those records on stderr.

    Sync

    chroncal sync run       [--calendar NAME] [--account NAME] [--conflict MODE]
    chroncal sync status
    chroncal sync conflicts [--resolved]
    chroncal sync resolve   <id> --pick {local,server}
    chroncal sync reset     [--calendar NAME]
    

    Sync runs on each connected calendar on its own. Calendars that share an account reuse its credential and sync one after another. Distinct accounts can sync at the same time. Use chroncal account calendars list and chroncal account calendars add to inspect and import remote collections. Use the calendar remote flags above when you attach one known URL.

    When you attach a local calendar, the next sync uploads its items. Some items stay local:

    • A read-only collection gets no item.
    • A collection that does not support a component (for example VTODO) gets no item of that component.
    • An override without its series stays local.

    sync status shows one line for each connected calendar. last_sync is the time of the last clean sync. last_attempt is the time of the last sync, clean or not. last_error is the error of the last sync, or - when it was clean. A failed sync keeps last_sync, so you can see when the last clean sync was. The TUI calendar detail shows that time below the error. In JSON, a value in last_sync_at does not mean that the last sync was clean. Read last_sync_ok for that: it is true only when a clean sync happened and no error followed it. A local calendar has no last_sync_ok. A link to another account, collection, server, user, or authentication type clears last_sync_at, last_attempt, and last_error.

    Narrow a run with --calendar NAME (one local calendar) or --account NAME (every calendar linked to one CalDAV account). The two flags are mutually exclusive. An account with no linked calendars is a clean no-op.

    Conflict handling:

    • Every write fires an opportunistic push. A delete and a restore are writes too. When the server answers 412, chroncal records a conflict and keeps the local edit. The edit stays dirty. The command prints a note on stderr. The note tells you to run chroncal sync conflicts.
    • The conflict strategy (server-wins or prompt) governs full sync passes only. The default is prompt; set server-wins in config.toml under [sync] to adopt the server body automatically. A full pass is sync run, the background service, or a TUI sync. A server-wins pass adopts the server body, clears the dirty flag, and marks the conflict resolved as server-auto. A prompt pass records the conflict and skips that row until you resolve it.
    • A resolved conflict keeps its row. Run chroncal sync conflicts --resolved to list resolved conflicts. Run chroncal sync resolve <id> --pick local on a resolved conflict to restore the recorded local version.
    • One exception applies to a move conflict. A new move of the item drops the resolved move conflicts of the earlier move in the same account. Their recorded versions describe a move that no longer exists.
    • Some servers keep a UID unique in the whole account (for example, iCloud). Such a server refuses the create of a moved copy while the source object exists. Sync then deletes the source object and sends the create again. When the server still refuses the create, sync waits a short time and sends it again, at most two more times. After that, the next sync tries again.
    • A create never overwrites an object on the server. When the collection holds an object at the same href, sync records a conflict and keeps both versions. This can occur on the first sync after you attach a local calendar.
    • When that object holds the same item as the local copy, sync records no conflict. The local item links to the object. The comparison ignores SEQUENCE, DTSTAMP, CREATED, LAST-MODIFIED, the alarm UIDs, and the order of the alarms, the categories, and the attendees. A conflict that an earlier push recorded closes when a later push reaches the item, for example the push after a local edit or a server-wins sync. A full sync with the prompt strategy leaves it open. Resolve it with chroncal sync resolve.
    • A remote delete can meet a local edit that sync did not send yet: an edit, a restore from the trash, or a move back into the calendar. The local edit wins. Sync creates the item again on the server and prints a note. A server-wins pass keeps the remote delete, and the item goes to the trash. A read-only calendar and an item with an open conflict also keep the remote delete.
    • A move between two calendars of one account can meet a remote edit. The item changed on the server in the source calendar before sync deleted it there. Sync then records a move conflict on the target calendar, and the source object stays on the server. When the server refuses the create of the copy, the moved copy stays unsent. When the create already landed, the copy is on the server in the target calendar. A cancel then deletes that copy on the next sync. A server-wins pass does not pick a side for you. Sync closes a move conflict by itself in three cases only, and an unlink of the source calendar ends it as detached. The conflict becomes abandoned when the moved copy is gone. A failed cancel becomes cancelled when a pull of the source brings the server version. The move becomes finished when the source object is gone from the server. The sync conflicts line shows moved-from-calendar=<id> and move=<state>. The JSON output has move_source_calendar_id and move_state. The state decides what each pick does:
      • open: no pick ran yet. --pick local completes the move: the next sync deletes the source object and creates the target object. --pick server cancels the move. The pick reads the current source object from the server. The item then goes back to the source calendar with that version. When the move already created a copy in the target calendar, the next sync deletes that copy.
      • local-picked: --pick local ran. The picks work as for open, so a second --pick server still cancels the move until the source object is gone.
      • finished: the source object is gone from the server, so the next sync creates the moved copy. Both picks refuse. Move the item again instead.
      • When the source object is gone and the move is still open, the move cannot be cancelled. No server version exists to bring back. Both picks follow one rule:
        • The target calendar can carry the copy. This is true in three cases. The copy landed on the server already, and it has no unsent edit in a read-only calendar. The target is a local calendar. The target is linked and writable, and the organizer gate does not hold the copy back.
        • Then both picks finish the move and exit with status 0. sync resolve prints the source is gone, so the move finished, with what remains. For example, the next sync creates the moved copy.
        • The -o json output of a finished move has "outcome": "finished", "picked": "none", and a note. No side ran, so picked is none. resolved stays true, because the conflict is closed. A normal pick has "outcome": "resolved" and the side that ran in picked.
        • A cancel-failed move has its rows back in the source calendar already, and they can hold a part of the failed import. When its source object is gone, --pick server refuses and changes nothing. The conflict stays cancel-failed.
        • Then use --pick local. It imports your recorded version over those rows and closes the conflict as cancelled. The source calendar gets no server object, so the next sync creates the item there. A read-only source calendar refuses this pick.
        • This pick asks the server first whether the source object is gone. When the server cannot be reached, the pick refuses and changes nothing. Try again when the server answers.
        • A move can also lose its source half in the database. Then nothing remains to wait for, so both picks finish the move, whatever the target is. When the target calendar cannot create the copy, the item stays only in that calendar locally until the calendar is writable.
        • The target calendar cannot carry the copy. Both picks refuse, and the conflict stays open. Make the target writable, or move the item to another calendar.
      • abandoned: the moved copy is gone (deleted before a pick). Both picks refuse. Restore the copy from the trash and move it again.
      • detached: an unlink of the source calendar ended the move. The source has no pending delete any more, so the move cannot finish. Both picks refuse. Move the item again. A cancel-failed move also becomes detached, because its replay cannot reach a server after the unlink. A sync reset of the source does not end a move. It keeps the pending delete of each move whose copy is live. A sync reset refuses a calendar with an open move conflict, as the target or as the source, and a failed cancel counts. Resolve those conflicts first (chroncal sync conflicts).
      • cancelled: --pick server ran. --pick local restores your recorded version in the source calendar. The cancel records the version of the moved copy first, so the restore keeps your last edit. It does not move the item again. A second --pick server refuses.
      • cancel-failed: the cancel moved the item back, and the import of the server version failed. --pick server runs the import again. --pick local restores your recorded version.
      • --pick local needs the current ETag of the source object. A server that gives no ETag refuses the pick, because the source delete must stay conditional. Pick server, or delete the copy on the server by hand.
      • A move into a local calendar follows the same rules. The conflict then lives on the local calendar.

    Stuck resources:

    • Sometimes the server keeps a body that chroncal cannot import for a resource that chroncal already synced. The multiget returns 404, or the body does not parse. Sync then keeps the local copy and tries again on the next run. The full snapshot of a server without sync-collection follows the same rule: one bad body does not stop the import of the other resources.
    • Sync stops the retries after three failed runs, when at least one hour has gone by since the first failure. A frequent background sync then cannot use up the retries on a short server problem. The calendar is then no longer marked as failing. The local copy stays, and the server changes to that resource are not imported.
    • After the stop, a new version of the resource on the server (a new ETag) gets one more attempt. When that attempt fails, the retries stay stopped, and sync does not warn again.
    • chroncal sync status lists each such resource on a stuck href=... line below its calendar. The -o json output lists it under stuck_hrefs. Correct the resource on the server. The next good fetch removes the entry.

    Failed pushes:

    • chroncal sync status lists each resource whose last push failed on a push failed uid=... line below its calendar. The line gives the count of failed attempts and the last error. The -o json output lists it under push_failures.

    • An edit of an occurrence that no series owns stays local. This occurs when two calendars hold a live master of its UID. chroncal sync status lists each such UID on an edit not pushed uid=... line, and the -o json output lists it under unowned_edits. The reason is "the item belongs to no series because two calendars hold its master; use the row ID of one series". Delete or move one of the two masters. The next edit of the occurrence then goes to the other series.

    • chroncal sync doctor lists the same two kinds of resources. A failed push shows on a failed push attempt(s); last error: ... line, and an unowned edit shows on a the edit stays local: ... line. It also lists each legacy override: an override in a calendar with no master of its UID, which an older version made on a calendar change. The doctor shows one line for each UID. The line gives the count of rows, with the rows in the trash. It also gives the calendar of the series that owns them, and the reason that the rows stay. When a move can pass, the line gives the chroncal event update <id> --calendar <name> command that moves all the rows there. When a move refuses, the line names the rule that refuses it. Examples are a row of the owner calendar at the same occurrence, a read-only calendar, and an open conflict of the UID. Its -o json items have "state": "push_failure" (with push_fail_count), "state": "unowned", or "state": "legacy_override", and a reason. A legacy_override item also has id, rows, owner_calendar_name, and movable. A movable item has kept_reason and move_command. Another item has move_refusal. A wedged resource shows only as wedged. When the doctor lists no wedged resource, the last line starts with No wedged resources. When it lists other items, that line also says that --push does not apply to them. When it lists wedged resources and other items, the hint says that --push applies to the wedged resources only.

    • On each start, chroncal can move the legacy overrides of a calendar with no account link into the calendar of their series. The move occurs only when all of these conditions are true:

      • Neither calendar has an account link or read-only access.
      • Neither calendar has sync state of the UID: a sync row, a tombstone, an open conflict, or an open move conflict.
      • One live master owns the rows.
      • The calendar of the series holds no row at the same occurrence.

      This move makes no server write. The other legacy overrides stay. chroncal sync doctor lists each UID with at least one live legacy override and a live owner master. It does not list a UID whose master is in the trash, because no series can take its rows then. Restore the series first. Resources that wait:

    • A push can wait for another step. For example, an open move conflict on a third calendar names the source of a move. Sync then keeps the source delete until you resolve that conflict.

    • chroncal sync status lists each such resource on a waiting uid=... on <reason> line below its calendar. The -o json output lists it under waiting.

    • chroncal sync doctor lists it on a waiting on <reason> line. Its -o json items have "state": "waiting" and a reason. A wedged item has "state": "wedged".

    • The reason names the step. Sync lists an entry only while its cause holds. When you resolve the conflict, or the source delete goes out, the entry goes.

    • A wait is no push failure. It does not count as a failed push attempt.

    Google Calendar via CalDAV

    Google Calendar requires OAuth 2.0. It only exposes VEVENT over CalDAV.

    The OS keyring stores credentials by default. chroncal uses OAuth PKCE for installed-app flows. The Google token endpoint also requires the Desktop client client_secret even with PKCE. You need both the client ID and the client secret at setup time. After the first authorization, the keyring stores refresh tokens and the client secret. Later syncs then run with no prompt.

    Plaintext fallback warning. Install a keyring provider (for example libsecret + gnome-keyring on Linux) before you use OAuth on a shared host or a host with backups. OAuth keeps a refresh token and the client_secret, so it always needs a store. A password command cannot replace it. On a system with no OS keyring, security.allow_plaintext (or --allow-plaintext) writes credentials to a 0600-mode file under ~/.config/chroncal/. See Credential storage. The mode blocks a casual cat. It does not block backups, filesystem snapshots, or sync tools (Dropbox, iCloud, rsync) that ignore Unix permissions.

    1. Create a Desktop app OAuth client in the Google Cloud Console. Record the client ID and the client secret.

    2. Add https://www.googleapis.com/auth/calendar to the OAuth consent screen. Add yourself as a Test user while the app is in Testing mode.

    3. Enable both APIs on the project. They are separate services:

      gcloud services enable calendar-json.googleapis.com --project=YOUR_PROJECT
      gcloud services enable caldav.googleapis.com         --project=YOUR_PROJECT
      

      The Calendar JSON API alone is not enough. The CalDAV endpoint returns 403 accessNotConfigured until you enable caldav.googleapis.com.

    4. Add the Google account. Provide the client secret with the GOOGLE_CLIENT_SECRET environment variable, or let chroncal prompt for it (echo disabled). The command does not accept the secret as a CLI flag. Flags leak through process listings and shell history.

      GOOGLE_CLIENT_SECRET="GOCSPX-…" chroncal account add "Google" \
        --server "https://apidata.googleusercontent.com/caldav" \
        --username "[email protected]" \
        --auth oauth2 \
        --oauth-client-id "YOUR_CLIENT_ID.apps.googleusercontent.com"
      
    5. The add command imports every usable calendar in the Google CalendarList. It then runs the first sync. This includes delegated, family, holiday, and subscription calendars. Inspect the inventory:

      chroncal account calendars list "Google"
      
    6. Run sync and inspect the status:

      chroncal sync run
      chroncal sync status
      

    To connect from the TUI, press C (Shift+C) or choose Calendars in the command palette (/ or Ctrl+K). Activate the bottom + Add action. Then choose Add Account…. Chroncal discovers, imports, and syncs every usable collection for that sign-in. There is no second selection step. Chroncal ignores unsupported collections.

    If discovery finds no usable calendars, Chroncal removes the new account. Browser authorization runs inside the app. The same + Add menu creates local calendars and imports iCal files. Remote calendars belong to an account. They do not carry separate credentials.

    Account maintenance has one entry point. In Calendars, select a remote account heading and press Enter. Select an account heading in the sidebar to open the same inspector. The inspector shows the provider, server URL, login identity, calendar count, and sync warnings. Actions let you sync now, add or remove calendars, rename the account, sign in again for OAuth accounts, or remove the account.

    Manage Calendars… replaces the inspector with the discovered collection list. The calendar hierarchy stays mounted. Calendars already in Chroncal start checked. If you uncheck one, Chroncal removes the local copy after destructive confirmation. If you remove every collection, Chroncal also removes the empty account and its stored credential.

    Remove Account… removes the remote links and stored sign-in. It keeps downloaded calendars as local calendars. Calendar metadata edits are inline: name, color, description, owner email, default status, and quiet account/sync context.

    Google limitations:

    • Google CalDAV only supports VEVENT. Use Nextcloud, Radicale, or Fastmail for VTODO and VJOURNAL.
    • Google paginates large sync-collection REPORT responses (RFC 6578 §3.6). It returns a 507 marker plus a continuation token. chroncal follows the pages and applies the union. The first sync of a large calendar then pulls every event.
    • If account add lists calendars but the first sync returns HTTP 403, enable caldav.googleapis.com on the Google Cloud project. The Calendar JSON API is not enough.
    • A failed calendar-color PROPFIND does not block event sync. Google colors come from CalendarList. Local Google color edits are written back through CalendarList, not Apple calendar-color.

    Free/busy

    chroncal freebusy --calendar NAME --from DATE --to DATE [--remote] [--format {text,ical}]
    

    Each bound accepts YYYY-MM-DD, an RFC 3339 timestamp, or a relative date word. Without --remote, freebusy computes busy time from local recurring data. With --remote, it sends a CalDAV free-busy report to the linked remote calendar.

    Alarms

    chroncal alarm check                          # Fire due alarms (one-shot)
    chroncal alarm list                           # List unacknowledged alarms
    chroncal alarm dismiss  <state-id>            # Dismiss a fired alarm
    chroncal alarm snooze   <state-id> [--for DURATION] [--until-start]
    chroncal alarm daemon   [--interval DURATION] # Run alarm checks in a loop (default: 30s)
    chroncal alarm missed   [--days N]            # Show missed alarms (default lookback: 7 days)
    

    alarm list and alarm missed cover default alarms as well. See Default alarms.

    Attach alarms with --alarm when you create or update events and todos (repeatable). The format is [ACTION:]TRIGGER[:DESC:REPEAT:DURATION:RELATED:ATTENDEES]. ACTION is DISPLAY (default), EMAIL, or AUDIO. TRIGGER is an RFC 5545 duration relative to the start (-PT15M = 15 minutes before) or an RFC 3339 absolute time. Only the trigger is required:

    chroncal event add "Standup" --date 2026-06-15 --time 09:00 --alarm "-PT15M"
    chroncal event add "Release" --date 2026-06-15 --time 14:00 --alarm "DISPLAY:-PT30M::3:PT5M"  # repeat 3x every 5 min
    

    See chroncal event add --help for the extra fields.

    A synced calendar can hold an alarm this app does not fire. A server sends one as a "no reminder" sentinel, and another client sends one with its own action, such as X-APPLE-SOUND. The --alarm flag has no syntax for those actions, so chroncal event update and chroncal todo update keep the stored rows. Without that rule, a routine reminder edit deletes the alarm of the other client from the server.

    Pass --clear-foreign-alarms to delete them. Use the flag with --alarm to make the new list the whole list. Use the flag on its own to rem