runmark 0.2.1__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 (105) hide show
  1. runmark/__init__.py +7 -0
  2. runmark/__main__.py +8 -0
  3. runmark/cli/__init__.py +5 -0
  4. runmark/cli/app.py +74 -0
  5. runmark/cli/commands/__init__.py +27 -0
  6. runmark/cli/commands/check.py +54 -0
  7. runmark/cli/commands/contract.py +236 -0
  8. runmark/cli/commands/diff.py +90 -0
  9. runmark/cli/commands/doctor.py +73 -0
  10. runmark/cli/commands/history.py +25 -0
  11. runmark/cli/commands/init.py +65 -0
  12. runmark/cli/commands/scan.py +25 -0
  13. runmark/cli/commands/share.py +104 -0
  14. runmark/cli/commands/snapshot.py +39 -0
  15. runmark/cli/commands/verify.py +75 -0
  16. runmark/cli/commands/version.py +36 -0
  17. runmark/contracts/__init__.py +60 -0
  18. runmark/contracts/canonicalizer.py +119 -0
  19. runmark/contracts/diff.py +390 -0
  20. runmark/contracts/discovery.py +33 -0
  21. runmark/contracts/evaluator.py +724 -0
  22. runmark/contracts/evidence.py +448 -0
  23. runmark/contracts/generator.py +107 -0
  24. runmark/contracts/parser.py +60 -0
  25. runmark/contracts/security.py +11 -0
  26. runmark/contracts/validator.py +157 -0
  27. runmark/contracts/version_constraints.py +234 -0
  28. runmark/core/__init__.py +32 -0
  29. runmark/core/contract_check.py +66 -0
  30. runmark/core/contract_diff.py +65 -0
  31. runmark/core/contract_init.py +80 -0
  32. runmark/core/diff.py +522 -0
  33. runmark/core/doctor.py +240 -0
  34. runmark/core/reporter.py +163 -0
  35. runmark/core/scanner.py +173 -0
  36. runmark/core/snapshot.py +34 -0
  37. runmark/core/verifier.py +83 -0
  38. runmark/detectors/__init__.py +86 -0
  39. runmark/detectors/base.py +66 -0
  40. runmark/detectors/containers/__init__.py +5 -0
  41. runmark/detectors/containers/compose.py +82 -0
  42. runmark/detectors/dependencies/__init__.py +9 -0
  43. runmark/detectors/dependencies/node.py +185 -0
  44. runmark/detectors/dependencies/python.py +162 -0
  45. runmark/detectors/environment/__init__.py +5 -0
  46. runmark/detectors/environment/env.py +89 -0
  47. runmark/detectors/git/__init__.py +5 -0
  48. runmark/detectors/git/git.py +81 -0
  49. runmark/detectors/network/__init__.py +5 -0
  50. runmark/detectors/network/ports.py +90 -0
  51. runmark/detectors/project/__init__.py +11 -0
  52. runmark/detectors/project/docker.py +48 -0
  53. runmark/detectors/project/node.py +95 -0
  54. runmark/detectors/project/python.py +97 -0
  55. runmark/detectors/registry.py +39 -0
  56. runmark/detectors/runtimes/__init__.py +13 -0
  57. runmark/detectors/runtimes/docker.py +59 -0
  58. runmark/detectors/runtimes/git.py +59 -0
  59. runmark/detectors/runtimes/node.py +59 -0
  60. runmark/detectors/runtimes/python.py +62 -0
  61. runmark/detectors/services/__init__.py +9 -0
  62. runmark/detectors/services/postgres.py +104 -0
  63. runmark/detectors/services/redis.py +104 -0
  64. runmark/detectors/system/__init__.py +5 -0
  65. runmark/detectors/system/system.py +40 -0
  66. runmark/models/__init__.py +81 -0
  67. runmark/models/common.py +36 -0
  68. runmark/models/container.py +14 -0
  69. runmark/models/contract.py +122 -0
  70. runmark/models/contract_result.py +84 -0
  71. runmark/models/dependency.py +19 -0
  72. runmark/models/diagnostic.py +133 -0
  73. runmark/models/environment.py +32 -0
  74. runmark/models/git.py +16 -0
  75. runmark/models/network.py +17 -0
  76. runmark/models/project.py +20 -0
  77. runmark/models/runmark.py +58 -0
  78. runmark/models/runtime.py +19 -0
  79. runmark/models/service.py +21 -0
  80. runmark/models/system.py +13 -0
  81. runmark/output/__init__.py +34 -0
  82. runmark/output/contract.py +337 -0
  83. runmark/output/json.py +16 -0
  84. runmark/output/markdown.py +172 -0
  85. runmark/output/tables.py +300 -0
  86. runmark/output/terminal.py +70 -0
  87. runmark/security/__init__.py +29 -0
  88. runmark/security/contract_sanitizer.py +38 -0
  89. runmark/security/export_sanitizer.py +142 -0
  90. runmark/security/redactor.py +75 -0
  91. runmark/security/sanitizer.py +105 -0
  92. runmark/security/secret_patterns.py +116 -0
  93. runmark/storage/__init__.py +6 -0
  94. runmark/storage/filesystem.py +133 -0
  95. runmark/storage/paths.py +60 -0
  96. runmark/utils/__init__.py +20 -0
  97. runmark/utils/commands.py +117 -0
  98. runmark/utils/hashing.py +240 -0
  99. runmark/utils/platform.py +36 -0
  100. runmark-0.2.1.dist-info/METADATA +149 -0
  101. runmark-0.2.1.dist-info/RECORD +105 -0
  102. runmark-0.2.1.dist-info/WHEEL +5 -0
  103. runmark-0.2.1.dist-info/entry_points.txt +2 -0
  104. runmark-0.2.1.dist-info/licenses/LICENSE +21 -0
  105. runmark-0.2.1.dist-info/top_level.txt +1 -0
runmark/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Runmark: Know what makes your code run.
2
+
3
+ Local-first development environment observability, fingerprinting, comparison, and verification.
4
+ """
5
+
6
+ __version__ = "0.2.1"
7
+ __schema_version__ = "1.0"
runmark/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entrypoint for `python -m runmark`."""
2
+
3
+ import sys
4
+
5
+ from runmark.cli.app import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """CLI package."""
2
+
3
+ from runmark.cli.app import app, main
4
+
5
+ __all__ = ["app", "main"]
runmark/cli/app.py ADDED
@@ -0,0 +1,74 @@
1
+ """Runmark CLI application entrypoint."""
2
+
3
+ import sys
4
+
5
+ import typer
6
+
7
+ from runmark.cli.commands.check import check_command
8
+ from runmark.cli.commands.contract import contract_app
9
+ from runmark.cli.commands.diff import diff_command
10
+ from runmark.cli.commands.doctor import doctor_command
11
+ from runmark.cli.commands.history import history_command
12
+ from runmark.cli.commands.init import init_command
13
+ from runmark.cli.commands.scan import scan_command
14
+ from runmark.cli.commands.share import share_command
15
+ from runmark.cli.commands.snapshot import snapshot_command
16
+ from runmark.cli.commands.verify import verify_command
17
+ from runmark.cli.commands.version import version_command
18
+ from runmark.output.terminal import term
19
+
20
+ app = typer.Typer(
21
+ name="runmark",
22
+ help="Runmark — Know what makes your code run.\n\nGit tracks your code. Runmark tracks what makes your code run.",
23
+ no_args_is_help=True,
24
+ add_completion=False,
25
+ )
26
+
27
+ # Register subcommands
28
+ app.command("init", help="Initialize Runmark tracking in the current project.")(init_command)
29
+ app.command(
30
+ "scan", help="Inspect and display the complete runtime, dependency, and service state."
31
+ )(scan_command)
32
+ app.command(
33
+ "check",
34
+ help="Evaluate whether the host environment satisfies the project environment contract (runmark.json).",
35
+ )(check_command)
36
+ app.add_typer(contract_app)
37
+ app.command(
38
+ "snapshot",
39
+ help="Capture and persist current environment state into an immutable baseline snapshot.",
40
+ )(snapshot_command)
41
+ app.command(
42
+ "diff", help="Compare environment state between snapshots or against live environment."
43
+ )(diff_command)
44
+ app.command("verify", help="Verify current machine environment against a baseline snapshot.")(
45
+ verify_command
46
+ )
47
+ app.command(
48
+ "doctor", help="Diagnose environment discrepancies and get actionable remediation advice."
49
+ )(doctor_command)
50
+ app.command(
51
+ "share",
52
+ help="Generate a sanitized, portable diagnostic report for sharing with teammates or issues.",
53
+ )(share_command)
54
+ app.command("version", help="Display version and platform diagnostics.")(version_command)
55
+ app.command("history", help="List snapshot history.")(history_command)
56
+
57
+
58
+ def main() -> int:
59
+ """Main CLI entrypoint."""
60
+ try:
61
+ app()
62
+ return 0
63
+ except typer.Exit as e:
64
+ return e.exit_code
65
+ except KeyboardInterrupt:
66
+ term.print("\n[dim]Operation cancelled by user.[/dim]")
67
+ return 130
68
+ except Exception as exc:
69
+ term.print_error(f"Unexpected error: {exc}")
70
+ return 3
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main())
@@ -0,0 +1,27 @@
1
+ """CLI commands package."""
2
+
3
+ from runmark.cli.commands.check import check_command
4
+ from runmark.cli.commands.contract import contract_app
5
+ from runmark.cli.commands.diff import diff_command
6
+ from runmark.cli.commands.doctor import doctor_command
7
+ from runmark.cli.commands.history import history_command
8
+ from runmark.cli.commands.init import init_command
9
+ from runmark.cli.commands.scan import scan_command
10
+ from runmark.cli.commands.share import share_command
11
+ from runmark.cli.commands.snapshot import snapshot_command
12
+ from runmark.cli.commands.verify import verify_command
13
+ from runmark.cli.commands.version import version_command
14
+
15
+ __all__ = [
16
+ "check_command",
17
+ "contract_app",
18
+ "diff_command",
19
+ "doctor_command",
20
+ "history_command",
21
+ "init_command",
22
+ "scan_command",
23
+ "share_command",
24
+ "snapshot_command",
25
+ "verify_command",
26
+ "version_command",
27
+ ]
@@ -0,0 +1,54 @@
1
+ """CLI command: runmark check."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from runmark.contracts.parser import ContractParseError
8
+ from runmark.contracts.security import ContractSecurityError
9
+ from runmark.contracts.validator import ContractValidationError
10
+ from runmark.core.contract_check import ContractCheckService
11
+ from runmark.output.contract import render_contract_check
12
+ from runmark.output.terminal import term
13
+
14
+
15
+ def check_command(
16
+ path: Path | None = typer.Option(
17
+ None,
18
+ "--path",
19
+ "-p",
20
+ help="Target project root directory (defaults to current directory)",
21
+ ),
22
+ json_output: bool = typer.Option(
23
+ False,
24
+ "--json",
25
+ help="Output evaluation results as machine-readable JSON",
26
+ ),
27
+ explain: bool = typer.Option(
28
+ False,
29
+ "--explain",
30
+ help="Display detailed diagnostic breakdown and suggested actions for unsatisfied requirements",
31
+ ),
32
+ ) -> None:
33
+ """Evaluate whether current host environment satisfies the project environment contract (runmark.json)."""
34
+ try:
35
+ contract, result = ContractCheckService.check_environment(project_path=path)
36
+ except ContractSecurityError as sec_err:
37
+ term.print_error(str(sec_err))
38
+ raise typer.Exit(code=4) from None
39
+ except (FileNotFoundError, ContractParseError, ContractValidationError) as err:
40
+ term.print_error(str(err))
41
+ raise typer.Exit(code=2) from None
42
+ except Exception as exc:
43
+ term.print_error(f"Internal error during contract evaluation: {exc}")
44
+ raise typer.Exit(code=3) from None
45
+
46
+ if json_output:
47
+ term.print_json(result)
48
+ else:
49
+ render_contract_check(result, explain=explain)
50
+
51
+ if result.is_passed:
52
+ raise typer.Exit(code=0)
53
+ else:
54
+ raise typer.Exit(code=1)
@@ -0,0 +1,236 @@
1
+ """CLI command group: runmark contract."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from runmark.contracts.parser import ContractParseError
8
+ from runmark.contracts.security import ContractSecurityError
9
+ from runmark.contracts.validator import ContractValidationError
10
+ from runmark.core.contract_check import ContractCheckService
11
+ from runmark.core.contract_diff import ContractDiffService
12
+ from runmark.core.contract_init import ContractInitService
13
+ from runmark.output.contract import (
14
+ render_contract_diff,
15
+ render_contract_preview,
16
+ render_contract_show,
17
+ )
18
+ from runmark.output.terminal import term
19
+
20
+ contract_app = typer.Typer(
21
+ name="contract",
22
+ help="Inspect, validate, initialize, and display project environment contracts (runmark.json).",
23
+ no_args_is_help=True,
24
+ )
25
+
26
+
27
+ @contract_app.command(
28
+ "init", help="Bootstrap a runmark.json contract from discovered project evidence."
29
+ )
30
+ def init_contract_command(
31
+ path: Path | None = typer.Option(
32
+ None,
33
+ "--path",
34
+ "-p",
35
+ help="Target project root directory (defaults to current directory)",
36
+ ),
37
+ yes: bool = typer.Option(
38
+ False,
39
+ "--yes",
40
+ "-y",
41
+ help="Skip interactive confirmation and generate contract directly",
42
+ ),
43
+ dry_run: bool = typer.Option(
44
+ False,
45
+ "--dry-run",
46
+ help="Preview generated contract without modifying any files",
47
+ ),
48
+ force: bool = typer.Option(
49
+ False,
50
+ "--force",
51
+ "-f",
52
+ help="Overwrite existing runmark.json contract",
53
+ ),
54
+ ) -> None:
55
+ """Bootstrap an environment contract from project evidence."""
56
+ target_root = path.resolve() if path else Path.cwd().resolve()
57
+ target_file = target_root / "runmark.json"
58
+
59
+ # Pre-check for existing contract if not using force/dry-run
60
+ if target_file.exists() and not force and not dry_run:
61
+ term.print_error(
62
+ f"Environment contract 'runmark.json' already exists in '{target_root}'.\n"
63
+ "Use --force to replace the existing contract."
64
+ )
65
+ raise typer.Exit(code=2)
66
+
67
+ try:
68
+ contract, evidence, _ = ContractInitService.init_contract(
69
+ project_path=path,
70
+ force=force,
71
+ dry_run=True, # Always generate in dry-run first for preview and validation
72
+ )
73
+ except ContractSecurityError as sec_err:
74
+ term.print_error(str(sec_err))
75
+ raise typer.Exit(code=4) from None
76
+ except FileExistsError as fe_err:
77
+ term.print_error(str(fe_err))
78
+ raise typer.Exit(code=2) from None
79
+ except (FileNotFoundError, ContractParseError, ContractValidationError) as err:
80
+ term.print_error(str(err))
81
+ raise typer.Exit(code=2) from None
82
+ except Exception as exc:
83
+ term.print_error(f"Internal error generating contract: {exc}")
84
+ raise typer.Exit(code=3) from None
85
+
86
+ # Render Preview
87
+ render_contract_preview(contract, evidence)
88
+
89
+ if dry_run:
90
+ term.print_warning("Dry-run mode enabled. No files were modified.")
91
+ raise typer.Exit(code=0)
92
+
93
+ # Interactive confirmation if not skipped with --yes
94
+ if not yes:
95
+ confirmed = typer.confirm("Create runmark.json?", default=True)
96
+ if not confirmed:
97
+ term.print("Aborted. No files were modified.")
98
+ raise typer.Exit(code=0)
99
+
100
+ # Persist contract atomically
101
+ try:
102
+ ContractInitService.init_contract(
103
+ project_path=path,
104
+ force=force,
105
+ dry_run=False,
106
+ )
107
+ except ContractSecurityError as sec_err:
108
+ term.print_error(str(sec_err))
109
+ raise typer.Exit(code=4) from None
110
+ except Exception as exc:
111
+ term.print_error(f"Failed to persist contract: {exc}")
112
+ raise typer.Exit(code=3) from None
113
+
114
+ term.print_success("Environment contract 'runmark.json' created successfully.")
115
+ raise typer.Exit(code=0)
116
+
117
+
118
+ @contract_app.command(
119
+ "diff", help="Show semantic differences between working contract and baseline."
120
+ )
121
+ def diff_contract_command(
122
+ path: Path | None = typer.Option(
123
+ None,
124
+ "--path",
125
+ "-p",
126
+ help="Target project root directory (defaults to current directory)",
127
+ ),
128
+ json_output: bool = typer.Option(
129
+ False,
130
+ "--json",
131
+ help="Output semantic contract diff as machine-readable JSON",
132
+ ),
133
+ ) -> None:
134
+ """Compare working runmark.json against Git repository baseline."""
135
+ try:
136
+ diff_result = ContractDiffService.diff_contracts(project_path=path)
137
+ except ContractSecurityError as sec_err:
138
+ term.print_error(str(sec_err))
139
+ raise typer.Exit(code=4) from None
140
+ except (FileNotFoundError, ContractParseError, ContractValidationError) as err:
141
+ term.print_error(str(err))
142
+ raise typer.Exit(code=2) from None
143
+ except Exception as exc:
144
+ term.print_error(f"Internal error diffing contracts: {exc}")
145
+ raise typer.Exit(code=3) from None
146
+
147
+ if json_output:
148
+ term.print_json(diff_result)
149
+ else:
150
+ render_contract_diff(diff_result)
151
+ raise typer.Exit(code=0)
152
+
153
+
154
+ @contract_app.command(
155
+ "validate", help="Validate runmark.json syntax, schema, domain semantics, and security."
156
+ )
157
+ def validate_contract_command(
158
+ path: Path | None = typer.Option(
159
+ None,
160
+ "--path",
161
+ "-p",
162
+ help="Target project root directory (defaults to current directory)",
163
+ ),
164
+ json_output: bool = typer.Option(
165
+ False,
166
+ "--json",
167
+ help="Output validation status as machine-readable JSON",
168
+ ),
169
+ ) -> None:
170
+ """Validate that runmark.json is structurally, semantically, and securely valid."""
171
+ try:
172
+ contract = ContractCheckService.validate_contract(project_path=path)
173
+ except ContractSecurityError as sec_err:
174
+ term.print_error(str(sec_err))
175
+ raise typer.Exit(code=4) from None
176
+ except (FileNotFoundError, ContractParseError, ContractValidationError) as err:
177
+ term.print_error(str(err))
178
+ raise typer.Exit(code=2) from None
179
+ except Exception as exc:
180
+ term.print_error(f"Internal error validating contract: {exc}")
181
+ raise typer.Exit(code=3) from None
182
+
183
+ if json_output:
184
+ term.print_json(
185
+ {
186
+ "status": "valid",
187
+ "version": contract.version,
188
+ "project": contract.project.name,
189
+ }
190
+ )
191
+ else:
192
+ term.print_success(
193
+ f"Environment contract 'runmark.json' is valid (version {contract.version})."
194
+ )
195
+ raise typer.Exit(code=0)
196
+
197
+
198
+ @contract_app.command(
199
+ "show", help="Display the canonical / normalized interpretation of runmark.json."
200
+ )
201
+ def show_contract_command(
202
+ path: Path | None = typer.Option(
203
+ None,
204
+ "--path",
205
+ "-p",
206
+ help="Target project root directory (defaults to current directory)",
207
+ ),
208
+ json_output: bool = typer.Option(
209
+ False,
210
+ "--json",
211
+ help="Output canonical contract definition as machine-readable JSON",
212
+ ),
213
+ ) -> None:
214
+ """Display normalized contract specifications and canonical fingerprint."""
215
+ try:
216
+ contract, canonical, fingerprint = ContractCheckService.show_contract(project_path=path)
217
+ except ContractSecurityError as sec_err:
218
+ term.print_error(str(sec_err))
219
+ raise typer.Exit(code=4) from None
220
+ except (FileNotFoundError, ContractParseError, ContractValidationError) as err:
221
+ term.print_error(str(err))
222
+ raise typer.Exit(code=2) from None
223
+ except Exception as exc:
224
+ term.print_error(f"Internal error displaying contract: {exc}")
225
+ raise typer.Exit(code=3) from None
226
+
227
+ if json_output:
228
+ term.print_json(
229
+ {
230
+ "contract": canonical,
231
+ "fingerprint": fingerprint,
232
+ }
233
+ )
234
+ else:
235
+ render_contract_show(contract, canonical, fingerprint)
236
+ raise typer.Exit(code=0)
@@ -0,0 +1,90 @@
1
+ """CLI command: runmark diff."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from runmark.core.diff import DiffEngine
8
+ from runmark.core.scanner import Scanner
9
+ from runmark.core.snapshot import SnapshotManager
10
+ from runmark.models.runmark import RunmarkState
11
+ from runmark.output.tables import render_diff
12
+ from runmark.output.terminal import term
13
+ from runmark.storage.paths import find_project_root
14
+
15
+
16
+ def diff_command(
17
+ from_snapshot: str | None = typer.Option(
18
+ None,
19
+ "--from",
20
+ help="Baseline snapshot ID (defaults to current active snapshot)",
21
+ ),
22
+ to_snapshot: str | None = typer.Option(
23
+ None,
24
+ "--to",
25
+ help="Target snapshot ID (defaults to active live scan of current environment)",
26
+ ),
27
+ path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
28
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
29
+ ) -> None:
30
+ """Compare environment state between snapshots or against current live environment."""
31
+ root = find_project_root(path)
32
+ mgr = SnapshotManager(root)
33
+
34
+ # 1. Load baseline state
35
+ base_state: RunmarkState
36
+ if from_snapshot:
37
+ try:
38
+ base_state = mgr.get_snapshot(from_snapshot)
39
+ except FileNotFoundError:
40
+ term.print_error(f"Snapshot '{from_snapshot}' not found.")
41
+ raise typer.Exit(code=2) from None
42
+ else:
43
+ loaded_base = mgr.get_current()
44
+ if not loaded_base:
45
+ term.print_error(
46
+ "No baseline snapshot found. Run 'runmark snapshot' first to create a baseline."
47
+ )
48
+ raise typer.Exit(code=2)
49
+ base_state = loaded_base
50
+
51
+ # 2. Load target state
52
+ target_state: RunmarkState
53
+ if to_snapshot:
54
+ try:
55
+ target_state = mgr.get_snapshot(to_snapshot)
56
+ except FileNotFoundError:
57
+ term.print_error(f"Snapshot '{to_snapshot}' not found.")
58
+ raise typer.Exit(code=2) from None
59
+ else:
60
+ scanner = Scanner(root)
61
+ target_state = scanner.scan()
62
+
63
+ # 3. Compute semantic diff
64
+ diff = DiffEngine.compare(base_state, target_state)
65
+
66
+ if json_output:
67
+ term.print_json(
68
+ {
69
+ "from_id": diff.from_id,
70
+ "to_id": diff.to_id,
71
+ "from_fingerprint": diff.from_fingerprint,
72
+ "to_fingerprint": diff.to_fingerprint,
73
+ "is_identical": diff.is_identical,
74
+ "items": [
75
+ {
76
+ "category": item.category,
77
+ "item_name": item.item_name,
78
+ "classification": item.classification.value,
79
+ "severity": item.severity.value,
80
+ "old_value": item.old_value,
81
+ "new_value": item.new_value,
82
+ "description": item.description,
83
+ "is_source_revision": item.is_source_revision,
84
+ }
85
+ for item in diff.items
86
+ ],
87
+ }
88
+ )
89
+ else:
90
+ render_diff(diff, term.console)
@@ -0,0 +1,73 @@
1
+ """CLI command: runmark doctor."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from runmark.core.diff import DiffEngine
8
+ from runmark.core.doctor import Doctor
9
+ from runmark.core.scanner import Scanner
10
+ from runmark.core.snapshot import SnapshotManager
11
+ from runmark.output.tables import render_doctor
12
+ from runmark.output.terminal import term
13
+ from runmark.storage.paths import find_project_root
14
+
15
+
16
+ def doctor_command(
17
+ snapshot: str | None = typer.Option(
18
+ None,
19
+ "--snapshot",
20
+ "-s",
21
+ help="Compare current machine against a baseline snapshot",
22
+ ),
23
+ path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
24
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
25
+ ) -> None:
26
+ """Diagnose development environment issues, missing dependencies, or baseline drift."""
27
+ root = find_project_root(path)
28
+ scanner = Scanner(root)
29
+ current_state = scanner.scan()
30
+
31
+ if snapshot:
32
+ mgr = SnapshotManager(root)
33
+ try:
34
+ expected_state = mgr.get_snapshot(snapshot)
35
+ diff = DiffEngine.compare(expected_state, current_state)
36
+ report = Doctor.diagnose_diff(diff)
37
+ except FileNotFoundError:
38
+ term.print_error(f"Snapshot '{snapshot}' not found.")
39
+ raise typer.Exit(code=2) from None
40
+ else:
41
+ # Check against baseline snapshot if exists, or diagnose current scan directly
42
+ mgr = SnapshotManager(root)
43
+ baseline = mgr.get_current()
44
+ if baseline:
45
+ diff = DiffEngine.compare(baseline, current_state)
46
+ report = Doctor.diagnose_diff(diff)
47
+ # If diff is clean, also do standalone check for missing required env vars in current state
48
+ if report.is_healthy:
49
+ report = Doctor.diagnose_state(current_state)
50
+ else:
51
+ report = Doctor.diagnose_state(current_state)
52
+
53
+ if json_output:
54
+ term.print_json(
55
+ {
56
+ "project_name": report.project_name,
57
+ "fingerprint": report.fingerprint,
58
+ "is_healthy": report.is_healthy,
59
+ "issues": [
60
+ {
61
+ "code": issue.code,
62
+ "severity": issue.severity.value,
63
+ "title": issue.title,
64
+ "evidence": issue.evidence,
65
+ "explanation": issue.explanation,
66
+ "suggested_action": issue.suggested_action,
67
+ }
68
+ for issue in report.issues
69
+ ],
70
+ }
71
+ )
72
+ else:
73
+ render_doctor(report, term.console)
@@ -0,0 +1,25 @@
1
+ """CLI command: runmark history."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from runmark.core.snapshot import SnapshotManager
8
+ from runmark.output.tables import render_history
9
+ from runmark.output.terminal import term
10
+ from runmark.storage.paths import find_project_root
11
+
12
+
13
+ def history_command(
14
+ path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
15
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
16
+ ) -> None:
17
+ """Display chronological history of captured environment snapshots."""
18
+ root = find_project_root(path)
19
+ mgr = SnapshotManager(root)
20
+ snapshots = mgr.list_history()
21
+
22
+ if json_output:
23
+ term.print_json([s.model_dump(mode="json") for s in snapshots])
24
+ else:
25
+ render_history(snapshots, term.console)
@@ -0,0 +1,65 @@
1
+ """CLI command: runmark init."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.panel import Panel
7
+
8
+ from runmark.output.terminal import term
9
+ from runmark.storage.filesystem import FilesystemStorage
10
+ from runmark.storage.paths import find_project_root
11
+
12
+
13
+ def init_command(
14
+ path: Path = typer.Option(None, "--path", "-p", help="Target project root directory"),
15
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing configuration"),
16
+ json_output: bool = typer.Option(False, "--json", help="Output result as JSON"),
17
+ ) -> None:
18
+ """Initialize Runmark configuration in the current project."""
19
+ root = find_project_root(path)
20
+ storage = FilesystemStorage(root)
21
+
22
+ already_initialized = storage.paths.is_initialized()
23
+ if already_initialized and not force:
24
+ if json_output:
25
+ term.print_json(
26
+ {
27
+ "status": "already_initialized",
28
+ "project_root": str(root),
29
+ "runmark_dir": str(storage.paths.runmark_dir),
30
+ }
31
+ )
32
+ else:
33
+ term.print(
34
+ Panel(
35
+ f"[yellow]Runmark is already initialized in:[/yellow] [bold]{root}[/bold]\n"
36
+ f"Configuration: [dim]{storage.paths.config_file}[/dim]\n"
37
+ f"Use [cyan]--force[/cyan] to re-initialize.",
38
+ border_style="yellow",
39
+ )
40
+ )
41
+ return
42
+
43
+ storage.initialize(force=force)
44
+
45
+ if json_output:
46
+ term.print_json(
47
+ {
48
+ "status": "initialized",
49
+ "project_root": str(root),
50
+ "runmark_dir": str(storage.paths.runmark_dir),
51
+ "config_file": str(storage.paths.config_file),
52
+ }
53
+ )
54
+ else:
55
+ term.print(
56
+ Panel(
57
+ f"[bold green]✓ Initialized Runmark in:[/bold green] [bold]{root}[/bold]\n\n"
58
+ f"• Created configuration: [dim]{storage.paths.config_file}[/dim]\n"
59
+ f"• Created snapshots dir: [dim]{storage.paths.snapshots_dir}[/dim]\n\n"
60
+ f"Next steps:\n"
61
+ f" 1. Run [bold cyan]runmark scan[/bold cyan] to inspect your environment.\n"
62
+ f" 2. Run [bold cyan]runmark snapshot[/bold cyan] to create your first baseline.",
63
+ border_style="green",
64
+ )
65
+ )