Metadata-Version: 2.4
Name: majordomo
Version: 0.2.1
Summary: Read and report Google Chat task activity from the cache mirror; send messages and thread replies.
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: typer>=0.9
Requires-Dist: rich>=13
Requires-Dist: google-api-python-client>=2
Requires-Dist: google-auth-oauthlib>=1
Requires-Dist: google-auth>=2
Provides-Extra: mcp
Requires-Dist: mcp>=1.0.0; extra == "mcp"
Provides-Extra: bi
Requires-Dist: pymysql>=1.0; extra == "bi"
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
Dynamic: license-file

# majordomo

[![Code Quality Checks](https://github.com/overseers-desk/majordomo/actions/workflows/code_checks.yml/badge.svg)](https://github.com/overseers-desk/majordomo/actions/workflows/code_checks.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
[![License: GPL v3](https://img.shields.io/badge/license-GPLv3-green)](LICENSE)

A command-line tool that reads Google Chat, reports task activity, and sends messages. The command line is the primary interface; an MCP server is a secondary interface for AI agents. Both are thin front doors over one shared core.

## What it does

When someone creates a task through Google Chat's "Create a task for @Person (via Tasks)", that task cannot be retrieved through the Google Tasks API; the only durable signal is the chat message itself (see [GOOGLE_CHAT_TASKS_LIMITATIONS.md](GOOGLE_CHAT_TASKS_LIMITATIONS.md)). majordomo reconstructs task activity from those messages and reports who holds which tasks across spaces over a date range.

- **Tasks** by assignee, space, and date; "assigned to me" and "assigned by me".
- **Spaces**, **people** (participants with message and task counts), and raw **messages** by space or thread.
- **Domain or consumer, and who owns it.** `spaces` reports whether each space belongs to a Google Workspace domain or a consumer/personal account, straight from the Chat API; `--owner` also resolves the space's Owner.
- **Three modes, one shape.** The fast path reads an existing server-side cache of Chat (the [data model](DATA-MODEL.md)). `--live` is up-to-dateness: it serves the cache and tops it up from the Chat API with anything newer. `--nocache` reads the Chat API directly and decodes tasks itself, so the tool also works without the cache. Every result is tagged with its source; an unforced read uses the cache and falls back to the direct API automatically when the cache is unreachable.
- **Send** a message to a space, or a reply into a thread, as the logged-in account, with optional file attachments (`majordomo send`).
- **A privacy sieve** in the core drops blocked spaces (and assignees) before any caller (CLI or MCP) can see them; it refuses sends into blocked spaces the same way.
- **Output** as a rich console table, `--json`, or `--csv`.

## Install

The simplest cross-platform install is from PyPI:

```bash
pip install majordomo                  # reads and sends through the Chat API
pip install "majordomo[bi,mcp]"        # plus the cache fast path and the MCP server
```

A plain install talks to Google directly and is complete on its own. The `bi` option adds the driver for reading an existing server-side cache of Chat, which is an accelerator for sites that already run that mirror.

With uv, `uvx majordomo ...` runs it without installing and `uv tool install majordomo` installs it permanently.

Homebrew (macOS or Linux):

```bash
brew tap overseers-desk/od
brew install majordomo
```

Python 3.11+.

**Run directly, no virtualenv, with Ubuntu/Debian packages:**

```bash
sudo apt-get install python3-typer python3-rich \
  python3-googleapi python3-google-auth python3-google-auth-oauthlib
# only if you read the server-side cache (--cache, --live) as well:
sudo apt-get install python3-pymysql
```

Then run from the repo without installing the package:

```bash
PYTHONPATH=src python3 -m majordomo spaces
```

**Or install the package** (puts `majordomo` on your PATH):

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[bi,mcp]"        # drop the options you don't need
majordomo --help
```

The MCP server (`majordomo mcp`) needs the `mcp` PyPI package, which is not in apt; install it through the virtualenv above (the `mcp` option).

## Configuration

majordomo reads two files from `~/.config/majordomo/`:

`config.toml` (hand-edited):

```toml
[me]
user_id = "users/1234567890"      # your Chat id, for --to-me / --by-me
                                   # (find it with `majordomo people`)

[sieve]
block_spaces = ["spaces/AAAA"]     # never shown through any front door
block_assignees = ["users/9999"]   # drop these assignees from every report

[api]                              # optional; defaults shown (OAuth for the API path)
token_file = "~/.config/majordomo/token.json"
client_file = "~/.config/majordomo/client_secret.json"
```

`.env` (the cache database connection, when using the cache path):

```
MYSQL_HOST=…
MYSQL_PORT=3306
MYSQL_USER=…
MYSQL_PASSWORD=…
MYSQL_DATABASE=…
```

## Authenticating (per-account OAuth)

majordomo signs in as **you**, through Google's own browser consent screen. `majordomo login` opens that flow, the account you pick grants access to its own Chat, and the resulting refresh token is stored for that account. There is no service account and no domain-wide delegation. Two things follow:

- **Any account works**, consumer `@gmail.com` or Google Workspace, as long as it can grant OAuth consent. No Workspace administrator has to enable anything first.
- **You see only what that account sees.** The tool reads the signed-in user's own spaces and nothing beyond them.

The two alternatives were considered and set aside. A **service account** is a robot identity, not a person, so by itself it cannot read a given user's Chat. **Domain-wide delegation**, where a service account impersonates users, can read it, but only a Google Workspace administrator may authorize it and only for accounts inside that Workspace domain; that excludes consumer Gmail and any account you do not administer. Per-account OAuth costs one login per user and, in exchange, works for everyone.

`majordomo login` writes the token file used by `--live` (for the top-up), `--nocache`, `send`, `attachments`, `spaces --owner`, and naming people. It needs a Desktop OAuth client with the Google Chat API and the People API enabled, saved as `client_secret.json` in the config directory. The permissions it asks for:

| Permission | For |
|---|---|
| `chat.spaces.readonly`, `chat.messages.readonly` | Reading spaces, messages and files over the Chat API |
| `chat.messages.create` | `send`, with its attachments |
| `chat.memberships.readonly` | `spaces --owner`: finding each space's Owner |
| `contacts.readonly`, `contacts.other.readonly`, `directory.readonly`, `userinfo.profile` | Naming people through the People API, from your saved contacts, your "other contacts", your Workspace directory, and your own profile |

**Upgrading can add permissions.** A new version may need a permission your saved login does not grant; naming people through the People API is one. Before it does anything else, the command works out every permission its own shape needs (reading the API directly, `--owner`, sending, showing people) and opens Google's consent page once for whatever is missing. That happens up front, so a decline or an unanswered page costs nothing already spent reading. The consent adds to what you granted before. When nobody is there to answer the page (a cron job, CI, an SSH session without a terminal), the command still answers: people show as `users/<id>`, and a line says to run `majordomo login`, which grants every permission at once.

```bash
majordomo login
```

## Commands

```bash
majordomo spaces
majordomo spaces --owner              # who owns each space too, by name
majordomo people --window year
majordomo people --person Alice       # one person: every spelling, email, DM space
majordomo tasks --to-me --window month
majordomo tasks --assignee Alice --since 2026-01-01
majordomo messages --space "Back Office" --window 7d
majordomo messages --person Alice --window 7d        # your DM with Alice, both sides
majordomo messages --thread spaces/AAAA/messages/BBBB
majordomo attachments --space spaces/AAAA --window 30d       # what files were posted
majordomo attachments --person Alice --window all            # the files in your DM
majordomo attachments --message spaces/AAAA/messages/BBBB --download ~/Downloads
majordomo send --space spaces/AAAA "On my way."
majordomo send --thread spaces/AAAA/messages/BBBB "Done, see the doc."
majordomo send --to alice@example.com "Lunch?"       # a person's existing 1:1 DM
majordomo send --space spaces/AAAA "Here it is." --attach ./report.pdf  # repeat --attach for several; text optional
majordomo mcp                       # run the MCP server (stdio)
```

- Source: default cache with an automatic direct-API fallback. `--cache` forces the cache; `--live` adds a freshness top-up from the API; `--nocache` reads the API directly. `attachments` is the exception: files are read over the API always, the cache mirroring message text and not the files themselves. `spaces`'s domain and Owner fields are the same exception: read over the API directly, so they populate on `--nocache`/`--owner` and render blank on a plain cache read, the mirror storing neither.
- Every report that shows a person names them through the Google People API: task assignees, message and file senders, space owners, and `people`. A person the API cannot name shows as `users/<id>`. What majordomo fetched is cached in `people.json` and `spaces.json` under `$XDG_CACHE_HOME/majordomo/` (`~/.cache/majordomo/` when unset), and deleting them costs only a refetch.
- A person (`--person`, `--assignee`, `--to`) is `users/<id>`, an email, or a name. A name matches People API names, and on the cache path also the spellings seen in task assignments and @-mentions. It must match one person; an ambiguous name lists the candidates with their ids. A space (`--space`) is `spaces/<id>` or its display name.
- Window: `7d | 30d | month | year | all`, or `--since` / `--until` (ISO dates).
- Output: default console, `--json`, or `--csv`.
- `attachments` lists what was posted; add `--download DIR` to save each file into an existing directory under the name it was posted with, and the path written appears in the output. A file already at that name is left alone and named, so a download never clobbers.
- `spaces` also reports each space's domain (a Google Workspace domain space, or a consumer/personal one) straight from the Chat API; `--owner` resolves who owns it too, one extra call per space that is not a direct message.

## Replay bounds: `WORLD_AS_OF`

`WORLD_AS_OF` is the office-wide replay bound (design: [WORLD_AS_OF.design.md](WORLD_AS_OF.design.md)): an ISO-8601 timestamp with a timezone offset, exported into the environment by a replay harness so a run sees the world as it stood at that instant.

```bash
WORLD_AS_OF='2026-07-12T17:07:00+10:00' majordomo tasks --window 7d
```

- **Unset**: normal operation, at no cost.
- **Set**: nothing dated after the bound is reported, on every source (cache, `--live`, `--nocache`) and through both front doors (CLI and MCP). Relative windows anchor to the bound, not to now: `7d` is the seven days before it, `month` the calendar month before the one containing it. A `--until` later than the bound is clamped down with a stderr note. Under a past bound `--live` degrades to the cache read (a top-up would fetch only what the bound excludes). The JSON/MCP envelope carries `world_as_of`, so a log proves the answer was bounded. `send` is refused while the bound is set: a bounded run is a replay, and a send would act in the real present.
- **Set but unparseable, or missing its timezone offset**: a hard error on every command, including ones that fetch no dates, because a silently ignored bound would produce a contaminated run that looks valid.

The bound is honest about what it cannot rewind. Space and user display names, People API names included, are current-state (neither the mirror nor the API keeps prior names) and the output says so. A message edited after the bound carries its post-edit text, marked `edited_after_bound` on the API path where the edit is observable. A bound older than the oldest cached message earns a warning that the store does not reach the as-of instant. For replaying the past the cache is the higher-fidelity source: the mirror retains messages the API has since dropped through deletion.

## Not yet (deferred)

- **Task completion and stats.** Google Chat does not reliably carry task completion, so every task is reported as `open`; completion-rate reporting waits on a later signal.

## License

See [LICENSE](LICENSE).
