masterytrace-cli 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.
@@ -0,0 +1,68 @@
1
+ """
2
+ MasteryTrace: Mastery Measurement API and CLI. Fits Bayesian Knowledge
3
+ Tracing (BKT) and 2-parameter logistic Item Response Theory (IRT) models
4
+ to learner response logs and reports per-learner, per-skill mastery
5
+ estimates.
6
+
7
+ Re-exports mirror src/index.ts's re-export surface (core/*, models/*,
8
+ adapters/*) so `from masterytrace import ...` covers the same public API
9
+ as the npm package's `from 'masterytrace-cli'`.
10
+ """
11
+ from .adapters.generic_adapter import GenericAdapter, generic_adapter, parse_csv
12
+ from .core.config import DEFAULT_CONFIG, MasteryTraceConfig, load_config
13
+ from .core.engine import EngineConfig, EngineResult, ModelSelector, run_scoring
14
+ from .core.event_schema import EventValidationError, ResponseEvent, parse_response_events
15
+ from .core.scoring_model import FittedModel, MasteryLearnerEntry, MasteryReport, MasterySkillEntry, ScoringModel
16
+ from .models.bkt import (
17
+ BKT_DEFAULT_PARAMS,
18
+ BktConfig,
19
+ BktFittedModel,
20
+ BktModel,
21
+ BktParams,
22
+ fit_skill_params_by_grid_search,
23
+ run_forward_recursion,
24
+ )
25
+ from .models.irt import (
26
+ IrtConfig,
27
+ IrtFittedModel,
28
+ IrtItemParams,
29
+ IrtLearnerResult,
30
+ IrtModel,
31
+ probability_correct,
32
+ )
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ "GenericAdapter",
38
+ "generic_adapter",
39
+ "parse_csv",
40
+ "DEFAULT_CONFIG",
41
+ "MasteryTraceConfig",
42
+ "load_config",
43
+ "EngineConfig",
44
+ "EngineResult",
45
+ "ModelSelector",
46
+ "run_scoring",
47
+ "EventValidationError",
48
+ "ResponseEvent",
49
+ "parse_response_events",
50
+ "FittedModel",
51
+ "MasteryLearnerEntry",
52
+ "MasteryReport",
53
+ "MasterySkillEntry",
54
+ "ScoringModel",
55
+ "BKT_DEFAULT_PARAMS",
56
+ "BktConfig",
57
+ "BktFittedModel",
58
+ "BktModel",
59
+ "BktParams",
60
+ "fit_skill_params_by_grid_search",
61
+ "run_forward_recursion",
62
+ "IrtConfig",
63
+ "IrtFittedModel",
64
+ "IrtItemParams",
65
+ "IrtLearnerResult",
66
+ "IrtModel",
67
+ "probability_correct",
68
+ ]
File without changes
@@ -0,0 +1,134 @@
1
+ """
2
+ A source of response events: reads a JSON array of response events, or a
3
+ CSV file with columns `learner_id,skill_id,correct,timestamp`, chosen by
4
+ file extension. Ported from src/adapters/generic-adapter.ts, including its
5
+ size guard and its symlink refusal.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any, List, Union
13
+
14
+ from ..core.event_schema import ResponseEvent, parse_response_events
15
+
16
+ # Event logs are small structured records (a handful of fields per
17
+ # response event); there is no legitimate reason for one to approach this
18
+ # size. This guard exists for the case where masterytrace is invoked
19
+ # programmatically (e.g. by another agent/tool) on a file it did not
20
+ # choose itself: without it, an oversized file is read fully into memory
21
+ # and handed to json.loads before any validation runs, which can exhaust
22
+ # memory or hang the process well before the "not valid event data" error
23
+ # a bad file should produce.
24
+ MAX_EVENT_LOG_BYTES = 100 * 1024 * 1024 # 100 MB
25
+
26
+ _CSV_COLUMNS = ("learner_id", "skill_id", "correct", "timestamp")
27
+
28
+
29
+ def _assert_readable_regular_file(path: Union[str, Path]) -> None:
30
+ """
31
+ os.lstat (not os.stat) never follows a symlink, so a symlink at
32
+ `path` is caught here even if its target is a regular file elsewhere
33
+ on disk.
34
+ """
35
+ stats = os.lstat(path)
36
+ import stat as stat_module
37
+
38
+ if stat_module.S_ISLNK(stats.st_mode):
39
+ raise ValueError(f"Refusing to read '{path}': symlinks are not supported for event log paths.")
40
+ if not stat_module.S_ISREG(stats.st_mode):
41
+ raise ValueError(f"Refusing to read '{path}': not a regular file.")
42
+ if stats.st_size > MAX_EVENT_LOG_BYTES:
43
+ size_mb = stats.st_size / (1024 * 1024)
44
+ limit_mb = MAX_EVENT_LOG_BYTES / (1024 * 1024)
45
+ raise ValueError(
46
+ f"Refusing to read '{path}': file is {size_mb:.1f} MB, which exceeds the {limit_mb:.0f} MB event log size limit."
47
+ )
48
+
49
+
50
+ def _parse_csv_boolean(raw: str) -> Union[bool, str]:
51
+ """
52
+ Parses a CSV `correct` cell into a boolean. Only recognizes
53
+ `true`/`false` and `1`/`0` (case-insensitive, trimmed); anything else
54
+ is returned as the original raw string rather than silently guessed.
55
+ That matters: coercing every unrecognized value to False would make a
56
+ typo, an empty cell from a shifted column, or a "yes"/"no" export
57
+ format silently record as an incorrect response instead of surfacing
58
+ as bad data. Returning the raw string instead lets it fail
59
+ ResponseEvent validation's `correct` boolean check with a clear,
60
+ row-numbered validation error, exactly like malformed JSON input does.
61
+ """
62
+ normalized = raw.strip().lower()
63
+ if normalized in ("true", "1"):
64
+ return True
65
+ if normalized in ("false", "0"):
66
+ return False
67
+ return raw
68
+
69
+
70
+ def parse_csv(content: str) -> List[Any]:
71
+ """
72
+ Parses the fixed-column CSV format (`learner_id,skill_id,correct,
73
+ timestamp`) into the raw row shape event-schema expects, so it goes
74
+ through the same validation as JSON input. Deliberately hand-rolled
75
+ rather than pulling in a CSV library: the format has no quoting/
76
+ escaping requirements (skill and learner ids are plain identifiers),
77
+ so a dependency would buy nothing.
78
+ """
79
+ lines = [line for line in content.splitlines() if line.strip()]
80
+ if not lines:
81
+ return []
82
+
83
+ header = [h.strip() for h in lines[0].split(",")]
84
+ column_index = {name: i for i, name in enumerate(header)}
85
+ for required in _CSV_COLUMNS:
86
+ if required not in column_index:
87
+ raise ValueError(
88
+ f"CSV event log is missing required column '{required}'. Expected header: {','.join(_CSV_COLUMNS)}"
89
+ )
90
+
91
+ rows: List[Any] = []
92
+ for line in lines[1:]:
93
+ cells = line.split(",")
94
+
95
+ def cell(name: str) -> str:
96
+ index = column_index.get(name)
97
+ if index is None or index >= len(cells):
98
+ return ""
99
+ return cells[index].strip()
100
+
101
+ rows.append(
102
+ {
103
+ "learnerId": cell("learner_id"),
104
+ "skillId": cell("skill_id"),
105
+ "correct": _parse_csv_boolean(cell("correct")),
106
+ "timestamp": cell("timestamp"),
107
+ }
108
+ )
109
+ return rows
110
+
111
+
112
+ class GenericAdapter:
113
+ """
114
+ The only event-log adapter shipped in v0.1: reads a JSON array of
115
+ response events, or a CSV file with columns
116
+ `learner_id,skill_id,correct,timestamp`, chosen by file extension.
117
+ """
118
+
119
+ name = "generic"
120
+
121
+ def load(self, path: Union[str, Path]) -> List[ResponseEvent]:
122
+ _assert_readable_regular_file(path)
123
+ content = Path(path).read_text(encoding="utf-8")
124
+ extension = Path(path).suffix.lower()
125
+
126
+ if extension == ".csv":
127
+ raw: Any = parse_csv(content)
128
+ else:
129
+ raw = json.loads(content)
130
+
131
+ return parse_response_events(raw)
132
+
133
+
134
+ generic_adapter = GenericAdapter()
File without changes
File without changes
@@ -0,0 +1,57 @@
1
+ """
2
+ Scaffolds a bundled sample `events.json` (3 learners x 3 skills, several
3
+ responses each) and a default `masterytrace.config.json` in `cwd`.
4
+ Ported from src/cli/commands/init.ts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from ...core.config import DEFAULT_CONFIG
13
+ from ...data.sample_events import SAMPLE_EVENTS
14
+ from ..format import ok
15
+ from ..json_encode import to_jsonable
16
+ from ..types import CommandResult
17
+
18
+ EVENTS_SAMPLE_FILENAME = "events.json"
19
+ CONFIG_FILENAME = "masterytrace.config.json"
20
+
21
+
22
+ @dataclass
23
+ class InitOptions:
24
+ json: bool
25
+ force: bool
26
+
27
+
28
+ def run_init(cwd: str, options: InitOptions) -> CommandResult:
29
+ """
30
+ Existing files are left untouched unless `--force` is passed.
31
+ """
32
+ events_path = Path(cwd) / EVENTS_SAMPLE_FILENAME
33
+ config_path = Path(cwd) / CONFIG_FILENAME
34
+
35
+ created = []
36
+ skipped = []
37
+
38
+ if not options.force and events_path.exists():
39
+ skipped.append(EVENTS_SAMPLE_FILENAME)
40
+ else:
41
+ events_path.write_text(json.dumps(to_jsonable(SAMPLE_EVENTS), indent=2) + "\n", encoding="utf-8")
42
+ created.append(EVENTS_SAMPLE_FILENAME)
43
+
44
+ if not options.force and config_path.exists():
45
+ skipped.append(CONFIG_FILENAME)
46
+ else:
47
+ config_path.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n", encoding="utf-8")
48
+ created.append(CONFIG_FILENAME)
49
+
50
+ lines = []
51
+ if created:
52
+ lines.append(f"Created: {', '.join(created)}")
53
+ if skipped:
54
+ lines.append(f"Skipped (already exists, use --force to overwrite): {', '.join(skipped)}")
55
+ lines.append("Next: run 'masterytrace record events.json' to load it, then 'masterytrace score'.")
56
+
57
+ return ok(options.json, {"created": created, "skipped": skipped}, "\n".join(lines) + "\n")
@@ -0,0 +1,51 @@
1
+ """
2
+ Validates and loads an event log (JSON or CSV, chosen by file extension)
3
+ and stores it to `.masterytrace/events.json`. Storing always *replaces*
4
+ any previously stored log in v0.1 (there is no append/merge mode yet).
5
+ Ported from src/cli/commands/record.ts.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from ...adapters.generic_adapter import generic_adapter
14
+ from ...core.event_schema import EventValidationError
15
+ from ..format import fail, ok
16
+ from ..json_encode import to_jsonable
17
+ from ..types import CommandResult
18
+
19
+ STATE_DIR = ".masterytrace"
20
+ EVENTS_STATE_FILENAME = "events.json"
21
+
22
+
23
+ @dataclass
24
+ class RecordOptions:
25
+ json: bool
26
+
27
+
28
+ def run_record(cwd: str, event_log_path: str, options: RecordOptions) -> CommandResult:
29
+ try:
30
+ events = generic_adapter.load(event_log_path)
31
+ except EventValidationError as error:
32
+ return fail(
33
+ 2,
34
+ options.json,
35
+ {"error": str(error), "issues": error.issues},
36
+ f"Validation error:\n{error}\n",
37
+ )
38
+ except Exception as error: # noqa: BLE001 -- mirrors the TS catch-all for I/O/parse errors
39
+ return fail(1, options.json, {"error": str(error)}, f"Error: {error}\n")
40
+
41
+ state_dir = Path(cwd) / STATE_DIR
42
+ state_dir.mkdir(parents=True, exist_ok=True)
43
+ state_path = state_dir / EVENTS_STATE_FILENAME
44
+ state_path.write_text(json.dumps(to_jsonable(events), indent=2) + "\n", encoding="utf-8")
45
+
46
+ return ok(
47
+ options.json,
48
+ {"eventCount": len(events), "storedAt": str(state_path)},
49
+ f"Stored {len(events)} event(s) to {state_path}\n"
50
+ "(record replaces any previously stored event log; see --help for details.)\n",
51
+ )
@@ -0,0 +1,102 @@
1
+ """
2
+ Reads `.masterytrace/scores.json` (written by `masterytrace score`) and
3
+ prints a per-learner, per-skill mastery table in the requested format.
4
+ Ported from src/cli/commands/report.ts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List
12
+
13
+ from ..format import fail
14
+ from ..types import CommandResult
15
+ from .record import STATE_DIR
16
+ from .score import SCORES_STATE_FILENAME
17
+
18
+ ReportFormat = str # "table" | "json" | "markdown"
19
+
20
+
21
+ @dataclass
22
+ class ReportOptions:
23
+ json: bool
24
+ format: ReportFormat
25
+
26
+
27
+ def _build_rows(result: Dict[str, Any]) -> List[Dict[str, Any]]:
28
+ rows: List[Dict[str, Any]] = []
29
+ for report in result.get("reports", []):
30
+ for learner in report.get("learners", []):
31
+ for skill in learner.get("skills", []):
32
+ rows.append(
33
+ {
34
+ "learner_id": learner["learner_id"],
35
+ "skill_id": skill["skill_id"],
36
+ "model": report["model"],
37
+ "metric": skill["metric"],
38
+ "value": skill["value"],
39
+ "response_count": skill["response_count"],
40
+ }
41
+ )
42
+ rows.sort(key=lambda r: (r["learner_id"], r["skill_id"], r["model"]))
43
+ return rows
44
+
45
+
46
+ _TABLE_HEADER = ["learner", "skill", "model", "metric", "value", "responses"]
47
+
48
+
49
+ def _render_table(rows: List[Dict[str, Any]]) -> str:
50
+ if not rows:
51
+ return "No scores found.\n"
52
+ data = [
53
+ [r["learner_id"], r["skill_id"], r["model"], r["metric"], f"{r['value']:.4f}", str(r["response_count"])]
54
+ for r in rows
55
+ ]
56
+ widths = [max(len(_TABLE_HEADER[i]), *(len(row[i]) for row in data)) for i in range(len(_TABLE_HEADER))]
57
+
58
+ def render_row(cells: List[str]) -> str:
59
+ return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells))
60
+
61
+ lines = [render_row(_TABLE_HEADER), render_row(["-" * w for w in widths])]
62
+ lines.extend(render_row(row) for row in data)
63
+ return "\n".join(lines) + "\n"
64
+
65
+
66
+ def _render_markdown(rows: List[Dict[str, Any]]) -> str:
67
+ if not rows:
68
+ return "No scores found.\n"
69
+ lines = [
70
+ f"| {' | '.join(_TABLE_HEADER)} |",
71
+ f"| {' | '.join('---' for _ in _TABLE_HEADER)} |",
72
+ ]
73
+ for r in rows:
74
+ lines.append(
75
+ f"| {r['learner_id']} | {r['skill_id']} | {r['model']} | {r['metric']} | {r['value']:.4f} | {r['response_count']} |"
76
+ )
77
+ return "\n".join(lines) + "\n"
78
+
79
+
80
+ def run_report(cwd: str, options: ReportOptions) -> CommandResult:
81
+ scores_path = Path(cwd) / STATE_DIR / SCORES_STATE_FILENAME
82
+ if not scores_path.exists():
83
+ message = f"No scores found at {scores_path}. Run 'masterytrace score' first."
84
+ return fail(1, options.json, {"error": message}, f"{message}\n")
85
+
86
+ try:
87
+ result = json.loads(scores_path.read_text(encoding="utf-8"))
88
+ except Exception as error: # noqa: BLE001 -- mirrors the TS catch-all for parse errors
89
+ return fail(1, options.json, {"error": str(error)}, f"Error reading scores: {error}\n")
90
+
91
+ if options.json:
92
+ return CommandResult(exit_code=0, stdout=f"{json.dumps(result)}\n")
93
+
94
+ rows = _build_rows(result)
95
+ if options.format == "markdown":
96
+ text = _render_markdown(rows)
97
+ elif options.format == "json":
98
+ text = json.dumps(result, indent=2) + "\n"
99
+ else:
100
+ text = _render_table(rows)
101
+
102
+ return CommandResult(exit_code=0, stdout=text)
@@ -0,0 +1,63 @@
1
+ """
2
+ Fits and scores the stored event log (`.masterytrace/events.json`,
3
+ written by `masterytrace record`) with the requested model(s), writing
4
+ the unified report(s) to `.masterytrace/scores.json`. Ported from
5
+ src/cli/commands/score.ts.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from ...core.config import bkt_config_from_dict, irt_config_from_dict, load_config
14
+ from ...core.engine import EngineConfig, ModelSelector, run_scoring
15
+ from ...core.event_schema import EventValidationError, parse_response_events
16
+ from ..format import fail, ok
17
+ from ..json_encode import to_jsonable
18
+ from ..types import CommandResult
19
+ from .record import EVENTS_STATE_FILENAME, STATE_DIR
20
+
21
+ SCORES_STATE_FILENAME = "scores.json"
22
+
23
+
24
+ @dataclass
25
+ class ScoreOptions:
26
+ json: bool
27
+ model: ModelSelector
28
+
29
+
30
+ def run_score(cwd: str, options: ScoreOptions) -> CommandResult:
31
+ events_path = Path(cwd) / STATE_DIR / EVENTS_STATE_FILENAME
32
+ if not events_path.exists():
33
+ message = f"No stored event log found at {events_path}. Run 'masterytrace record <path>' first."
34
+ return fail(1, options.json, {"error": message}, f"{message}\n")
35
+
36
+ try:
37
+ raw = json.loads(events_path.read_text(encoding="utf-8"))
38
+ events = parse_response_events(raw)
39
+ except EventValidationError as error:
40
+ return fail(
41
+ 2,
42
+ options.json,
43
+ {"error": str(error), "issues": error.issues},
44
+ f"Validation error:\n{error}\n",
45
+ )
46
+ except Exception as error: # noqa: BLE001 -- mirrors the TS catch-all for parse errors
47
+ return fail(1, options.json, {"error": str(error)}, f"Error: {error}\n")
48
+
49
+ raw_config = load_config(cwd)
50
+ config = EngineConfig(
51
+ bkt=bkt_config_from_dict(raw_config.get("bkt")),
52
+ irt=irt_config_from_dict(raw_config.get("irt")),
53
+ )
54
+ result = run_scoring(events, options.model, config)
55
+
56
+ scores_path = Path(cwd) / STATE_DIR / SCORES_STATE_FILENAME
57
+ scores_path.write_text(json.dumps(to_jsonable(result), indent=2) + "\n", encoding="utf-8")
58
+
59
+ return ok(
60
+ options.json,
61
+ {"model": options.model, "eventCount": len(events), "storedAt": str(scores_path)},
62
+ f"Scored {len(events)} event(s) with model(s): {options.model}\nWrote {scores_path}\n",
63
+ )
@@ -0,0 +1,17 @@
1
+ """Builds successful/failing CommandResults, choosing JSON or human text output. Ported from src/cli/format.ts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any
6
+
7
+ from .types import CommandResult
8
+
9
+
10
+ def ok(as_json: bool, json_payload: Any, text: str) -> CommandResult:
11
+ return CommandResult(exit_code=0, stdout=f"{json.dumps(json_payload)}\n" if as_json else text)
12
+
13
+
14
+ def fail(exit_code: int, as_json: bool, json_payload: Any, text: str) -> CommandResult:
15
+ if as_json:
16
+ return CommandResult(exit_code=exit_code, stdout=f"{json.dumps(json_payload)}\n")
17
+ return CommandResult(exit_code=exit_code, stdout="", stderr=text)
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Console entry point: `masterytrace <command> [options]`, installed via the
4
+ `masterytrace` console-script defined in python/pyproject.toml. Ported
5
+ from src/cli/index.ts (which uses `commander`); this port uses the stdlib
6
+ `argparse` to avoid a CLI-framework dependency, matching the flags,
7
+ defaults, subcommands, and exit-code contract of the npm CLI's `--help`
8
+ output.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from typing import List
16
+
17
+ from .. import __version__
18
+ from .commands.init import InitOptions, run_init
19
+ from .commands.record import RecordOptions, run_record
20
+ from .commands.report import ReportOptions, run_report
21
+ from .commands.score import ScoreOptions, run_score
22
+ from .format import fail
23
+ from .types import CommandResult
24
+
25
+ _MODELS = ("bkt", "irt", "both")
26
+ _FORMATS = ("table", "json", "markdown")
27
+
28
+
29
+ def _emit(result: CommandResult) -> int:
30
+ if result.stdout:
31
+ sys.stdout.write(result.stdout)
32
+ if result.stderr:
33
+ sys.stderr.write(result.stderr)
34
+ return result.exit_code
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(
39
+ prog="masterytrace",
40
+ description=(
41
+ "Mastery Measurement API/CLI. Fits Bayesian Knowledge Tracing (BKT) and "
42
+ "2-parameter logistic Item Response Theory (IRT) models to learner "
43
+ "response logs and reports per-learner, per-skill mastery estimates."
44
+ ),
45
+ )
46
+ parser.add_argument("--version", action="version", version=f"masterytrace-cli {__version__}")
47
+ parser.add_argument(
48
+ "--json",
49
+ action="store_true",
50
+ default=False,
51
+ help="force machine-readable JSON output on stdout instead of human-formatted text",
52
+ )
53
+
54
+ subparsers = parser.add_subparsers(dest="command")
55
+
56
+ init_parser = subparsers.add_parser(
57
+ "init",
58
+ help="Scaffold a sample events.json and a default masterytrace.config.json in the current directory",
59
+ )
60
+ init_parser.add_argument(
61
+ "--force",
62
+ action="store_true",
63
+ default=False,
64
+ help="overwrite events.json/masterytrace.config.json if they already exist",
65
+ )
66
+
67
+ record_parser = subparsers.add_parser(
68
+ "record",
69
+ help="Validate and load an event log (JSON or CSV) and store it to .masterytrace/events.json",
70
+ )
71
+ record_parser.add_argument(
72
+ "path",
73
+ help="path to a JSON (array of response events) or CSV (learner_id,skill_id,correct,timestamp) event log",
74
+ )
75
+
76
+ score_parser = subparsers.add_parser(
77
+ "score",
78
+ help="Fit and score the stored event log and write results to .masterytrace/scores.json",
79
+ )
80
+ score_parser.add_argument(
81
+ "--model", default="both", help="which model(s) to run: bkt, irt, or both"
82
+ )
83
+
84
+ report_parser = subparsers.add_parser(
85
+ "report",
86
+ help="Read .masterytrace/scores.json and print a per-learner, per-skill mastery table",
87
+ )
88
+ report_parser.add_argument(
89
+ "--format", default="table", help="output format: table, json, or markdown"
90
+ )
91
+
92
+ return parser
93
+
94
+
95
+ def run_cli(argv: List[str]) -> int:
96
+ """
97
+ `argv` follows the sys.argv convention: argv[0] is the program name,
98
+ the real arguments start at argv[1]. Returns the process exit code
99
+ (0 success, 1 general/usage error, 2 validation error).
100
+
101
+ argparse's `--help`/`--version` actions call `parser.exit()` directly
102
+ (raising SystemExit) rather than returning normally -- that is caught
103
+ here and turned into a normal return, so `run_cli` always returns an
104
+ int and never raises, matching the TS CLI's `program.exitOverride()`
105
+ behavior (commander.helpDisplayed/commander.version both map to exit
106
+ code 0) and letting command handlers be tested as plain functions.
107
+
108
+ `--json` is a *global* flag that must work whether it appears before
109
+ or after the subcommand (`masterytrace --json record x` and
110
+ `masterytrace record x --json` both need to force JSON output, same
111
+ as the npm CLI's commander-based global option). Registering the same
112
+ `--json` action on both the main parser and every subparser does not
113
+ reliably compose in argparse (a subparser's own default can overwrite
114
+ a value already set by the main parser's parsing pass), so instead
115
+ `--json` is detected directly from the raw argument list and stripped
116
+ out before argparse ever sees it.
117
+ """
118
+ rest = list(argv[1:])
119
+ as_json = "--json" in rest
120
+ while "--json" in rest:
121
+ rest.remove("--json")
122
+
123
+ parser = build_parser()
124
+ try:
125
+ args = parser.parse_args(rest)
126
+ except SystemExit as exc:
127
+ code = exc.code
128
+ return code if isinstance(code, int) else 0
129
+
130
+ if args.command == "init":
131
+ return _emit(run_init(os.getcwd(), InitOptions(json=as_json, force=args.force)))
132
+
133
+ if args.command == "record":
134
+ return _emit(run_record(os.getcwd(), args.path, RecordOptions(json=as_json)))
135
+
136
+ if args.command == "score":
137
+ if args.model not in _MODELS:
138
+ return _emit(
139
+ fail(
140
+ 1,
141
+ as_json,
142
+ {"error": f"Invalid --model '{args.model}'. Expected one of: bkt, irt, both."},
143
+ f"Invalid --model '{args.model}'. Expected one of: bkt, irt, both.\n",
144
+ )
145
+ )
146
+ return _emit(run_score(os.getcwd(), ScoreOptions(json=as_json, model=args.model)))
147
+
148
+ if args.command == "report":
149
+ if args.format not in _FORMATS:
150
+ return _emit(
151
+ fail(
152
+ 1,
153
+ as_json,
154
+ {"error": f"Invalid --format '{args.format}'. Expected one of: table, json, markdown."},
155
+ f"Invalid --format '{args.format}'. Expected one of: table, json, markdown.\n",
156
+ )
157
+ )
158
+ return _emit(run_report(os.getcwd(), ReportOptions(json=as_json, format=args.format)))
159
+
160
+ parser.print_help()
161
+ return 0
162
+
163
+
164
+ def main() -> None:
165
+ try:
166
+ code = run_cli(sys.argv)
167
+ except SystemExit:
168
+ raise
169
+ except Exception as error: # noqa: BLE001 -- top-level crash guard, mirrors src/cli/index.ts's catch-all
170
+ sys.stderr.write(f"Error: {error}\n")
171
+ sys.exit(1)
172
+ else:
173
+ sys.exit(code)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()