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,646 @@
1
+ """Shared MCP access-policy helpers used by ``setup.py`` and ``mcp_cmd.py``
2
+ (``fp mcp view/read/check/bulk``).
3
+ """
4
+
5
+ import os
6
+ import sqlite3
7
+
8
+ from rich.table import Table
9
+
10
+ from footprinter.access import (
11
+ count_affected_entities,
12
+ recalculate_access,
13
+ recalculate_access_batched,
14
+ )
15
+ from footprinter.cli._common import connect_db, console, output_json
16
+ from footprinter.cli._prompt import SafeConfirm as Confirm
17
+ from footprinter.db.policies import (
18
+ PERMISSION_SETTINGS,
19
+ VISIBILITY_SETTINGS,
20
+ seed_access_policies, # noqa: F401 — re-exported; setup.py imports from here
21
+ set_permission_policy,
22
+ set_visibility_policy,
23
+ )
24
+ from footprinter.paths import get_db_path
25
+
26
+ CONFIRM_THRESHOLD = 100
27
+ """Entity count above which policy changes require interactive confirmation."""
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Confirmation helper
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def confirm_recalculation(conn: sqlite3.Connection, scope: str, *, yes: bool = False) -> bool:
36
+ """Count affected entities; if above threshold, prompt for confirmation.
37
+
38
+ Returns True to proceed, False to cancel.
39
+ """
40
+ counts = count_affected_entities(conn, scope)
41
+ total = sum(counts.values())
42
+ if total <= CONFIRM_THRESHOLD:
43
+ return True
44
+ if yes:
45
+ return True
46
+ parts = [f"{c:,} {t}{'s' if c != 1 else ''}" for t, c in counts.items() if c]
47
+ console.print(f"\nThis will recalculate access on [bold]{total:,}[/bold] entities across {len(counts)} tables.")
48
+ for part in parts:
49
+ console.print(f" {part}")
50
+ return Confirm.ask("Proceed?", default=False)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Progress-aware recalculation
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def recalculate_with_progress(conn: sqlite3.Connection, scope: str) -> dict[str, int]:
59
+ """Recalculate access with a Rich progress bar for large scopes.
60
+
61
+ If total affected entities <= CONFIRM_THRESHOLD, runs the fast unbatched
62
+ path and prints a one-line summary. Otherwise shows a Rich progress bar
63
+ with per-batch updates.
64
+ """
65
+ counts = count_affected_entities(conn, scope)
66
+ total = sum(counts.values())
67
+
68
+ if total <= CONFIRM_THRESHOLD:
69
+ return recalculate_access(conn, scope)
70
+
71
+ from rich.progress import Progress
72
+
73
+ with Progress(console=console) as progress:
74
+ task = progress.add_task("Recalculating access…", total=total)
75
+ stats = recalculate_access_batched(
76
+ conn,
77
+ scope,
78
+ on_batch=lambda n: progress.advance(task, advance=n),
79
+ )
80
+ return stats
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # DB / path helpers
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ def get_policy_db() -> sqlite3.Connection | None:
89
+ """Open a connection to the Footprinter database for policy operations."""
90
+ return connect_db(get_db_path())
91
+
92
+
93
+ def normalize_path(path: str) -> str:
94
+ """Convert absolute paths to ``~/…`` form for consistent config storage.
95
+
96
+ Strips trailing slashes and collapses double slashes via ``os.path.normpath``.
97
+ Paths not under ``$HOME`` are returned normalized but unchanged.
98
+ """
99
+ normalized = os.path.normpath(path)
100
+ home = os.path.expanduser("~")
101
+ if normalized.startswith(home + os.sep):
102
+ normalized = "~" + normalized[len(home) :]
103
+ elif normalized == home:
104
+ normalized = "~"
105
+ return normalized
106
+
107
+
108
+ def abbreviate_home(path: str) -> str:
109
+ """Replace ``$HOME`` prefix with ``~`` for display."""
110
+ home = os.path.expanduser("~")
111
+ if path.startswith(home + os.sep):
112
+ return "~" + path[len(home) :]
113
+ elif path == home:
114
+ return "~"
115
+ return path
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Single-path check helpers
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ def check_file_path(conn: sqlite3.Connection, path: str, json_output: bool, verbose: bool = False) -> int:
124
+ """Check resolved access for a single file path."""
125
+ from footprinter.permissions import resolve_permission_with_source
126
+ from footprinter.visibility import resolve_visibility_with_source
127
+
128
+ expanded = os.path.expanduser(os.path.normpath(path))
129
+
130
+ row = conn.execute(
131
+ "SELECT id, name, project_id FROM files WHERE path = ? AND status != 'removed'",
132
+ (expanded,),
133
+ ).fetchone()
134
+
135
+ if row:
136
+ file_id = row["id"]
137
+ perm_val, perm_src = resolve_permission_with_source(conn, "file", file_id)
138
+ vis_val, vis_src = resolve_visibility_with_source(conn, "file", file_id)
139
+ perm_str = "allow" if perm_val else "deny"
140
+ found = True
141
+ client_id = None
142
+ if row["project_id"] is not None:
143
+ proj = conn.execute(
144
+ "SELECT client_id FROM projects WHERE id = ?",
145
+ (row["project_id"],),
146
+ ).fetchone()
147
+ if proj:
148
+ client_id = proj["client_id"]
149
+ chain = build_policy_chain(conn, expanded, file_id, row["project_id"], client_id)
150
+ else:
151
+ # Check folders table before falling through to not-found
152
+ folder_row = conn.execute(
153
+ "SELECT id, name, path FROM folders WHERE path = ?",
154
+ (expanded,),
155
+ ).fetchone()
156
+ if folder_row:
157
+ return check_folder(conn, expanded, json_output, verbose=verbose)
158
+
159
+ perm_str, perm_src = simulate_path_permission(conn, expanded)
160
+ vis_val, vis_src = simulate_path_visibility(conn, expanded)
161
+ found = False
162
+ file_id = None
163
+ chain = build_policy_chain(conn, expanded, None, None, None)
164
+
165
+ display_path = abbreviate_home(expanded)
166
+
167
+ if json_output:
168
+ data = {
169
+ "type": "file",
170
+ "path": display_path,
171
+ "file_id": file_id,
172
+ "found_in_db": found,
173
+ "permission": {"resolved": perm_str, "source": perm_src},
174
+ "visibility": {"resolved": vis_val, "source": vis_src},
175
+ "chain": chain,
176
+ }
177
+ output_json(data)
178
+ else:
179
+ console.print(f"\nAccess Check: [bold]{display_path}[/bold]")
180
+ if not found:
181
+ console.print(" [dim]Not found in files or folders — resolving from policy chain[/dim]")
182
+ console.print(" [dim]Tip: Use --folder for directory aggregate, --project for project ID[/dim]")
183
+ console.print()
184
+ console.print(f" Permission: [bold]{perm_str}[/bold] (from {perm_src})")
185
+ console.print(f" Visibility: [bold]{vis_val}[/bold] (from {vis_src})")
186
+ if chain:
187
+ console.print()
188
+ print_policy_chain(chain)
189
+
190
+ return 0
191
+
192
+
193
+ def check_folder(conn: sqlite3.Connection, path: str, json_output: bool, verbose: bool) -> int:
194
+ """Check resolved access for all files under a folder path."""
195
+ from footprinter.permissions import batch_resolve_permissions
196
+ from footprinter.visibility import batch_resolve_visibility
197
+
198
+ expanded = os.path.expanduser(os.path.normpath(path))
199
+ if not expanded.endswith(os.sep):
200
+ expanded += os.sep
201
+
202
+ rows = conn.execute(
203
+ "SELECT id, name FROM files WHERE path LIKE ? AND status != 'removed'",
204
+ (expanded + "%",),
205
+ ).fetchall()
206
+
207
+ display_path = abbreviate_home(expanded)
208
+ file_count = len(rows)
209
+
210
+ if file_count == 0:
211
+ if json_output:
212
+ data = {
213
+ "type": "folder",
214
+ "folder": display_path,
215
+ "file_count": 0,
216
+ "permission_counts": {"allow": 0, "deny": 0},
217
+ "visibility_counts": {"visible": 0, "opaque": 0, "hidden": 0},
218
+ }
219
+ output_json(data)
220
+ else:
221
+ console.print(f"\nFolder Check: [bold]{display_path}[/bold] (0 files)")
222
+ console.print(" [dim]No indexed files in this folder.[/dim]")
223
+ return 0
224
+
225
+ ids = [r["id"] for r in rows]
226
+ perm_results = batch_resolve_permissions(conn, "file", ids)
227
+ vis_results = batch_resolve_visibility(conn, "file", ids)
228
+
229
+ perm_counts = {"allow": 0, "deny": 0}
230
+ vis_counts = {"visible": 0, "opaque": 0, "hidden": 0}
231
+
232
+ for aid in ids:
233
+ allowed, _ = perm_results.get(aid, (False, "baseline"))
234
+ perm_counts["allow" if allowed else "deny"] += 1
235
+
236
+ vis_state, _ = vis_results.get(aid, ("opaque", "baseline"))
237
+ vis_counts[vis_state] = vis_counts.get(vis_state, 0) + 1
238
+
239
+ if json_output:
240
+ data = {
241
+ "type": "folder",
242
+ "folder": display_path,
243
+ "file_count": file_count,
244
+ "permission_counts": perm_counts,
245
+ "visibility_counts": vis_counts,
246
+ }
247
+ if verbose:
248
+ file_details = []
249
+ for r in rows:
250
+ aid = r["id"]
251
+ allowed, p_src = perm_results.get(aid, (False, "baseline"))
252
+ vis_state, v_src = vis_results.get(aid, ("opaque", "baseline"))
253
+ file_details.append(
254
+ {
255
+ "name": r["name"],
256
+ "permission": "allow" if allowed else "deny",
257
+ "permission_source": p_src,
258
+ "visibility": vis_state,
259
+ "visibility_source": v_src,
260
+ }
261
+ )
262
+ data["files"] = file_details
263
+ output_json(data)
264
+ else:
265
+ console.print(f"\nFolder Check: [bold]{display_path}[/bold] ({file_count} files)")
266
+ console.print()
267
+ console.print(f" Permission: allow: {perm_counts['allow']} deny: {perm_counts['deny']}")
268
+ console.print(
269
+ f" Visibility: visible: {vis_counts['visible']} "
270
+ f"opaque: {vis_counts['opaque']} hidden: {vis_counts['hidden']}"
271
+ )
272
+
273
+ if verbose:
274
+ console.print()
275
+ table = Table(title="Files")
276
+ table.add_column("Name", style="cyan")
277
+ table.add_column("Permission")
278
+ table.add_column("Source", style="dim")
279
+ table.add_column("Visibility")
280
+ table.add_column("Source", style="dim")
281
+ for r in rows:
282
+ aid = r["id"]
283
+ allowed, p_src = perm_results.get(aid, (False, "baseline"))
284
+ vis_state, v_src = vis_results.get(aid, ("opaque", "baseline"))
285
+ table.add_row(
286
+ r["name"],
287
+ "allow" if allowed else "deny",
288
+ p_src,
289
+ vis_state,
290
+ v_src,
291
+ )
292
+ console.print(table)
293
+
294
+ return 0
295
+
296
+
297
+ def check_project(conn: sqlite3.Connection, project_id: int, json_output: bool) -> int:
298
+ """Check resolved access for a project by ID."""
299
+ from footprinter.permissions import resolve_permission_with_source
300
+ from footprinter.visibility import resolve_visibility_with_source
301
+
302
+ row = conn.execute("SELECT id, project_name FROM projects WHERE id = ?", (project_id,)).fetchone()
303
+ if not row:
304
+ console.print(f"[red]Project not found:[/red] id={project_id}")
305
+ return 1
306
+
307
+ perm_val, perm_src = resolve_permission_with_source(conn, "project", project_id)
308
+ vis_val, vis_src = resolve_visibility_with_source(conn, "project", project_id)
309
+ perm_str = "allow" if perm_val else "deny"
310
+
311
+ if json_output:
312
+ data = {
313
+ "project_id": project_id,
314
+ "project_name": row["project_name"],
315
+ "permission": {"resolved": perm_str, "source": perm_src},
316
+ "visibility": {"resolved": vis_val, "source": vis_src},
317
+ }
318
+ output_json(data)
319
+ else:
320
+ console.print(f"\nProject Check: [bold]{row['project_name']}[/bold] (id={project_id})")
321
+ console.print()
322
+ console.print(f" Permission: [bold]{perm_str}[/bold] (from {perm_src})")
323
+ console.print(f" Visibility: [bold]{vis_val}[/bold] (from {vis_src})")
324
+
325
+ return 0
326
+
327
+
328
+ def check_client(conn: sqlite3.Connection, client_id: int, json_output: bool) -> int:
329
+ """Check resolved access for a client by ID."""
330
+ from footprinter.permissions import resolve_permission_with_source
331
+ from footprinter.visibility import resolve_visibility_with_source
332
+
333
+ row = conn.execute("SELECT id, name FROM clients WHERE id = ?", (client_id,)).fetchone()
334
+ if not row:
335
+ console.print(f"[red]Client not found:[/red] id={client_id}")
336
+ return 1
337
+
338
+ perm_val, perm_src = resolve_permission_with_source(conn, "client", client_id)
339
+ vis_val, vis_src = resolve_visibility_with_source(conn, "client", client_id)
340
+ perm_str = "allow" if perm_val else "deny"
341
+
342
+ if json_output:
343
+ data = {
344
+ "client_id": client_id,
345
+ "client_name": row["name"],
346
+ "permission": {"resolved": perm_str, "source": perm_src},
347
+ "visibility": {"resolved": vis_val, "source": vis_src},
348
+ }
349
+ output_json(data)
350
+ else:
351
+ console.print(f"\nClient Check: [bold]{row['name']}[/bold] (id={client_id})")
352
+ console.print()
353
+ console.print(f" Permission: [bold]{perm_str}[/bold] (from {perm_src})")
354
+ console.print(f" Visibility: [bold]{vis_val}[/bold] (from {vis_src})")
355
+
356
+ return 0
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # Policy chain / simulation
361
+ # ---------------------------------------------------------------------------
362
+
363
+
364
+ def build_policy_chain(
365
+ conn: sqlite3.Connection,
366
+ path: str,
367
+ file_id: int | None,
368
+ project_id: int | None,
369
+ client_id: int | None,
370
+ ) -> list[dict]:
371
+ """Build diagnostic policy chain showing what policies exist at each scope level."""
372
+ from footprinter.permissions import BASELINE_PERMISSION
373
+ from footprinter.visibility import BASELINE_VISIBILITY
374
+
375
+ chain = []
376
+
377
+ # 1. File-level
378
+ if file_id is not None:
379
+ perm = conn.execute(
380
+ "SELECT setting FROM permission_policies WHERE scope = ?",
381
+ (f"file:{file_id}",),
382
+ ).fetchone()
383
+ vis = conn.execute(
384
+ "SELECT setting FROM visibility_policies WHERE scope = ?",
385
+ (f"file:{file_id}",),
386
+ ).fetchone()
387
+ chain.append(
388
+ {
389
+ "scope": f"file:{file_id}",
390
+ "permission": perm["setting"] if perm else None,
391
+ "visibility": vis["setting"] if vis else None,
392
+ }
393
+ )
394
+
395
+ # 2. Folder prefix policies (longest first)
396
+ if path:
397
+ folder_perms = conn.execute(
398
+ "SELECT scope, setting FROM permission_policies WHERE scope LIKE 'folder:%' ORDER BY LENGTH(scope) DESC"
399
+ ).fetchall()
400
+ folder_vis = conn.execute(
401
+ "SELECT scope, setting FROM visibility_policies WHERE scope LIKE 'folder:%' ORDER BY LENGTH(scope) DESC"
402
+ ).fetchall()
403
+
404
+ folder_perm_map = {r["scope"]: r["setting"] for r in folder_perms}
405
+ folder_vis_map = {r["scope"]: r["setting"] for r in folder_vis}
406
+ all_folder_scopes = sorted(
407
+ set(folder_perm_map.keys()) | set(folder_vis_map.keys()),
408
+ key=lambda s: len(s),
409
+ reverse=True,
410
+ )
411
+
412
+ for scope in all_folder_scopes:
413
+ prefix = scope[len("folder:") :]
414
+ expanded_prefix = os.path.expanduser(prefix)
415
+ if path.startswith(expanded_prefix):
416
+ chain.append(
417
+ {
418
+ "scope": scope,
419
+ "permission": folder_perm_map.get(scope),
420
+ "visibility": folder_vis_map.get(scope),
421
+ }
422
+ )
423
+
424
+ # 3. Project-level
425
+ if project_id is not None:
426
+ perm = conn.execute(
427
+ "SELECT setting FROM permission_policies WHERE scope = ?",
428
+ (f"project:{project_id}",),
429
+ ).fetchone()
430
+ vis = conn.execute(
431
+ "SELECT setting FROM visibility_policies WHERE scope = ?",
432
+ (f"project:{project_id}",),
433
+ ).fetchone()
434
+ chain.append(
435
+ {
436
+ "scope": f"project:{project_id}",
437
+ "permission": perm["setting"] if perm else None,
438
+ "visibility": vis["setting"] if vis else None,
439
+ }
440
+ )
441
+
442
+ # 4. Client-level
443
+ if client_id is not None:
444
+ perm = conn.execute(
445
+ "SELECT setting FROM permission_policies WHERE scope = ?",
446
+ (f"client:{client_id}",),
447
+ ).fetchone()
448
+ vis = conn.execute(
449
+ "SELECT setting FROM visibility_policies WHERE scope = ?",
450
+ (f"client:{client_id}",),
451
+ ).fetchone()
452
+ chain.append(
453
+ {
454
+ "scope": f"client:{client_id}",
455
+ "permission": perm["setting"] if perm else None,
456
+ "visibility": vis["setting"] if vis else None,
457
+ }
458
+ )
459
+
460
+ # 5. Source: files
461
+ src_perm = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'source:files'").fetchone()
462
+ src_vis = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'source:files'").fetchone()
463
+ chain.append(
464
+ {
465
+ "scope": "source:files",
466
+ "permission": src_perm["setting"] if src_perm else None,
467
+ "visibility": src_vis["setting"] if src_vis else None,
468
+ }
469
+ )
470
+
471
+ # 6. Global
472
+ global_perm = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
473
+ global_vis = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
474
+ chain.append(
475
+ {
476
+ "scope": "global",
477
+ "permission": global_perm["setting"] if global_perm else None,
478
+ "visibility": global_vis["setting"] if global_vis else None,
479
+ }
480
+ )
481
+
482
+ # 7. Baseline
483
+ chain.append(
484
+ {
485
+ "scope": "baseline",
486
+ "permission": "allow" if BASELINE_PERMISSION else "deny",
487
+ "visibility": BASELINE_VISIBILITY,
488
+ }
489
+ )
490
+
491
+ return chain
492
+
493
+
494
+ def print_policy_chain(chain: list[dict]) -> None:
495
+ """Print the policy chain as a Rich table."""
496
+ table = Table(title="Policy Chain")
497
+ table.add_column("Scope", style="cyan")
498
+ table.add_column("Permission")
499
+ table.add_column("Visibility")
500
+ for entry in chain:
501
+ table.add_row(
502
+ entry["scope"],
503
+ entry.get("permission") or "-",
504
+ entry.get("visibility") or "-",
505
+ )
506
+ console.print(table)
507
+
508
+
509
+ def simulate_path_permission(conn: sqlite3.Connection, expanded_path: str) -> tuple[str, str]:
510
+ """Simulate permission resolution for a path not in the database.
511
+
512
+ Walks folder prefix -> source:files -> global -> baseline.
513
+ """
514
+ from footprinter.permissions import BASELINE_PERMISSION
515
+
516
+ rows = conn.execute(
517
+ "SELECT scope, setting FROM permission_policies WHERE scope LIKE 'folder:%' ORDER BY LENGTH(scope) DESC"
518
+ ).fetchall()
519
+ for row in rows:
520
+ prefix = row["scope"][len("folder:") :]
521
+ expanded_prefix = os.path.expanduser(prefix)
522
+ if expanded_path.startswith(expanded_prefix):
523
+ return (row["setting"], row["scope"])
524
+
525
+ src = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'source:files'").fetchone()
526
+ if src:
527
+ return (src["setting"], "source:files")
528
+
529
+ gl = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
530
+ if gl:
531
+ return (gl["setting"], "global")
532
+
533
+ return ("allow" if BASELINE_PERMISSION else "deny", "baseline")
534
+
535
+
536
+ def simulate_path_visibility(conn: sqlite3.Connection, expanded_path: str) -> tuple[str, str]:
537
+ """Simulate visibility resolution for a path not in the database.
538
+
539
+ Walks folder prefix -> source:files -> global -> baseline.
540
+ """
541
+ from footprinter.visibility import BASELINE_VISIBILITY
542
+
543
+ rows = conn.execute(
544
+ "SELECT scope, setting FROM visibility_policies WHERE scope LIKE 'folder:%' ORDER BY LENGTH(scope) DESC"
545
+ ).fetchall()
546
+ for row in rows:
547
+ prefix = row["scope"][len("folder:") :]
548
+ expanded_prefix = os.path.expanduser(prefix)
549
+ if expanded_path.startswith(expanded_prefix):
550
+ return (row["setting"], row["scope"])
551
+
552
+ src = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'source:files'").fetchone()
553
+ if src:
554
+ return (src["setting"], "source:files")
555
+
556
+ gl = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
557
+ if gl:
558
+ return (gl["setting"], "global")
559
+
560
+ return (BASELINE_VISIBILITY, "baseline")
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # Bulk helper
565
+ # ---------------------------------------------------------------------------
566
+
567
+
568
+ def bulk_apply(
569
+ conn: sqlite3.Connection,
570
+ *,
571
+ folder: str | None,
572
+ project: int | None,
573
+ permission: str | None,
574
+ visibility: str | None,
575
+ dry_run: bool = False,
576
+ yes: bool = False,
577
+ ) -> int:
578
+ """Bulk set permission and/or visibility for a folder or project scope.
579
+
580
+ Returns 0 on success, 1 on validation error.
581
+ """
582
+ if not folder and project is None:
583
+ console.print("[red]Specify a target:[/red] --folder or --project")
584
+ return 1
585
+
586
+ if not permission and not visibility:
587
+ console.print("[red]Specify at least one setting:[/red] --permission or --visibility")
588
+ return 1
589
+
590
+ if permission and permission not in PERMISSION_SETTINGS:
591
+ console.print(f"[red]Invalid permission:[/red] {permission}\n Valid: {', '.join(sorted(PERMISSION_SETTINGS))}")
592
+ return 1
593
+ if visibility and visibility not in VISIBILITY_SETTINGS:
594
+ console.print(f"[red]Invalid visibility:[/red] {visibility}\n Valid: {', '.join(sorted(VISIBILITY_SETTINGS))}")
595
+ return 1
596
+
597
+ if folder:
598
+ normalized = normalize_path(folder)
599
+ scope = f"folder:{normalized}"
600
+ target_label = f"folder [cyan]{normalized}[/cyan]"
601
+ else:
602
+ row = conn.execute("SELECT project_name FROM projects WHERE id = ?", (project,)).fetchone()
603
+ if not row:
604
+ console.print(f"[red]Project not found:[/red] id={project}")
605
+ return 1
606
+ scope = f"project:{project}"
607
+ target_label = f"project [cyan]{row['project_name']}[/cyan] (id={project})"
608
+
609
+ counts = count_affected_entities(conn, scope)
610
+ total = sum(counts.values())
611
+ parts = [f"{c:,} {t}{'s' if c != 1 else ''}" for t, c in counts.items() if c]
612
+
613
+ settings_desc = []
614
+ if permission:
615
+ settings_desc.append(f"permission=[bold]{permission}[/bold]")
616
+ if visibility:
617
+ settings_desc.append(f"visibility=[bold]{visibility}[/bold]")
618
+
619
+ console.print(
620
+ f"\nScope: [cyan]{scope}[/cyan] ({total:,} entities: {', '.join(parts)})"
621
+ if parts
622
+ else f"\nScope: [cyan]{scope}[/cyan] (0 entities)"
623
+ )
624
+ console.print(f" Setting: {', '.join(settings_desc)}")
625
+
626
+ if dry_run:
627
+ console.print("\n[dim]Dry run — no changes made.[/dim]")
628
+ return 0
629
+
630
+ if not yes and total > CONFIRM_THRESHOLD:
631
+ if not Confirm.ask("\nProceed?", default=False):
632
+ console.print("[dim]Cancelled.[/dim]")
633
+ return 0
634
+
635
+ if permission:
636
+ set_permission_policy(conn, scope, permission)
637
+ if visibility:
638
+ set_visibility_policy(conn, scope, visibility)
639
+
640
+ stats = recalculate_with_progress(conn, scope)
641
+ console.print(f"\n[green]Applied[/green] to {target_label}")
642
+ if stats:
643
+ parts = [f"{c} {t}{'s' if c != 1 else ''}" for t, c in stats.items() if c]
644
+ if parts:
645
+ console.print(f" [dim]Recalculated: {', '.join(parts)}[/dim]")
646
+ return 0