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,750 @@
1
+ """fp mcp — MCP server and access policy management.
2
+
3
+ Subcommands:
4
+ fp mcp Start the MCP server
5
+ fp mcp view {show,set,delete,check,reset} Visibility policy management
6
+ fp mcp read {show,set,delete,check,reset} Permission policy management
7
+ fp mcp check [path] Combined resolution (both layers)
8
+ fp mcp bulk Bulk policy changes
9
+ """
10
+
11
+ import os
12
+
13
+ from rich.table import Table
14
+
15
+ from footprinter.cli._common import FORMATTER, add_json_flag, console, output_json
16
+ from footprinter.cli._policy_helpers import (
17
+ abbreviate_home,
18
+ bulk_apply,
19
+ check_client,
20
+ check_file_path,
21
+ check_folder,
22
+ check_project,
23
+ confirm_recalculation,
24
+ get_policy_db,
25
+ recalculate_with_progress,
26
+ simulate_path_permission,
27
+ simulate_path_visibility,
28
+ )
29
+ from footprinter.db.policies import (
30
+ PERMISSION_SETTINGS,
31
+ VISIBILITY_SETTINGS,
32
+ clear_permission_policies,
33
+ clear_visibility_policies,
34
+ delete_permission_policy,
35
+ delete_visibility_policy,
36
+ list_permission_policies,
37
+ list_visibility_policies,
38
+ seed_permission_defaults,
39
+ seed_visibility_defaults,
40
+ set_permission_policy,
41
+ set_visibility_policy,
42
+ )
43
+
44
+
45
+ def _print_recalc_stats(stats: dict[str, int]) -> None:
46
+ """Print a dim summary of recalculation results."""
47
+ if not stats:
48
+ return
49
+ parts = [f"{count} {etype}{'s' if count != 1 else ''}" for etype, count in stats.items() if count]
50
+ if parts:
51
+ console.print(f" [dim]Recalculated: {', '.join(parts)}[/dim]")
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Handler: server start
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def _start_server(args) -> None:
60
+ from footprinter.mcp.server import main
61
+
62
+ main()
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # View handlers (visibility layer only)
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def _view_show(args) -> None:
71
+ json_output = getattr(args, "json", False)
72
+
73
+ conn = get_policy_db()
74
+ if conn is None:
75
+ if json_output:
76
+ output_json([])
77
+ else:
78
+ console.print("[yellow]No database found.[/yellow]")
79
+ return
80
+
81
+ try:
82
+ rows = list_visibility_policies(conn)
83
+
84
+ if json_output:
85
+ output_json(rows)
86
+ return
87
+
88
+ if not rows:
89
+ console.print("No visibility policies configured.")
90
+ console.print(" [dim]Run: fp mcp view reset[/dim]")
91
+ return
92
+
93
+ table = Table(title="Visibility Policies")
94
+ table.add_column("Scope", style="cyan")
95
+ table.add_column("Setting")
96
+ table.add_column("Updated", style="dim")
97
+ for row in rows:
98
+ table.add_row(row["scope"], row["setting"], str(row["updated_at"] or ""))
99
+ console.print(table)
100
+
101
+ console.print()
102
+ console.print("[dim]Baseline (when no policy matches): visibility=opaque[/dim]")
103
+ finally:
104
+ conn.close()
105
+
106
+
107
+ def _view_set(args) -> None:
108
+ setting = args.level
109
+ if setting not in VISIBILITY_SETTINGS:
110
+ console.print(
111
+ f"[red]Invalid visibility setting:[/red] {setting}\n Valid: {', '.join(sorted(VISIBILITY_SETTINGS))}"
112
+ )
113
+ raise SystemExit(1)
114
+
115
+ conn = get_policy_db()
116
+ if conn is None:
117
+ console.print("[yellow]No database found.[/yellow]")
118
+ raise SystemExit(1)
119
+
120
+ try:
121
+ yes = getattr(args, "yes", False)
122
+ if not confirm_recalculation(conn, args.scope, yes=yes):
123
+ console.print("[dim]Cancelled.[/dim]")
124
+ return
125
+ set_visibility_policy(conn, args.scope, setting)
126
+ console.print(f"Set visibility_policies: [cyan]{args.scope}[/cyan] = [bold]{setting}[/bold]")
127
+ stats = recalculate_with_progress(conn, args.scope)
128
+ _print_recalc_stats(stats)
129
+ finally:
130
+ conn.close()
131
+
132
+
133
+ def _view_delete(args) -> None:
134
+ conn = get_policy_db()
135
+ if conn is None:
136
+ console.print("[yellow]No database found.[/yellow]")
137
+ return
138
+
139
+ try:
140
+ exists = conn.execute("SELECT 1 FROM visibility_policies WHERE scope = ?", (args.scope,)).fetchone()
141
+ if not exists:
142
+ console.print(f"No visibility policy found for scope [cyan]{args.scope}[/cyan]")
143
+ return
144
+
145
+ yes = getattr(args, "yes", False)
146
+ if not confirm_recalculation(conn, args.scope, yes=yes):
147
+ console.print("[dim]Cancelled.[/dim]")
148
+ return
149
+ deleted = delete_visibility_policy(conn, args.scope)
150
+ if deleted:
151
+ console.print(f"Deleted visibility policy for [cyan]{args.scope}[/cyan]")
152
+ stats = recalculate_with_progress(conn, args.scope)
153
+ _print_recalc_stats(stats)
154
+ else:
155
+ console.print(f"No visibility policy found for scope [cyan]{args.scope}[/cyan]")
156
+ finally:
157
+ conn.close()
158
+
159
+
160
+ def _view_check(args) -> None:
161
+ from footprinter.visibility import BASELINE_VISIBILITY, resolve_visibility_with_source
162
+
163
+ path = getattr(args, "path", None)
164
+ json_output = getattr(args, "json", False)
165
+
166
+ conn = get_policy_db()
167
+ if conn is None:
168
+ vis_str = BASELINE_VISIBILITY
169
+ if json_output:
170
+ output_json(
171
+ {
172
+ "path": path or "(none)",
173
+ "visibility": {"resolved": vis_str, "source": "baseline"},
174
+ }
175
+ )
176
+ else:
177
+ console.print("[yellow]No database found.[/yellow] Showing baseline.")
178
+ console.print(f" Visibility: [bold]{vis_str}[/bold] (baseline)")
179
+ return
180
+
181
+ try:
182
+ if not path:
183
+ # Show global resolution
184
+ row = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
185
+ resolved = row["setting"] if row else BASELINE_VISIBILITY
186
+ source = "global" if row else "baseline"
187
+ if json_output:
188
+ output_json({"visibility": {"resolved": resolved, "source": source}})
189
+ else:
190
+ console.print(f" Visibility: [bold]{resolved}[/bold] (from {source})")
191
+ return
192
+
193
+ expanded = os.path.expanduser(os.path.normpath(path))
194
+ display = abbreviate_home(expanded)
195
+
196
+ # Try to find file in DB
197
+ row = conn.execute(
198
+ "SELECT id FROM files WHERE path = ? AND status != 'removed'",
199
+ (expanded,),
200
+ ).fetchone()
201
+
202
+ found_in_db = row is not None
203
+ if row:
204
+ vis_val, vis_src = resolve_visibility_with_source(conn, "file", row["id"])
205
+ else:
206
+ # Fall back to folders table
207
+ folder_row = conn.execute(
208
+ "SELECT id FROM folders WHERE path = ?",
209
+ (expanded,),
210
+ ).fetchone()
211
+ if folder_row:
212
+ found_in_db = True
213
+ vis_val, vis_src = resolve_visibility_with_source(conn, "folder", folder_row["id"])
214
+ else:
215
+ vis_val, vis_src = simulate_path_visibility(conn, expanded)
216
+
217
+ if json_output:
218
+ output_json(
219
+ {
220
+ "path": display,
221
+ "found_in_db": found_in_db,
222
+ "visibility": {"resolved": vis_val, "source": vis_src},
223
+ }
224
+ )
225
+ else:
226
+ console.print(f"\nVisibility Check: [bold]{display}[/bold]")
227
+ if not found_in_db:
228
+ console.print(" [dim]Not found in files or folders — resolving from policy chain[/dim]")
229
+ console.print(f" Visibility: [bold]{vis_val}[/bold] (from {vis_src})")
230
+ finally:
231
+ conn.close()
232
+
233
+
234
+ def _view_reset(args) -> None:
235
+ conn = get_policy_db()
236
+ if conn is None:
237
+ console.print("[yellow]No database found.[/yellow]")
238
+ return
239
+
240
+ try:
241
+ yes = getattr(args, "yes", False)
242
+ if not confirm_recalculation(conn, "global", yes=yes):
243
+ console.print("[dim]Cancelled.[/dim]")
244
+ return
245
+ deleted = clear_visibility_policies(conn)
246
+ console.print(f"Cleared {deleted} visibility policies")
247
+ seed_visibility_defaults(conn)
248
+ console.print("Re-seeded visibility defaults (global=visible)")
249
+ stats = recalculate_with_progress(conn, "global")
250
+ _print_recalc_stats(stats)
251
+ finally:
252
+ conn.close()
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Read handlers (permission layer only)
257
+ # ---------------------------------------------------------------------------
258
+
259
+
260
+ def _read_show(args) -> None:
261
+ json_output = getattr(args, "json", False)
262
+
263
+ conn = get_policy_db()
264
+ if conn is None:
265
+ if json_output:
266
+ output_json([])
267
+ else:
268
+ console.print("[yellow]No database found.[/yellow]")
269
+ return
270
+
271
+ try:
272
+ rows = list_permission_policies(conn)
273
+
274
+ if json_output:
275
+ output_json(rows)
276
+ return
277
+
278
+ if not rows:
279
+ console.print("No permission policies configured.")
280
+ console.print(" [dim]Run: fp mcp read reset[/dim]")
281
+ return
282
+
283
+ table = Table(title="Permission Policies")
284
+ table.add_column("Scope", style="cyan")
285
+ table.add_column("Setting")
286
+ table.add_column("Updated", style="dim")
287
+ for row in rows:
288
+ table.add_row(row["scope"], row["setting"], str(row["updated_at"] or ""))
289
+ console.print(table)
290
+
291
+ console.print()
292
+ console.print("[dim]Baseline (when no policy matches): permission=allow[/dim]")
293
+ finally:
294
+ conn.close()
295
+
296
+
297
+ def _read_set(args) -> None:
298
+ setting = args.level
299
+ if setting not in PERMISSION_SETTINGS:
300
+ console.print(
301
+ f"[red]Invalid permission setting:[/red] {setting}\n Valid: {', '.join(sorted(PERMISSION_SETTINGS))}"
302
+ )
303
+ raise SystemExit(1)
304
+
305
+ conn = get_policy_db()
306
+ if conn is None:
307
+ console.print("[yellow]No database found.[/yellow]")
308
+ raise SystemExit(1)
309
+
310
+ try:
311
+ yes = getattr(args, "yes", False)
312
+ if not confirm_recalculation(conn, args.scope, yes=yes):
313
+ console.print("[dim]Cancelled.[/dim]")
314
+ return
315
+ set_permission_policy(conn, args.scope, setting)
316
+ console.print(f"Set permission_policies: [cyan]{args.scope}[/cyan] = [bold]{setting}[/bold]")
317
+ stats = recalculate_with_progress(conn, args.scope)
318
+ _print_recalc_stats(stats)
319
+ finally:
320
+ conn.close()
321
+
322
+
323
+ def _read_delete(args) -> None:
324
+ conn = get_policy_db()
325
+ if conn is None:
326
+ console.print("[yellow]No database found.[/yellow]")
327
+ return
328
+
329
+ try:
330
+ exists = conn.execute("SELECT 1 FROM permission_policies WHERE scope = ?", (args.scope,)).fetchone()
331
+ if not exists:
332
+ console.print(f"No permission policy found for scope [cyan]{args.scope}[/cyan]")
333
+ return
334
+
335
+ yes = getattr(args, "yes", False)
336
+ if not confirm_recalculation(conn, args.scope, yes=yes):
337
+ console.print("[dim]Cancelled.[/dim]")
338
+ return
339
+ deleted = delete_permission_policy(conn, args.scope)
340
+ if deleted:
341
+ console.print(f"Deleted permission policy for [cyan]{args.scope}[/cyan]")
342
+ stats = recalculate_with_progress(conn, args.scope)
343
+ _print_recalc_stats(stats)
344
+ else:
345
+ console.print(f"No permission policy found for scope [cyan]{args.scope}[/cyan]")
346
+ finally:
347
+ conn.close()
348
+
349
+
350
+ def _read_check(args) -> None:
351
+ from footprinter.permissions import BASELINE_PERMISSION, resolve_permission_with_source
352
+
353
+ path = getattr(args, "path", None)
354
+ json_output = getattr(args, "json", False)
355
+
356
+ conn = get_policy_db()
357
+ if conn is None:
358
+ perm_str = "allow" if BASELINE_PERMISSION else "deny"
359
+ if json_output:
360
+ output_json(
361
+ {
362
+ "path": path or "(none)",
363
+ "permission": {"resolved": perm_str, "source": "baseline"},
364
+ }
365
+ )
366
+ else:
367
+ console.print("[yellow]No database found.[/yellow] Showing baseline.")
368
+ console.print(f" Permission: [bold]{perm_str}[/bold] (baseline)")
369
+ return
370
+
371
+ try:
372
+ if not path:
373
+ row = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
374
+ resolved = row["setting"] if row else ("allow" if BASELINE_PERMISSION else "deny")
375
+ source = "global" if row else "baseline"
376
+ if json_output:
377
+ output_json({"permission": {"resolved": resolved, "source": source}})
378
+ else:
379
+ console.print(f" Permission: [bold]{resolved}[/bold] (from {source})")
380
+ return
381
+
382
+ expanded = os.path.expanduser(os.path.normpath(path))
383
+ display = abbreviate_home(expanded)
384
+
385
+ row = conn.execute(
386
+ "SELECT id FROM files WHERE path = ? AND status != 'removed'",
387
+ (expanded,),
388
+ ).fetchone()
389
+
390
+ found_in_db = row is not None
391
+ if row:
392
+ perm_val, perm_src = resolve_permission_with_source(conn, "file", row["id"])
393
+ perm_str = "allow" if perm_val else "deny"
394
+ else:
395
+ # Fall back to folders table
396
+ folder_row = conn.execute(
397
+ "SELECT id FROM folders WHERE path = ?",
398
+ (expanded,),
399
+ ).fetchone()
400
+ if folder_row:
401
+ found_in_db = True
402
+ perm_val, perm_src = resolve_permission_with_source(conn, "folder", folder_row["id"])
403
+ perm_str = "allow" if perm_val else "deny"
404
+ else:
405
+ perm_str, perm_src = simulate_path_permission(conn, expanded)
406
+
407
+ if json_output:
408
+ output_json(
409
+ {
410
+ "path": display,
411
+ "found_in_db": found_in_db,
412
+ "permission": {"resolved": perm_str, "source": perm_src},
413
+ }
414
+ )
415
+ else:
416
+ console.print(f"\nPermission Check: [bold]{display}[/bold]")
417
+ if not found_in_db:
418
+ console.print(" [dim]Not found in files or folders — resolving from policy chain[/dim]")
419
+ console.print(f" Permission: [bold]{perm_str}[/bold] (from {perm_src})")
420
+ finally:
421
+ conn.close()
422
+
423
+
424
+ def _read_reset(args) -> None:
425
+ conn = get_policy_db()
426
+ if conn is None:
427
+ console.print("[yellow]No database found.[/yellow]")
428
+ return
429
+
430
+ try:
431
+ yes = getattr(args, "yes", False)
432
+ if not confirm_recalculation(conn, "global", yes=yes):
433
+ console.print("[dim]Cancelled.[/dim]")
434
+ return
435
+ deleted = clear_permission_policies(conn)
436
+ console.print(f"Cleared {deleted} permission policies")
437
+ seed_permission_defaults(conn)
438
+ console.print("Re-seeded permission defaults (global=allow)")
439
+ stats = recalculate_with_progress(conn, "global")
440
+ _print_recalc_stats(stats)
441
+ finally:
442
+ conn.close()
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # Combined check handler (both layers)
447
+ # ---------------------------------------------------------------------------
448
+
449
+
450
+ def _check(args) -> None:
451
+ from footprinter.permissions import BASELINE_PERMISSION
452
+ from footprinter.visibility import BASELINE_VISIBILITY
453
+
454
+ path = getattr(args, "path", None)
455
+ folder = getattr(args, "folder", None)
456
+ project = getattr(args, "project", None)
457
+ client = getattr(args, "client", None)
458
+ json_output = getattr(args, "json", False)
459
+ verbose = getattr(args, "verbose", False)
460
+
461
+ targets = []
462
+ if path:
463
+ targets.append("path")
464
+ if folder:
465
+ targets.append("folder")
466
+ if project is not None:
467
+ targets.append("project")
468
+ if client is not None:
469
+ targets.append("client")
470
+
471
+ if len(targets) == 0:
472
+ console.print("[red]No target specified.[/red]")
473
+ console.print()
474
+ console.print("Usage: fp mcp check <path>")
475
+ console.print()
476
+ console.print("Examples:")
477
+ console.print(" fp mcp check ~/Work/file.py Check a file or folder")
478
+ console.print(" fp mcp check --folder ~/Work Check a folder (aggregate view)")
479
+ console.print(" fp mcp check --project 3 Check a project")
480
+ raise SystemExit(1)
481
+ if len(targets) > 1:
482
+ console.print("[red]Specify only one target.[/red] Got: " + ", ".join(targets))
483
+ raise SystemExit(1)
484
+
485
+ conn = get_policy_db()
486
+ if conn is None:
487
+ perm_str = "allow" if BASELINE_PERMISSION else "deny"
488
+ vis_str = BASELINE_VISIBILITY
489
+ if json_output:
490
+ output_json(
491
+ {
492
+ "path": path or folder or str(project) or str(client),
493
+ "found_in_db": False,
494
+ "permission": {"resolved": perm_str, "source": "baseline"},
495
+ "visibility": {"resolved": vis_str, "source": "baseline"},
496
+ "chain": [{"scope": "baseline", "permission": perm_str, "visibility": vis_str}],
497
+ }
498
+ )
499
+ else:
500
+ console.print("[yellow]No database found.[/yellow] Showing baseline defaults.")
501
+ console.print(f" Permission: [bold]{perm_str}[/bold] (baseline)")
502
+ console.print(f" Visibility: [bold]{vis_str}[/bold] (baseline)")
503
+ return
504
+
505
+ try:
506
+ if path:
507
+ check_file_path(conn, path, json_output, verbose)
508
+ elif folder:
509
+ check_folder(conn, folder, json_output, verbose)
510
+ elif project is not None:
511
+ check_project(conn, project, json_output)
512
+ elif client is not None:
513
+ check_client(conn, client, json_output)
514
+ finally:
515
+ conn.close()
516
+
517
+
518
+ # ---------------------------------------------------------------------------
519
+ # Bulk handler (both layers)
520
+ # ---------------------------------------------------------------------------
521
+
522
+
523
+ def _bulk(args) -> None:
524
+ conn = get_policy_db()
525
+ if conn is None:
526
+ console.print("[yellow]No database found.[/yellow]")
527
+ raise SystemExit(1)
528
+
529
+ try:
530
+ rc = bulk_apply(
531
+ conn,
532
+ folder=getattr(args, "folder", None),
533
+ project=getattr(args, "project", None),
534
+ permission=getattr(args, "permission", None),
535
+ visibility=getattr(args, "visibility", None),
536
+ dry_run=getattr(args, "dry_run", False),
537
+ yes=getattr(args, "yes", False),
538
+ )
539
+ if rc:
540
+ raise SystemExit(rc)
541
+ finally:
542
+ conn.close()
543
+
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # Parser registration
547
+ # ---------------------------------------------------------------------------
548
+
549
+
550
+ def _add_set_parser(subparsers, *, dest: str, handler) -> None:
551
+ """Add a ``set <scope> <level>`` parser to *subparsers*."""
552
+ p = subparsers.add_parser(
553
+ "set",
554
+ help="Set a policy",
555
+ description=("Set a policy for a scope.\n\nScopes: global, folder:~/path, project:<id>, client:<id>."),
556
+ epilog=(
557
+ "examples:\n"
558
+ " fp mcp view set global visible\n"
559
+ " fp mcp read set folder:~/Work allow\n"
560
+ " fp mcp read set project:3 deny"
561
+ ),
562
+ formatter_class=FORMATTER,
563
+ )
564
+ p.add_argument("scope", help="Policy scope (e.g. global, folder:~/Work, project:3)")
565
+ p.add_argument("level", help="Policy value (e.g. allow, deny, visible, opaque, hidden)")
566
+ p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
567
+ p.set_defaults(func=handler)
568
+
569
+
570
+ def _add_delete_parser(subparsers, *, handler) -> None:
571
+ p = subparsers.add_parser(
572
+ "delete",
573
+ help="Delete a policy for a scope",
574
+ description="Remove a policy entry, reverting the scope to inherited resolution.",
575
+ formatter_class=FORMATTER,
576
+ )
577
+ p.add_argument("scope", help="Policy scope to delete (e.g. folder:~/Work)")
578
+ p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
579
+ p.set_defaults(func=handler)
580
+
581
+
582
+ def _add_check_parser(subparsers, *, handler) -> None:
583
+ p = subparsers.add_parser(
584
+ "check",
585
+ help="Check resolution for a path",
586
+ description="Show how a policy resolves for a specific file path.",
587
+ formatter_class=FORMATTER,
588
+ )
589
+ p.add_argument("path", nargs="?", default=None, help="File path to check")
590
+ add_json_flag(p)
591
+ p.set_defaults(func=handler)
592
+
593
+
594
+ def register(subparsers) -> None:
595
+ """Register the ``mcp`` subcommand and its verbs."""
596
+ parser = subparsers.add_parser(
597
+ "mcp",
598
+ help="MCP server and access policies",
599
+ description=(
600
+ "Start the MCP server or manage access control policies.\n\n"
601
+ "With no subcommand, starts the MCP server for Claude Desktop.\n"
602
+ "Use view/read subcommands to manage visibility and permission\n"
603
+ "policies, or check/bulk for resolution and batch operations."
604
+ ),
605
+ epilog=(
606
+ "examples:\n"
607
+ " fp mcp Start the MCP server\n"
608
+ " fp mcp view show List visibility policies\n"
609
+ " fp mcp read show List permission policies\n"
610
+ " fp mcp check ~/Work/file.py Check combined resolution\n"
611
+ " fp mcp bulk --folder ~/Work --permission allow\n"
612
+ "\n"
613
+ "tip: use 'fp mcp <command> --help' for details on any command."
614
+ ),
615
+ formatter_class=FORMATTER,
616
+ )
617
+ parser.set_defaults(func=_start_server)
618
+
619
+ sub = parser.add_subparsers(dest="mcp_command", metavar="COMMAND", title="commands (one required)")
620
+
621
+ # -- view subgroup (visibility) --
622
+ view_parser = sub.add_parser(
623
+ "view",
624
+ help="Visibility policy management",
625
+ description=(
626
+ "Manage visibility policies that control what metadata\nClaude can see (visible, opaque, or hidden)."
627
+ ),
628
+ epilog=(
629
+ "examples:\n"
630
+ " fp mcp view show List all visibility policies\n"
631
+ " fp mcp view set global visible Set global visibility\n"
632
+ " fp mcp view check ~/Work/file.py Check resolution for a path\n"
633
+ " fp mcp view reset Re-seed defaults"
634
+ ),
635
+ formatter_class=FORMATTER,
636
+ )
637
+ view_parser.set_defaults(func=lambda args: view_parser.print_help())
638
+ view_sub = view_parser.add_subparsers(dest="view_command", metavar="COMMAND", title="commands (one required)")
639
+
640
+ show_v = view_sub.add_parser(
641
+ "show",
642
+ help="Show visibility policies",
643
+ description="List all configured visibility policies.",
644
+ formatter_class=FORMATTER,
645
+ )
646
+ add_json_flag(show_v)
647
+ show_v.set_defaults(func=_view_show)
648
+
649
+ _add_set_parser(view_sub, dest="view_command", handler=_view_set)
650
+ _add_delete_parser(view_sub, handler=_view_delete)
651
+ _add_check_parser(view_sub, handler=_view_check)
652
+
653
+ reset_v = view_sub.add_parser(
654
+ "reset",
655
+ help="Clear and re-seed visibility defaults",
656
+ description="Delete all visibility policies and re-seed with defaults (global=visible).",
657
+ formatter_class=FORMATTER,
658
+ )
659
+ reset_v.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
660
+ reset_v.set_defaults(func=_view_reset)
661
+
662
+ # -- read subgroup (permission) --
663
+ read_parser = sub.add_parser(
664
+ "read",
665
+ help="Permission policy management",
666
+ description=("Manage permission policies that control whether Claude\ncan read file content (allow or deny)."),
667
+ epilog=(
668
+ "examples:\n"
669
+ " fp mcp read show List all permission policies\n"
670
+ " fp mcp read set folder:~/Work allow Allow reading Work files\n"
671
+ " fp mcp read check ~/Work/file.py Check resolution for a path\n"
672
+ " fp mcp read reset Re-seed defaults"
673
+ ),
674
+ formatter_class=FORMATTER,
675
+ )
676
+ read_parser.set_defaults(func=lambda args: read_parser.print_help())
677
+ read_sub = read_parser.add_subparsers(dest="read_command", metavar="COMMAND", title="commands (one required)")
678
+
679
+ show_r = read_sub.add_parser(
680
+ "show",
681
+ help="Show permission policies",
682
+ description="List all configured permission policies.",
683
+ formatter_class=FORMATTER,
684
+ )
685
+ add_json_flag(show_r)
686
+ show_r.set_defaults(func=_read_show)
687
+
688
+ _add_set_parser(read_sub, dest="read_command", handler=_read_set)
689
+ _add_delete_parser(read_sub, handler=_read_delete)
690
+ _add_check_parser(read_sub, handler=_read_check)
691
+
692
+ reset_r = read_sub.add_parser(
693
+ "reset",
694
+ help="Clear and re-seed permission defaults",
695
+ description="Delete all permission policies and re-seed with defaults (global=allow).",
696
+ formatter_class=FORMATTER,
697
+ )
698
+ reset_r.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
699
+ reset_r.set_defaults(func=_read_reset)
700
+
701
+ # -- check (combined) --
702
+ check_parser = sub.add_parser(
703
+ "check",
704
+ help="Check combined access resolution",
705
+ description=(
706
+ "Check both permission and visibility resolution for a target.\n\n"
707
+ "Specify exactly one target: a file path, --folder, --project, or --client."
708
+ ),
709
+ epilog=(
710
+ "examples:\n"
711
+ " fp mcp check ~/Work/file.py Check a file path\n"
712
+ " fp mcp check --folder ~/Work Check a folder\n"
713
+ " fp mcp check --project 3 Check a project\n"
714
+ " fp mcp check --folder ~/Work --verbose Show per-file details"
715
+ ),
716
+ formatter_class=FORMATTER,
717
+ )
718
+ check_parser.add_argument("path", nargs="?", default=None, help="File path to check")
719
+ check_parser.add_argument("--folder", default=None, help="Folder path to check")
720
+ check_parser.add_argument("--project", type=int, default=None, help="Project ID to check")
721
+ check_parser.add_argument("--client", type=int, default=None, help="Client ID to check")
722
+ check_parser.add_argument("--verbose", action="store_true", help="Show per-file details")
723
+ add_json_flag(check_parser)
724
+ check_parser.set_defaults(func=_check)
725
+
726
+ # -- bulk --
727
+ bulk_parser = sub.add_parser(
728
+ "bulk",
729
+ help="Bulk policy changes",
730
+ description=(
731
+ "Apply permission and/or visibility policies in bulk.\n\n"
732
+ "Scope by --folder or --project. Set --permission and/or --visibility.\n"
733
+ "Use --dry-run to preview before applying."
734
+ ),
735
+ epilog=(
736
+ "examples:\n"
737
+ " fp mcp bulk --folder ~/Work --permission allow\n"
738
+ " fp mcp bulk --project 3 --visibility visible\n"
739
+ " fp mcp bulk --folder ~/Work --permission allow --visibility visible\n"
740
+ " fp mcp bulk --folder ~/Work --permission deny --dry-run"
741
+ ),
742
+ formatter_class=FORMATTER,
743
+ )
744
+ bulk_parser.add_argument("--folder", default=None, help="Folder scope (path)")
745
+ bulk_parser.add_argument("--project", type=int, default=None, help="Project ID scope")
746
+ bulk_parser.add_argument("--permission", default=None, help="Permission setting: allow or deny")
747
+ bulk_parser.add_argument("--visibility", default=None, help="Visibility setting: visible, opaque, or hidden")
748
+ bulk_parser.add_argument("--dry-run", action="store_true", dest="dry_run", help="Preview changes without applying")
749
+ bulk_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
750
+ bulk_parser.set_defaults(func=_bulk)