@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zeeg GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @zeegme/cli
2
+
3
+ `zeeg` works with your Zeeg account from the terminal: event types, available times, bookings, notes, CRM records, routing forms and AI phone calls. It calls the public [Zeeg API v2](https://developer.zeeg.me), so it can do exactly what an API token can do, with your own dashboard permissions.
4
+
5
+ The CLI needs a plan that includes API access. Finding available times and booking meetings need a paid plan; a trial can use everything else. If your API access comes from AI minutes alone, the CLI can only read your AI phone agents and their calls, and start calls.
6
+
7
+ ## Install
8
+
9
+ Node.js 20 or later:
10
+
11
+ ```sh
12
+ npm install -g @zeegme/cli
13
+ zeeg --help
14
+ ```
15
+
16
+ ## Log in
17
+
18
+ ```sh
19
+ zeeg login
20
+ ```
21
+
22
+ The CLI prints a one-time code and opens `https://api.zeeg.me/oauth/device` in your browser. Sign in to Zeeg if you are not signed in already, check that the code on the page matches the one in your terminal, and allow access. On a machine without a browser, run `zeeg login --no-browser` and open the printed link anywhere.
23
+
24
+ Only approve a code you started yourself. Anyone holding a code you approve gets access to your account.
25
+
26
+ - `zeeg whoami` shows the account and the permissions the login carries.
27
+ - `zeeg logout` revokes the login on Zeeg's side and removes it from this machine.
28
+
29
+ The access token lasts an hour and renews itself; a login you do not use for 30 days ends, and `zeeg login` starts a new one.
30
+
31
+ ### Where the login is stored
32
+
33
+ In the system keychain: Keychain on macOS, Credential Manager on Windows, and the Secret Service (GNOME Keyring, KWallet) on Linux. A machine without one, such as a server you reach over SSH, keeps it in `~/.config/zeeg/credentials.json` (or under `$XDG_CONFIG_HOME`), readable only by you; the CLI says so the first time. Set `ZEEG_TOKEN_STORE=file` to use the file everywhere.
34
+
35
+ ### Other environments
36
+
37
+ `--base-url` or `ZEEG_API_URL` points the CLI at another API origin, for example a local backend:
38
+
39
+ ```sh
40
+ ZEEG_API_URL=http://localhost:8000 zeeg login
41
+ ```
42
+
43
+ Plain `http` is accepted for `localhost` only. Each origin keeps its own login.
44
+
45
+ ## Commands
46
+
47
+ | Command | What it does |
48
+ |---|---|
49
+ | `zeeg event-types list` / `get <uuid>` | Your event types (scheduling pages) |
50
+ | `zeeg availability <eventTypeUuid> [--from --to --tz]` | Open start times, by day |
51
+ | `zeeg schedules list [--email]` | Availability schedules |
52
+ | `zeeg time-off add --start --end --title [--type]` | Block days as time off |
53
+ | `zeeg bookings list [--status --from --to --invitee-email ...]` | Bookings you can see |
54
+ | `zeeg bookings get <uuid>` | One booking |
55
+ | `zeeg bookings create <eventTypeUuid> --date --time --name --email` | Book a meeting for an invitee |
56
+ | `zeeg bookings cancel <uuid> [--reason]` | Cancel a booking |
57
+ | `zeeg bookings reschedule <uuid> --date --time` | Move a booking |
58
+ | `zeeg bookings hand-over <uuid> [--to]` | Hand a round-robin booking to another host |
59
+ | `zeeg notes list <bookingUuid>` / `add <bookingUuid> <text>` | Notes on a booking |
60
+ | `zeeg crm list <people\|companies\|slug> [--search <text>]` | CRM records |
61
+ | `zeeg crm get <object> <id>` | One CRM record |
62
+ | `zeeg crm upsert <object> --match <attribute> --data <json>` | Create a record, or update the one that matches |
63
+ | `zeeg forms list` / `submissions [--form]` | Routing forms and their submissions |
64
+ | `zeeg calls start <agentUuid> --phone [--name --email --data --at]` | Have an AI phone agent call someone |
65
+ | `zeeg agents list`, `zeeg agents calls <agentUuid>`, `zeeg agents call <callId>` | AI phone agents, their calls, and one call with its transcript |
66
+
67
+ `zeeg <command> --help` lists every option. Times are in your machine's time zone unless you pass `--tz`.
68
+
69
+ Commands that reach other people or spend AI minutes (`bookings create`, `cancel`, `reschedule`, `hand-over`, `calls start`) ask before they act. Pass `--yes` to skip the question, which scripts must do: without a terminal to ask in, they stop instead.
70
+
71
+ ```sh
72
+ zeeg availability 5c7e… --from 2026-10-05 --to 2026-10-09 --tz Europe/Berlin
73
+ zeeg bookings create 5c7e… --date 2026-10-06 --time 14:30 --tz Europe/Berlin \
74
+ --name "Sophie Laurent" --email sophie@northwind.io
75
+ zeeg crm upsert people --match emails --data '{"firstName":"Sophie","emails":["sophie@northwind.io"]}'
76
+ ```
77
+
78
+ ## JSON output
79
+
80
+ In a terminal the CLI prints tables. When its output goes to a pipe or a file, or with `--json`, it prints the API's JSON unchanged, so you can hand it to `jq` or an agent:
81
+
82
+ ```sh
83
+ zeeg bookings list --status confirmed | jq -r '.collection[].uuid'
84
+ ```
85
+
86
+ Messages meant for you (the login code, questions, notices) go to stderr and never mix with the JSON.
87
+
88
+ ## Exit codes
89
+
90
+ | Code | Meaning |
91
+ |---|---|
92
+ | 0 | Done |
93
+ | 1 | Wrong usage, or you answered no |
94
+ | 2 | Not logged in, or the login ended: run `zeeg login` |
95
+ | 3 | The API refused the request or could not be reached; the message says why |
96
+
97
+ ## Development
98
+
99
+ ```sh
100
+ pnpm install --filter @zeegme/cli...
101
+ pnpm --filter @zeegme/cli build
102
+ pnpm --filter @zeegme/cli test
103
+ pnpm --filter @zeegme/cli generate:api # after apps/backend/tools/api-docs/openapi.yaml changes
104
+ ```
105
+
106
+ `src/api/schema.d.ts` is generated from the API spec and committed; regenerate it rather than editing it.
@@ -0,0 +1,24 @@
1
+ import createClient from 'openapi-fetch';
2
+ import { apiErrorFrom, fetchFrom } from '../errors.js';
3
+ export function createApi(baseUrl, session, userAgent, fetchImpl = fetch) {
4
+ const api = createClient({
5
+ baseUrl: `${baseUrl}/v2`,
6
+ headers: { Accept: 'application/json', 'User-Agent': userAgent },
7
+ fetch: (request) => fetchFrom(fetchImpl, baseUrl, request),
8
+ });
9
+ api.use({
10
+ async onRequest({ request }) {
11
+ request.headers.set('Authorization', `Bearer ${await session.accessToken()}`);
12
+ return request;
13
+ },
14
+ });
15
+ return api;
16
+ }
17
+ /** The body of a successful answer; any other status becomes a CliError with the API's message. */
18
+ export async function unwrap(call) {
19
+ const { data, error, response } = await call;
20
+ if (!response.ok) {
21
+ throw apiErrorFrom(response.status, error);
22
+ }
23
+ return data;
24
+ }
@@ -0,0 +1,44 @@
1
+ import { AuthError } from '../errors.js';
2
+ import { OAuthError } from './oauth.js';
3
+ /** RFC 8628 §3.5: a `slow_down` answer adds five seconds to the polling interval. */
4
+ const SLOW_DOWN_SECONDS = 5;
5
+ function formatCode(userCode) {
6
+ return userCode.length === 8 ? `${userCode.slice(0, 4)}-${userCode.slice(4)}` : userCode;
7
+ }
8
+ export async function deviceLogin(oauth, scopes, io) {
9
+ const code = await oauth.requestDeviceCode(scopes);
10
+ const url = code.verificationUriComplete ?? code.verificationUri;
11
+ io.say(`Your one-time code: ${formatCode(code.userCode)}`);
12
+ const opened = await io.openBrowser(url);
13
+ io.say(opened
14
+ ? `A browser window is open at ${code.verificationUri}. Check the code matches, then allow access.`
15
+ : `Open ${code.verificationUri} in a browser and enter the code above.`);
16
+ io.say('Waiting for you to allow access…');
17
+ const deadline = io.now() + code.expiresIn * 1000;
18
+ let interval = Math.max(code.interval, 1);
19
+ while (io.now() < deadline) {
20
+ await io.sleep(interval * 1000);
21
+ try {
22
+ return await oauth.pollDeviceToken(code.deviceCode);
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof OAuthError)) {
26
+ throw error;
27
+ }
28
+ switch (error.code) {
29
+ case 'authorization_pending':
30
+ continue;
31
+ case 'slow_down':
32
+ interval += SLOW_DOWN_SECONDS;
33
+ continue;
34
+ case 'access_denied':
35
+ throw new AuthError(`Access was not granted. ${error.description ?? ''}`.trim());
36
+ case 'expired_token':
37
+ throw new AuthError('The code expired before access was allowed. Run `zeeg login` again.');
38
+ default:
39
+ throw new AuthError(`Login failed: ${error.message}`);
40
+ }
41
+ }
42
+ }
43
+ throw new AuthError('The code expired before access was allowed. Run `zeeg login` again.');
44
+ }
@@ -0,0 +1,102 @@
1
+ import { ApiError, fetchFrom } from '../errors.js';
2
+ /** An OAuth error answer (`error`, and the server's explanation when it gives one). */
3
+ export class OAuthError extends Error {
4
+ code;
5
+ description;
6
+ constructor(code, description) {
7
+ super(description ?? code);
8
+ this.code = code;
9
+ this.description = description;
10
+ this.name = 'OAuthError';
11
+ }
12
+ }
13
+ /** Reads the `scopes` claim of a Passport JWT; the token response itself does not list them. */
14
+ export function scopesOf(accessToken) {
15
+ try {
16
+ const payload = JSON.parse(Buffer.from(accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'));
17
+ return Array.isArray(payload.scopes) ? payload.scopes.filter((scope) => typeof scope === 'string') : [];
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ export class HttpOAuthClient {
24
+ baseUrl;
25
+ clientId;
26
+ userAgent;
27
+ fetchImpl;
28
+ now;
29
+ constructor(baseUrl, clientId, userAgent, fetchImpl = fetch, now = Date.now) {
30
+ this.baseUrl = baseUrl;
31
+ this.clientId = clientId;
32
+ this.userAgent = userAgent;
33
+ this.fetchImpl = fetchImpl;
34
+ this.now = now;
35
+ }
36
+ async requestDeviceCode(scopes) {
37
+ const body = await this.post('/oauth/device/code', {
38
+ client_id: this.clientId,
39
+ scope: scopes.join(' '),
40
+ });
41
+ return {
42
+ deviceCode: String(body.device_code),
43
+ userCode: String(body.user_code),
44
+ verificationUri: String(body.verification_uri),
45
+ verificationUriComplete: typeof body.verification_uri_complete === 'string' ? body.verification_uri_complete : undefined,
46
+ expiresIn: Number(body.expires_in),
47
+ interval: Number(body.interval ?? 5),
48
+ };
49
+ }
50
+ async pollDeviceToken(deviceCode) {
51
+ return this.tokens(await this.post('/oauth/token', {
52
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
53
+ client_id: this.clientId,
54
+ device_code: deviceCode,
55
+ }));
56
+ }
57
+ async refresh(refreshToken) {
58
+ return this.tokens(await this.post('/oauth/token', {
59
+ grant_type: 'refresh_token',
60
+ client_id: this.clientId,
61
+ refresh_token: refreshToken,
62
+ }));
63
+ }
64
+ async logout(accessToken) {
65
+ const response = await this.send('/oauth/device/logout', {
66
+ method: 'POST',
67
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'User-Agent': this.userAgent },
68
+ });
69
+ if (!response.ok && response.status !== 401) {
70
+ throw new ApiError(`Zeeg could not end the session (HTTP ${response.status}).`);
71
+ }
72
+ }
73
+ tokens(body) {
74
+ return {
75
+ accessToken: body.access_token,
76
+ refreshToken: body.refresh_token,
77
+ expiresAt: this.now() + body.expires_in * 1000,
78
+ scopes: scopesOf(body.access_token),
79
+ };
80
+ }
81
+ async post(path, form) {
82
+ const response = await this.send(path, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/x-www-form-urlencoded',
86
+ Accept: 'application/json',
87
+ 'User-Agent': this.userAgent,
88
+ },
89
+ body: new URLSearchParams(form).toString(),
90
+ });
91
+ const body = (await response.json().catch(() => ({})));
92
+ if (!response.ok) {
93
+ // League puts the useful sentence in `hint` (e.g. why access was denied).
94
+ const description = [body.hint, body.error_description].find((text) => typeof text === 'string' && text !== '');
95
+ throw new OAuthError(typeof body.error === 'string' ? body.error : `http_${response.status}`, description);
96
+ }
97
+ return body;
98
+ }
99
+ send(path, init) {
100
+ return fetchFrom(this.fetchImpl, this.baseUrl, `${this.baseUrl}${path}`, init);
101
+ }
102
+ }
@@ -0,0 +1,51 @@
1
+ import { AuthError } from '../errors.js';
2
+ import { OAuthError } from './oauth.js';
3
+ /** Refresh this long before the access token expires, so a request never races the expiry. */
4
+ export const REFRESH_MARGIN_MS = 60_000;
5
+ const NOT_LOGGED_IN = 'You are not logged in. Run `zeeg login` first.';
6
+ const SESSION_ENDED = 'Your Zeeg session has ended. Run `zeeg login` to sign in again.';
7
+ /** OAuth answers that mean the refresh token is gone for good. */
8
+ const DEAD_GRANT = new Set(['invalid_grant', 'invalid_client', 'access_denied', 'unauthorized_client']);
9
+ export class Session {
10
+ store;
11
+ oauth;
12
+ now;
13
+ constructor(store, oauth, now = Date.now) {
14
+ this.store = store;
15
+ this.oauth = oauth;
16
+ this.now = now;
17
+ }
18
+ /**
19
+ * A valid access token, refreshed when it is about to expire. Refresh tokens rotate: a second
20
+ * `zeeg` process that refreshed first leaves this one holding a spent token, so an
21
+ * `invalid_grant` is checked against the store before the session is declared over.
22
+ */
23
+ async accessToken() {
24
+ const tokens = await this.store.load();
25
+ if (tokens === null) {
26
+ throw new AuthError(NOT_LOGGED_IN);
27
+ }
28
+ if (tokens.expiresAt - this.now() > REFRESH_MARGIN_MS) {
29
+ return tokens.accessToken;
30
+ }
31
+ return (await this.refresh(tokens)).accessToken;
32
+ }
33
+ async refresh(tokens) {
34
+ try {
35
+ const fresh = await this.oauth.refresh(tokens.refreshToken);
36
+ await this.store.save(fresh);
37
+ return fresh;
38
+ }
39
+ catch (error) {
40
+ if (!(error instanceof OAuthError) || !DEAD_GRANT.has(error.code)) {
41
+ throw error;
42
+ }
43
+ const current = await this.store.load();
44
+ if (current !== null && current.refreshToken !== tokens.refreshToken && current.expiresAt - this.now() > REFRESH_MARGIN_MS) {
45
+ return current;
46
+ }
47
+ await this.store.clear();
48
+ throw new AuthError(SESSION_ENDED);
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,125 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ const SERVICE = 'zeeg-cli';
5
+ /**
6
+ * On Linux the library otherwise falls back to the kernel keyring, which is emptied at logout
7
+ * or reboot; pinned to the Secret Service, a headless host throws and gets the file instead.
8
+ */
9
+ const loadKeychainEntry = async (service, account) => {
10
+ const { Entry } = await import('@napi-rs/keyring');
11
+ return new Entry(service, account, { linux: { store: 'secret-service' } });
12
+ };
13
+ function parse(raw) {
14
+ if (!raw) {
15
+ return null;
16
+ }
17
+ try {
18
+ const value = JSON.parse(raw);
19
+ return typeof value.accessToken === 'string' && typeof value.refreshToken === 'string' && typeof value.expiresAt === 'number'
20
+ ? { accessToken: value.accessToken, refreshToken: value.refreshToken, expiresAt: value.expiresAt, scopes: value.scopes ?? [] }
21
+ : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ class KeychainStore {
28
+ entry;
29
+ location = 'the system keychain';
30
+ constructor(entry) {
31
+ this.entry = entry;
32
+ }
33
+ async load() {
34
+ return parse(this.entry.getPassword());
35
+ }
36
+ async save(tokens) {
37
+ this.entry.setPassword(JSON.stringify(tokens));
38
+ }
39
+ async clear() {
40
+ try {
41
+ this.entry.deletePassword();
42
+ }
43
+ catch {
44
+ // Nothing stored under this account.
45
+ }
46
+ }
47
+ }
48
+ /**
49
+ * One JSON file holding every API origin's tokens, readable by the owner only (0600 in a 0700
50
+ * directory). Written through a temporary file and a rename, so a crash never leaves half a file.
51
+ */
52
+ class FileStore {
53
+ location;
54
+ account;
55
+ announce;
56
+ constructor(location, account, announce) {
57
+ this.location = location;
58
+ this.account = account;
59
+ this.announce = announce;
60
+ }
61
+ async readAll() {
62
+ try {
63
+ return JSON.parse(await readFile(this.location, 'utf8'));
64
+ }
65
+ catch {
66
+ return {};
67
+ }
68
+ }
69
+ async writeAll(all) {
70
+ await mkdir(dirname(this.location), { recursive: true, mode: 0o700 });
71
+ const temporary = `${this.location}.${process.pid}.tmp`;
72
+ await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
73
+ await chmod(temporary, 0o600);
74
+ await rename(temporary, this.location);
75
+ }
76
+ async load() {
77
+ const all = await this.readAll();
78
+ return parse(all[this.account] ? JSON.stringify(all[this.account]) : null);
79
+ }
80
+ async save(tokens) {
81
+ const all = await this.readAll();
82
+ if (Object.keys(all).length === 0) {
83
+ this.announce(`No system keychain is available, so your Zeeg login is stored in ${this.location} (readable only by you).`);
84
+ }
85
+ all[this.account] = tokens;
86
+ await this.writeAll(all);
87
+ }
88
+ async clear() {
89
+ const all = await this.readAll();
90
+ delete all[this.account];
91
+ if (Object.keys(all).length === 0) {
92
+ await rm(this.location, { force: true });
93
+ }
94
+ else {
95
+ await this.writeAll(all);
96
+ }
97
+ }
98
+ }
99
+ export function credentialsFile(env = process.env) {
100
+ const base = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME !== '' ? env.XDG_CONFIG_HOME : join(homedir(), '.config');
101
+ return join(base, 'zeeg', 'credentials.json');
102
+ }
103
+ /**
104
+ * Tokens live in the OS keychain (macOS Keychain, Windows Credential Manager, the Secret
105
+ * Service on Linux), one entry per API origin so local, staging and production logins coexist.
106
+ * A host without a usable keychain (a headless Linux box has no Secret Service) falls back to
107
+ * a 0600 file, and says so the first time it writes one. `ZEEG_TOKEN_STORE=file` forces the file.
108
+ */
109
+ export async function openTokenStore(baseUrl, options = {}) {
110
+ const env = options.env ?? process.env;
111
+ const announce = options.announce ?? ((message) => process.stderr.write(`${message}\n`));
112
+ const fileStore = () => new FileStore(credentialsFile(env), baseUrl, announce);
113
+ if (env.ZEEG_TOKEN_STORE === 'file') {
114
+ return fileStore();
115
+ }
116
+ try {
117
+ const entry = await (options.keychain ?? loadKeychainEntry)(SERVICE, baseUrl);
118
+ // The probe fails here, not at save time, when no keychain backend is running.
119
+ entry.getPassword();
120
+ return new KeychainStore(entry);
121
+ }
122
+ catch {
123
+ return fileStore();
124
+ }
125
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { registerAgents } from './commands/agents.js';
4
+ import { registerAuth } from './commands/auth.js';
5
+ import { registerBookings } from './commands/bookings.js';
6
+ import { registerCalls } from './commands/calls.js';
7
+ import { registerCrm } from './commands/crm.js';
8
+ import { registerEventTypes } from './commands/eventTypes.js';
9
+ import { registerForms } from './commands/forms.js';
10
+ import { registerNotes } from './commands/notes.js';
11
+ import { registerSchedules } from './commands/schedules.js';
12
+ import { createContext } from './context.js';
13
+ import { CliError, ExitCode, exitCodeFor, isCommanderError } from './errors.js';
14
+ import { VERSION } from './version.js';
15
+ export function buildProgram() {
16
+ const program = new Command('zeeg')
17
+ .description('Zeeg from your terminal: bookings, availability, notes, CRM, routing forms and AI phone calls.')
18
+ .version(VERSION)
19
+ .option('--base-url <url>', 'API origin (default: $ZEEG_API_URL or https://api.zeeg.me)')
20
+ .option('--json', 'print JSON (the default when output is piped)')
21
+ .showHelpAfterError('(run with --help to see the options)')
22
+ .addHelpText('after', `
23
+ Exit codes: 0 ok, 1 usage, 2 not logged in or session ended, 3 the API refused or failed.
24
+ Start with \`zeeg login\`.`)
25
+ // Every subcommand inherits this, since each is created through `.command()` after it.
26
+ .exitOverride();
27
+ let context;
28
+ const lazyContext = () => (context ??= createContext(program.opts()));
29
+ registerAuth(program, lazyContext);
30
+ registerEventTypes(program, lazyContext);
31
+ registerSchedules(program, lazyContext);
32
+ registerBookings(program, lazyContext);
33
+ registerNotes(program, lazyContext);
34
+ registerCrm(program, lazyContext);
35
+ registerForms(program, lazyContext);
36
+ registerCalls(program, lazyContext);
37
+ registerAgents(program, lazyContext);
38
+ return program;
39
+ }
40
+ export async function run(argv) {
41
+ try {
42
+ await buildProgram().parseAsync(argv);
43
+ return ExitCode.Ok;
44
+ }
45
+ catch (error) {
46
+ const code = exitCodeFor(error);
47
+ // Commander has already printed its own usage errors and help.
48
+ if (error instanceof CliError) {
49
+ process.stderr.write(`Error: ${error.message}\n`);
50
+ }
51
+ else if (!isCommanderError(error)) {
52
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
53
+ }
54
+ return code;
55
+ }
56
+ }
57
+ process.exitCode = await run(process.argv);
@@ -0,0 +1,53 @@
1
+ import { unwrap } from '../api/client.js';
2
+ import { asJson, pageFooter, positiveInt } from './util.js';
3
+ export function registerAgents(program, context) {
4
+ const agents = program.command('agents').description('AI phone agents and their calls');
5
+ agents
6
+ .command('list')
7
+ .description('List your AI phone agents')
8
+ .option('--page <n>', 'page number', positiveInt('--page'))
9
+ .option('--per-page <n>', 'results per page', positiveInt('--per-page'))
10
+ .action(async (options) => {
11
+ const ctx = context();
12
+ const data = asJson(await unwrap((await ctx.api()).GET('/agents', { params: { query: options } })));
13
+ ctx.print.list(data, (data.data?.agents ?? []), [
14
+ { header: 'id', value: (row) => row.id },
15
+ { header: 'title', value: (row) => row.title },
16
+ { header: 'active', value: (row) => (row.active ? 'yes' : 'no') },
17
+ { header: 'phone', value: (row) => row.phoneNumber },
18
+ { header: 'calls', value: (row) => row.totalCalls },
19
+ ], pageFooter(data.data?.pagination));
20
+ });
21
+ agents
22
+ .command('calls <agentUuid>')
23
+ .description('List an agent’s calls, newest first')
24
+ .option('--from <date>', 'calls on or after this day (YYYY-MM-DD, UTC)')
25
+ .option('--to <date>', 'calls on or before this day (YYYY-MM-DD, UTC)')
26
+ .option('--direction <direction>', 'inbound or outbound')
27
+ .option('--page <n>', 'page number', positiveInt('--page'))
28
+ .option('--per-page <n>', 'results per page', positiveInt('--per-page'))
29
+ .action(async (agentUuid, options) => {
30
+ const ctx = context();
31
+ const data = asJson(await unwrap((await ctx.api()).GET('/agents/{agentUuid}/calls', {
32
+ params: { path: { agentUuid }, query: options },
33
+ })));
34
+ ctx.print.list(data, (data.data?.calls ?? []), [
35
+ { header: 'id', value: (row) => row.id },
36
+ { header: 'started', value: (row) => row.startedAt },
37
+ { header: 'direction', value: (row) => row.direction },
38
+ { header: 'status', value: (row) => row.status },
39
+ { header: 'seconds', value: (row) => row.durationSeconds },
40
+ { header: 'summary', value: (row) => row.callSummaryTitle },
41
+ ], pageFooter(data.data?.pagination));
42
+ });
43
+ agents
44
+ .command('call <callId>')
45
+ .description('Show one call with its transcript and collected data')
46
+ .action(async (callId) => {
47
+ const ctx = context();
48
+ const data = asJson(await unwrap((await ctx.api()).GET('/agent-calls/{callId}', {
49
+ params: { path: { callId } },
50
+ })));
51
+ ctx.print.record(data, asJson(data.call));
52
+ });
53
+ }
@@ -0,0 +1,60 @@
1
+ import { unwrap } from '../api/client.js';
2
+ import { deviceLogin } from '../auth/deviceLogin.js';
3
+ import { SCOPES } from '../config.js';
4
+ import { AuthError } from '../errors.js';
5
+ export function registerAuth(program, context) {
6
+ program
7
+ .command('login')
8
+ .description('Sign in through your browser with a one-time code')
9
+ .option('--no-browser', 'print the sign-in link instead of opening a browser')
10
+ .action(async (options) => {
11
+ const ctx = context();
12
+ const tokens = await deviceLogin(ctx.oauth, SCOPES, {
13
+ say: ctx.say,
14
+ openBrowser: (url) => (options.browser ? ctx.openBrowser(url) : Promise.resolve(false)),
15
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
16
+ now: Date.now,
17
+ });
18
+ const store = await ctx.store();
19
+ await store.save(tokens);
20
+ const me = await unwrap((await ctx.api()).GET('/whoami'));
21
+ const missing = SCOPES.filter((scope) => !tokens.scopes.includes(scope));
22
+ ctx.print.message({ email: me.email, baseUrl: ctx.baseUrl, scopes: tokens.scopes, missingScopes: missing, tokenStore: store.location }, [
23
+ `Logged in to ${ctx.baseUrl} as ${me.email}.`,
24
+ missing.length > 0 ? `Your plan does not cover: ${missing.join(', ')}. Commands that need them will be refused.` : '',
25
+ ].filter(Boolean).join('\n'));
26
+ });
27
+ program
28
+ .command('logout')
29
+ .description('End the session on this machine and revoke its token')
30
+ .action(async () => {
31
+ const ctx = context();
32
+ const store = await ctx.store();
33
+ if ((await store.load()) === null) {
34
+ ctx.print.message({ loggedOut: false }, 'You are not logged in.');
35
+ return;
36
+ }
37
+ let revoked = true;
38
+ try {
39
+ await ctx.oauth.logout(await (await ctx.session()).accessToken());
40
+ }
41
+ catch (error) {
42
+ // A session that already ended has nothing left to revoke.
43
+ revoked = error instanceof AuthError;
44
+ if (!revoked) {
45
+ ctx.say(`Zeeg could not be told about the logout (${error.message}). The token is removed here and expires within an hour.`);
46
+ }
47
+ }
48
+ await store.clear();
49
+ ctx.print.message({ loggedOut: true, revoked }, 'Logged out.');
50
+ });
51
+ program
52
+ .command('whoami')
53
+ .description('Show which Zeeg account the CLI is signed in to')
54
+ .action(async () => {
55
+ const ctx = context();
56
+ const me = await unwrap((await ctx.api()).GET('/whoami'));
57
+ const tokens = await (await ctx.store()).load();
58
+ ctx.print.record({ email: me.email, baseUrl: ctx.baseUrl, scopes: tokens?.scopes ?? [] }, { email: me.email, api: ctx.baseUrl, scopes: tokens?.scopes ?? [] });
59
+ });
60
+ }