cmdscope 0.1.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.
- cmdscope/__init__.py +12 -0
- cmdscope/__main__.py +8 -0
- cmdscope/analysis/__init__.py +1 -0
- cmdscope/analysis/comparison.py +134 -0
- cmdscope/analysis/summary.py +37 -0
- cmdscope/cli.py +555 -0
- cmdscope/collectors/__init__.py +1 -0
- cmdscope/collectors/filesystem.py +370 -0
- cmdscope/collectors/network.py +196 -0
- cmdscope/collectors/processes.py +365 -0
- cmdscope/config.py +461 -0
- cmdscope/core/__init__.py +1 -0
- cmdscope/core/collector.py +48 -0
- cmdscope/core/engine.py +514 -0
- cmdscope/core/events.py +113 -0
- cmdscope/core/ignore.py +56 -0
- cmdscope/core/registry.py +33 -0
- cmdscope/core/session.py +81 -0
- cmdscope/diagnostics.py +183 -0
- cmdscope/errors.py +36 -0
- cmdscope/platforms/__init__.py +57 -0
- cmdscope/platforms/base.py +140 -0
- cmdscope/platforms/linux.py +28 -0
- cmdscope/platforms/macos.py +26 -0
- cmdscope/platforms/windows.py +28 -0
- cmdscope/privacy.py +143 -0
- cmdscope/py.typed +0 -0
- cmdscope/reports/__init__.py +1 -0
- cmdscope/reports/html.py +279 -0
- cmdscope/reports/json_report.py +133 -0
- cmdscope/reports/terminal.py +429 -0
- cmdscope/storage/__init__.py +6 -0
- cmdscope/storage/sqlite.py +469 -0
- cmdscope/storage/writer.py +105 -0
- cmdscope-0.1.0.dist-info/METADATA +353 -0
- cmdscope-0.1.0.dist-info/RECORD +40 -0
- cmdscope-0.1.0.dist-info/WHEEL +5 -0
- cmdscope-0.1.0.dist-info/entry_points.txt +2 -0
- cmdscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- cmdscope-0.1.0.dist-info/top_level.txt +1 -0
cmdscope/cli.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Command-line interface for CmdScope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import traceback
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from cmdscope import __version__
|
|
16
|
+
from cmdscope.analysis.comparison import compare_sessions
|
|
17
|
+
from cmdscope.analysis.summary import summarize
|
|
18
|
+
from cmdscope.config import (
|
|
19
|
+
Config,
|
|
20
|
+
config_as_dict,
|
|
21
|
+
default_config_text,
|
|
22
|
+
load_config,
|
|
23
|
+
resolve_watch_roots,
|
|
24
|
+
user_config_path,
|
|
25
|
+
)
|
|
26
|
+
from cmdscope.core.engine import RunOptions, execute
|
|
27
|
+
from cmdscope.core.events import StoredEvent
|
|
28
|
+
from cmdscope.core.session import Session, SessionSummary
|
|
29
|
+
from cmdscope.diagnostics import collect_diagnostics, render_diagnostics
|
|
30
|
+
from cmdscope.errors import (
|
|
31
|
+
CmdScopeError,
|
|
32
|
+
CommandLaunchError,
|
|
33
|
+
ConfigurationError,
|
|
34
|
+
ReportExistsError,
|
|
35
|
+
SessionNotFoundError,
|
|
36
|
+
)
|
|
37
|
+
from cmdscope.reports.html import render_html
|
|
38
|
+
from cmdscope.reports.json_report import (
|
|
39
|
+
build_report,
|
|
40
|
+
render_comparison_json,
|
|
41
|
+
render_json,
|
|
42
|
+
)
|
|
43
|
+
from cmdscope.reports.terminal import (
|
|
44
|
+
render_comparison,
|
|
45
|
+
render_session_report,
|
|
46
|
+
render_sessions,
|
|
47
|
+
)
|
|
48
|
+
from cmdscope.storage.sqlite import Storage
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
52
|
+
"""Build the CLI parser. Kept public for tests and documentation."""
|
|
53
|
+
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="cmdscope",
|
|
56
|
+
description=(
|
|
57
|
+
"Run a command and record observable filesystem, process, and network "
|
|
58
|
+
"changes. Local, privacy-first, and honest about limitations."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument("--version", action="version", version=f"cmdscope {__version__}")
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--config",
|
|
64
|
+
type=Path,
|
|
65
|
+
metavar="PATH",
|
|
66
|
+
help="explicit TOML configuration file",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--db",
|
|
70
|
+
type=Path,
|
|
71
|
+
metavar="PATH",
|
|
72
|
+
help="override the session database path",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--no-color",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="disable ANSI colors",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"-v",
|
|
81
|
+
"--verbose",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="show debug tracebacks for unexpected errors",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
87
|
+
|
|
88
|
+
run_parser = subparsers.add_parser(
|
|
89
|
+
"run",
|
|
90
|
+
help="run and observe a command",
|
|
91
|
+
description="Run a command with observation. Use '--' before the command.",
|
|
92
|
+
)
|
|
93
|
+
run_parser.add_argument(
|
|
94
|
+
"--watch",
|
|
95
|
+
action="append",
|
|
96
|
+
default=[],
|
|
97
|
+
metavar="PATH",
|
|
98
|
+
help="additional watch root (repeatable)",
|
|
99
|
+
)
|
|
100
|
+
run_parser.add_argument(
|
|
101
|
+
"--ignore",
|
|
102
|
+
action="append",
|
|
103
|
+
default=[],
|
|
104
|
+
metavar="GLOB",
|
|
105
|
+
help="additional ignore pattern (repeatable)",
|
|
106
|
+
)
|
|
107
|
+
run_parser.add_argument(
|
|
108
|
+
"--env",
|
|
109
|
+
action="append",
|
|
110
|
+
default=[],
|
|
111
|
+
metavar="KEY=VALUE",
|
|
112
|
+
help="set an environment variable for the monitored command (repeatable)",
|
|
113
|
+
)
|
|
114
|
+
run_parser.add_argument(
|
|
115
|
+
"--no-filesystem",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="disable filesystem observation",
|
|
118
|
+
)
|
|
119
|
+
run_parser.add_argument(
|
|
120
|
+
"--no-processes",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="disable process observation",
|
|
123
|
+
)
|
|
124
|
+
run_parser.add_argument(
|
|
125
|
+
"--no-network",
|
|
126
|
+
action="store_true",
|
|
127
|
+
help="disable network observation",
|
|
128
|
+
)
|
|
129
|
+
run_parser.add_argument(
|
|
130
|
+
"--no-report",
|
|
131
|
+
action="store_true",
|
|
132
|
+
help="do not print a report after the command",
|
|
133
|
+
)
|
|
134
|
+
run_parser.add_argument(
|
|
135
|
+
"--json",
|
|
136
|
+
action="store_true",
|
|
137
|
+
help="print the post-run report as JSON",
|
|
138
|
+
)
|
|
139
|
+
run_parser.add_argument(
|
|
140
|
+
"--include-environment",
|
|
141
|
+
action="store_true",
|
|
142
|
+
help="include the sanitized initial environment diff in the report",
|
|
143
|
+
)
|
|
144
|
+
run_parser.add_argument(
|
|
145
|
+
"--limit",
|
|
146
|
+
type=int,
|
|
147
|
+
default=None,
|
|
148
|
+
metavar="N",
|
|
149
|
+
help="maximum events shown per section in the terminal report",
|
|
150
|
+
)
|
|
151
|
+
run_parser.add_argument(
|
|
152
|
+
"command",
|
|
153
|
+
nargs=argparse.REMAINDER,
|
|
154
|
+
help="command and arguments (use '--' before the command)",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
sessions_parser = subparsers.add_parser("sessions", help="list recorded sessions")
|
|
158
|
+
sessions_parser.add_argument("--limit", type=int, default=50, metavar="N")
|
|
159
|
+
sessions_parser.add_argument("--json", action="store_true")
|
|
160
|
+
|
|
161
|
+
show_parser = subparsers.add_parser("show", help="show one session")
|
|
162
|
+
show_parser.add_argument("session", help="session reference (S000001 or 1)")
|
|
163
|
+
show_parser.add_argument("--limit", type=int, default=None, metavar="N")
|
|
164
|
+
show_parser.add_argument("--all", action="store_true", help="show every event")
|
|
165
|
+
show_parser.add_argument("--include-environment", action="store_true")
|
|
166
|
+
show_parser.add_argument("--json", action="store_true")
|
|
167
|
+
|
|
168
|
+
report_parser = subparsers.add_parser("report", help="render a session report")
|
|
169
|
+
report_parser.add_argument("session", help="session reference (S000001 or 1)")
|
|
170
|
+
report_parser.add_argument(
|
|
171
|
+
"--format",
|
|
172
|
+
choices=("terminal", "json", "html"),
|
|
173
|
+
default=None,
|
|
174
|
+
help="report format (default from configuration)",
|
|
175
|
+
)
|
|
176
|
+
report_parser.add_argument("--output", type=Path, metavar="PATH", help="write to a file")
|
|
177
|
+
report_parser.add_argument(
|
|
178
|
+
"--force",
|
|
179
|
+
action="store_true",
|
|
180
|
+
help="overwrite an existing output file",
|
|
181
|
+
)
|
|
182
|
+
report_parser.add_argument("--limit", type=int, default=None, metavar="N")
|
|
183
|
+
report_parser.add_argument("--all", action="store_true", help="include every event")
|
|
184
|
+
report_parser.add_argument("--include-environment", action="store_true")
|
|
185
|
+
|
|
186
|
+
compare_parser = subparsers.add_parser("compare", help="compare two sessions")
|
|
187
|
+
compare_parser.add_argument("session_a")
|
|
188
|
+
compare_parser.add_argument("session_b")
|
|
189
|
+
compare_parser.add_argument(
|
|
190
|
+
"--format",
|
|
191
|
+
choices=("terminal", "json"),
|
|
192
|
+
default="terminal",
|
|
193
|
+
)
|
|
194
|
+
compare_parser.add_argument("--limit", type=int, default=None, metavar="N")
|
|
195
|
+
compare_parser.add_argument("--all", action="store_true")
|
|
196
|
+
|
|
197
|
+
doctor_parser = subparsers.add_parser("doctor", help="inspect local capabilities")
|
|
198
|
+
doctor_parser.add_argument("--json", action="store_true")
|
|
199
|
+
|
|
200
|
+
config_parser = subparsers.add_parser("config", help="show or initialize configuration")
|
|
201
|
+
config_parser.add_argument("--path", action="store_true", help="show configuration paths")
|
|
202
|
+
config_parser.add_argument("--json", action="store_true", help="show effective values")
|
|
203
|
+
config_parser.add_argument(
|
|
204
|
+
"--init",
|
|
205
|
+
action="store_true",
|
|
206
|
+
help="write a commented cmdscope.toml in the current directory",
|
|
207
|
+
)
|
|
208
|
+
config_parser.add_argument("--force", action="store_true", help="overwrite on --init")
|
|
209
|
+
|
|
210
|
+
subparsers.add_parser("version", help="print the version")
|
|
211
|
+
|
|
212
|
+
return parser
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
216
|
+
"""CLI entry point. Returns a process exit code."""
|
|
217
|
+
|
|
218
|
+
_configure_console()
|
|
219
|
+
parser = build_parser()
|
|
220
|
+
args = parser.parse_args(argv)
|
|
221
|
+
|
|
222
|
+
logging.basicConfig(
|
|
223
|
+
level=logging.DEBUG if args.verbose else logging.WARNING,
|
|
224
|
+
format="cmdscope: %(levelname)s: %(message)s",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
return _dispatch(args, parser)
|
|
229
|
+
except ConfigurationError as exc:
|
|
230
|
+
print(f"cmdscope: configuration error: {exc}", file=sys.stderr)
|
|
231
|
+
return 2
|
|
232
|
+
except CommandLaunchError as exc:
|
|
233
|
+
print(f"cmdscope: error: {exc}", file=sys.stderr)
|
|
234
|
+
if exc.session_id is not None:
|
|
235
|
+
print(
|
|
236
|
+
f"cmdscope: partial session S{exc.session_id:06d} was recorded; "
|
|
237
|
+
f"inspect it with: cmdscope show S{exc.session_id:06d}",
|
|
238
|
+
file=sys.stderr,
|
|
239
|
+
)
|
|
240
|
+
return exc.exit_code
|
|
241
|
+
except SessionNotFoundError as exc:
|
|
242
|
+
print(f"cmdscope: {exc}", file=sys.stderr)
|
|
243
|
+
return 1
|
|
244
|
+
except CmdScopeError as exc:
|
|
245
|
+
print(f"cmdscope: error: {exc}", file=sys.stderr)
|
|
246
|
+
return 1
|
|
247
|
+
except KeyboardInterrupt:
|
|
248
|
+
print("cmdscope: interrupted", file=sys.stderr)
|
|
249
|
+
return 130
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
print(f"cmdscope: internal error: {exc}", file=sys.stderr)
|
|
252
|
+
if args.verbose or os.environ.get("CMDSCOPE_DEBUG"):
|
|
253
|
+
traceback.print_exc()
|
|
254
|
+
return 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _configure_console() -> None:
|
|
258
|
+
for stream in (sys.stdout, sys.stderr):
|
|
259
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
260
|
+
if reconfigure is not None:
|
|
261
|
+
with contextlib.suppress(OSError, ValueError):
|
|
262
|
+
reconfigure(errors="replace")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _dispatch(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
266
|
+
if args.subcommand == "version":
|
|
267
|
+
print(f"cmdscope {__version__}")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
config = load_config(args.config, cwd=Path.cwd())
|
|
271
|
+
database = _database_path(args, config)
|
|
272
|
+
|
|
273
|
+
if args.subcommand == "run":
|
|
274
|
+
return _run(args, parser, config, database)
|
|
275
|
+
if args.subcommand == "sessions":
|
|
276
|
+
return _sessions(args, config, database)
|
|
277
|
+
if args.subcommand == "show":
|
|
278
|
+
return _show(args, config, database)
|
|
279
|
+
if args.subcommand == "report":
|
|
280
|
+
return _report(args, config, database)
|
|
281
|
+
if args.subcommand == "compare":
|
|
282
|
+
return _compare(args, config, database)
|
|
283
|
+
if args.subcommand == "doctor":
|
|
284
|
+
return _doctor(args, config, database)
|
|
285
|
+
if args.subcommand == "config":
|
|
286
|
+
return _config(args, config)
|
|
287
|
+
parser.error(f"unknown subcommand: {args.subcommand}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _database_path(args: argparse.Namespace, config: Config) -> Path:
|
|
291
|
+
if args.db is not None:
|
|
292
|
+
candidate = Path(args.db).expanduser()
|
|
293
|
+
if not candidate.is_absolute():
|
|
294
|
+
candidate = Path.cwd() / candidate
|
|
295
|
+
return candidate.resolve()
|
|
296
|
+
return config.database_path()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _color_enabled(args: argparse.Namespace) -> bool:
|
|
300
|
+
if args.no_color or os.environ.get("NO_COLOR") is not None:
|
|
301
|
+
return False
|
|
302
|
+
if os.environ.get("TERM") == "dumb":
|
|
303
|
+
return False
|
|
304
|
+
return bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _validate_limit(value: int | None) -> None:
|
|
308
|
+
if value is not None and value < 1:
|
|
309
|
+
raise ConfigurationError("--limit must be at least 1")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _parse_env(values: list[str], parser: argparse.ArgumentParser) -> dict[str, str]:
|
|
313
|
+
overrides: dict[str, str] = {}
|
|
314
|
+
for item in values:
|
|
315
|
+
key, separator, value = item.partition("=")
|
|
316
|
+
if not separator or not key:
|
|
317
|
+
parser.error("--env requires KEY=VALUE")
|
|
318
|
+
overrides[key] = value
|
|
319
|
+
return overrides
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _run(
|
|
323
|
+
args: argparse.Namespace,
|
|
324
|
+
parser: argparse.ArgumentParser,
|
|
325
|
+
config: Config,
|
|
326
|
+
database: Path,
|
|
327
|
+
) -> int:
|
|
328
|
+
command = list(args.command)
|
|
329
|
+
if command and command[0] == "--":
|
|
330
|
+
command = command[1:]
|
|
331
|
+
if not command:
|
|
332
|
+
parser.error("run requires a command, for example: cmdscope run -- npm install")
|
|
333
|
+
_validate_limit(args.limit)
|
|
334
|
+
|
|
335
|
+
cwd = Path.cwd()
|
|
336
|
+
watch_roots = resolve_watch_roots(config, tuple(args.watch), cwd=cwd)
|
|
337
|
+
ignore_patterns = tuple(dict.fromkeys((*config.filesystem.ignore, *args.ignore)))
|
|
338
|
+
env_overrides = _parse_env(args.env, parser)
|
|
339
|
+
|
|
340
|
+
options = RunOptions(
|
|
341
|
+
command=tuple(command),
|
|
342
|
+
cwd=cwd,
|
|
343
|
+
watch_roots=watch_roots,
|
|
344
|
+
ignore_patterns=ignore_patterns,
|
|
345
|
+
env_overrides=env_overrides,
|
|
346
|
+
database=database,
|
|
347
|
+
enable_filesystem=config.filesystem.enabled and not args.no_filesystem,
|
|
348
|
+
enable_processes=config.processes.enabled and not args.no_processes,
|
|
349
|
+
enable_network=config.network.enabled and not args.no_network,
|
|
350
|
+
filesystem_poll_interval=config.filesystem.poll_interval,
|
|
351
|
+
process_poll_interval=config.processes.poll_interval,
|
|
352
|
+
network_poll_interval=config.network.poll_interval,
|
|
353
|
+
max_files=config.filesystem.max_files,
|
|
354
|
+
max_events=config.storage.max_events,
|
|
355
|
+
max_sessions=config.storage.max_sessions,
|
|
356
|
+
use_native_events=config.filesystem.native_events,
|
|
357
|
+
capture_environment=config.privacy.capture_environment,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
result = execute(options)
|
|
361
|
+
if not args.no_report:
|
|
362
|
+
session, events, summary = _load_session_data(database, str(result.session_id))
|
|
363
|
+
include_environment = args.include_environment or config.privacy.show_environment
|
|
364
|
+
print()
|
|
365
|
+
if args.json:
|
|
366
|
+
report = build_report(
|
|
367
|
+
session,
|
|
368
|
+
summary,
|
|
369
|
+
events,
|
|
370
|
+
include_environment=include_environment,
|
|
371
|
+
)
|
|
372
|
+
print(render_json(report), end="")
|
|
373
|
+
else:
|
|
374
|
+
limit = args.limit if args.limit is not None else config.report.max_events
|
|
375
|
+
text = render_session_report(
|
|
376
|
+
session,
|
|
377
|
+
summary,
|
|
378
|
+
events,
|
|
379
|
+
limit=limit,
|
|
380
|
+
include_environment=include_environment,
|
|
381
|
+
color=_color_enabled(args),
|
|
382
|
+
)
|
|
383
|
+
print(text)
|
|
384
|
+
return 130 if result.interrupted else result.exit_code
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _sessions(args: argparse.Namespace, config: Config, database: Path) -> int:
|
|
388
|
+
if args.limit < 1:
|
|
389
|
+
raise ConfigurationError("--limit must be at least 1")
|
|
390
|
+
with Storage(database) as storage:
|
|
391
|
+
sessions = storage.list_sessions(args.limit)
|
|
392
|
+
if args.json:
|
|
393
|
+
payload = [
|
|
394
|
+
{
|
|
395
|
+
"id": session.id,
|
|
396
|
+
"ref": session.ref,
|
|
397
|
+
"status": str(session.status),
|
|
398
|
+
"command": session.command,
|
|
399
|
+
"command_display": session.command_display,
|
|
400
|
+
"started_at": session.started_at.isoformat() if session.started_at else None,
|
|
401
|
+
"duration_s": session.duration_s,
|
|
402
|
+
"exit_code": session.exit_code,
|
|
403
|
+
"interrupted": session.interrupted,
|
|
404
|
+
}
|
|
405
|
+
for session in sessions
|
|
406
|
+
]
|
|
407
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
408
|
+
else:
|
|
409
|
+
print(render_sessions(sessions, color=_color_enabled(args)))
|
|
410
|
+
return 0
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _load_session_data(
|
|
414
|
+
database: Path, reference: str
|
|
415
|
+
) -> tuple[Session, list[StoredEvent], SessionSummary]:
|
|
416
|
+
with Storage(database) as storage:
|
|
417
|
+
session = storage.require_session(reference)
|
|
418
|
+
events = storage.get_events(session.id)
|
|
419
|
+
summary = summarize(session, events)
|
|
420
|
+
return session, events, summary
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _show(args: argparse.Namespace, config: Config, database: Path) -> int:
|
|
424
|
+
_validate_limit(args.limit)
|
|
425
|
+
session, events, summary = _load_session_data(database, args.session)
|
|
426
|
+
if args.json:
|
|
427
|
+
report = build_report(
|
|
428
|
+
session,
|
|
429
|
+
summary,
|
|
430
|
+
events,
|
|
431
|
+
include_environment=args.include_environment or config.privacy.show_environment,
|
|
432
|
+
)
|
|
433
|
+
print(render_json(report), end="")
|
|
434
|
+
return 0
|
|
435
|
+
limit = None if args.all else (args.limit or config.report.max_events)
|
|
436
|
+
print(
|
|
437
|
+
render_session_report(
|
|
438
|
+
session,
|
|
439
|
+
summary,
|
|
440
|
+
events,
|
|
441
|
+
limit=limit,
|
|
442
|
+
include_environment=args.include_environment or config.privacy.show_environment,
|
|
443
|
+
color=_color_enabled(args),
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _report(args: argparse.Namespace, config: Config, database: Path) -> int:
|
|
450
|
+
_validate_limit(args.limit)
|
|
451
|
+
session, events, summary = _load_session_data(database, args.session)
|
|
452
|
+
report_format = args.format or config.report.default_format
|
|
453
|
+
include_environment = args.include_environment or config.privacy.show_environment
|
|
454
|
+
|
|
455
|
+
if report_format == "terminal":
|
|
456
|
+
limit = None if args.all else (args.limit or config.report.max_events)
|
|
457
|
+
content = (
|
|
458
|
+
render_session_report(
|
|
459
|
+
session,
|
|
460
|
+
summary,
|
|
461
|
+
events,
|
|
462
|
+
limit=limit,
|
|
463
|
+
include_environment=include_environment,
|
|
464
|
+
color=False if args.output else _color_enabled(args),
|
|
465
|
+
)
|
|
466
|
+
+ "\n"
|
|
467
|
+
)
|
|
468
|
+
elif report_format == "json":
|
|
469
|
+
event_limit = None if args.all else args.limit
|
|
470
|
+
report = build_report(
|
|
471
|
+
session,
|
|
472
|
+
summary,
|
|
473
|
+
events,
|
|
474
|
+
include_environment=include_environment,
|
|
475
|
+
event_limit=event_limit,
|
|
476
|
+
)
|
|
477
|
+
content = render_json(report)
|
|
478
|
+
else:
|
|
479
|
+
event_limit = None if args.all else (args.limit or config.report.max_events)
|
|
480
|
+
report = build_report(
|
|
481
|
+
session,
|
|
482
|
+
summary,
|
|
483
|
+
events,
|
|
484
|
+
include_environment=include_environment,
|
|
485
|
+
event_limit=event_limit,
|
|
486
|
+
)
|
|
487
|
+
content = render_html(report)
|
|
488
|
+
|
|
489
|
+
if args.output is None:
|
|
490
|
+
print(content, end="" if content.endswith("\n") else "\n")
|
|
491
|
+
return 0
|
|
492
|
+
_write_output(args.output, content, force=args.force)
|
|
493
|
+
print(f"Report written to {args.output}")
|
|
494
|
+
return 0
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _write_output(path: Path, content: str, *, force: bool) -> None:
|
|
498
|
+
target = path.expanduser()
|
|
499
|
+
if target.exists() and not force:
|
|
500
|
+
raise ReportExistsError(f"output file already exists: {target} (use --force to overwrite)")
|
|
501
|
+
try:
|
|
502
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
503
|
+
target.write_text(content, encoding="utf-8")
|
|
504
|
+
except OSError as exc:
|
|
505
|
+
raise CmdScopeError(f"cannot write report {target}: {exc}") from exc
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _compare(args: argparse.Namespace, config: Config, database: Path) -> int:
|
|
509
|
+
_validate_limit(args.limit)
|
|
510
|
+
with Storage(database) as storage:
|
|
511
|
+
result = compare_sessions(storage, args.session_a, args.session_b)
|
|
512
|
+
if args.format == "json":
|
|
513
|
+
print(render_comparison_json(result), end="")
|
|
514
|
+
return 0
|
|
515
|
+
limit = None if args.all else (args.limit or config.report.max_events)
|
|
516
|
+
print(render_comparison(result, limit=limit, color=_color_enabled(args)))
|
|
517
|
+
return 0
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _doctor(args: argparse.Namespace, config: Config, database: Path) -> int:
|
|
521
|
+
data = collect_diagnostics(config, database)
|
|
522
|
+
if args.json:
|
|
523
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
524
|
+
else:
|
|
525
|
+
print(render_diagnostics(data))
|
|
526
|
+
return 0
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _config(args: argparse.Namespace, config: Config) -> int:
|
|
530
|
+
if args.init:
|
|
531
|
+
target = Path.cwd() / "cmdscope.toml"
|
|
532
|
+
if target.exists() and not args.force:
|
|
533
|
+
raise ConfigurationError(
|
|
534
|
+
f"configuration file already exists: {target} (use --force to overwrite)"
|
|
535
|
+
)
|
|
536
|
+
try:
|
|
537
|
+
target.write_text(default_config_text(), encoding="utf-8")
|
|
538
|
+
except OSError as exc:
|
|
539
|
+
raise ConfigurationError(f"cannot write {target}: {exc}") from exc
|
|
540
|
+
print(f"Wrote {target}")
|
|
541
|
+
return 0
|
|
542
|
+
|
|
543
|
+
if args.path:
|
|
544
|
+
print(f"source: {config.source_path or 'none (defaults)'}")
|
|
545
|
+
print(f"local: {Path.cwd() / 'cmdscope.toml'}")
|
|
546
|
+
print(f"user: {user_config_path()}")
|
|
547
|
+
print(f"database: {config.database_path()}")
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
print(json.dumps(config_as_dict(config), indent=2, ensure_ascii=False))
|
|
551
|
+
return 0
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
if __name__ == "__main__": # pragma: no cover
|
|
555
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Collectors for filesystem, process, and network observation."""
|