affinity-sdk 0.9.5__py3-none-any.whl

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 (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from ..click_compat import RichCommand, click
6
+ from ..context import CLIContext
7
+ from ..decorators import category
8
+ from ..options import output_options
9
+ from ..runner import CommandOutput, run_command
10
+
11
+
12
+ @category("local")
13
+ @click.command(name="completion", cls=RichCommand)
14
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
15
+ @output_options
16
+ @click.pass_obj
17
+ def completion_cmd(ctx: CLIContext, shell: str) -> None:
18
+ """Output shell completion script for bash, zsh, or fish."""
19
+ if shell == "bash":
20
+ script = 'eval "$(_XAFFINITY_COMPLETE=bash_source xaffinity)"\n'
21
+ elif shell == "zsh":
22
+ script = 'eval "$(_XAFFINITY_COMPLETE=zsh_source xaffinity)"\n'
23
+ else:
24
+ script = "eval (env _XAFFINITY_COMPLETE=fish_source xaffinity)\n"
25
+
26
+ if ctx.output == "table":
27
+ sys.stdout.write(script)
28
+ raise click.exceptions.Exit(0)
29
+
30
+ def fn(_: CLIContext, _warnings: list[str]) -> CommandOutput:
31
+ return CommandOutput(data={"shell": shell, "script": script}, api_called=False)
32
+
33
+ run_command(ctx, command="completion", fn=fn)
@@ -0,0 +1,540 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import os
5
+ import re
6
+ import sys
7
+ from contextlib import suppress
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn
12
+
13
+ from ..click_compat import RichCommand, RichGroup, click
14
+ from ..config import config_init_template
15
+ from ..context import CLIContext
16
+ from ..decorators import category
17
+ from ..errors import CLIError
18
+ from ..options import output_options
19
+ from ..runner import CommandOutput, run_command
20
+
21
+
22
+ @click.group(name="config", cls=RichGroup)
23
+ def config_group() -> None:
24
+ """Configuration and profiles."""
25
+
26
+
27
+ @category("local")
28
+ @config_group.command(name="path", cls=RichCommand)
29
+ @output_options
30
+ @click.pass_obj
31
+ def config_path(ctx: CLIContext) -> None:
32
+ """Show the path to the configuration file."""
33
+
34
+ def fn(_: CLIContext, _warnings: list[str]) -> CommandOutput:
35
+ path = ctx.paths.config_path
36
+ return CommandOutput(data={"path": str(path), "exists": path.exists()}, api_called=False)
37
+
38
+ run_command(ctx, command="config path", fn=fn)
39
+
40
+
41
+ @category("local")
42
+ @config_group.command(name="init", cls=RichCommand)
43
+ @click.option("--force", is_flag=True, help="Overwrite existing config file.")
44
+ @output_options
45
+ @click.pass_obj
46
+ def config_init(ctx: CLIContext, *, force: bool) -> None:
47
+ """Create a new configuration file with template."""
48
+
49
+ def fn(_: CLIContext, _warnings: list[str]) -> CommandOutput:
50
+ path = ctx.paths.config_path
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ overwritten = False
53
+ if path.exists():
54
+ if not force:
55
+ raise CLIError(
56
+ f"Config already exists: {path} (use --force to overwrite)",
57
+ exit_code=2,
58
+ error_type="usage_error",
59
+ )
60
+ overwritten = True
61
+
62
+ path.write_text(config_init_template(), encoding="utf-8")
63
+ if os.name == "posix":
64
+ with suppress(OSError):
65
+ path.chmod(0o600)
66
+ return CommandOutput(
67
+ data={"path": str(path), "created": True, "overwritten": overwritten},
68
+ api_called=False,
69
+ )
70
+
71
+ run_command(ctx, command="config init", fn=fn)
72
+
73
+
74
+ # API key format validation - most Affinity keys are alphanumeric with some punctuation
75
+ _API_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_\-:.]+$")
76
+
77
+
78
+ def _validate_api_key_format(api_key: str) -> bool:
79
+ """Validate API key contains only expected characters."""
80
+ return bool(_API_KEY_PATTERN.match(api_key)) and 10 <= len(api_key) <= 200
81
+
82
+
83
+ def _find_existing_key(ctx: CLIContext) -> tuple[bool, str | None]:
84
+ """
85
+ Check all sources for an existing API key.
86
+
87
+ Returns (found: bool, source: str | None).
88
+ Source is "environment", "dotenv", "config", or None.
89
+ """
90
+ # Check environment variable
91
+ env_key = os.getenv("AFFINITY_API_KEY", "").strip()
92
+ if env_key:
93
+ return True, "environment"
94
+
95
+ # Check .env file in current directory (without requiring --dotenv flag)
96
+ # This allows check-key to discover keys even if user forgot --dotenv
97
+ dotenv_path = Path(".env")
98
+ if dotenv_path.exists():
99
+ try:
100
+ content = dotenv_path.read_text(encoding="utf-8")
101
+ for line in content.splitlines():
102
+ stripped = line.strip()
103
+ # Match AFFINITY_API_KEY=<non-empty-value>
104
+ if stripped.startswith("AFFINITY_API_KEY="):
105
+ value = stripped[len("AFFINITY_API_KEY=") :].strip()
106
+ # Handle quoted values
107
+ if (value.startswith('"') and value.endswith('"')) or (
108
+ value.startswith("'") and value.endswith("'")
109
+ ):
110
+ value = value[1:-1]
111
+ if value: # Non-empty value
112
+ return True, "dotenv"
113
+ except OSError:
114
+ pass
115
+
116
+ # Check config.toml - only in [default] section for consistency with _store_in_config
117
+ config_path = ctx.paths.config_path
118
+ if config_path.exists():
119
+ try:
120
+ content = config_path.read_text(encoding="utf-8")
121
+ # Parse section-aware: only look in [default] section
122
+ in_default = False
123
+ for line in content.splitlines():
124
+ stripped = line.strip()
125
+ if stripped == "[default]":
126
+ in_default = True
127
+ elif stripped.startswith("[") and stripped.endswith("]"):
128
+ in_default = False
129
+ elif in_default and re.match(r'^api_key\s*=\s*"[^"]+', stripped):
130
+ # Found non-empty api_key in [default] section
131
+ return True, "config"
132
+ except OSError:
133
+ pass
134
+
135
+ return False, None
136
+
137
+
138
+ @category("local")
139
+ @config_group.command(name="check-key", cls=RichCommand)
140
+ @output_options
141
+ @click.pass_obj
142
+ def check_key(ctx: CLIContext) -> None:
143
+ """
144
+ Check if an API key is configured.
145
+
146
+ Exit codes:
147
+ 0: Key found (configured)
148
+ 1: Key not found (not configured) - this is NOT an error
149
+
150
+ This follows the pattern of `git diff --exit-code` where non-zero exit
151
+ indicates a specific condition (difference/missing), not an error.
152
+
153
+ Does not validate the key against the API - only checks if one exists.
154
+
155
+ Examples:
156
+ xaffinity config check-key
157
+ xaffinity config check-key --json
158
+ xaffinity config check-key && echo "Key exists"
159
+ """
160
+ # For human-readable output, bypass run_command to avoid the "OK" box
161
+ if ctx.output != "json":
162
+ key_found, source = _find_existing_key(ctx)
163
+ if key_found:
164
+ click.echo(f"✓ API key configured (source: {source})")
165
+ else:
166
+ click.echo("✗ No API key configured")
167
+ raise click.exceptions.Exit(0 if key_found else 1)
168
+
169
+ # For JSON output, use the normal flow
170
+ def fn(_ctx: CLIContext, _warnings: list[str]) -> CommandOutput:
171
+ key_found, source = _find_existing_key(ctx)
172
+
173
+ # Build the recommended command pattern based on key source
174
+ pattern: str | None = None
175
+ if key_found:
176
+ if source == "dotenv":
177
+ pattern = "xaffinity --dotenv --readonly <command> --json"
178
+ else:
179
+ pattern = "xaffinity --readonly <command> --json"
180
+
181
+ return CommandOutput(
182
+ data={
183
+ "configured": key_found,
184
+ "source": source, # "environment", "dotenv", "config", or None
185
+ "pattern": pattern, # Recommended command pattern to use
186
+ },
187
+ api_called=False,
188
+ exit_code=0 if key_found else 1,
189
+ )
190
+
191
+ run_command(ctx, command="config check-key", fn=fn)
192
+
193
+
194
+ def _validate_key(api_key: str, warnings: list[str]) -> bool:
195
+ """
196
+ Validate API key by calling whoami endpoint.
197
+
198
+ Uses lazy import of httpx - while httpx is a core dependency,
199
+ keeping the import inside the function avoids loading it for
200
+ commands that don't need validation (like --no-validate).
201
+ """
202
+ import httpx
203
+
204
+ try:
205
+ # Use V1 whoami endpoint for validation (simpler auth)
206
+ response = httpx.get(
207
+ "https://api.affinity.co/auth/whoami",
208
+ auth=("", api_key),
209
+ timeout=10.0,
210
+ )
211
+ if response.status_code == 401:
212
+ warnings.append("API key was rejected (401 Unauthorized)")
213
+ return False
214
+ return response.status_code == 200
215
+ except httpx.RequestError as e:
216
+ warnings.append(f"Network error during validation: {e}")
217
+ return False
218
+
219
+
220
+ def _store_in_dotenv(api_key: str, *, warnings: list[str]) -> CommandOutput:
221
+ """Store API key in .env file in current directory."""
222
+ env_path = Path(".env")
223
+ gitignore_path = Path(".gitignore")
224
+
225
+ # Read existing .env content
226
+ lines: list[str] = []
227
+ key_line_index: int | None = None
228
+
229
+ if env_path.exists():
230
+ content = env_path.read_text(encoding="utf-8")
231
+ lines = content.splitlines()
232
+ for i, line in enumerate(lines):
233
+ # Match AFFINITY_API_KEY= at start of line (ignore comments)
234
+ stripped = line.strip()
235
+ if stripped.startswith("AFFINITY_API_KEY="):
236
+ key_line_index = i
237
+ break
238
+
239
+ # Update or append
240
+ new_line = f"AFFINITY_API_KEY={api_key}"
241
+ if key_line_index is not None:
242
+ lines[key_line_index] = new_line
243
+ else:
244
+ # Add blank line separator if file has content
245
+ if lines and lines[-1].strip():
246
+ lines.append("")
247
+ lines.append("# Affinity API key")
248
+ lines.append(new_line)
249
+
250
+ # Write .env
251
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
252
+
253
+ # Ensure .env is in .gitignore
254
+ env_existed_before = key_line_index is not None # Had AFFINITY_API_KEY before
255
+ gitignore_updated = _ensure_gitignore(gitignore_path)
256
+ if gitignore_updated:
257
+ warnings.append(f"Added .env to {gitignore_path}")
258
+ # Warn about potential git history exposure
259
+ if env_existed_before:
260
+ warnings.append(
261
+ "Warning: .env was not in .gitignore before. If it was previously committed, "
262
+ "secrets may still be in git history. Consider running: git rm --cached .env"
263
+ )
264
+
265
+ return CommandOutput(
266
+ data={
267
+ "key_stored": True,
268
+ "scope": "project",
269
+ "path": str(env_path.absolute()),
270
+ "gitignore_updated": gitignore_updated,
271
+ },
272
+ api_called=False,
273
+ )
274
+
275
+
276
+ def _store_in_config(ctx: CLIContext, api_key: str) -> CommandOutput:
277
+ """Store API key in user config.toml."""
278
+ config_path = ctx.paths.config_path
279
+ config_path.parent.mkdir(parents=True, exist_ok=True)
280
+
281
+ # Escape special characters for TOML string
282
+ # TOML basic strings use backslash escapes: \" for quote, \\ for backslash
283
+ escaped_key = api_key.replace("\\", "\\\\").replace('"', '\\"')
284
+
285
+ # Read or create config
286
+ if config_path.exists():
287
+ content = config_path.read_text(encoding="utf-8")
288
+ # Simple TOML manipulation - find [default] section and update/add api_key
289
+ # For robustness, we use basic string manipulation rather than full TOML parsing
290
+ # to avoid adding toml as a required dependency
291
+ lines = content.splitlines()
292
+ in_default = False
293
+ key_line_index: int | None = None
294
+ default_section_index: int | None = None
295
+
296
+ for i, line in enumerate(lines):
297
+ stripped = line.strip()
298
+ if stripped == "[default]":
299
+ in_default = True
300
+ default_section_index = i
301
+ elif stripped.startswith("[") and stripped.endswith("]"):
302
+ in_default = False
303
+ # More precise matching: api_key followed by whitespace or =
304
+ # Avoids matching api_key_backup, api_keys, etc.
305
+ elif in_default and re.match(r"^api_key\s*=", stripped):
306
+ key_line_index = i
307
+ break
308
+
309
+ if key_line_index is not None:
310
+ # Update existing key
311
+ lines[key_line_index] = f'api_key = "{escaped_key}"'
312
+ elif default_section_index is not None:
313
+ # Add key after [default] section header
314
+ lines.insert(default_section_index + 1, f'api_key = "{escaped_key}"')
315
+ else:
316
+ # No [default] section - add it
317
+ if lines and lines[-1].strip():
318
+ lines.append("")
319
+ lines.append("[default]")
320
+ lines.append(f'api_key = "{escaped_key}"')
321
+
322
+ new_content = "\n".join(lines) + "\n"
323
+ else:
324
+ # Create new config file
325
+ new_content = f'[default]\napi_key = "{escaped_key}"\n'
326
+
327
+ config_path.write_text(new_content, encoding="utf-8")
328
+
329
+ # Set restrictive permissions on Unix
330
+ if os.name == "posix":
331
+ with suppress(OSError):
332
+ config_path.chmod(0o600)
333
+
334
+ return CommandOutput(
335
+ data={
336
+ "key_stored": True,
337
+ "scope": "user",
338
+ "path": str(config_path),
339
+ },
340
+ api_called=False,
341
+ )
342
+
343
+
344
+ def _ensure_gitignore(gitignore_path: Path) -> bool:
345
+ """Ensure .env is in .gitignore. Returns True if file was modified."""
346
+ patterns_to_check = [".env", "*.env", ".env*"]
347
+
348
+ if gitignore_path.exists():
349
+ content = gitignore_path.read_text(encoding="utf-8")
350
+ # Check if any pattern already covers .env
351
+ for line in content.splitlines():
352
+ stripped = line.strip()
353
+ if stripped in patterns_to_check or stripped == ".env":
354
+ return False # Already covered
355
+
356
+ # Append .env
357
+ with gitignore_path.open("a", encoding="utf-8") as f:
358
+ if not content.endswith("\n"):
359
+ f.write("\n")
360
+ f.write("\n# Affinity API key\n.env\n")
361
+ return True
362
+ else:
363
+ # Create .gitignore with .env
364
+ gitignore_path.write_text("# Affinity API key\n.env\n", encoding="utf-8")
365
+ return True
366
+
367
+
368
+ @category("local")
369
+ @config_group.command(name="setup-key", cls=RichCommand)
370
+ @click.option(
371
+ "--scope",
372
+ type=click.Choice(["project", "user"], case_sensitive=False),
373
+ default=None,
374
+ help="Where to store: 'project' (.env) or 'user' (config.toml). Interactive if omitted.",
375
+ )
376
+ @click.option(
377
+ "--force",
378
+ is_flag=True,
379
+ help="Overwrite existing API key without prompting.",
380
+ )
381
+ @click.option(
382
+ "--validate/--no-validate",
383
+ default=True,
384
+ help="Test the key against the API after storing (default: validate).",
385
+ )
386
+ @output_options
387
+ @click.pass_obj
388
+ def setup_key(ctx: CLIContext, *, scope: str | None, force: bool, validate: bool) -> None:
389
+ """
390
+ Securely configure your Affinity API key.
391
+
392
+ This command prompts for your API key with hidden input (not echoed to screen)
393
+ and stores it in your chosen location. The key is never passed as a command-line
394
+ argument or logged.
395
+
396
+ Get your API key from Affinity:
397
+ https://support.affinity.co/s/article/How-to-Create-and-Manage-API-Keys
398
+
399
+ Storage options:
400
+ - project: Stores in .env file in current directory (auto-added to .gitignore)
401
+ - user: Stores in user config file (chmod 600 on Unix)
402
+
403
+ Examples:
404
+ xaffinity config setup-key
405
+ xaffinity config setup-key --scope project
406
+ xaffinity config setup-key --scope user --force
407
+ xaffinity config setup-key --no-validate
408
+ """
409
+
410
+ def fn(_ctx: CLIContext, warnings: list[str]) -> CommandOutput:
411
+ # Helper to print only for human output
412
+ human_output = ctx.output != "json"
413
+ console = Console(file=sys.stderr, force_terminal=None) if human_output else None
414
+
415
+ def echo(msg: str = "", style: str | None = None) -> None:
416
+ if console:
417
+ console.print(msg, style=style, highlight=False)
418
+
419
+ # Check for existing key using full resolution chain
420
+ key_found, source = _find_existing_key(ctx)
421
+ if key_found and not force:
422
+ # Key exists - confirm overwrite
423
+ echo(f"An API key is already configured [dim](source: {source})[/dim].")
424
+ if not click.confirm("Do you want to configure a new key?", default=False):
425
+ # For human output, show clean message and exit
426
+ if human_output:
427
+ echo("Keeping existing key.", style="dim")
428
+ raise click.exceptions.Exit(0)
429
+ return CommandOutput(
430
+ data={"key_stored": False, "reason": "existing_key_kept"},
431
+ api_called=False,
432
+ )
433
+
434
+ # Get the API key securely
435
+ echo()
436
+ echo("[bold]Enter your Affinity API key.[/bold]")
437
+ echo(
438
+ "Get your key from: [link=https://support.affinity.co/s/article/How-to-Create-and-Manage-API-Keys]"
439
+ "https://support.affinity.co/s/article/How-to-Create-and-Manage-API-Keys[/link]"
440
+ )
441
+ echo()
442
+ echo("[dim](Input is hidden - nothing will appear as you type)[/dim]")
443
+ api_key = getpass.getpass(prompt="API Key: " if human_output else "").strip()
444
+ if not api_key:
445
+ raise CLIError("No API key provided.", exit_code=2, error_type="usage_error")
446
+
447
+ # Validate API key format
448
+ if not _validate_api_key_format(api_key):
449
+ raise CLIError(
450
+ "Invalid API key format. Keys should be 10-200 characters, "
451
+ "containing only letters, numbers, underscores, hyphens, colons, or dots.",
452
+ exit_code=2,
453
+ error_type="validation_error",
454
+ )
455
+
456
+ # Determine scope (simplified prompt - just 1 or 2)
457
+ chosen_scope = scope
458
+ if chosen_scope is None:
459
+ echo()
460
+ echo("[bold]Where should the key be stored?[/bold]")
461
+ echo(" [cyan]1[/cyan] project — .env in current directory [dim](this project)[/dim]")
462
+ echo(" [cyan]2[/cyan] user — User config file [dim](all projects)[/dim]")
463
+ choice = click.prompt("Choice", type=click.Choice(["1", "2"]))
464
+ chosen_scope = "project" if choice == "1" else "user"
465
+
466
+ # Store the key
467
+ try:
468
+ if chosen_scope == "project":
469
+ result = _store_in_dotenv(api_key, warnings=warnings)
470
+ else:
471
+ result = _store_in_config(ctx, api_key)
472
+ except PermissionError as e:
473
+ raise CLIError(
474
+ f"Permission denied writing to file: {e}. Check directory permissions.",
475
+ exit_code=1,
476
+ error_type="permission_error",
477
+ ) from e
478
+ except OSError as e:
479
+ raise CLIError(
480
+ f"Failed to write configuration: {e}",
481
+ exit_code=1,
482
+ error_type="io_error",
483
+ ) from e
484
+
485
+ # Validate key if requested
486
+ validated = False
487
+ if validate:
488
+ if console:
489
+ with Progress(
490
+ SpinnerColumn(),
491
+ TextColumn("[progress.description]{task.description}"),
492
+ console=console,
493
+ transient=True,
494
+ ) as progress:
495
+ progress.add_task("Validating key against Affinity API...", total=None)
496
+ validated = _validate_key(api_key, warnings)
497
+ else:
498
+ validated = _validate_key(api_key, warnings)
499
+ # Need to create a new CommandOutput with validated field
500
+ # result.data is always set by _store_in_dotenv/_store_in_config
501
+ assert result.data is not None
502
+ result = CommandOutput(
503
+ data={**result.data, "validated": validated},
504
+ api_called=False,
505
+ )
506
+ if validated:
507
+ echo("[green]✓ Key validated successfully[/green]")
508
+ else:
509
+ warnings.append("Key stored but validation failed - check key is correct")
510
+
511
+ # Show usage hint based on scope
512
+ echo()
513
+ if chosen_scope == "project":
514
+ echo("Key stored. To use it, run commands with [bold]--dotenv[/bold] flag:")
515
+ echo(" [dim]xaffinity --dotenv whoami[/dim]")
516
+ else:
517
+ echo("Key stored in user config. Test with:")
518
+ echo(" [dim]xaffinity whoami[/dim]")
519
+
520
+ # Clear key reference (minimal security benefit but good practice)
521
+ del api_key
522
+
523
+ return result
524
+
525
+ # For human output, bypass run_command to avoid rendering the data dict
526
+ # (we already printed our own messages above)
527
+ if ctx.output != "json":
528
+ warnings: list[str] = []
529
+ try:
530
+ result = fn(ctx, warnings)
531
+ except CLIError as e:
532
+ click.echo(f"Error: {e.message}", err=True)
533
+ raise click.exceptions.Exit(e.exit_code) from e
534
+ # Emit any warnings that were collected
535
+ if warnings and not ctx.quiet:
536
+ for w in warnings:
537
+ click.echo(f"Warning: {w}", err=True)
538
+ raise click.exceptions.Exit(result.exit_code)
539
+
540
+ run_command(ctx, command="config setup-key", fn=fn)
@@ -0,0 +1,33 @@
1
+ """Top-level 'entry' command group - shorthand for 'list entry'."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..click_compat import RichGroup, click
6
+
7
+ # Import the underlying command functions from list_cmds
8
+ # These are Click Command objects after decoration
9
+ from .list_cmds import (
10
+ list_entry_add,
11
+ list_entry_delete,
12
+ list_entry_field,
13
+ list_entry_get,
14
+ )
15
+
16
+
17
+ @click.group(name="entry", cls=RichGroup)
18
+ def entry_group() -> None:
19
+ """List entry commands (shorthand for 'list entry').
20
+
21
+ These commands work on list entries, which are the rows within an Affinity list.
22
+ Each entry represents a person, company, or opportunity tracked in that list.
23
+
24
+ This is a convenience alias - all commands are also available under 'list entry'.
25
+ """
26
+
27
+
28
+ # Register the same command functions under the entry group
29
+ # Click commands can be added to multiple groups
30
+ entry_group.add_command(list_entry_get, name="get")
31
+ entry_group.add_command(list_entry_add, name="add")
32
+ entry_group.add_command(list_entry_delete, name="delete")
33
+ entry_group.add_command(list_entry_field, name="field")