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,215 @@
1
+ """fp vectorize — manage per-record vectorization control flags.
2
+
3
+ Commands:
4
+ fp vectorize exclude <entity> <id> [<id>...] Mark records to skip vectorization
5
+ fp vectorize include <entity> <id> [<id>...] Restore records for vectorization
6
+ fp vectorize review [<entity>] Show excluded record counts
7
+ fp vectorize import <path> Apply flags from a JSON file
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import sqlite3
13
+ from pathlib import Path
14
+ from typing import Optional, Union
15
+
16
+ from rich.console import Console
17
+
18
+ from footprinter.cli._common import FORMATTER, console, open_db
19
+
20
+ # Entity types that support the vectorize flag
21
+ ENTITY_TABLES = {"files": "files", "messages": "messages", "chats": "chats"}
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Core flag operations
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def _set_vectorize_flag(
30
+ conn: sqlite3.Connection,
31
+ table: str,
32
+ ids: list[int],
33
+ value: int,
34
+ ) -> int:
35
+ """Set metadata.vectorize on records via json_set().
36
+
37
+ Returns the number of rows updated.
38
+ """
39
+ if not ids:
40
+ return 0
41
+ placeholders = ",".join("?" for _ in ids)
42
+ cursor = conn.execute(
43
+ f"UPDATE {table} SET metadata = json_set("
44
+ f"COALESCE(metadata, '{{}}'), '$.vectorize', ?) "
45
+ f"WHERE id IN ({placeholders}) AND status != 'removed'",
46
+ [value, *ids],
47
+ )
48
+ conn.commit()
49
+ return cursor.rowcount
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Handlers
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _handle_exclude(
58
+ args: argparse.Namespace,
59
+ *,
60
+ db_path: Optional[Union[str, Path]] = None,
61
+ output: Optional[Console] = None,
62
+ ) -> None:
63
+ """Set metadata.vectorize=0 for given entity/IDs."""
64
+ table = ENTITY_TABLES.get(args.entity)
65
+ if not table:
66
+ (output or console).print(f"[red]Unknown entity:[/red] {args.entity}. Use one of: {', '.join(ENTITY_TABLES)}")
67
+ return
68
+
69
+ with open_db(db_path) as conn:
70
+ count = _set_vectorize_flag(conn, table, args.ids, 0)
71
+ (output or console).print(f"Excluded {count} {args.entity} from vectorization.")
72
+
73
+
74
+ def _handle_include(
75
+ args: argparse.Namespace,
76
+ *,
77
+ db_path: Optional[Union[str, Path]] = None,
78
+ output: Optional[Console] = None,
79
+ ) -> None:
80
+ """Set metadata.vectorize=1 for given entity/IDs."""
81
+ table = ENTITY_TABLES.get(args.entity)
82
+ if not table:
83
+ (output or console).print(f"[red]Unknown entity:[/red] {args.entity}. Use one of: {', '.join(ENTITY_TABLES)}")
84
+ return
85
+
86
+ with open_db(db_path) as conn:
87
+ count = _set_vectorize_flag(conn, table, args.ids, 1)
88
+ (output or console).print(f"Included {count} {args.entity} for vectorization.")
89
+
90
+
91
+ def _handle_review(
92
+ args: argparse.Namespace,
93
+ *,
94
+ db_path: Optional[Union[str, Path]] = None,
95
+ output: Optional[Console] = None,
96
+ ) -> None:
97
+ """Show counts of excluded records per entity."""
98
+ out = output or console
99
+ entities = (
100
+ {args.entity: ENTITY_TABLES[args.entity]} if args.entity and args.entity in ENTITY_TABLES else ENTITY_TABLES
101
+ )
102
+
103
+ from rich.table import Table
104
+
105
+ table = Table(title="Vectorization Exclusions")
106
+ table.add_column("Entity", style="bold")
107
+ table.add_column("Excluded", justify="right")
108
+ table.add_column("Total", justify="right")
109
+
110
+ with open_db(db_path) as conn:
111
+ for entity_name, table_name in entities.items():
112
+ excluded = conn.execute(
113
+ f"SELECT COUNT(*) as n FROM {table_name} "
114
+ f"WHERE json_extract(metadata, '$.vectorize') = 0 "
115
+ f"AND status != 'removed'"
116
+ ).fetchone()["n"]
117
+ total = conn.execute(f"SELECT COUNT(*) as n FROM {table_name} WHERE status != 'removed'").fetchone()["n"]
118
+ table.add_row(entity_name, str(excluded), str(total))
119
+
120
+ out.print(table)
121
+
122
+
123
+ VALID_ACTIONS = frozenset({"exclude", "include"})
124
+
125
+
126
+ def _handle_import(
127
+ args: argparse.Namespace,
128
+ *,
129
+ db_path: Optional[Union[str, Path]] = None,
130
+ output: Optional[Console] = None,
131
+ ) -> None:
132
+ """Apply vectorize flags from a JSON file."""
133
+ out = output or console
134
+ path = Path(args.path)
135
+ if not path.exists():
136
+ out.print(f"[red]File not found:[/red] {path}")
137
+ return
138
+
139
+ data = json.loads(path.read_text())
140
+
141
+ # Support structured format: {"entity": ..., "action": ..., "ids": [...]}
142
+ # and flat list format: [1, 2, 3] (defaults to files + exclude)
143
+ if isinstance(data, list):
144
+ entity = "files"
145
+ action = "exclude"
146
+ ids = [int(i) for i in data]
147
+ elif isinstance(data, dict):
148
+ entity = data.get("entity", "files")
149
+ action = data.get("action", "exclude")
150
+ ids = [int(i) for i in data.get("ids", [])]
151
+ else:
152
+ out.print("[red]Invalid JSON format.[/red] Expected a list or object.")
153
+ return
154
+
155
+ if action not in VALID_ACTIONS:
156
+ out.print(f"[red]Unknown action:[/red] {action}. Use one of: {', '.join(VALID_ACTIONS)}")
157
+ return
158
+
159
+ table = ENTITY_TABLES.get(entity)
160
+ if not table:
161
+ out.print(f"[red]Unknown entity:[/red] {entity}. Use one of: {', '.join(ENTITY_TABLES)}")
162
+ return
163
+
164
+ value = 0 if action == "exclude" else 1
165
+ with open_db(db_path) as conn:
166
+ count = _set_vectorize_flag(conn, table, ids, value)
167
+ verb = "Excluded" if action == "exclude" else "Included"
168
+ out.print(f"{verb} {count} {entity} via import.")
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # CLI registration
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def register(subparsers) -> None:
177
+ """Register the ``fp vectorize`` subcommand."""
178
+ parser = subparsers.add_parser(
179
+ "vectorize",
180
+ help="Manage per-record vectorization control",
181
+ description="Exclude or include individual records from vectorization.",
182
+ formatter_class=FORMATTER,
183
+ )
184
+ sub = parser.add_subparsers(dest="vectorize_action", metavar="ACTION")
185
+
186
+ # exclude
187
+ exc = sub.add_parser("exclude", help="Exclude records from vectorization")
188
+ exc.add_argument("entity", choices=list(ENTITY_TABLES), help="Entity type")
189
+ exc.add_argument("ids", nargs="+", type=int, help="Record IDs to exclude")
190
+ exc.set_defaults(func=lambda args: _handle_exclude(args))
191
+
192
+ # include
193
+ inc = sub.add_parser("include", help="Include records for vectorization")
194
+ inc.add_argument("entity", choices=list(ENTITY_TABLES), help="Entity type")
195
+ inc.add_argument("ids", nargs="+", type=int, help="Record IDs to include")
196
+ inc.set_defaults(func=lambda args: _handle_include(args))
197
+
198
+ # review
199
+ rev = sub.add_parser("review", help="Show excluded record counts")
200
+ rev.add_argument(
201
+ "entity",
202
+ nargs="?",
203
+ default=None,
204
+ choices=list(ENTITY_TABLES),
205
+ help="Filter to a specific entity type",
206
+ )
207
+ rev.set_defaults(func=lambda args: _handle_review(args))
208
+
209
+ # import
210
+ imp = sub.add_parser("import", help="Apply flags from a JSON file")
211
+ imp.add_argument("path", help="Path to JSON file")
212
+ imp.set_defaults(func=lambda args: _handle_import(args))
213
+
214
+ # Default: show help if no action given
215
+ parser.set_defaults(func=lambda args: parser.print_help())
@@ -0,0 +1,322 @@
1
+ """fp view — unified entity viewer with singular/plural noun convention.
2
+
3
+ Routes ``fp view client 42`` (single record) and ``fp view clients``
4
+ (paginated collection) through the service layer. Supports ``--json``,
5
+ ``--csv``, and ``--verbose`` output flags.
6
+ """
7
+
8
+ import sys
9
+
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from footprinter.cli._common import (
14
+ FORMATTER,
15
+ add_csv_flag,
16
+ add_json_flag,
17
+ add_verbose_flag,
18
+ console,
19
+ enrich_verbose_access,
20
+ format_size,
21
+ open_db,
22
+ output_csv,
23
+ output_json,
24
+ verbose_access_cells,
25
+ )
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Entity dispatch table
29
+ # ---------------------------------------------------------------------------
30
+
31
+ #: Maps every recognised noun to (service_module, list_key, entity_type, mode).
32
+ ENTITY_MAP: dict[str, tuple[str, str, str, str]] = {
33
+ # singular → single record
34
+ "client": ("client_service", "clients", "client", "single"),
35
+ "project": ("project_service", "projects", "project", "single"),
36
+ "file": ("file_service", "files", "file", "single"),
37
+ "folder": ("folder_service", "folders", "folder", "single"),
38
+ "chat": ("chat_service", "chats", "chat", "single"),
39
+ "email": ("email_service", "emails", "email", "single"),
40
+ "visit": ("visit_service", "visits", "visit", "single"),
41
+ # plural → paginated collection
42
+ "clients": ("client_service", "clients", "client", "collection"),
43
+ "projects": ("project_service", "projects", "project", "collection"),
44
+ "files": ("file_service", "files", "file", "collection"),
45
+ "folders": ("folder_service", "folders", "folder", "collection"),
46
+ "chats": ("chat_service", "chats", "chat", "collection"),
47
+ "emails": ("email_service", "emails", "email", "collection"),
48
+ "visits": ("visit_service", "visits", "visit", "collection"),
49
+ }
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Column specs for Rich table rendering
53
+ # ---------------------------------------------------------------------------
54
+
55
+ #: (header, dict_key, style, justify)
56
+ _Col = tuple[str, str, str | None, str | None]
57
+
58
+ ENTITY_COLUMNS: dict[str, list[_Col]] = {
59
+ "client": [
60
+ ("ID", "id", "dim", "right"),
61
+ ("Name", "name", "cyan", None),
62
+ ("Type", "client_type", None, None),
63
+ ("Status", "status", None, None),
64
+ ("Projects", "project_count", None, "right"),
65
+ ("Files", "file_count", None, "right"),
66
+ ],
67
+ "project": [
68
+ ("ID", "id", "dim", "right"),
69
+ ("Name", "name", "cyan", None),
70
+ ("Type", "type", None, None),
71
+ ("Client", "client", None, None),
72
+ ("Status", "status", None, None),
73
+ ("Files", "file_count", None, "right"),
74
+ ("Path", "root_path", "dim", None),
75
+ ],
76
+ "file": [
77
+ ("ID", "id", "dim", "right"),
78
+ ("Name", "name", None, None),
79
+ ("Source", "source", None, None),
80
+ ("Status", "status", None, None),
81
+ ("Project", "project_name", None, None),
82
+ ],
83
+ "folder": [
84
+ ("ID", "id", "dim", "right"),
85
+ ("Path", "relative_path", None, None),
86
+ ("Files", "direct_files", None, "right"),
87
+ ("Size", "total_size_bytes", None, "right"),
88
+ ("Project", "project_name", "cyan", None),
89
+ ],
90
+ "chat": [
91
+ ("ID", "id", "cyan", "right"),
92
+ ("Account", "account", "magenta", None),
93
+ ("Msgs", "message_count", None, "right"),
94
+ ("Title", "title", None, None),
95
+ ],
96
+ "email": [
97
+ ("ID", "id", "dim", "right"),
98
+ ("From", "from_address", None, None),
99
+ ("Subject", "subject", None, None),
100
+ ("Account", "account", None, None),
101
+ ("Date", "received_at", None, None),
102
+ ],
103
+ "visit": [
104
+ ("ID", "id", "dim", "right"),
105
+ ("Title", "title", None, None),
106
+ ("URL", "url", None, None),
107
+ ("Browser", "browser", None, None),
108
+ ("Time", "visit_time", None, None),
109
+ ],
110
+ }
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Service resolution
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def _get_service(service_name: str):
118
+ """Lazy-import and return a service module from footprinter.services."""
119
+ import footprinter.services as svc
120
+
121
+ return getattr(svc, service_name)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Handlers
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def _handle_single(args) -> None:
130
+ """Handle singular noun: ``fp view client 42``."""
131
+ from footprinter.services.roles import Role
132
+
133
+ noun = args.noun
134
+ svc_name, _list_key, entity_type, _mode = ENTITY_MAP[noun]
135
+ service = _get_service(svc_name)
136
+
137
+ try:
138
+ entity_id = int(args.id)
139
+ except ValueError:
140
+ console.print(f"[red]Invalid ID: {args.id!r} — expected an integer.[/red]")
141
+ sys.exit(1)
142
+
143
+ with open_db() as conn:
144
+ record = service.get(conn, entity_id, role=Role.ADMIN)
145
+
146
+ if record is None:
147
+ console.print(f"[red]{entity_type.title()} {args.id} not found.[/red]")
148
+ sys.exit(1)
149
+
150
+ if getattr(args, "json", False):
151
+ output_json(record)
152
+ return
153
+
154
+ # Rich panel — show all key-value pairs
155
+ lines = []
156
+ for key, value in record.items():
157
+ if key.startswith("mcp_") or isinstance(value, (list, dict)):
158
+ continue
159
+ display_val = str(value) if value is not None else "—"
160
+ lines.append(f"[bold]{key}:[/bold] {display_val}")
161
+
162
+ console.print(
163
+ Panel(
164
+ "\n".join(lines),
165
+ title=f"{entity_type.title()} #{record['id']}",
166
+ border_style="cyan",
167
+ )
168
+ )
169
+
170
+
171
+ def _handle_collection(args) -> None:
172
+ """Handle plural noun: ``fp view clients``."""
173
+ from footprinter.services.roles import Role
174
+
175
+ noun = args.noun
176
+ svc_name, list_key, entity_type, _mode = ENTITY_MAP[noun]
177
+ service = _get_service(svc_name)
178
+
179
+ verbose = getattr(args, "verbose", False)
180
+ limit = getattr(args, "limit", 50)
181
+ page = getattr(args, "page", 1)
182
+
183
+ with open_db() as conn:
184
+ result = service.list_(conn, role=Role.ADMIN, limit=limit, page=page)
185
+ rows = result[list_key]
186
+ if (verbose or getattr(args, "json", False)) and rows:
187
+ enrich_verbose_access(rows, entity_type)
188
+
189
+ if getattr(args, "json", False):
190
+ output_json(result)
191
+ return
192
+
193
+ if getattr(args, "csv", False):
194
+ cols = ENTITY_COLUMNS.get(entity_type)
195
+ col_keys = [c[1] for c in cols] if cols else None
196
+ output_csv(rows, columns=col_keys)
197
+ return
198
+
199
+ if not rows:
200
+ console.print(f"[dim]No {list_key} found.[/dim]")
201
+ return
202
+
203
+ # Build Rich table from column specs
204
+ pag = result["pagination"]
205
+ table = Table(
206
+ title=f"{list_key.title()} (page {pag['page']}/{pag['total_pages']}, {pag['total']} total)",
207
+ )
208
+
209
+ cols = ENTITY_COLUMNS.get(entity_type, [])
210
+ for header, _key, style, justify in cols:
211
+ kwargs: dict = {}
212
+ if style:
213
+ kwargs["style"] = style
214
+ if justify:
215
+ kwargs["justify"] = justify
216
+ table.add_column(header, **kwargs)
217
+ if verbose:
218
+ table.add_column("Access")
219
+ table.add_column("Visibility")
220
+
221
+ for row in rows:
222
+ cells = []
223
+ for _header, key, _style, _justify in cols:
224
+ val = row.get(key)
225
+ if key == "total_size_bytes" and isinstance(val, (int, float)):
226
+ cells.append(format_size(int(val)))
227
+ elif key == "file_count" and isinstance(val, int):
228
+ cells.append(f"{val:,}")
229
+ else:
230
+ cells.append(str(val) if val is not None else "")
231
+ if verbose:
232
+ cells.extend(verbose_access_cells(row))
233
+ table.add_row(*cells)
234
+
235
+ console.print(table)
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Registration
240
+ # ---------------------------------------------------------------------------
241
+
242
+ SINGULAR_NOUNS = ["client", "project", "file", "folder", "chat", "email", "visit"]
243
+ PLURAL_NOUNS = ["clients", "projects", "files", "folders", "chats", "emails", "visits"]
244
+
245
+
246
+ def register(subparsers) -> None:
247
+ """Register the ``view`` subcommand with noun sub-subparsers."""
248
+ parser = subparsers.add_parser(
249
+ "view",
250
+ help="View entity records",
251
+ description=(
252
+ "View entity records — singular noun for one record, plural for a list.\n\n"
253
+ "Singular: fp view client 42 Single record by ID\n"
254
+ "Plural: fp view clients Paginated collection\n"
255
+ "Export: fp view clients --csv Bulk CSV export"
256
+ ),
257
+ epilog=(
258
+ "examples:\n"
259
+ " fp view client 42 View a single client\n"
260
+ " fp view clients List all clients\n"
261
+ " fp view clients --json JSON output\n"
262
+ " fp view clients --csv CSV export\n"
263
+ " fp view files --limit 10 First 10 files\n"
264
+ " fp view projects --verbose Include access columns\n"
265
+ "\n"
266
+ "entity nouns:\n"
267
+ " singular: client, project, file, folder, chat, email, visit\n"
268
+ " plural: clients, projects, files, folders, chats, emails, visits\n"
269
+ "\n"
270
+ "tip: use 'fp view <noun> --help' for details on any noun."
271
+ ),
272
+ formatter_class=FORMATTER,
273
+ )
274
+ noun_subs = parser.add_subparsers(
275
+ dest="noun",
276
+ metavar="NOUN",
277
+ title="entity nouns (one required)",
278
+ )
279
+ parser.set_defaults(func=lambda args: parser.print_help())
280
+
281
+ # Singular nouns — require an ID positional arg
282
+ for noun in SINGULAR_NOUNS:
283
+ entity_type = ENTITY_MAP[noun][2]
284
+ p = noun_subs.add_parser(
285
+ noun,
286
+ help=f"View a single {entity_type}",
287
+ description=f"Show details for a single {entity_type} record by ID.",
288
+ formatter_class=FORMATTER,
289
+ )
290
+ p.add_argument("id", help=f"{entity_type.title()} ID")
291
+ add_json_flag(p)
292
+ p.set_defaults(func=_handle_single)
293
+
294
+ # Plural nouns — pagination + format flags
295
+ for noun in PLURAL_NOUNS:
296
+ entity_type = ENTITY_MAP[noun][2]
297
+ p = noun_subs.add_parser(
298
+ noun,
299
+ help=f"List {noun}",
300
+ description=f"List {noun} with pagination.",
301
+ formatter_class=FORMATTER,
302
+ )
303
+ p.add_argument(
304
+ "--limit",
305
+ type=int,
306
+ default=50,
307
+ help="Max rows to return (default: 50)",
308
+ )
309
+ p.add_argument(
310
+ "--page",
311
+ type=int,
312
+ default=1,
313
+ help="Page number (default: 1)",
314
+ )
315
+ add_verbose_flag(p)
316
+
317
+ # --json and --csv are mutually exclusive
318
+ fmt_group = p.add_mutually_exclusive_group()
319
+ add_json_flag(fmt_group)
320
+ add_csv_flag(fmt_group)
321
+
322
+ p.set_defaults(func=_handle_collection)