Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ Run `partiful --help` and `partiful <command> --help` for command reference. Run

## Things you will get wrong without reading this

### `myRsvp: SENT` means an inbound invite awaiting the user's reply
`events list` returns the authenticated user's raw Partiful RSVP state in `myRsvp`:

| `myRsvp` | Meaning | Category |
|---|---|---|
| `GOING` | User accepted the invitation | Going |
| `MAYBE` | User replied maybe | Maybe |
| `INTERESTED` | User marked interest | Interested |
| `DECLINED` | User declined the invitation | Declined |
| `SENT` | Invitation sent to the authenticated user; no RSVP reply yet | Awaiting RSVP |

`SENT` does not mean you sent an invitation. It is an inbound invitation awaiting your RSVP. When categorizing event lists, `isHost === true` takes precedence and means **Hosting**; otherwise use the `myRsvp` mapping. A `null` value means no personal guest RSVP record is present. Run `partiful schema events.list` for the machine-readable contract.

### Contacts don't expose emails or phone numbers
`contacts list` returns names, user IDs, and shared event counts. It does **not** return email addresses or phone numbers. This is a Partiful privacy constraint. Don't try to extract contact details — they aren't available through any endpoint.

Expand Down Expand Up @@ -42,7 +55,7 @@ Integration tests (`tests/*.integration.test.js`) hit real Partiful APIs and nee

## Code conventions

- Plain JavaScript, no TypeScript, no build step
- TypeScript strict mode; executed through the tsx loader (no checked-in build output)
- Commander.js CLI framework, Vitest for tests
- One file per command group in `src/commands/`
- Structured error objects: `{ status, error: { code, type, message } }`
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,25 @@ All commands support `--format json`. Responses follow a consistent envelope:
}
```

- **`myRsvp`** — your personal RSVP: `GOING`, `MAYBE`, `DECLINED`, `SENT` (invited, no reply yet), or `null` on events you host. Filter on this to sync only the events you've accepted, e.g. `partiful events list | jq '.data[] | select(.myRsvp == "GOING")'`.
- **`isHost`** — `true` for events you own. (`going`/`maybe` are aggregate counts across all guests, not your status.)
`myRsvp` is the raw Partiful state for the authenticated user:

| `myRsvp` | Meaning | Category |
|---|---|---|
| `GOING` | User accepted the invitation | Going |
| `MAYBE` | User replied maybe | Maybe |
| `INTERESTED` | User marked interest | Interested |
| `DECLINED` | User declined the invitation | Declined |
| `SENT` | Invitation sent to the authenticated user; no RSVP reply yet | Awaiting RSVP |

**Important:** `SENT` does not mean you sent an invitation. It is an inbound invitation awaiting your RSVP. For categorization, `isHost === true` takes precedence and means **Hosting**; otherwise use the `myRsvp` mapping above. A `null` `myRsvp` means no personal guest RSVP record is present, commonly because you host the event.

Filter on the raw field to sync only events you've accepted:

```bash
partiful events list | jq '.data[] | select(.myRsvp == "GOING")'
```

`going` and `maybe` are aggregate counts across all guests, not your personal status. Run `partiful schema events.list` for the same mapping as machine-readable JSON.

Errors return `{ "status": "error", "error": { "code": 1, "type": "api_error", "message": "..." } }`.

Expand Down
23 changes: 17 additions & 6 deletions skills/partiful-events/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,24 @@ partiful events list --past
partiful events list --past --include-cancelled
```

Each event in the list includes **your own** RSVP and host status:
Each event in the list includes **your own** raw RSVP state and host status:

| Field | Meaning |
|-------|---------|
| `myRsvp` | Your personal RSVP: `GOING`, `MAYBE`, `DECLINED`, `SENT` (invited, not yet answered), or `null` on events you host |
| `isHost` | `true` when you own the event |
| `going` / `maybe` | Aggregate guest counts (everyone), **not** your status |
| `myRsvp` | Meaning | Category |
|---|---|---|
| `GOING` | User accepted the invitation | Going |
| `MAYBE` | User replied maybe | Maybe |
| `INTERESTED` | User marked interest | Interested |
| `DECLINED` | User declined the invitation | Declined |
| `SENT` | Invitation sent to the authenticated user; no RSVP reply yet | Awaiting RSVP |

**Do not misread `SENT`:** `SENT` does not mean you sent an invitation. It is an inbound invitation awaiting your RSVP.

Categorize in this order:
1. `isHost === true` → **Hosting** (takes precedence over `myRsvp`)
2. Otherwise, use the `myRsvp` category in the table
3. `myRsvp === null` → no personal guest RSVP record is present

`going` and `maybe` are aggregate counts across all guests, **not** your status. Use `partiful schema events.list` for this mapping as machine-readable JSON.

```bash
# Only the events you've said yes to (e.g. to sync to a calendar)
Expand Down
61 changes: 60 additions & 1 deletion src/commands/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,32 @@ interface SchemaParameter {
positional?: boolean;
}

/** A command schema entry: invocation string + parameter map. */
/** Meaning and user-facing category for one enum value. */
interface SchemaEnumValue {
meaning: string;
category: string;
}

/** A field in a command's machine-readable output contract. */
interface SchemaOutputField {
type: string;
description: string;
values?: Record<string, SchemaEnumValue>;
warning?: string;
}

/** Machine-readable output and categorization guidance for agents. */
interface SchemaOutput {
type: string;
fields: Record<string, SchemaOutputField>;
categorization?: Record<string, string>;
}

/** A command schema entry: invocation string, parameters, and optional output contract. */
interface CommandSchema {
command: string;
parameters: Record<string, SchemaParameter>;
output?: SchemaOutput;
}

const SCHEMAS: Record<string, CommandSchema> = {
Expand All @@ -24,6 +46,43 @@ const SCHEMAS: Record<string, CommandSchema> = {
'--past': { type: 'boolean', required: false, description: 'Show past events' },
'--include-cancelled': { type: 'boolean', required: false, description: 'Include cancelled events' },
},
output: {
type: 'EventSummary[]',
fields: {
myRsvp: {
type: '"GOING" | "MAYBE" | "INTERESTED" | "DECLINED" | "SENT" | null',
description: 'Authenticated user personal RSVP state; preserve this raw Partiful value',
values: {
GOING: { meaning: 'User accepted the invitation', category: 'Going' },
MAYBE: { meaning: 'User replied maybe', category: 'Maybe' },
INTERESTED: { meaning: 'User marked interest', category: 'Interested' },
DECLINED: { meaning: 'User declined the invitation', category: 'Declined' },
SENT: {
meaning: 'Invitation sent to the authenticated user; no RSVP reply yet',
category: 'Awaiting RSVP',
},
null: {
meaning: 'No personal guest RSVP record is present',
category: 'No RSVP record',
},
},
warning: 'SENT is inbound invitation state; it does not mean the authenticated user sent an invitation',
},
isHost: {
type: 'boolean',
description: 'True when the authenticated user owns the event; host categorization takes precedence over myRsvp',
},
},
categorization: {
precedence: 'isHost === true',
hosting: 'isHost === true → Hosting',
going: 'myRsvp === "GOING" → Going',
maybe: 'myRsvp === "MAYBE" → Maybe',
interested: 'myRsvp === "INTERESTED" → Interested',
declined: 'myRsvp === "DECLINED" → Declined',
awaitingRsvp: 'myRsvp === "SENT" → Awaiting RSVP',
},
},
},
'events.get': {
command: 'events get <eventId>',
Expand Down
66 changes: 66 additions & 0 deletions tests/rsvp-semantics.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { mapEventSummary } from '../src/lib/events.js';
import { run } from './helpers.js';

const rootFile = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');

const expectedStatuses = {
GOING: { meaning: 'User accepted the invitation', category: 'Going' },
MAYBE: { meaning: 'User replied maybe', category: 'Maybe' },
INTERESTED: { meaning: 'User marked interest', category: 'Interested' },
DECLINED: { meaning: 'User declined the invitation', category: 'Declined' },
SENT: {
meaning: 'Invitation sent to the authenticated user; no RSVP reply yet',
category: 'Awaiting RSVP',
},
null: { meaning: 'No personal guest RSVP record is present', category: 'No RSVP record' },
};

describe('events.list RSVP semantics contract', () => {
it('publishes every myRsvp enum meaning and category in command schema output', () => {
const schema = run(['schema', 'events.list']).data;

expect(schema.output.fields.myRsvp.values).toEqual(expectedStatuses);
});

it('states that SENT is inbound and host status takes categorization precedence', () => {
const schema = run(['schema', 'events.list']).data;

expect(schema.output.fields.myRsvp.warning).toMatch(
/does not mean the authenticated user sent/i,
);
expect(schema.output.categorization.precedence).toBe('isHost === true');
expect(schema.output.categorization.hosting).toMatch(/isHost.*true/i);
expect(schema.output.categorization.awaitingRsvp).toMatch(/myRsvp.*SENT/i);
});

it('preserves SENT as the raw myRsvp value without adding a derived runtime field', () => {
const summary = mapEventSummary(
{
id: 'evt-sent',
guest: { status: 'SENT' },
ownerIds: ['someone-else'],
guestStatusCounts: {},
},
'me',
);

expect(summary.myRsvp).toBe('SENT');
expect(summary).not.toHaveProperty('myRsvpCategory');
expect(summary).not.toHaveProperty('myRsvpLabel');
});

it.each([
['AGENTS.md', /SENT.*invitation sent to (?:the )?authenticated user.*awaiting.*RSVP/is],
['README.md', /SENT.*invitation sent to (?:the )?authenticated user.*awaiting.*RSVP/is],
['skills/partiful-events/SKILL.md', /SENT.*invitation sent to (?:the )?authenticated user.*awaiting.*RSVP/is],
])('%s teaches the canonical SENT meaning', (path, sentPattern) => {
const contents = rootFile(path);

expect(contents).toMatch(sentPattern);
expect(contents).toMatch(/INTERESTED/);
expect(contents).toMatch(/SENT.*does not mean.*you sent/is);
expect(contents).toMatch(/isHost.*true.*Hosting/is);
});
});