zoodealio 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  `zoodealio` is a command line wrapper over the partner API (`/openapi`). It prints JSON,
4
4
  takes JSON bodies, and describes itself, so people, scripts and AI agents can drive the
5
- same contract as the Zoodealio MCP server (`Mcp/OpenApiMcpTools.cs`) and the
6
- [Zapier app](../zapier/README.md). It adds no API surface: every command is one
5
+ same contract as the Zoodealio MCP server and the Zoodealio Zapier app. It adds no API
6
+ surface: every command is one
7
7
  documented `/openapi` request, and bodies pass through unchanged.
8
8
 
9
9
  It has no runtime dependencies and needs Node 22 or newer.
@@ -31,9 +31,15 @@ zoodealio login --name work-laptop # device name shown at sign-in (default: th
31
31
  zoodealio logout # revokes this machine's key and forgets it
32
32
  ```
33
33
 
34
- `login` binds a loopback port, sends you to Zoodealio AI to approve, and exchanges the one-time
35
- code (with PKCE) for a personal API key named `CLI – <device>`. It waits up to 5 minutes. It prints
36
- `{ok, email, name, keyPrefix, origin}` and never the key.
34
+ `login` opens your browser on the sign-in page, explains the steps and prints the link. In a
35
+ terminal, press **Enter** to open it again, **c** to copy the link, or **q** to cancel; the link also
36
+ works pasted into any browser, on this or another device. Sign in the way you usually do and click **Authorize**; the terminal
37
+ finishes by itself and suggests what to run next. Run by a script or an agent (no terminal), it
38
+ opens the browser the same way and prints the link, without the key prompt. All of this goes to stderr.
39
+
40
+ Under the hood it binds a loopback port, sends you to Zoodealio AI to approve, and exchanges the
41
+ one-time code (with PKCE) for a personal API key named `CLI – <device>`. It waits up to 5 minutes.
42
+ It prints `{ok, email, name, keyPrefix, origin}` on stdout and never the key.
37
43
 
38
44
  The key is stored per API origin in `$XDG_CONFIG_HOME/zoodealio/credentials.json`
39
45
  (default `~/.config/zoodealio/credentials.json`; `%APPDATA%\zoodealio\credentials.json` on Windows),
@@ -225,8 +231,8 @@ zoodealio api GET Properties --query pageSize=5
225
231
  destructive action (accepting an offer, removing a member, deleting or rotating a
226
232
  webhook); do not add it to make an error go away.
227
233
  - For request body shapes run `zoodealio openapi`, which prints the partner OpenAPI document.
228
- - Prefer webhooks, or polling `comping status` and `import-status`, to long waits. See
229
- [OPEN_API_WEBHOOKS.md](../../docs/OPEN_API_WEBHOOKS.md) for events, signatures and delivery.
234
+ - Prefer webhooks, or polling `comping status` and `import-status`, to long waits. The
235
+ [developer docs](https://zoodealio.ai/developers#webhooks) cover events, signatures and delivery.
230
236
 
231
237
  ## Limits
232
238
 
package/bin/zoodealio.js CHANGED
@@ -1,11 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  const { run } = require('../src/cli');
3
3
 
4
+ /**
5
+ * Single keypresses from the terminal while `login` waits; the returned function hands the terminal back.
6
+ * @param {(key: string) => void} handler
7
+ */
8
+ function onKey(handler) {
9
+ const stdin = process.stdin;
10
+ stdin.setRawMode(true);
11
+ stdin.setEncoding('utf8');
12
+ stdin.resume();
13
+ const listener = (/** @type {string} */ data) => {
14
+ for (const key of data) handler(key);
15
+ };
16
+ stdin.on('data', listener);
17
+ return () => {
18
+ stdin.off('data', listener);
19
+ stdin.setRawMode(false);
20
+ stdin.pause();
21
+ };
22
+ }
23
+
4
24
  run(process.argv.slice(2), {
5
25
  env: process.env,
6
26
  stdout: process.stdout,
7
27
  stderr: process.stderr,
8
28
  stdin: process.stdin,
29
+ // Someone is at a terminal: prompt for keys. Agents and scripts never get a prompt.
30
+ interactive: !!process.stdin.isTTY && !!process.stderr.isTTY,
31
+ onKey,
9
32
  }).then((code) => {
10
33
  process.exitCode = code;
11
34
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zoodealio",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Zoodealio command line tool: the partner API as JSON, built for people, scripts and AI agents",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
package/src/args.js CHANGED
@@ -29,7 +29,7 @@ function parse(argv) {
29
29
  }
30
30
  const { values, positionals } = parsed;
31
31
  if (values.help) return { help: helpText(positionals) };
32
- if (positionals.length === 0) throw usage('missing command; run `zoodealio --help`');
32
+ if (positionals.length === 0) return { help: helpText([]) };
33
33
 
34
34
  let command;
35
35
  for (const c of COMMANDS) {
package/src/cli.js CHANGED
@@ -6,11 +6,13 @@ const { USER_AGENT, redactKey, scrubKeys, resolveOrigin, buildUrl, send } = requ
6
6
  const { readCredential, saveCredential, removeCredential } = require('./credentials');
7
7
  const { browserLogin, resolveWebOrigin, defaultDevice } = require('./login');
8
8
  const { bridge } = require('./mcp');
9
+ const { createUi } = require('./ui');
9
10
 
10
11
  /**
11
12
  * @typedef {{env: Record<string, string | undefined>, stdout: {write(s: string): unknown, isTTY?: boolean},
12
13
  * stderr: {write(s: string): unknown}, stdin: AsyncIterable<string | Buffer>, fetch?: typeof fetch,
13
- * openBrowser?: (url: string) => Promise<void>, loginTimeoutMs?: number}} Io
14
+ * openBrowser?: (url: string) => Promise<void>, copy?: (text: string) => Promise<void>, loginTimeoutMs?: number,
15
+ * interactive?: boolean, onKey?: (handler: (key: string) => void) => () => void}} Io
14
16
  */
15
17
 
16
18
  /**
@@ -64,12 +66,34 @@ async function execute(argv, io, envKey, secrets) {
64
66
  if (['login', 'logout', 'mcp'].includes(command.name)) {
65
67
  if (flags['dry-run']) throw usage(`--dry-run is not supported for \`${command.name}\``);
66
68
  if (command.name === 'login') {
69
+ const ui = createUi(io.stderr, { fancy: !!io.interactive && !io.env.NO_COLOR });
67
70
  const result = await browserLogin({
68
71
  apiOrigin: origin, webOrigin: resolveWebOrigin(io.env, flags.env), device: (flags.name?.trim() || defaultDevice()).slice(0, 64),
69
- noBrowser: !!flags['no-browser'], openBrowser: io.openBrowser, timeoutMs: io.loginTimeoutMs, stderr: io.stderr, fetch: io.fetch,
72
+ envName: flags.env || 'prod', noBrowser: !!flags['no-browser'], openBrowser: io.openBrowser, copy: io.copy,
73
+ interactive: io.interactive, onKey: io.onKey, timeoutMs: io.loginTimeoutMs, ui, fetch: io.fetch,
70
74
  });
71
75
  secrets.add(result.key);
72
- saveCredential(io.env, origin, { key: result.key, keyId: result.keyId, email: result.email, name: result.name });
76
+ const file = saveCredential(io.env, origin, { key: result.key, keyId: result.keyId, email: result.email, name: result.name });
77
+ const dev = flags.env && flags.env !== 'prod' ? ` --env ${flags.env}` : '';
78
+ const home = io.env.HOME || io.env.USERPROFILE;
79
+ const where = home && file.startsWith(home) ? `~${file.slice(home.length)}` : file;
80
+ const { style } = ui;
81
+ const next = [
82
+ [`zoodealio whoami${dev}`, 'check who you\'re signed in as'],
83
+ [`zoodealio properties list${dev}`, 'your first call'],
84
+ [`zoodealio mcp --help`, 'connect an AI client'],
85
+ ['zoodealio logout', 'sign this computer out'],
86
+ ];
87
+ const width = Math.max(...next.map(([c]) => c.length)) + 3;
88
+ ui.say();
89
+ ui.say(`${style.green('✓')} Signed in as ${style.bold(result.email)}${dev ? ` ${style.dim(`(${flags.env})`)}` : ''}`);
90
+ ui.say(` ${style.dim(`Key "${result.name}" saved to ${where}`)}`);
91
+ ui.say();
92
+ ui.say(` ${style.bold('Next')}`);
93
+ for (const [cmd, what] of next) ui.say(` ${cmd.padEnd(width)}${style.dim(what)}`);
94
+ ui.say();
95
+ // A person at a terminal has just read all that; the JSON is for scripts and agents.
96
+ if (io.interactive && io.stdout.isTTY) return;
73
97
  return emit({ ok: true, email: result.email, name: result.name, keyPrefix: result.keyPrefix, origin });
74
98
  }
75
99
  if (command.name === 'logout') {
package/src/commands.js CHANGED
@@ -7,7 +7,7 @@ const { parseJsonArg } = require('./input');
7
7
  * @typedef {{name: string, description: string, guid?: boolean}} Arg
8
8
  * @typedef {{name: string, summary: string, method: string | null, path: string | null, args: Arg[],
9
9
  * flags: Flag[], body?: 'required' | 'optional', convenience?: string[], destructive?: boolean,
10
- * auth?: boolean, build?: (flags: Record<string, any>) => unknown}} Command
10
+ * auth?: boolean, build?: (flags: Record<string, any>) => unknown, notes?: string[]}} Command
11
11
  */
12
12
 
13
13
  /**
@@ -137,6 +137,14 @@ const COMMANDS = [
137
137
  }),
138
138
  cmd('login', 'Sign in through the browser and store a CLI key', null, null, {
139
139
  auth: false,
140
+ notes: [
141
+ 'Opens the Zoodealio sign-in page in your browser. Sign in the way you usually do and click',
142
+ 'Authorize; this terminal then finishes by itself. In a terminal, press Enter to open it',
143
+ 'again, c to copy the link, or q to cancel. The link is always printed too, for when no browser',
144
+ 'can be opened (SSH, containers: use --no-browser and open it on another device).',
145
+ 'The key is saved for this computer; zoodealio logout removes it.',
146
+ 'On CI or a server, set ZOODEALIO_API_KEY instead.',
147
+ ],
140
148
  flags: [
141
149
  { name: 'no-browser', type: 'boolean', description: 'Print the sign-in URL to stderr instead of opening a browser' },
142
150
  str('name', 'Device name shown at sign-in (default: this host)'),
@@ -149,7 +157,15 @@ const COMMANDS = [
149
157
  build: (f) => ({ name: f.name }),
150
158
  }),
151
159
  cmd('keys revoke', 'Revoke an API key', 'DELETE', '/openapi/Keys/{id}', { args: [id('id', 'Key id')], destructive: true }),
152
- cmd('mcp', 'Run a stdio MCP server bridged to /openapi/mcp', 'POST', '/openapi/mcp'),
160
+ cmd('mcp', 'Run a stdio MCP server bridged to /openapi/mcp', 'POST', '/openapi/mcp', {
161
+ notes: [
162
+ 'Uses your zoodealio login (or ZOODEALIO_API_KEY). Add it to an AI client:',
163
+ ' Claude Code: claude mcp add --scope user zoodealio -- zoodealio mcp',
164
+ ' Claude Desktop,',
165
+ ' Cursor, VS Code: {"command": "zoodealio", "args": ["mcp"]}',
166
+ 'Add --env dev to the args for the dev environment.',
167
+ ],
168
+ }),
153
169
  cmd('whoami', 'Check the API key and show the target origin', 'GET', '/openapi/Webhooks/event-types'),
154
170
  cmd('openapi', 'Print the partner OpenAPI document (no key needed)', 'GET', '/_openapi/partner.json', { auth: false }),
155
171
  cmd('api', 'Call any /openapi endpoint directly (DELETE needs --yes)', null, null, {
@@ -208,8 +224,21 @@ function helpText(prefix) {
208
224
  if (exact.destructive) lines.push('Destructive: requires --yes.');
209
225
  for (const a of exact.args) lines.push(` <${a.name}> ${a.description}`);
210
226
  for (const f of exact.flags) lines.push(` --${f.name} ${f.description}${f.required ? ' (required)' : ''}`);
227
+ if (exact.notes) lines.push('', ...exact.notes);
211
228
  } else {
212
229
  lines.push('zoodealio <command> [args] [flags]', '', 'JSON on stdout; errors as JSON on stderr. Sign in with `zoodealio login` or set ZOODEALIO_API_KEY.', '');
230
+ if (prefix.length === 0) {
231
+ lines.push(
232
+ 'Get started:',
233
+ ' zoodealio login Sign in through your browser',
234
+ ' zoodealio whoami Check who you\'re signed in as',
235
+ ' zoodealio properties list Your first call',
236
+ ' zoodealio mcp --help Connect an AI client (Claude, Cursor, VS Code)',
237
+ ' zoodealio commands Every command as JSON, for AI agents',
238
+ '',
239
+ 'Commands:',
240
+ );
241
+ }
213
242
  for (const c of matches) lines.push(` ${c.name.padEnd(28)}${c.summary}`);
214
243
  lines.push('', 'Global flags:');
215
244
  for (const f of GLOBAL_FLAGS) lines.push(` --${f.name.padEnd(10)}${f.description}`);
@@ -69,9 +69,11 @@ function writeAll(env, all) {
69
69
  * @param {Record<string, string | undefined>} env
70
70
  * @param {string} origin
71
71
  * @param {Credential} credential
72
+ * @returns {string} the credentials file
72
73
  */
73
74
  function saveCredential(env, origin, credential) {
74
75
  writeAll(env, { ...readAll(env, true), [origin]: credential });
76
+ return path.join(configDir(env), 'credentials.json');
75
77
  }
76
78
 
77
79
  /**
package/src/login.js CHANGED
@@ -37,6 +37,27 @@ function openBrowser(url) {
37
37
  });
38
38
  }
39
39
 
40
+ /**
41
+ * Puts text on the system clipboard with the first tool this machine has. Rejects when none works.
42
+ * @param {string} text
43
+ * @returns {Promise<void>}
44
+ */
45
+ async function copyToClipboard(text) {
46
+ const tools = process.platform === 'darwin' ? [['pbcopy', []]]
47
+ : process.platform === 'win32' ? [['clip', []]]
48
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
49
+ for (const [command, args] of /** @type {[string, string[]][]} */ (tools)) {
50
+ const ok = await new Promise((resolve) => {
51
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] });
52
+ child.once('error', () => resolve(false));
53
+ child.once('close', (code) => resolve(code === 0));
54
+ child.stdin.end(text);
55
+ });
56
+ if (ok) return;
57
+ }
58
+ throw new Error('no clipboard tool');
59
+ }
60
+
40
61
  const PAGE = (/** @type {string} */ message) => `<!doctype html><meta charset="utf-8"><title>Zoodealio CLI</title><body style="font-family:system-ui;text-align:center;margin-top:4rem"><h1>${message}</h1><p>You can close this tab and return to your terminal.</p>`;
41
62
 
42
63
  /**
@@ -89,8 +110,13 @@ async function listenForCallback(state, timeoutMs) {
89
110
 
90
111
  /**
91
112
  * Browser sign-in (authorization code + PKCE). Returns the exchange response; the caller stores it.
92
- * @param {{apiOrigin: string, webOrigin: string, device: string, noBrowser: boolean,
93
- * openBrowser?: (url: string) => Promise<void>, timeoutMs?: number, stderr: {write(s: string): unknown}, fetch?: typeof fetch}} opts
113
+ * Everything it says goes to stderr, so stdout stays JSON. In a terminal someone is typing in
114
+ * (`interactive` with `onKey`), Enter opens the browser, c copies the link and q cancels; otherwise the
115
+ * browser opens straight away. The link is always printed, in case no browser can be opened.
116
+ * @param {{apiOrigin: string, webOrigin: string, device: string, envName: string, noBrowser: boolean,
117
+ * openBrowser?: (url: string) => Promise<void>, copy?: (text: string) => Promise<void>,
118
+ * interactive?: boolean, onKey?: (handler: (key: string) => void) => () => void,
119
+ * timeoutMs?: number, ui: ReturnType<typeof import('./ui').createUi>, fetch?: typeof fetch}} opts
94
120
  * @returns {Promise<{key: string, keyId: string, keyPrefix: string, name: string, email: string}>}
95
121
  */
96
122
  async function browserLogin(opts) {
@@ -100,31 +126,81 @@ async function browserLogin(opts) {
100
126
  const listener = await listenForCallback(state, opts.timeoutMs ?? LOGIN_TIMEOUT_MS);
101
127
  try {
102
128
  const url = `${opts.webOrigin}/cli/login?${new URLSearchParams({ port: String(listener.port), state, challenge, device: opts.device })}`;
103
- let opened = false;
104
- if (!opts.noBrowser) {
129
+ const { ui } = opts;
130
+ const { style } = ui;
131
+ const minutes = Math.max(1, Math.round((opts.timeoutMs ?? LOGIN_TIMEOUT_MS) / 60000));
132
+ const open = async () => {
105
133
  try {
106
134
  await (opts.openBrowser || openBrowser)(url);
107
- opened = true;
135
+ ui.say(` ${style.dim('Opened your browser. Nothing there? Use the link above, or press Enter.')}`);
108
136
  } catch {
109
- // fall through to printing the URL
137
+ ui.say(` ${style.red('Couldn\'t open a browser.')} Copy the link above into one.`);
110
138
  }
139
+ };
140
+ const prompting = !opts.noBrowser && !!opts.interactive && !!opts.onKey;
141
+
142
+ ui.say();
143
+ ui.say(` ${style.bold(`Sign in to Zoodealio${opts.envName === 'prod' ? '' : ` (${opts.envName})`}`)}`);
144
+ ui.say();
145
+ ui.say(opts.noBrowser ? ' 1. Open the link below in a browser, on this or any device.'
146
+ : ' 1. Your browser opens the sign-in page. If it doesn\'t, use the link below.');
147
+ ui.say(` 2. Sign in the way you usually do, then click ${style.bold('Authorize')} for "${opts.device}".`);
148
+ ui.say(' 3. Come back here. This finishes by itself.');
149
+ ui.say();
150
+ ui.say(` ${style.dim('Link:')} ${style.link(url)}`);
151
+ if (prompting) ui.say(` ${style.dim('Press Enter to open it again · c to copy the link · q to cancel')}`);
152
+ ui.say();
153
+
154
+ /** @type {() => void} */
155
+ let stopKeys = () => {};
156
+ /** @type {Promise<{code?: string, error?: string}>} */
157
+ let cancelled = new Promise(() => {});
158
+ if (!opts.noBrowser) await open();
159
+ if (prompting) {
160
+ cancelled = new Promise((resolve) => {
161
+ stopKeys = /** @type {(handler: (key: string) => void) => () => void} */ (opts.onKey)((key) => {
162
+ if (key === '\r' || key === '\n') {
163
+ void open();
164
+ } else if (key === 'c' || key === 'C') {
165
+ (opts.copy || copyToClipboard)(url).then(
166
+ () => ui.say(` ${style.green('✓')} Link copied. Paste it into your browser.`),
167
+ () => ui.say(` ${style.red('Couldn\'t reach the clipboard.')} Select the link above to copy it.`),
168
+ );
169
+ } else if (key === 'q' || key === 'Q' || key === '\u0003') {
170
+ resolve({ error: 'cancelled' });
171
+ }
172
+ });
173
+ });
111
174
  }
112
- if (!opened) opts.stderr.write(`Open this URL in a browser to sign in:\n${url}\n`);
175
+ ui.spin(`Waiting for you to authorize in the browser… ${style.dim(`(the link works for ${minutes} minute${minutes === 1 ? '' : 's'})`)}`);
113
176
 
114
- const { code, error } = await listener.result;
177
+ let result;
178
+ try {
179
+ result = await Promise.race([listener.result, cancelled]);
180
+ } finally {
181
+ stopKeys();
182
+ ui.done();
183
+ }
184
+ const { code, error } = result;
115
185
  if (!code) {
116
186
  throw new CliError({
117
187
  exit: 1, code: error === 'cancelled' ? 'login_cancelled' : 'login_failed',
118
188
  title: error === 'cancelled' ? 'Login cancelled' : 'Login failed',
119
- detail: error === 'cancelled' ? 'Sign-in was cancelled in the browser.' : `The browser reported: ${error}`,
189
+ detail: error === 'cancelled' ? 'Sign-in was cancelled.' : `The browser reported: ${error}`,
120
190
  });
121
191
  }
122
- return /** @type {any} */ (await send(opts.fetch || fetch, {
123
- method: 'POST',
124
- url: `${opts.apiOrigin}/openapi/cli-logins/exchange`,
125
- headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
126
- body: { code, verifier },
127
- }));
192
+ ui.say(`${ui.style.green('✓')} Authorized in the browser`);
193
+ ui.spin('Finishing sign-in…');
194
+ try {
195
+ return /** @type {any} */ (await send(opts.fetch || fetch, {
196
+ method: 'POST',
197
+ url: `${opts.apiOrigin}/openapi/cli-logins/exchange`,
198
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
199
+ body: { code, verifier },
200
+ }));
201
+ } finally {
202
+ ui.done();
203
+ }
128
204
  } finally {
129
205
  listener.close();
130
206
  }
package/src/ui.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What a person sees on stderr while `login` runs. In a terminal it has color and a spinner; for
3
+ * agents, scripts and NO_COLOR it is the same text, plain, one line per message.
4
+ */
5
+
6
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+
8
+ /**
9
+ * @param {{write(s: string): unknown}} stream
10
+ * @param {{fancy: boolean}} options fancy = an interactive terminal that allows color
11
+ */
12
+ function createUi(stream, { fancy }) {
13
+ /** @param {string} code @returns {(s: string) => string} */
14
+ const paint = (code) => (s) => (fancy ? `\u001b[${code}m${s}\u001b[0m` : s);
15
+ const style = { bold: paint('1'), dim: paint('2'), green: paint('32'), cyan: paint('36'), red: paint('31'), link: paint('4;36') };
16
+
17
+ /** @type {{text: string, timer: NodeJS.Timeout, frame: number} | null} */
18
+ let spinner = null;
19
+ const draw = () => {
20
+ if (!spinner) return;
21
+ stream.write(`\r\u001b[2K${style.cyan(FRAMES[spinner.frame % FRAMES.length])} ${spinner.text}`);
22
+ spinner.frame++;
23
+ };
24
+ const clear = () => {
25
+ if (spinner) stream.write('\r\u001b[2K');
26
+ };
27
+
28
+ /** Prints a line, keeping any spinner on the line below it. @param {string} [line] */
29
+ const say = (line = '') => {
30
+ clear();
31
+ stream.write(`${line}\n`);
32
+ draw();
33
+ };
34
+
35
+ return {
36
+ style,
37
+ say,
38
+ /** @param {string} text */
39
+ spin(text) {
40
+ if (!fancy) {
41
+ stream.write(`${text}\n`);
42
+ return;
43
+ }
44
+ spinner = { text, frame: 0, timer: setInterval(draw, 80) };
45
+ draw();
46
+ },
47
+ /** Stops the spinner, replacing it with a final line. @param {string} [line] */
48
+ done(line) {
49
+ if (spinner) {
50
+ clearInterval(spinner.timer);
51
+ clear();
52
+ spinner = null;
53
+ }
54
+ if (line) stream.write(`${line}\n`);
55
+ },
56
+ };
57
+ }
58
+
59
+ module.exports = { createUi };