Skip to content
Merged
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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,26 @@ Every command supports `--json` for piping into `jq` or Claude.

| Command | Description |
|---|---|
| `mna variants add <tripId> --name <name> [--notes=...]` | Add a new variant. |
| `mna variants add <tripId> --name <name> <date flags> [--notes=...]` | Add a new variant. Dates are required — see below. |
| `mna variants duplicate <tripId> <variantId>` | Duplicate a variant. |
| `mna variants edit <tripId> <variantId> [--name=...] [--notes=...]` | Update variant fields. |
| `mna variants edit <tripId> <variantId> [--name=...] [--notes=...] [<date flags>]` | Update variant fields. Omitting the date flags leaves the dates alone. |
| `mna variants select <tripId> <variantId>` | Set the selected variant. |
| `mna variants delete <tripId> <variantId> [--yes]` | Delete a variant. |

A variant carries the trip's dates, and the API refuses to store one without them. Give
either shape, whole — the CLI rejects a half-filled or mixed set before it sends anything:

- **Exact:** `--start-date=YYYY-MM-DD --end-date=YYYY-MM-DD`
- **Flexible:** `--depart-not-before --depart-not-after --return-not-before --return-not-after --min-nights --max-nights`

```bash
mna variants add <tripId> --name "Beach option" --start-date 2026-09-01 --end-date 2026-09-08
mna variants add <tripId> --name "Flexible option" \
--depart-not-before 2026-09-01 --depart-not-after 2026-09-03 \
--return-not-before 2026-09-10 --return-not-after 2026-09-12 \
--min-nights 7 --max-nights 10
```

### Destinations

| Command | Description |
Expand Down
5 changes: 4 additions & 1 deletion skills/mna/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ the playbook below.
0. **Bootstrap** — `mna whoami`; everything `--json`.
1. **Frame & anchor** — confirm participants, exact dates, origin/return, vibe, budget, the main
goal. Anchor on an existing trip (`trips list`) or `mna trips create --name "…"`.
2. **Shape variants with the user** — agree on 2–3 comparable strategies. `mna variants add`, or
2. **Shape variants with the user** — agree on 2–3 comparable strategies. The variant carries the
trip's dates, and `mna variants add` requires them:
`mna variants add <trip> --name "…" --start-date <YYYY-MM-DD> --end-date <YYYY-MM-DD>`
(or the six flexible-dates flags — see `references/cli-and-schemas.md`). Or
`mna variants duplicate` to fork a baseline and then `variants edit` the notes.
3. **Lay out destinations** — add in travel order with dates:
`mna destinations add <trip> <variant> --place "<City, Country>"
Expand Down
29 changes: 23 additions & 6 deletions skills/mna/references/cli-and-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ the field shapes you can't guess. Examples below use neutral placeholders.

```
trips list | show <id> [--all-options] | create --name | edit <id> | delete <id> | share | unshare
variants add <trip> --name | duplicate <trip> <var> | edit <trip> <var> | select <trip> <var> | delete
variants add <trip> --name <dates…> [--notes] | duplicate <trip> <var>
edit <trip> <var> [--name --notes <dates…>] | select <trip> <var> | delete
destinations add <trip> <var> --place [--start-date --end-date --notes --return-to-home]
edit <trip> <var> <destKey> [--place --notes --start-date --end-date --return-to-home/--no-…]
reorder <trip> <var> --order=k1,k2,k3 | delete <trip> <var> <destKey>
Expand Down Expand Up @@ -70,7 +71,7 @@ home-return drive on the `--return-to-home` destination so the per-variant total
```json
{ "name": "City walking tour", "start": "2026-07-06T10:00", "end": "2026-07-06T13:00",
"totalCost": 0, "currency": "EUR", "link": "…", "notes": "…",
"location": { "name": "Old Town", "formattedAddress": "…", "coordinates": { "latitude": 0.0, "longitude": 0.0 } } }
"location": { "name": "Old Town", "formattedAddress": "…", "coordinates": { "lat": 0.0, "lng": 0.0 } } }
```

## Location shape — a non-obvious gotcha
Expand All @@ -86,17 +87,33 @@ Coordinates persist **only** when sent nested:
"coordinates": { "lat": 0.000000, "lng": 0.000000 }
}
```
(Option locations use `coordinates.lat`/`.lng`; **event** locations use
`coordinates.latitude`/`.longitude`.) Get exact coordinates from the search result, or geocode
the address (e.g. OpenStreetMap Nominatim). After setting, **verify with `trips show`** — never
trust the 2xx alone.
Coordinate keys are `lat`/`lng` **everywhere, including event locations** — an event body with
`coordinates.latitude`/`.longitude` is a 500, not a silent drop (verified against production).
Get exact coordinates from the search result, or geocode the address (e.g. OpenStreetMap
Nominatim). After setting, **verify with `trips show`** — never trust the 2xx alone.

## Dates

`destinations --start-date/--end-date`, accommodation `checkIn/checkOut/freeCancellationUntil`,
and event `start/end` are all ISO date-time. Accept `YYYY-MM-DD` from the user and normalize.
Using `T12:00:00.000Z` (noon UTC) avoids timezone off-by-one on the displayed calendar date.

### Variant dates — required on create

The variant is what carries the trip's dates, and the API refuses to store one without them.
`variants add` takes one of two complete shapes; a half-filled or mixed set is rejected locally:

```bash
mna variants add <trip> --name "Beach option" --start-date 2026-09-01 --end-date 2026-09-08
mna variants add <trip> --name "Flexible option" \
--depart-not-before 2026-09-01 --depart-not-after 2026-09-03 \
--return-not-before 2026-09-10 --return-not-after 2026-09-12 \
--min-nights 7 --max-nights 10
```

`variants edit` takes the same flags; leave them off and the existing dates stay untouched.
Switching a variant between the two shapes is just an edit with the other flag set.

## Verify-after-write

Mutation responses are intentionally thin (often just the new key). Confirm the state with
Expand Down
6 changes: 5 additions & 1 deletion src/commands/variants/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,27 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede
import { renderJson } from '../../render/json'
import { colors } from '../../render/colors'
import { reportAndExit, requireApiKey } from '../../util/errors'
import { requireVariantDates, variantDateArgs } from './dates'

export const variantsAddCommand = defineCommand({
meta: { name: 'add', description: 'Add a new variant to a trip.' },
args: {
tripId: { type: 'positional', description: 'Trip ID.' },
name: { type: 'string', required: true, description: 'Variant name.' },
notes: { type: 'string', description: 'Free-form variant notes.' },
...variantDateArgs,
json: { type: 'boolean', default: false, description: 'Output as JSON.' },
},
async run({ args }) {
try {
const dates = requireVariantDates(args)

const creds = await loadCredentials()
const apiKey = resolveApiKey(creds)
requireApiKey(apiKey)
const client = createApiClient({ baseUrl: resolveBaseUrl(creds), apiKey })

const body: Record<string, unknown> = { name: args.name }
const body: Record<string, unknown> = { name: args.name, dates }
if (args.notes !== undefined) body.notes = args.notes

const { data, error } = await client.POST('/v1/trips/{id}/variants', {
Expand Down
78 changes: 78 additions & 0 deletions src/commands/variants/dates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test'
import { buildVariantDates, requireVariantDates } from './dates'

const FLEXIBLE_ARGS = {
'depart-not-before': '2026-09-01',
'depart-not-after': '2026-09-03',
'return-not-before': '2026-09-10',
'return-not-after': '2026-09-12',
'min-nights': '7',
'max-nights': '10',
}

describe('buildVariantDates', () => {
test('returns undefined when no date flag is given', () => {
expect(buildVariantDates({ name: 'Beach option' })).toBeUndefined()
})

test('builds an exact-dates object from --start-date / --end-date', () => {
expect(buildVariantDates({ 'start-date': '2026-09-01', 'end-date': '2026-09-08' })).toEqual({
startDate: '2026-09-01T00:00:00.000Z',
endDate: '2026-09-08T00:00:00.000Z',
})
})

test('builds a flexi-dates object with numeric night counts', () => {
expect(buildVariantDates(FLEXIBLE_ARGS)).toEqual({
departLeavingNotBeforeDate: '2026-09-01T00:00:00.000Z',
departArrivingNotAfterDate: '2026-09-03T00:00:00.000Z',
returnLeavingNotBeforeDate: '2026-09-10T00:00:00.000Z',
returnArrivingNotAfterDate: '2026-09-12T00:00:00.000Z',
minNights: 7,
maxNights: 10,
})
})

test('rejects a half-filled exact-dates pair, naming the missing flag', () => {
expect(() => buildVariantDates({ 'start-date': '2026-09-01' })).toThrow(
/Missing --end-date/,
)
})

test('rejects a half-filled flexi-dates set', () => {
const { 'max-nights': _dropped, ...partial } = FLEXIBLE_ARGS
expect(() => buildVariantDates(partial)).toThrow(/Missing --max-nights/)
})

test('rejects mixing the two shapes', () => {
expect(() =>
buildVariantDates({ 'start-date': '2026-09-01', 'min-nights': '7' }),
).toThrow(/Mixed date shapes/)
})

test('rejects a non-integer night count', () => {
expect(() => buildVariantDates({ ...FLEXIBLE_ARGS, 'min-nights': 'seven' })).toThrow(
/Invalid night count for --min-nights/,
)
})

test('rejects an unparseable date with the flag name', () => {
expect(() =>
buildVariantDates({ 'start-date': 'next tuesday', 'end-date': '2026-09-08' }),
).toThrow(/Invalid date for --start-date/)
})
})

describe('requireVariantDates', () => {
test('fails fast when dates are absent, listing both shapes', () => {
expect(() => requireVariantDates({ name: 'Beach option' })).toThrow(
/cannot be created without dates/,
)
})

test('passes a complete shape through', () => {
expect(requireVariantDates({ 'start-date': '2026-09-01', 'end-date': '2026-09-08' })).toEqual(
{ startDate: '2026-09-01T00:00:00.000Z', endDate: '2026-09-08T00:00:00.000Z' },
)
})
})
119 changes: 119 additions & 0 deletions src/commands/variants/dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { normalizeToIsoDateTime } from '../../util/dates'

const EXACT_DATE_FIELDS: Record<string, string> = {
'start-date': 'startDate',
'end-date': 'endDate',
}

const FLEXIBLE_DATE_FIELDS: Record<string, string> = {
'depart-not-before': 'departLeavingNotBeforeDate',
'depart-not-after': 'departArrivingNotAfterDate',
'return-not-before': 'returnLeavingNotBeforeDate',
'return-not-after': 'returnArrivingNotAfterDate',
}

const FLEXIBLE_NIGHT_FIELDS: Record<string, string> = {
'min-nights': 'minNights',
'max-nights': 'maxNights',
}

const EXACT_FLAGS = Object.keys(EXACT_DATE_FIELDS)
const FLEXIBLE_FLAGS = [...Object.keys(FLEXIBLE_DATE_FIELDS), ...Object.keys(FLEXIBLE_NIGHT_FIELDS)]

/** Date flags shared by `variants add` and `variants edit`. */
export const variantDateArgs = {
'start-date': {
type: 'string',
description: 'Exact dates: first day of the trip (YYYY-MM-DD or ISO date-time).',
},
'end-date': {
type: 'string',
description: 'Exact dates: last day of the trip (YYYY-MM-DD or ISO date-time).',
},
'depart-not-before': {
type: 'string',
description: 'Flexible dates: earliest outbound departure.',
},
'depart-not-after': {
type: 'string',
description: 'Flexible dates: latest outbound arrival.',
},
'return-not-before': {
type: 'string',
description: 'Flexible dates: earliest return departure.',
},
'return-not-after': {
type: 'string',
description: 'Flexible dates: latest return arrival.',
},
'min-nights': { type: 'string', description: 'Flexible dates: minimum nights away.' },
'max-nights': { type: 'string', description: 'Flexible dates: maximum nights away.' },
} as const

function suppliedFlags(args: Record<string, unknown>, flags: string[]): string[] {
return flags.filter((flag) => args[flag] !== undefined && args[flag] !== '')
}

function flagList(flags: string[]): string {
return flags.map((flag) => `--${flag}`).join(', ')
}

function parseNightCount(value: string, flag: string): number {
const nights = Number(value.trim())
if (!Number.isInteger(nights) || nights < 0) {
throw new Error(`Invalid night count for ${flag}: "${value}". Use a whole number of nights.`)
}
return nights
}

/**
* Builds the variant `dates` body from the date flags, or returns undefined when
* none were given. The API accepts only a complete exact-dates or complete
* flexible-dates object, so anything half-filled throws before the request goes out.
*/
export function buildVariantDates(
args: Record<string, unknown>,
): Record<string, unknown> | undefined {
const exact = suppliedFlags(args, EXACT_FLAGS)
const flexible = suppliedFlags(args, FLEXIBLE_FLAGS)

if (exact.length > 0 && flexible.length > 0) {
throw new Error(
`Mixed date shapes: use either exact dates (${flagList(EXACT_FLAGS)}) or flexible dates (${flagList(FLEXIBLE_FLAGS)}), not both.`,
)
}
if (exact.length === 0 && flexible.length === 0) return undefined

const isExact = exact.length > 0
const required = isExact ? EXACT_FLAGS : FLEXIBLE_FLAGS
const missing = required.filter((flag) => !(isExact ? exact : flexible).includes(flag))
if (missing.length > 0) {
throw new Error(
`Incomplete dates: ${flagList(required)} must be given together. Missing ${flagList(missing)}.`,
)
}

const dates: Record<string, unknown> = {}
for (const [flag, field] of Object.entries(
isExact ? EXACT_DATE_FIELDS : FLEXIBLE_DATE_FIELDS,
)) {
dates[field] = normalizeToIsoDateTime(String(args[flag]), `--${flag}`)
}
if (!isExact) {
for (const [flag, field] of Object.entries(FLEXIBLE_NIGHT_FIELDS)) {
dates[field] = parseNightCount(String(args[flag]), `--${flag}`)
}
}
return dates
}

/** Same as `buildVariantDates`, for the create path where the API demands dates. */
export function requireVariantDates(args: Record<string, unknown>): Record<string, unknown> {
const dates = buildVariantDates(args)
if (dates === undefined) {
throw new Error(
`A variant cannot be created without dates. Supply exact dates (${flagList(EXACT_FLAGS)}) or flexible dates (${flagList(FLEXIBLE_FLAGS)}).`,
)
}
return dates
}
Loading
Loading