diff --git a/CLAUDE.md b/CLAUDE.md index 9e3aa4e..3734bab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,15 +162,18 @@ desk (CLI entry point) │ ├── stack → Flow elements in a line at natural size (align + gap) │ ├── group → Group elements into one group object │ └── ungroup → Ungroup a group back into its members -└── cal → Google Calendar operations - ├── today → Today's events - ├── week → This week's events - ├── next → Upcoming events - ├── list → List calendars - ├── create → Create an event - ├── delete → Delete an event - ├── update → Update an event - └── find → Search events by text +├── cal → Google Calendar operations +│ ├── today → Today's events +│ ├── week → This week's events +│ ├── next → Upcoming events +│ ├── list → List calendars +│ ├── create → Create an event (--meet, guest perms, --send-updates) +│ ├── delete → Delete an event +│ ├── update → Update an event (--meet adds a link to an existing event) +│ └── find → Search events by text +└── meet → Google Meet operations + ├── read → Read a meeting space's settings + └── update → Set auto-record / transcript / smart notes Config/Tokens: ~/.desk/ ├── credentials.json ← User provides (from their Google Cloud project) @@ -189,6 +192,7 @@ Config/Tokens: ~/.desk/ - `src/desk/commands/cal.py` - Calendar commands - `src/desk/commands/forms.py` - Forms commands - `src/desk/commands/slides.py` - Slides commands +- `src/desk/commands/meet.py` - Meet commands - `src/desk/services/gmail.py` - GmailClient API wrapper - `src/desk/services/drive.py` - DriveClient API wrapper - `src/desk/services/sheets.py` - SheetsClient API wrapper @@ -196,6 +200,7 @@ Config/Tokens: ~/.desk/ - `src/desk/services/calendar.py` - CalendarClient API wrapper - `src/desk/services/forms.py` - FormsClient API wrapper - `src/desk/services/slides.py` - SlidesClient API wrapper +- `src/desk/services/meet.py` - MeetClient API wrapper ## Adding a New Service diff --git a/README.md b/README.md index a924ff6..102ff73 100644 --- a/README.md +++ b/README.md @@ -169,10 +169,28 @@ desk cal next --max 5 # Upcoming events desk cal list # List calendars desk cal find "standup" # Search events desk cal create "Meeting" --start 2024-01-15T10:00:00 --end 2024-01-15T11:00:00 +desk cal create "Training" --start ... --end ... --meet --hide-guest-list desk cal update --summary "New Title" -desk cal delete +desk cal update --meet # Add a Meet link to an existing event +desk cal delete --send-updates none # Delete without mailing attendees ``` +### Meet + +Meeting-space settings — recording and transcription. Needs a scope that predates +this feature, so run `desk auth login` again if `desk meet` reports a missing scope. + +```bash +# The conferenceId from a cal read addresses the space +desk cal create "Training" --start ... --end ... --meet --json +desk meet read abc-defg-hij # Current settings +desk meet update abc-defg-hij --auto-record on --auto-transcript on +desk meet update abc-defg-hij --auto-record default # Defer to your Workspace policy +``` + +Co-hosts are UI-only — Google restricts the Meet space-members API to its Developer +Preview Program. See `desk meet --help`. + ### Forms ```bash diff --git a/docs/decisions/034-scope-aware-commands.md b/docs/decisions/034-scope-aware-commands.md new file mode 100644 index 0000000..17514fd --- /dev/null +++ b/docs/decisions/034-scope-aware-commands.md @@ -0,0 +1,250 @@ +--- +id: 034 +title: Scope-Aware Commands +status: accepted (§2 amended by ADR-036 — the `@requires_scope` decorator was removed as unused) +date: 2026-07-31 +supersedes: [] +superseded_by: null +tags: [auth, cli, agent-first, api] +--- + +# ADR-034: Scope-Aware Commands + +Amends ADR-030 §3, whose "proactive" scope-drift detection never functioned (issue #82). + +## Context + +Issues #80 and #81 ask for Calendar and Meet features. The Meet half needs the Google Meet +REST API and an OAuth scope Desk doesn't request today (`meetings.space.settings`). #81 +frames the real cost accurately: "this requires a scope addition and a re-consent — that is +the real cost of the feature, not the API calls." + +Three facts shape the decision: + +1. **Adding a scope does not break existing tokens.** A token granted N scopes keeps + refreshing and keeps working for those N. Only calls needing the new scope fail, with a + 403. There is no flag day and no forced migration. +2. **Desk users self-serve their own consent.** Each user brings their own Google Cloud + project (ADR-001), so the fix for a missing scope is one command they run themselves — + unlike a Slack-style app where an admin must approve scopes and the user is hard-blocked. +3. **Desk currently cannot tell whether a scope was granted.** Issue #82: `SCOPES` is + passed into `Credentials.from_authorized_user_info()`, which makes `creds.scopes` the + *requested* set, and `to_json()` never serializes the *granted* set. So + `_missing_scopes()` returned `[]` for every user and the ADR-030 §3 proactive path was + dead on arrival. + +So the blocker isn't consent mechanics, it's **legibility**: a user or agent has no way to +learn that a command can't work until it fails. Cafe hit the same problem from the other +direction (workspace admins removing `search:read`) and solved it in its ADR-006, amended +by its ADR-024. We adopt that pattern. + +## Decision + +### 1. Persist the granted scope set (prerequisite, issue #82) + +`_save_credentials()` writes `creds.granted_scopes` under a dedicated +`granted_scopes` key, because `to_json()` drops it. A save from a credentials object that +doesn't know the granted set carries forward the previously stored value rather than +wiping it. `_get_oauth_credentials()` re-attaches the stored set on load, since +`from_authorized_user_info()` never restores it. A new `auth.granted_scopes()` accessor is +the single source of truth: credentials object first, then storage. + +**`None` means "unknown", not "granted nothing".** Tokens issued before this fix carry no +record; google-auth repopulates `granted_scopes` from the next refresh response +automatically, so users recover without re-authenticating. + +### 2. `enforce_scopes()`, resolved at invocation only + +`agent.enforce_scopes(scopes, as_json)` checks the granted set and, if a scope is missing, +emits the existing `ErrorCode.INSUFFICIENT_SCOPES` structured error — naming the missing +scope, the affected commands, and `desk auth login` — before any API call. + +**Gate service-wide scopes at the service's `_get_client()` helper, not per command.** +Desk's scopes are service-shaped: `presentations` covers all 25 `slides` commands, and every +one of them already funnels through `_get_client(as_json)`. One call there gates the whole +service, versus decorating 25 commands and keeping them in sync. + +*(Amended by ADR-036 §5: this section originally also shipped a `@requires_scope` decorator +for scopes covering only part of a service, anticipating Meet. ADR-036 made Meet its own +service group, so the scope is service-wide, the decorator had no user, and it was removed. +`enforce_scopes()` is the sole mechanism.)* + +**Never at decoration/import time.** `desk.cli` imports every command module at startup and +resolving scopes touches the keyring, so a decoration-time read would make bare +`desk --help` crash on hosts with no keyring backend. This is not hypothetical: it is +exactly the regression Cafe's ADR-024 was written to undo. + +**The gate fails open when the granted set is unknown.** A pre-#82 token, or the gcloud ADC +path, must never block a command that would have worked. + +### 3. `SCOPE_COMMANDS` map in `config.py` + +Scope → targets, where a target is either a bare service name (`"slides"`, meaning every +command in it) or a specific `"service command"` pair for a scope covering part of a service. +Three resolvers read it: `scopes_for_service()`, `scopes_for_command()`, and +`commands_for_scopes()` (which renders the gate's "affected commands" list). It may list +scopes for features that don't exist yet; that's how a planned capability stays documented. + +Only scopes worth gating need an entry. Most of Desk's scopes are requested together at +first login, so a user has all of them or isn't authenticated at all — gating those adds +noise without catching anything. The entries that matter are scopes added *after* a release. + +### 3b. Keyring reads treat "no backend" as "nothing stored" + +`keyring_store.get_token()` and `get_client_credentials()` catch +`keyring.errors.NoKeyringError` and report absence. On a host with no backend nothing *can* +be stored, so `None` is truthful. + +This is load-bearing, not incidental: `--capabilities` is pure introspection but now reads +the granted set on every invocation, which turned bare `desk --capabilities` into a +`NoKeyringError` traceback on headless Linux, containers, and CI runners. Caught by running +the CLI under `PYTHON_KEYRING_BACKEND=keyring.backends.fail.Keyring`. + +Writes still fail loudly — storing a secret with nowhere to put it must never be silent. +Other keyring errors (locked keychain, denied access) still propagate, since only +`NoKeyringError` is unambiguous. + +### 4. `--capabilities` reports `scope` and a tri-state `enabled` + +Each command entry gains `scope` (list, possibly empty) and `enabled`: +`true` / `false` / `null` when the granted set is unknown. This is the agent-facing half +(ADR-004) — an agent can tell it can't do something, and why, without a failed call. + +Both fields are **derived from `SCOPE_COMMANDS` at runtime**, not written into the ~150 +hand-maintained command entries in `cli.py`. The map stays the single source of truth, so +capabilities can't drift from the gate. + +### 5. Reuse `INSUFFICIENT_SCOPES`; do not add a new error code + +Desk already has `INSUFFICIENT_SCOPES` with the right suggestions (ADR-030). Cafe added a +distinct `MISSING_SCOPE` for pre-flight, but Desk's existing code already means "you +haven't granted this, re-auth" — a second near-identical code would just make agents +pattern-match two strings for one condition. + +### 6. New scopes are requested by default, not opted into + +When Desk adds a scope it goes into `config.SCOPES`, so `desk auth login` requests it. +Existing tokens are unaffected until the user re-auths; the gate and `--capabilities` keep +them honest in the meantime. No per-feature opt-in flag. + +## Alternatives Considered + +### Alternative 1: Hide commands whose scopes aren't granted + +**Description**: Omit unscoped commands from the Click tree entirely. + +**Pros**: +- `--help` shows only what works +- No error path to design + +**Cons**: +- Commands vanish with no explanation of why or how to get them back +- `--help` output differs per user, so documentation and examples become unreliable +- An agent can't discover a capability exists but needs consent + +**Why rejected**: Cafe explicitly rejected this (its ADR-006 Option B) and we'd inherit the +same problems. Visible-but-disabled is strictly more informative. + +### Alternative 2: Annotate `--help` with `[DISABLED — missing scope: X]` + +**Description**: Append disabled status to each command's help text. + +**Pros**: +- Discovery right where the user is already looking + +**Cons**: +- Click bakes help text when the `Command` object is constructed at import, so this forces + scope resolution at import time — the keyring-less crash above +- Avoiding that needs a custom `Command` subclass threaded through every group + +**Why rejected**: Cafe shipped this and then removed it in ADR-024 for exactly these +reasons. `--capabilities`, `auth status`, and the invocation error already cover discovery. + +### Alternative 3: Two-tier scope sets (required + optional, `desk auth login --with meet`) + +**Description**: Keep new scopes out of the default consent request; users opt in per +feature. + +**Pros**: +- Least privilege — users grant only what they use +- Consent screen stays short + +**Cons**: +- Two scope lists to keep in sync, plus flag plumbing and per-feature naming +- Most users would want the feature anyway, so the common path gains a step +- Partial-grant states multiply + +**Why rejected**: not worth the machinery for a non-sensitive scope. Worth revisiting if a +sensitive or restricted scope ever lands, since those carry verification cost — noted as an +open question in idea 079. + +### Alternative 4: Rely on the reactive 403 path only (ADR-030 §3 as built) + +**Pros**: +- Already implemented, zero new code + +**Cons**: +- Costs a round trip to learn a call was never going to work +- Agents must fail to discover a limit, which is what ADR-004 argues against +- Gives no inventory of what's disabled + +**Why rejected**: it's the floor, not the ceiling. We keep it as the backstop for scopes no +gate covers. + +## Consequences + +### Positive + +- Scope-dependent features can ship without coordinating a re-auth across all users +- `desk auth status` reports real scope drift for the first time, including the + pre-existing `presentations` drift from ADR-026 +- Agents can read `--capabilities` and explain a missing capability instead of hitting a 403 +- Fast fail — no wasted API call when a scope is known-missing + +### Negative + +- `SCOPE_COMMANDS` needs maintenance as commands are added; a stale map means a command is + gated on the wrong scope. Mitigation: the map is data in one file, service-level entries + cover new commands in an existing service automatically, and the gate failing open on + unknown limits the damage. +- Services must opt in by calling `enforce_scopes()` in their `_get_client()` — an + un-gated service still fails reactively, so coverage is incremental rather than + guaranteed. Only `slides` is gated today, because it's the only service with a + post-release scope. +- A service-level gate fires in `_get_client()`, so it can't distinguish commands within a + service that would work under a narrower grant. Fine for `presentations` (all-or-nothing); + partial-coverage scopes must use the decorator instead. + +### Neutral + +- Pre-#82 tokens report `enabled: null` until their next refresh. Correct, if briefly + uninformative. + +## Implementation Notes + +Key files: + +- `src/desk/auth.py` — `granted_scopes()`, `_stored_granted_scopes()`, + `_restore_granted_scopes()`, `_save_credentials()`, `_missing_scopes()` +- `src/desk/config.py` — `SCOPE_COMMANDS`, `scopes_for_service()`, + `scopes_for_command()`, `commands_for_scopes()` +- `src/desk/agent.py` — `enforce_scopes()` +- `src/desk/cli.py` — `_get_capabilities()` / `_annotate_scopes()` +- `src/desk/commands/slides.py` — `_get_client()` gates on `presentations` +- `src/desk/keyring_store.py` — read helpers tolerate a missing backend +- `tests/test_scopes.py` — persistence, gate behavior, keyring-less host + +Rollback: the gate is additive and fails open; removing the `enforce_scopes()` calls restores +prior behavior. The `granted_scopes` storage key is ignored by older versions. + +The test suite runs under `PYTHON_KEYRING_BACKEND=keyring.backends.fail.Keyring` as well as +normally; keep it that way, or the import-time/read-time keyring regression returns. + +## References + +- Issue #82 — granted scopes never persisted +- Issues #80, #81 — the Calendar/Meet gaps motivating this +- ADR-030 §3 — the scope re-auth UX this amends +- ADR-004 — agent-first CLI +- Cafe ADR-006 (scope-aware commands) and ADR-024 (lazy scope resolution) +- [Meet API spaces.patch scopes](https://developers.google.com/workspace/meet/api/reference/rest/v2/spaces/patch) diff --git a/docs/decisions/035-calendar-event-fields.md b/docs/decisions/035-calendar-event-fields.md new file mode 100644 index 0000000..cbbf7ef --- /dev/null +++ b/docs/decisions/035-calendar-event-fields.md @@ -0,0 +1,212 @@ +--- +id: 035 +title: Calendar Event Fields — Conferencing, Guest Permissions, Notification Control +status: accepted +date: 2026-07-31 +supersedes: [] +superseded_by: null +tags: [cal, api, agent-first] +--- + +# ADR-035: Calendar Event Fields — Conferencing, Guest Permissions, Notification Control + +## Context + +Issue #80, found while scripting recurring training invites: `desk cal create` / `update` +cover title, time, description, and attendees, but several Calendar fields that come up on +almost any real invite force a fall back to the web UI. + +Verified against the code: + +- **No conferencing.** `create()` builds a body of only summary/start/end/description/ + attendees (`calendar.py:158-166`). An invite made with Desk has no join link, which makes + it unusable for a remote meeting without a manual UI edit. +- **`sendUpdates` is hardcoded to `"all"`** in four places — create (`calendar.py:174`), + update (`:278`), delete (`:210`), respond (`:517`). This is worse than the issue reports: + it's not that you can't opt into notifying, it's that you can never opt *out*. You cannot + stage an event quietly, and you cannot delete an event without mailing a cancellation to + every attendee. +- **No guest permission flags** — `guestsCanSeeOtherGuests`, `guestsCanInviteOthers`, + `guestsCanModify`. Hiding the guest list matters whenever a mailing list or a large + audience is invited, and the API default exposes it. +- **No `--location`, visibility, or free-vs-busy.** + +One gap the issue didn't mention: `_parse_event()` (`calendar.py:382-409`) drops +`hangoutLink` and `conferenceData` entirely, so Desk cannot even *display* a Meet link on +events that already have one, whoever created them. Read-side, no new scope. + +None of this needs a scope change — `auth/calendar` already covers it. + +## Decision + +### 1. Surface the conference link on read + +`_parse_event()` gains `meetLink` (from `hangoutLink`) and `conferenceId` (from +`conferenceData.conferenceId`). Every read path — `today`, `week`, `next`, `find`, +`get_event` — gets it for free. + +`conferenceId` is included because it is the handle the Meet API addresses a space by +(`spaces/{meetingCode}`), which is what makes ADR-036 composable from the CLI. + +### 2. `--meet` on `create` and `update` + +Attaches a Google Meet conference via `conferenceData.createRequest` with +`conferenceDataVersion=1` on the insert/update. On `update` this is the "add a Meet link to +an existing event" case, which the issue calls out as common on its own. + +`--meet` is idempotent on update: an event that already has a conference is left alone +rather than having a second one requested. + +The `requestId` for `createRequest` is derived from the event, not random — Calendar treats +it as an idempotency key, and Desk scripts get retried. + +### 3. Guest permission flags, named for what they do + +``` +--hide-guest-list guestsCanSeeOtherGuests: false +--no-guest-invites guestsCanInviteOthers: false +--guests-can-modify guestsCanModify: true +``` + +Only sent when the flag is passed, so Google's defaults stand otherwise. + +### 4. `--send-updates [all|external-only|none]`, defaulting to `all` + +Applies to `create`, `update`, `delete`, and `respond`. **The default remains `all`, +preserving today's hardcoded behavior** — this is deliberately not a behavior change, only +a way to opt out. + +The CLI spells the middle value `external-only`; the API spells it `externalOnly`. We take +the hyphenated form because every other Desk flag value is hyphenated, and map it at the +service boundary. + +### 5. `--location`, `--visibility`, `--free` + +`--visibility [default|public|private]` maps to the API's `visibility`. `--free` sets +`transparency: transparent` (free); its absence leaves the event opaque (busy). + +`--free` rather than `--transparency=transparent`: "transparency" is Google's field name but +it's opaque jargon at a CLI, and free-vs-busy is what the UI calls it. This is a rename of +an existing Google concept, not invented vocabulary — ADR-002 is about not inventing +concepts Google doesn't have, and "Free" is literally the Calendar UI's label. + +### 6. Document the per-guest-role limitation + +`desk cal create --help` states that Calendar has no per-guest co-organizer role +(`guestsCanModify` is event-wide) and that Meet co-hosts aren't settable through the +Calendar API. Issue #80 asked for this explicitly so the limitation reads as Google's, not +Desk's. + +## Alternatives Considered + +### Alternative 1: `--conference` / `--add-conference` instead of `--meet` + +**Description**: Name the flag after the API field (`conferenceData`) rather than the +product. + +**Pros**: +- Matches the API vocabulary exactly (ADR-002) +- Would extend to non-Meet conference solutions + +**Cons**: +- Users and agents think "Meet", and the request in #80 is literally `--meet` +- Desk only ever requests `hangoutsMeet`; the generality is theoretical +- "Conference" is ambiguous in a calendar context (a conference *event*?) + +**Why rejected**: `--meet` is what the product is called in the Calendar UI, so this is +Google's vocabulary too — just the user-facing half. + +### Alternative 2: Make `--send-updates none` the default + +**Description**: Default to quiet, require opting into notifications. + +**Pros**: +- Safer default — no accidental mail to attendees, which is irreversible +- Matches the "stage quietly, notify later" flow that motivated the issue + +**Cons**: +- Silently changes the behavior of every existing script and agent workflow +- Surprising: creating an invite that nobody is told about is rarely what's meant +- Diverges from the Calendar UI, which notifies by default + +**Why rejected**: too large a behavior change to smuggle into a feature addition — and on +reflection, the premise was weaker than it looked. The Calendar UI's own default action isn't +a blunt "mail everyone": adding one guest and taking the primary button only notifies that +guest, not the existing attendee list. That scoping happens inside Google's backend under +`sendUpdates=all` — the API has no fourth value for "only whoever's actually affected" — so +`all` likely already reproduces the UI's default lean for the cases that matter (an add) and +is *correctly* unscoped for the cases where every attendee genuinely needs to know (a deletion, +a reschedule). Revisited and closed with the user 2026-08-12: leave the default at `all`. + +### Alternative 3: A single `--guest-permissions` taking a comma list + +**Description**: `--guest-permissions no-see-others,no-invite`. + +**Pros**: +- One flag instead of three + +**Cons**: +- Invents a vocabulary for values that have real API names +- Harder to discover from `--help`; no per-value help text +- Awkward to express the tri-state (unset vs true vs false) + +**Why rejected**: three boolean flags are more discoverable and map 1:1 to API fields. + +### Alternative 4: Fold Meet artifact settings (`--auto-record`) into `cal create` + +**Description**: What issue #81 suggests — `desk cal create --meet --auto-record --cohost ...`. + +**Pros**: +- One command for the whole "set up a recorded meeting" flow +- Fewer round trips for the caller + +**Cons**: +- Makes a Calendar command call the Meet API — precisely the cross-service composition + ADR-003 forbids +- Bundles two failure modes: a partial success (event created, artifact config rejected) + has no clean receipt +- The scope story differs — Calendar needs no new scope, Meet does. Bundling them would + make `cal create` gated on a scope most of its uses don't need. + +**Why rejected**: ADR-003. The Meet settings become their own primitive in ADR-036, and the +agent writes the two-step. `conferenceId` on read (decision 1) is what makes that cheap. + +## Consequences + +### Positive + +- An invite created from Desk can be a working remote meeting +- `sendUpdates` is controllable, so an event can be staged quietly and — more importantly — + an event can be deleted without mailing every attendee +- Guest lists can be hidden, which is the case that actually blocked the reporter +- Meet links appear on every event read, useful independent of the write side +- `conferenceId` gives ADR-036 a handle without a second lookup + +### Negative + +- `create` and `update` grow a lot of flags. Mitigated by grouping them in `--help` and + leaving all of them optional with Google's defaults intact. +- `--meet` costs `conferenceDataVersion=1` on every insert, changing the request shape even + when no conference is requested. Harmless, but it's a shared code path now. +- Conference creation is asynchronous — Google may return `status: pending`, so the link is + occasionally absent from the immediate response. The receipt reports the status rather + than pretending the link exists. + +### Neutral + +- Four flags on `respond`/`delete` that most callers won't pass. + +## Implementation Notes + +- `src/desk/services/calendar.py` — `create()`, `update()`, `delete()`, `respond()`, + `_parse_event()` +- `src/desk/commands/cal.py` — `create`, `update`, `delete`, `respond` +- `SEND_UPDATES` value map lives in the service, so the CLI-to-API spelling translation + happens once + +## References + +- Issue #80 +- [Calendar events.insert](https://developers.google.com/workspace/calendar/api/v3/reference/events/insert) +- ADR-002 (no invented vocabulary), ADR-003 (no cross-service commands) +- ADR-036 (Meet support — the other half of #80/#81) diff --git a/docs/decisions/036-google-meet-support.md b/docs/decisions/036-google-meet-support.md new file mode 100644 index 0000000..631ff47 --- /dev/null +++ b/docs/decisions/036-google-meet-support.md @@ -0,0 +1,203 @@ +--- +id: 036 +title: Google Meet Support — Space Artifact Settings +status: accepted +date: 2026-07-31 +supersedes: [] +superseded_by: null +tags: [meet, auth, api, agent-first] +--- + +# ADR-036: Google Meet Support — Space Artifact Settings + +## Context + +Issue #81: Desk has no Google Meet API support, so the settings that govern *how a meeting +runs* — auto-recording, auto-transcription, auto smart notes, co-hosts — can only be clicked +in by hand. Hit while scripting training invites: after ADR-035 everything about the *event* +was scriptable, but recording had to be set in the UI, which for a session published to +people who can't attend is the difference between a working artifact and a forgotten one. + +The issue correctly flagged that its own premises needed checking before building. Verified +against current docs: + +- **Auto-artifacts are reachable.** `meetings.space.settings` is documented for "auto + artifact generation for **spaces created by other apps**" and is a **non-sensitive** scope, + so no Google verification review. `artifactConfig` carries no preview label. +- **A Calendar conference is addressable.** `spaces.get` accepts `spaces/{meetingCode}`, so + the `conferenceId` that ADR-035 now surfaces on every event read resolves a space directly. + `meetings.space.created` — which only covers spaces the *app* created — is not needed. +- **Co-hosts are not shippable.** `spaces.members.create` with `role: COHOST` is labeled + **Developer Preview Program** (enrollment-gated), and moderation must be `on` for co-host + management. The issue's ordering trap (co-hosts are chosen from invited guests, so a staged + event shows an empty picker) is real but moot while the API is gated. + +So the issue's own fallback — "if it can't, this whole issue is a docs note instead of a +feature" — applies to co-hosts only. The artifact settings are a feature. + +The re-consent cost that #81 identified as "the real cost of the feature" is handled by +ADR-034: the scope gate and `--capabilities` make an ungranted scope legible instead of a +mid-task 403, so the scope can be added without coordinating a re-auth. + +## Decision + +### 1. A `meet` service group, not flags on `desk cal` + +``` +desk meet read → space config and artifact settings +desk meet update → set auto-record / transcript / smart notes +``` + +Issue #81 suggested `desk cal create --meet --auto-record --cohost alice@…`. We reject that: +it makes a Calendar command call the Meet API, which is exactly the cross-service composition +ADR-003 forbids. It would also bundle two failure modes — event created, artifact config +rejected has no clean receipt — and would gate `cal create` on a scope most of its uses don't +need. + +The agent writes the two-step instead, which `conferenceId` on read (ADR-035) makes cheap: + +``` +desk cal create "Training" --start … --end … --meet --json # → conferenceId +desk meet update --auto-record on +``` + +`` accepts either a server-assigned space ID or a meeting code, because `spaces.get` +and `spaces.patch` both do. + +### 2. `on` / `off` / `default` values, not bare flags + +``` +desk meet update abc-defg-hij --auto-record on --auto-transcript on +``` + +Each of `--auto-record`, `--auto-transcript`, `--auto-smart-notes` takes an explicit value +mapping to the API's `AutoGenerationType`: `on` → `ON`, `off` → `OFF`, `default` → +`AUTO_GENERATION_TYPE_UNSPECIFIED` ("defer to user policy"). + +A bare `--auto-record` flag could only ever turn things on, and the third state — "stop +overriding, defer to policy" — would be unreachable. Only the fields actually passed go into +the `updateMask`, so an unmentioned setting is untouched. + +### 3. The `meetings.space.settings` scope, gated at the service + +Added to `config.SCOPES`, and registered in `SCOPE_COMMANDS` against the whole `meet` +service. Existing tokens keep working for every other service; `desk meet` reports itself +disabled in `--capabilities` and fails fast with `INSUFFICIENT_SCOPES` naming +`desk auth login`, per ADR-034. + +`meetings.space.created` and `.readonly` are deliberately **not** requested. `.created` only +covers app-created spaces, which isn't our case, and `.settings` already permits +`spaces.get`. + +### 4. Co-hosts are documented as UI-only, not implemented + +`desk meet --help` states that co-hosts must be set in the Calendar/Meet UI, that this is a +Google Developer Preview limitation rather than a Desk gap, and notes the ordering trap +(add guests first — the co-host picker only offers invited guests). Shipping a command that +fails for anyone not enrolled in a preview program would be worse than not shipping one. + +### 5. `requires_scope` is removed from `agent.py` + +ADR-034 shipped `enforce_scopes()` plus a `@requires_scope` decorator, the latter justified +by an anticipated Meet feature that would cover only *part* of the `cal` service. Decision 1 +makes Meet its own service, so the scope is service-wide and the decorator has no user and +no reader — nothing consumes the `_required_scopes` attribute it set, since +`--capabilities` derives everything from `SCOPE_COMMANDS`. + +Rather than ship a tested-but-unused extension point, we delete it. A genuine +partial-coverage scope can reintroduce a per-command variant in ~15 lines when one actually +arrives. This amends ADR-034 §2. + +## Alternatives Considered + +### Alternative 1: `desk cal create --auto-record` (as issue #81 suggests) + +**Pros**: +- One command for the whole flow; fewest round trips +- Matches how the user thinks about the task + +**Cons**: +- Cross-service composition, forbidden by ADR-003 +- Partial-failure states with no clean receipt +- Gates `cal create` on a scope most of its callers don't need + +**Why rejected**: ADR-003. Two primitives compose fine now that `conferenceId` is on reads. + +### Alternative 2: Ship co-hosts anyway, behind a `--preview` flag + +**Pros**: +- Fully answers #81 +- Enrolled users get the feature immediately + +**Cons**: +- Fails for anyone not in the Developer Preview Program, with an error that looks like a Desk + bug +- Preview APIs change without notice; we'd own the churn +- `--preview` is invented vocabulary for "might not work" + +**Why rejected**: a documented limitation is more honest than a command that usually fails. +Revisit when `spaces.members` reaches GA. + +### Alternative 3: Request `meetings.space.created` as well + +**Pros**: +- Covers spaces Desk itself might create later + +**Cons**: +- Desk doesn't create spaces — Calendar does, via `conferenceData` +- A second scope for no present capability, paid for in consent-screen surface + +**Why rejected**: `.settings` is sufficient and documented for exactly our case. + +### Alternative 4: Bare boolean flags (`--auto-record` / `--no-auto-record`) + +**Pros**: +- Terser for the common "turn it on" case + +**Cons**: +- Two flags per setting, and the third state (defer to policy) still needs a third spelling +- Doesn't match the API's tri-state enum + +**Why rejected**: one flag with three values maps cleanly to `AutoGenerationType`. + +## Consequences + +### Positive + +- A recorded, transcribed training session is fully scriptable end to end +- The scope addition costs no forced re-auth, thanks to ADR-034 +- `meet read` gives an agent a way to verify settings took effect — self-verification rather + than asking the user to check the UI + +### Negative + +- First scope added since `presentations`, so every user sees `meetings.space.settings` in + `auth status` as missing until they re-auth. Intended: that's the honest report, and only + `desk meet` is affected. +- Two commands instead of one for "create a recorded meeting". Accepted cost of ADR-003. +- Co-hosts remain UI-only, so #81 is only partly closed. Tracked in idea 081. +- Artifact settings apply to the *space*, so on a recurring event they affect every + occurrence — there's no per-occurrence override in the API. Documented in `--help`. + +### Neutral + +- `desk meet` is the first service group with no read-only capability beyond its own config. + +## Implementation Notes + +- `src/desk/config.py` — scope + `SCOPE_COMMANDS` entry for `meet` +- `src/desk/services/meet.py` — `MeetClient.get_space()`, `configure_artifacts()` +- `src/desk/commands/meet.py` — `read`, `update` +- `src/desk/cli.py` — register the group, add to `--capabilities` +- `src/desk/agent.py` — remove `requires_scope` + +Rollback: removing the `SCOPES` entry and the group leaves no trace; tokens that granted the +scope simply carry an unused grant. + +## References + +- Issue #81, issue #80 +- [Meet spaces.patch](https://developers.google.com/workspace/meet/api/reference/rest/v2/spaces/patch) +- [Meet spaces.get](https://developers.google.com/workspace/meet/api/reference/rest/v2/spaces/get) +- [Configure meeting spaces and members](https://developers.google.com/workspace/meet/api/guides/meeting-spaces-configuration) +- ADR-003 (no cross-service commands), ADR-034 (scope-aware commands), ADR-035 (Calendar fields) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 6927158..877c02f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -38,6 +38,11 @@ proposed → accepted → [deprecated | superseded] | 029 | [Slides Multi-Element Arrange and Richer Regions (Phase 3b-i)](029-slides-arrange-and-richer-regions.md) | accepted | 2026-06-09 | | 030 | [Slides Authoring Refinements + Scope Re-Auth UX](030-slides-authoring-refinements-and-scope-ux.md) | accepted | 2026-06-09 | | 031 | [Slides `stack` — flow layout](031-slides-stack-flow-layout.md) | accepted | 2026-06-09 | +| 032 | [Slides `group` / `ungroup` — persistent element grouping](032-slides-group-ungroup.md) | accepted | 2026-06-10 | +| 033 | [Slides use 1-based slide numbers (match the Slides UI)](033-slides-1-based-numbering.md) | accepted | 2026-06-12 | +| 034 | [Scope-Aware Commands](034-scope-aware-commands.md) | accepted | 2026-07-31 | +| 035 | [Calendar Event Fields — Conferencing, Guest Permissions, Notification Control](035-calendar-event-fields.md) | accepted | 2026-07-31 | +| 036 | [Google Meet Support — Space Artifact Settings](036-google-meet-support.md) | accepted | 2026-07-31 | ## Creating a New ADR diff --git a/docs/ideas/060-scope-mismatch-reauth-ux.md b/docs/ideas/060-scope-mismatch-reauth-ux.md index e4e7197..b2a1bfe 100644 --- a/docs/ideas/060-scope-mismatch-reauth-ux.md +++ b/docs/ideas/060-scope-mismatch-reauth-ux.md @@ -65,3 +65,10 @@ S — Error reclassification is small; `auth status` scope-diff is a modest addi maps to `INSUFFICIENT_SCOPES` ("run `desk auth login`") instead of the misleading `PERMISSION_DENIED` everywhere. `auth status` scope-diff (added earlier) covers the proactive side. +- **Correction (2026-07-31):** the proactive side did *not* work. `SCOPES` was passed into + `Credentials.from_authorized_user_info()`, making `creds.scopes` the requested set, and + the granted set was never persisted at all — so `_missing_scopes()` returned `[]` for + every user and no drift was ever reported. Open question 1 above ("are granted scopes + reliably available?") turned out to be "no". Filed as issue #82 and fixed in ADR-034, + which also builds the scope gate this idea's second bullet gestured at. See + [[079-scope-aware-commands]]. diff --git a/docs/ideas/079-scope-aware-commands.md b/docs/ideas/079-scope-aware-commands.md new file mode 100644 index 0000000..a3bdd37 --- /dev/null +++ b/docs/ideas/079-scope-aware-commands.md @@ -0,0 +1,89 @@ +--- +id: 079 +title: Scope-Aware Commands +status: adr-created +effort: M +value: Build features behind scopes users haven't granted yet, without a forced re-auth flag day +created: 2026-07-31 +updated: 2026-07-31 +adr: docs/decisions/034-scope-aware-commands.md +--- + +# Idea 079: Scope-Aware Commands + +## Problem + +Desk is about to need OAuth scopes that existing users have not consented to. Issues #80 +and #81 (Calendar/Meet gaps) both land on the Meet REST API, which needs +`meetings.space.settings` — not in `config.SCOPES` today. Every future service will hit +the same wall. + +Adding a scope doesn't invalidate an existing token: refresh keeps working on the old +grant, and only calls needing the new scope fail. But today Desk can't tell the difference +between "you have this" and "you don't", so the failure surfaces as a mid-task 403. That +makes shipping a scope-dependent feature feel like it requires re-authenticating the whole +user base at once, which is the actual blocker on #81. + +Idea 060 addressed the *reactive* half (a scope 403 now says "run `desk auth login`" +instead of "request access from the owner"). The proactive half it claimed — +`auth status` scope-diff — never worked: issue #82 found the granted set was never +persisted, so `missing_scopes` was always `[]`. + +## Sketch + +Port the pattern Cafe landed in its ADR-006 + ADR-024, adapted to Desk: + +1. An `enforce_scopes()` check resolving scopes **at invocation only**, emitting the + existing `INSUFFICIENT_SCOPES` structured error before any API call, naming the scope + and the affected commands. Called from a service's `_get_client()` for service-wide + scopes. (A `@requires_scope` decorator for partial-coverage scopes shipped and was then + removed in ADR-036 — Meet became its own service, so nothing needed it.) +2. A `SCOPE_COMMANDS` map in `config.py` keyed by scope, whose targets are either a service + name or a `"service command"` pair. +3. `--capabilities` gains a per-command `scope` list and a tri-state `enabled` flag + derived from the map and the granted set at runtime. + +Commands stay **visible but disabled** rather than hidden, so `--help` doesn't vary +between users. + +## Open Questions + +- [x] Is the granted scope set reliably available? — No. Issue #82; fixed as step 1. +- [x] Can the Meet API patch a Calendar-created conference? — Yes, + `meetings.space.settings` is documented for "spaces created by other apps" and is + non-sensitive. +- [x] Hide unscoped commands or show them disabled? — Show disabled (see ADR). +- [ ] Should `desk auth login` request new scopes by default, or opt in per feature + (`--with meet`)? ADR-034 picks default-request; revisit if a sensitive or + restricted scope ever lands. +- [ ] Does the gcloud ADC path (`GCLOUD_SCOPES`) ever yield a knowable grant set? It + reports unknown today, so the gate fails open there. + +## Value Signal + +Directly unblocks #80/#81, which are real user-reported gaps found while scripting +training invites. Generalizes: every scope addition after this one gets discoverability +for free instead of re-litigating the re-auth question. + +Agent-facing value (ADR-004): an agent can read `--capabilities` and know a command won't +work *before* trying it, and tell the user exactly why. + +## Effort Guess + +M — the decorator and map are small. `--capabilities` is a hand-maintained static dict in +`cli.py` (not Click introspection like Cafe's), so threading `scope` through every entry +is the bulk of the work. + +## Notes + +- Prior art read directly: `~/git/yahoo-orion/cafe/docs/decisions/006-scope-aware-commands.md` + and `024-lazy-scope-resolution.md`. +- Cafe's hard-won lesson: resolving scopes at *decoration* time crashed every invocation + on hosts with no keyring backend, because the CLI imports all command modules at + startup. Desk has the same structure and a keyring path — gate at invocation only. +- Cafe's gate fails open when the granted set is unknown. Desk must do the same, since + pre-#82 tokens have no record. +- Cafe's `SCOPE_COMMANDS` lists scopes for unbuilt features (`"search:read.files": + ["file search (idea 032)"]`) — pre-declaring is fine. +- Related: [[slides-phased-rollout]] (the `presentations` scope addition that first + exposed this), idea 060. diff --git a/docs/ideas/080-calendar-event-fields.md b/docs/ideas/080-calendar-event-fields.md new file mode 100644 index 0000000..10643b4 --- /dev/null +++ b/docs/ideas/080-calendar-event-fields.md @@ -0,0 +1,67 @@ +--- +id: 080 +title: Calendar Event Fields — Meet Link, Guest Permissions, Notification Control +status: implemented +effort: M +value: An invite created from the CLI can be a real remote meeting with a hidden guest list +created: 2026-07-31 +updated: 2026-07-31 +adr: docs/decisions/035-calendar-event-fields.md +--- + +# Idea 080: Calendar Event Fields + +## Problem + +Issue #80. `desk cal create` / `update` couldn't attach a Meet link, hide the guest list, +control who gets notified, or set location/visibility/free-busy — so any real invite meant +finishing the job in the Calendar web UI. + +The `sendUpdates` gap was the sharpest: it was hardcoded to `"all"` in four places, so you +could never *stop* notifications. Deleting an event always mailed a cancellation to every +attendee. + +Also found while verifying: `_parse_event()` dropped `hangoutLink`/`conferenceData`, so Desk +couldn't display a Meet link on events that already had one. + +## Sketch + +Shipped as ADR-035: `--meet`, `--hide-guest-list`, `--no-guest-invites`, +`--guests-can-modify`, `--location`, `--visibility`, `--free`, and +`--send-updates all|external-only|none` (default `all`, preserving prior behavior). +`meetLink` / `conferenceId` / `conferenceStatus` added to every event read. + +## Open Questions + +- [x] Should `--send-updates` default to `none` instead? **Resolved 2026-08-12: no, leave it + at `all`.** The premise weakened on inspection — the Calendar UI's own default action + (adding a guest, hitting the primary button) only notifies that guest, not everyone; + that scoping is Google doing dedup server-side under `sendUpdates=all`, not a distinct + API value. `all` already tracks the UI's default lean for adds, and is correctly + unscoped for deletes/reschedules where every attendee really is affected. See ADR-035 + alternative 2. +- [ ] Boolean flags are one-way: `--hide-guest-list` hides, but there's no `--show-guest-list` + to undo it. Add the negative forms if anyone needs them. +- [ ] `--meet` on a recurring event attaches one conference to the series; no per-occurrence + control exists in the API. Untested against a real recurring event. + +## Value Signal + +Direct user report from real work (scripting a pair of recurring training invites). Everything +except the Meet link and the hidden guest list was already scriptable, so this closed a +concrete blocker rather than a hypothetical one. + +## Effort Guess + +M — many flags, but each maps 1:1 to an API field. The conference `requestId` idempotency and +the async-creation status were the only subtle parts. + +## Notes + +- `requestId` is derived from the event (hash of summary + start) rather than random, because + Calendar treats it as an idempotency key — a retried create must not produce a second + conference. +- Conference creation is asynchronous, so the receipt reports `conferenceStatus: pending` + rather than implying a link exists. +- The Meet *settings* half of this work (recording, transcription) is idea 081 / ADR-036, + deliberately kept out of `cal` per ADR-003. diff --git a/docs/ideas/081-meet-cohosts-preview.md b/docs/ideas/081-meet-cohosts-preview.md new file mode 100644 index 0000000..000ad03 --- /dev/null +++ b/docs/ideas/081-meet-cohosts-preview.md @@ -0,0 +1,58 @@ +--- +id: 081 +title: Meet Co-Hosts (blocked on Developer Preview) +status: parked +effort: S +value: Name co-hosts from the CLI instead of clicking through the Meet UI +created: 2026-07-31 +updated: 2026-07-31 +adr: docs/decisions/036-google-meet-support.md +--- + +# Idea 081: Meet Co-Hosts + +## Problem + +The remaining half of issue #81. ADR-036 shipped auto-recording, auto-transcription, and auto +smart notes via `desk meet update`, but co-hosts are still UI-only. + +## Sketch + +``` +desk meet cohost add alice@example.com +desk meet cohost list +desk meet cohost remove alice@example.com +``` + +Maps to `spaces.members.create` / `.list` / `.delete` with `role: COHOST`. + +## Open Questions + +- [ ] **Blocker:** `spaces.members` is restricted to Google's Developer Preview Program. Check + GA status before building — shipping it earlier means it fails for anyone not enrolled, + and the error looks like a Desk bug. +- [ ] Moderation must be `on` for co-host management. Should `desk meet update` gain a + `--moderation on|off`, or should the cohost command turn it on implicitly? Implicit + state changes are usually the wrong call, so probably an explicit flag first. +- [ ] Does adding a member require the person to already be an invited guest of the Calendar + event? The UI picker only offers invited guests, but that may be a UI constraint rather + than an API one. Untested. +- [ ] Would this need `meetings.space.created` in addition to `.settings`? Unverified. + +## Value Signal + +Named in issue #81 as one of three things that had to be clicked in by hand. Lower value than +the artifact settings, which are the ones that determine whether a recording exists at all. + +## Effort Guess + +S once unblocked — three thin commands over one endpoint. The cost is entirely in the preview +gating, not the code. + +## Notes + +- Ordering trap worth documenting whenever this ships (and already in `desk meet --help` for + the manual path): co-hosts are picked from the event's *invited guests*, so a staged event + with no attendees shows an empty picker. Add guests, then set co-hosts, then add any + remaining lists. +- ADR-036 alternative 2 records why we didn't ship this behind a `--preview` flag. diff --git a/docs/ideas/README.md b/docs/ideas/README.md index b8cc137..d57823d 100644 --- a/docs/ideas/README.md +++ b/docs/ideas/README.md @@ -69,6 +69,16 @@ idea → exploring → planned → adr-created → (implemented) | 076 | [Headless slides fit/overflow check](076-slides-fit-check.md) | implemented | M | slides-fit skill: overflow/dead-space/off-center, no rendering | +| 077 | [Slides group / ungroup](077-slides-group-ungroup.md) | implemented | S | Persistent element grouping | + +| 078 | [1-based slide numbers](078-slides-1-based-numbering.md) | implemented | M | Slide numbers match the Slides UI | + +| 079 | [Scope-Aware Commands](079-scope-aware-commands.md) | implemented | M | Ship scope-dependent features without a forced re-auth flag day | + +| 080 | [Calendar Event Fields](080-calendar-event-fields.md) | implemented | M | Meet link, guest permissions, sendUpdates control | + +| 081 | [Meet Co-Hosts](081-meet-cohosts-preview.md) | parked | S | BLOCKED: spaces.members is Developer Preview only | + ## Adding an Idea 1. Copy `_template.md` to `NNN-short-title.md` diff --git a/src/desk/agent.py b/src/desk/agent.py index 5d25175..9e21ca6 100644 --- a/src/desk/agent.py +++ b/src/desk/agent.py @@ -36,6 +36,7 @@ class ErrorCode(str, Enum): SPREADSHEET_NOT_FOUND = "SPREADSHEET_NOT_FOUND" FORM_NOT_FOUND = "FORM_NOT_FOUND" PRESENTATION_NOT_FOUND = "PRESENTATION_NOT_FOUND" + SPACE_NOT_FOUND = "SPACE_NOT_FOUND" # Permission errors PERMISSION_DENIED = "PERMISSION_DENIED" @@ -113,6 +114,11 @@ class ErrorCode(str, Enum): "Run `desk slides read ` to check the presentation ID", "The presentation may have been deleted or you may not have access", ], + ErrorCode.SPACE_NOT_FOUND: [ + "Check the meeting code — it's the abc-defg-hij part of a Meet URL", + "Get it from `desk cal find --json` as `conferenceId`", + "A space only exists once the event has a Meet conference attached", + ], ErrorCode.PERMISSION_DENIED: [ "You may not have access to this resource", "Request access from the owner", @@ -662,3 +668,58 @@ def output_result( target = error_console if is_error else Console() target.print(formatter(result)) + + +def enforce_scopes(scopes: list[str] | tuple[str, ...], as_json: bool = False) -> None: + """Exit with a structured error if the user hasn't consented to `scopes`. + + Emits `INSUFFICIENT_SCOPES` naming the scope, the commands it affects, and + the fix (`desk auth login`) — before any API call, so the user doesn't pay a + round trip to learn the call was never going to work. + + Two deliberate properties (ADR-034): + + - **Scopes resolve when the command runs, never at import time.** `desk.cli` + imports every command module at startup and resolving scopes reads the + keyring, so an import-time check would make bare `desk --help` crash on + hosts with no keyring backend. + - **Unknown grant sets fail open.** Tokens issued before granted scopes were + persisted (issue #82) report None, and so does the gcloud ADC path. A + command that might work must never be blocked on a guess. + """ + if not scopes: + return + + from desk.auth import granted_scopes + from desk.config import commands_for_scopes + + current = granted_scopes() + if current is None: + return # Unknown grant set — fail open. + + missing = [s for s in scopes if s not in current] + if not missing: + return + + scope_list = ", ".join(missing) + err = structured_error( + ErrorCode.INSUFFICIENT_SCOPES, + f"Missing required scope: {scope_list}", + suggestions=[ + f"This command requires the {scope_list} scope", + "Run `desk auth status` to see your current scopes", + "Run `desk auth login` to re-authenticate and grant it", + ], + details={ + "scope_needed": missing, + "affected_commands": commands_for_scopes(missing), + }, + ) + output_result(err, as_json) + raise SystemExit(1) + + +# A per-command `@requires_scope` decorator was removed in ADR-036: every scope +# Desk gates on covers a whole service, so `enforce_scopes()` in that service's +# `_get_client()` is the only call site. Reintroduce a decorator when a scope +# genuinely covers only part of a service. diff --git a/src/desk/auth.py b/src/desk/auth.py index 9692ed2..12deddf 100644 --- a/src/desk/auth.py +++ b/src/desk/auth.py @@ -28,6 +28,11 @@ # Fields safe to keep in plaintext metadata files (no secrets) _TOKEN_SENSITIVE_FIELDS = ("token", "refresh_token", "client_secret") +# Key under which the *granted* (consented) scope set is stored alongside the +# token. Distinct from google-auth's `scopes`, which records what Desk +# requested — see issue #82. Non-sensitive, so it survives scrubbing. +GRANTED_SCOPES_KEY = "granted_scopes" + # Debug logging - enable with DESK_DEBUG=1 _logger = logging.getLogger("desk.auth") if os.environ.get("DESK_DEBUG"): @@ -82,6 +87,46 @@ def get_credentials() -> Credentials | None: return None +def _stored_granted_scopes() -> list[str] | None: + """Read the persisted granted scope set, or None if it was never recorded. + + Checks the keyring first, then the scrubbed token file (the granted set is + non-sensitive, so it survives in both). Returns None — not an empty list — + when no record exists, so callers can distinguish "granted nothing" from + "we don't know", and fail open on the latter. See issue #82. + """ + token_data = keyring_store.get_token() + if token_data and token_data.get(GRANTED_SCOPES_KEY): + return list(token_data[GRANTED_SCOPES_KEY]) + if TOKEN_FILE.exists(): + try: + file_data = json_module.loads(TOKEN_FILE.read_text()) + except (json_module.JSONDecodeError, OSError): + return None + if file_data.get(GRANTED_SCOPES_KEY): + return list(file_data[GRANTED_SCOPES_KEY]) + return None + + +def _restore_granted_scopes(creds: Credentials) -> None: + """Attach the persisted granted scope set to a freshly loaded credentials object. + + google-auth populates `granted_scopes` only from a live token response, and + `to_json()` doesn't serialize it, so a credentials object loaded from + storage always reports None. No-op when nothing was recorded. + + Note: `granted_scopes` is a read-only property, so this writes the backing + `_granted_scopes` attribute. If a google-auth upgrade renames it, the + granted set silently reads as unknown and the scope gate fails open — which + degrades safely, but `tests/test_scopes.py` is what catches it. + """ + if getattr(creds, "granted_scopes", None): + return # Already known (e.g. refreshed this run) — don't overwrite. + stored = _stored_granted_scopes() + if stored: + creds._granted_scopes = stored + + def _migrate_token_to_keyring() -> None: """Migrate token from token.json to keyring, then scrub secrets from file.""" if not TOKEN_FILE.exists(): @@ -151,6 +196,11 @@ def _get_oauth_credentials() -> Credentials | None: else: return None + # `from_authorized_user_*` never restores the granted set, so re-attach it + # from storage. Without this, drift is only visible in the window right + # after a live refresh. See issue #82. + _restore_granted_scopes(creds) + if creds.valid: return creds @@ -353,6 +403,19 @@ def _save_credentials(creds: Credentials) -> None: # Preserve quota_project_id for gcloud ADC credentials if getattr(creds, "quota_project_id", None): data["quota_project_id"] = creds.quota_project_id + # Persist the *granted* scope set separately. `to_json()` only serializes + # `scopes` (what Desk asked for), so without this the consented set is lost + # and scope drift is undetectable. See issue #82, ADR-034. + granted = getattr(creds, "granted_scopes", None) + if granted: + data[GRANTED_SCOPES_KEY] = sorted(granted) + else: + # This credentials object doesn't know the granted set (loaded from + # storage without a refresh this run). Don't wipe a value learned + # earlier — carry it forward. + previous = _stored_granted_scopes() + if previous: + data[GRANTED_SCOPES_KEY] = sorted(previous) # Store full token in keyring keyring_store.set_token(data) # Write scrubbed metadata to file for debuggability @@ -406,16 +469,41 @@ def get_auth_status(verify: bool = False) -> dict: return status +def granted_scopes(credentials: Credentials | None = None) -> set[str] | None: + """Return the scope set the user actually consented to, or None if unknown. + + None means "not recorded" — the caller must fail open rather than treat it + as an empty grant. Tokens issued before issue #82 was fixed carry no record; + google-auth repopulates it from the next refresh response automatically, so + no re-auth is needed to recover. + + Args: + credentials: Optional loaded credentials to consult first. Falls back to + the persisted record, which is what callers that haven't built a + client yet (e.g. the scope gate) rely on. + """ + if credentials is not None: + from_creds = getattr(credentials, "granted_scopes", None) + if from_creds: + return set(from_creds) + stored = _stored_granted_scopes() + return set(stored) if stored else None + + def _missing_scopes(credentials: Credentials) -> list[str] | None: """Return SCOPES the granted token lacks, or None if grant set is unknown. Lets `auth status` flag scope drift proactively after Desk adds a scope, so - the user is told to re-auth before hitting a 403. See ADR-030. + the user is told to re-auth before hitting a 403. See ADR-030, ADR-034. + + Reads `granted_scopes` (what the user consented to), never `scopes` (what + Desk requested) — the latter is always the full `SCOPES` constant, which + made this function return `[]` unconditionally. See issue #82. """ - granted = getattr(credentials, "scopes", None) - if not granted: + granted = granted_scopes(credentials) + if granted is None: return None - return sorted(set(SCOPES) - set(granted)) + return sorted(set(SCOPES) - granted) def verify_service_access(credentials: Credentials) -> dict[str, bool]: @@ -530,4 +618,19 @@ def verify_service_access(credentials: Credentials) -> dict[str, bool]: _logger.debug(f"Forms access check error: {type(e).__name__}: {e}") results["forms"] = False + # Meet - get a non-existent space: 404 = scopes OK, 403 = no scope. + # Expected to report False until the user re-auths for the scope ADR-036 + # added, which is the honest answer. + try: + service = build("meet", "v2", credentials=credentials) + service.spaces().get(name="spaces/nonexistenttestid").execute() + results["meet"] = True + except HttpError as e: + results["meet"] = e.resp.status == 404 + if e.resp.status not in (404, 403): + _logger.debug(f"Meet access check unexpected: {e}") + except Exception as e: + _logger.debug(f"Meet access check error: {type(e).__name__}: {e}") + results["meet"] = False + return results diff --git a/src/desk/cli.py b/src/desk/cli.py index efb2e37..a58c659 100644 --- a/src/desk/cli.py +++ b/src/desk/cli.py @@ -42,7 +42,7 @@ def _get_credentials_or_exit(): def _get_capabilities() -> dict: """Return structured capabilities for agent introspection.""" - return { + caps = { "version": __version__, "agent_first": True, "services": { @@ -191,6 +191,13 @@ def _get_capabilities() -> dict: "ungroup": {"description": "Ungroup a group back into its members", "batch": False, "destructive": False}, }, }, + "meet": { + "description": "Google Meet operations", + "commands": { + "read": {"description": "Read a meeting space's settings", "batch": False, "destructive": False}, + "update": {"description": "Set auto-record/transcript/smart-notes", "batch": False, "destructive": False, "reversible": True}, + }, + }, }, "global_flags": { "--json": "Output as JSON (agent-friendly structured output)", @@ -202,8 +209,34 @@ def _get_capabilities() -> dict: "structured_errors": "Errors include code, message, suggestions, and retryable flag", "operation_receipts": "Mutating operations return receipts with undo commands", "dry_run_preview": "Dry-run shows target details and reversibility", + "scope_aware": "Commands report required scopes and whether they're enabled", }, } + _annotate_scopes(caps) + return caps + + +def _annotate_scopes(caps: dict) -> None: + """Add `scope` and `enabled` to every command entry, in place. + + Scopes come from `config.SCOPE_COMMANDS` rather than being written into each + entry by hand, so the map stays the single source of truth. `enabled` is + tri-state: True, False, or None when the granted set is unknown (an + unauthenticated user, or a token predating issue #82). See ADR-034. + """ + from desk.auth import granted_scopes + from desk.config import scopes_for_command + + granted = granted_scopes() + + for service, info in caps["services"].items(): + for cmd_name, cmd in info["commands"].items(): + scopes = scopes_for_command(service, cmd_name) + cmd["scope"] = scopes + if granted is None: + cmd["enabled"] = None + else: + cmd["enabled"] = all(s in granted for s in scopes) @click.group(invoke_without_command=True) @@ -487,6 +520,7 @@ def auth_status(as_json: bool, verify: bool) -> None: from desk.commands.drive import drive # noqa: E402 from desk.commands.forms import forms # noqa: E402 from desk.commands.mail import mail # noqa: E402 +from desk.commands.meet import meet # noqa: E402 from desk.commands.sheets import sheets # noqa: E402 from desk.commands.slides import slides # noqa: E402 @@ -497,6 +531,7 @@ def auth_status(as_json: bool, verify: bool) -> None: main.add_command(cal) main.add_command(forms) main.add_command(slides) +main.add_command(meet) if __name__ == "__main__": diff --git a/src/desk/commands/cal.py b/src/desk/commands/cal.py index 888cacf..9245fdc 100644 --- a/src/desk/commands/cal.py +++ b/src/desk/commands/cal.py @@ -20,7 +20,11 @@ ) from desk.auth import get_credentials, get_last_auth_failure from desk.console import error_console -from desk.services.calendar import CalendarClient +from desk.services.calendar import ( + SEND_UPDATES_CHOICES, + VISIBILITY_CHOICES, + CalendarClient, +) console = Console() @@ -419,6 +423,20 @@ def list_calendars(as_json: bool) -> None: @click.option("--end", required=True, help="End time (ISO 8601 or YYYY-MM-DD)") @click.option("--description", "-d", default="", help="Event description") @click.option("--attendee", "-a", "attendees", multiple=True, help="Attendee email (repeatable)") +@click.option("--meet", is_flag=True, help="Attach a Google Meet conference") +@click.option("--location", "-l", help="Event location") +@click.option("--visibility", type=click.Choice(VISIBILITY_CHOICES), help="Event visibility") +@click.option("--free", is_flag=True, help="Show as free rather than busy") +@click.option("--hide-guest-list", is_flag=True, help="Hide other guests from attendees") +@click.option("--no-guest-invites", is_flag=True, help="Prevent guests from inviting others") +@click.option("--guests-can-modify", is_flag=True, help="Let guests edit the event") +@click.option( + "--send-updates", + type=click.Choice(SEND_UPDATES_CHOICES), + default="all", + show_default=True, + help="Who gets invitation emails", +) @click.option("--dry-run", is_flag=True, help="Preview without executing") @click.option("--quiet", "-q", is_flag=True, help="Suppress success messages") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") @@ -428,17 +446,36 @@ def create( end: str, description: str, attendees: tuple[str, ...], + meet: bool, + location: str | None, + visibility: str | None, + free: bool, + hide_guest_list: bool, + no_guest_invites: bool, + guests_can_modify: bool, + send_updates: str, dry_run: bool, quiet: bool, as_json: bool, ) -> None: """Create a new event. + Use --meet to attach a Google Meet link, and --send-updates none to stage an + event without mailing anyone yet. + + Note: Google Calendar has no per-guest "co-organizer" role — --guests-can-modify + applies to every guest. Meet co-hosts, auto-recording, and auto-transcription + are Meet settings, not Calendar ones: see `desk meet --help`. + Examples: desk cal create "Standup" --start 2024-01-15T10:00:00 --end 2024-01-15T10:30:00 desk cal create "Sync" --start 2024-01-15T10:00:00 --end 2024-01-15T11:00:00 -a bob@co.com + + desk cal create "Training" --start ... --end ... --meet --hide-guest-list + + desk cal create "Draft" --start ... --end ... -a team@co.com --send-updates none """ client = _get_client(as_json) @@ -450,12 +487,17 @@ def create( } if attendees: target["attendees"] = list(attendees) + if meet: + target["meet"] = True + warnings = [] + if attendees and send_updates != "none": + warnings.append("Attendees will receive invitation emails") preview = dry_run_preview( operation="create event", targets=[target], reversible=True, undo_command="desk cal delete ", - warnings=["Attendees will receive invitation emails"] if attendees else None, + warnings=warnings or None, ) output_result(preview, as_json, quiet) return @@ -467,19 +509,36 @@ def create( end, description=description, attendees=list(attendees) if attendees else None, + meet=meet, + location=location, + visibility=visibility, + free=free, + hide_guest_list=hide_guest_list, + no_guest_invites=no_guest_invites, + guests_can_modify=guests_can_modify, + send_updates=send_updates, ) except Exception as e: _handle_api_error(e, as_json, {"summary": summary, "start": start, "end": end}) + target = { + "id": event.get("id"), + "summary": event.get("summary"), + "start": event.get("start"), + "end": event.get("end"), + "link": event.get("htmlLink"), + } + if meet: + # Conference creation is asynchronous, so the link can be absent from the + # immediate response. Report what Google actually said. See ADR-035. + target["meetLink"] = event.get("meetLink") or None + target["conferenceId"] = event.get("conferenceId") or None + if not event.get("meetLink"): + target["conferenceStatus"] = event.get("conferenceStatus") or "pending" + receipt = operation_receipt( operation="create", - target={ - "id": event.get("id"), - "summary": event.get("summary"), - "start": event.get("start"), - "end": event.get("end"), - "link": event.get("htmlLink"), - }, + target=target, undo_command=f"desk cal delete {event.get('id')} --yes", ) output_result(receipt, as_json, quiet) @@ -488,14 +547,29 @@ def create( @cal.command() @click.argument("event_id") @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.option( + "--send-updates", + type=click.Choice(SEND_UPDATES_CHOICES), + default="all", + show_default=True, + help="Who gets cancellation emails", +) @click.option("--dry-run", is_flag=True, help="Preview without executing") @click.option("--quiet", "-q", is_flag=True, help="Suppress success messages") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") -def delete(event_id: str, yes: bool, dry_run: bool, quiet: bool, as_json: bool) -> None: +def delete( + event_id: str, + yes: bool, + send_updates: str, + dry_run: bool, + quiet: bool, + as_json: bool, +) -> None: """Delete an event. Deleting an event with attendees sends cancellation emails to all attendees. - Use --yes to skip confirmation (for scripting). + Use --send-updates none to delete quietly, and --yes to skip confirmation + (for scripting). Examples: @@ -503,6 +577,8 @@ def delete(event_id: str, yes: bool, dry_run: bool, quiet: bool, as_json: bool) desk cal delete --yes + desk cal delete --send-updates none --yes + desk cal delete --dry-run """ client = _get_client(as_json) @@ -517,7 +593,7 @@ def delete(event_id: str, yes: bool, dry_run: bool, quiet: bool, as_json: bool) if dry_run: warnings = [] - if attendee_count > 0: + if attendee_count > 0 and send_updates != "none": warnings.append(f"Cancellation emails will be sent to {attendee_count} attendee(s)") preview = dry_run_preview( operation="delete event", @@ -555,14 +631,17 @@ def delete(event_id: str, yes: bool, dry_run: bool, quiet: bool, as_json: bool) console.print(f"[yellow]Start: {event.get('start', '')}[/yellow]") console.print(f"[yellow]Attendees: {attendee_count}[/yellow]") console.print() - console.print("[bold red]Deleting this event will send cancellation emails to all attendees.[/bold red]") + if send_updates == "none": + console.print("[bold yellow]Deleting this event. No cancellation emails will be sent (--send-updates none).[/bold yellow]") + else: + console.print("[bold red]Deleting this event will send cancellation emails to all attendees.[/bold red]") if not click.confirm("Are you sure you want to delete this event?"): console.print("Cancelled.") return try: - client.delete(event_id) + client.delete(event_id, send_updates=send_updates) except Exception as e: _handle_api_error(e, as_json, {"event_id": event_id}) @@ -588,6 +667,20 @@ def delete(event_id: str, yes: bool, dry_run: bool, quiet: bool, as_json: bool) @click.option( "--remove-attendee", "-r", "remove_attendees", multiple=True, help="Email to remove (sends cancellation notification)" ) +@click.option("--meet", is_flag=True, help="Add a Google Meet conference (no-op if one exists)") +@click.option("--location", "-l", help="New location") +@click.option("--visibility", type=click.Choice(VISIBILITY_CHOICES), help="Event visibility") +@click.option("--free", is_flag=True, help="Show as free rather than busy") +@click.option("--hide-guest-list", is_flag=True, help="Hide other guests from attendees") +@click.option("--no-guest-invites", is_flag=True, help="Prevent guests from inviting others") +@click.option("--guests-can-modify", is_flag=True, help="Let guests edit the event") +@click.option( + "--send-updates", + type=click.Choice(SEND_UPDATES_CHOICES), + default="all", + show_default=True, + help="Who gets update emails", +) @click.option("--quiet", "-q", is_flag=True, help="Suppress success messages") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") def update( @@ -598,12 +691,22 @@ def update( description: str | None, add_attendees: tuple[str, ...], remove_attendees: tuple[str, ...], + meet: bool, + location: str | None, + visibility: str | None, + free: bool, + hide_guest_list: bool, + no_guest_invites: bool, + guests_can_modify: bool, + send_updates: str, quiet: bool, as_json: bool, ) -> None: """Update an existing event. - Only provided fields are changed. + Only provided fields are changed. Boolean flags are one-way — passing + --hide-guest-list hides the list, but omitting it leaves the current setting + alone rather than un-hiding it. Examples: @@ -614,6 +717,8 @@ def update( desk cal update -a newperson@example.com desk cal update -r former.employee@example.com + + desk cal update --meet --send-updates none """ client = _get_client(as_json) try: @@ -625,6 +730,14 @@ def update( description=description, add_attendees=list(add_attendees) if add_attendees else None, remove_attendees=list(remove_attendees) if remove_attendees else None, + meet=meet, + location=location, + visibility=visibility, + free=free, + hide_guest_list=hide_guest_list, + no_guest_invites=no_guest_invites, + guests_can_modify=guests_can_modify, + send_updates=send_updates, ) except Exception as e: _handle_api_error(e, as_json, {"event_id": event_id}) @@ -638,6 +751,22 @@ def update( changes["end"] = end if description: changes["description"] = description + if location is not None: + changes["location"] = location + if visibility: + changes["visibility"] = visibility + if free: + changes["transparency"] = "free" + if hide_guest_list: + changes["guestsCanSeeOtherGuests"] = False + if no_guest_invites: + changes["guestsCanInviteOthers"] = False + if guests_can_modify: + changes["guestsCanModify"] = True + if meet: + changes["meet"] = "added" if event.get("conferenceAdded") else "already present" + if event.get("meetLink"): + changes["meetLink"] = event["meetLink"] if add_attendees: changes["added_attendees"] = list(add_attendees) if remove_attendees: @@ -777,9 +906,18 @@ def invitations(max_results: int, limit: int | None, page_token: str | None, as_ type=click.Choice(["accepted", "declined", "tentative"]), help="Your response", ) +@click.option( + "--send-updates", + type=click.Choice(SEND_UPDATES_CHOICES), + default="all", + show_default=True, + help="Who is notified of your response", +) @click.option("--quiet", "-q", is_flag=True, help="Suppress success messages") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") -def respond(event_id: str, status: str, quiet: bool, as_json: bool) -> None: +def respond( + event_id: str, status: str, send_updates: str, quiet: bool, as_json: bool +) -> None: """Respond to an event invitation. Accepts, declines, or marks an event as tentative. @@ -792,11 +930,13 @@ def respond(event_id: str, status: str, quiet: bool, as_json: bool) -> None: desk cal respond --status declined desk cal respond --status tentative + + desk cal respond --status accepted --send-updates none """ client = _get_client(as_json) try: - event = client.respond(event_id, status) + event = client.respond(event_id, status, send_updates=send_updates) except ValueError as e: if as_json: error = structured_error( diff --git a/src/desk/commands/meet.py b/src/desk/commands/meet.py new file mode 100644 index 0000000..fadb1b4 --- /dev/null +++ b/src/desk/commands/meet.py @@ -0,0 +1,244 @@ +"""Meet commands — meeting-space recording and transcription settings. + +See ADR-036. Co-hosts are not here: `spaces.members.create` is gated behind +Google's Developer Preview Program, so it stays a UI-only step. +""" + +import json +import sys + +import click +from rich.console import Console +from rich.markup import escape + +from desk.agent import ( + ERROR_SUGGESTIONS, + ErrorCode, + enforce_scopes, + is_scope_error, + operation_receipt, + output_result, + parse_api_error, + structured_error, +) +from desk.auth import get_credentials, get_last_auth_failure +from desk.config import scopes_for_service +from desk.console import error_console +from desk.services.meet import AUTO_GENERATION_CHOICES, MeetClient + +console = Console() + + +def _get_client(as_json: bool = False) -> MeetClient: + """Get authenticated Meet client or exit. + + Gates the whole service on `meetings.space.settings` (ADR-034/036) — this is + a scope no existing token has, so the fast fail here is the common path until + users re-auth. + """ + enforce_scopes(scopes_for_service("meet"), as_json) + creds = get_credentials() + if not creds: + reason, error_code = get_last_auth_failure() + if as_json: + code = ErrorCode(error_code) if error_code else ErrorCode.AUTH_REQUIRED + error = structured_error(code, reason or "Not authenticated") + print(json.dumps(error, indent=2), file=sys.stderr) + else: + error_console.print("[red]Not authenticated.[/red]") + if reason: + error_console.print(f"[yellow]{escape(reason)}[/yellow]") + else: + error_console.print("Run: [cyan]desk setup[/cyan]") + sys.exit(1) + return MeetClient(creds) + + +def _handle_api_error(e: Exception, as_json: bool, context: dict | None = None) -> None: + """Handle API errors with structured output when --json is used.""" + raw_error = str(e) + error_msg = parse_api_error(raw_error) + + if is_scope_error(raw_error): + code = ErrorCode.INSUFFICIENT_SCOPES + elif "not found" in raw_error.lower() or "404" in raw_error: + code = ErrorCode.SPACE_NOT_FOUND + elif "401" in raw_error or "invalid credentials" in raw_error.lower(): + code = ErrorCode.AUTH_EXPIRED + elif "403" in raw_error or "permission" in raw_error.lower(): + code = ErrorCode.PERMISSION_DENIED + elif "429" in raw_error or "rate" in raw_error.lower(): + code = ErrorCode.RATE_LIMITED + elif "400" in raw_error or "invalid" in raw_error.lower(): + code = ErrorCode.INVALID_INPUT + else: + code = ErrorCode.OPERATION_FAILED + + suggestions = ERROR_SUGGESTIONS.get(code, []) + + if as_json: + error = structured_error( + code=code, + message=error_msg, + suggestions=suggestions, + retryable=code == ErrorCode.RATE_LIMITED, + details=context, + ) + print(json.dumps(error, indent=2), file=sys.stderr) + else: + error_console.print(f"[red]Error: {error_msg}[/red]") + if suggestions: + error_console.print("[dim]Suggestions:[/dim]") + for s in suggestions: + error_console.print(f" [cyan]- {s}[/cyan]") + + sys.exit(1) + + +@click.group() +def meet() -> None: + """Google Meet — meeting-space recording and transcription settings. + + A space is addressed by its meeting code (the `abc-defg-hij` in a Meet URL) + or its space ID. `desk cal` reports this as `conferenceId` on any event with + a Meet link, so the two compose: + + desk cal create "Training" --start ... --end ... --meet --json + desk meet update --auto-record on + + Not supported — Google limitations, not Desk ones: + + \b + - Co-hosts. Turning on host management and naming co-hosts is UI-only; + the Meet API's space-members endpoint is restricted to Google's + Developer Preview Program. Note the ordering trap when doing it by + hand: co-hosts are picked from the event's invited guests, so add + guests first or the picker is empty. + - Per-occurrence settings. Artifact settings belong to the space, so on a + recurring event they apply to every occurrence. + """ + pass + + +@meet.command() +@click.argument("space") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +def read(space: str, as_json: bool) -> None: + """Read a meeting space's settings. + + SPACE is a meeting code (abc-defg-hij), a space ID, or a full spaces/… name. + + Examples: + + desk meet read abc-defg-hij + + desk meet read abc-defg-hij --json + """ + client = _get_client(as_json) + + try: + result = client.get_space(space) + except Exception as e: + _handle_api_error(e, as_json, {"space": space}) + + if as_json: + print(json.dumps(result, indent=2)) + return + + console.print(f"[bold]{escape(result['meetingCode'] or result['name'])}[/bold]") + if result["meetingUri"]: + console.print(f" URI: {result['meetingUri']}") + console.print(f" Auto-record: {result['autoRecord'] or '(unset)'}") + console.print(f" Auto-transcript:{result['autoTranscript'] or '(unset)'}") + console.print(f" Auto smart notes:{result['autoSmartNotes'] or '(unset)'}") + if result["moderation"]: + console.print(f" Moderation: {result['moderation']}") + + +@meet.command() +@click.argument("space") +@click.option( + "--auto-record", + type=click.Choice(AUTO_GENERATION_CHOICES), + help="Record the meeting automatically", +) +@click.option( + "--auto-transcript", + type=click.Choice(AUTO_GENERATION_CHOICES), + help="Transcribe the meeting automatically", +) +@click.option( + "--auto-smart-notes", + type=click.Choice(AUTO_GENERATION_CHOICES), + help="Generate smart notes automatically", +) +@click.option("--quiet", "-q", is_flag=True, help="Suppress success messages") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +def update( + space: str, + auto_record: str | None, + auto_transcript: str | None, + auto_smart_notes: str | None, + quiet: bool, + as_json: bool, +) -> None: + """Set a meeting space's auto-artifact settings. + + SPACE is a meeting code (abc-defg-hij), a space ID, or a full spaces/… name. + + Each setting takes on, off, or default — `default` stops overriding and + defers to your Workspace policy. Settings you don't pass are left alone. + + Examples: + + desk meet update abc-defg-hij --auto-record on + + desk meet update abc-defg-hij --auto-record on --auto-transcript on + + desk meet update abc-defg-hij --auto-record default + """ + client = _get_client(as_json) + + try: + result = client.configure_artifacts( + space, + auto_record=auto_record, + auto_transcript=auto_transcript, + auto_smart_notes=auto_smart_notes, + ) + except ValueError as e: + if as_json: + error = structured_error( + ErrorCode.INVALID_INPUT, + str(e), + suggestions=[ + "Pass at least one of --auto-record, --auto-transcript, " + "or --auto-smart-notes", + f"Values: {', '.join(AUTO_GENERATION_CHOICES)}", + ], + ) + print(json.dumps(error, indent=2), file=sys.stderr) + else: + error_console.print(f"[red]Error: {e}[/red]") + sys.exit(1) + except Exception as e: + _handle_api_error(e, as_json, {"space": space}) + + changes = {} + if auto_record is not None: + changes["autoRecord"] = result["autoRecord"] or auto_record + if auto_transcript is not None: + changes["autoTranscript"] = result["autoTranscript"] or auto_transcript + if auto_smart_notes is not None: + changes["autoSmartNotes"] = result["autoSmartNotes"] or auto_smart_notes + + receipt = operation_receipt( + operation="update", + target={ + "name": result["name"], + "meetingCode": result["meetingCode"], + "meetingUri": result["meetingUri"], + }, + changes=changes, + ) + output_result(receipt, as_json, quiet) diff --git a/src/desk/commands/slides.py b/src/desk/commands/slides.py index b589dab..0cd71b5 100644 --- a/src/desk/commands/slides.py +++ b/src/desk/commands/slides.py @@ -15,6 +15,7 @@ from desk.agent import ( ERROR_SUGGESTIONS, ErrorCode, + enforce_scopes, is_scope_error, operation_receipt, output_result, @@ -22,6 +23,7 @@ structured_error, ) from desk.auth import get_credentials, get_last_auth_failure +from desk.config import scopes_for_service from desk.console import error_console from desk.services.slides import ( ARRANGE_MODES, @@ -37,7 +39,13 @@ def _get_client(as_json: bool = False) -> SlidesClient: - """Get authenticated Slides client or exit.""" + """Get authenticated Slides client or exit. + + Gates the whole service on the `presentations` scope (ADR-034). Tokens + issued before ADR-026 added that scope get a "run `desk auth login`" error + here instead of a 403 from the first API call. + """ + enforce_scopes(scopes_for_service("slides"), as_json) creds = get_credentials() if not creds: reason, error_code = get_last_auth_failure() diff --git a/src/desk/config.py b/src/desk/config.py index 3fb6dab..f5b2930 100644 --- a/src/desk/config.py +++ b/src/desk/config.py @@ -22,6 +22,10 @@ "https://www.googleapis.com/auth/forms.body", "https://www.googleapis.com/auth/forms.responses.readonly", "https://www.googleapis.com/auth/presentations", + # Meet space settings (ADR-036). Non-sensitive, and documented as covering + # spaces created by other apps — which is how a Calendar conference is + # reachable. Existing tokens predate it; `desk meet` is gated accordingly. + "https://www.googleapis.com/auth/meetings.space.settings", ] # Scopes for gcloud ADC @@ -29,6 +33,56 @@ *SCOPES, ] +# Scope -> the commands that need it. Drives the `enforce_scopes()` gate's +# "affected commands" list and the `enabled` flag in `--capabilities`. +# See ADR-034. +# +# An entry is either a bare service name ("slides"), meaning every command in +# that service, or a specific "service command" pair ("cal create") when a scope +# only covers part of a service. +# +# Only scopes worth gating need an entry. Most of Desk's scopes are requested +# together at first login, so a user either has all of them or isn't +# authenticated at all — gating those would add noise without catching anything. +# The entries that matter are scopes added *after* a release, which existing +# tokens predate. +SCOPE_COMMANDS: dict[str, list[str]] = { + # Added in ADR-026 (Slides). Tokens issued before it lack this scope, which + # is the drift that the dead scope-diff (issue #82) failed to report. + "https://www.googleapis.com/auth/presentations": ["slides"], + # Added in ADR-036. No existing token has this, so `desk meet` reports + # itself disabled until the user re-auths — the case this gate exists for. + "https://www.googleapis.com/auth/meetings.space.settings": ["meet"], +} + + +def scopes_for_service(service: str) -> list[str]: + """Return scopes every command in `service` needs. + + Excludes scopes registered against individual commands — those are gated per + command, not at the service's client helper. + """ + return sorted( + scope for scope, targets in SCOPE_COMMANDS.items() if service in targets + ) + + +def scopes_for_command(service: str, command: str) -> list[str]: + """Return the scopes a given command needs, service-wide entries included.""" + keys = {service, f"{service} {command}"} + return sorted( + scope for scope, targets in SCOPE_COMMANDS.items() if keys & set(targets) + ) + + +def commands_for_scopes(scopes: list[str]) -> list[str]: + """Human-readable list of what a set of missing scopes blocks.""" + affected: set[str] = set() + for scope in scopes: + for target in SCOPE_COMMANDS.get(scope, []): + affected.add(f"{target} (all commands)" if " " not in target else target) + return sorted(affected) + def ensure_config_dir() -> Path: """Ensure config directory exists with restricted permissions and return its path.""" diff --git a/src/desk/keyring_store.py b/src/desk/keyring_store.py index e90f051..728578c 100644 --- a/src/desk/keyring_store.py +++ b/src/desk/keyring_store.py @@ -38,7 +38,10 @@ def get_client_credentials() -> dict | None: Returns the parsed dict (e.g. {"installed": {...}}) or None if not stored. """ - data = keyring.get_password(KEYRING_SERVICE, "client:credentials") + try: + data = keyring.get_password(KEYRING_SERVICE, "client:credentials") + except keyring.errors.NoKeyringError: + return None # No backend at all — nothing can be stored. See ADR-034. if data is None: return None try: @@ -56,8 +59,17 @@ def get_token() -> dict | None: """Read the OAuth token dict from keyring. Returns the parsed token dict or None if not stored. + + A host with no keyring backend reports "not stored" rather than raising: + nothing *can* be stored there, so None is truthful, and read-only paths like + `--capabilities` must not crash. Writes still fail loudly — putting a secret + nowhere must never be silent. Other keyring errors (locked keychain, denied + access) still propagate. See ADR-034. """ - data = keyring.get_password(KEYRING_SERVICE, "oauth:token") + try: + data = keyring.get_password(KEYRING_SERVICE, "oauth:token") + except keyring.errors.NoKeyringError: + return None if data is None: return None try: diff --git a/src/desk/services/calendar.py b/src/desk/services/calendar.py index 96b8f9e..80fbe06 100644 --- a/src/desk/services/calendar.py +++ b/src/desk/services/calendar.py @@ -1,11 +1,29 @@ """Google Calendar API wrapper.""" +import hashlib from datetime import datetime, timedelta from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from googleapiclient.errors import HttpError +# Notification control. The CLI uses hyphenated values like every other Desk +# flag; the API spells the middle one `externalOnly`. Translated here so the +# mapping lives in one place. See ADR-035. +SEND_UPDATES_CHOICES = ("all", "external-only", "none") +_SEND_UPDATES_API = { + "all": "all", + "external-only": "externalOnly", + "none": "none", +} + +VISIBILITY_CHOICES = ("default", "public", "private") + + +def _api_send_updates(value: str) -> str: + """Translate a CLI --send-updates value into the API's spelling.""" + return _SEND_UPDATES_API.get(value, "all") + class CalendarClient: """Client for Google Calendar API operations.""" @@ -141,6 +159,14 @@ def create( description: str = "", attendees: list[str] | None = None, calendar_id: str = "primary", + meet: bool = False, + location: str | None = None, + visibility: str | None = None, + free: bool = False, + hide_guest_list: bool = False, + no_guest_invites: bool = False, + guests_can_modify: bool = False, + send_updates: str = "all", ) -> dict: """Create a new event. @@ -151,6 +177,14 @@ def create( description: Optional event description attendees: Optional list of email addresses to invite calendar_id: Calendar ID + meet: Attach a Google Meet conference + location: Event location + visibility: "default", "public", or "private" + free: Mark the event as free rather than busy + hide_guest_list: Hide other guests from attendees + no_guest_invites: Prevent guests from inviting others + guests_can_modify: Let guests edit the event + send_updates: "all", "external-only", or "none". See ADR-035. Returns: Created event dict @@ -164,6 +198,17 @@ def create( body["description"] = description if attendees: body["attendees"] = [{"email": email} for email in attendees] + self._apply_event_options( + body, + location=location, + visibility=visibility, + free=free, + hide_guest_list=hide_guest_list, + no_guest_invites=no_guest_invites, + guests_can_modify=guests_can_modify, + ) + if meet: + body["conferenceData"] = self._conference_create_request(summary, start) try: event = ( @@ -171,8 +216,9 @@ def create( .insert( calendarId=calendar_id, body=body, - sendUpdates="all", + sendUpdates=_api_send_updates(send_updates), supportsAttachments=True, + conferenceDataVersion=1, ) .execute() ) @@ -180,6 +226,51 @@ def create( except HttpError as error: raise RuntimeError(f"Calendar API error: {error}") + def _apply_event_options( + self, + body: dict, + location: str | None = None, + visibility: str | None = None, + free: bool = False, + hide_guest_list: bool = False, + no_guest_invites: bool = False, + guests_can_modify: bool = False, + ) -> None: + """Apply the optional event fields to a request body, in place. + + Each field is only set when asked for, so Google's defaults stand + otherwise — important on update, where an unset flag must not silently + flip an existing value. See ADR-035. + """ + if location is not None: + body["location"] = location + if visibility is not None: + body["visibility"] = visibility + if free: + body["transparency"] = "transparent" + if hide_guest_list: + body["guestsCanSeeOtherGuests"] = False + if no_guest_invites: + body["guestsCanInviteOthers"] = False + if guests_can_modify: + body["guestsCanModify"] = True + + def _conference_create_request(self, summary: str, start: str) -> dict: + """Build a conferenceData.createRequest for a Google Meet link. + + `requestId` is derived from the event rather than random: Calendar treats + it as an idempotency key, so a retried create must not produce a second + conference. See ADR-035. + """ + seed = f"{summary}|{start}".encode() + request_id = f"desk-{hashlib.sha256(seed).hexdigest()[:16]}" + return { + "createRequest": { + "requestId": request_id, + "conferenceSolutionKey": {"type": "hangoutsMeet"}, + } + } + def get_event(self, event_id: str, calendar_id: str = "primary") -> dict: """Get a single event by ID. @@ -198,16 +289,26 @@ def get_event(self, event_id: str, calendar_id: str = "primary") -> dict: except HttpError as error: raise RuntimeError(f"Calendar API error: {error}") - def delete(self, event_id: str, calendar_id: str = "primary") -> None: + def delete( + self, + event_id: str, + calendar_id: str = "primary", + send_updates: str = "all", + ) -> None: """Delete an event. Args: event_id: The event ID calendar_id: Calendar ID + send_updates: "all", "external-only", or "none". Deleting an event + mails a cancellation to every attendee unless this says + otherwise. See ADR-035. """ try: self.service.events().delete( - calendarId=calendar_id, eventId=event_id, sendUpdates="all" + calendarId=calendar_id, + eventId=event_id, + sendUpdates=_api_send_updates(send_updates), ).execute() except HttpError as error: raise RuntimeError(f"Calendar API error: {error}") @@ -222,6 +323,14 @@ def update( add_attendees: list[str] | None = None, remove_attendees: list[str] | None = None, calendar_id: str = "primary", + meet: bool = False, + location: str | None = None, + visibility: str | None = None, + free: bool = False, + hide_guest_list: bool = False, + no_guest_invites: bool = False, + guests_can_modify: bool = False, + send_updates: str = "all", ) -> dict: """Update an existing event. @@ -234,6 +343,14 @@ def update( add_attendees: Email addresses to add remove_attendees: Email addresses to remove calendar_id: Calendar ID + meet: Add a Google Meet conference if the event has none + location: New location (or None to keep) + visibility: "default", "public", or "private" (or None to keep) + free: Mark the event as free rather than busy + hide_guest_list: Hide other guests from attendees + no_guest_invites: Prevent guests from inviting others + guests_can_modify: Let guests edit the event + send_updates: "all", "external-only", or "none". See ADR-035. Returns: Updated event dict @@ -269,20 +386,40 @@ def update( ] event["attendees"] = kept + self._apply_event_options( + event, + location=location, + visibility=visibility, + free=free, + hide_guest_list=hide_guest_list, + no_guest_invites=no_guest_invites, + guests_can_modify=guests_can_modify, + ) + # Idempotent: an event that already has a conference keeps it rather + # than requesting a second one. See ADR-035. + conference_added = False + if meet and not event.get("conferenceData"): + event["conferenceData"] = self._conference_create_request( + event.get("summary", ""), event.get("start", {}).get("dateTime", "") + ) + conference_added = True + result = ( self.service.events() .update( calendarId=calendar_id, eventId=event_id, body=event, - sendUpdates="all", + sendUpdates=_api_send_updates(send_updates), supportsAttachments=True, + conferenceDataVersion=1, ) .execute() ) parsed = self._parse_event(result) if remove_attendees: parsed["removedAttendees"] = actually_removed + parsed["conferenceAdded"] = conference_added return parsed except HttpError as error: raise RuntimeError(f"Calendar API error: {error}") @@ -379,6 +516,7 @@ def _parse_event(self, event: dict, calendar_id: str | None = None) -> dict: start = event.get("start", {}) end = event.get("end", {}) attendees = event.get("attendees", []) + conference = event.get("conferenceData") or {} parsed = { "id": event.get("id", ""), "summary": event.get("summary", "(no title)"), @@ -388,6 +526,14 @@ def _parse_event(self, event: dict, calendar_id: str | None = None) -> dict: "description": event.get("description", ""), "htmlLink": event.get("htmlLink", ""), "status": event.get("status", ""), + # Conference details. `conferenceId` is the handle the Meet API + # addresses a space by (`spaces/{meetingCode}`), which is what makes + # `desk meet` composable from a `desk cal` read. See ADR-035/036. + "meetLink": event.get("hangoutLink", ""), + "conferenceId": conference.get("conferenceId", ""), + "conferenceStatus": ( + conference.get("createRequest", {}).get("status", {}).get("statusCode", "") + ), "attendees": [ { "email": a.get("email", ""), @@ -474,6 +620,7 @@ def respond( event_id: str, response: str, calendar_id: str = "primary", + send_updates: str = "all", ) -> dict: """Respond to an event invitation. @@ -481,6 +628,7 @@ def respond( event_id: The event ID response: Response status ('accepted', 'declined', 'tentative') calendar_id: Calendar ID + send_updates: "all", "external-only", or "none". See ADR-035. Returns: Updated event dict @@ -514,7 +662,7 @@ def respond( calendarId=calendar_id, eventId=event_id, body=event, - sendUpdates="all", + sendUpdates=_api_send_updates(send_updates), supportsAttachments=True, ) .execute() diff --git a/src/desk/services/meet.py b/src/desk/services/meet.py new file mode 100644 index 0000000..81fd9dd --- /dev/null +++ b/src/desk/services/meet.py @@ -0,0 +1,167 @@ +"""Google Meet API wrapper. + +Covers meeting-space artifact settings — auto-recording, auto-transcription, and +auto smart notes. See ADR-036. + +Co-host membership (`spaces.members.create` with `role: COHOST`) is deliberately +absent: it's gated behind Google's Developer Preview Program, so shipping it +would fail for anyone not enrolled. +""" + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +# CLI values for the API's AutoGenerationType. `default` defers to the user's +# Workspace policy rather than forcing the setting either way. See ADR-036. +AUTO_GENERATION_CHOICES = ("on", "off", "default") +_AUTO_GENERATION_API = { + "on": "ON", + "off": "OFF", + "default": "AUTO_GENERATION_TYPE_UNSPECIFIED", +} + +# Each artifact setting: CLI name -> (config block, field, updateMask path) +_ARTIFACT_FIELDS = { + "auto_record": ( + "recordingConfig", + "autoRecordingGeneration", + "config.artifactConfig.recordingConfig.autoRecordingGeneration", + ), + "auto_transcript": ( + "transcriptionConfig", + "autoTranscriptionGeneration", + "config.artifactConfig.transcriptionConfig.autoTranscriptionGeneration", + ), + "auto_smart_notes": ( + "smartNotesConfig", + "autoSmartNotesGeneration", + "config.artifactConfig.smartNotesConfig.autoSmartNotesGeneration", + ), +} + + +def space_resource_name(space: str) -> str: + """Normalize a space ID or meeting code into a `spaces/…` resource name. + + Both `spaces.get` and `spaces.patch` accept `spaces/{space}` (a + server-assigned ID) or `spaces/{meetingCode}` (the typeable + `abc-mnop-xyz` form), so callers can pass a Calendar event's + `conferenceId` straight through. See ADR-036. + """ + space = space.strip() + if space.startswith("spaces/"): + return space + return f"spaces/{space}" + + +class MeetClient: + """Client for Google Meet API operations.""" + + def __init__(self, credentials: Credentials): + self.service = build("meet", "v2", credentials=credentials) + + def get_space(self, space: str) -> dict: + """Read a meeting space's configuration. + + Args: + space: Space ID, meeting code, or full `spaces/…` resource name + + Returns: + Parsed space dict + """ + try: + result = ( + self.service.spaces() + .get(name=space_resource_name(space)) + .execute() + ) + return self._parse_space(result) + except HttpError as error: + raise RuntimeError(f"Meet API error: {error}") + + def configure_artifacts( + self, + space: str, + auto_record: str | None = None, + auto_transcript: str | None = None, + auto_smart_notes: str | None = None, + ) -> dict: + """Set a space's auto-artifact generation settings. + + Only the settings passed are sent, so an unmentioned setting is left + alone rather than reset. + + Args: + space: Space ID, meeting code, or full `spaces/…` resource name + auto_record: "on", "off", or "default" + auto_transcript: "on", "off", or "default" + auto_smart_notes: "on", "off", or "default" + + Returns: + Parsed space dict reflecting the update + + Raises: + ValueError: If no setting was requested, or a value is invalid. + """ + requested = { + "auto_record": auto_record, + "auto_transcript": auto_transcript, + "auto_smart_notes": auto_smart_notes, + } + requested = {k: v for k, v in requested.items() if v is not None} + if not requested: + raise ValueError( + "Nothing to update. Pass at least one of --auto-record, " + "--auto-transcript, or --auto-smart-notes." + ) + + artifact_config: dict = {} + update_mask: list[str] = [] + for name, value in requested.items(): + if value not in _AUTO_GENERATION_API: + raise ValueError( + f"Invalid value '{value}' for {name}. " + f"Must be one of: {', '.join(AUTO_GENERATION_CHOICES)}" + ) + block, field, mask_path = _ARTIFACT_FIELDS[name] + artifact_config[block] = {field: _AUTO_GENERATION_API[value]} + update_mask.append(mask_path) + + body = {"config": {"artifactConfig": artifact_config}} + + try: + result = ( + self.service.spaces() + .patch( + name=space_resource_name(space), + body=body, + updateMask=",".join(update_mask), + ) + .execute() + ) + return self._parse_space(result) + except HttpError as error: + raise RuntimeError(f"Meet API error: {error}") + + def _parse_space(self, space: dict) -> dict: + """Parse a Meet API space resource into a clean dict.""" + config = space.get("config") or {} + artifacts = config.get("artifactConfig") or {} + + def _setting(block: str, field: str) -> str: + return (artifacts.get(block) or {}).get(field, "") + + return { + "name": space.get("name", ""), + "meetingCode": space.get("meetingCode", ""), + "meetingUri": space.get("meetingUri", ""), + "accessType": config.get("accessType", ""), + "entryPointAccess": config.get("entryPointAccess", ""), + "moderation": config.get("moderation", ""), + "autoRecord": _setting("recordingConfig", "autoRecordingGeneration"), + "autoTranscript": _setting( + "transcriptionConfig", "autoTranscriptionGeneration" + ), + "autoSmartNotes": _setting("smartNotesConfig", "autoSmartNotesGeneration"), + } diff --git a/tests/test_commands/test_cal.py b/tests/test_commands/test_cal.py index 2ffc69d..64e4e15 100644 --- a/tests/test_commands/test_cal.py +++ b/tests/test_commands/test_cal.py @@ -370,3 +370,249 @@ def test_find_multi_calendar_merges( assert result.exit_code == 0 output = json.loads(result.output) assert [e["id"] for e in output["events"]] == ["k1", "p1"] + + +class TestCalCreateEventFields: + """Flags added in ADR-035 (issue #80).""" + + def _client(self, mock_class, event=None): + client = MagicMock() + client.create.return_value = event or { + "id": "e1", + "summary": "Training", + "htmlLink": "https://cal/e1", + } + mock_class.return_value = client + return client + + def test_meet_flag_reaches_service( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + client = self._client(mock_calendar_client_class) + result = runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "--meet", "--json"], + ) + + assert result.exit_code == 0 + assert client.create.call_args.kwargs["meet"] is True + + def test_guest_and_location_flags_reach_service( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + client = self._client(mock_calendar_client_class) + result = runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "--hide-guest-list", + "--no-guest-invites", "--guests-can-modify", + "--location", "Room 4", "--visibility", "private", "--free", "--json"], + ) + + assert result.exit_code == 0 + kwargs = client.create.call_args.kwargs + assert kwargs["hide_guest_list"] is True + assert kwargs["no_guest_invites"] is True + assert kwargs["guests_can_modify"] is True + assert kwargs["location"] == "Room 4" + assert kwargs["visibility"] == "private" + assert kwargs["free"] is True + + def test_send_updates_defaults_to_all( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + client = self._client(mock_calendar_client_class) + runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "--json"], + ) + + assert client.create.call_args.kwargs["send_updates"] == "all" + + def test_send_updates_none( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + client = self._client(mock_calendar_client_class) + runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "--send-updates", "none", "--json"], + ) + + assert client.create.call_args.kwargs["send_updates"] == "none" + + def test_rejects_unknown_send_updates_value( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + self._client(mock_calendar_client_class) + result = runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "--send-updates", "externalOnly"], + ) + + assert result.exit_code != 0 + + def test_receipt_reports_meet_link( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + self._client( + mock_calendar_client_class, + event={ + "id": "e1", + "summary": "Training", + "htmlLink": "https://cal/e1", + "meetLink": "https://meet.google.com/abc-defg-hij", + "conferenceId": "abc-defg-hij", + }, + ) + result = runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "--meet", "--json"], + ) + + payload = json.loads(result.output) + assert payload["targets"][0]["meetLink"] == "https://meet.google.com/abc-defg-hij" + assert payload["targets"][0]["conferenceId"] == "abc-defg-hij" + + def test_receipt_reports_pending_conference( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + """Async conference creation must not be reported as a link that exists.""" + from desk.commands.cal import cal + + self._client( + mock_calendar_client_class, + event={ + "id": "e1", + "summary": "Training", + "htmlLink": "https://cal/e1", + "meetLink": "", + "conferenceStatus": "pending", + }, + ) + result = runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "--meet", "--json"], + ) + + payload = json.loads(result.output) + assert payload["targets"][0]["meetLink"] is None + assert payload["targets"][0]["conferenceStatus"] == "pending" + + def test_dry_run_omits_email_warning_when_quiet( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + self._client(mock_calendar_client_class) + result = runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "-a", "bob@co.com", + "--send-updates", "none", "--dry-run", "--json"], + ) + + payload = json.loads(result.output) + assert not payload.get("warnings") + + def test_dry_run_warns_about_emails_by_default( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + self._client(mock_calendar_client_class) + result = runner.invoke( + cal, + ["create", "Training", "--start", "2026-08-01T10:00:00", + "--end", "2026-08-01T11:00:00", "-a", "bob@co.com", + "--dry-run", "--json"], + ) + + payload = json.loads(result.output) + assert any("invitation emails" in w for w in payload["warnings"]) + + +class TestCalUpdateEventFields: + def test_meet_added_reported_in_changes( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + client = MagicMock() + client.update.return_value = { + "id": "e1", + "summary": "Training", + "conferenceAdded": True, + "meetLink": "https://meet.google.com/abc-defg-hij", + } + mock_calendar_client_class.return_value = client + result = runner.invoke(cal, ["update", "e1", "--meet", "--json"]) + + payload = json.loads(result.output) + assert payload["changes"]["meet"] == "added" + + def test_existing_conference_reported_as_already_present( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + client = MagicMock() + client.update.return_value = { + "id": "e1", + "summary": "Training", + "conferenceAdded": False, + } + mock_calendar_client_class.return_value = client + result = runner.invoke(cal, ["update", "e1", "--meet", "--json"]) + + payload = json.loads(result.output) + assert payload["changes"]["meet"] == "already present" + + +class TestCalDeleteSendUpdates: + def test_quiet_delete_passes_through( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + client = MagicMock() + client.get_event.return_value = {"id": "e1", "summary": "T", "attendeeCount": 3} + mock_calendar_client_class.return_value = client + result = runner.invoke( + cal, ["delete", "e1", "--send-updates", "none", "--yes", "--json"] + ) + + assert result.exit_code == 0 + assert client.delete.call_args.kwargs["send_updates"] == "none" + + def test_dry_run_drops_cancellation_warning_when_quiet( + self, runner, mock_get_credentials, mock_calendar_client_class + ): + from desk.commands.cal import cal + + client = MagicMock() + client.get_event.return_value = {"id": "e1", "summary": "T", "attendeeCount": 3} + mock_calendar_client_class.return_value = client + result = runner.invoke( + cal, ["delete", "e1", "--send-updates", "none", "--dry-run", "--json"] + ) + + payload = json.loads(result.output) + assert not payload.get("warnings") diff --git a/tests/test_commands/test_meet.py b/tests/test_commands/test_meet.py new file mode 100644 index 0000000..add1a12 --- /dev/null +++ b/tests/test_commands/test_meet.py @@ -0,0 +1,255 @@ +"""Tests for meet CLI commands (ADR-036, issue #81).""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +MEET_SCOPE = "https://www.googleapis.com/auth/meetings.space.settings" + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_get_credentials(): + with patch("desk.commands.meet.get_credentials") as mock: + mock.return_value = MagicMock() + yield mock + + +@pytest.fixture +def scope_granted(): + """Pretend the user has consented to the Meet scope.""" + with patch("desk.auth.granted_scopes", return_value={MEET_SCOPE}): + yield + + +@pytest.fixture +def mock_meet_client_class(): + with patch("desk.commands.meet.MeetClient") as mock: + yield mock + + +class TestMeetUpdate: + def test_sets_auto_record( + self, runner, mock_get_credentials, scope_granted, mock_meet_client_class + ): + from desk.commands.meet import meet + + client = MagicMock() + client.configure_artifacts.return_value = { + "name": "spaces/x", + "meetingCode": "abc-defg-hij", + "meetingUri": "https://meet.google.com/abc-defg-hij", + "autoRecord": "ON", + "autoTranscript": "", + "autoSmartNotes": "", + } + mock_meet_client_class.return_value = client + + result = runner.invoke( + meet, ["update", "abc-defg-hij", "--auto-record", "on", "--json"] + ) + + assert result.exit_code == 0 + assert client.configure_artifacts.call_args.kwargs["auto_record"] == "on" + payload = json.loads(result.output) + assert payload["changes"]["autoRecord"] == "ON" + + def test_unmentioned_settings_passed_as_none( + self, runner, mock_get_credentials, scope_granted, mock_meet_client_class + ): + """Only what the user asked for should reach the service.""" + from desk.commands.meet import meet + + client = MagicMock() + client.configure_artifacts.return_value = { + "name": "spaces/x", "meetingCode": "c", "meetingUri": "", + "autoRecord": "ON", "autoTranscript": "", "autoSmartNotes": "", + } + mock_meet_client_class.return_value = client + + runner.invoke(meet, ["update", "abc", "--auto-record", "on", "--json"]) + + kwargs = client.configure_artifacts.call_args.kwargs + assert kwargs["auto_transcript"] is None + assert kwargs["auto_smart_notes"] is None + + def test_rejects_invalid_value( + self, runner, mock_get_credentials, scope_granted, mock_meet_client_class + ): + from desk.commands.meet import meet + + mock_meet_client_class.return_value = MagicMock() + result = runner.invoke(meet, ["update", "abc", "--auto-record", "yes"]) + + assert result.exit_code != 0 + + def test_empty_update_is_a_structured_error( + self, runner, mock_get_credentials, scope_granted, mock_meet_client_class + ): + from desk.commands.meet import meet + + client = MagicMock() + client.configure_artifacts.side_effect = ValueError("Nothing to update.") + mock_meet_client_class.return_value = client + + result = runner.invoke(meet, ["update", "abc", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.stderr) + assert payload["error"]["code"] == "INVALID_INPUT" + + def test_all_three_settings( + self, runner, mock_get_credentials, scope_granted, mock_meet_client_class + ): + from desk.commands.meet import meet + + client = MagicMock() + client.configure_artifacts.return_value = { + "name": "spaces/x", "meetingCode": "c", "meetingUri": "", + "autoRecord": "ON", "autoTranscript": "ON", "autoSmartNotes": "ON", + } + mock_meet_client_class.return_value = client + + result = runner.invoke( + meet, + ["update", "abc", "--auto-record", "on", "--auto-transcript", "on", + "--auto-smart-notes", "on", "--json"], + ) + + payload = json.loads(result.output) + assert payload["changes"] == { + "autoRecord": "ON", + "autoTranscript": "ON", + "autoSmartNotes": "ON", + } + + +class TestMeetRead: + def test_json_output( + self, runner, mock_get_credentials, scope_granted, mock_meet_client_class + ): + from desk.commands.meet import meet + + client = MagicMock() + client.get_space.return_value = { + "name": "spaces/x", + "meetingCode": "abc-defg-hij", + "meetingUri": "https://meet.google.com/abc-defg-hij", + "accessType": "TRUSTED", + "entryPointAccess": "", + "moderation": "ON", + "autoRecord": "ON", + "autoTranscript": "OFF", + "autoSmartNotes": "", + } + mock_meet_client_class.return_value = client + + result = runner.invoke(meet, ["read", "abc-defg-hij", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["autoRecord"] == "ON" + assert payload["meetingCode"] == "abc-defg-hij" + + def test_human_output_marks_unset_settings( + self, runner, mock_get_credentials, scope_granted, mock_meet_client_class + ): + """An absent setting must not read as "off".""" + from desk.commands.meet import meet + + client = MagicMock() + client.get_space.return_value = { + "name": "spaces/x", "meetingCode": "abc", "meetingUri": "", + "accessType": "", "entryPointAccess": "", "moderation": "", + "autoRecord": "", "autoTranscript": "", "autoSmartNotes": "", + } + mock_meet_client_class.return_value = client + + result = runner.invoke(meet, ["read", "abc"]) + + assert result.exit_code == 0 + assert "(unset)" in result.output + + +class TestMeetScopeGate: + """The scope no existing token has — this gate is the common path (ADR-036).""" + + def test_blocked_without_scope( + self, runner, mock_get_credentials, mock_meet_client_class + ): + from desk.commands.meet import meet + + mock_meet_client_class.return_value = MagicMock() + with patch("desk.auth.granted_scopes", return_value={"https://x/other"}): + result = runner.invoke(meet, ["read", "abc", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.stderr) + assert payload["error"]["code"] == "INSUFFICIENT_SCOPES" + assert MEET_SCOPE in payload["error"]["details"]["scope_needed"] + assert payload["error"]["details"]["affected_commands"] == ["meet (all commands)"] + + def test_no_api_call_when_blocked( + self, runner, mock_get_credentials, mock_meet_client_class + ): + """Fast fail — the gate must run before a client is built.""" + from desk.commands.meet import meet + + client = MagicMock() + mock_meet_client_class.return_value = client + with patch("desk.auth.granted_scopes", return_value=set()): + runner.invoke(meet, ["update", "abc", "--auto-record", "on", "--json"]) + + client.configure_artifacts.assert_not_called() + + def test_fails_open_when_grant_unknown( + self, runner, mock_get_credentials, mock_meet_client_class + ): + """A token predating granted-scope persistence must not be blocked.""" + from desk.commands.meet import meet + + client = MagicMock() + client.get_space.return_value = { + "name": "spaces/x", "meetingCode": "abc", "meetingUri": "", + "accessType": "", "entryPointAccess": "", "moderation": "", + "autoRecord": "", "autoTranscript": "", "autoSmartNotes": "", + } + mock_meet_client_class.return_value = client + + with patch("desk.auth.granted_scopes", return_value=None): + result = runner.invoke(meet, ["read", "abc", "--json"]) + + assert result.exit_code == 0 + + +class TestMeetHelpDocumentsLimitations: + """#80/#81 asked for the Google limitations to be discoverable.""" + + def test_group_help_covers_cohosts(self, runner): + from desk.commands.meet import meet + + result = runner.invoke(meet, ["--help"]) + + assert "Co-hosts" in result.output + assert "Developer Preview" in result.output + + def test_group_help_covers_recurring_events(self, runner): + from desk.commands.meet import meet + + result = runner.invoke(meet, ["--help"]) + + assert "recurring" in result.output + + def test_cal_create_help_points_at_meet(self, runner): + from desk.commands.cal import cal + + result = runner.invoke(cal, ["create", "--help"]) + + assert "co-organizer" in result.output + assert "desk meet" in result.output diff --git a/tests/test_scopes.py b/tests/test_scopes.py new file mode 100644 index 0000000..2e5b086 --- /dev/null +++ b/tests/test_scopes.py @@ -0,0 +1,308 @@ +"""Tests for granted-scope persistence and the scope gate. + +Covers issue #82: the granted scope set was never persisted, so +`_missing_scopes()` compared `SCOPES` against `SCOPES` and returned `[]` for +every user. See ADR-034. +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from desk import auth +from desk.config import SCOPES + +A_SCOPE = "https://www.googleapis.com/auth/calendar" +PRESENTATIONS = "https://www.googleapis.com/auth/presentations" +# The scope ADR-026 added, so the one existing tokens are most likely to lack. +B_SCOPE = PRESENTATIONS + + +@pytest.fixture +def token_store(tmp_path): + """Isolate token storage: empty keyring, token file under tmp_path.""" + keyring: dict = {} + + def get_token(): + return keyring.get("token") + + def set_token(data): + keyring["token"] = data + + token_file = tmp_path / "token.json" + with ( + patch("desk.auth.keyring_store.get_token", side_effect=get_token), + patch("desk.auth.keyring_store.set_token", side_effect=set_token), + patch("desk.auth.TOKEN_FILE", token_file), + patch("desk.auth.ensure_config_dir", lambda: tmp_path), + ): + yield {"keyring": keyring, "file": token_file} + + +def _creds(granted=None, requested=None): + """A credentials double shaped like google-auth's Credentials.""" + creds = MagicMock() + creds.granted_scopes = granted + creds.scopes = requested if requested is not None else list(SCOPES) + creds.quota_project_id = None + creds.to_json.return_value = json.dumps( + { + "token": "at", + "refresh_token": "rt", + "client_id": "cid", + "client_secret": "secret", + "token_uri": "https://oauth2.googleapis.com/token", + # google-auth writes the *requested* set here, never the granted one + "scopes": creds.scopes, + } + ) + return creds + + +class TestGrantedScopePersistence: + def test_save_records_granted_set(self, token_store): + auth._save_credentials(_creds(granted=[A_SCOPE])) + + stored = token_store["keyring"]["token"] + assert stored[auth.GRANTED_SCOPES_KEY] == [A_SCOPE] + + def test_granted_set_survives_scrubbing(self, token_store): + """The granted set is non-sensitive, so it stays in the on-disk file.""" + auth._save_credentials(_creds(granted=[A_SCOPE])) + + on_disk = json.loads(token_store["file"].read_text()) + assert on_disk[auth.GRANTED_SCOPES_KEY] == [A_SCOPE] + for secret in auth._TOKEN_SENSITIVE_FIELDS: + assert secret not in on_disk + + def test_save_without_granted_set_preserves_prior_record(self, token_store): + """A save from a non-refreshed object must not wipe known truth. + + google-auth only populates `granted_scopes` from a live token response, + so a routine save would otherwise erase it. + """ + auth._save_credentials(_creds(granted=[A_SCOPE])) + auth._save_credentials(_creds(granted=None)) + + stored = token_store["keyring"]["token"] + assert stored[auth.GRANTED_SCOPES_KEY] == [A_SCOPE] + + def test_unknown_when_never_recorded(self, token_store): + assert auth._stored_granted_scopes() is None + + def test_restore_attaches_stored_set(self, token_store): + auth._save_credentials(_creds(granted=[A_SCOPE])) + + loaded = _creds(granted=None) + auth._restore_granted_scopes(loaded) + assert loaded._granted_scopes == [A_SCOPE] + + def test_restore_does_not_clobber_fresher_value(self, token_store): + auth._save_credentials(_creds(granted=[A_SCOPE])) + + refreshed = _creds(granted=[A_SCOPE, B_SCOPE]) + auth._restore_granted_scopes(refreshed) + assert refreshed.granted_scopes == [A_SCOPE, B_SCOPE] + + +class TestMissingScopes: + def test_detects_drift(self, token_store): + """The regression from issue #82: a partial grant must be reported.""" + partial = [s for s in SCOPES if s != B_SCOPE] + creds = _creds(granted=partial) + + assert auth._missing_scopes(creds) == [B_SCOPE] + + def test_requested_set_is_ignored(self, token_store): + """`scopes` claiming everything must not mask a narrower grant. + + This is the exact shape of the bug: desk passes SCOPES into + `from_authorized_user_info`, so `creds.scopes` always looks complete. + """ + creds = _creds(granted=[A_SCOPE], requested=list(SCOPES)) + + missing = auth._missing_scopes(creds) + assert missing == sorted(set(SCOPES) - {A_SCOPE}) + assert missing, "a one-scope grant cannot satisfy every desk scope" + + def test_full_grant_reports_nothing_missing(self, token_store): + assert auth._missing_scopes(_creds(granted=list(SCOPES))) == [] + + def test_unknown_grant_reports_none_not_empty(self, token_store): + """None and [] mean different things — callers fail open only on None.""" + assert auth._missing_scopes(_creds(granted=None)) is None + + +class TestGrantedScopesAccessor: + def test_prefers_credentials_over_storage(self, token_store): + auth._save_credentials(_creds(granted=[A_SCOPE])) + + assert auth.granted_scopes(_creds(granted=[B_SCOPE])) == {B_SCOPE} + + def test_falls_back_to_storage(self, token_store): + auth._save_credentials(_creds(granted=[A_SCOPE])) + + assert auth.granted_scopes() == {A_SCOPE} + + def test_none_when_unknown(self, token_store): + assert auth.granted_scopes() is None + assert auth.granted_scopes(_creds(granted=None)) is None + + +class TestScopeMap: + def test_service_entry_covers_every_command(self): + from desk.config import scopes_for_command, scopes_for_service + + assert scopes_for_service("slides") == [PRESENTATIONS] + assert scopes_for_command("slides", "create") == [PRESENTATIONS] + assert scopes_for_command("slides", "ungroup") == [PRESENTATIONS] + + def test_ungated_service_has_no_scopes(self): + from desk.config import scopes_for_command, scopes_for_service + + assert scopes_for_service("mail") == [] + assert scopes_for_command("mail", "search") == [] + + def test_affected_commands_reads_naturally(self): + from desk.config import commands_for_scopes + + assert commands_for_scopes([PRESENTATIONS]) == ["slides (all commands)"] + + +class TestEnforceScopes: + def test_blocks_when_scope_missing(self, token_store): + from desk.agent import enforce_scopes + + auth._save_credentials(_creds(granted=[A_SCOPE])) + with pytest.raises(SystemExit) as exc: + enforce_scopes([PRESENTATIONS], as_json=True) + assert exc.value.code == 1 + + def test_error_names_scope_and_fix(self, token_store, capsys): + from desk.agent import enforce_scopes + + auth._save_credentials(_creds(granted=[A_SCOPE])) + with pytest.raises(SystemExit): + enforce_scopes([PRESENTATIONS], as_json=True) + + payload = json.loads(capsys.readouterr().err) + assert payload["error"]["code"] == "INSUFFICIENT_SCOPES" + assert payload["error"]["details"]["scope_needed"] == [PRESENTATIONS] + assert payload["error"]["details"]["affected_commands"] == ["slides (all commands)"] + assert any("desk auth login" in s for s in payload["error"]["suggestions"]) + + def test_allows_when_scope_granted(self, token_store): + from desk.agent import enforce_scopes + + auth._save_credentials(_creds(granted=[PRESENTATIONS])) + enforce_scopes([PRESENTATIONS], as_json=True) # must not raise + + def test_fails_open_when_grant_unknown(self, token_store): + """A pre-#82 token must never be blocked on a guess.""" + from desk.agent import enforce_scopes + + assert auth.granted_scopes() is None + enforce_scopes([PRESENTATIONS], as_json=True) # must not raise + + def test_no_scopes_is_a_noop(self, token_store): + from desk.agent import enforce_scopes + + auth._save_credentials(_creds(granted=[])) + enforce_scopes([], as_json=True) # must not raise + + +class TestCapabilitiesEnabled: + def test_enabled_false_when_scope_missing(self, token_store): + from desk.cli import _get_capabilities + + auth._save_credentials(_creds(granted=[s for s in SCOPES if s != PRESENTATIONS])) + caps = _get_capabilities() + + assert caps["services"]["slides"]["commands"]["create"]["enabled"] is False + assert caps["services"]["mail"]["commands"]["search"]["enabled"] is True + + def test_enabled_true_with_full_grant(self, token_store): + from desk.cli import _get_capabilities + + auth._save_credentials(_creds(granted=list(SCOPES))) + caps = _get_capabilities() + + assert caps["services"]["slides"]["commands"]["create"]["enabled"] is True + + def test_enabled_null_when_grant_unknown(self, token_store): + """Tri-state: unknown is not the same as disabled.""" + from desk.cli import _get_capabilities + + caps = _get_capabilities() + assert caps["services"]["slides"]["commands"]["create"]["enabled"] is None + + def test_scope_reported_per_command(self, token_store): + from desk.cli import _get_capabilities + + caps = _get_capabilities() + assert caps["services"]["slides"]["commands"]["create"]["scope"] == [PRESENTATIONS] + assert caps["services"]["mail"]["commands"]["search"]["scope"] == [] + + +class TestKeyringlessHost: + """A host with no keyring backend must degrade, not crash. + + `--capabilities` is pure introspection, so it reads the granted set on every + invocation. Without this, adding that read regressed startup on headless + Linux, containers, and CI runners into a NoKeyringError traceback — the + failure mode Cafe's ADR-024 was written to undo. See ADR-034. + """ + + @pytest.fixture + def no_backend(self): + import keyring.errors + + def boom(*args, **kwargs): + raise keyring.errors.NoKeyringError("no backend") + + with patch("desk.keyring_store.keyring.get_password", side_effect=boom): + yield + + def test_get_token_reports_absence(self, no_backend): + from desk.keyring_store import get_token + + assert get_token() is None + + def test_get_client_credentials_reports_absence(self, no_backend): + from desk.keyring_store import get_client_credentials + + assert get_client_credentials() is None + + def test_granted_scopes_unknown(self, no_backend, tmp_path): + with patch("desk.auth.TOKEN_FILE", tmp_path / "absent.json"): + assert auth.granted_scopes() is None + + def test_capabilities_still_renders(self, no_backend, tmp_path): + from desk.cli import _get_capabilities + + with patch("desk.auth.TOKEN_FILE", tmp_path / "absent.json"): + caps = _get_capabilities() + + assert caps["services"]["slides"]["commands"]["create"]["enabled"] is None + + def test_scope_gate_fails_open(self, no_backend, tmp_path): + from desk.agent import enforce_scopes + + with patch("desk.auth.TOKEN_FILE", tmp_path / "absent.json"): + enforce_scopes([PRESENTATIONS], as_json=True) # must not raise + + def test_writes_still_fail_loudly(self, no_backend): + """Storing a secret with nowhere to put it must never be silent.""" + import keyring.errors + + from desk.keyring_store import set_token + + with patch( + "desk.keyring_store.keyring.set_password", + side_effect=keyring.errors.NoKeyringError("no backend"), + ): + with pytest.raises(keyring.errors.NoKeyringError): + set_token({"token": "x"}) diff --git a/tests/test_services/test_calendar_fields.py b/tests/test_services/test_calendar_fields.py new file mode 100644 index 0000000..feedd5d --- /dev/null +++ b/tests/test_services/test_calendar_fields.py @@ -0,0 +1,272 @@ +"""Tests for Calendar event fields added in ADR-035 (issue #80). + +Focused on the request bodies Desk sends, since that's where the gaps were: +no conferenceData, no guest permissions, and a hardcoded sendUpdates="all". +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from desk.services.calendar import CalendarClient + + +@pytest.fixture +def client(): + with patch("desk.services.calendar.build") as build: + service = MagicMock() + build.return_value = service + c = CalendarClient(MagicMock()) + c._service_mock = service + yield c + + +def _insert_call(client): + return client._service_mock.events.return_value.insert.call_args + + +def _update_call(client): + return client._service_mock.events.return_value.update.call_args + + +def _set_insert_result(client, result=None): + client._service_mock.events.return_value.insert.return_value.execute.return_value = ( + result or {"id": "e1", "summary": "x"} + ) + + +def _set_get_result(client, result): + client._service_mock.events.return_value.get.return_value.execute.return_value = result + + +def _set_update_result(client, result=None): + client._service_mock.events.return_value.update.return_value.execute.return_value = ( + result or {"id": "e1", "summary": "x"} + ) + + +class TestSendUpdates: + """The hardcoded "all" was the sharpest edge — you could never opt out.""" + + def test_defaults_to_all_preserving_prior_behavior(self, client): + _set_insert_result(client) + client.create("Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00") + + assert _insert_call(client).kwargs["sendUpdates"] == "all" + + def test_none_is_honored(self, client): + _set_insert_result(client) + client.create( + "Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00", send_updates="none" + ) + + assert _insert_call(client).kwargs["sendUpdates"] == "none" + + def test_cli_spelling_maps_to_api_spelling(self, client): + """CLI says external-only; the API wants externalOnly.""" + _set_insert_result(client) + client.create( + "Standup", + "2026-08-01T10:00:00", + "2026-08-01T10:30:00", + send_updates="external-only", + ) + + assert _insert_call(client).kwargs["sendUpdates"] == "externalOnly" + + def test_delete_can_be_quiet(self, client): + """Deleting mailed a cancellation to every attendee unconditionally.""" + client.delete("e1", send_updates="none") + + call = client._service_mock.events.return_value.delete.call_args + assert call.kwargs["sendUpdates"] == "none" + + def test_respond_can_be_quiet(self, client): + _set_get_result(client, {"id": "e1", "attendees": [{"email": "me@x", "self": True}]}) + _set_update_result(client) + client.respond("e1", "accepted", send_updates="none") + + assert _update_call(client).kwargs["sendUpdates"] == "none" + + +class TestMeetConference: + def test_no_conference_unless_requested(self, client): + _set_insert_result(client) + client.create("Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00") + + assert "conferenceData" not in _insert_call(client).kwargs["body"] + + def test_meet_adds_create_request(self, client): + _set_insert_result(client) + client.create( + "Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00", meet=True + ) + + body = _insert_call(client).kwargs["body"] + req = body["conferenceData"]["createRequest"] + assert req["conferenceSolutionKey"] == {"type": "hangoutsMeet"} + assert req["requestId"] + + def test_conference_data_version_is_set(self, client): + """Without conferenceDataVersion=1 the API silently ignores the request.""" + _set_insert_result(client) + client.create( + "Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00", meet=True + ) + + assert _insert_call(client).kwargs["conferenceDataVersion"] == 1 + + def test_request_id_is_stable_for_the_same_event(self, client): + """Calendar treats requestId as an idempotency key, so a retry must reuse it.""" + _set_insert_result(client) + client.create("Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00", meet=True) + first = _insert_call(client).kwargs["body"]["conferenceData"]["createRequest"][ + "requestId" + ] + + client.create("Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00", meet=True) + second = _insert_call(client).kwargs["body"]["conferenceData"]["createRequest"][ + "requestId" + ] + + assert first == second + + def test_request_id_differs_across_events(self, client): + _set_insert_result(client) + client.create("Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00", meet=True) + a = _insert_call(client).kwargs["body"]["conferenceData"]["createRequest"]["requestId"] + + client.create("Retro", "2026-08-02T10:00:00", "2026-08-02T10:30:00", meet=True) + b = _insert_call(client).kwargs["body"]["conferenceData"]["createRequest"]["requestId"] + + assert a != b + + def test_update_adds_conference_when_absent(self, client): + _set_get_result(client, {"id": "e1", "summary": "Standup", "start": {}}) + _set_update_result(client) + result = client.update("e1", meet=True) + + assert "createRequest" in _update_call(client).kwargs["body"]["conferenceData"] + assert result["conferenceAdded"] is True + + def test_update_is_idempotent_when_conference_exists(self, client): + """Adding --meet twice must not request a second conference.""" + existing = {"conferenceId": "abc-defg-hij"} + _set_get_result( + client, {"id": "e1", "summary": "Standup", "conferenceData": existing} + ) + _set_update_result(client) + result = client.update("e1", meet=True) + + assert _update_call(client).kwargs["body"]["conferenceData"] == existing + assert result["conferenceAdded"] is False + + +class TestEventOptions: + def test_guest_flags_only_sent_when_asked(self, client): + """Unset flags must leave Google's defaults alone.""" + _set_insert_result(client) + client.create("Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00") + + body = _insert_call(client).kwargs["body"] + for field in ( + "guestsCanSeeOtherGuests", + "guestsCanInviteOthers", + "guestsCanModify", + "location", + "visibility", + "transparency", + ): + assert field not in body + + def test_guest_flags_map_to_api_fields(self, client): + _set_insert_result(client) + client.create( + "Standup", + "2026-08-01T10:00:00", + "2026-08-01T10:30:00", + hide_guest_list=True, + no_guest_invites=True, + guests_can_modify=True, + ) + + body = _insert_call(client).kwargs["body"] + assert body["guestsCanSeeOtherGuests"] is False + assert body["guestsCanInviteOthers"] is False + assert body["guestsCanModify"] is True + + def test_location_and_visibility(self, client): + _set_insert_result(client) + client.create( + "Standup", + "2026-08-01T10:00:00", + "2026-08-01T10:30:00", + location="Room 4", + visibility="private", + ) + + body = _insert_call(client).kwargs["body"] + assert body["location"] == "Room 4" + assert body["visibility"] == "private" + + def test_free_maps_to_transparency(self, client): + _set_insert_result(client) + client.create( + "Standup", "2026-08-01T10:00:00", "2026-08-01T10:30:00", free=True + ) + + assert _insert_call(client).kwargs["body"]["transparency"] == "transparent" + + def test_update_applies_options(self, client): + _set_get_result(client, {"id": "e1", "summary": "Standup"}) + _set_update_result(client) + client.update("e1", location="Room 9", hide_guest_list=True) + + body = _update_call(client).kwargs["body"] + assert body["location"] == "Room 9" + assert body["guestsCanSeeOtherGuests"] is False + + +class TestParseEventConference: + """The read-side gap not mentioned in #80: Meet links were dropped entirely.""" + + def test_surfaces_meet_link_and_conference_id(self, client): + parsed = client._parse_event( + { + "id": "e1", + "summary": "Standup", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + "conferenceData": {"conferenceId": "abc-defg-hij"}, + } + ) + + assert parsed["meetLink"] == "https://meet.google.com/abc-defg-hij" + assert parsed["conferenceId"] == "abc-defg-hij" + + def test_empty_when_no_conference(self, client): + parsed = client._parse_event({"id": "e1", "summary": "Standup"}) + + assert parsed["meetLink"] == "" + assert parsed["conferenceId"] == "" + + def test_reports_pending_conference_status(self, client): + """Conference creation is async, so the link can lag the response.""" + parsed = client._parse_event( + { + "id": "e1", + "summary": "Standup", + "conferenceData": { + "createRequest": {"status": {"statusCode": "pending"}} + }, + } + ) + + assert parsed["conferenceStatus"] == "pending" + assert parsed["meetLink"] == "" + + def test_tolerates_null_conference_data(self, client): + parsed = client._parse_event({"id": "e1", "conferenceData": None}) + + assert parsed["conferenceId"] == "" diff --git a/tests/test_services/test_meet.py b/tests/test_services/test_meet.py new file mode 100644 index 0000000..20c2118 --- /dev/null +++ b/tests/test_services/test_meet.py @@ -0,0 +1,160 @@ +"""Tests for the Meet service wrapper (ADR-036, issue #81).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from desk.services.meet import MeetClient, space_resource_name + + +@pytest.fixture +def client(): + with patch("desk.services.meet.build") as build: + service = MagicMock() + build.return_value = service + c = MeetClient(MagicMock()) + c._service_mock = service + yield c + + +def _patch_call(client): + return client._service_mock.spaces.return_value.patch.call_args + + +def _set_patch_result(client, result=None): + client._service_mock.spaces.return_value.patch.return_value.execute.return_value = ( + result or {"name": "spaces/x", "meetingCode": "abc-defg-hij"} + ) + + +class TestSpaceResourceName: + """A Calendar event's conferenceId must work as-is.""" + + def test_bare_meeting_code(self): + assert space_resource_name("abc-defg-hij") == "spaces/abc-defg-hij" + + def test_already_qualified(self): + assert space_resource_name("spaces/jQCFfuBOdN5z") == "spaces/jQCFfuBOdN5z" + + def test_strips_whitespace(self): + assert space_resource_name(" abc-defg-hij \n") == "spaces/abc-defg-hij" + + +class TestConfigureArtifacts: + def test_sets_recording_with_correct_enum(self, client): + _set_patch_result(client) + client.configure_artifacts("abc-defg-hij", auto_record="on") + + body = _patch_call(client).kwargs["body"] + recording = body["config"]["artifactConfig"]["recordingConfig"] + assert recording["autoRecordingGeneration"] == "ON" + + def test_off_and_default_enums(self, client): + _set_patch_result(client) + client.configure_artifacts("abc", auto_record="off") + assert ( + _patch_call(client).kwargs["body"]["config"]["artifactConfig"][ + "recordingConfig" + ]["autoRecordingGeneration"] + == "OFF" + ) + + client.configure_artifacts("abc", auto_record="default") + assert ( + _patch_call(client).kwargs["body"]["config"]["artifactConfig"][ + "recordingConfig" + ]["autoRecordingGeneration"] + == "AUTO_GENERATION_TYPE_UNSPECIFIED" + ) + + def test_update_mask_names_only_requested_fields(self, client): + """An unmentioned setting must not be reset.""" + _set_patch_result(client) + client.configure_artifacts("abc", auto_record="on") + + mask = _patch_call(client).kwargs["updateMask"] + assert mask == "config.artifactConfig.recordingConfig.autoRecordingGeneration" + + def test_multiple_settings_build_a_combined_mask(self, client): + _set_patch_result(client) + client.configure_artifacts("abc", auto_record="on", auto_transcript="on") + + mask = _patch_call(client).kwargs["updateMask"].split(",") + assert "config.artifactConfig.recordingConfig.autoRecordingGeneration" in mask + assert ( + "config.artifactConfig.transcriptionConfig.autoTranscriptionGeneration" + in mask + ) + assert len(mask) == 2 + + def test_smart_notes_path(self, client): + _set_patch_result(client) + client.configure_artifacts("abc", auto_smart_notes="on") + + assert ( + _patch_call(client).kwargs["updateMask"] + == "config.artifactConfig.smartNotesConfig.autoSmartNotesGeneration" + ) + + def test_resolves_space_name(self, client): + _set_patch_result(client) + client.configure_artifacts("abc-defg-hij", auto_record="on") + + assert _patch_call(client).kwargs["name"] == "spaces/abc-defg-hij" + + def test_rejects_empty_update(self, client): + with pytest.raises(ValueError, match="Nothing to update"): + client.configure_artifacts("abc") + + def test_rejects_invalid_value(self, client): + with pytest.raises(ValueError, match="Invalid value"): + client.configure_artifacts("abc", auto_record="yes") + + def test_no_api_call_on_empty_update(self, client): + with pytest.raises(ValueError): + client.configure_artifacts("abc") + + client._service_mock.spaces.return_value.patch.assert_not_called() + + +class TestParseSpace: + def test_surfaces_artifact_settings(self, client): + client._service_mock.spaces.return_value.get.return_value.execute.return_value = { + "name": "spaces/jQCFfuBOdN5z", + "meetingCode": "abc-defg-hij", + "meetingUri": "https://meet.google.com/abc-defg-hij", + "config": { + "accessType": "TRUSTED", + "moderation": "ON", + "artifactConfig": { + "recordingConfig": {"autoRecordingGeneration": "ON"}, + "transcriptionConfig": {"autoTranscriptionGeneration": "OFF"}, + }, + }, + } + + space = client.get_space("abc-defg-hij") + assert space["meetingCode"] == "abc-defg-hij" + assert space["autoRecord"] == "ON" + assert space["autoTranscript"] == "OFF" + assert space["autoSmartNotes"] == "" # absent, not assumed off + assert space["moderation"] == "ON" + + def test_tolerates_missing_config(self, client): + client._service_mock.spaces.return_value.get.return_value.execute.return_value = { + "name": "spaces/x" + } + + space = client.get_space("x") + assert space["autoRecord"] == "" + assert space["accessType"] == "" + + def test_tolerates_null_config(self, client): + client._service_mock.spaces.return_value.get.return_value.execute.return_value = { + "name": "spaces/x", + "config": None, + } + + assert client.get_space("x")["autoRecord"] == ""