footprinter-cli 1.0.0rc1__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 (138) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +431 -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/bundled/samples/hidden-client-file-sample.txt +2 -0
  19. footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
  20. footprinter/bundled/samples/visible-file-sample.txt +2 -0
  21. footprinter/cli/__init__.py +135 -0
  22. footprinter/cli/__main__.py +6 -0
  23. footprinter/cli/_common.py +327 -0
  24. footprinter/cli/_policy_helpers.py +646 -0
  25. footprinter/cli/_prompt.py +220 -0
  26. footprinter/cli/_sample_seed.py +204 -0
  27. footprinter/cli/api_cmd.py +32 -0
  28. footprinter/cli/connect.py +591 -0
  29. footprinter/cli/data.py +879 -0
  30. footprinter/cli/delete.py +128 -0
  31. footprinter/cli/ingest.py +543 -0
  32. footprinter/cli/mcp_cmd.py +750 -0
  33. footprinter/cli/mcp_setup.py +306 -0
  34. footprinter/cli/search.py +393 -0
  35. footprinter/cli/search_cmd.py +69 -0
  36. footprinter/cli/setup.py +2001 -0
  37. footprinter/cli/status.py +747 -0
  38. footprinter/cli/status_cmd.py +104 -0
  39. footprinter/cli/upsert.py +794 -0
  40. footprinter/cli/vectorize_cmd.py +215 -0
  41. footprinter/cli/view.py +322 -0
  42. footprinter/connectors/__init__.py +171 -0
  43. footprinter/connectors/config_utils.py +141 -0
  44. footprinter/db/__init__.py +37 -0
  45. footprinter/db/browser.py +198 -0
  46. footprinter/db/chats.py +602 -0
  47. footprinter/db/clients.py +307 -0
  48. footprinter/db/emails.py +279 -0
  49. footprinter/db/files.py +724 -0
  50. footprinter/db/folders.py +659 -0
  51. footprinter/db/messages.py +192 -0
  52. footprinter/db/policies.py +151 -0
  53. footprinter/db/projects.py +673 -0
  54. footprinter/db/search.py +573 -0
  55. footprinter/db/sql_utils.py +168 -0
  56. footprinter/db/status.py +320 -0
  57. footprinter/db/uploads.py +70 -0
  58. footprinter/ingest/__init__.py +0 -0
  59. footprinter/ingest/adapters/__init__.py +33 -0
  60. footprinter/ingest/adapters/browser.py +54 -0
  61. footprinter/ingest/adapters/chat.py +57 -0
  62. footprinter/ingest/adapters/ingest.py +146 -0
  63. footprinter/ingest/adapters/local_files.py +68 -0
  64. footprinter/ingest/adapters/local_folders.py +52 -0
  65. footprinter/ingest/adapters/protocol.py +174 -0
  66. footprinter/ingest/browser_indexer.py +216 -0
  67. footprinter/ingest/chat_dedup.py +156 -0
  68. footprinter/ingest/chat_indexer.py +487 -0
  69. footprinter/ingest/chat_parsers/__init__.py +8 -0
  70. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  71. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  72. footprinter/ingest/cli.py +827 -0
  73. footprinter/ingest/content_extractors.py +117 -0
  74. footprinter/ingest/database.py +36 -0
  75. footprinter/ingest/db/__init__.py +1 -0
  76. footprinter/ingest/db/connector_schema.py +47 -0
  77. footprinter/ingest/db/migration.py +315 -0
  78. footprinter/ingest/db/schema.py +1043 -0
  79. footprinter/ingest/db/security.py +6 -0
  80. footprinter/ingest/file_indexer.py +223 -0
  81. footprinter/ingest/file_scanner.py +277 -0
  82. footprinter/ingest/folder_indexer.py +226 -0
  83. footprinter/ingest/full_content_extractor.py +321 -0
  84. footprinter/ingest/orchestrator.py +112 -0
  85. footprinter/ingest/pipe_runner.py +200 -0
  86. footprinter/ingest/processing.py +165 -0
  87. footprinter/ingest/registry.py +186 -0
  88. footprinter/ingest/run_record.py +91 -0
  89. footprinter/ingest/status.py +346 -0
  90. footprinter/mcp/__init__.py +0 -0
  91. footprinter/mcp/__main__.py +5 -0
  92. footprinter/mcp/db.py +67 -0
  93. footprinter/mcp/errors.py +105 -0
  94. footprinter/mcp/extraction.py +226 -0
  95. footprinter/mcp/server.py +39 -0
  96. footprinter/mcp/tools/__init__.py +0 -0
  97. footprinter/mcp/tools/navigation.py +70 -0
  98. footprinter/mcp/tools/read.py +75 -0
  99. footprinter/mcp/tools/search.py +158 -0
  100. footprinter/mcp/tools/semantic.py +79 -0
  101. footprinter/mcp/tools/status.py +19 -0
  102. footprinter/paths.py +117 -0
  103. footprinter/permissions.py +1152 -0
  104. footprinter/semantic/__init__.py +13 -0
  105. footprinter/semantic/chunking.py +52 -0
  106. footprinter/semantic/embeddings.py +23 -0
  107. footprinter/semantic/hybrid_search.py +273 -0
  108. footprinter/semantic/vector_store.py +471 -0
  109. footprinter/services/__init__.py +49 -0
  110. footprinter/services/access_service.py +342 -0
  111. footprinter/services/chat_service.py +85 -0
  112. footprinter/services/client_service.py +267 -0
  113. footprinter/services/content_service.py +181 -0
  114. footprinter/services/email_service.py +89 -0
  115. footprinter/services/file_service.py +83 -0
  116. footprinter/services/folder_service.py +122 -0
  117. footprinter/services/includes.py +19 -0
  118. footprinter/services/ingest_service.py +231 -0
  119. footprinter/services/project_service.py +262 -0
  120. footprinter/services/roles.py +25 -0
  121. footprinter/services/search_service.py +177 -0
  122. footprinter/services/semantic_service.py +360 -0
  123. footprinter/services/status_service.py +18 -0
  124. footprinter/services/visit_service.py +65 -0
  125. footprinter/source_registry.py +194 -0
  126. footprinter/utils/__init__.py +7 -0
  127. footprinter/utils/hash_utils.py +59 -0
  128. footprinter/utils/logging_config.py +68 -0
  129. footprinter/utils/mime.py +30 -0
  130. footprinter/utils/text.py +6 -0
  131. footprinter/utils/time.py +11 -0
  132. footprinter/visibility.py +1264 -0
  133. footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
  134. footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
  135. footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
  136. footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  137. footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  138. footprinter_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,747 @@
1
+ """
2
+ Lightweight terminal status command for Footprinter.
3
+
4
+ Shows data counts, source health, and last run info using rich tables.
5
+ No web/FastAPI dependencies required.
6
+
7
+ Usage:
8
+ fp status # Rich formatted output
9
+ fp status --json # Machine-readable JSON
10
+ fp status --last-run # Last pipeline run details
11
+ python -m footprinter.cli.status
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import sqlite3
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from rich.console import Console
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+
25
+ from footprinter.connectors import discover_connectors, is_installed, resolve_hook
26
+ from footprinter.paths import get_chroma_path, get_config_path, get_db_path
27
+ from footprinter.source_registry import get_config
28
+
29
+ console = Console()
30
+
31
+
32
+ def get_data_counts(db_path: Path) -> dict:
33
+ """Query database for all data counts. Each query wrapped in try/except."""
34
+ counts: dict = {}
35
+
36
+ conn = sqlite3.connect(str(db_path), timeout=10)
37
+ conn.row_factory = sqlite3.Row
38
+ conn.execute("PRAGMA busy_timeout=5000")
39
+ conn.execute("PRAGMA foreign_keys=ON")
40
+ cursor = conn.cursor()
41
+
42
+ try:
43
+ return _query_all_counts(cursor, counts)
44
+ finally:
45
+ conn.close()
46
+
47
+
48
+ def _query_all_counts(cursor, counts: dict) -> dict:
49
+ """Run all count queries. Separated for try/finally in caller."""
50
+ # Files by source
51
+ try:
52
+ cursor.execute(
53
+ """
54
+ SELECT source, COUNT(*) as count, SUM(size_bytes) as size
55
+ FROM files WHERE status != 'removed'
56
+ GROUP BY source
57
+ """
58
+ )
59
+ counts["files"] = {
60
+ row["source"]: {
61
+ "count": row["count"],
62
+ "size_mb": round((row["size"] or 0) / 1024 / 1024, 1),
63
+ }
64
+ for row in cursor.fetchall()
65
+ }
66
+ except sqlite3.OperationalError:
67
+ counts["files"] = {}
68
+
69
+ # Total files
70
+ try:
71
+ cursor.execute("SELECT COUNT(*) FROM files WHERE status != 'removed'")
72
+ counts["files_total"] = cursor.fetchone()[0]
73
+ except sqlite3.OperationalError:
74
+ counts["files_total"] = 0
75
+
76
+ # Folders by source
77
+ try:
78
+ cursor.execute(
79
+ """
80
+ SELECT source, COUNT(*) as count
81
+ FROM folders WHERE status != 'removed'
82
+ GROUP BY source
83
+ """
84
+ )
85
+ counts["folders"] = {row["source"] or "local": row["count"] for row in cursor.fetchall()}
86
+ except sqlite3.OperationalError:
87
+ counts["folders"] = {}
88
+
89
+ # Browser visits
90
+ try:
91
+ cursor.execute("SELECT COUNT(*) FROM visits")
92
+ counts["visits"] = cursor.fetchone()[0]
93
+ except sqlite3.OperationalError:
94
+ counts["visits"] = 0
95
+
96
+ # Emails
97
+ try:
98
+ cursor.execute("SELECT COUNT(*) FROM emails")
99
+ counts["emails"] = cursor.fetchone()[0]
100
+ except sqlite3.OperationalError:
101
+ counts["emails"] = 0
102
+
103
+ # Chats by account
104
+ try:
105
+ cursor.execute("SELECT account, COUNT(*) as count FROM chats GROUP BY account")
106
+ counts["chats"] = {row["account"]: row["count"] for row in cursor.fetchall()}
107
+ except sqlite3.OperationalError:
108
+ counts["chats"] = {}
109
+
110
+ # Chat messages
111
+ try:
112
+ cursor.execute("SELECT COUNT(*) FROM messages")
113
+ counts["messages"] = cursor.fetchone()[0]
114
+ except sqlite3.OperationalError:
115
+ counts["messages"] = 0
116
+
117
+ # Top chats by message count
118
+ try:
119
+ cursor.execute(
120
+ """
121
+ SELECT title, message_count, created_at
122
+ FROM chats
123
+ ORDER BY message_count DESC
124
+ LIMIT 5
125
+ """
126
+ )
127
+ counts["top_chats"] = [
128
+ {
129
+ "title": row["title"],
130
+ "message_count": row["message_count"],
131
+ "created_at": row["created_at"],
132
+ }
133
+ for row in cursor.fetchall()
134
+ ]
135
+ except sqlite3.OperationalError:
136
+ counts["top_chats"] = []
137
+
138
+ # Chat date range
139
+ try:
140
+ cursor.execute("SELECT MIN(created_at) as earliest, MAX(created_at) as latest FROM chats")
141
+ row = cursor.fetchone()
142
+ counts["chat_date_range"] = {
143
+ "earliest": row["earliest"] if row else None,
144
+ "latest": row["latest"] if row else None,
145
+ }
146
+ except sqlite3.OperationalError:
147
+ counts["chat_date_range"] = {"earliest": None, "latest": None}
148
+
149
+ # Remote source accounts (for display labels in print_status)
150
+ try:
151
+ cursor.execute("SELECT name, account FROM sources WHERE source_type = 'remote'")
152
+ counts["remote_source_accounts"] = {row["name"]: row["account"] for row in cursor.fetchall()}
153
+ except sqlite3.OperationalError:
154
+ counts["remote_source_accounts"] = {}
155
+
156
+ # Recently modified files
157
+ try:
158
+ cursor.execute(
159
+ """
160
+ SELECT name, source, modified_at
161
+ FROM files WHERE status != 'removed'
162
+ ORDER BY modified_at DESC
163
+ LIMIT 10
164
+ """
165
+ )
166
+ counts["recent_files"] = [
167
+ {
168
+ "name": row["name"],
169
+ "source": row["source"],
170
+ "modified_at": row["modified_at"],
171
+ }
172
+ for row in cursor.fetchall()
173
+ ]
174
+ except sqlite3.OperationalError:
175
+ counts["recent_files"] = []
176
+
177
+ # Recent uploads
178
+ try:
179
+ cursor.execute(
180
+ """
181
+ SELECT filename, type, status, items_added, uploaded_at
182
+ FROM uploads
183
+ ORDER BY uploaded_at DESC
184
+ LIMIT 5
185
+ """
186
+ )
187
+ counts["recent_uploads"] = [
188
+ {
189
+ "filename": row["filename"],
190
+ "type": row["type"],
191
+ "status": row["status"],
192
+ "items_added": row["items_added"],
193
+ "uploaded_at": row["uploaded_at"],
194
+ }
195
+ for row in cursor.fetchall()
196
+ ]
197
+ except sqlite3.OperationalError:
198
+ counts["recent_uploads"] = []
199
+
200
+ # Last ingest run (exclude 'running' and last-run-only rows which lack mode)
201
+ try:
202
+ cursor.execute(
203
+ """
204
+ SELECT pipe, started_at, completed_at, mode,
205
+ items_processed, errors, status, elapsed_seconds
206
+ FROM ingests
207
+ WHERE status != 'running' AND mode IS NOT NULL
208
+ ORDER BY completed_at DESC LIMIT 1
209
+ """
210
+ )
211
+ row = cursor.fetchone()
212
+ if row:
213
+ elapsed = row["elapsed_seconds"]
214
+ if elapsed is None and row["started_at"] and row["completed_at"]:
215
+ try:
216
+ start = datetime.fromisoformat(row["started_at"])
217
+ end = datetime.fromisoformat(row["completed_at"])
218
+ elapsed = round((end - start).total_seconds(), 1)
219
+ except (ValueError, TypeError):
220
+ pass
221
+ counts["last_run"] = {
222
+ "mode": row["mode"] or "unknown",
223
+ "pipe": row["pipe"],
224
+ "started_at": row["started_at"],
225
+ "completed_at": row["completed_at"],
226
+ "items_processed": row["items_processed"] or 0,
227
+ "errors": row["errors"] or 0,
228
+ "status": row["status"],
229
+ "elapsed_seconds": elapsed,
230
+ }
231
+ else:
232
+ counts["last_run"] = None
233
+ except sqlite3.OperationalError:
234
+ counts["last_run"] = None
235
+
236
+ # Fallback: if no ingests, try MAX(indexed_at) from files
237
+ if counts["last_run"] is None:
238
+ try:
239
+ cursor.execute("SELECT MAX(indexed_at) FROM files")
240
+ row = cursor.fetchone()
241
+ if row and row[0]:
242
+ counts["last_run"] = {
243
+ "mode": "unknown",
244
+ "pipe": "unknown",
245
+ "started_at": row[0],
246
+ "completed_at": None,
247
+ "items_processed": counts["files_total"],
248
+ "errors": 0,
249
+ "status": "unknown",
250
+ "elapsed_seconds": None,
251
+ }
252
+ except sqlite3.OperationalError:
253
+ pass
254
+
255
+ return counts
256
+
257
+
258
+ def get_source_health(config: Optional[dict]) -> dict:
259
+ """Check source health via connector hooks and built-in checks."""
260
+ health: dict = {}
261
+
262
+ # Dynamic connector health via ConnectorSpec.health_check hooks
263
+ connector_rows: list[dict] = []
264
+ for name, spec in discover_connectors().items():
265
+ if is_installed(spec) and spec.health_check:
266
+ try:
267
+ fn = resolve_hook(spec.health_check)
268
+ if fn and config:
269
+ connector_rows.extend(fn(config))
270
+ except Exception:
271
+ pass
272
+ health["connector_rows"] = connector_rows
273
+ health["remote_enabled"] = len(connector_rows) > 0
274
+
275
+ # Semantic search — config-aware health check
276
+ config_enabled = config.get("semantic", {}).get("file_vectorization", False) if config else False
277
+ try:
278
+ from footprinter.semantic.vector_store import (
279
+ VectorStore,
280
+ _semantic_available,
281
+ )
282
+
283
+ installed = _semantic_available()
284
+ except ImportError:
285
+ installed = False
286
+ VectorStore = None # type: ignore[assignment]
287
+
288
+ if not config_enabled:
289
+ health["semantic"] = {"enabled": False, "installed": installed, "available": False}
290
+ elif not installed:
291
+ health["semantic"] = {"enabled": True, "installed": False, "available": False}
292
+ elif not get_chroma_path().exists():
293
+ health["semantic"] = {"enabled": True, "installed": True, "available": False}
294
+ else:
295
+ try:
296
+ vs = VectorStore.get_instance()
297
+ file_stats = vs.get_file_stats()
298
+ conv_stats = vs.get_chat_stats()
299
+ health["semantic"] = {
300
+ "enabled": True,
301
+ "installed": True,
302
+ "available": True,
303
+ "file_chunks": file_stats.get("total_chunks", 0),
304
+ "chat_docs": conv_stats.get("total_documents", 0),
305
+ }
306
+ except Exception:
307
+ health["semantic"] = {
308
+ "enabled": True,
309
+ "installed": True,
310
+ "available": False,
311
+ }
312
+
313
+ return health
314
+
315
+
316
+ def format_relative_time(dt_str: Optional[str]) -> str:
317
+ """Convert ISO datetime string to relative time like '2 hours ago'."""
318
+ if not dt_str:
319
+ return "unknown"
320
+ try:
321
+ dt = datetime.fromisoformat(dt_str)
322
+ if dt.tzinfo is None:
323
+ dt = dt.replace(tzinfo=timezone.utc)
324
+ now = datetime.now(timezone.utc)
325
+ delta = now - dt
326
+ seconds = int(delta.total_seconds())
327
+
328
+ if seconds < 0:
329
+ return "just now"
330
+ if seconds < 60:
331
+ return f"{seconds}s ago"
332
+ minutes = seconds // 60
333
+ if minutes < 60:
334
+ return f"{minutes}m ago"
335
+ hours = minutes // 60
336
+ if hours < 24:
337
+ return f"{hours}h ago"
338
+ days = hours // 24
339
+ if days < 30:
340
+ return f"{days}d ago"
341
+ return dt.strftime("%Y-%m-%d")
342
+ except (ValueError, TypeError):
343
+ return "unknown"
344
+
345
+
346
+ def visible_totals(counts: dict, health: dict) -> dict:
347
+ """Compute file/folder totals from visible sources only.
348
+
349
+ When no remote connector is enabled, remote sources are excluded so
350
+ totals match the displayed breakdown.
351
+ Returns ``{"files": int, "folders": int, "size_mb": float}``.
352
+ """
353
+ files = counts.get("files", {})
354
+ folders = counts.get("folders", {})
355
+ remote_accounts = counts.get("remote_source_accounts", {})
356
+ remote_enabled = health.get("remote_enabled", False)
357
+
358
+ if remote_enabled:
359
+ vis_files = files
360
+ vis_folders = folders
361
+ else:
362
+ vis_files = {k: v for k, v in files.items() if k not in remote_accounts}
363
+ vis_folders = {k: v for k, v in folders.items() if k not in remote_accounts}
364
+
365
+ return {
366
+ "files": sum(info["count"] for info in vis_files.values()),
367
+ "folders": sum(vis_folders.values()),
368
+ "size_mb": sum(info["size_mb"] for info in vis_files.values()),
369
+ }
370
+
371
+
372
+ def _print_source_health(health: dict) -> None:
373
+ """Render the Source Health table. Skip entirely if no rows would appear."""
374
+ connector_rows = health.get("connector_rows", [])
375
+ semantic = health.get("semantic", {})
376
+
377
+ # Early return if nothing to show
378
+ if not (connector_rows or semantic.get("enabled")):
379
+ return
380
+
381
+ health_table = Table(show_header=True, header_style="bold", title="Source Health")
382
+ health_table.add_column("Source", style="cyan")
383
+ health_table.add_column("Status")
384
+
385
+ # Connector rows — provided dynamically by connector health_check hooks
386
+ for row in connector_rows:
387
+ health_table.add_row(row["source"], row["status"])
388
+
389
+ # Semantic Search
390
+ if semantic.get("enabled"):
391
+ if not semantic.get("installed"):
392
+ health_table.add_row(
393
+ "Semantic Search",
394
+ "[yellow]missing deps[/yellow] — pip install footprinter-cli[semantic]",
395
+ )
396
+ elif not semantic.get("available"):
397
+ health_table.add_row(
398
+ "Semantic Search",
399
+ "[yellow]enabled[/yellow] — run fp ingest to build index",
400
+ )
401
+ else:
402
+ chunks = semantic.get("file_chunks", 0)
403
+ docs = semantic.get("chat_docs", 0)
404
+ health_table.add_row(
405
+ "Semantic Search (files)",
406
+ f"[green]active[/green] {chunks:,} chunks",
407
+ )
408
+ health_table.add_row(
409
+ "Semantic Search (chats)",
410
+ f"[green]active[/green] {docs:,} docs",
411
+ )
412
+
413
+ console.print(health_table)
414
+
415
+
416
+ def print_status(data: dict, health: dict) -> None:
417
+ """Render status with rich panels and tables."""
418
+ db_path = data["database"]["path"]
419
+ db_size = data["database"]["size_mb"]
420
+ config_path = data["config"]["path"]
421
+ config_exists = data["config"]["exists"]
422
+
423
+ # Section 1: Header panel
424
+ header_lines = [f"[bold]Database:[/bold] {db_path} ({db_size:.1f} MB)"]
425
+ config_status = config_path if config_exists else f"{config_path} [dim](not found)[/dim]"
426
+ header_lines.append(f"[bold]Config:[/bold] {config_status}")
427
+ console.print(Panel("\n".join(header_lines), title="Footprinter Status", expand=False))
428
+
429
+ # Section 2: Source health (skip if no connectors configured)
430
+ _print_source_health(health)
431
+
432
+ # Section 3: Data counts table
433
+ counts = data["counts"]
434
+ table = Table(show_header=True, header_style="bold")
435
+ table.add_column("Source", style="cyan")
436
+ table.add_column("Count", justify="right")
437
+ table.add_column("Size", justify="right")
438
+
439
+ files = counts.get("files", {})
440
+ folders = counts.get("folders", {})
441
+ remote_accounts = counts.get("remote_source_accounts", {})
442
+ remote_enabled = health.get("remote_enabled", False)
443
+
444
+ totals = visible_totals(counts, health)
445
+ total_folder_count = totals["folders"]
446
+ total_file_count = totals["files"]
447
+ total_file_size = totals["size_mb"]
448
+
449
+ # Local section
450
+ local_folders = folders.get("local", 0)
451
+ if local_folders:
452
+ table.add_row("Local folders", f"{local_folders:,}", "")
453
+
454
+ local_files = files.get("local")
455
+ if local_files:
456
+ table.add_row(
457
+ "Local files",
458
+ f"{local_files['count']:,}",
459
+ f"{local_files['size_mb']:.1f} MB",
460
+ )
461
+
462
+ # Remote section (per account, rows shown with 0 counts when remote enabled)
463
+ if remote_enabled and remote_accounts:
464
+ # Build account → display label from connector health rows
465
+ account_labels = {
466
+ row["account"]: row["label"]
467
+ for row in health.get("connector_rows", [])
468
+ if "account" in row and "label" in row
469
+ }
470
+ table.add_section()
471
+ for source_name, account in remote_accounts.items():
472
+ display = account_labels.get(account, account)
473
+ remote_folders = folders.get(source_name, 0)
474
+ remote_files = files.get(source_name)
475
+ table.add_row(
476
+ f"Remote folders ({display})",
477
+ f"{remote_folders:,}",
478
+ "",
479
+ )
480
+ table.add_row(
481
+ f"Remote files ({display})",
482
+ f"{remote_files['count']:,}" if remote_files else "0",
483
+ f"{remote_files['size_mb']:.1f} MB" if remote_files else "0.0 MB",
484
+ )
485
+
486
+ # Totals section
487
+ table.add_section()
488
+ if total_folder_count:
489
+ table.add_row(
490
+ "[bold]Total folders[/bold]",
491
+ f"[bold]{total_folder_count:,}[/bold]",
492
+ "",
493
+ )
494
+ table.add_row(
495
+ "[bold]Total files[/bold]",
496
+ f"[bold]{total_file_count:,}[/bold]",
497
+ f"[bold]{total_file_size:.1f} MB[/bold]",
498
+ )
499
+
500
+ # Other data sources
501
+ table.add_section()
502
+ table.add_row("Browser history", f"{counts.get('visits', 0):,}", "")
503
+ table.add_row("Emails", f"{counts.get('emails', 0):,}", "")
504
+ table.add_row("Chat messages", f"{counts.get('messages', 0):,}", "")
505
+
506
+ chat_total = sum(counts.get("chats", {}).values())
507
+ if chat_total:
508
+ table.add_row("Chats", f"{chat_total:,}", "")
509
+
510
+ console.print(table)
511
+
512
+ # Section 4: Recently modified files
513
+ recent_files = counts.get("recent_files", [])
514
+ if recent_files:
515
+ console.print()
516
+ files_table = Table(show_header=True, header_style="bold", title="Recently Modified Files")
517
+ files_table.add_column("Filename", style="cyan", max_width=40)
518
+ files_table.add_column("Source")
519
+ files_table.add_column("Date", style="dim")
520
+ for f in recent_files:
521
+ files_table.add_row(
522
+ f["name"],
523
+ f["source"],
524
+ format_relative_time(f["modified_at"]),
525
+ )
526
+ console.print(files_table)
527
+
528
+ # Section 5: Recent uploads
529
+ recent_uploads = counts.get("recent_uploads", [])
530
+ if recent_uploads:
531
+ console.print()
532
+ upload_table = Table(show_header=True, header_style="bold", title="Recent Uploads")
533
+ upload_table.add_column("Filename", style="cyan")
534
+ upload_table.add_column("Type")
535
+ upload_table.add_column("Status")
536
+ upload_table.add_column("Items", justify="right")
537
+ upload_table.add_column("Date", style="dim")
538
+ for u in recent_uploads:
539
+ status_style = "[green]" if u["status"] == "completed" else "[red]"
540
+ upload_table.add_row(
541
+ u["filename"],
542
+ u["type"],
543
+ f"{status_style}{u['status']}[/]",
544
+ str(u["items_added"] or 0),
545
+ format_relative_time(u["uploaded_at"]),
546
+ )
547
+ console.print(upload_table)
548
+
549
+ # Section 6: Top chats (only when messages exist — metadata-only imports
550
+ # may have chat titles but 0 actual messages)
551
+ top_convos = counts.get("top_chats", [])
552
+ if top_convos and counts.get("messages", 0) > 0:
553
+ console.print()
554
+ chat_table = Table(show_header=True, header_style="bold", title="Top Chats")
555
+ chat_table.add_column("Title", style="cyan", max_width=50)
556
+ chat_table.add_column("Messages", justify="right")
557
+ chat_table.add_column("Date", style="dim")
558
+ for conv in top_convos:
559
+ chat_table.add_row(
560
+ conv["title"] or "(untitled)",
561
+ str(conv["message_count"] or 0),
562
+ format_relative_time(conv["created_at"]),
563
+ )
564
+ console.print(chat_table)
565
+
566
+ console.print()
567
+
568
+ # Section 7: Last run footer
569
+ last_run = data.get("last_run")
570
+ if last_run:
571
+ time_ago = format_relative_time(last_run.get("started_at"))
572
+ mode = last_run.get("mode", "unknown")
573
+ items = last_run.get("items_processed", 0)
574
+ errors = last_run.get("errors", 0)
575
+ elapsed = last_run.get("elapsed_seconds")
576
+ elapsed_str = f", {elapsed}s" if elapsed is not None else ""
577
+ console.print()
578
+ console.print(
579
+ f"[dim]Last ingest:[/dim] {time_ago} [dim]({mode}, {items:,} items, {errors} errors{elapsed_str})[/dim]"
580
+ )
581
+ else:
582
+ console.print()
583
+ console.print("[dim]No ingest runs recorded.[/dim]")
584
+
585
+ console.print()
586
+
587
+
588
+ # ---------------------------------------------------------------------------
589
+ # Zero-result heuristic: stages where 0 results likely indicate a problem
590
+ # ---------------------------------------------------------------------------
591
+ _CORE_ZERO_RESULT_CHECKS: dict[str, str] = {
592
+ "browser": "urls_indexed",
593
+ }
594
+
595
+
596
+ def _build_zero_result_checks() -> dict[str, str]:
597
+ """Merge core checks with checks from installed connectors."""
598
+ from footprinter.connectors import discover_connectors, is_installed
599
+
600
+ checks = dict(_CORE_ZERO_RESULT_CHECKS)
601
+ for spec in discover_connectors().values():
602
+ if is_installed(spec):
603
+ for pipe_name, count_key in spec.zero_result_checks:
604
+ checks[pipe_name] = count_key
605
+ return checks
606
+
607
+
608
+ def print_last_run(record: Optional[dict]) -> None:
609
+ """Render the last pipeline run as a Rich table with zero-result warnings."""
610
+ if record is None:
611
+ console.print("No pipeline runs recorded.")
612
+ return
613
+
614
+ from footprinter.ingest.status import _stage_detail_string
615
+
616
+ interrupted = record.get("interrupted", False)
617
+ title = "Last Pipeline Run (interrupted)" if interrupted else "Last Pipeline Run"
618
+ table = Table(show_header=True, header_style="bold", title=title)
619
+ table.add_column("Stage", style="cyan")
620
+ table.add_column("Status")
621
+ table.add_column("Time", justify="right")
622
+ table.add_column("Details", style="dim")
623
+
624
+ status_icons = {
625
+ "completed": "[green]OK[/green]",
626
+ "completed_with_errors": "[yellow]WARN[/yellow]",
627
+ "info": "[blue]info[/blue]",
628
+ "skipped": "[yellow]skip[/yellow]",
629
+ "error": "[red]FAIL[/red]",
630
+ }
631
+
632
+ zero_checks = _build_zero_result_checks()
633
+
634
+ for stage_result in record.get("stages", []):
635
+ stage = stage_result.get("stage", "unknown")
636
+ status = stage_result.get("status", "unknown")
637
+ elapsed = stage_result.get("elapsed_seconds", 0)
638
+ icon = status_icons.get(status, f"[dim]{status}[/dim]")
639
+ details = _stage_detail_string(stage_result)
640
+
641
+ if status == "error":
642
+ error_msg = stage_result.get("error", "")
643
+ if error_msg:
644
+ details = str(error_msg)[:200]
645
+
646
+ # Zero-result warning
647
+ count_key = zero_checks.get(stage)
648
+ if count_key and status == "completed" and stage_result.get(count_key, -1) == 0:
649
+ icon = "[yellow]⚠ WARNING[/yellow]"
650
+ details = "0 results — check configuration"
651
+
652
+ table.add_row(stage, icon, f"{elapsed:.1f}s", details)
653
+
654
+ console.print(table)
655
+
656
+ # Footer
657
+ mode = record.get("mode", "unknown")
658
+ mode_display = f"{mode} (interrupted)" if interrupted else mode
659
+ total = record.get("total_elapsed_seconds", 0)
660
+ started_at = record.get("started_at")
661
+ time_ago = format_relative_time(started_at)
662
+ console.print(f"[dim]Mode: {mode_display} | Total: {total:.1f}s | {time_ago}[/dim]")
663
+ console.print()
664
+
665
+
666
+ def main() -> None:
667
+ """Entry point for fp status command."""
668
+ parser = argparse.ArgumentParser(
669
+ description="Show Footprinter system status",
670
+ prog="fp status",
671
+ )
672
+ parser.add_argument(
673
+ "--json",
674
+ action="store_true",
675
+ help="Output structured JSON instead of rich tables",
676
+ )
677
+ parser.add_argument(
678
+ "--last-run",
679
+ action="store_true",
680
+ help="Show details from the last pipeline run",
681
+ )
682
+ args = parser.parse_args()
683
+
684
+ # --last-run: per-stage breakdown from run_record.py (session-level JSON cache).
685
+ # Different from the footer's "Last ingest" which reads the ingests DB table
686
+ # for the most recent per-pipe record.
687
+ if getattr(args, "last_run", False):
688
+ from footprinter.ingest.run_record import load_run_record
689
+
690
+ print_last_run(load_run_record())
691
+ return
692
+
693
+ db_path = get_db_path()
694
+ config_path = get_config_path()
695
+
696
+ # Build structured data
697
+ data: dict = {
698
+ "database": {
699
+ "path": str(db_path),
700
+ "exists": db_path.exists(),
701
+ "size_mb": round(db_path.stat().st_size / 1024 / 1024, 1) if db_path.exists() else 0,
702
+ },
703
+ "config": {
704
+ "path": str(config_path),
705
+ "exists": config_path.exists(),
706
+ },
707
+ }
708
+
709
+ if not db_path.exists():
710
+ if args.json:
711
+ data["counts"] = {}
712
+ data["health"] = {}
713
+ data["last_run"] = None
714
+ print(json.dumps(data, indent=2, default=str))
715
+ else:
716
+ console.print(
717
+ Panel(
718
+ f"No database found at [cyan]{db_path}[/cyan]\nRun [bold]fp ingest[/bold] to start indexing.",
719
+ title="Footprinter Status",
720
+ expand=False,
721
+ )
722
+ )
723
+ return
724
+
725
+ try:
726
+ config = get_config()
727
+ except Exception:
728
+ config = None
729
+ counts = get_data_counts(db_path)
730
+ health = get_source_health(config)
731
+
732
+ data["counts"] = counts
733
+ data["health"] = health
734
+ data["last_run"] = counts.get("last_run")
735
+
736
+ # Align files_total with visibility-filtered totals
737
+ totals = visible_totals(counts, health)
738
+ counts["files_total"] = totals["files"]
739
+
740
+ if args.json:
741
+ print(json.dumps(data, indent=2, default=str))
742
+ else:
743
+ print_status(data, health)
744
+
745
+
746
+ if __name__ == "__main__":
747
+ main()