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/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."""