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,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)
@@ -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)
@@ -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