@zeegme/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ import { unwrap } from '../api/client.js';
2
+ import { UsageError } from '../errors.js';
3
+ import { asJson, clockTime, collect, isoDate, localTimeZone, pageFooter, positiveInt } from './util.js';
4
+ const inviteeNames = (row) => (row.invitees ?? []).map((invitee) => invitee.fullName ?? invitee.email);
5
+ export function registerBookings(program, context) {
6
+ const bookings = program.command('bookings').description('Scheduled events (bookings)');
7
+ bookings
8
+ .command('list')
9
+ .description('List bookings you can see, soonest first')
10
+ .option('--status <status>', 'confirmed or cancelled')
11
+ .option('--from <datetime>', 'start at or after this ISO 8601 time')
12
+ .option('--to <datetime>', 'start at or before this ISO 8601 time')
13
+ .option('--invitee-email <email>', 'only bookings with this invitee')
14
+ .option('--host-email <email>', 'only bookings hosted by this user')
15
+ .option('--keyword <text>', 'search titles and invitees')
16
+ .option('--all', 'include your team’s bookings (admins)')
17
+ .option('--desc', 'latest first')
18
+ .option('--page <n>', 'page number', positiveInt('--page'))
19
+ .option('--count <n>', 'results per page (max 100)', positiveInt('--count'))
20
+ .action(async (options) => {
21
+ if (options.status !== undefined && options.status !== 'confirmed' && options.status !== 'cancelled') {
22
+ throw new UsageError('--status must be confirmed or cancelled.');
23
+ }
24
+ const ctx = context();
25
+ const data = asJson(await unwrap((await ctx.api()).GET('/scheduled-events', {
26
+ params: {
27
+ query: {
28
+ status: options.status,
29
+ minStartTime: options.from,
30
+ maxStartTime: options.to,
31
+ inviteeEmail: options.inviteeEmail,
32
+ hostEmail: options.hostEmail,
33
+ keyword: options.keyword,
34
+ scope: options.all ? 'all' : undefined,
35
+ sort: options.desc ? 'desc' : 'asc',
36
+ page: options.page,
37
+ count: options.count,
38
+ },
39
+ },
40
+ })));
41
+ ctx.print.list(data, (data.collection ?? []), [
42
+ { header: 'uuid', value: (row) => row.uuid },
43
+ { header: 'start', value: (row) => row.startTime },
44
+ { header: 'title', value: (row) => row.title },
45
+ { header: 'status', value: (row) => row.status },
46
+ { header: 'invitees', value: inviteeNames },
47
+ ], pageFooter(data.pagination));
48
+ });
49
+ bookings
50
+ .command('get <uuid>')
51
+ .description('Show one booking')
52
+ .action(async (uuid) => {
53
+ const ctx = context();
54
+ const data = asJson(await unwrap((await ctx.api()).GET('/scheduled-events/{uuid}', { params: { path: { uuid } } })));
55
+ ctx.print.record(data, asJson(data.resource));
56
+ });
57
+ bookings
58
+ .command('create <eventTypeUuid>')
59
+ .description('Book a meeting for an invitee (needs a paid plan; find a time with `zeeg availability` first)')
60
+ .requiredOption('--date <date>', 'day, YYYY-MM-DD', isoDate('--date'))
61
+ .requiredOption('--time <time>', 'start time, HH:mm in --tz', clockTime('--time'))
62
+ .requiredOption('--name <name>', 'invitee’s full name')
63
+ .requiredOption('--email <email>', 'invitee’s email; Zeeg sends the confirmation there')
64
+ .option('--tz <zone>', 'IANA time zone of --date and --time (default: this machine’s)')
65
+ .option('--duration <minutes>', 'meeting length (default: the event type’s)', positiveInt('--duration'))
66
+ .option('--phone <number>', 'invitee’s phone number, for phone-call locations')
67
+ .option('--guest <email>', 'add a guest, repeatable', collect)
68
+ .option('--location <type>', 'location type, when the event type offers several')
69
+ .option('--location-key <key>', 'exact location key from the event type')
70
+ .option('-y, --yes', 'book without asking')
71
+ .action(async (uuid, options) => {
72
+ const ctx = context();
73
+ const timeZone = options.tz ?? localTimeZone();
74
+ await ctx.confirm(`Book ${options.name} <${options.email}> on ${options.date} at ${options.time} (${timeZone})? Zeeg emails them a confirmation.`, options.yes);
75
+ const data = asJson(await unwrap((await ctx.api()).POST('/event', {
76
+ body: {
77
+ eventTypeId: uuid,
78
+ date: options.date,
79
+ start: options.time,
80
+ timeZone,
81
+ duration: options.duration,
82
+ name: options.name,
83
+ email: options.email,
84
+ phone: options.phone,
85
+ guests: options.guest,
86
+ location: options.location,
87
+ locationKey: options.locationKey,
88
+ },
89
+ })));
90
+ ctx.print.message(data, `Booked: ${data.title ?? 'meeting'} at ${data.startTime ?? `${options.date} ${options.time}`} (${data.uuid}).`);
91
+ });
92
+ bookings
93
+ .command('cancel <uuid>')
94
+ .description('Cancel a booking; Zeeg notifies the invitees')
95
+ .option('--reason <text>', 'shown to the invitees')
96
+ .option('-y, --yes', 'cancel without asking')
97
+ .action(async (uuid, options) => {
98
+ const ctx = context();
99
+ await ctx.confirm(`Cancel booking ${uuid}? The invitees are notified.`, options.yes);
100
+ const data = await unwrap((await ctx.api()).PUT('/scheduled-events/{uuid}/cancel', {
101
+ params: { path: { uuid } },
102
+ body: { cancellationReason: options.reason },
103
+ }));
104
+ ctx.print.message(data, `Cancelled ${uuid}.`);
105
+ });
106
+ bookings
107
+ .command('reschedule <uuid>')
108
+ .description('Move a booking to a new time; Zeeg notifies the invitees')
109
+ .requiredOption('--date <date>', 'new day, YYYY-MM-DD', isoDate('--date'))
110
+ .requiredOption('--time <time>', 'new start time, HH:mm', clockTime('--time'))
111
+ .option('--tz <zone>', 'IANA time zone of --date and --time (default: the invitee’s)')
112
+ .option('--duration <minutes>', 'new length', positiveInt('--duration'))
113
+ .option('--reason <text>', 'shown to the invitees')
114
+ .option('-y, --yes', 'reschedule without asking')
115
+ .action(async (uuid, options) => {
116
+ const ctx = context();
117
+ await ctx.confirm(`Move booking ${uuid} to ${options.date} ${options.time}? The invitees are notified.`, options.yes);
118
+ const data = asJson(await unwrap((await ctx.api()).PUT('/scheduled-events/{uuid}/reschedule', {
119
+ params: { path: { uuid } },
120
+ body: { date: options.date, start: options.time, timeZone: options.tz, duration: options.duration, reason: options.reason },
121
+ })));
122
+ ctx.print.message(data, `Rescheduled ${uuid} to ${options.date} ${options.time}.`);
123
+ });
124
+ bookings
125
+ .command('hand-over <uuid>')
126
+ .description('Hand a round-robin booking to another host')
127
+ .option('--to <email>', 'the new host (default: Zeeg picks the next available host)')
128
+ .option('--require-availability', 'only hand over to a host who is free then')
129
+ .option('--fallback-to-reschedule', 'offer the invitee a reschedule when nobody is free')
130
+ .option('-y, --yes', 'hand over without asking')
131
+ .action(async (uuid, options) => {
132
+ const ctx = context();
133
+ await ctx.confirm(`Hand booking ${uuid} over to ${options.to ?? 'another host'}?`, options.yes);
134
+ const data = await unwrap((await ctx.api()).PUT('/scheduled-events/{uuid}/handover', {
135
+ params: { path: { uuid } },
136
+ body: {
137
+ newHostEmail: options.to,
138
+ requireHostAvailability: options.requireAvailability,
139
+ fallbackToReschedule: options.fallbackToReschedule,
140
+ },
141
+ }));
142
+ ctx.print.message(data, `Handed over ${uuid}.`);
143
+ });
144
+ }
@@ -0,0 +1,41 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { unwrap } from '../api/client.js';
3
+ import { UsageError } from '../errors.js';
4
+ import { asJson } from './util.js';
5
+ export function registerCalls(program, context) {
6
+ const calls = program.command('calls').description('Calls with your AI phone agents');
7
+ calls
8
+ .command('start <agentUuid>')
9
+ .description('Have an AI phone agent call someone (uses AI minutes)')
10
+ .requiredOption('--phone <number>', 'number to call, in international format (+49…)')
11
+ .option('--email <email>', 'the person’s email')
12
+ .option('--name <name>', 'the person’s full name')
13
+ .option('--data <text>', 'what the agent should know during the call, as plain text or @file')
14
+ .option('--at <datetime>', 'call later, at this ISO 8601 time')
15
+ .option('-y, --yes', 'start the call without asking')
16
+ .action(async (agentUuid, options) => {
17
+ const additionalData = options.data !== undefined ? await textArgument(options.data) : undefined;
18
+ const ctx = context();
19
+ await ctx.confirm(`Have the agent call ${options.name ? `${options.name} at ` : ''}${options.phone}${options.at ? ` at ${options.at}` : ' now'}? This uses AI minutes.`, options.yes);
20
+ const data = asJson(await unwrap((await ctx.api()).POST('/agents/{agentUuid}/outbound-calls', {
21
+ params: { path: { agentUuid } },
22
+ body: {
23
+ phoneNumber: options.phone,
24
+ email: options.email,
25
+ fullName: options.name,
26
+ additionalData,
27
+ scheduledTime: options.at,
28
+ },
29
+ })));
30
+ ctx.print.message(data, `Call ${options.at ? 'scheduled' : 'started'} (${asJson(data.element).status ?? 'queued'}).`);
31
+ });
32
+ }
33
+ /** The API takes the agent's extra context as one string, so a file is sent as its text. */
34
+ async function textArgument(raw) {
35
+ if (!raw.startsWith('@')) {
36
+ return raw;
37
+ }
38
+ return readFile(raw.slice(1), 'utf8').catch(() => {
39
+ throw new UsageError(`Could not read ${raw.slice(1)}.`);
40
+ });
41
+ }
@@ -0,0 +1,96 @@
1
+ import { unwrap } from '../api/client.js';
2
+ import { UsageError } from '../errors.js';
3
+ import { asJson, jsonArgument, pageFooter, positiveInt } from './util.js';
4
+ /** `people` and `companies` have their own endpoints; anything else is a custom object slug. */
5
+ async function list(api, object, query) {
6
+ if (object === 'people') {
7
+ const data = asJson(await unwrap(api.GET('/crm/people', { params: { query } })));
8
+ return { data, rows: (data.data?.people ?? []) };
9
+ }
10
+ if (object === 'companies') {
11
+ const data = asJson(await unwrap(api.GET('/crm/companies', { params: { query } })));
12
+ return { data, rows: (data.data?.companies ?? []) };
13
+ }
14
+ const data = asJson(await unwrap(api.GET('/crm/{objectSlug}', { params: { path: { objectSlug: object }, query } })));
15
+ return { data, rows: (data.data?.records ?? []) };
16
+ }
17
+ async function get(api, object, id) {
18
+ if (object === 'people') {
19
+ return asJson(await unwrap(api.GET('/crm/people/{id}', { params: { path: { id } } })));
20
+ }
21
+ if (object === 'companies') {
22
+ return asJson(await unwrap(api.GET('/crm/companies/{id}', { params: { path: { id } } })));
23
+ }
24
+ return asJson(await unwrap(api.GET('/crm/{objectSlug}/{recordId}', { params: { path: { objectSlug: object, recordId: id } } })));
25
+ }
26
+ async function upsert(api, object, matchingAttribute, body) {
27
+ if (object === 'people') {
28
+ return asJson(await unwrap(api.PUT('/crm/people', { params: { query: { matchingAttribute } }, body: body })));
29
+ }
30
+ if (object === 'companies') {
31
+ if (matchingAttribute !== 'domain') {
32
+ throw new UsageError('Companies are matched on their domain: pass --match domain.');
33
+ }
34
+ return asJson(await unwrap(api.PUT('/crm/companies', { params: { query: { matchingAttribute } }, body: body })));
35
+ }
36
+ return asJson(await unwrap(api.PUT('/crm/{objectSlug}', {
37
+ params: { path: { objectSlug: object }, query: { matchingAttribute } },
38
+ body: body,
39
+ })));
40
+ }
41
+ /** The record inside a get/upsert answer, whatever key the endpoint wraps it in. */
42
+ function recordOf(data) {
43
+ const inner = asJson(data.data ?? data);
44
+ return asJson(inner.person ?? inner.company ?? inner.record ?? inner);
45
+ }
46
+ function displayName(row) {
47
+ if (row.firstName !== undefined || row.lastName !== undefined) {
48
+ return [row.firstName, row.lastName].filter(Boolean).join(' ');
49
+ }
50
+ return row.name ?? row.title ?? row.displayName;
51
+ }
52
+ export function registerCrm(program, context) {
53
+ const crm = program.command('crm').description('CRM people, companies and custom object records');
54
+ const objectHelp = 'people, companies, or a custom object slug';
55
+ crm
56
+ .command('list <object>')
57
+ .description(`List CRM records (${objectHelp})`)
58
+ .option('--page <n>', 'page number', positiveInt('--page'))
59
+ .option('--per-page <n>', 'results per page (max 100)', positiveInt('--per-page'))
60
+ .option('--sort-by <attribute>', 'attribute to sort on')
61
+ .option('--sort-order <order>', 'asc or desc')
62
+ .option('--search <text>', 'only records matching this text')
63
+ .action(async (object, options) => {
64
+ if (options.sortOrder !== undefined && !['asc', 'desc'].includes(options.sortOrder)) {
65
+ throw new UsageError('--sort-order must be asc or desc.');
66
+ }
67
+ const ctx = context();
68
+ const { data, rows } = await list(await ctx.api(), object, options);
69
+ ctx.print.list(data, rows, [
70
+ { header: 'id', value: (row) => row.id },
71
+ { header: 'name', value: displayName },
72
+ { header: 'email', value: (row) => row.emails ?? row.domain },
73
+ { header: 'updated', value: (row) => row.updatedAt },
74
+ ], pageFooter(data.data?.pagination));
75
+ });
76
+ crm
77
+ .command('get <object> <id>')
78
+ .description(`Show one CRM record (${objectHelp})`)
79
+ .action(async (object, id) => {
80
+ const ctx = context();
81
+ const data = await get(await ctx.api(), object, id);
82
+ ctx.print.record(data, recordOf(data));
83
+ });
84
+ crm
85
+ .command('upsert <object>')
86
+ .description(`Create a CRM record, or update the one that matches (${objectHelp})`)
87
+ .requiredOption('--match <attribute>', 'attribute that identifies an existing record, e.g. emails or domain')
88
+ .requiredOption('--data <json>', 'the record as a JSON object, inline or as @file.json')
89
+ .action(async (object, options) => {
90
+ const body = await jsonArgument(options.data);
91
+ const ctx = context();
92
+ const data = await upsert(await ctx.api(), object, options.match, body);
93
+ const record = recordOf(data);
94
+ ctx.print.message(data, `Saved ${object} record ${record.id ?? ''}.`.replace(' .', '.'));
95
+ });
96
+ }
@@ -0,0 +1,56 @@
1
+ import { unwrap } from '../api/client.js';
2
+ import { addDays, asJson, isoDate, localTimeZone, pageFooter, positiveInt } from './util.js';
3
+ export function registerEventTypes(program, context) {
4
+ const eventTypes = program.command('event-types').description('Scheduling pages (event types)');
5
+ eventTypes
6
+ .command('list')
7
+ .description('List event types you can see')
8
+ .option('--user-slug <slug>', 'only this host’s event types')
9
+ .option('--email <email>', 'only the event types of the host with this email')
10
+ .option('--page <n>', 'page number', positiveInt('--page'))
11
+ .option('--count <n>', 'results per page', positiveInt('--count'))
12
+ .action(async (options) => {
13
+ const ctx = context();
14
+ const data = asJson(await unwrap((await ctx.api()).GET('/event-types', {
15
+ params: { query: { userSlug: options.userSlug, email: options.email, page: options.page, count: options.count } },
16
+ })));
17
+ ctx.print.list(data, (data.collection ?? []), [
18
+ { header: 'uuid', value: (row) => row.uuid },
19
+ { header: 'title', value: (row) => row.title },
20
+ { header: 'minutes', value: (row) => row.duration },
21
+ { header: 'active', value: (row) => (row.isActive ? 'yes' : 'no') },
22
+ { header: 'url', value: (row) => row.schedulingUrl },
23
+ ], pageFooter(data.pagination));
24
+ });
25
+ eventTypes
26
+ .command('get <uuid>')
27
+ .description('Show one event type')
28
+ .action(async (uuid) => {
29
+ const ctx = context();
30
+ const data = asJson(await unwrap((await ctx.api()).GET('/event-types/{uuid}', { params: { path: { uuid } } })));
31
+ ctx.print.record(data, asJson(data.resource));
32
+ });
33
+ program
34
+ .command('availability <eventTypeUuid>')
35
+ .description('Available start times for an event type (needs a paid plan)')
36
+ .option('--from <date>', 'first day, YYYY-MM-DD (default: today)', isoDate('--from'))
37
+ .option('--to <date>', 'last day, YYYY-MM-DD (default: six days after --from)', isoDate('--to'))
38
+ .option('--tz <zone>', 'IANA time zone for the times (default: this machine’s)')
39
+ .option('--duration <minutes>', 'meeting length, when the event type offers several', positiveInt('--duration'))
40
+ .action(async (uuid, options) => {
41
+ const ctx = context();
42
+ const from = options.from ?? new Date().toISOString().slice(0, 10);
43
+ const to = options.to ?? addDays(new Date(`${from}T00:00:00Z`), 6);
44
+ const data = asJson(await unwrap((await ctx.api()).GET('/event-types/{uuid}/availability', {
45
+ params: {
46
+ path: { uuid },
47
+ query: { startDate: from, endDate: to, timeZone: options.tz ?? localTimeZone(), duration: options.duration },
48
+ },
49
+ })));
50
+ const days = (data.availability ?? []).filter((day) => Array.isArray(day.slots) && day.slots.length > 0);
51
+ ctx.print.list(data, days, [
52
+ { header: 'date', value: (day) => day.date },
53
+ { header: `times (${data.timeZone ?? options.tz ?? localTimeZone()})`, full: true, value: (day) => day.slots.map((slot) => (typeof slot === 'string' ? slot : asJson(slot).time)).join(' ') },
54
+ ]);
55
+ });
56
+ }
@@ -0,0 +1,49 @@
1
+ import { unwrap } from '../api/client.js';
2
+ import { asJson, pageFooter, positiveInt } from './util.js';
3
+ export function registerForms(program, context) {
4
+ const forms = program.command('forms').description('Routing forms');
5
+ forms
6
+ .command('list')
7
+ .description('List routing forms')
8
+ .option('--page <n>', 'page number', positiveInt('--page'))
9
+ .option('--count <n>', 'results per page', positiveInt('--count'))
10
+ .action(async (options) => {
11
+ const ctx = context();
12
+ const data = asJson(await unwrap((await ctx.api()).GET('/routing-forms', { params: { query: options } })));
13
+ ctx.print.list(data, (data.data?.routingForms ?? []), [
14
+ { header: 'id', value: (row) => row.id },
15
+ { header: 'name', value: (row) => row.name },
16
+ { header: 'active', value: (row) => (row.isActive ? 'yes' : 'no') },
17
+ { header: 'updated', value: (row) => row.updatedAt },
18
+ ], pageFooter(data.data?.pagination));
19
+ });
20
+ forms
21
+ .command('submissions')
22
+ .description('List routing form submissions, newest first')
23
+ .option('--form <id>', 'only this routing form’s submissions')
24
+ .option('--from <datetime>', 'created at or after this ISO 8601 time')
25
+ .option('--to <datetime>', 'created at or before this ISO 8601 time')
26
+ .option('--page <n>', 'page number', positiveInt('--page'))
27
+ .option('--count <n>', 'results per page', positiveInt('--count'))
28
+ .action(async (options) => {
29
+ const ctx = context();
30
+ const data = asJson(await unwrap((await ctx.api()).GET('/routing-form-submissions', {
31
+ params: {
32
+ query: {
33
+ routingFormId: options.form,
34
+ minCreatedAt: options.from,
35
+ maxCreatedAt: options.to,
36
+ sort: 'desc',
37
+ page: options.page,
38
+ count: options.count,
39
+ },
40
+ },
41
+ })));
42
+ ctx.print.list(data, (data.data?.submissions ?? []), [
43
+ { header: 'id', value: (row) => row.id },
44
+ { header: 'created', value: (row) => row.createdAt },
45
+ { header: 'form', value: (row) => row.routingForm?.name ?? row.routingFormId },
46
+ { header: 'route', value: (row) => row.routeType ?? row.route?.type },
47
+ ], pageFooter(data.data?.pagination));
48
+ });
49
+ }
@@ -0,0 +1,28 @@
1
+ import { unwrap } from '../api/client.js';
2
+ import { asJson } from './util.js';
3
+ export function registerNotes(program, context) {
4
+ const notes = program.command('notes').description('Notes on bookings');
5
+ notes
6
+ .command('list <bookingUuid>')
7
+ .description('List the notes on a booking')
8
+ .action(async (uuid) => {
9
+ const ctx = context();
10
+ const data = asJson(await unwrap((await ctx.api()).GET('/notes/event/{uuid}', { params: { path: { uuid } } })));
11
+ ctx.print.list(data, (data.list ?? []), [
12
+ { header: 'id', value: (row) => row.id },
13
+ { header: 'created', value: (row) => row.createdAt },
14
+ { header: 'by', value: (row) => row.creator?.fullName ?? row.creator?.email },
15
+ { header: 'note', value: (row) => row.note },
16
+ ]);
17
+ });
18
+ notes
19
+ .command('add <bookingUuid> <text...>')
20
+ .description('Add a note to a booking (basic HTML such as <b> is kept)')
21
+ .action(async (uuid, text) => {
22
+ const ctx = context();
23
+ const data = await unwrap((await ctx.api()).POST('/notes', {
24
+ body: { relation: 'event', uuid, note: text.join(' ') },
25
+ }));
26
+ ctx.print.message(data, `Note added to ${uuid}.`);
27
+ });
28
+ }
@@ -0,0 +1,56 @@
1
+ import { unwrap } from '../api/client.js';
2
+ import { UsageError } from '../errors.js';
3
+ import { asJson, collect, isoDate, pageFooter, positiveInt } from './util.js';
4
+ async function ownEmail(api) {
5
+ return (await unwrap(api.GET('/whoami'))).email;
6
+ }
7
+ export function registerSchedules(program, context) {
8
+ const schedules = program.command('schedules').description('Availability schedules');
9
+ schedules
10
+ .command('list')
11
+ .description('List a user’s availability schedules')
12
+ .option('--email <email>', 'whose schedules (default: yours)')
13
+ .option('--page <n>', 'page number', positiveInt('--page'))
14
+ .option('--count <n>', 'results per page', positiveInt('--count'))
15
+ .action(async (options) => {
16
+ const ctx = context();
17
+ const api = await ctx.api();
18
+ const data = asJson(await unwrap(api.GET('/schedules', {
19
+ params: { query: { email: options.email ?? await ownEmail(api), page: options.page, count: options.count } },
20
+ })));
21
+ ctx.print.list(data, (data.collection ?? []), [
22
+ { header: 'uuid', value: (row) => row.uuid },
23
+ { header: 'title', value: (row) => row.title },
24
+ { header: 'time zone', value: (row) => row.timezone },
25
+ { header: 'default', value: (row) => (row.isDefault ? 'yes' : 'no') },
26
+ ], pageFooter(data.pagination));
27
+ });
28
+ const timeOff = program.command('time-off').description('Time off (vacation, out of office)');
29
+ timeOff
30
+ .command('add')
31
+ .description('Block days as time off')
32
+ .requiredOption('--start <date>', 'first day off, YYYY-MM-DD', isoDate('--start'))
33
+ .requiredOption('--end <date>', 'last day off, YYYY-MM-DD', isoDate('--end'))
34
+ .requiredOption('--title <title>', 'shown on the calendar, e.g. "Summer holiday"')
35
+ .option('--type <type>', 'vacation or out_of_office', 'vacation')
36
+ .option('--note <text>', 'a note for your team')
37
+ .option('--email <email>', 'whose time off, repeatable (default: yours)', collect)
38
+ .action(async (options) => {
39
+ if (options.type !== 'vacation' && options.type !== 'out_of_office') {
40
+ throw new UsageError('--type must be vacation or out_of_office.');
41
+ }
42
+ const ctx = context();
43
+ const api = await ctx.api();
44
+ const data = await unwrap(api.POST('/time-off', {
45
+ body: {
46
+ users: { emails: options.email ?? [await ownEmail(api)] },
47
+ type: options.type,
48
+ title: options.title,
49
+ startDate: options.start,
50
+ endDate: options.end,
51
+ note: options.note,
52
+ },
53
+ }));
54
+ ctx.print.message(data, `Time off added from ${options.start} to ${options.end}.`);
55
+ });
56
+ }
@@ -0,0 +1,68 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { UsageError } from '../errors.js';
3
+ export function asJson(value) {
4
+ return value !== null && typeof value === 'object' ? value : {};
5
+ }
6
+ export function positiveInt(name) {
7
+ return (raw) => {
8
+ const value = Number(raw);
9
+ if (!Number.isInteger(value) || value < 1) {
10
+ throw new UsageError(`${name} must be a whole number of 1 or more, not "${raw}".`);
11
+ }
12
+ return value;
13
+ };
14
+ }
15
+ export function isoDate(name) {
16
+ return (raw) => {
17
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(raw))) {
18
+ throw new UsageError(`${name} must be a date as YYYY-MM-DD, not "${raw}".`);
19
+ }
20
+ return raw;
21
+ };
22
+ }
23
+ export function clockTime(name) {
24
+ return (raw) => {
25
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(raw)) {
26
+ throw new UsageError(`${name} must be a 24-hour time as HH:mm, not "${raw}".`);
27
+ }
28
+ return raw;
29
+ };
30
+ }
31
+ export function collect(value, previous = []) {
32
+ return [...previous, value];
33
+ }
34
+ /** `--data '{"a":1}'` or `--data @record.json`. */
35
+ export async function jsonArgument(raw) {
36
+ const text = raw.startsWith('@') ? await readFile(raw.slice(1), 'utf8').catch(() => {
37
+ throw new UsageError(`Could not read ${raw.slice(1)}.`);
38
+ }) : raw;
39
+ try {
40
+ const value = JSON.parse(text);
41
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
42
+ throw new Error('not an object');
43
+ }
44
+ return value;
45
+ }
46
+ catch {
47
+ throw new UsageError('--data must be a JSON object, inline or as @file.json.');
48
+ }
49
+ }
50
+ export function localTimeZone() {
51
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
52
+ }
53
+ export function addDays(date, days) {
54
+ const copy = new Date(date);
55
+ copy.setUTCDate(copy.getUTCDate() + days);
56
+ return copy.toISOString().slice(0, 10);
57
+ }
58
+ /** "Page 2 of 5 (93 in total)" from either pagination shape the v2 API uses. */
59
+ export function pageFooter(pagination) {
60
+ const p = asJson(pagination);
61
+ const current = p.currentPage;
62
+ const last = p.totalPages ?? p.lastPage;
63
+ const total = p.total ?? p.totalItems;
64
+ if (current === undefined || last === undefined) {
65
+ return undefined;
66
+ }
67
+ return `Page ${current} of ${last}${total !== undefined ? ` (${total} in total)` : ''}. Use --page to see more.`;
68
+ }
package/dist/config.js ADDED
@@ -0,0 +1,41 @@
1
+ import { UsageError } from './errors.js';
2
+ export const DEFAULT_BASE_URL = 'https://api.zeeg.me';
3
+ /**
4
+ * The CLI's OAuth client. The backend inserts it with this id in every environment
5
+ * (config('mcp.cli.client_id')), so one build reaches local, staging and production.
6
+ */
7
+ export const DEFAULT_CLIENT_ID = '1000001';
8
+ /** The v2 scopes the commands need. The server narrows them to what the plan allows. */
9
+ export const SCOPES = [
10
+ 'event_types:read',
11
+ 'timetable',
12
+ 'schedules:read',
13
+ 'schedules:write',
14
+ 'events:read',
15
+ 'events:write',
16
+ 'booking',
17
+ 'notes:read',
18
+ 'notes:write',
19
+ 'crm:read',
20
+ 'crm:write',
21
+ 'routing_forms:read',
22
+ 'outbound-calls:write',
23
+ 'agents:read',
24
+ ];
25
+ export function resolveBaseUrl(flag, env = process.env) {
26
+ const raw = flag ?? env.ZEEG_API_URL ?? DEFAULT_BASE_URL;
27
+ let url;
28
+ try {
29
+ url = new URL(raw);
30
+ }
31
+ catch {
32
+ throw new UsageError(`"${raw}" is not a URL. Pass the API origin, e.g. --base-url https://api.zeeg.me`);
33
+ }
34
+ if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
35
+ throw new UsageError('The API URL must use https (plain http is accepted for localhost only).');
36
+ }
37
+ return url.origin + url.pathname.replace(/\/+$/, '');
38
+ }
39
+ export function clientId(env = process.env) {
40
+ return env.ZEEG_CLIENT_ID ?? DEFAULT_CLIENT_ID;
41
+ }
@@ -0,0 +1,66 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { createApi } from './api/client.js';
4
+ import { HttpOAuthClient } from './auth/oauth.js';
5
+ import { Session } from './auth/session.js';
6
+ import { openTokenStore } from './auth/tokenStore.js';
7
+ import { clientId, resolveBaseUrl } from './config.js';
8
+ import { UsageError } from './errors.js';
9
+ import { createPrinter, selectOutputMode } from './output.js';
10
+ import { VERSION } from './version.js';
11
+ const USER_AGENT = `zeeg-cli/${VERSION} (node ${process.versions.node}; ${process.platform})`;
12
+ export function createContext(options) {
13
+ const baseUrl = resolveBaseUrl(options.baseUrl);
14
+ const oauth = new HttpOAuthClient(baseUrl, clientId(), USER_AGENT);
15
+ let store;
16
+ let session;
17
+ const context = {
18
+ baseUrl,
19
+ print: createPrinter(selectOutputMode({ json: options.json, isTTY: process.stdout.isTTY })),
20
+ oauth,
21
+ store: () => (store ??= openTokenStore(baseUrl)),
22
+ session: () => (session ??= context.store().then((tokens) => new Session(tokens, oauth))),
23
+ api: async () => createApi(baseUrl, await context.session(), USER_AGENT),
24
+ say: (message) => process.stderr.write(`${message}\n`),
25
+ confirm: async (question, yes) => {
26
+ if (yes === true) {
27
+ return;
28
+ }
29
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
30
+ throw new UsageError(`${question} Pass --yes to confirm when there is no terminal to ask in.`);
31
+ }
32
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
33
+ const answer = await prompt.question(`${question} [y/N] `);
34
+ prompt.close();
35
+ if (!/^y(es)?$/i.test(answer.trim())) {
36
+ throw new UsageError('Nothing was changed.');
37
+ }
38
+ },
39
+ openBrowser,
40
+ };
41
+ return context;
42
+ }
43
+ function openBrowser(url) {
44
+ const [command, args] = process.platform === 'darwin'
45
+ ? ['open', [url]]
46
+ : process.platform === 'win32'
47
+ ? ['cmd', ['/c', 'start', '""', url.replace(/&/g, '^&')]]
48
+ : ['xdg-open', [url]];
49
+ // A Linux host without a display (SSH, a server) has nothing to open the page in.
50
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
51
+ return Promise.resolve(false);
52
+ }
53
+ return new Promise((resolve) => {
54
+ try {
55
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
56
+ child.once('error', () => resolve(false));
57
+ child.once('spawn', () => {
58
+ child.unref();
59
+ resolve(true);
60
+ });
61
+ }
62
+ catch {
63
+ resolve(false);
64
+ }
65
+ });
66
+ }