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.
- footprinter/__init__.py +8 -0
- footprinter/access.py +431 -0
- footprinter/api/__init__.py +1 -0
- footprinter/api/db.py +61 -0
- footprinter/api/entities.py +250 -0
- footprinter/api/search.py +47 -0
- footprinter/api/semantic.py +33 -0
- footprinter/api/server.py +66 -0
- footprinter/api/status.py +15 -0
- footprinter/bundled/__init__.py +0 -0
- footprinter/bundled/config.example.yaml +161 -0
- footprinter/bundled/patterns/context_patterns.yaml +18 -0
- footprinter/bundled/patterns/extensions.yaml +283 -0
- footprinter/bundled/patterns/filename_patterns.yaml +61 -0
- footprinter/bundled/patterns/mime_mappings.yaml +68 -0
- footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
- footprinter/bundled/patterns/security_patterns.yaml +27 -0
- footprinter/bundled/samples/hidden-client-file-sample.txt +2 -0
- footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
- footprinter/bundled/samples/visible-file-sample.txt +2 -0
- footprinter/cli/__init__.py +135 -0
- footprinter/cli/__main__.py +6 -0
- footprinter/cli/_common.py +327 -0
- footprinter/cli/_policy_helpers.py +646 -0
- footprinter/cli/_prompt.py +220 -0
- footprinter/cli/_sample_seed.py +204 -0
- footprinter/cli/api_cmd.py +32 -0
- footprinter/cli/connect.py +591 -0
- footprinter/cli/data.py +879 -0
- footprinter/cli/delete.py +128 -0
- footprinter/cli/ingest.py +543 -0
- footprinter/cli/mcp_cmd.py +750 -0
- footprinter/cli/mcp_setup.py +306 -0
- footprinter/cli/search.py +393 -0
- footprinter/cli/search_cmd.py +69 -0
- footprinter/cli/setup.py +2001 -0
- footprinter/cli/status.py +747 -0
- footprinter/cli/status_cmd.py +104 -0
- footprinter/cli/upsert.py +794 -0
- footprinter/cli/vectorize_cmd.py +215 -0
- footprinter/cli/view.py +322 -0
- footprinter/connectors/__init__.py +171 -0
- footprinter/connectors/config_utils.py +141 -0
- footprinter/db/__init__.py +37 -0
- footprinter/db/browser.py +198 -0
- footprinter/db/chats.py +602 -0
- footprinter/db/clients.py +307 -0
- footprinter/db/emails.py +279 -0
- footprinter/db/files.py +724 -0
- footprinter/db/folders.py +659 -0
- footprinter/db/messages.py +192 -0
- footprinter/db/policies.py +151 -0
- footprinter/db/projects.py +673 -0
- footprinter/db/search.py +573 -0
- footprinter/db/sql_utils.py +168 -0
- footprinter/db/status.py +320 -0
- footprinter/db/uploads.py +70 -0
- footprinter/ingest/__init__.py +0 -0
- footprinter/ingest/adapters/__init__.py +33 -0
- footprinter/ingest/adapters/browser.py +54 -0
- footprinter/ingest/adapters/chat.py +57 -0
- footprinter/ingest/adapters/ingest.py +146 -0
- footprinter/ingest/adapters/local_files.py +68 -0
- footprinter/ingest/adapters/local_folders.py +52 -0
- footprinter/ingest/adapters/protocol.py +174 -0
- footprinter/ingest/browser_indexer.py +216 -0
- footprinter/ingest/chat_dedup.py +156 -0
- footprinter/ingest/chat_indexer.py +487 -0
- footprinter/ingest/chat_parsers/__init__.py +8 -0
- footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
- footprinter/ingest/chat_parsers/claude_parser.py +161 -0
- footprinter/ingest/cli.py +827 -0
- footprinter/ingest/content_extractors.py +117 -0
- footprinter/ingest/database.py +36 -0
- footprinter/ingest/db/__init__.py +1 -0
- footprinter/ingest/db/connector_schema.py +47 -0
- footprinter/ingest/db/migration.py +315 -0
- footprinter/ingest/db/schema.py +1043 -0
- footprinter/ingest/db/security.py +6 -0
- footprinter/ingest/file_indexer.py +223 -0
- footprinter/ingest/file_scanner.py +277 -0
- footprinter/ingest/folder_indexer.py +226 -0
- footprinter/ingest/full_content_extractor.py +321 -0
- footprinter/ingest/orchestrator.py +112 -0
- footprinter/ingest/pipe_runner.py +200 -0
- footprinter/ingest/processing.py +165 -0
- footprinter/ingest/registry.py +186 -0
- footprinter/ingest/run_record.py +91 -0
- footprinter/ingest/status.py +346 -0
- footprinter/mcp/__init__.py +0 -0
- footprinter/mcp/__main__.py +5 -0
- footprinter/mcp/db.py +67 -0
- footprinter/mcp/errors.py +105 -0
- footprinter/mcp/extraction.py +226 -0
- footprinter/mcp/server.py +39 -0
- footprinter/mcp/tools/__init__.py +0 -0
- footprinter/mcp/tools/navigation.py +70 -0
- footprinter/mcp/tools/read.py +75 -0
- footprinter/mcp/tools/search.py +158 -0
- footprinter/mcp/tools/semantic.py +79 -0
- footprinter/mcp/tools/status.py +19 -0
- footprinter/paths.py +117 -0
- footprinter/permissions.py +1152 -0
- footprinter/semantic/__init__.py +13 -0
- footprinter/semantic/chunking.py +52 -0
- footprinter/semantic/embeddings.py +23 -0
- footprinter/semantic/hybrid_search.py +273 -0
- footprinter/semantic/vector_store.py +471 -0
- footprinter/services/__init__.py +49 -0
- footprinter/services/access_service.py +342 -0
- footprinter/services/chat_service.py +85 -0
- footprinter/services/client_service.py +267 -0
- footprinter/services/content_service.py +181 -0
- footprinter/services/email_service.py +89 -0
- footprinter/services/file_service.py +83 -0
- footprinter/services/folder_service.py +122 -0
- footprinter/services/includes.py +19 -0
- footprinter/services/ingest_service.py +231 -0
- footprinter/services/project_service.py +262 -0
- footprinter/services/roles.py +25 -0
- footprinter/services/search_service.py +177 -0
- footprinter/services/semantic_service.py +360 -0
- footprinter/services/status_service.py +18 -0
- footprinter/services/visit_service.py +65 -0
- footprinter/source_registry.py +194 -0
- footprinter/utils/__init__.py +7 -0
- footprinter/utils/hash_utils.py +59 -0
- footprinter/utils/logging_config.py +68 -0
- footprinter/utils/mime.py +30 -0
- footprinter/utils/text.py +6 -0
- footprinter/utils/time.py +11 -0
- footprinter/visibility.py +1264 -0
- footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
- footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
- footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
- footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
- footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
- footprinter_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Status reporting — terminal status display, stage detail formatting, data counts."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
from footprinter.paths import get_db_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_status(db_path: str = None) -> Dict:
|
|
10
|
+
"""Get status of all data sources.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
db_path: Path to SQLite database. Falls back to get_db_path().
|
|
14
|
+
"""
|
|
15
|
+
if db_path is None:
|
|
16
|
+
db_path = str(get_db_path())
|
|
17
|
+
|
|
18
|
+
conn = sqlite3.connect(db_path, timeout=10)
|
|
19
|
+
conn.row_factory = sqlite3.Row
|
|
20
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
21
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
22
|
+
cursor = conn.cursor()
|
|
23
|
+
|
|
24
|
+
status = {}
|
|
25
|
+
|
|
26
|
+
# Files - status != 'removed' means active files (includes
|
|
27
|
+
# 'active' and 'hidden')
|
|
28
|
+
cursor.execute(
|
|
29
|
+
"""
|
|
30
|
+
SELECT source, COUNT(*) as count, SUM(size_bytes) as size
|
|
31
|
+
FROM files WHERE status != 'removed'
|
|
32
|
+
GROUP BY source
|
|
33
|
+
"""
|
|
34
|
+
)
|
|
35
|
+
status["files"] = {
|
|
36
|
+
row["source"]: {
|
|
37
|
+
"count": row["count"],
|
|
38
|
+
"size_mb": round((row["size"] or 0) / 1024 / 1024, 1),
|
|
39
|
+
}
|
|
40
|
+
for row in cursor.fetchall()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cursor.execute("SELECT COUNT(*) FROM files WHERE status != 'removed'")
|
|
44
|
+
status["files_total"] = cursor.fetchone()[0]
|
|
45
|
+
|
|
46
|
+
# Indexed folders
|
|
47
|
+
cursor.execute(
|
|
48
|
+
"""
|
|
49
|
+
SELECT source, COUNT(*) as count
|
|
50
|
+
FROM folders WHERE status != 'removed'
|
|
51
|
+
GROUP BY source
|
|
52
|
+
"""
|
|
53
|
+
)
|
|
54
|
+
status["folders"] = {row["source"] or "local": row["count"] for row in cursor.fetchall()}
|
|
55
|
+
|
|
56
|
+
# Browser visits
|
|
57
|
+
cursor.execute("SELECT COUNT(*) FROM visits")
|
|
58
|
+
status["visits"] = cursor.fetchone()[0]
|
|
59
|
+
|
|
60
|
+
# Emails
|
|
61
|
+
cursor.execute("SELECT COUNT(*) FROM emails")
|
|
62
|
+
status["emails"] = cursor.fetchone()[0]
|
|
63
|
+
|
|
64
|
+
# Chats
|
|
65
|
+
cursor.execute("SELECT account, COUNT(*) as count FROM chats GROUP BY account")
|
|
66
|
+
status["chats"] = {row["account"]: row["count"] for row in cursor.fetchall()}
|
|
67
|
+
|
|
68
|
+
cursor.execute("SELECT COUNT(*) FROM messages")
|
|
69
|
+
status["messages"] = cursor.fetchone()[0]
|
|
70
|
+
|
|
71
|
+
# Projects
|
|
72
|
+
cursor.execute("SELECT COUNT(*) FROM projects")
|
|
73
|
+
status["projects"] = cursor.fetchone()[0]
|
|
74
|
+
|
|
75
|
+
# retention classifications removed — not part of CLI tool
|
|
76
|
+
|
|
77
|
+
# Access resolution — count entities with stamped visibility
|
|
78
|
+
access = {}
|
|
79
|
+
for table in ("files", "emails", "chats"):
|
|
80
|
+
try:
|
|
81
|
+
where = "mcp_view IS NOT NULL"
|
|
82
|
+
if table == "files":
|
|
83
|
+
where += " AND status != 'removed'"
|
|
84
|
+
stamped = cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}").fetchone()[0]
|
|
85
|
+
total_where = "status != 'removed'" if table == "files" else "1=1"
|
|
86
|
+
total = cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE {total_where}").fetchone()[0]
|
|
87
|
+
access[table] = {"stamped": stamped, "total": total}
|
|
88
|
+
except sqlite3.OperationalError:
|
|
89
|
+
pass
|
|
90
|
+
status["access_resolution"] = access
|
|
91
|
+
|
|
92
|
+
# FTS health — check existence and integrity inline on the existing
|
|
93
|
+
# connection to avoid opening a second Database() instance
|
|
94
|
+
fts_tables = {
|
|
95
|
+
"files_fts": "files",
|
|
96
|
+
"emails_fts": "emails",
|
|
97
|
+
"chats_fts": "chats",
|
|
98
|
+
}
|
|
99
|
+
fts = {}
|
|
100
|
+
for fts_table, base_table in fts_tables.items():
|
|
101
|
+
base_rows = cursor.execute(f"SELECT COUNT(*) FROM {base_table}").fetchone()[0]
|
|
102
|
+
try:
|
|
103
|
+
fts_rows = cursor.execute(f"SELECT COUNT(*) FROM {fts_table}").fetchone()[0]
|
|
104
|
+
except sqlite3.OperationalError:
|
|
105
|
+
fts[fts_table] = {
|
|
106
|
+
"status": "error",
|
|
107
|
+
"fts_rows": None,
|
|
108
|
+
"base_rows": base_rows,
|
|
109
|
+
}
|
|
110
|
+
continue
|
|
111
|
+
# FTS5 integrity-check detects index drift
|
|
112
|
+
try:
|
|
113
|
+
cursor.execute(f"INSERT INTO {fts_table}({fts_table}, rank) VALUES('integrity-check', 1)")
|
|
114
|
+
fts[fts_table] = {
|
|
115
|
+
"status": "ok",
|
|
116
|
+
"fts_rows": fts_rows,
|
|
117
|
+
"base_rows": base_rows,
|
|
118
|
+
}
|
|
119
|
+
except sqlite3.DatabaseError:
|
|
120
|
+
fts[fts_table] = {
|
|
121
|
+
"status": "drift",
|
|
122
|
+
"fts_rows": fts_rows,
|
|
123
|
+
"base_rows": base_rows,
|
|
124
|
+
}
|
|
125
|
+
status["fts"] = fts
|
|
126
|
+
|
|
127
|
+
conn.close()
|
|
128
|
+
|
|
129
|
+
return status
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _stage_detail_string(result: Dict) -> str:
|
|
133
|
+
"""Extract a short detail string from a stage result dict."""
|
|
134
|
+
reason = result.get("reason")
|
|
135
|
+
if reason:
|
|
136
|
+
return str(reason)
|
|
137
|
+
|
|
138
|
+
known_keys = {
|
|
139
|
+
"files_indexed": "files",
|
|
140
|
+
"folders_found": "folders",
|
|
141
|
+
"folders_indexed": "folders",
|
|
142
|
+
"urls_indexed": "urls",
|
|
143
|
+
"emails_indexed": "emails",
|
|
144
|
+
"inserted": "inserted",
|
|
145
|
+
"updated": "updated",
|
|
146
|
+
"skipped": "skipped",
|
|
147
|
+
"errors": "errors",
|
|
148
|
+
}
|
|
149
|
+
parts = []
|
|
150
|
+
for key, label in known_keys.items():
|
|
151
|
+
if key in result and isinstance(result[key], (int, float)):
|
|
152
|
+
parts.append(f"{result[key]:,} {label}")
|
|
153
|
+
|
|
154
|
+
# Check nested dicts with 'status' key (sub-results like classification, scoring)
|
|
155
|
+
for key, value in result.items():
|
|
156
|
+
if key in (
|
|
157
|
+
"stage",
|
|
158
|
+
"status",
|
|
159
|
+
"elapsed_seconds",
|
|
160
|
+
"error",
|
|
161
|
+
"error_type",
|
|
162
|
+
"recoverable",
|
|
163
|
+
"mode",
|
|
164
|
+
"note",
|
|
165
|
+
):
|
|
166
|
+
continue
|
|
167
|
+
if isinstance(value, dict) and "status" in value:
|
|
168
|
+
sub_status = value["status"]
|
|
169
|
+
if sub_status == "error":
|
|
170
|
+
parts.append(f"{key}: error")
|
|
171
|
+
else:
|
|
172
|
+
# Pull a useful number from the sub-result
|
|
173
|
+
for sub_key in (
|
|
174
|
+
"processed",
|
|
175
|
+
"files_processed",
|
|
176
|
+
"messages_indexed",
|
|
177
|
+
"projects_found",
|
|
178
|
+
"files_updated",
|
|
179
|
+
"folders_updated",
|
|
180
|
+
):
|
|
181
|
+
if sub_key in value and isinstance(value[sub_key], (int, float)):
|
|
182
|
+
parts.append(f"{value[sub_key]:,} {sub_key.replace('_', ' ')}")
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
return ", ".join(parts[:3])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def print_status(status: Dict, quiet: bool = False, console=None):
|
|
189
|
+
"""Pretty print status as a Rich table."""
|
|
190
|
+
if quiet:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
from rich.console import Console
|
|
194
|
+
from rich.table import Table
|
|
195
|
+
|
|
196
|
+
if console is None:
|
|
197
|
+
console = Console()
|
|
198
|
+
|
|
199
|
+
console.print()
|
|
200
|
+
console.print("[bold]Data Pipeline Status[/bold]")
|
|
201
|
+
console.print()
|
|
202
|
+
|
|
203
|
+
# Main data table
|
|
204
|
+
table = Table(show_header=True, header_style="bold")
|
|
205
|
+
table.add_column("Source", style="cyan")
|
|
206
|
+
table.add_column("Count", justify="right")
|
|
207
|
+
table.add_column("Size", justify="right")
|
|
208
|
+
|
|
209
|
+
# File rows by source
|
|
210
|
+
total_count = 0
|
|
211
|
+
total_size = 0.0
|
|
212
|
+
for source, data in status.get("files", {}).items():
|
|
213
|
+
count = data["count"]
|
|
214
|
+
size_mb = data["size_mb"]
|
|
215
|
+
total_count += count
|
|
216
|
+
total_size += size_mb
|
|
217
|
+
table.add_row(f" {source}", f"{count:,}", f"{size_mb:.1f} MB")
|
|
218
|
+
|
|
219
|
+
# Non-file sources
|
|
220
|
+
browser = status.get("visits", 0)
|
|
221
|
+
emails = status.get("emails", 0)
|
|
222
|
+
messages_count = status.get("messages", 0)
|
|
223
|
+
projects = status.get("projects", 0)
|
|
224
|
+
|
|
225
|
+
table.add_row("Browser history", f"{browser:,}", "")
|
|
226
|
+
table.add_row("Emails", f"{emails:,}", "")
|
|
227
|
+
table.add_row("Chat messages", f"{messages_count:,}", "")
|
|
228
|
+
table.add_row("Projects", f"{projects:,}", "")
|
|
229
|
+
|
|
230
|
+
# Chats and folders as table rows
|
|
231
|
+
chat_total = sum(status.get("chats", {}).values())
|
|
232
|
+
if chat_total:
|
|
233
|
+
table.add_row("Chats", f"{chat_total:,}", "")
|
|
234
|
+
folder_total = sum(status.get("folders", {}).values())
|
|
235
|
+
if folder_total:
|
|
236
|
+
table.add_row("Indexed folders", f"{folder_total:,}", "")
|
|
237
|
+
|
|
238
|
+
# Total row
|
|
239
|
+
table.add_section()
|
|
240
|
+
table.add_row(
|
|
241
|
+
"[bold]Total files[/bold]",
|
|
242
|
+
f"[bold]{total_count:,}[/bold]",
|
|
243
|
+
f"[bold]{total_size:.1f} MB[/bold]",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
console.print(table)
|
|
247
|
+
|
|
248
|
+
# FTS health section
|
|
249
|
+
fts_data = status.get("fts", {})
|
|
250
|
+
if fts_data:
|
|
251
|
+
console.print()
|
|
252
|
+
console.print("[bold]FTS Search Indexes[/bold]")
|
|
253
|
+
fts_table = Table(show_header=True, header_style="bold")
|
|
254
|
+
fts_table.add_column("Index", style="cyan")
|
|
255
|
+
fts_table.add_column("Rows", justify="right")
|
|
256
|
+
fts_table.add_column("Status")
|
|
257
|
+
|
|
258
|
+
for idx_name, info in fts_data.items():
|
|
259
|
+
idx_status = info.get("status", "unknown")
|
|
260
|
+
if idx_status == "ok":
|
|
261
|
+
status_text = "[green]ok[/green]"
|
|
262
|
+
rows_text = f"{info['base_rows']:,}"
|
|
263
|
+
elif idx_status == "error":
|
|
264
|
+
status_text = "[red]missing[/red]"
|
|
265
|
+
rows_text = "—"
|
|
266
|
+
else:
|
|
267
|
+
status_text = f"[yellow]{idx_status}[/yellow]"
|
|
268
|
+
rows_text = f"{info.get('fts_rows', '?')}"
|
|
269
|
+
fts_table.add_row(idx_name, rows_text, status_text)
|
|
270
|
+
|
|
271
|
+
console.print(fts_table)
|
|
272
|
+
|
|
273
|
+
console.print()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _print_completion_summary(console, results: List[Dict], *, show_next_steps: bool = True):
|
|
277
|
+
"""Print a completion summary after pipeline run."""
|
|
278
|
+
total_time = sum(r.get("elapsed_seconds", 0) for r in results)
|
|
279
|
+
error_count = sum(1 for r in results if r.get("status") == "error")
|
|
280
|
+
warn_count = sum(1 for r in results if r.get("status") == "completed_with_errors")
|
|
281
|
+
completed_count = sum(1 for r in results if r.get("status") in ("completed", "completed_with_errors", "info"))
|
|
282
|
+
|
|
283
|
+
console.print()
|
|
284
|
+
if error_count == 0 and warn_count == 0:
|
|
285
|
+
console.print(f"[bold green]Pipeline complete[/bold green] {completed_count} stages in {total_time:.1f}s")
|
|
286
|
+
elif error_count == 0:
|
|
287
|
+
console.print(
|
|
288
|
+
f"[bold yellow]Pipeline complete with {warn_count} warning(s)[/bold yellow] "
|
|
289
|
+
f"{completed_count} stages in {total_time:.1f}s"
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
console.print(
|
|
293
|
+
f"[bold yellow]Pipeline finished with {error_count} error(s)[/bold yellow] "
|
|
294
|
+
f"{completed_count} OK, {error_count} failed in {total_time:.1f}s"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if show_next_steps:
|
|
298
|
+
console.print()
|
|
299
|
+
console.print("[dim]Next steps:[/dim]")
|
|
300
|
+
console.print("[dim] fp mcp Configure Claude Desktop[/dim]")
|
|
301
|
+
console.print("[dim] fp status Show data counts[/dim]")
|
|
302
|
+
console.print()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def print_results(results: List[Dict], quiet: bool = False, console=None, *, show_next_steps: bool = True):
|
|
306
|
+
"""Pretty print pipeline results as a Rich table."""
|
|
307
|
+
if quiet:
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
from rich.console import Console
|
|
311
|
+
from rich.table import Table
|
|
312
|
+
|
|
313
|
+
if console is None:
|
|
314
|
+
console = Console()
|
|
315
|
+
|
|
316
|
+
console.print()
|
|
317
|
+
table = Table(show_header=True, header_style="bold", title="Pipeline Results")
|
|
318
|
+
table.add_column("Stage", style="cyan")
|
|
319
|
+
table.add_column("Status")
|
|
320
|
+
table.add_column("Time", justify="right")
|
|
321
|
+
table.add_column("Details", style="dim")
|
|
322
|
+
|
|
323
|
+
status_styles = {
|
|
324
|
+
"completed": "[green]OK[/green]",
|
|
325
|
+
"completed_with_errors": "[yellow]WARN[/yellow]",
|
|
326
|
+
"info": "[blue]info[/blue]",
|
|
327
|
+
"skipped": "[yellow]skip[/yellow]",
|
|
328
|
+
"error": "[red]FAIL[/red]",
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for result in results:
|
|
332
|
+
stage = result.get("stage", "unknown")
|
|
333
|
+
status = result.get("status", "unknown")
|
|
334
|
+
elapsed = result.get("elapsed_seconds", 0)
|
|
335
|
+
status_text = status_styles.get(status, f"[dim]{status}[/dim]")
|
|
336
|
+
details = _stage_detail_string(result)
|
|
337
|
+
|
|
338
|
+
if status == "error":
|
|
339
|
+
error_msg = result.get("error", "")
|
|
340
|
+
if error_msg:
|
|
341
|
+
details = str(error_msg)[:200]
|
|
342
|
+
|
|
343
|
+
table.add_row(stage, status_text, f"{elapsed:.1f}s", details)
|
|
344
|
+
|
|
345
|
+
console.print(table)
|
|
346
|
+
_print_completion_summary(console, results, show_next_steps=show_next_steps)
|
|
File without changes
|
footprinter/mcp/db.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Database connection for Footprinter MCP server."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
|
|
7
|
+
from footprinter.mcp.errors import mcp_error
|
|
8
|
+
from footprinter.paths import get_db_path, is_test_mode
|
|
9
|
+
from footprinter.services.access_service import load_globals
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatabaseNotInitializedError(Exception):
|
|
13
|
+
"""Raised when the database exists but has no tables (uninitialized)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_db_initialized(conn: sqlite3.Connection) -> None:
|
|
17
|
+
"""Check that the database has been initialized with the expected schema.
|
|
18
|
+
|
|
19
|
+
Uses the ``files`` table as a sentinel — if it's missing, the database
|
|
20
|
+
has never been populated by ``fp ingest``.
|
|
21
|
+
"""
|
|
22
|
+
row = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'").fetchone()
|
|
23
|
+
if row[0] == 0:
|
|
24
|
+
raise DatabaseNotInitializedError()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def get_db():
|
|
29
|
+
"""Context manager for read-only database connections.
|
|
30
|
+
|
|
31
|
+
Also calls ``load_globals()`` to refresh the global visibility/permission
|
|
32
|
+
policy cache in ``access_service`` for the current request.
|
|
33
|
+
"""
|
|
34
|
+
conn = sqlite3.connect(str(get_db_path()), timeout=10)
|
|
35
|
+
conn.row_factory = sqlite3.Row
|
|
36
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
37
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
38
|
+
conn.execute("PRAGMA query_only = ON")
|
|
39
|
+
try:
|
|
40
|
+
_check_db_initialized(conn)
|
|
41
|
+
load_globals(conn)
|
|
42
|
+
yield conn
|
|
43
|
+
finally:
|
|
44
|
+
conn.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def handle_db_errors(func):
|
|
48
|
+
"""Decorator that catches DatabaseNotInitializedError and returns a structured MCP error.
|
|
49
|
+
|
|
50
|
+
When sandbox/test mode is active, adds ``_sandbox`` metadata and overrides
|
|
51
|
+
the hint to a sandbox-specific message.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
@functools.wraps(func)
|
|
55
|
+
def wrapper(*args, **kwargs):
|
|
56
|
+
try:
|
|
57
|
+
return func(*args, **kwargs)
|
|
58
|
+
except DatabaseNotInitializedError:
|
|
59
|
+
if is_test_mode():
|
|
60
|
+
return mcp_error(
|
|
61
|
+
"DB_NOT_INITIALIZED",
|
|
62
|
+
hint="Sandbox active — run 'fp ingest' to populate, or 'fp setup --endtest' to exit",
|
|
63
|
+
metadata={"_sandbox": True},
|
|
64
|
+
)
|
|
65
|
+
return mcp_error("DB_NOT_INITIALIZED")
|
|
66
|
+
|
|
67
|
+
return wrapper
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Standardized MCP error responses.
|
|
2
|
+
|
|
3
|
+
Provides consistent error handling that:
|
|
4
|
+
- Logs detailed info internally (paths, IDs, exception details)
|
|
5
|
+
- Returns generic messages externally (closes information oracles)
|
|
6
|
+
- Enforces consistent response structure
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Error codes mapped to user-facing messages (intentionally vague)
|
|
14
|
+
ERROR_MESSAGES = {
|
|
15
|
+
"NOT_FOUND": "Nothing here",
|
|
16
|
+
"VISIBILITY_RESTRICTED": "Veiled",
|
|
17
|
+
"PERMISSION_DENIED": "Forbidden",
|
|
18
|
+
"INVALID_TYPE": "Unknown kind",
|
|
19
|
+
"INVALID_INPUT": "Unclear",
|
|
20
|
+
"MISSING_REQUIRED": "Incomplete",
|
|
21
|
+
"READ_FAILED": "Illegible",
|
|
22
|
+
"EXTRACTION_FAILED": "Resists extraction",
|
|
23
|
+
"DECODE_FAILED": "Indecipherable",
|
|
24
|
+
"CONFIG_ERROR": "Unconfigured",
|
|
25
|
+
"DEPENDENCY_MISSING": "Unequipped",
|
|
26
|
+
"DATABASE_ERROR": "Unreachable",
|
|
27
|
+
"DB_NOT_INITIALIZED": "Unpopulated",
|
|
28
|
+
"SEARCH_FAILED": "Fruitless",
|
|
29
|
+
"QUERY_INVALID": "Too brief",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Agent-friendly hints paired with each error code.
|
|
33
|
+
# Personality stays in ERROR_MESSAGES; these give actionable next steps.
|
|
34
|
+
ERROR_HINTS = {
|
|
35
|
+
"NOT_FOUND": "Check the ID or type and retry",
|
|
36
|
+
"VISIBILITY_RESTRICTED": "This item's metadata is included — content requires a visibility change",
|
|
37
|
+
"PERMISSION_DENIED": "Access policy blocks this item — request a permission change",
|
|
38
|
+
"INVALID_TYPE": "Check the type parameter — see tool description for valid values",
|
|
39
|
+
"INVALID_INPUT": "Review the required parameters and retry",
|
|
40
|
+
"MISSING_REQUIRED": "A required parameter is missing — check the tool schema",
|
|
41
|
+
"READ_FAILED": "The item exists but could not be read — retry or try a different format",
|
|
42
|
+
"EXTRACTION_FAILED": "Text extraction failed — retry with format='raw'",
|
|
43
|
+
"DECODE_FAILED": "Content is not valid text — this may be a binary item",
|
|
44
|
+
"CONFIG_ERROR": "A required service is not configured — check setup",
|
|
45
|
+
"DEPENDENCY_MISSING": "A required dependency is not installed",
|
|
46
|
+
"DATABASE_ERROR": "Storage is temporarily unavailable — retry shortly",
|
|
47
|
+
"DB_NOT_INITIALIZED": (
|
|
48
|
+
"No data indexed yet — run 'fp ingest' to populate,"
|
|
49
|
+
" or exit sandbox mode with 'fp setup --endtest'"
|
|
50
|
+
),
|
|
51
|
+
"SEARCH_FAILED": "Search could not complete — try a different query or retry",
|
|
52
|
+
"QUERY_INVALID": "Query is too short — provide at least a few words",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mcp_error(
|
|
57
|
+
code: str,
|
|
58
|
+
*,
|
|
59
|
+
detail: str = None,
|
|
60
|
+
metadata: dict = None,
|
|
61
|
+
hint: str = None,
|
|
62
|
+
internal_message: str = None,
|
|
63
|
+
level: str = "warning",
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Create a standardized MCP error response.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
code: Error code from ERROR_MESSAGES (e.g., "NOT_FOUND", "INVALID_TYPE")
|
|
69
|
+
detail: Override default message (use sparingly - may leak info)
|
|
70
|
+
metadata: Pre-filtered metadata to include in response
|
|
71
|
+
hint: Override default hint (actionable guidance for agents)
|
|
72
|
+
internal_message: Logged only, never exposed to client
|
|
73
|
+
level: Logging level ("debug", "info", "warning", "error")
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dict with 'error', 'error_code', and optionally 'metadata' and 'hint'
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
>>> mcp_error("NOT_FOUND", internal_message=f"file {id} missing")
|
|
80
|
+
{"error": "Nothing here", "error_code": "NOT_FOUND",
|
|
81
|
+
"hint": "Check the ID or type and retry"}
|
|
82
|
+
|
|
83
|
+
>>> mcp_error("NOT_FOUND", hint="Try searching by name instead")
|
|
84
|
+
{"error": "Nothing here", "error_code": "NOT_FOUND",
|
|
85
|
+
"hint": "Try searching by name instead"}
|
|
86
|
+
"""
|
|
87
|
+
# Get user-facing message (or use detail override)
|
|
88
|
+
message = detail if detail else ERROR_MESSAGES.get(code, "Error")
|
|
89
|
+
|
|
90
|
+
# Log internal details (never exposed)
|
|
91
|
+
if internal_message:
|
|
92
|
+
log_func = getattr(logger, level, logger.warning)
|
|
93
|
+
log_func(f"[{code}] {internal_message}")
|
|
94
|
+
|
|
95
|
+
# Build response
|
|
96
|
+
result = {"error": message, "error_code": code}
|
|
97
|
+
if metadata:
|
|
98
|
+
result["metadata"] = metadata
|
|
99
|
+
|
|
100
|
+
# Resolve hint: explicit override > default lookup > omit
|
|
101
|
+
resolved_hint = hint if hint else ERROR_HINTS.get(code)
|
|
102
|
+
if resolved_hint:
|
|
103
|
+
result["hint"] = resolved_hint
|
|
104
|
+
|
|
105
|
+
return result
|