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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- 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")
|