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,283 @@
|
|
|
1
|
+
"""JSON help output generator for xaffinity CLI.
|
|
2
|
+
|
|
3
|
+
Generates machine-readable JSON help output for use by MCP tools and automation.
|
|
4
|
+
Invoked via `xaffinity --help --json`.
|
|
5
|
+
|
|
6
|
+
Commands MUST use @category decorator from affinity.cli.decorators to specify
|
|
7
|
+
their classification. Missing @category will raise an error during JSON help
|
|
8
|
+
generation to ensure all commands are explicitly categorized.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from click import Argument, Command, Context, Option
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MissingCategoryError(Exception):
|
|
22
|
+
"""Raised when a command is missing the required @category decorator."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _classify_command(cmd: Command, cmd_name: str) -> tuple[str, bool, bool]:
|
|
26
|
+
"""Classify command by category, destructive flag, and progress capability.
|
|
27
|
+
|
|
28
|
+
Reads @category, @destructive, and @progress_capable decorator metadata.
|
|
29
|
+
All commands MUST have an explicit @category decorator.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
cmd: Click command object
|
|
33
|
+
cmd_name: Full command name (for error messages)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tuple of (category, destructive, progress_capable)
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
MissingCategoryError: If command lacks @category decorator
|
|
40
|
+
"""
|
|
41
|
+
category = getattr(cmd, "category", None)
|
|
42
|
+
if category is None:
|
|
43
|
+
raise MissingCategoryError(
|
|
44
|
+
f"Command '{cmd_name}' is missing @category decorator. "
|
|
45
|
+
f"Add @category('read'), @category('write'), or @category('local') "
|
|
46
|
+
f"above @...group.command() decorator."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
destructive = getattr(cmd, "destructive", False)
|
|
50
|
+
progress_capable = getattr(cmd, "progress_capable", False)
|
|
51
|
+
return category, destructive, progress_capable
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_param_type(param: Option | Argument) -> str:
|
|
55
|
+
"""Get the parameter type string for JSON output."""
|
|
56
|
+
from click import BOOL, INT, Choice, Path
|
|
57
|
+
|
|
58
|
+
param_type = param.type
|
|
59
|
+
|
|
60
|
+
# Handle is_flag for options
|
|
61
|
+
if hasattr(param, "is_flag") and param.is_flag:
|
|
62
|
+
return "flag"
|
|
63
|
+
|
|
64
|
+
# Check common types
|
|
65
|
+
if param_type == INT or (hasattr(param_type, "name") and param_type.name == "INT"):
|
|
66
|
+
return "int"
|
|
67
|
+
if param_type == BOOL or (hasattr(param_type, "name") and param_type.name == "BOOL"):
|
|
68
|
+
return "bool"
|
|
69
|
+
if isinstance(param_type, Choice):
|
|
70
|
+
return "string" # Choices are strings
|
|
71
|
+
if isinstance(param_type, Path):
|
|
72
|
+
return "string" # Paths are strings
|
|
73
|
+
|
|
74
|
+
# Default to string
|
|
75
|
+
return "string"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _extract_option(opt: Option, all_opt_names: list[str] | None = None) -> dict[str, Any]:
|
|
79
|
+
"""Extract option metadata for JSON output."""
|
|
80
|
+
from click import Choice
|
|
81
|
+
|
|
82
|
+
result: dict[str, Any] = {
|
|
83
|
+
"type": _get_param_type(opt),
|
|
84
|
+
"required": opt.required,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Add help text if available
|
|
88
|
+
if opt.help:
|
|
89
|
+
result["help"] = opt.help
|
|
90
|
+
|
|
91
|
+
# Add choices if this is a Choice type
|
|
92
|
+
if isinstance(opt.type, Choice):
|
|
93
|
+
result["choices"] = list(opt.type.choices)
|
|
94
|
+
|
|
95
|
+
# Add multiple flag if applicable
|
|
96
|
+
if opt.multiple:
|
|
97
|
+
result["multiple"] = True
|
|
98
|
+
|
|
99
|
+
# Add aliases if there are multiple option names (excluding the primary)
|
|
100
|
+
if all_opt_names and len(all_opt_names) > 1:
|
|
101
|
+
# Primary is the longest, aliases are the rest
|
|
102
|
+
primary = max(all_opt_names, key=len)
|
|
103
|
+
aliases = [name for name in all_opt_names if name != primary]
|
|
104
|
+
if aliases:
|
|
105
|
+
result["aliases"] = sorted(aliases, key=len, reverse=True)
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _extract_positional(arg: Argument) -> dict[str, Any]:
|
|
111
|
+
"""Extract positional argument metadata for JSON output."""
|
|
112
|
+
return {
|
|
113
|
+
"name": arg.name.upper() if arg.name else "ARG",
|
|
114
|
+
"type": _get_param_type(arg),
|
|
115
|
+
"required": arg.required,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_examples(docstring: str) -> list[str]:
|
|
120
|
+
"""Parse examples from a command docstring.
|
|
121
|
+
|
|
122
|
+
Looks for an "Examples:" section and extracts lines starting with "- `".
|
|
123
|
+
Returns the command portion without the leading "xaffinity " prefix.
|
|
124
|
+
"""
|
|
125
|
+
import re
|
|
126
|
+
|
|
127
|
+
examples: list[str] = []
|
|
128
|
+
if not docstring:
|
|
129
|
+
return examples
|
|
130
|
+
|
|
131
|
+
# Find Examples: section
|
|
132
|
+
lines = docstring.split("\n")
|
|
133
|
+
in_examples = False
|
|
134
|
+
for line in lines:
|
|
135
|
+
stripped = line.strip()
|
|
136
|
+
if stripped.lower().startswith("examples:"):
|
|
137
|
+
in_examples = True
|
|
138
|
+
continue
|
|
139
|
+
if in_examples:
|
|
140
|
+
# Stop at next section (line ending with :) or empty section marker
|
|
141
|
+
if stripped and not stripped.startswith("-") and stripped.endswith(":"):
|
|
142
|
+
break
|
|
143
|
+
# Extract example from "- `xaffinity cmd args`" format
|
|
144
|
+
match = re.match(r"^-\s*`xaffinity\s+(.+?)`\s*$", stripped)
|
|
145
|
+
if match:
|
|
146
|
+
examples.append(match.group(1))
|
|
147
|
+
elif re.match(r"^-\s*`(.+?)`\s*$", stripped):
|
|
148
|
+
# Also handle examples without xaffinity prefix
|
|
149
|
+
match = re.match(r"^-\s*`(.+?)`\s*$", stripped)
|
|
150
|
+
if match:
|
|
151
|
+
examples.append(match.group(1))
|
|
152
|
+
|
|
153
|
+
return examples
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _extract_command(
|
|
157
|
+
cmd: Command,
|
|
158
|
+
prefix: str = "",
|
|
159
|
+
) -> list[dict[str, Any]]:
|
|
160
|
+
"""Extract command metadata, recursively handling groups.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
cmd: Click command or group
|
|
164
|
+
prefix: Command name prefix (e.g., "company" for "company get")
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
List of command metadata dictionaries
|
|
168
|
+
"""
|
|
169
|
+
from click import Argument, Group, Option
|
|
170
|
+
|
|
171
|
+
results: list[dict[str, Any]] = []
|
|
172
|
+
full_name = f"{prefix} {cmd.name}".strip() if prefix else (cmd.name or "")
|
|
173
|
+
|
|
174
|
+
# If this is a group, recurse into subcommands
|
|
175
|
+
if isinstance(cmd, Group):
|
|
176
|
+
for subcmd_name in cmd.list_commands(None): # type: ignore[arg-type]
|
|
177
|
+
subcmd = cmd.get_command(None, subcmd_name) # type: ignore[arg-type]
|
|
178
|
+
if subcmd:
|
|
179
|
+
results.extend(_extract_command(subcmd, full_name))
|
|
180
|
+
return results
|
|
181
|
+
|
|
182
|
+
# Skip the root command itself (no name)
|
|
183
|
+
if not full_name:
|
|
184
|
+
return results
|
|
185
|
+
|
|
186
|
+
# Extract description from docstring (strip first to handle leading newlines)
|
|
187
|
+
docstring = cmd.help or ""
|
|
188
|
+
description = docstring.strip().split("\n")[0].strip()
|
|
189
|
+
|
|
190
|
+
# Extract examples from docstring
|
|
191
|
+
examples = _parse_examples(docstring)
|
|
192
|
+
|
|
193
|
+
# Classify command from decorator metadata (required)
|
|
194
|
+
category, destructive, progress_capable = _classify_command(cmd, full_name)
|
|
195
|
+
|
|
196
|
+
# Extract parameters (options) and positionals (arguments)
|
|
197
|
+
parameters: dict[str, dict[str, Any]] = {}
|
|
198
|
+
positionals: list[dict[str, Any]] = []
|
|
199
|
+
|
|
200
|
+
# Global options to skip (output format options inherited from parent)
|
|
201
|
+
skip_option_names = {"output", "json_flag", "help"}
|
|
202
|
+
skip_option_flags = {"--json", "-j", "--output", "-o", "--help", "-h"}
|
|
203
|
+
|
|
204
|
+
for param in cmd.params:
|
|
205
|
+
if isinstance(param, Option):
|
|
206
|
+
# Skip hidden options
|
|
207
|
+
if param.hidden:
|
|
208
|
+
continue
|
|
209
|
+
# Skip common options that aren't command-specific
|
|
210
|
+
if param.name in skip_option_names:
|
|
211
|
+
continue
|
|
212
|
+
# Skip global output format flags
|
|
213
|
+
if any(opt in skip_option_flags for opt in param.opts):
|
|
214
|
+
continue
|
|
215
|
+
# Get the primary option name (longest form, usually --flag)
|
|
216
|
+
opt_names = list(param.opts)
|
|
217
|
+
primary_name = max(opt_names, key=len) if opt_names else f"--{param.name}"
|
|
218
|
+
parameters[primary_name] = _extract_option(param, opt_names)
|
|
219
|
+
elif isinstance(param, Argument):
|
|
220
|
+
positionals.append(_extract_positional(param))
|
|
221
|
+
|
|
222
|
+
command_data: dict[str, Any] = {
|
|
223
|
+
"name": full_name,
|
|
224
|
+
"description": description,
|
|
225
|
+
"category": category,
|
|
226
|
+
"destructive": destructive,
|
|
227
|
+
"progressCapable": progress_capable,
|
|
228
|
+
"parameters": parameters,
|
|
229
|
+
"positionals": positionals,
|
|
230
|
+
}
|
|
231
|
+
if examples:
|
|
232
|
+
command_data["examples"] = examples
|
|
233
|
+
|
|
234
|
+
results.append(command_data)
|
|
235
|
+
|
|
236
|
+
return results
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def generate_help_json(ctx: Context) -> str:
|
|
240
|
+
"""Generate JSON help output for all CLI commands.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
ctx: Click context with the root command
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
JSON string with command metadata
|
|
247
|
+
"""
|
|
248
|
+
# Get the root command (cli group)
|
|
249
|
+
root = ctx.command
|
|
250
|
+
|
|
251
|
+
# Extract all commands
|
|
252
|
+
commands: list[dict[str, Any]] = []
|
|
253
|
+
|
|
254
|
+
# If root is a group, iterate through subcommands
|
|
255
|
+
from click import Group
|
|
256
|
+
|
|
257
|
+
if isinstance(root, Group):
|
|
258
|
+
for cmd_name in root.list_commands(ctx):
|
|
259
|
+
cmd = root.get_command(ctx, cmd_name)
|
|
260
|
+
if cmd:
|
|
261
|
+
commands.extend(_extract_command(cmd))
|
|
262
|
+
|
|
263
|
+
# Sort commands by name for consistent output
|
|
264
|
+
commands.sort(key=lambda c: c["name"])
|
|
265
|
+
|
|
266
|
+
# Build the output structure
|
|
267
|
+
output = {
|
|
268
|
+
"commands": commands,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return json.dumps(output, indent=2, ensure_ascii=False)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def emit_help_json_and_exit(ctx: Context) -> None:
|
|
275
|
+
"""Generate JSON help and exit.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
ctx: Click context
|
|
279
|
+
"""
|
|
280
|
+
json_output = generate_help_json(ctx)
|
|
281
|
+
sys.stdout.write(json_output)
|
|
282
|
+
sys.stdout.write("\n")
|
|
283
|
+
sys.exit(0)
|
affinity/cli/logging.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from logging.handlers import RotatingFileHandler
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _RedactFilter(logging.Filter):
|
|
11
|
+
def __init__(self, *, api_key: str | None):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self._api_key = api_key
|
|
14
|
+
|
|
15
|
+
def set_api_key(self, api_key: str | None) -> None:
|
|
16
|
+
self._api_key = api_key
|
|
17
|
+
|
|
18
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
19
|
+
if self._api_key:
|
|
20
|
+
record.msg = str(record.getMessage()).replace(self._api_key, "[REDACTED]")
|
|
21
|
+
record.args = ()
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class LoggingState:
|
|
27
|
+
handlers: list[logging.Handler]
|
|
28
|
+
level: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def configure_logging(
|
|
32
|
+
*,
|
|
33
|
+
verbosity: int,
|
|
34
|
+
log_file: Path | None,
|
|
35
|
+
enable_file: bool,
|
|
36
|
+
api_key_for_redaction: str | None,
|
|
37
|
+
) -> LoggingState:
|
|
38
|
+
level = logging.WARNING
|
|
39
|
+
if verbosity >= 2:
|
|
40
|
+
level = logging.DEBUG
|
|
41
|
+
elif verbosity == 1:
|
|
42
|
+
level = logging.INFO
|
|
43
|
+
|
|
44
|
+
root = logging.getLogger()
|
|
45
|
+
previous = LoggingState(handlers=list(root.handlers), level=root.level)
|
|
46
|
+
root.setLevel(level)
|
|
47
|
+
|
|
48
|
+
# Avoid duplicate handlers when invoked multiple times (tests).
|
|
49
|
+
for h in list(root.handlers):
|
|
50
|
+
root.removeHandler(h)
|
|
51
|
+
with suppress(Exception):
|
|
52
|
+
h.close()
|
|
53
|
+
|
|
54
|
+
stderr_handler = logging.StreamHandler()
|
|
55
|
+
stderr_handler.setLevel(level)
|
|
56
|
+
stderr_handler.addFilter(_RedactFilter(api_key=api_key_for_redaction))
|
|
57
|
+
stderr_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
58
|
+
root.addHandler(stderr_handler)
|
|
59
|
+
|
|
60
|
+
if enable_file and log_file is not None:
|
|
61
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
file_handler = RotatingFileHandler(
|
|
63
|
+
log_file,
|
|
64
|
+
maxBytes=2_000_000,
|
|
65
|
+
backupCount=3,
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
file_handler.setLevel(logging.INFO if verbosity < 2 else logging.DEBUG)
|
|
69
|
+
file_handler.addFilter(_RedactFilter(api_key=api_key_for_redaction))
|
|
70
|
+
file_handler.setFormatter(
|
|
71
|
+
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
72
|
+
)
|
|
73
|
+
root.addHandler(file_handler)
|
|
74
|
+
|
|
75
|
+
return previous
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def restore_logging(state: LoggingState) -> None:
|
|
79
|
+
root = logging.getLogger()
|
|
80
|
+
for h in list(root.handlers):
|
|
81
|
+
root.removeHandler(h)
|
|
82
|
+
with suppress(Exception):
|
|
83
|
+
h.close()
|
|
84
|
+
for h in state.handlers:
|
|
85
|
+
root.addHandler(h)
|
|
86
|
+
root.setLevel(state.level)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def set_redaction_api_key(api_key: str | None) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Update any CLI-installed redaction filters with the resolved API key.
|
|
92
|
+
|
|
93
|
+
This avoids resolving credentials eagerly at process start (so no-network commands
|
|
94
|
+
stay no-network), while still providing defense-in-depth once credentials exist.
|
|
95
|
+
"""
|
|
96
|
+
root = logging.getLogger()
|
|
97
|
+
for handler in root.handlers:
|
|
98
|
+
for flt in handler.filters:
|
|
99
|
+
if isinstance(flt, _RedactFilter):
|
|
100
|
+
flt.set_api_key(api_key)
|
affinity/cli/main.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
7
|
+
|
|
8
|
+
import affinity
|
|
9
|
+
|
|
10
|
+
from .click_compat import RichGroup, click
|
|
11
|
+
from .context import CLIContext
|
|
12
|
+
from .logging import configure_logging, restore_logging
|
|
13
|
+
from .paths import get_paths
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _RootGroupMixin:
|
|
20
|
+
"""Mixin that adds --help --json support to the root CLI group."""
|
|
21
|
+
|
|
22
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
23
|
+
"""Override to support --help --json for machine-readable output."""
|
|
24
|
+
# Check if --json flag is present in args
|
|
25
|
+
if "--json" in sys.argv:
|
|
26
|
+
from .help_json import emit_help_json_and_exit
|
|
27
|
+
|
|
28
|
+
emit_help_json_and_exit(ctx)
|
|
29
|
+
|
|
30
|
+
# Standard help output
|
|
31
|
+
super().format_help(ctx, formatter) # type: ignore[misc]
|
|
32
|
+
|
|
33
|
+
def main(self, *args: Any, **kwargs: Any) -> Any:
|
|
34
|
+
"""Override main to handle --help --json before Click processes args."""
|
|
35
|
+
# Check for --help --json combination early
|
|
36
|
+
argv = sys.argv[1:]
|
|
37
|
+
if ("--help" in argv or "-h" in argv) and "--json" in argv:
|
|
38
|
+
# Create a minimal context and emit JSON help
|
|
39
|
+
with self.make_context("xaffinity", []) as ctx: # type: ignore[attr-defined]
|
|
40
|
+
from .help_json import emit_help_json_and_exit
|
|
41
|
+
|
|
42
|
+
emit_help_json_and_exit(ctx)
|
|
43
|
+
|
|
44
|
+
return super().main(*args, **kwargs) # type: ignore[misc]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Create RootGroup by mixing in the JSON help behavior with RichGroup
|
|
48
|
+
# This approach satisfies mypy since the base class is determined at import time
|
|
49
|
+
RootGroup: type[click.Group] = type("RootGroup", (_RootGroupMixin, RichGroup), {})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.group(
|
|
53
|
+
name="xaffinity",
|
|
54
|
+
invoke_without_command=True,
|
|
55
|
+
cls=RootGroup,
|
|
56
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--output",
|
|
60
|
+
type=click.Choice(["table", "json"]),
|
|
61
|
+
default="table",
|
|
62
|
+
help="Output format (table or json).",
|
|
63
|
+
)
|
|
64
|
+
@click.option("--json", "json_flag", is_flag=True, help="Alias for --output json.")
|
|
65
|
+
@click.option("-q", "--quiet", is_flag=True, help="Suppress non-essential stderr output.")
|
|
66
|
+
@click.option("-v", "verbose", count=True, help="Increase verbosity (-v, -vv).")
|
|
67
|
+
@click.option("--pager/--no-pager", default=None, help="Page table / long output when interactive.")
|
|
68
|
+
@click.option(
|
|
69
|
+
"--all-columns",
|
|
70
|
+
is_flag=True,
|
|
71
|
+
help="Show all table columns (disable auto-limiting based on terminal width).",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--max-columns",
|
|
75
|
+
type=int,
|
|
76
|
+
default=None,
|
|
77
|
+
help="Limit table output to N columns (default: auto based on terminal width).",
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--progress/--no-progress",
|
|
81
|
+
default=None,
|
|
82
|
+
help="Force enable/disable progress bars (stderr).",
|
|
83
|
+
)
|
|
84
|
+
@click.option("--profile", type=str, default=None, help="Config profile name.")
|
|
85
|
+
@click.option("--dotenv/--no-dotenv", default=False, help="Opt-in .env loading.")
|
|
86
|
+
@click.option(
|
|
87
|
+
"--env-file",
|
|
88
|
+
type=click.Path(dir_okay=False),
|
|
89
|
+
default=".env",
|
|
90
|
+
help="Path to .env file (used with --dotenv).",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--api-key-file",
|
|
94
|
+
type=str,
|
|
95
|
+
default=None,
|
|
96
|
+
help="Read API key from file (or '-' for stdin).",
|
|
97
|
+
)
|
|
98
|
+
@click.option("--api-key-stdin", is_flag=True, help="Alias for --api-key-file -.")
|
|
99
|
+
@click.option("--timeout", type=float, default=None, help="Per-request timeout in seconds.")
|
|
100
|
+
@click.option(
|
|
101
|
+
"--max-retries",
|
|
102
|
+
type=int,
|
|
103
|
+
default=3,
|
|
104
|
+
show_default=True,
|
|
105
|
+
help="Maximum retries for rate-limited requests.",
|
|
106
|
+
)
|
|
107
|
+
@click.option(
|
|
108
|
+
"--beta",
|
|
109
|
+
is_flag=True,
|
|
110
|
+
help="Enable beta endpoints (required for merge commands).",
|
|
111
|
+
)
|
|
112
|
+
@click.option(
|
|
113
|
+
"--readonly",
|
|
114
|
+
is_flag=True,
|
|
115
|
+
help="Disallow write operations (safety guard; affects all SDK calls).",
|
|
116
|
+
)
|
|
117
|
+
@click.option(
|
|
118
|
+
"--trace",
|
|
119
|
+
is_flag=True,
|
|
120
|
+
help="Trace request/response/error events to stderr (safe redaction).",
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--log-file", type=click.Path(dir_okay=False), default=None, help="Override log file path."
|
|
124
|
+
)
|
|
125
|
+
@click.option("--no-log-file", is_flag=True, help="Disable file logging explicitly.")
|
|
126
|
+
@click.option(
|
|
127
|
+
"--session-cache",
|
|
128
|
+
type=click.Path(file_okay=False),
|
|
129
|
+
default=None,
|
|
130
|
+
help="Enable session caching using the specified directory.",
|
|
131
|
+
)
|
|
132
|
+
@click.option("--no-cache", is_flag=True, help="Disable session caching.")
|
|
133
|
+
@click.version_option(version=affinity.__version__, prog_name="xaffinity")
|
|
134
|
+
@click.pass_context
|
|
135
|
+
def cli(
|
|
136
|
+
click_ctx: click.Context,
|
|
137
|
+
*,
|
|
138
|
+
output: str,
|
|
139
|
+
json_flag: bool,
|
|
140
|
+
quiet: bool,
|
|
141
|
+
verbose: int,
|
|
142
|
+
pager: bool | None,
|
|
143
|
+
all_columns: bool,
|
|
144
|
+
max_columns: int | None,
|
|
145
|
+
progress: bool | None,
|
|
146
|
+
profile: str | None,
|
|
147
|
+
dotenv: bool,
|
|
148
|
+
env_file: str,
|
|
149
|
+
api_key_file: str | None,
|
|
150
|
+
api_key_stdin: bool,
|
|
151
|
+
timeout: float | None,
|
|
152
|
+
max_retries: int,
|
|
153
|
+
beta: bool,
|
|
154
|
+
readonly: bool,
|
|
155
|
+
trace: bool,
|
|
156
|
+
log_file: str | None,
|
|
157
|
+
no_log_file: bool,
|
|
158
|
+
session_cache: str | None,
|
|
159
|
+
no_cache: bool,
|
|
160
|
+
) -> None:
|
|
161
|
+
if click_ctx.invoked_subcommand is None:
|
|
162
|
+
# No args: show help; no network calls.
|
|
163
|
+
click.echo(click_ctx.get_help())
|
|
164
|
+
raise click.exceptions.Exit(0)
|
|
165
|
+
|
|
166
|
+
out = "json" if json_flag else output
|
|
167
|
+
progress_mode: Literal["auto", "always", "never"] = "auto"
|
|
168
|
+
if progress is True:
|
|
169
|
+
progress_mode = "always"
|
|
170
|
+
if progress is False:
|
|
171
|
+
progress_mode = "never"
|
|
172
|
+
if trace and progress is None:
|
|
173
|
+
progress_mode = "never"
|
|
174
|
+
|
|
175
|
+
paths = get_paths()
|
|
176
|
+
effective_log_file = Path(log_file) if log_file else paths.log_file
|
|
177
|
+
enable_log_file = not no_log_file
|
|
178
|
+
|
|
179
|
+
# Set session cache environment variable if --session-cache flag is passed
|
|
180
|
+
# This ensures SessionCacheConfig picks up the value via its standard environment check
|
|
181
|
+
if session_cache:
|
|
182
|
+
os.environ["AFFINITY_SESSION_CACHE"] = session_cache
|
|
183
|
+
|
|
184
|
+
click_ctx.obj = CLIContext(
|
|
185
|
+
output=out, # type: ignore[arg-type]
|
|
186
|
+
quiet=quiet,
|
|
187
|
+
verbosity=verbose,
|
|
188
|
+
pager=pager,
|
|
189
|
+
progress=progress_mode,
|
|
190
|
+
profile=profile,
|
|
191
|
+
dotenv=dotenv,
|
|
192
|
+
env_file=Path(env_file),
|
|
193
|
+
api_key_file=api_key_file,
|
|
194
|
+
api_key_stdin=api_key_stdin,
|
|
195
|
+
timeout=timeout,
|
|
196
|
+
max_retries=max_retries,
|
|
197
|
+
enable_beta_endpoints=beta,
|
|
198
|
+
readonly=readonly,
|
|
199
|
+
trace=trace,
|
|
200
|
+
log_file=effective_log_file,
|
|
201
|
+
enable_log_file=enable_log_file,
|
|
202
|
+
all_columns=all_columns,
|
|
203
|
+
max_columns=max_columns,
|
|
204
|
+
_paths=paths,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Set no_cache flag on context
|
|
208
|
+
if no_cache:
|
|
209
|
+
click_ctx.obj._no_cache = True
|
|
210
|
+
|
|
211
|
+
click_ctx.call_on_close(click_ctx.obj.close)
|
|
212
|
+
|
|
213
|
+
previous_logging = configure_logging(
|
|
214
|
+
verbosity=verbose,
|
|
215
|
+
log_file=effective_log_file,
|
|
216
|
+
enable_file=enable_log_file,
|
|
217
|
+
api_key_for_redaction=None,
|
|
218
|
+
)
|
|
219
|
+
click_ctx.call_on_close(lambda: restore_logging(previous_logging))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Register commands
|
|
223
|
+
from .commands.company_cmds import company_group as _company_group
|
|
224
|
+
from .commands.completion_cmd import completion_cmd as _completion_cmd
|
|
225
|
+
from .commands.config_cmds import config_group as _config_group
|
|
226
|
+
from .commands.entry_cmds import entry_group as _entry_group
|
|
227
|
+
from .commands.field_cmds import field_group as _field_group
|
|
228
|
+
from .commands.interaction_cmds import interaction_group as _interaction_group
|
|
229
|
+
from .commands.list_cmds import list_group as _list_group
|
|
230
|
+
from .commands.note_cmds import note_group as _note_group
|
|
231
|
+
from .commands.opportunity_cmds import opportunity_group as _opportunity_group
|
|
232
|
+
from .commands.person_cmds import person_group as _person_group
|
|
233
|
+
from .commands.query_cmd import query_cmd as _query_cmd
|
|
234
|
+
from .commands.relationship_strength_cmds import (
|
|
235
|
+
relationship_strength_group as _relationship_strength_group,
|
|
236
|
+
)
|
|
237
|
+
from .commands.reminder_cmds import reminder_group as _reminder_group
|
|
238
|
+
from .commands.resolve_url_cmd import resolve_url_cmd as _resolve_url_cmd
|
|
239
|
+
from .commands.session_cmds import session_group as _session_group
|
|
240
|
+
from .commands.task_cmds import task_group as _task_group
|
|
241
|
+
from .commands.version_cmd import version_cmd as _version_cmd
|
|
242
|
+
from .commands.whoami_cmd import whoami_cmd as _whoami_cmd
|
|
243
|
+
|
|
244
|
+
cli.add_command(_completion_cmd)
|
|
245
|
+
cli.add_command(_version_cmd)
|
|
246
|
+
cli.add_command(_config_group)
|
|
247
|
+
cli.add_command(_whoami_cmd)
|
|
248
|
+
cli.add_command(_resolve_url_cmd)
|
|
249
|
+
cli.add_command(_person_group)
|
|
250
|
+
cli.add_command(_company_group)
|
|
251
|
+
cli.add_command(_opportunity_group)
|
|
252
|
+
cli.add_command(_list_group)
|
|
253
|
+
cli.add_command(_entry_group)
|
|
254
|
+
cli.add_command(_note_group)
|
|
255
|
+
cli.add_command(_reminder_group)
|
|
256
|
+
cli.add_command(_interaction_group)
|
|
257
|
+
cli.add_command(_field_group)
|
|
258
|
+
cli.add_command(_relationship_strength_group)
|
|
259
|
+
cli.add_command(_session_group)
|
|
260
|
+
cli.add_command(_task_group)
|
|
261
|
+
cli.add_command(_query_cmd)
|
affinity/cli/options.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from .click_compat import click
|
|
7
|
+
from .context import CLIContext
|
|
8
|
+
|
|
9
|
+
F = TypeVar("F", bound=Callable[..., object])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _set_output(ctx: click.Context, _param: click.Parameter, value: str | None) -> str | None:
|
|
13
|
+
if value is None:
|
|
14
|
+
return value
|
|
15
|
+
obj = ctx.obj
|
|
16
|
+
if isinstance(obj, CLIContext):
|
|
17
|
+
obj.output = value # type: ignore[assignment]
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _set_json(ctx: click.Context, _param: click.Parameter, value: bool) -> bool:
|
|
22
|
+
if not value:
|
|
23
|
+
return value
|
|
24
|
+
obj = ctx.obj
|
|
25
|
+
if isinstance(obj, CLIContext):
|
|
26
|
+
obj.output = "json"
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def output_options(fn: F) -> F:
|
|
31
|
+
"""Add output format options to a command.
|
|
32
|
+
|
|
33
|
+
Adds --output/-o and --json flags. Note: --csv is NOT included here
|
|
34
|
+
because individual commands have their own --csv flags with additional
|
|
35
|
+
options (--csv-bom, --csv-header, --csv-mode).
|
|
36
|
+
"""
|
|
37
|
+
fn = click.option(
|
|
38
|
+
"--output",
|
|
39
|
+
"-o",
|
|
40
|
+
type=click.Choice(["table", "json", "jsonl", "markdown", "toon", "csv"]),
|
|
41
|
+
default=None,
|
|
42
|
+
help="Output format (default: table for terminal, json for pipes).",
|
|
43
|
+
callback=_set_output,
|
|
44
|
+
expose_value=False,
|
|
45
|
+
)(fn)
|
|
46
|
+
fn = click.option(
|
|
47
|
+
"--json",
|
|
48
|
+
is_flag=True,
|
|
49
|
+
help="Alias for --output json.",
|
|
50
|
+
callback=_set_json,
|
|
51
|
+
expose_value=False,
|
|
52
|
+
)(fn)
|
|
53
|
+
return fn
|