footprinter-cli 1.0.0__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 (134) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +444 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/cli/__init__.py +128 -0
  19. footprinter/cli/__main__.py +6 -0
  20. footprinter/cli/_common.py +332 -0
  21. footprinter/cli/_policy_helpers.py +646 -0
  22. footprinter/cli/_prompt.py +220 -0
  23. footprinter/cli/api_cmd.py +32 -0
  24. footprinter/cli/connect.py +591 -0
  25. footprinter/cli/data.py +879 -0
  26. footprinter/cli/delete.py +128 -0
  27. footprinter/cli/ingest.py +579 -0
  28. footprinter/cli/mcp_cmd.py +750 -0
  29. footprinter/cli/mcp_setup.py +306 -0
  30. footprinter/cli/search.py +393 -0
  31. footprinter/cli/search_cmd.py +69 -0
  32. footprinter/cli/setup.py +1836 -0
  33. footprinter/cli/status.py +729 -0
  34. footprinter/cli/status_cmd.py +104 -0
  35. footprinter/cli/upsert.py +794 -0
  36. footprinter/cli/vectorize_cmd.py +215 -0
  37. footprinter/cli/view.py +322 -0
  38. footprinter/connectors/__init__.py +171 -0
  39. footprinter/connectors/config_utils.py +141 -0
  40. footprinter/db/__init__.py +37 -0
  41. footprinter/db/browser.py +198 -0
  42. footprinter/db/chats.py +610 -0
  43. footprinter/db/clients.py +307 -0
  44. footprinter/db/emails.py +279 -0
  45. footprinter/db/files.py +741 -0
  46. footprinter/db/folders.py +659 -0
  47. footprinter/db/messages.py +192 -0
  48. footprinter/db/policies.py +151 -0
  49. footprinter/db/projects.py +673 -0
  50. footprinter/db/search.py +573 -0
  51. footprinter/db/sql_utils.py +168 -0
  52. footprinter/db/status.py +320 -0
  53. footprinter/db/uploads.py +70 -0
  54. footprinter/ingest/__init__.py +0 -0
  55. footprinter/ingest/adapters/__init__.py +33 -0
  56. footprinter/ingest/adapters/browser.py +54 -0
  57. footprinter/ingest/adapters/chat.py +57 -0
  58. footprinter/ingest/adapters/ingest.py +146 -0
  59. footprinter/ingest/adapters/local_files.py +68 -0
  60. footprinter/ingest/adapters/local_folders.py +52 -0
  61. footprinter/ingest/adapters/protocol.py +174 -0
  62. footprinter/ingest/browser_indexer.py +216 -0
  63. footprinter/ingest/chat_dedup.py +156 -0
  64. footprinter/ingest/chat_indexer.py +515 -0
  65. footprinter/ingest/chat_parsers/__init__.py +8 -0
  66. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  67. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  68. footprinter/ingest/cli.py +827 -0
  69. footprinter/ingest/content_extractors.py +117 -0
  70. footprinter/ingest/database.py +36 -0
  71. footprinter/ingest/db/__init__.py +1 -0
  72. footprinter/ingest/db/connector_schema.py +47 -0
  73. footprinter/ingest/db/migration.py +328 -0
  74. footprinter/ingest/db/schema.py +1043 -0
  75. footprinter/ingest/db/security.py +6 -0
  76. footprinter/ingest/file_indexer.py +261 -0
  77. footprinter/ingest/file_scanner.py +277 -0
  78. footprinter/ingest/folder_indexer.py +226 -0
  79. footprinter/ingest/full_content_extractor.py +321 -0
  80. footprinter/ingest/orchestrator.py +125 -0
  81. footprinter/ingest/pipe_runner.py +217 -0
  82. footprinter/ingest/processing.py +165 -0
  83. footprinter/ingest/registry.py +201 -0
  84. footprinter/ingest/run_record.py +91 -0
  85. footprinter/ingest/status.py +346 -0
  86. footprinter/mcp/__init__.py +0 -0
  87. footprinter/mcp/__main__.py +5 -0
  88. footprinter/mcp/db.py +57 -0
  89. footprinter/mcp/errors.py +102 -0
  90. footprinter/mcp/extraction.py +226 -0
  91. footprinter/mcp/server.py +39 -0
  92. footprinter/mcp/tools/__init__.py +0 -0
  93. footprinter/mcp/tools/navigation.py +70 -0
  94. footprinter/mcp/tools/read.py +75 -0
  95. footprinter/mcp/tools/search.py +158 -0
  96. footprinter/mcp/tools/semantic.py +79 -0
  97. footprinter/mcp/tools/status.py +15 -0
  98. footprinter/paths.py +91 -0
  99. footprinter/permissions.py +1160 -0
  100. footprinter/semantic/__init__.py +13 -0
  101. footprinter/semantic/chunking.py +52 -0
  102. footprinter/semantic/embeddings.py +23 -0
  103. footprinter/semantic/hybrid_search.py +273 -0
  104. footprinter/semantic/vector_store.py +471 -0
  105. footprinter/services/__init__.py +49 -0
  106. footprinter/services/access_service.py +342 -0
  107. footprinter/services/chat_service.py +85 -0
  108. footprinter/services/client_service.py +267 -0
  109. footprinter/services/content_service.py +181 -0
  110. footprinter/services/email_service.py +89 -0
  111. footprinter/services/file_service.py +83 -0
  112. footprinter/services/folder_service.py +122 -0
  113. footprinter/services/includes.py +19 -0
  114. footprinter/services/ingest_service.py +231 -0
  115. footprinter/services/project_service.py +262 -0
  116. footprinter/services/roles.py +25 -0
  117. footprinter/services/search_service.py +177 -0
  118. footprinter/services/semantic_service.py +360 -0
  119. footprinter/services/status_service.py +18 -0
  120. footprinter/services/visit_service.py +65 -0
  121. footprinter/source_registry.py +194 -0
  122. footprinter/utils/__init__.py +7 -0
  123. footprinter/utils/hash_utils.py +59 -0
  124. footprinter/utils/logging_config.py +68 -0
  125. footprinter/utils/mime.py +30 -0
  126. footprinter/utils/text.py +6 -0
  127. footprinter/utils/time.py +11 -0
  128. footprinter/visibility.py +1272 -0
  129. footprinter_cli-1.0.0.dist-info/LICENSE +21 -0
  130. footprinter_cli-1.0.0.dist-info/METADATA +229 -0
  131. footprinter_cli-1.0.0.dist-info/RECORD +134 -0
  132. footprinter_cli-1.0.0.dist-info/WHEEL +5 -0
  133. footprinter_cli-1.0.0.dist-info/entry_points.txt +2 -0
  134. footprinter_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,591 @@
1
+ """fp connect — manage optional integrations.
2
+
3
+ Discover, install, and remove connectors
4
+ without needing to know about pip extras.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from footprinter.cli._common import FORMATTER, add_json_flag, console, output_json
12
+ from footprinter.cli._prompt import PromptCancelled
13
+ from footprinter.cli._prompt import SafeConfirm as Confirm
14
+ from footprinter.connectors import (
15
+ ConnectorSpec,
16
+ discover_connectors,
17
+ get_status,
18
+ is_configured,
19
+ is_installed,
20
+ resolve_check_auth,
21
+ resolve_hook,
22
+ )
23
+ from footprinter.connectors.config_utils import account_label
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # argparse registration
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def register(subparsers) -> None:
31
+ """Register the ``connect`` subcommand and its verbs."""
32
+ parser = subparsers.add_parser(
33
+ "connect",
34
+ help="Manage optional integrations",
35
+ description=(
36
+ "Discover, install, and remove data source connectors.\n\n"
37
+ "Connectors add support for external data sources.\n"
38
+ "Each connector installs its own dependencies and runs\n"
39
+ "a setup wizard."
40
+ ),
41
+ epilog=(
42
+ "examples:\n"
43
+ " fp connect list Show all connectors with status\n"
44
+ " fp connect install <name> Install a connector\n"
45
+ " fp connect config <name> Reconfigure a connector\n"
46
+ " fp connect status <name> Detailed state and credentials\n"
47
+ " fp connect remove <name> Uninstall connector packages\n"
48
+ "\n"
49
+ "tip: use 'fp connect <command> --help' for details on any command."
50
+ ),
51
+ formatter_class=FORMATTER,
52
+ )
53
+
54
+ def _connect_base(args, _parser=parser):
55
+ if not discover_connectors():
56
+ console.print(
57
+ "Connectors add support for external data sources.\n"
58
+ "Each connector is a separate package.\n\n"
59
+ "No connectors are installed.\n\n"
60
+ "Learn more: https://github.com/swellcitygroup/footprinter"
61
+ )
62
+ return
63
+ _parser.print_help()
64
+
65
+ parser.set_defaults(func=_connect_base)
66
+
67
+ subs = parser.add_subparsers(dest="verb", metavar="COMMAND", title="commands (one required)")
68
+
69
+ # list
70
+ p_list = subs.add_parser(
71
+ "list",
72
+ help="Show all connectors with status",
73
+ description="Show all available connectors with installed/configured status.",
74
+ formatter_class=FORMATTER,
75
+ )
76
+ add_json_flag(p_list)
77
+ p_list.set_defaults(func=_cmd_list)
78
+
79
+ # install
80
+ p_install = subs.add_parser(
81
+ "install",
82
+ help="Install a connector",
83
+ description=(
84
+ "Install a connector's dependencies and run its setup wizard.\n\n"
85
+ "Installs pip extras and configures OAuth credentials."
86
+ ),
87
+ epilog=("examples:\n fp connect install <name> Install the <name> connector"),
88
+ formatter_class=FORMATTER,
89
+ )
90
+ p_install.add_argument("name", nargs="?", default=None, help="Connector name (from fp connect list)")
91
+ p_install.set_defaults(func=_cmd_install)
92
+
93
+ # remove
94
+ p_remove = subs.add_parser(
95
+ "remove",
96
+ help="Remove a connector",
97
+ description="Uninstall a connector's packages and disable in config.",
98
+ epilog=("examples:\n fp connect remove <name> Remove a connector and its packages"),
99
+ formatter_class=FORMATTER,
100
+ )
101
+ p_remove.add_argument("name", nargs="?", default=None, help="Connector name (from fp connect list)")
102
+ p_remove.set_defaults(func=_cmd_remove)
103
+
104
+ # status
105
+ p_status = subs.add_parser(
106
+ "status",
107
+ help="Show detailed connector state",
108
+ description=(
109
+ "Show detailed status for one or all connectors.\n\n"
110
+ "Includes install state, config, credentials, and account details."
111
+ ),
112
+ epilog=(
113
+ "examples:\n fp connect status All connectors\n fp connect status <name> Single connector"
114
+ ),
115
+ formatter_class=FORMATTER,
116
+ )
117
+ p_status.add_argument("name", nargs="?", default=None, help="Connector name (omit for all)")
118
+ add_json_flag(p_status)
119
+ p_status.set_defaults(func=_cmd_status)
120
+
121
+ # config
122
+ p_config = subs.add_parser(
123
+ "config",
124
+ help="Reconfigure an installed connector",
125
+ description=(
126
+ "Reconfigure an installed connector's settings.\n\n"
127
+ "Opens the setup wizard in reconfiguration mode —\n"
128
+ "skips dependency install, shows existing accounts,\n"
129
+ "and allows adding or modifying accounts."
130
+ ),
131
+ epilog=("examples:\n fp connect config <name> Reconfigure a connector"),
132
+ formatter_class=FORMATTER,
133
+ )
134
+ p_config.add_argument("name", nargs="?", default=None, help="Connector name (from fp connect list)")
135
+ p_config.set_defaults(func=_cmd_config)
136
+
137
+ # label
138
+ p_label = subs.add_parser(
139
+ "label",
140
+ help="Set a display label for a connector account",
141
+ description=(
142
+ "Set a user-facing display label for a connector account.\n\n"
143
+ "The internal account name stays unchanged — only the\n"
144
+ "label shown in CLI output and status displays changes."
145
+ ),
146
+ epilog=("examples:\n fp connect label <name> work Consulting\n fp connect label <name> personal Family"),
147
+ formatter_class=FORMATTER,
148
+ )
149
+ p_label.add_argument("name", nargs="?", default=None, help="Connector name (from fp connect list)")
150
+ p_label.add_argument("account", nargs="?", default=None, help="Account name (e.g. work, personal)")
151
+ p_label.add_argument("label", nargs="?", default=None, help="New display label")
152
+
153
+ def _label_handler(args, _parser=p_label) -> None:
154
+ if args.name is None or args.account is None or args.label is None:
155
+ connectors = discover_connectors()
156
+ if connectors:
157
+ console.print("Available connectors:\n")
158
+ for spec in connectors.values():
159
+ console.print(f" [bold]{spec.name}[/bold] — {spec.description}")
160
+ console.print("\nUsage: [bold]fp connect label <name> <account> <label>[/bold]")
161
+ else:
162
+ _parser.print_help()
163
+ return
164
+ _cmd_label(args)
165
+
166
+ p_label.set_defaults(func=_label_handler)
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Handlers
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def _cmd_list(args) -> None:
175
+ """Show all connectors with installed/configured status."""
176
+ from footprinter.source_registry import ConfigError, get_config
177
+
178
+ try:
179
+ config = get_config()
180
+ except ConfigError:
181
+ config = {}
182
+
183
+ connectors = discover_connectors()
184
+
185
+ if not connectors:
186
+ if getattr(args, "json", False):
187
+ output_json([])
188
+ return
189
+ console.print(
190
+ "No connectors installed.\n\n"
191
+ "Connectors are separate packages that plug into Footprinter's\n"
192
+ "ingest pipeline. To get started, visit:\n"
193
+ " https://github.com/swellcitygroup/footprinter"
194
+ )
195
+ return
196
+
197
+ if getattr(args, "json", False):
198
+ rows = []
199
+ for spec in connectors.values():
200
+ rows.append(
201
+ {
202
+ "name": spec.name,
203
+ "description": spec.description,
204
+ "status": get_status(spec, config),
205
+ }
206
+ )
207
+ output_json(rows)
208
+ return
209
+
210
+ from rich.table import Table
211
+
212
+ _STATUS_STYLE = {
213
+ "installed": "[green]installed[/green]",
214
+ "available": "[yellow]available[/yellow]",
215
+ "not available": "[dim]not available[/dim]",
216
+ }
217
+
218
+ table = Table(title="Connectors")
219
+ table.add_column("Name", style="bold")
220
+ table.add_column("Description")
221
+ table.add_column("Status")
222
+
223
+ for spec in connectors.values():
224
+ status = get_status(spec, config)
225
+ table.add_row(spec.name, spec.description, _STATUS_STYLE.get(status, status))
226
+
227
+ console.print(table)
228
+
229
+
230
+ def _cmd_install(args) -> None:
231
+ """Install a connector's dependencies and run its setup hook."""
232
+ import subprocess
233
+
234
+ name = args.name
235
+ connectors = discover_connectors()
236
+ if name is None:
237
+ if connectors:
238
+ console.print("Available connectors:\n")
239
+ for spec in connectors.values():
240
+ console.print(f" [bold]{spec.name}[/bold] — {spec.description}")
241
+ console.print("\nInstall one: [bold]fp connect install <name>[/bold]")
242
+ else:
243
+ console.print("No connectors available.\n\nLearn more: https://github.com/swellcitygroup/footprinter")
244
+ return
245
+ spec = connectors.get(name)
246
+ if spec is None:
247
+ console.print(f"[red]Unknown connector:[/red] {name}")
248
+ console.print(f"Available: {', '.join(connectors)}")
249
+ sys.exit(1)
250
+
251
+ already = is_installed(spec)
252
+ if already:
253
+ console.print(f"[green]{name}[/green] is already installed.")
254
+
255
+ # Check if already configured — prompt before reconfiguring
256
+ configured = is_configured(spec, _load_config())
257
+ if configured:
258
+ try:
259
+ if not Confirm.ask(
260
+ f" [bold]{name}[/bold] is already configured. Reconfigure?",
261
+ default=False,
262
+ ):
263
+ console.print(" [dim]Keeping current configuration.[/dim]")
264
+ return
265
+ except PromptCancelled:
266
+ console.print("\n [dim]Keeping current configuration.[/dim]")
267
+ return
268
+ else:
269
+ console.print(f"Installing [bold]{name}[/bold] dependencies...")
270
+ try:
271
+ subprocess.check_call(
272
+ [sys.executable, "-m", "pip", "install", f"footprinter-cli[{spec.extra}]"],
273
+ )
274
+ except (subprocess.CalledProcessError, OSError) as e:
275
+ console.print(f"[red]Install failed:[/red] {e}")
276
+ sys.exit(1)
277
+
278
+ if not is_installed(spec):
279
+ console.print(f"[red]Install failed:[/red] {spec.probe_module} not importable after pip install")
280
+ sys.exit(1)
281
+
282
+ console.print(f"[green]{name} dependencies installed.[/green]")
283
+
284
+ # Run setup hook
285
+ try:
286
+ result = _resolve_setup_hook(spec, reconfigure=already)
287
+ if result:
288
+ _update_config_enabled(result, spec)
289
+ except (
290
+ Exception
291
+ ) as e: # Intentional broad catch: setup hook loading is unpredictable (dynamic import + user config)
292
+ console.print(f"[red]Setup failed:[/red] {e}")
293
+ sys.exit(1)
294
+
295
+
296
+ def _cmd_config(args) -> None:
297
+ """Reconfigure an installed connector's settings."""
298
+ name = args.name
299
+ connectors = discover_connectors()
300
+ if name is None:
301
+ installed = {n: s for n, s in connectors.items() if is_installed(s)}
302
+ if installed:
303
+ console.print("Installed connectors:\n")
304
+ for spec in installed.values():
305
+ console.print(f" [bold]{spec.name}[/bold] — {spec.description}")
306
+ console.print("\nConfigure one: [bold]fp connect config <name>[/bold]")
307
+ else:
308
+ console.print("No connectors are installed.\n\nInstall one first: [bold]fp connect install <name>[/bold]")
309
+ return
310
+ spec = connectors.get(name)
311
+ if spec is None:
312
+ console.print(f"[red]Unknown connector:[/red] {name}")
313
+ console.print(f"Available: {', '.join(connectors)}")
314
+ sys.exit(1)
315
+
316
+ if not is_installed(spec):
317
+ console.print(f"[dim]{name} is not installed.[/dim]")
318
+ console.print(f" Install first: [bold]fp connect install {name}[/bold]")
319
+ sys.exit(1)
320
+
321
+ try:
322
+ result = _resolve_setup_hook(spec, reconfigure=True)
323
+ if result:
324
+ _update_config_enabled(result, spec)
325
+ except (
326
+ Exception
327
+ ) as e: # Intentional broad catch: setup hook loading is unpredictable (dynamic import + user config)
328
+ console.print(f"[red]Setup failed:[/red] {e}")
329
+ sys.exit(1)
330
+
331
+
332
+ def _resolve_setup_hook(spec, **kwargs):
333
+ """Resolve and call the connector's setup hook. Returns hook result."""
334
+ hook_fn = resolve_hook(spec.setup_hook)
335
+ if hook_fn is None:
336
+ return None
337
+ return hook_fn(**kwargs)
338
+
339
+
340
+ def _update_config_enabled(result: dict, spec: ConnectorSpec) -> None:
341
+ """Update config enabled flags based on setup hook result.
342
+
343
+ Args:
344
+ result: Dict like {"personal": {"services": ["drive", "gmail"], "root_folder_id": "..."}}.
345
+ spec: The ConnectorSpec whose config_apply hook to call.
346
+ """
347
+ from footprinter.cli.setup import _require_config, write_config
348
+
349
+ config, config_path = _require_config()
350
+ if spec.config_apply:
351
+ fn = resolve_hook(spec.config_apply)
352
+ if fn:
353
+ fn(config, result)
354
+ write_config(config, config_path)
355
+
356
+
357
+ def _cmd_remove(args) -> None:
358
+ """Remove a connector's dependencies and disable in config."""
359
+ import subprocess
360
+
361
+ name = args.name
362
+ connectors = discover_connectors()
363
+ if name is None:
364
+ installed = {n: s for n, s in connectors.items() if is_installed(s)}
365
+ if installed:
366
+ console.print("Installed connectors:\n")
367
+ for spec in installed.values():
368
+ console.print(f" [bold]{spec.name}[/bold] — {spec.description}")
369
+ console.print("\nRemove one: [bold]fp connect remove <name>[/bold]")
370
+ else:
371
+ console.print("No connectors are installed.")
372
+ return
373
+ spec = connectors.get(name)
374
+ if spec is None:
375
+ console.print(f"[red]Unknown connector:[/red] {name}")
376
+ console.print(f"Available: {', '.join(connectors)}")
377
+ sys.exit(1)
378
+
379
+ if not is_installed(spec):
380
+ console.print(f"[dim]{name} is not installed.[/dim]")
381
+ return
382
+
383
+ console.print(f"Removing [bold]{name}[/bold] packages...")
384
+ try:
385
+ subprocess.check_call(
386
+ [sys.executable, "-m", "pip", "uninstall", "-y", *spec.remove_packages],
387
+ )
388
+ except (subprocess.CalledProcessError, OSError) as e:
389
+ console.print(f"[red]Uninstall failed:[/red] {e}")
390
+ sys.exit(1)
391
+
392
+ _disable_config_sections(spec)
393
+ console.print(f"[green]{name} removed.[/green]")
394
+
395
+
396
+ def _disable_config_sections(spec) -> None:
397
+ """Disable config sections for a connector."""
398
+ from footprinter.cli.setup import _require_config, write_config
399
+
400
+ try:
401
+ config, config_path = _require_config()
402
+ except SystemExit:
403
+ return
404
+
405
+ for section in spec.config_sections:
406
+ if section in config:
407
+ config[section]["enabled"] = False
408
+ write_config(config, config_path)
409
+
410
+
411
+ def _cmd_status(args) -> None:
412
+ """Show detailed connector state."""
413
+ name = getattr(args, "name", None)
414
+ config = _load_config()
415
+ connectors = discover_connectors()
416
+
417
+ if name:
418
+ spec = connectors.get(name)
419
+ if spec is None:
420
+ console.print(f"[red]Unknown connector:[/red] {name}")
421
+ sys.exit(1)
422
+ specs = [spec]
423
+ else:
424
+ specs = list(connectors.values())
425
+
426
+ if getattr(args, "json", False):
427
+ if len(specs) == 1:
428
+ output_json(_status_dict(specs[0], config))
429
+ else:
430
+ output_json([_status_dict(s, config) for s in specs])
431
+ return
432
+
433
+ for spec in specs:
434
+ _print_status_panel(spec, config)
435
+
436
+
437
+ def _load_config() -> dict:
438
+ """Load config, returning empty dict on failure."""
439
+ from footprinter.source_registry import ConfigError, get_config
440
+
441
+ try:
442
+ return get_config()
443
+ except ConfigError:
444
+ return {}
445
+
446
+
447
+ def _resolve_auth_label(spec: ConnectorSpec, config: dict) -> str:
448
+ """Resolve auth status: check_auth callable if available, else credential existence."""
449
+ from footprinter.connectors import has_credentials
450
+
451
+ auth_result = resolve_check_auth(spec, config)
452
+ if auth_result is not None:
453
+ return auth_result
454
+ return "credentials found" if has_credentials(spec, config) else "no credentials"
455
+
456
+
457
+ def _status_dict(spec: ConnectorSpec, config: dict) -> dict:
458
+ """Build a status dict for a connector."""
459
+ from footprinter.connectors import has_credentials as _has_creds
460
+
461
+ creds = _has_creds(spec, config)
462
+ auth = _resolve_auth_label(spec, config)
463
+
464
+ accounts = []
465
+ if is_configured(spec, config):
466
+ for section in spec.config_sections:
467
+ if section in config:
468
+ for acct in config[section].get("accounts", []):
469
+ token_path = acct.get("token_path", "")
470
+ expanded = Path(os.path.expanduser(token_path))
471
+ accounts.append(
472
+ {
473
+ "name": acct.get("name", ""),
474
+ "label": account_label(acct),
475
+ "section": section,
476
+ "token_path": str(expanded),
477
+ "token_exists": expanded.exists(),
478
+ }
479
+ )
480
+
481
+ return {
482
+ "name": spec.name,
483
+ "description": spec.description,
484
+ "status": get_status(spec, config),
485
+ "auth": auth,
486
+ "credentials": creds,
487
+ "pipes": list(spec.pipes),
488
+ "accounts": accounts,
489
+ }
490
+
491
+
492
+ def _print_status_panel(spec: ConnectorSpec, config: dict) -> None:
493
+ """Print a Rich panel with connector status."""
494
+ from rich.panel import Panel
495
+
496
+ status = get_status(spec, config)
497
+ status_style = {
498
+ "installed": "[green]installed[/green]",
499
+ "available": "[yellow]available[/yellow]",
500
+ "not available": "[red]not available[/red]",
501
+ }
502
+
503
+ auth_style = {
504
+ "authenticated": "[green]authenticated[/green]",
505
+ "expired": "[yellow]expired[/yellow]",
506
+ "error": "[red]error[/red]",
507
+ "no credentials": "[red]no credentials[/red]",
508
+ "credentials found": "[dim]credentials found[/dim]",
509
+ }
510
+
511
+ auth = _resolve_auth_label(spec, config)
512
+
513
+ lines = []
514
+ lines.append(f" Status: {status_style.get(status, status)}")
515
+ lines.append(f" Auth: {auth_style.get(auth, auth)}")
516
+ lines.append(f" Pipes: {', '.join(spec.pipes)}")
517
+
518
+ # Account details — only when connector is configured
519
+ if is_configured(spec, config):
520
+ for section in spec.config_sections:
521
+ if section in config:
522
+ for acct in config[section].get("accounts", []):
523
+ token_path = Path(os.path.expanduser(acct.get("token_path", "")))
524
+ token_icon = "[green]yes[/green]" if token_path.exists() else "[red]no[/red]"
525
+ lines.append(f" {account_label(acct)} ({section}): token {token_icon}")
526
+
527
+ if status == "not available":
528
+ lines.append(f"\n Install: [bold]fp connect install {spec.name}[/bold]")
529
+ elif status == "available":
530
+ lines.append(f"\n Configure: [bold]fp connect install {spec.name}[/bold]")
531
+
532
+ console.print(
533
+ Panel(
534
+ "\n".join(lines),
535
+ title=f"[bold]{spec.name}[/bold] — {spec.description}",
536
+ )
537
+ )
538
+
539
+
540
+ def _require_config_for_label() -> tuple[dict, str]:
541
+ """Load config for the label command. Separate function for testability."""
542
+ from footprinter.cli.setup import _require_config
543
+
544
+ return _require_config()
545
+
546
+
547
+ def _cmd_label(args) -> None:
548
+ """Set a display label for a connector account."""
549
+ from footprinter.cli.setup import write_config
550
+
551
+ name = args.name
552
+ connectors = discover_connectors()
553
+ spec = connectors.get(name)
554
+ if spec is None:
555
+ console.print(f"[red]Unknown connector:[/red] {name}")
556
+ console.print(f"Available: {', '.join(connectors)}")
557
+ sys.exit(1)
558
+
559
+ config, config_path = _require_config_for_label()
560
+
561
+ acct_name = args.account
562
+ new_label = args.label
563
+ found = False
564
+
565
+ # Update account entries across all config sections for this connector
566
+ for section in spec.config_sections:
567
+ if section not in config:
568
+ continue
569
+ for acct in config[section].get("accounts", []):
570
+ if acct.get("name") == acct_name:
571
+ acct["label"] = new_label
572
+ found = True
573
+
574
+ if not found:
575
+ console.print(f"[red]Account not found:[/red] {acct_name}")
576
+ console.print(f" Run [bold]fp connect status {name}[/bold] to see configured accounts.")
577
+ sys.exit(1)
578
+
579
+ # Update matching source seed labels (scoped to this connector's seeds)
580
+ if spec.seed_prefix:
581
+ seed_name = f"{spec.seed_prefix}_{acct_name}"
582
+ for seed in config.get("source_seeds", []):
583
+ if seed.get("name") == seed_name and seed.get("source_type") == "remote":
584
+ if spec.seed_label_fn:
585
+ fn = resolve_hook(spec.seed_label_fn)
586
+ seed["label"] = fn(new_label) if fn else new_label
587
+ else:
588
+ seed["label"] = new_label
589
+
590
+ write_config(config, config_path)
591
+ console.print(f"[green]Label updated:[/green] {acct_name} → {new_label}")