autotouch-cli 0.2.58__tar.gz → 0.2.60__tar.gz

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 (51) hide show
  1. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/PKG-INFO +1 -1
  2. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/cli.py +10 -0
  3. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/sequences.py +123 -17
  4. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/tasks.py +98 -0
  5. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/http.py +8 -2
  6. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/validation.py +62 -6
  7. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/data/CLI_REFERENCE.md +193 -11
  8. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/data/cli-manifest.json +1124 -15
  9. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/parser.py +5 -0
  10. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/parser_groups.py +56 -0
  11. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/sequence_support.py +36 -2
  12. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/templates.py +45 -6
  13. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli.egg-info/PKG-INFO +1 -1
  14. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_shared/provider_registry.py +56 -12
  15. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/pyproject.toml +1 -1
  16. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/MANIFEST.in +0 -0
  17. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/README.md +0 -0
  18. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/__init__.py +0 -0
  19. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/cli_contracts.py +0 -0
  20. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/__init__.py +0 -0
  21. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/auth.py +0 -0
  22. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/cells.py +0 -0
  23. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/columns.py +0 -0
  24. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/jobs.py +0 -0
  25. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/leads.py +0 -0
  26. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/linkedin.py +0 -0
  27. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/prompts.py +0 -0
  28. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/rows.py +0 -0
  29. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/search.py +0 -0
  30. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/tables.py +0 -0
  31. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/webhooks.py +0 -0
  32. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/commands/workspace_secrets.py +0 -0
  33. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/__init__.py +0 -0
  34. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/auth.py +0 -0
  35. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/config.py +0 -0
  36. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/csv_import.py +0 -0
  37. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/io.py +0 -0
  38. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/output.py +0 -0
  39. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/polling.py +0 -0
  40. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/core/run.py +0 -0
  41. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/exceptions.py +0 -0
  42. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli/mongo_status.py +0 -0
  43. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli.egg-info/SOURCES.txt +0 -0
  44. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli.egg-info/dependency_links.txt +0 -0
  45. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli.egg-info/entry_points.txt +0 -0
  46. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli.egg-info/requires.txt +0 -0
  47. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_cli.egg-info/top_level.txt +0 -0
  48. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_shared/__init__.py +0 -0
  49. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_shared/linkedin_contract.py +0 -0
  50. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/autotouch_shared/search_contract.py +0 -0
  51. {autotouch_cli-0.2.58 → autotouch_cli-0.2.60}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: autotouch-cli
3
- Version: 0.2.58
3
+ Version: 0.2.60
4
4
  Summary: Autotouch Smart Table CLI
5
5
  Project-URL: Homepage, https://app.autotouch.ai
6
6
  Project-URL: Documentation, https://github.com/nicolonic/autotouch_main/tree/main/docs/research-table/reference
@@ -158,9 +158,14 @@ from autotouch_cli.commands.tasks import (
158
158
  cmd_tasks_delete as cmd_tasks_delete_impl,
159
159
  cmd_tasks_draft as cmd_tasks_draft_impl,
160
160
  cmd_tasks_get as cmd_tasks_get_impl,
161
+ cmd_tasks_prioritize_email as cmd_tasks_prioritize_email_impl,
162
+ cmd_tasks_prioritize_linkedin as cmd_tasks_prioritize_linkedin_impl,
161
163
  cmd_tasks_query as cmd_tasks_query_impl,
162
164
  cmd_tasks_recipe as cmd_tasks_recipe_impl,
165
+ cmd_tasks_reschedule_email as cmd_tasks_reschedule_email_impl,
166
+ cmd_tasks_reschedule_linkedin as cmd_tasks_reschedule_linkedin_impl,
163
167
  cmd_tasks_schedule_email as cmd_tasks_schedule_email_impl,
168
+ cmd_tasks_schedule_linkedin as cmd_tasks_schedule_linkedin_impl,
164
169
  cmd_tasks_stats as cmd_tasks_stats_impl,
165
170
  cmd_tasks_update as cmd_tasks_update_impl,
166
171
  )
@@ -1460,6 +1465,11 @@ _register("tasks_bulk_update", cmd_tasks_bulk_update_impl, _task_command_runtime
1460
1465
  _register("tasks_bulk_delete", cmd_tasks_bulk_delete_impl, _task_command_runtime)
1461
1466
  _register("tasks_draft", cmd_tasks_draft_impl, _task_command_runtime)
1462
1467
  _register("tasks_schedule_email", cmd_tasks_schedule_email_impl, _task_command_runtime)
1468
+ _register("tasks_prioritize_email", cmd_tasks_prioritize_email_impl, _task_command_runtime)
1469
+ _register("tasks_reschedule_email", cmd_tasks_reschedule_email_impl, _task_command_runtime)
1470
+ _register("tasks_schedule_linkedin", cmd_tasks_schedule_linkedin_impl, _task_command_runtime)
1471
+ _register("tasks_prioritize_linkedin", cmd_tasks_prioritize_linkedin_impl, _task_command_runtime)
1472
+ _register("tasks_reschedule_linkedin", cmd_tasks_reschedule_linkedin_impl, _task_command_runtime)
1463
1473
  _register("tasks_recipe", cmd_tasks_recipe_impl, _task_command_runtime)
1464
1474
 
1465
1475
  # -- Webhook commands --
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
5
+ import sys
4
6
  from dataclasses import dataclass
5
7
  from typing import Any, Callable, Dict, Optional, Sequence
6
8
 
@@ -30,6 +32,51 @@ class SequenceCommandRuntime:
30
32
  sequence_runtime_factory: Callable[[], SequenceRuntime]
31
33
 
32
34
 
35
+ def _sequence_enroll_research_context(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
36
+ research_context = payload.get("researchContext")
37
+ return research_context if isinstance(research_context, dict) else None
38
+
39
+
40
+ def _sequence_enroll_output_hints(payload: Dict[str, Any]) -> Dict[str, Any]:
41
+ research_context = _sequence_enroll_research_context(payload) or {}
42
+ source_table_id = str(
43
+ research_context.get("sourceTableId")
44
+ or research_context.get("source_table_id")
45
+ or ""
46
+ ).strip() or None
47
+ attached = bool(research_context)
48
+
49
+ if attached:
50
+ context_hint = (
51
+ "Attached research context will follow later tasks, sidecar views, and AI drafts"
52
+ + (f" using table {source_table_id}." if source_table_id else ".")
53
+ )
54
+ else:
55
+ context_hint = (
56
+ "No research context is attached; later tasks and AI drafts will use lead/company context only."
57
+ )
58
+
59
+ return {
60
+ "research_context_attached": attached,
61
+ "research_context_table_id": source_table_id,
62
+ "context_hint": context_hint,
63
+ "workflow_hint": (
64
+ "Direct enroll is best for one-off/manual adds. Use add_to_sequence when rows should "
65
+ "auto-enroll from a research table over time."
66
+ ),
67
+ "personalization_hint": (
68
+ "Attached research context does not personalize copy by itself; static copy stays the same "
69
+ "unless the sequence uses variables or AI draft steps."
70
+ ),
71
+ }
72
+
73
+
74
+ def _attach_sequence_enroll_hints(output: Dict[str, Any], payload: Dict[str, Any]) -> Dict[str, Any]:
75
+ enriched = dict(output)
76
+ enriched.update(_sequence_enroll_output_hints(payload))
77
+ return enriched
78
+
79
+
33
80
  def _sequence_mutation_params(args: argparse.Namespace) -> Optional[Dict[str, Any]]:
34
81
  return sequence_mutation_params_impl(args)
35
82
 
@@ -148,17 +195,41 @@ def cmd_sequences_update(args: argparse.Namespace, *, runtime: SequenceCommandRu
148
195
  if not isinstance(payload, dict):
149
196
  raise AutotouchInputError("sequence update requires --data-json/--data-file with a JSON object")
150
197
 
151
- data = runtime.request_api(
152
- "PUT",
153
- f"/api/sequences/{args.sequence_id}",
154
- base_url=args.base_url,
155
- token=token,
156
- use_x_api_key=args.use_x_api_key,
157
- params=_sequence_mutation_params(args),
158
- payload=payload,
159
- timeout=args.timeout,
160
- verbose=args.verbose,
161
- )
198
+ try:
199
+ data = runtime.request_api(
200
+ "PUT",
201
+ f"/api/sequences/{args.sequence_id}",
202
+ base_url=args.base_url,
203
+ token=token,
204
+ use_x_api_key=args.use_x_api_key,
205
+ params=_sequence_mutation_params(args),
206
+ payload=payload,
207
+ timeout=args.timeout,
208
+ verbose=args.verbose,
209
+ )
210
+ except AutotouchAPIError as exc:
211
+ if exc.status_code == 409 and exc.response_body:
212
+ try:
213
+ body = json.loads(exc.response_body)
214
+ except Exception:
215
+ body = None
216
+ if isinstance(body, dict) and str(body.get("error") or "").strip() == "delivery_edit_locked":
217
+ message = str(body.get("message") or "Cannot change delivery backend because this sequence has in-flight email work.").strip()
218
+ policy = body.get("deliveryEditPolicy") if isinstance(body.get("deliveryEditPolicy"), dict) else {}
219
+ enrollment_count = int(policy.get("inFlightEnrollmentCount") or 0)
220
+ task_count = int(policy.get("activeEmailTaskCount") or 0)
221
+ outbox_count = int(policy.get("activeEmailOutboxCount") or 0)
222
+ print(f"ERROR: {message}", file=sys.stderr)
223
+ print(
224
+ f"Counts: active enrollments={enrollment_count}, email tasks={task_count}, queued email jobs={outbox_count}",
225
+ file=sys.stderr,
226
+ )
227
+ print(
228
+ f"Hint: clone the sequence with `autotouch sequences clone --sequence-id {args.sequence_id}` and apply the delivery change there.",
229
+ file=sys.stderr,
230
+ )
231
+ raise AutotouchAPIError(message, status_code=exc.status_code, response_body=exc.response_body) from exc
232
+ raise
162
233
  runtime.print_json(data, args.compact)
163
234
 
164
235
 
@@ -258,6 +329,7 @@ def cmd_sequences_enroll(args: argparse.Namespace, *, runtime: SequenceCommandRu
258
329
  output.setdefault("job_id", job_id)
259
330
  output["job_status_url"] = status_url
260
331
  output["watch_command"] = watch_command
332
+ output = _attach_sequence_enroll_hints(output, payload)
261
333
  runtime.print_json(output, args.compact)
262
334
  return
263
335
 
@@ -306,6 +378,7 @@ def cmd_sequences_enroll(args: argparse.Namespace, *, runtime: SequenceCommandRu
306
378
  "timed_out": timed_out,
307
379
  "polls": int(poll_result.get("polls") or 0),
308
380
  }
381
+ output = _attach_sequence_enroll_hints(output, payload)
309
382
  runtime.print_json(output, args.compact)
310
383
 
311
384
  if timed_out:
@@ -386,7 +459,10 @@ def register_sequences_subcommands(
386
459
  add_api_common_arguments(psqc)
387
460
  psqc.set_defaults(func=handlers["create"])
388
461
 
389
- psqu = sequences_sub.add_parser("update", help="Update a sequence from a JSON payload")
462
+ psqu = sequences_sub.add_parser(
463
+ "update",
464
+ help="Update a sequence from a JSON payload (schedule changes may rewindow unsent scheduled work)",
465
+ )
390
466
  psqu.add_argument("--sequence-id", required=True)
391
467
  psqu.add_argument("--data-json", help="Sequence update payload JSON")
392
468
  psqu.add_argument("--data-file", help="Sequence update payload file path")
@@ -432,10 +508,17 @@ def register_sequences_subcommands(
432
508
  add_api_common_arguments(psqar)
433
509
  psqar.set_defaults(func=handlers["status"], target_status="ARCHIVED")
434
510
 
435
- psqe = sequences_sub.add_parser("enroll", help="Enroll leads into a sequence")
511
+ psqe = sequences_sub.add_parser(
512
+ "enroll",
513
+ help="Enroll leads into a sequence (best for one-off/manual adds)",
514
+ description=(
515
+ "Direct enroll is best for one-off/manual adds. Use add_to_sequence when rows should "
516
+ "auto-enroll from a research table over time."
517
+ ),
518
+ )
436
519
  psqe.add_argument("--sequence-id", required=True)
437
- psqe.add_argument("--data-json", help="Full enroll payload JSON")
438
- psqe.add_argument("--data-file", help="Full enroll payload JSON file")
520
+ psqe.add_argument("--data-json", help="Full enroll payload JSON (advanced; overrides simpler flags)")
521
+ psqe.add_argument("--data-file", help="Full enroll payload JSON file (advanced; overrides simpler flags)")
439
522
  psqe.add_argument(
440
523
  "--lead-id",
441
524
  action="append",
@@ -445,8 +528,31 @@ def register_sequences_subcommands(
445
528
  psqe.add_argument("--lead-ids-file", help="Path to JSON/TXT/CSV file with lead ids")
446
529
  psqe.add_argument("--lead-ids-column", default="lead_id", help="CSV column for lead ids (default: lead_id)")
447
530
  psqe.add_argument("--start-date", help="Optional startDate ISO timestamp")
448
- psqe.add_argument("--research-context-json", help="researchContext JSON")
449
- psqe.add_argument("--research-context-file", help="researchContext JSON file")
531
+ psqe.add_argument(
532
+ "--research-context-json",
533
+ "--attach-research-context-json",
534
+ dest="research_context_json",
535
+ help="Advanced raw researchContext JSON for workflow-scoped table context",
536
+ )
537
+ psqe.add_argument(
538
+ "--research-context-file",
539
+ "--attach-research-context-file",
540
+ dest="research_context_file",
541
+ help="Advanced raw researchContext JSON file for workflow-scoped table context",
542
+ )
543
+ psqe.add_argument(
544
+ "--attach-research-table-id",
545
+ help="Attach research context from this table for later tasks, sidecar views, and AI drafts",
546
+ )
547
+ psqe.add_argument(
548
+ "--attach-research-table-name",
549
+ help="Optional source table name to store alongside attached research context",
550
+ )
551
+ psqe.add_argument(
552
+ "--attach-research-field-id",
553
+ action="append",
554
+ help="Optional research field id to keep in attached context (repeatable; comma-separated also supported)",
555
+ )
450
556
  psqe.add_argument("--variable-map-json", help="variableMap JSON")
451
557
  psqe.add_argument("--variable-map-file", help="variableMap JSON file")
452
558
  psqe.add_argument("--variable-overrides-json", help="variableOverrides JSON")
@@ -231,3 +231,101 @@ def cmd_tasks_schedule_email(args: argparse.Namespace, *, runtime: TaskCommandRu
231
231
  verbose=args.verbose,
232
232
  )
233
233
  runtime.print_json(data, args.compact)
234
+
235
+
236
+ def cmd_tasks_prioritize_email(args: argparse.Namespace, *, runtime: TaskCommandRuntime) -> None:
237
+ token = runtime.resolve_token(args.token, required=True)
238
+ data = runtime.request_api(
239
+ "POST",
240
+ f"/api/task-queue/{args.task_id}/email/prioritize",
241
+ base_url=args.base_url,
242
+ token=token,
243
+ use_x_api_key=args.use_x_api_key,
244
+ params=_task_mutation_params(args),
245
+ timeout=args.timeout,
246
+ verbose=args.verbose,
247
+ )
248
+ runtime.print_json(data, args.compact)
249
+
250
+
251
+ def cmd_tasks_reschedule_email(args: argparse.Namespace, *, runtime: TaskCommandRuntime) -> None:
252
+ token = runtime.resolve_token(args.token, required=True)
253
+ payload = runtime.load_required_object_payload(args, noun="task email reschedule")
254
+ data = runtime.request_api(
255
+ "POST",
256
+ f"/api/task-queue/{args.task_id}/email/reschedule",
257
+ base_url=args.base_url,
258
+ token=token,
259
+ use_x_api_key=args.use_x_api_key,
260
+ params=_task_mutation_params(args),
261
+ payload=payload,
262
+ timeout=args.timeout,
263
+ verbose=args.verbose,
264
+ )
265
+ runtime.print_json(data, args.compact)
266
+
267
+
268
+ def _load_optional_task_payload(
269
+ args: argparse.Namespace,
270
+ *,
271
+ runtime: TaskCommandRuntime,
272
+ noun: str,
273
+ ) -> Dict[str, Any]:
274
+ payload = runtime.load_json_input(
275
+ inline_json=getattr(args, "data_json", None),
276
+ file_path=getattr(args, "data_file", None),
277
+ context="data",
278
+ default={},
279
+ )
280
+ if not isinstance(payload, dict):
281
+ raise AutotouchInputError(f"{noun} payload must be a JSON object")
282
+ return payload
283
+
284
+
285
+ def cmd_tasks_schedule_linkedin(args: argparse.Namespace, *, runtime: TaskCommandRuntime) -> None:
286
+ token = runtime.resolve_token(args.token, required=True)
287
+ payload = _load_optional_task_payload(args, runtime=runtime, noun="task linkedin schedule")
288
+ data = runtime.request_api(
289
+ "POST",
290
+ f"/api/task-queue/{args.task_id}/linkedin/schedule",
291
+ base_url=args.base_url,
292
+ token=token,
293
+ use_x_api_key=args.use_x_api_key,
294
+ params=_task_mutation_params(args),
295
+ payload=payload,
296
+ timeout=args.timeout,
297
+ verbose=args.verbose,
298
+ )
299
+ runtime.print_json(data, args.compact)
300
+
301
+
302
+ def cmd_tasks_prioritize_linkedin(args: argparse.Namespace, *, runtime: TaskCommandRuntime) -> None:
303
+ token = runtime.resolve_token(args.token, required=True)
304
+ data = runtime.request_api(
305
+ "POST",
306
+ f"/api/task-queue/{args.task_id}/linkedin/prioritize",
307
+ base_url=args.base_url,
308
+ token=token,
309
+ use_x_api_key=args.use_x_api_key,
310
+ params=_task_mutation_params(args),
311
+ timeout=args.timeout,
312
+ verbose=args.verbose,
313
+ )
314
+ runtime.print_json(data, args.compact)
315
+
316
+
317
+ def cmd_tasks_reschedule_linkedin(args: argparse.Namespace, *, runtime: TaskCommandRuntime) -> None:
318
+ token = runtime.resolve_token(args.token, required=True)
319
+ payload = runtime.load_required_object_payload(args, noun="task linkedin reschedule")
320
+ data = runtime.request_api(
321
+ "POST",
322
+ f"/api/task-queue/{args.task_id}/linkedin/reschedule",
323
+ base_url=args.base_url,
324
+ token=token,
325
+ use_x_api_key=args.use_x_api_key,
326
+ params=_task_mutation_params(args),
327
+ payload=payload,
328
+ timeout=args.timeout,
329
+ verbose=args.verbose,
330
+ )
331
+ runtime.print_json(data, args.compact)
@@ -54,6 +54,12 @@ def _print_error_body(body: Any) -> None:
54
54
  print(str(body), file=sys.stderr)
55
55
 
56
56
 
57
+ def _serialize_error_body(body: Any) -> str:
58
+ if isinstance(body, (dict, list)):
59
+ return json.dumps(body, default=str)
60
+ return str(body)
61
+
62
+
57
63
  def request_api(
58
64
  method: str,
59
65
  path: str,
@@ -129,7 +135,7 @@ def request_api(
129
135
  raise AutotouchAPIError(
130
136
  f"API {response.status_code}",
131
137
  status_code=response.status_code,
132
- response_body=str(body),
138
+ response_body=_serialize_error_body(body),
133
139
  )
134
140
  return body
135
141
 
@@ -212,6 +218,6 @@ def request_multipart_api(
212
218
  raise AutotouchAPIError(
213
219
  f"API {response.status_code}",
214
220
  status_code=response.status_code,
215
- response_body=str(body),
221
+ response_body=_serialize_error_body(body),
216
222
  )
217
223
  return body
@@ -55,8 +55,18 @@ def extract_add_to_crm_field_mappings(payload: Any) -> Optional[Dict[str, Any]]:
55
55
  return {}
56
56
 
57
57
 
58
- def inspect_add_to_crm_field_mappings(field_mappings: Dict[str, Any]) -> Dict[str, Any]:
59
- mode = "multi" if str(field_mappings.get("mode") or "").strip().lower() == "multi" else "single"
58
+ def inspect_add_to_crm_field_mappings(
59
+ field_mappings: Dict[str, Any],
60
+ *,
61
+ require_company_domain: bool = True,
62
+ ) -> Dict[str, Any]:
63
+ raw_mode = str(field_mappings.get("mode") or "").strip().lower()
64
+ if raw_mode == "array":
65
+ mode = "array"
66
+ elif raw_mode == "multi":
67
+ mode = "multi"
68
+ else:
69
+ mode = "single"
60
70
  required_missing: List[str] = []
61
71
  optional_missing: List[str] = []
62
72
 
@@ -75,12 +85,20 @@ def inspect_add_to_crm_field_mappings(field_mappings: Dict[str, Any]) -> Dict[st
75
85
  return True
76
86
  return False
77
87
 
88
+ if not require_company_domain and mode != "single":
89
+ required_missing.append("singleModeOnlyWhenCompanyDomainOptional")
90
+ return {
91
+ "mode": mode,
92
+ "required_missing": required_missing,
93
+ "optional_missing": optional_missing,
94
+ }
95
+
78
96
  if mode == "multi":
79
97
  common = field_mappings.get("common") if isinstance(field_mappings.get("common"), dict) else {}
80
98
  leads = field_mappings.get("leads") if isinstance(field_mappings.get("leads"), list) else []
81
99
 
82
100
  common_domain = str((common or {}).get("companyDomain") or field_mappings.get("companyDomain") or "").strip()
83
- if not common_domain:
101
+ if require_company_domain and not common_domain:
84
102
  required_missing.append("common.companyDomain")
85
103
 
86
104
  has_lead_identity = False
@@ -95,10 +113,24 @@ def inspect_add_to_crm_field_mappings(field_mappings: Dict[str, Any]) -> Dict[st
95
113
  optional_missing.append(f"leads[{idx}].{slot}")
96
114
  if not has_lead_identity:
97
115
  required_missing.append("leads[].identityFields")
116
+ elif mode == "array":
117
+ common = field_mappings.get("common") if isinstance(field_mappings.get("common"), dict) else {}
118
+ template = field_mappings.get("template") if isinstance(field_mappings.get("template"), dict) else {}
119
+ if require_company_domain and not str((common or {}).get("companyDomain") or "").strip():
120
+ required_missing.append("common.companyDomain")
121
+ if not str(field_mappings.get("sourceColumn") or "").strip():
122
+ required_missing.append("sourceColumn")
123
+ if not _has_identity_mapping(template):
124
+ required_missing.append("template.identityFields")
98
125
  else:
99
126
  company_domain_key = str(field_mappings.get("companyDomain") or "").strip()
100
- if not company_domain_key:
127
+ if require_company_domain and not company_domain_key:
101
128
  required_missing.append("companyDomain")
129
+ if require_company_domain:
130
+ if not _has_identity_mapping(field_mappings):
131
+ required_missing.append("identityFields")
132
+ elif not str(field_mappings.get("linkedinUrl") or "").strip():
133
+ required_missing.append("linkedinUrl")
102
134
  if not _has_identity_mapping(field_mappings):
103
135
  required_missing.append("identityFields")
104
136
  for slot in ["firstName", "lastName", "title", "companyName"]:
@@ -116,6 +148,7 @@ def validate_add_to_crm_create_payload(payload: Dict[str, Any]) -> None:
116
148
  field_mappings = extract_add_to_crm_field_mappings(payload)
117
149
  if field_mappings is None:
118
150
  return
151
+ config = payload.get("config") if isinstance(payload.get("config"), dict) else {}
119
152
 
120
153
  kind_value = str(payload.get("kind") or "").strip().lower()
121
154
  if kind_value != "enrichment":
@@ -130,10 +163,24 @@ def validate_add_to_crm_create_payload(payload: Dict[str, Any]) -> None:
130
163
  "Tip: run `autotouch columns recipe --type add_to_crm`."
131
164
  )
132
165
 
133
- issues = inspect_add_to_crm_field_mappings(field_mappings)
166
+ require_company_domain = config.get("requireCompanyDomain", True)
167
+ if not isinstance(require_company_domain, bool):
168
+ raise AutotouchInputError(
169
+ "add_to_crm config.requireCompanyDomain must be a boolean when provided."
170
+ )
171
+
172
+ issues = inspect_add_to_crm_field_mappings(
173
+ field_mappings,
174
+ require_company_domain=require_company_domain,
175
+ )
134
176
  required_missing = issues.get("required_missing") or []
135
177
  optional_missing = issues.get("optional_missing") or []
136
178
  if required_missing:
179
+ if "singleModeOnlyWhenCompanyDomainOptional" in required_missing:
180
+ raise AutotouchInputError(
181
+ "add_to_crm with config.requireCompanyDomain=false only supports single-mode fieldMappings. "
182
+ "Tip: map config.fieldMappings.linkedinUrl and keep mode='single' for LinkedIn-First Sync to Leads."
183
+ )
137
184
  missing = ", ".join(str(v) for v in required_missing)
138
185
  raise AutotouchInputError(
139
186
  f"add_to_crm fieldMappings missing required keys: {missing}. "
@@ -301,11 +348,20 @@ def run_precheck_for_add_to_crm(
301
348
  if field_mappings is None:
302
349
  return
303
350
 
304
- issues = inspect_add_to_crm_field_mappings(field_mappings)
351
+ config = column.get("config") if isinstance(column.get("config"), dict) else {}
352
+ require_company_domain = config.get("requireCompanyDomain", True)
353
+ issues = inspect_add_to_crm_field_mappings(
354
+ field_mappings,
355
+ require_company_domain=bool(require_company_domain) if isinstance(require_company_domain, bool) else True,
356
+ )
305
357
  required_missing = issues.get("required_missing") or []
306
358
  optional_missing = issues.get("optional_missing") or []
307
359
 
308
360
  if required_missing:
361
+ if "singleModeOnlyWhenCompanyDomainOptional" in required_missing:
362
+ raise AutotouchInputError(
363
+ "add_to_crm column is misconfigured: config.requireCompanyDomain=false only supports single-mode fieldMappings for LinkedIn-First Sync to Leads."
364
+ )
309
365
  missing = ", ".join(str(v) for v in required_missing)
310
366
  raise AutotouchInputError(
311
367
  f"add_to_crm column is misconfigured (missing required mappings: {missing}). "