attio-cli 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.
Files changed (2) hide show
  1. package/dist/attio.js +128 -28
  2. package/package.json +3 -2
package/dist/attio.js CHANGED
@@ -3,6 +3,9 @@
3
3
  // bin/attio.ts
4
4
  import { program } from "commander";
5
5
  import chalk7 from "chalk";
6
+ import { readFileSync as readFileSync2 } from "fs";
7
+ import { dirname, join as join2 } from "path";
8
+ import { fileURLToPath } from "url";
6
9
 
7
10
  // src/errors.ts
8
11
  import chalk from "chalk";
@@ -57,7 +60,7 @@ var AttioRateLimitError = class extends Error {
57
60
  import chalk2 from "chalk";
58
61
 
59
62
  // src/config.ts
60
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
63
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
61
64
  import { join } from "path";
62
65
  import { homedir } from "os";
63
66
  import * as dotenv from "dotenv";
@@ -73,8 +76,16 @@ function loadConfig() {
73
76
  }
74
77
  }
75
78
  function saveConfig(config2) {
76
- mkdirSync(CONFIG_DIR, { recursive: true });
77
- writeFileSync(CONFIG_FILE, JSON.stringify(config2, null, 2));
79
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
80
+ try {
81
+ chmodSync(CONFIG_DIR, 448);
82
+ } catch {
83
+ }
84
+ writeFileSync(CONFIG_FILE, JSON.stringify(config2, null, 2), { mode: 384 });
85
+ try {
86
+ chmodSync(CONFIG_FILE, 384);
87
+ } catch {
88
+ }
78
89
  }
79
90
  function resolveApiKey(flagValue) {
80
91
  return flagValue || process.env.ATTIO_API_KEY || loadConfig().apiKey || "";
@@ -96,6 +107,20 @@ var BASE_URL = "https://api.attio.com/v2";
96
107
  var MAX_RETRIES = 3;
97
108
  var INITIAL_BACKOFF_MS = 1e3;
98
109
  var DEFAULT_TIMEOUT_MS = 3e4;
110
+ function getRetryDelayMs(response, attempt) {
111
+ const retryAfter = response.headers.get("retry-after");
112
+ if (retryAfter) {
113
+ const asSeconds = Number(retryAfter);
114
+ if (!Number.isNaN(asSeconds)) {
115
+ return Math.max(0, Math.ceil(asSeconds * 1e3));
116
+ }
117
+ const asDate = Date.parse(retryAfter);
118
+ if (!Number.isNaN(asDate)) {
119
+ return Math.max(0, asDate - Date.now());
120
+ }
121
+ }
122
+ return INITIAL_BACKOFF_MS * Math.pow(2, attempt);
123
+ }
99
124
  var AttioClient = class {
100
125
  apiKey;
101
126
  debug;
@@ -141,7 +166,10 @@ var AttioClient = class {
141
166
  }
142
167
  if (response.status === 429) {
143
168
  if (attempt < MAX_RETRIES) {
144
- const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
169
+ const backoff = Math.max(100, getRetryDelayMs(response, attempt));
170
+ if (this.debug) {
171
+ console.error(chalk2.dim(` retrying in ${backoff}ms`));
172
+ }
145
173
  await new Promise((resolve) => setTimeout(resolve, backoff));
146
174
  continue;
147
175
  }
@@ -193,6 +221,43 @@ var AttioClient = class {
193
221
  import chalk3 from "chalk";
194
222
  import Table from "cli-table3";
195
223
  import { createInterface } from "readline";
224
+ var ID_KEY_PRIORITY = [
225
+ "record_id",
226
+ "entry_id",
227
+ "task_id",
228
+ "note_id",
229
+ "comment_id",
230
+ "thread_id",
231
+ "workspace_member_id",
232
+ "list_id",
233
+ "object_id",
234
+ "webhook_id",
235
+ "workspace_id"
236
+ ];
237
+ function toIdString(value) {
238
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
239
+ const id = String(value);
240
+ return id.length > 0 ? id : "";
241
+ }
242
+ return "";
243
+ }
244
+ function isRecord(value) {
245
+ return typeof value === "object" && value !== null && !Array.isArray(value);
246
+ }
247
+ function extractOutputId(raw) {
248
+ const direct = toIdString(raw);
249
+ if (direct) return direct;
250
+ if (!isRecord(raw)) return "";
251
+ for (const key of ID_KEY_PRIORITY) {
252
+ const id = toIdString(raw[key]);
253
+ if (id) return id;
254
+ }
255
+ for (const value of Object.values(raw)) {
256
+ const fallback = toIdString(value);
257
+ if (fallback) return fallback;
258
+ }
259
+ return "";
260
+ }
196
261
  function detectFormat(opts) {
197
262
  if (opts.quiet) return "quiet";
198
263
  if (opts.json) return "json";
@@ -205,8 +270,8 @@ function outputList(items, opts) {
205
270
  const idField = opts.idField || "id";
206
271
  if (format === "quiet") {
207
272
  for (const item of items) {
208
- const raw = typeof item === "string" ? item : item[idField] || "";
209
- const id = typeof raw === "object" && raw !== null ? Object.values(raw)[0] : raw;
273
+ const raw = typeof item === "string" ? item : item[idField] ?? "";
274
+ const id = extractOutputId(raw);
210
275
  if (id) console.log(id);
211
276
  }
212
277
  return;
@@ -245,8 +310,8 @@ function outputList(items, opts) {
245
310
  }
246
311
  function outputSingle(item, opts) {
247
312
  if (opts.format === "quiet") {
248
- const raw = item[opts.idField || "id"] || "";
249
- const id = typeof raw === "object" && raw !== null ? Object.values(raw)[0] : raw;
313
+ const raw = item[opts.idField || "id"] ?? "";
314
+ const id = extractOutputId(raw);
250
315
  console.log(id || "");
251
316
  return;
252
317
  }
@@ -443,6 +508,20 @@ function flattenRecord(record) {
443
508
  }
444
509
  return flat;
445
510
  }
511
+ function stripWrappingQuotes(raw) {
512
+ if (raw.startsWith('"') && raw.endsWith('"') || raw.startsWith("'") && raw.endsWith("'")) {
513
+ return raw.slice(1, -1);
514
+ }
515
+ return raw;
516
+ }
517
+ function parseScalar(raw) {
518
+ const normalized = stripWrappingQuotes(raw.trim());
519
+ if (normalized === "true") return true;
520
+ if (normalized === "false") return false;
521
+ if (normalized === "null") return null;
522
+ if (/^-?\d+(\.\d+)?$/.test(normalized)) return Number(normalized);
523
+ return normalized;
524
+ }
446
525
  function parseSets(sets) {
447
526
  const values = {};
448
527
  for (const set of sets) {
@@ -450,23 +529,31 @@ function parseSets(sets) {
450
529
  if (eqIdx === -1) throw new Error(`Invalid --set format: "${set}". Expected: key=value`);
451
530
  const key = set.slice(0, eqIdx).trim();
452
531
  const raw = set.slice(eqIdx + 1).trim();
453
- if (raw === "true") {
454
- values[key] = true;
455
- continue;
456
- }
457
- if (raw === "false") {
458
- values[key] = false;
459
- continue;
460
- }
461
- if (/^-?\d+(\.\d+)?$/.test(raw)) {
462
- values[key] = Number(raw);
463
- continue;
532
+ if (raw.startsWith("{") && raw.endsWith("}")) {
533
+ try {
534
+ values[key] = JSON.parse(raw);
535
+ continue;
536
+ } catch {
537
+ }
464
538
  }
465
539
  if (raw.startsWith("[") && raw.endsWith("]")) {
466
- values[key] = raw.slice(1, -1).split(",").map((s) => s.trim());
540
+ try {
541
+ const parsed = JSON.parse(raw);
542
+ if (Array.isArray(parsed)) {
543
+ values[key] = parsed;
544
+ continue;
545
+ }
546
+ } catch {
547
+ }
548
+ const inner = raw.slice(1, -1).trim();
549
+ if (inner === "") {
550
+ values[key] = [];
551
+ continue;
552
+ }
553
+ values[key] = inner.split(",").map((s) => parseScalar(s));
467
554
  continue;
468
555
  }
469
- values[key] = raw;
556
+ values[key] = parseScalar(raw);
470
557
  }
471
558
  return values;
472
559
  }
@@ -481,8 +568,8 @@ async function readStdin() {
481
568
  async function resolveValues(options) {
482
569
  if (options.values) {
483
570
  if (options.values.startsWith("@")) {
484
- const { readFileSync: readFileSync2 } = await import("fs");
485
- return JSON.parse(readFileSync2(options.values.slice(1), "utf-8"));
571
+ const { readFileSync: readFileSync3 } = await import("fs");
572
+ return JSON.parse(readFileSync3(options.values.slice(1), "utf-8"));
486
573
  }
487
574
  return JSON.parse(options.values);
488
575
  }
@@ -507,7 +594,7 @@ async function paginate(fetchPage, options) {
507
594
  return fetchPage(options.limit, options.offset);
508
595
  }
509
596
  const allResults = [];
510
- let offset = 0;
597
+ let offset = options.offset;
511
598
  const pageSize = 500;
512
599
  while (true) {
513
600
  const page = await fetchPage(pageSize, offset);
@@ -1398,10 +1485,11 @@ import chalk5 from "chalk";
1398
1485
  function register14(program2) {
1399
1486
  program2.command("open <object> [record-id]").description("Open an object or record in the Attio web app").action(async (object, recordId, options, command) => {
1400
1487
  const opts = command.optsWithGlobals();
1488
+ const objectSlug = encodeURIComponent(object);
1401
1489
  let url;
1402
1490
  if (recordId) {
1403
1491
  const client = new AttioClient(opts.apiKey, opts.debug);
1404
- const res = await client.get(`/objects/${object}/records/${recordId}`);
1492
+ const res = await client.get(`/objects/${objectSlug}/records/${encodeURIComponent(recordId)}`);
1405
1493
  const record = res.data;
1406
1494
  url = record.web_url;
1407
1495
  if (!url) {
@@ -1412,9 +1500,9 @@ function register14(program2) {
1412
1500
  const client = new AttioClient(opts.apiKey, opts.debug);
1413
1501
  const self = await client.get("/self");
1414
1502
  const slug = self.workspace_slug || "";
1415
- url = `https://app.attio.com/${slug}/${object}`;
1503
+ url = `https://app.attio.com/${encodeURIComponent(slug)}/${objectSlug}`;
1416
1504
  }
1417
- const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
1505
+ const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "explorer" : "xdg-open";
1418
1506
  execFile(cmd, [url], (err) => {
1419
1507
  if (err) {
1420
1508
  console.log(url);
@@ -1515,6 +1603,18 @@ function register15(program2) {
1515
1603
  }
1516
1604
 
1517
1605
  // bin/attio.ts
1606
+ function loadCliVersion() {
1607
+ try {
1608
+ const here = dirname(fileURLToPath(import.meta.url));
1609
+ const packagePath = join2(here, "..", "package.json");
1610
+ const packageJson = JSON.parse(readFileSync2(packagePath, "utf-8"));
1611
+ if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
1612
+ return packageJson.version;
1613
+ }
1614
+ } catch {
1615
+ }
1616
+ return "0.2.0";
1617
+ }
1518
1618
  function handleError(err) {
1519
1619
  const jsonMode = program.opts().json || !process.stdout.isTTY;
1520
1620
  if (err instanceof AttioApiError) {
@@ -1559,7 +1659,7 @@ function handleError(err) {
1559
1659
  }
1560
1660
  process.exit(1);
1561
1661
  }
1562
- program.name("attio").version("0.1.0").description("CLI for the Attio CRM API. Built for scripts, agents, and humans who prefer terminals.").option("--api-key <key>", "Override API key").option("--json", "Force JSON output").option("--table", "Force table output").option("--csv", "Force CSV output").option("-q, --quiet", "Only output IDs").option("--no-color", "Disable colors").option("--debug", "Print request/response details to stderr");
1662
+ program.name("attio").version(loadCliVersion()).description("CLI for the Attio CRM API. Built for scripts, agents, and humans who prefer terminals.").option("--api-key <key>", "Override API key").option("--json", "Force JSON output").option("--table", "Force table output").option("--csv", "Force CSV output").option("-q, --quiet", "Only output IDs").option("--no-color", "Disable colors").option("--debug", "Print request/response details to stderr");
1563
1663
  if (process.argv.includes("--no-color")) {
1564
1664
  process.env.NO_COLOR = "1";
1565
1665
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "attio-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for the Attio CRM API. Built for scripts, agents, and humans who prefer terminals.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,8 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "build": "tsup",
15
- "dev": "tsx bin/attio.ts"
15
+ "dev": "tsx bin/attio.ts",
16
+ "test": "node --import tsx --test tests/**/*.test.ts"
16
17
  },
17
18
  "dependencies": {
18
19
  "commander": "^12.0.0",