attio-cli 0.1.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 (3) hide show
  1. package/README.md +21 -6
  2. package/dist/attio.js +435 -62
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -11,19 +11,34 @@ npm install -g attio-cli
11
11
  ## Quick Start
12
12
 
13
13
  ```bash
14
- export ATTIO_API_KEY=your_key
15
- # or
16
- attio config set api-key your_key
17
-
18
- attio whoami
19
- attio objects list
14
+ attio init # guided setup — paste your API key, done
15
+ attio whoami # verify connection
20
16
  attio people list --limit 5
21
17
  ```
22
18
 
19
+ You'll need an API key from [Attio Developer Settings](https://app.attio.com/settings/developers). `attio init` walks you through the rest.
20
+
21
+ For non-interactive environments (CI, scripts), use any of:
22
+
23
+ ```bash
24
+ attio init --api-key <key> # validates and saves
25
+ export ATTIO_API_KEY=<key> # env var (takes precedence)
26
+ attio config set api-key <key> # direct config write
27
+ ```
28
+
29
+ ## Agent Setup
30
+
31
+ To let AI agents (Claude, etc.) discover this CLI, append the auto-generated snippet to your project's `CLAUDE.md`:
32
+
33
+ ```bash
34
+ attio config claude-md >> CLAUDE.md
35
+ ```
36
+
23
37
  ## Command Reference
24
38
 
25
39
  | Command | Description |
26
40
  |---------|-------------|
41
+ | `attio init` | Interactive setup wizard — connect to your Attio workspace |
27
42
  | `attio whoami` | Show current workspace and user info |
28
43
  | **Objects** | |
29
44
  | `attio objects list` | List all objects in the workspace |
package/dist/attio.js CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  // bin/attio.ts
4
4
  import { program } from "commander";
5
- import chalk6 from "chalk";
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 || "";
@@ -87,11 +98,29 @@ function setApiKey(key) {
87
98
  function getConfigPath() {
88
99
  return CONFIG_FILE;
89
100
  }
101
+ function isConfigured() {
102
+ return resolveApiKey() !== "";
103
+ }
90
104
 
91
105
  // src/client.ts
92
106
  var BASE_URL = "https://api.attio.com/v2";
93
107
  var MAX_RETRIES = 3;
94
108
  var INITIAL_BACKOFF_MS = 1e3;
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
+ }
95
124
  var AttioClient = class {
96
125
  apiKey;
97
126
  debug;
@@ -103,6 +132,9 @@ var AttioClient = class {
103
132
  const url = `${BASE_URL}${path}`;
104
133
  if (this.debug) {
105
134
  console.error(chalk2.dim(`\u2192 ${method} ${url}`));
135
+ if (body !== void 0) {
136
+ console.error(chalk2.dim(` body: ${JSON.stringify(body)}`));
137
+ }
106
138
  }
107
139
  if (!this.apiKey) {
108
140
  throw new AttioAuthError("No API key configured");
@@ -116,13 +148,28 @@ var AttioClient = class {
116
148
  init.body = JSON.stringify(body);
117
149
  }
118
150
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
119
- const response = await fetch(url, init);
151
+ const controller = new AbortController();
152
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
153
+ let response;
154
+ try {
155
+ response = await fetch(url, { ...init, signal: controller.signal });
156
+ } catch (err) {
157
+ clearTimeout(timer);
158
+ if (err?.name === "AbortError") {
159
+ throw new Error(`Request timed out after ${DEFAULT_TIMEOUT_MS / 1e3}s: ${method} ${path}`);
160
+ }
161
+ throw err;
162
+ }
163
+ clearTimeout(timer);
120
164
  if (this.debug) {
121
- console.error(chalk2.dim(`\u2190 ${response.status}`));
165
+ console.error(chalk2.dim(`\u2190 ${response.status} ${response.statusText}`));
122
166
  }
123
167
  if (response.status === 429) {
124
168
  if (attempt < MAX_RETRIES) {
125
- 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
+ }
126
173
  await new Promise((resolve) => setTimeout(resolve, backoff));
127
174
  continue;
128
175
  }
@@ -135,6 +182,9 @@ var AttioClient = class {
135
182
  return void 0;
136
183
  }
137
184
  const json = await response.json();
185
+ if (this.debug && !response.ok) {
186
+ console.error(chalk2.dim(` error: ${JSON.stringify(json)}`));
187
+ }
138
188
  if (!response.ok) {
139
189
  const errorType = json?.type ?? "unknown_error";
140
190
  let errorDetail = json?.message ?? json?.detail ?? response.statusText;
@@ -171,6 +221,43 @@ var AttioClient = class {
171
221
  import chalk3 from "chalk";
172
222
  import Table from "cli-table3";
173
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
+ }
174
261
  function detectFormat(opts) {
175
262
  if (opts.quiet) return "quiet";
176
263
  if (opts.json) return "json";
@@ -183,8 +270,8 @@ function outputList(items, opts) {
183
270
  const idField = opts.idField || "id";
184
271
  if (format === "quiet") {
185
272
  for (const item of items) {
186
- const raw = typeof item === "string" ? item : item[idField] || "";
187
- 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);
188
275
  if (id) console.log(id);
189
276
  }
190
277
  return;
@@ -223,8 +310,8 @@ function outputList(items, opts) {
223
310
  }
224
311
  function outputSingle(item, opts) {
225
312
  if (opts.format === "quiet") {
226
- const raw = item[opts.idField || "id"] || "";
227
- const id = typeof raw === "object" && raw !== null ? Object.values(raw)[0] : raw;
313
+ const raw = item[opts.idField || "id"] ?? "";
314
+ const id = extractOutputId(raw);
228
315
  console.log(id || "");
229
316
  return;
230
317
  }
@@ -240,6 +327,11 @@ function outputSingle(item, opts) {
240
327
  console.log(table.toString());
241
328
  }
242
329
  async function confirm(message) {
330
+ if (!process.stdin.isTTY) {
331
+ console.error(`${message} [y/N]`);
332
+ console.error("Error: Confirmation required but stdin is not a TTY. Use --yes to skip.");
333
+ return false;
334
+ }
243
335
  const rl = createInterface({ input: process.stdin, output: process.stderr });
244
336
  return new Promise((resolve) => {
245
337
  rl.question(`${message} [y/N] `, (answer) => {
@@ -416,6 +508,20 @@ function flattenRecord(record) {
416
508
  }
417
509
  return flat;
418
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
+ }
419
525
  function parseSets(sets) {
420
526
  const values = {};
421
527
  for (const set of sets) {
@@ -423,23 +529,31 @@ function parseSets(sets) {
423
529
  if (eqIdx === -1) throw new Error(`Invalid --set format: "${set}". Expected: key=value`);
424
530
  const key = set.slice(0, eqIdx).trim();
425
531
  const raw = set.slice(eqIdx + 1).trim();
426
- if (raw === "true") {
427
- values[key] = true;
428
- continue;
429
- }
430
- if (raw === "false") {
431
- values[key] = false;
432
- continue;
433
- }
434
- if (/^-?\d+(\.\d+)?$/.test(raw)) {
435
- values[key] = Number(raw);
436
- continue;
532
+ if (raw.startsWith("{") && raw.endsWith("}")) {
533
+ try {
534
+ values[key] = JSON.parse(raw);
535
+ continue;
536
+ } catch {
537
+ }
437
538
  }
438
539
  if (raw.startsWith("[") && raw.endsWith("]")) {
439
- 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));
440
554
  continue;
441
555
  }
442
- values[key] = raw;
556
+ values[key] = parseScalar(raw);
443
557
  }
444
558
  return values;
445
559
  }
@@ -454,8 +568,8 @@ async function readStdin() {
454
568
  async function resolveValues(options) {
455
569
  if (options.values) {
456
570
  if (options.values.startsWith("@")) {
457
- const { readFileSync: readFileSync2 } = await import("fs");
458
- 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"));
459
573
  }
460
574
  return JSON.parse(options.values);
461
575
  }
@@ -466,20 +580,31 @@ async function resolveValues(options) {
466
580
  if (stdin.trim()) return JSON.parse(stdin);
467
581
  return {};
468
582
  }
583
+ function requireValues(values) {
584
+ if (Object.keys(values).length === 0) {
585
+ throw new Error(`No values provided. Use --set key=value, --values '{"key":"value"}', or pipe JSON to stdin.`);
586
+ }
587
+ return values;
588
+ }
469
589
 
470
590
  // src/pagination.ts
591
+ var MAX_ALL_RECORDS = 1e4;
471
592
  async function paginate(fetchPage, options) {
472
593
  if (!options.all) {
473
594
  return fetchPage(options.limit, options.offset);
474
595
  }
475
596
  const allResults = [];
476
- let offset = 0;
597
+ let offset = options.offset;
477
598
  const pageSize = 500;
478
599
  while (true) {
479
600
  const page = await fetchPage(pageSize, offset);
480
601
  allResults.push(...page);
481
602
  if (page.length < pageSize) break;
482
603
  offset += pageSize;
604
+ if (allResults.length >= MAX_ALL_RECORDS) {
605
+ console.error(`Warning: --all stopped after ${MAX_ALL_RECORDS} records. Use --limit and --offset for manual pagination.`);
606
+ break;
607
+ }
483
608
  }
484
609
  return allResults;
485
610
  }
@@ -548,7 +673,7 @@ async function getRecord(object, recordId, cmdOpts) {
548
673
  async function createRecord(object, cmdOpts) {
549
674
  const client = new AttioClient(cmdOpts.apiKey, cmdOpts.debug);
550
675
  const format = detectFormat(cmdOpts);
551
- const values = await resolveValues(cmdOpts);
676
+ const values = requireValues(await resolveValues(cmdOpts));
552
677
  const res = await client.post(
553
678
  `/objects/${encodeURIComponent(object)}/records`,
554
679
  { data: { values } }
@@ -567,7 +692,7 @@ async function createRecord(object, cmdOpts) {
567
692
  async function updateRecord(object, recordId, cmdOpts) {
568
693
  const client = new AttioClient(cmdOpts.apiKey, cmdOpts.debug);
569
694
  const format = detectFormat(cmdOpts);
570
- const values = await resolveValues(cmdOpts);
695
+ const values = requireValues(await resolveValues(cmdOpts));
571
696
  const res = await client.patch(
572
697
  `/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(recordId)}`,
573
698
  { data: { values } }
@@ -605,7 +730,7 @@ async function upsertRecord(object, cmdOpts) {
605
730
  if (!matchAttr) {
606
731
  throw new Error("--match <attribute-slug> is required for upsert");
607
732
  }
608
- const values = await resolveValues(cmdOpts);
733
+ const values = requireValues(await resolveValues(cmdOpts));
609
734
  const res = await client.put(
610
735
  `/objects/${encodeURIComponent(object)}/records?matching_attribute=${encodeURIComponent(matchAttr)}`,
611
736
  { data: { values } }
@@ -652,7 +777,7 @@ function register4(program2) {
652
777
  const records = program2.command("records").description("Manage records in any Attio object");
653
778
  records.command("list").description("List or query records for an object").argument("<object>", "Object slug or ID (e.g. companies, people)").option(
654
779
  "--filter <expr>",
655
- 'Filter expression, e.g. "name~Acme" (repeatable)',
780
+ 'Filter: = != ~ !~ ^ > >= < <= ? (e.g. "name~Acme", "revenue>=1000", "email?"). Repeatable',
656
781
  (val, prev) => [...prev, val],
657
782
  []
658
783
  ).option("--filter-json <json>", "Raw JSON filter (overrides --filter)").option(
@@ -708,7 +833,7 @@ function register4(program2) {
708
833
  // src/commands/people.ts
709
834
  function register5(program2) {
710
835
  const cmd = program2.command("people").description("Manage people records (shortcut for: records <cmd> people)");
711
- cmd.command("list").description("List people").option("--filter <expr>", "Filter expression (repeatable)", (v, p) => [...p, v], []).option("--filter-json <json>", "Raw JSON filter").option("--sort <expr>", "Sort expression (repeatable)", (v, p) => [...p, v], []).option("--limit <n>", "Max results per page", "25").option("--offset <n>", "Starting offset", "0").option("--all", "Fetch all pages").action(async (options, command) => {
836
+ cmd.command("list").description("List people").option("--filter <expr>", 'Filter: = != ~ !~ ^ > >= < <= ? (e.g. "name~Acme"). Repeatable', (v, p) => [...p, v], []).option("--filter-json <json>", "Raw JSON filter").option("--sort <expr>", "Sort expression (repeatable)", (v, p) => [...p, v], []).option("--limit <n>", "Max results per page", "25").option("--offset <n>", "Starting offset", "0").option("--all", "Fetch all pages").action(async (options, command) => {
712
837
  await listRecords("people", command.optsWithGlobals());
713
838
  });
714
839
  cmd.command("get <record-id>").description("Get a person by record ID").action(async (recordId, options, command) => {
@@ -731,7 +856,7 @@ function register5(program2) {
731
856
  // src/commands/companies.ts
732
857
  function register6(program2) {
733
858
  const cmd = program2.command("companies").description("Manage company records (shortcut for: records <cmd> companies)");
734
- cmd.command("list").description("List companies").option("--filter <expr>", "Filter expression (repeatable)", (v, p) => [...p, v], []).option("--filter-json <json>", "Raw JSON filter").option("--sort <expr>", "Sort expression (repeatable)", (v, p) => [...p, v], []).option("--limit <n>", "Max results per page", "25").option("--offset <n>", "Starting offset", "0").option("--all", "Fetch all pages").action(async (options, command) => {
859
+ cmd.command("list").description("List companies").option("--filter <expr>", 'Filter: = != ~ !~ ^ > >= < <= ? (e.g. "name~Acme"). Repeatable', (v, p) => [...p, v], []).option("--filter-json <json>", "Raw JSON filter").option("--sort <expr>", "Sort expression (repeatable)", (v, p) => [...p, v], []).option("--limit <n>", "Max results per page", "25").option("--offset <n>", "Starting offset", "0").option("--all", "Fetch all pages").action(async (options, command) => {
735
860
  await listRecords("companies", command.optsWithGlobals());
736
861
  });
737
862
  cmd.command("get <record-id>").description("Get a company by record ID").action(async (recordId, options, command) => {
@@ -760,6 +885,16 @@ function register7(program2) {
760
885
  const format = detectFormat(opts);
761
886
  const res = await client.get("/lists");
762
887
  const lists = res.data;
888
+ if (format === "quiet") {
889
+ for (const l of lists) {
890
+ console.log(l.id?.list_id ?? "");
891
+ }
892
+ return;
893
+ }
894
+ if (format === "json") {
895
+ outputList(lists, { format });
896
+ return;
897
+ }
763
898
  const flat = lists.map((l) => ({
764
899
  id: l.id?.list_id || "",
765
900
  api_slug: l.api_slug || "",
@@ -809,7 +944,7 @@ function flattenEntry(entry) {
809
944
  }
810
945
  function register8(program2) {
811
946
  const cmd = program2.command("entries").description("Manage list entries");
812
- cmd.command("list <list>").description("List entries in a list").option("--filter <expr>", "Filter expression (repeatable)", (v, p) => [...p, v], []).option("--filter-json <json>", "Raw JSON filter").option("--sort <expr>", "Sort expression (repeatable)", (v, p) => [...p, v], []).option("--limit <n>", "Max results per page", "25").option("--offset <n>", "Starting offset", "0").option("--all", "Fetch all pages").action(async (list, _options, command) => {
947
+ cmd.command("list <list>").description("List entries in a list").option("--filter <expr>", 'Filter: = != ~ !~ ^ > >= < <= ? (e.g. "name~Acme"). Repeatable', (v, p) => [...p, v], []).option("--filter-json <json>", "Raw JSON filter").option("--sort <expr>", "Sort expression (repeatable)", (v, p) => [...p, v], []).option("--limit <n>", "Max results per page", "25").option("--offset <n>", "Starting offset", "0").option("--all", "Fetch all pages").action(async (list, _options, command) => {
813
948
  const opts = command.optsWithGlobals();
814
949
  const client = new AttioClient(opts.apiKey, opts.debug);
815
950
  const format = detectFormat(opts);
@@ -857,7 +992,7 @@ function register8(program2) {
857
992
  const opts = command.optsWithGlobals();
858
993
  const client = new AttioClient(opts.apiKey, opts.debug);
859
994
  const format = detectFormat(opts);
860
- const resolvedValues = await resolveValues({ values: opts.values, set: opts.set });
995
+ const resolvedValues = requireValues(await resolveValues({ values: opts.values, set: opts.set }));
861
996
  const body = {
862
997
  data: {
863
998
  parent_record_id: opts.record,
@@ -878,7 +1013,7 @@ function register8(program2) {
878
1013
  const opts = command.optsWithGlobals();
879
1014
  const client = new AttioClient(opts.apiKey, opts.debug);
880
1015
  const format = detectFormat(opts);
881
- const resolvedValues = await resolveValues({ values: opts.values, set: opts.set });
1016
+ const resolvedValues = requireValues(await resolveValues({ values: opts.values, set: opts.set }));
882
1017
  const body = {
883
1018
  data: {
884
1019
  entry_values: resolvedValues
@@ -925,6 +1060,16 @@ function register9(program2) {
925
1060
  if (opts.sort) params.set("sort", opts.sort);
926
1061
  const res = await client.get(`/tasks?${params.toString()}`);
927
1062
  const tasksList = res.data;
1063
+ if (format === "quiet") {
1064
+ for (const t of tasksList) {
1065
+ console.log(t.id?.task_id ?? "");
1066
+ }
1067
+ return;
1068
+ }
1069
+ if (format === "json") {
1070
+ outputList(tasksList, { format });
1071
+ return;
1072
+ }
928
1073
  const flat = tasksList.map((t) => ({
929
1074
  id: t.id?.task_id || "",
930
1075
  content: truncate(t.content_plaintext || "", 60),
@@ -1023,6 +1168,16 @@ function register10(program2) {
1023
1168
  if (opts.record) params.set("parent_record_id", opts.record);
1024
1169
  const res = await client.get(`/notes?${params.toString()}`);
1025
1170
  const notesList = res.data;
1171
+ if (format === "quiet") {
1172
+ for (const n of notesList) {
1173
+ console.log(n.id?.note_id ?? "");
1174
+ }
1175
+ return;
1176
+ }
1177
+ if (format === "json") {
1178
+ outputList(notesList, { format });
1179
+ return;
1180
+ }
1026
1181
  const flat = notesList.map((n) => ({
1027
1182
  id: n.id?.note_id || "",
1028
1183
  title: n.title || "",
@@ -1086,6 +1241,16 @@ function register11(program2) {
1086
1241
  if (opts.offset) params.set("offset", String(opts.offset));
1087
1242
  const res = await client.get(`/threads?${params.toString()}`);
1088
1243
  const threads = res.data;
1244
+ if (format === "quiet") {
1245
+ for (const thread of threads) {
1246
+ console.log(thread.id?.thread_id || thread.thread_id || "");
1247
+ }
1248
+ return;
1249
+ }
1250
+ if (format === "json") {
1251
+ outputList(threads, { format });
1252
+ return;
1253
+ }
1089
1254
  const flat = [];
1090
1255
  for (const thread of threads) {
1091
1256
  const threadId = thread.id?.thread_id || thread.thread_id || "";
@@ -1227,39 +1392,104 @@ function register13(program2) {
1227
1392
  }
1228
1393
  var CLAUDE_MD_SNIPPET = `## Attio CLI (\`attio\`)
1229
1394
 
1230
- Use the \`attio\` CLI for all Attio CRM operations. Run \`attio <command> --help\` for full usage.
1395
+ Use the \`attio\` CLI for all Attio CRM operations. Always pass \`--yes\` on delete commands to avoid interactive prompts.
1396
+
1397
+ ### Discovery & setup
1398
+
1399
+ \`\`\`
1400
+ attio whoami Show workspace info
1401
+ attio objects list List all objects (people, companies, custom...)
1402
+ attio attributes list <object> List attributes for an object (shows slugs, types)
1403
+ attio lists list List all lists
1404
+ attio members list List workspace members (get member IDs for tasks)
1405
+ \`\`\`
1406
+
1407
+ ### Records (CRUD \u2014 works for any object)
1408
+
1409
+ \`\`\`
1410
+ attio records list <object> [--filter <expr>] [--sort <expr>] [--limit N] [--all]
1411
+ attio records get <object> <record-id>
1412
+ attio records create <object> --set key=value [--set key2=value2]
1413
+ attio records update <object> <record-id> --set key=value
1414
+ attio records delete <object> <record-id> --yes
1415
+ attio records upsert <object> --match <attr-slug> --set key=value
1416
+ attio records search <object> <query>
1417
+ \`\`\`
1418
+
1419
+ ### Shortcuts
1420
+
1421
+ \`\`\`
1422
+ attio people list|get|create|update|delete|search (same as: records ... people)
1423
+ attio companies list|get|create|update|delete|search (same as: records ... companies)
1424
+ \`\`\`
1425
+
1426
+ ### Lists & entries
1427
+
1428
+ \`\`\`
1429
+ attio entries list <list> [--filter <expr>] [--sort <expr>] [--limit N] [--all]
1430
+ attio entries get <list> <entry-id>
1431
+ attio entries create <list> --record <record-id> --object <obj> [--set key=value]
1432
+ attio entries update <list> <entry-id> --set key=value
1433
+ attio entries delete <list> <entry-id> --yes
1434
+ \`\`\`
1435
+
1436
+ ### Tasks
1437
+
1438
+ \`\`\`
1439
+ attio tasks list [--assignee <member-id>] [--is-completed] [--limit N]
1440
+ attio tasks get <task-id>
1441
+ attio tasks create --content "..." [--assignee <member-id>] [--deadline <ISO-date>] [--record <object:record-id>]
1442
+ attio tasks update <task-id> [--complete] [--incomplete] [--deadline <ISO-date>] [--content "..."]
1443
+ attio tasks delete <task-id> --yes
1444
+ \`\`\`
1231
1445
 
1232
- ### Common commands
1446
+ ### Notes & comments
1233
1447
 
1234
1448
  \`\`\`
1235
- attio companies search <query> Search companies by name/domain
1236
- attio people search <query> Search people by name/email
1237
- attio records list <object> --filter <expr> List/filter records (companies, people, or custom objects)
1238
- attio tasks create --content <text> --deadline <ISO-date> --record <object:record-id>
1239
- attio notes create --object <obj> --record <id> --title <t> --content <text>
1240
- attio open <object> [record-id] Open in browser
1449
+ attio notes list [--object <obj> --record <id>]
1450
+ attio notes get <note-id>
1451
+ attio notes create --object <obj> --record <id> --title "..." --content "..."
1452
+ attio notes delete <note-id> --yes
1453
+ attio comments list --object <obj> --record <id>
1454
+ attio comments create --object <obj> --record <id> --content "..."
1455
+ attio comments delete <comment-id> --yes
1241
1456
  \`\`\`
1242
1457
 
1243
- ### Output
1458
+ ### Output modes
1244
1459
 
1245
- Auto-detects TTY: table for terminal, JSON when piped. Force with \`--json\`, \`--csv\`, or \`--table\`. Use \`-q\` for IDs only.
1460
+ Auto-detects: table for TTY, JSON when piped. Force with \`--json\`, \`--csv\`, or \`--table\`.
1461
+ Use \`-q\` for IDs only (one per line) \u2014 ideal for chaining:
1246
1462
 
1247
- ### Filters & sorting
1463
+ \`\`\`bash
1464
+ ID=$(attio records create companies --set name="Acme" -q)
1465
+ attio notes create --object companies --record $ID --title "Note" --content "..."
1466
+ \`\`\`
1467
+
1468
+ ### Filter syntax
1248
1469
 
1249
- \`--filter 'name~Acme'\` \`--filter 'revenue>=1000000'\` \`--sort name:asc\`
1250
- Multiple \`--filter\` flags are ANDed together.`;
1470
+ \`--filter\` supports: \`=\` (equals), \`!=\` (not equals), \`~\` (contains), \`!~\` (not contains), \`^\` (starts with), \`>\`, \`>=\`, \`<\`, \`<=\`, \`?\` (is set/not empty).
1471
+ Multiple \`--filter\` flags are ANDed. Use \`--filter-json '{...}'\` for raw Attio filter JSON.
1472
+
1473
+ \`\`\`
1474
+ --filter 'name~Acme' --filter 'revenue>=1000000' --sort name:asc
1475
+ \`\`\`
1476
+
1477
+ ### Values for create/update
1478
+
1479
+ \`--set key=value\` (repeatable), \`--values '{"key":"value"}'\`, \`--values @file.json\`, or pipe JSON to stdin.`;
1251
1480
 
1252
1481
  // src/commands/open.ts
1253
- import { exec } from "child_process";
1482
+ import { execFile } from "child_process";
1254
1483
  import { platform } from "os";
1255
1484
  import chalk5 from "chalk";
1256
1485
  function register14(program2) {
1257
1486
  program2.command("open <object> [record-id]").description("Open an object or record in the Attio web app").action(async (object, recordId, options, command) => {
1258
1487
  const opts = command.optsWithGlobals();
1488
+ const objectSlug = encodeURIComponent(object);
1259
1489
  let url;
1260
1490
  if (recordId) {
1261
1491
  const client = new AttioClient(opts.apiKey, opts.debug);
1262
- const res = await client.get(`/objects/${object}/records/${recordId}`);
1492
+ const res = await client.get(`/objects/${objectSlug}/records/${encodeURIComponent(recordId)}`);
1263
1493
  const record = res.data;
1264
1494
  url = record.web_url;
1265
1495
  if (!url) {
@@ -1270,10 +1500,10 @@ function register14(program2) {
1270
1500
  const client = new AttioClient(opts.apiKey, opts.debug);
1271
1501
  const self = await client.get("/self");
1272
1502
  const slug = self.workspace_slug || "";
1273
- url = `https://app.attio.com/${slug}/${object}`;
1503
+ url = `https://app.attio.com/${encodeURIComponent(slug)}/${objectSlug}`;
1274
1504
  }
1275
- const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
1276
- exec(`${cmd} "${url}"`, (err) => {
1505
+ const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "explorer" : "xdg-open";
1506
+ execFile(cmd, [url], (err) => {
1277
1507
  if (err) {
1278
1508
  console.log(url);
1279
1509
  }
@@ -1281,31 +1511,158 @@ function register14(program2) {
1281
1511
  });
1282
1512
  }
1283
1513
 
1514
+ // src/commands/init.ts
1515
+ import { createInterface as createInterface2 } from "readline";
1516
+ import chalk6 from "chalk";
1517
+ function register15(program2) {
1518
+ program2.command("init").description("Interactive setup wizard \u2014 connect to your Attio workspace").action(async function() {
1519
+ const opts = this.optsWithGlobals();
1520
+ let apiKey = opts.apiKey;
1521
+ if (!apiKey) {
1522
+ if (!process.stdin.isTTY) {
1523
+ console.log("Non-interactive environment detected. To configure manually:\n");
1524
+ console.log(" export ATTIO_API_KEY=your_key");
1525
+ console.log(" # or");
1526
+ console.log(` attio config set api-key your_key`);
1527
+ console.log(` # or`);
1528
+ console.log(` attio init --api-key your_key
1529
+ `);
1530
+ console.log(`Get your API key at: https://app.attio.com/settings/developers`);
1531
+ return;
1532
+ }
1533
+ if (isConfigured()) {
1534
+ const rl2 = createInterface2({ input: process.stdin, output: process.stderr });
1535
+ const overwrite = await new Promise((resolve) => {
1536
+ rl2.question(chalk6.yellow("An API key is already configured. Overwrite? [y/N] "), (answer) => {
1537
+ rl2.close();
1538
+ resolve(answer.trim().toLowerCase() === "y");
1539
+ });
1540
+ });
1541
+ if (!overwrite) {
1542
+ console.error(chalk6.dim("Setup cancelled."));
1543
+ return;
1544
+ }
1545
+ }
1546
+ console.error("");
1547
+ console.error(chalk6.bold(" Attio CLI Setup"));
1548
+ console.error(chalk6.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1549
+ console.error("");
1550
+ console.error(` You'll need an API key from ${chalk6.cyan("https://app.attio.com/settings/developers")}`);
1551
+ console.error("");
1552
+ const rl = createInterface2({ input: process.stdin, output: process.stderr });
1553
+ apiKey = await new Promise((resolve, reject) => {
1554
+ rl.question(" Paste your API key: ", (answer) => {
1555
+ rl.close();
1556
+ resolve(answer);
1557
+ });
1558
+ rl.on("close", () => reject(new Error("cancelled")));
1559
+ }).catch(() => {
1560
+ console.error("");
1561
+ process.exit(0);
1562
+ });
1563
+ }
1564
+ apiKey = apiKey.trim().replace(/^['"]|['"]$/g, "");
1565
+ if (!apiKey) {
1566
+ console.error(chalk6.red("\n No API key provided."));
1567
+ console.error(` Get one at: ${chalk6.cyan("https://app.attio.com/settings/developers")}`);
1568
+ process.exit(1);
1569
+ }
1570
+ process.stderr.write(chalk6.dim(" Verifying..."));
1571
+ try {
1572
+ const client = new AttioClient(apiKey);
1573
+ const self = await client.get("/self");
1574
+ console.error(chalk6.green(" \u2713"));
1575
+ console.error("");
1576
+ const name = self.workspace_name || "your workspace";
1577
+ const slug = self.workspace_slug;
1578
+ console.error(` Connected to ${chalk6.bold(`"${name}"`)}${slug ? ` (${slug})` : ""}`);
1579
+ setApiKey(apiKey);
1580
+ console.error(` API key saved to ${chalk6.dim(getConfigPath())}`);
1581
+ console.error("");
1582
+ console.error(chalk6.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1583
+ console.error(chalk6.bold(" Agent Setup") + chalk6.dim(" (optional)"));
1584
+ console.error("");
1585
+ console.error(" To let AI agents discover this CLI, run:");
1586
+ console.error("");
1587
+ console.error(` ${chalk6.cyan("attio config claude-md >> CLAUDE.md")}`);
1588
+ console.error("");
1589
+ console.error(` Done! Try ${chalk6.cyan("attio whoami")} or ${chalk6.cyan("attio companies list")} to get started.`);
1590
+ console.error("");
1591
+ } catch (err) {
1592
+ console.error(chalk6.red(" \u2717"));
1593
+ console.error("");
1594
+ if (err?.message?.includes("Invalid or expired") || err?.message?.includes("401") || err?.message?.includes("not recognised")) {
1595
+ console.error(chalk6.red(" Invalid API key."));
1596
+ console.error(` Double-check at: ${chalk6.cyan("https://app.attio.com/settings/developers")}`);
1597
+ } else {
1598
+ console.error(chalk6.red(` Could not connect: ${err?.message || err}`));
1599
+ }
1600
+ process.exit(1);
1601
+ }
1602
+ });
1603
+ }
1604
+
1284
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
+ }
1285
1618
  function handleError(err) {
1619
+ const jsonMode = program.opts().json || !process.stdout.isTTY;
1286
1620
  if (err instanceof AttioApiError) {
1287
- console.error(err.display());
1621
+ if (jsonMode) {
1622
+ console.error(JSON.stringify({ error: true, status: err.statusCode, type: err.type, message: err.detail }));
1623
+ } else {
1624
+ console.error(err.display());
1625
+ }
1288
1626
  process.exit(err.exitCode);
1289
1627
  }
1290
1628
  if (err instanceof AttioAuthError) {
1291
- console.error(err.display());
1629
+ if (jsonMode) {
1630
+ console.error(JSON.stringify({ error: true, status: 401, type: "auth_error", message: err.message }));
1631
+ } else {
1632
+ console.error(err.display());
1633
+ }
1292
1634
  process.exit(err.exitCode);
1293
1635
  }
1294
1636
  if (err instanceof AttioRateLimitError) {
1295
- console.error(err.display());
1637
+ if (jsonMode) {
1638
+ console.error(JSON.stringify({ error: true, status: 429, type: "rate_limit", message: err.message }));
1639
+ } else {
1640
+ console.error(err.display());
1641
+ }
1296
1642
  process.exit(err.exitCode);
1297
1643
  }
1298
1644
  if (err instanceof Error) {
1299
- console.error(chalk6.red(`Error: ${err.message}`));
1300
- if (process.env.ATTIO_DEBUG || program.opts().debug) {
1301
- console.error(err.stack);
1645
+ if (jsonMode) {
1646
+ console.error(JSON.stringify({ error: true, type: "unknown_error", message: err.message }));
1647
+ } else {
1648
+ console.error(chalk7.red(`Error: ${err.message}`));
1649
+ if (process.env.ATTIO_DEBUG || program.opts().debug) {
1650
+ console.error(err.stack);
1651
+ }
1302
1652
  }
1303
1653
  } else {
1304
- console.error(chalk6.red("An unexpected error occurred"));
1654
+ if (jsonMode) {
1655
+ console.error(JSON.stringify({ error: true, type: "unknown_error", message: "An unexpected error occurred" }));
1656
+ } else {
1657
+ console.error(chalk7.red("An unexpected error occurred"));
1658
+ }
1305
1659
  }
1306
1660
  process.exit(1);
1307
1661
  }
1308
- 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");
1663
+ if (process.argv.includes("--no-color")) {
1664
+ process.env.NO_COLOR = "1";
1665
+ }
1309
1666
  register(program);
1310
1667
  register2(program);
1311
1668
  register3(program);
@@ -1320,6 +1677,22 @@ register11(program);
1320
1677
  register12(program);
1321
1678
  register13(program);
1322
1679
  register14(program);
1680
+ register15(program);
1681
+ program.action(() => {
1682
+ if (!isConfigured()) {
1683
+ console.error("");
1684
+ console.error(chalk7.bold(" Welcome to attio-cli!"));
1685
+ console.error("");
1686
+ console.error(` You haven't configured an API key yet. Run:`);
1687
+ console.error("");
1688
+ console.error(` ${chalk7.cyan("attio init")}`);
1689
+ console.error("");
1690
+ console.error(" to connect to your Attio workspace.");
1691
+ console.error("");
1692
+ } else {
1693
+ program.outputHelp();
1694
+ }
1695
+ });
1323
1696
  program.parseAsync(process.argv).catch(handleError);
1324
1697
  process.on("uncaughtException", handleError);
1325
1698
  process.on("unhandledRejection", (reason) => handleError(reason));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "attio-cli",
3
- "version": "0.1.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",