brkraw 0.3.11__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. brkraw/__init__.py +9 -3
  2. brkraw/apps/__init__.py +12 -0
  3. brkraw/apps/addon/__init__.py +30 -0
  4. brkraw/apps/addon/core.py +35 -0
  5. brkraw/apps/addon/dependencies.py +402 -0
  6. brkraw/apps/addon/installation.py +500 -0
  7. brkraw/apps/addon/io.py +21 -0
  8. brkraw/apps/hook/__init__.py +25 -0
  9. brkraw/apps/hook/core.py +636 -0
  10. brkraw/apps/loader/__init__.py +10 -0
  11. brkraw/apps/loader/core.py +622 -0
  12. brkraw/apps/loader/formatter.py +288 -0
  13. brkraw/apps/loader/helper.py +797 -0
  14. brkraw/apps/loader/info/__init__.py +11 -0
  15. brkraw/apps/loader/info/scan.py +85 -0
  16. brkraw/apps/loader/info/scan.yaml +90 -0
  17. brkraw/apps/loader/info/study.py +69 -0
  18. brkraw/apps/loader/info/study.yaml +156 -0
  19. brkraw/apps/loader/info/transform.py +92 -0
  20. brkraw/apps/loader/types.py +220 -0
  21. brkraw/cli/__init__.py +5 -0
  22. brkraw/cli/commands/__init__.py +2 -0
  23. brkraw/cli/commands/addon.py +327 -0
  24. brkraw/cli/commands/config.py +205 -0
  25. brkraw/cli/commands/convert.py +903 -0
  26. brkraw/cli/commands/hook.py +348 -0
  27. brkraw/cli/commands/info.py +74 -0
  28. brkraw/cli/commands/init.py +214 -0
  29. brkraw/cli/commands/params.py +106 -0
  30. brkraw/cli/commands/prune.py +288 -0
  31. brkraw/cli/commands/session.py +371 -0
  32. brkraw/cli/hook_args.py +80 -0
  33. brkraw/cli/main.py +83 -0
  34. brkraw/cli/utils.py +60 -0
  35. brkraw/core/__init__.py +13 -0
  36. brkraw/core/config.py +380 -0
  37. brkraw/core/entrypoints.py +25 -0
  38. brkraw/core/formatter.py +367 -0
  39. brkraw/core/fs.py +495 -0
  40. brkraw/core/jcamp.py +600 -0
  41. brkraw/core/layout.py +451 -0
  42. brkraw/core/parameters.py +781 -0
  43. brkraw/core/zip.py +1121 -0
  44. brkraw/dataclasses/__init__.py +14 -0
  45. brkraw/dataclasses/node.py +139 -0
  46. brkraw/dataclasses/reco.py +33 -0
  47. brkraw/dataclasses/scan.py +61 -0
  48. brkraw/dataclasses/study.py +131 -0
  49. brkraw/default/__init__.py +3 -0
  50. brkraw/default/pruner_specs/deid4share.yaml +42 -0
  51. brkraw/default/rules/00_default.yaml +4 -0
  52. brkraw/default/specs/metadata_dicom.yaml +236 -0
  53. brkraw/default/specs/metadata_transforms.py +92 -0
  54. brkraw/resolver/__init__.py +7 -0
  55. brkraw/resolver/affine.py +539 -0
  56. brkraw/resolver/datatype.py +69 -0
  57. brkraw/resolver/fid.py +90 -0
  58. brkraw/resolver/helpers.py +36 -0
  59. brkraw/resolver/image.py +188 -0
  60. brkraw/resolver/nifti.py +370 -0
  61. brkraw/resolver/shape.py +235 -0
  62. brkraw/schema/__init__.py +3 -0
  63. brkraw/schema/context_map.yaml +62 -0
  64. brkraw/schema/meta.yaml +57 -0
  65. brkraw/schema/niftiheader.yaml +95 -0
  66. brkraw/schema/pruner.yaml +55 -0
  67. brkraw/schema/remapper.yaml +128 -0
  68. brkraw/schema/rules.yaml +154 -0
  69. brkraw/specs/__init__.py +10 -0
  70. brkraw/specs/hook/__init__.py +12 -0
  71. brkraw/specs/hook/logic.py +31 -0
  72. brkraw/specs/hook/validator.py +22 -0
  73. brkraw/specs/meta/__init__.py +5 -0
  74. brkraw/specs/meta/validator.py +156 -0
  75. brkraw/specs/pruner/__init__.py +15 -0
  76. brkraw/specs/pruner/logic.py +361 -0
  77. brkraw/specs/pruner/validator.py +119 -0
  78. brkraw/specs/remapper/__init__.py +27 -0
  79. brkraw/specs/remapper/logic.py +924 -0
  80. brkraw/specs/remapper/validator.py +314 -0
  81. brkraw/specs/rules/__init__.py +6 -0
  82. brkraw/specs/rules/logic.py +263 -0
  83. brkraw/specs/rules/validator.py +103 -0
  84. brkraw-0.5.0.dist-info/METADATA +81 -0
  85. brkraw-0.5.0.dist-info/RECORD +88 -0
  86. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
  87. brkraw-0.5.0.dist-info/entry_points.txt +13 -0
  88. brkraw/lib/__init__.py +0 -4
  89. brkraw/lib/backup.py +0 -641
  90. brkraw/lib/bids.py +0 -0
  91. brkraw/lib/errors.py +0 -125
  92. brkraw/lib/loader.py +0 -1220
  93. brkraw/lib/orient.py +0 -194
  94. brkraw/lib/parser.py +0 -48
  95. brkraw/lib/pvobj.py +0 -301
  96. brkraw/lib/reference.py +0 -245
  97. brkraw/lib/utils.py +0 -471
  98. brkraw/scripts/__init__.py +0 -0
  99. brkraw/scripts/brk_backup.py +0 -106
  100. brkraw/scripts/brkraw.py +0 -744
  101. brkraw/ui/__init__.py +0 -0
  102. brkraw/ui/config.py +0 -17
  103. brkraw/ui/main_win.py +0 -214
  104. brkraw/ui/previewer.py +0 -225
  105. brkraw/ui/scan_info.py +0 -72
  106. brkraw/ui/scan_list.py +0 -73
  107. brkraw/ui/subj_info.py +0 -128
  108. brkraw-0.3.11.dist-info/METADATA +0 -25
  109. brkraw-0.3.11.dist-info/RECORD +0 -28
  110. brkraw-0.3.11.dist-info/entry_points.txt +0 -3
  111. brkraw-0.3.11.dist-info/top_level.txt +0 -2
  112. tests/__init__.py +0 -0
  113. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,348 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import importlib
5
+ from pathlib import Path
6
+ import inspect
7
+ from typing import Any, Dict, Mapping
8
+
9
+ import argparse
10
+ import logging
11
+
12
+ from brkraw.apps import hook as hook_app
13
+ from brkraw.core import config as config_core
14
+ from brkraw.core import formatter
15
+ from brkraw.specs import hook as converter_core
16
+ import yaml
17
+
18
+ logger = logging.getLogger("brkraw")
19
+
20
+
21
+ def cmd_hook(args: argparse.Namespace) -> int:
22
+ handler = getattr(args, "hook_func", None)
23
+ if handler is None:
24
+ args.parser.print_help()
25
+ return 2
26
+ return handler(args)
27
+
28
+
29
+ def _normalize_row(row: Dict[str, str]) -> Dict[str, object]:
30
+ name = row.get("name", "")
31
+ version = row.get("version", "")
32
+ entrypoints = row.get("entrypoints", "")
33
+ description = row.get("description", "")
34
+ install_status = row.get("install_status", "")
35
+ name_cell: object = name
36
+ version_cell: object = version
37
+ entrypoints_cell: object = entrypoints
38
+ description_cell: object = description
39
+ install_cell: object = install_status
40
+ if row.get("name_unknown") == "1":
41
+ name_cell = {"value": name, "color": "gray"}
42
+ if row.get("version_unknown") == "1":
43
+ version_cell = {"value": version, "color": "gray"}
44
+ if row.get("entrypoints_unknown") == "1":
45
+ entrypoints_cell = {"value": entrypoints, "color": "gray"}
46
+ if row.get("description_unknown") == "1":
47
+ description_cell = {"value": description, "color": "gray"}
48
+ if row.get("install_status_color"):
49
+ install_cell = {"value": install_status, "color": row["install_status_color"]}
50
+ return {
51
+ "name": name_cell,
52
+ "version": version_cell,
53
+ "entrypoints": entrypoints_cell,
54
+ "description": description_cell,
55
+ "installed": install_cell,
56
+ }
57
+
58
+
59
+ def cmd_list(args: argparse.Namespace) -> int:
60
+ hooks = hook_app.list_hooks(root=args.root)
61
+ width = config_core.output_width(root=args.root)
62
+ rows = []
63
+ for hook in hooks:
64
+ install_status = hook.get("install_status", "No")
65
+ if install_status == "Yes":
66
+ status_color = "green"
67
+ elif install_status == "Partially":
68
+ status_color = "yellow"
69
+ else:
70
+ status_color = "red"
71
+ rows.append(
72
+ _normalize_row(
73
+ {
74
+ "name": hook.get("name", "<Unknown>"),
75
+ "version": hook.get("version", "<Unknown>"),
76
+ "entrypoints": ", ".join(hook.get("entrypoints") or []) or "<Unknown>",
77
+ "description": hook.get("description", "<Unknown>"),
78
+ "install_status": install_status,
79
+ "install_status_color": status_color,
80
+ "name_unknown": "1" if hook.get("name") in (None, "<Unknown>") else "0",
81
+ "version_unknown": "1" if hook.get("version") in (None, "<Unknown>") else "0",
82
+ "entrypoints_unknown": "1"
83
+ if not hook.get("entrypoints")
84
+ else "0",
85
+ "description_unknown": "1"
86
+ if hook.get("description") in (None, "<Unknown>")
87
+ else "0",
88
+ }
89
+ )
90
+ )
91
+ columns = ("name", "version", "installed", "entrypoints", "description")
92
+ table = formatter.format_table(
93
+ "Hooks",
94
+ columns,
95
+ rows,
96
+ width=width,
97
+ colors={"name": "cyan", "description": "gray", "entrypoints": "yellow"},
98
+ title_color="cyan",
99
+ col_widths=formatter.compute_column_widths(columns, rows),
100
+ min_last_col_width=40,
101
+ )
102
+ logger.info("%s", table)
103
+ return 0
104
+
105
+
106
+ def cmd_install(args: argparse.Namespace) -> int:
107
+ if args.target == "all":
108
+ result = hook_app.install_all(root=args.root, upgrade=args.upgrade, force=args.force)
109
+ logger.info("Installed %d hook(s).", len(result["installed"]))
110
+ if result["skipped"]:
111
+ logger.info("Skipped %d hook(s).", len(result["skipped"]))
112
+ return 0
113
+ status = hook_app.install_hook(
114
+ args.target,
115
+ root=args.root,
116
+ upgrade=args.upgrade,
117
+ force=args.force,
118
+ )
119
+ if status == "installed":
120
+ logger.info("Installed hook: %s", args.target)
121
+ else:
122
+ logger.info("Hook already installed: %s", args.target)
123
+ return 0
124
+
125
+
126
+ def cmd_uninstall(args: argparse.Namespace) -> int:
127
+ try:
128
+ hook_name, removed, module_missing = hook_app.uninstall_hook(
129
+ args.target,
130
+ root=args.root,
131
+ force=args.force,
132
+ )
133
+ except LookupError as exc:
134
+ logger.error("%s", exc)
135
+ return 2
136
+ except RuntimeError as exc:
137
+ logger.error("%s", exc)
138
+ return 2
139
+ removed_count = sum(len(items) for items in removed.values())
140
+ logger.info("Removed %d file(s).", removed_count)
141
+ if module_missing:
142
+ logger.info(
143
+ "Hook module is not installed; removed registry entries only."
144
+ )
145
+ logger.info("To uninstall the package, run: pip uninstall %s", hook_name)
146
+ return 0
147
+
148
+
149
+ def cmd_docs(args: argparse.Namespace) -> int:
150
+ try:
151
+ hook_name, text = hook_app.read_hook_docs(args.target, root=args.root)
152
+ except (LookupError, FileNotFoundError) as exc:
153
+ logger.error("%s", exc)
154
+ return 2
155
+ logger.info("[Hook Docs] %s", hook_name)
156
+ if args.render:
157
+ try:
158
+ import importlib
159
+
160
+ console_mod = importlib.import_module("rich.console")
161
+ markdown_mod = importlib.import_module("rich.markdown")
162
+ Console = getattr(console_mod, "Console")
163
+ Markdown = getattr(markdown_mod, "Markdown")
164
+ except Exception:
165
+ logger.warning("rich is not available; printing raw text.")
166
+ print(text)
167
+ return 0
168
+ console = Console()
169
+ console.print(Markdown(text))
170
+ return 0
171
+ print(text)
172
+ return 0
173
+
174
+
175
+ _PRESET_IGNORE_PARAMS = frozenset(
176
+ {
177
+ "self",
178
+ "scan",
179
+ "scan_id",
180
+ "reco_id",
181
+ "format",
182
+ "space",
183
+ "override_header",
184
+ "override_subject_type",
185
+ "override_subject_pose",
186
+ "flip_x",
187
+ "xyz_units",
188
+ "t_units",
189
+ "decimals",
190
+ "spec",
191
+ "context_map",
192
+ "return_spec",
193
+ "hook_args_by_name",
194
+ }
195
+ )
196
+
197
+
198
+ def _infer_hook_preset(entry: Mapping[str, Any]) -> Dict[str, Any]:
199
+ preset: Dict[str, Any] = {}
200
+ modules: list[object] = []
201
+
202
+ for func in entry.values():
203
+ if callable(func):
204
+ mod_name = getattr(func, "__module__", None)
205
+ if isinstance(mod_name, str) and mod_name:
206
+ try:
207
+ modules.append(importlib.import_module(mod_name))
208
+ except Exception:
209
+ pass
210
+
211
+ for module in modules:
212
+ module_preset = _infer_hook_preset_from_module(module)
213
+ if module_preset:
214
+ return dict(sorted(module_preset.items(), key=lambda item: item[0]))
215
+
216
+ for func in entry.values():
217
+ if not callable(func):
218
+ continue
219
+ try:
220
+ sig = inspect.signature(func)
221
+ except (TypeError, ValueError):
222
+ continue
223
+ for param in sig.parameters.values():
224
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
225
+ continue
226
+ name = param.name
227
+ if name in _PRESET_IGNORE_PARAMS:
228
+ continue
229
+ if name in preset:
230
+ continue
231
+ if param.default is inspect.Parameter.empty:
232
+ preset[name] = None
233
+ else:
234
+ preset[name] = param.default
235
+ return dict(sorted(preset.items(), key=lambda item: item[0]))
236
+
237
+
238
+ def _infer_hook_preset_from_module(module: object) -> Dict[str, Any]:
239
+ for attr in ("HOOK_PRESET", "HOOK_ARGS", "HOOK_DEFAULTS"):
240
+ value = getattr(module, attr, None)
241
+ if isinstance(value, Mapping):
242
+ return dict(value)
243
+
244
+ build_options = getattr(module, "_build_options", None)
245
+ if callable(build_options):
246
+ try:
247
+ options = build_options({})
248
+ except Exception:
249
+ return {}
250
+ if dataclasses.is_dataclass(options):
251
+ if not isinstance(options, type):
252
+ return dict(dataclasses.asdict(options))
253
+ defaults: Dict[str, Any] = {}
254
+ for field in dataclasses.fields(options):
255
+ if field.default is not dataclasses.MISSING:
256
+ defaults[field.name] = field.default
257
+ continue
258
+ if field.default_factory is not dataclasses.MISSING: # type: ignore[comparison-overlap]
259
+ try:
260
+ defaults[field.name] = field.default_factory() # type: ignore[misc]
261
+ except Exception:
262
+ defaults[field.name] = None
263
+ continue
264
+ defaults[field.name] = None
265
+ return defaults
266
+ if hasattr(options, "__dict__"):
267
+ return dict(vars(options))
268
+ return {}
269
+
270
+
271
+ def cmd_preset(args: argparse.Namespace) -> int:
272
+ try:
273
+ entry = converter_core.resolve_hook(args.target)
274
+ except LookupError as exc:
275
+ logger.error("%s", exc)
276
+ return 2
277
+ preset = _infer_hook_preset(entry)
278
+ payload = {"hooks": {args.target: preset}}
279
+ text = yaml.safe_dump(payload, sort_keys=False)
280
+ if args.output:
281
+ Path(args.output).expanduser().write_text(text, encoding="utf-8")
282
+ logger.info("Wrote preset: %s", args.output)
283
+ return 0
284
+ print(text, end="" if text.endswith("\n") else "\n")
285
+ return 0
286
+
287
+
288
+ def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[name-defined]
289
+ hook_parser = subparsers.add_parser(
290
+ "hook",
291
+ help="Manage converter hook packages.",
292
+ )
293
+ hook_parser.add_argument(
294
+ "--root",
295
+ help="Override config root directory (default: BRKRAW_CONFIG_HOME or ~/.brkraw).",
296
+ )
297
+ hook_parser.set_defaults(func=cmd_hook, parser=hook_parser)
298
+ hook_sub = hook_parser.add_subparsers(dest="hook_command")
299
+
300
+ list_parser = hook_sub.add_parser("list", help="List installed hook packages.")
301
+ list_parser.set_defaults(hook_func=cmd_list)
302
+
303
+ install_parser = hook_sub.add_parser("install", help="Install hook addons.")
304
+ install_parser.add_argument("target", help="Hook name or entrypoint name, or 'all'.")
305
+ install_parser.add_argument(
306
+ "--upgrade",
307
+ action="store_true",
308
+ help="Reinstall when a newer version is available.",
309
+ )
310
+ install_parser.add_argument(
311
+ "--force",
312
+ action="store_true",
313
+ help="Reinstall even if the same or older version is installed.",
314
+ )
315
+ install_parser.set_defaults(hook_func=cmd_install)
316
+
317
+ uninstall_parser = hook_sub.add_parser("uninstall", help="Remove hook addons.")
318
+ uninstall_parser.add_argument("target", help="Hook name or entrypoint name.")
319
+ uninstall_parser.add_argument(
320
+ "--force",
321
+ action="store_true",
322
+ help="Remove even if dependencies are detected.",
323
+ )
324
+ uninstall_parser.set_defaults(hook_func=cmd_uninstall)
325
+
326
+ docs_parser = hook_sub.add_parser("docs", help="Show hook documentation.")
327
+ docs_parser.add_argument("target", help="Hook name or entrypoint name.")
328
+ docs_parser.add_argument(
329
+ "--render",
330
+ action="store_true",
331
+ help="Render markdown using rich (if installed).",
332
+ )
333
+ docs_parser.set_defaults(hook_func=cmd_docs)
334
+
335
+ preset_parser = hook_sub.add_parser(
336
+ "preset",
337
+ help="Generate a YAML hook-args preset template.",
338
+ )
339
+ preset_parser.add_argument(
340
+ "target",
341
+ help="Hook entrypoint name.",
342
+ )
343
+ preset_parser.add_argument(
344
+ "-o",
345
+ "--output",
346
+ help="Write the preset YAML to a file instead of stdout.",
347
+ )
348
+ preset_parser.set_defaults(hook_func=cmd_preset)
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+ from brkraw.core import config as config_core
8
+ from brkraw.cli.utils import load
9
+
10
+ logger = logging.getLogger("brkraw")
11
+
12
+
13
+ def cmd_info(args: argparse.Namespace) -> int:
14
+ if args.path is None:
15
+ args.path = os.environ.get("BRKRAW_PATH")
16
+ if args.path is None:
17
+ args.parser.print_help()
18
+ return 2
19
+ if args.scan_id is None:
20
+ env_scan = os.environ.get("BRKRAW_SCAN_ID")
21
+ if env_scan:
22
+ parts = [p.strip() for p in env_scan.split(",") if p.strip()]
23
+ try:
24
+ args.scan_id = [int(p) for p in parts]
25
+ except ValueError:
26
+ logger.error("Invalid BRKRAW_SCAN_ID: %s", env_scan)
27
+ return 2
28
+ if not Path(args.path).exists():
29
+ logger.error("Path not found: %s", args.path)
30
+ return 2
31
+ loader = load(args.path, prefix="Loading")
32
+ width = config_core.output_width(root=args.root)
33
+ text = loader.info(
34
+ scope=args.scope,
35
+ scan_id=args.scan_id,
36
+ as_dict=False,
37
+ scan_transpose=True,
38
+ show_reco=args.show_reco,
39
+ width=width,
40
+ )
41
+ if text is not None:
42
+ logger.info("%s", text)
43
+ return 0
44
+
45
+
46
+ def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[name-defined]
47
+ info_parser = subparsers.add_parser(
48
+ "info",
49
+ help="Show scan/study info from a dataset path.",
50
+ )
51
+ info_parser.add_argument("path", nargs="?", help="Path to the Bruker study.")
52
+ info_parser.add_argument(
53
+ "--scope",
54
+ choices=["full", "study", "scan"],
55
+ default="full",
56
+ help="Select info scope (default: full).",
57
+ )
58
+ info_parser.add_argument(
59
+ "-s",
60
+ "--scan-id",
61
+ nargs="*",
62
+ type=int,
63
+ help="Scan id(s) to include when scope is scan/full.",
64
+ )
65
+ info_parser.add_argument(
66
+ "--show-reco",
67
+ action="store_true",
68
+ help="Include reco entries in output.",
69
+ )
70
+ info_parser.add_argument(
71
+ "--root",
72
+ help="Override config root directory (default: BRKRAW_CONFIG_HOME or ~/.brkraw).",
73
+ )
74
+ info_parser.set_defaults(func=cmd_info, parser=info_parser)
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+ from typing import Optional, Dict, Any
3
+ from pprint import pprint
4
+
5
+ import argparse
6
+ import logging
7
+ import os
8
+ from datetime import date
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from brkraw.core import config as config_core
14
+ from brkraw.apps import addon as addon_app
15
+
16
+ logger = logging.getLogger("brkraw")
17
+
18
+
19
+ def cmd_init(args: argparse.Namespace) -> int:
20
+ if args.config:
21
+ config_core.init(
22
+ root=args.root,
23
+ create_config=False,
24
+ exist_ok=not args.no_exist_ok,
25
+ )
26
+ paths = config_core.paths(root=args.root)
27
+ existing = config_core.load(root=args.root)
28
+ if paths.config_file.exists():
29
+ pprint(existing or {})
30
+ replace = _prompt_bool(
31
+ "Replace existing config.yaml?",
32
+ default=False,
33
+ )
34
+ if not replace:
35
+ return 0
36
+ defaults = existing
37
+ else:
38
+ defaults = config_core.default_config()
39
+ config_values = _prompt_config_values(defaults=defaults)
40
+ config_core.write_config(config_values, root=args.root)
41
+ logger.info("Wrote config at %s", config_core.paths(root=args.root).config_file)
42
+ return 0
43
+
44
+ interactive = not args.yes
45
+ create_config = True
46
+ install_defaults = args.install_default
47
+ shellrc = Path(args.shellrc) if args.shellrc else _default_shell_rc()
48
+ explicit_actions = args.install_default or args.shellrc
49
+ config_values: Optional[Dict[str, Any]] = None
50
+
51
+ if interactive and explicit_actions:
52
+ interactive = False
53
+ create_config = False
54
+ install_defaults = args.install_default
55
+ shellrc = Path(args.shellrc) if args.shellrc else None
56
+
57
+ if interactive:
58
+ create_config = _prompt_bool("Create config.yaml?", default=create_config)
59
+ if create_config:
60
+ config_values = _prompt_config_values()
61
+ install_defaults = _prompt_bool(
62
+ "Install default specs/rules?", default=install_defaults
63
+ )
64
+ install_helpers = _prompt_bool(
65
+ "Install shell helpers?", default=shellrc is not None
66
+ )
67
+ if install_helpers:
68
+ if shellrc is None:
69
+ shellrc = _prompt_path("Shell rc path", default=None)
70
+ if shellrc is not None:
71
+ _install_shell_helpers(shellrc)
72
+ else:
73
+ shellrc = None
74
+
75
+ config_core.init(
76
+ root=args.root,
77
+ create_config=False,
78
+ exist_ok=not args.no_exist_ok,
79
+ )
80
+ logger.info("Initialized config at %s", config_core.paths(root=args.root).root)
81
+ if create_config:
82
+ if config_values is None:
83
+ config_values = config_core.default_config()
84
+ config_core.write_config(config_values, root=args.root)
85
+ if install_defaults:
86
+ installed = addon_app.install_defaults(root=args.root)
87
+ if installed:
88
+ logger.info("Installed %d default file(s).", len(installed))
89
+ if not interactive and shellrc is not None:
90
+ _install_shell_helpers(shellrc)
91
+ return 0
92
+
93
+
94
+ def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[name-defined]
95
+ init_parser = subparsers.add_parser(
96
+ "init",
97
+ help="Initialize config and install defaults.",
98
+ )
99
+ init_parser.add_argument(
100
+ "--root",
101
+ help="Override config root directory (default: BRKRAW_CONFIG_HOME or ~/.brkraw).",
102
+ )
103
+ init_parser.add_argument(
104
+ "--no-exist-ok",
105
+ action="store_true",
106
+ help="Fail if the root directory already exists.",
107
+ )
108
+ init_parser.add_argument(
109
+ "--config",
110
+ action="store_true",
111
+ help="Create or replace config.yaml only.",
112
+ )
113
+ init_parser.add_argument(
114
+ "--yes",
115
+ action="store_true",
116
+ help="Skip prompts and use defaults.",
117
+ )
118
+ init_parser.add_argument(
119
+ "--install-default",
120
+ action="store_true",
121
+ help="Install default specs and rules.",
122
+ )
123
+ init_parser.add_argument(
124
+ "--shell-rc",
125
+ dest="shellrc",
126
+ help="Append shell helpers to the specified rc file (defaults to ~/.zshrc or ~/.bashrc).",
127
+ )
128
+ init_parser.set_defaults(func=cmd_init)
129
+
130
+
131
+ def _prompt_bool(label: str, *, default: bool) -> bool:
132
+ prompt = "Y/n" if default else "y/N"
133
+ while True:
134
+ reply = input(f"{label} [{prompt}]: ").strip().lower()
135
+ if not reply:
136
+ return default
137
+ if reply in {"y", "yes"}:
138
+ return True
139
+ if reply in {"n", "no"}:
140
+ return False
141
+
142
+
143
+ def _prompt_config_values(*, defaults: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
144
+ base = config_core.default_config()
145
+ if defaults:
146
+ base.update(defaults)
147
+ result: Dict[str, Any] = {}
148
+ keys = list(base.keys())
149
+ for key in keys:
150
+ if key == "config_version":
151
+ result[key] = base.get(key)
152
+ continue
153
+ default = base.get(key)
154
+ display = "null" if default is None else str(default)
155
+ reply = input(f"{key} [{display}]: ").strip()
156
+ if reply == "":
157
+ result[key] = default
158
+ else:
159
+ result[key] = yaml.safe_load(reply)
160
+ return result
161
+
162
+
163
+ def _prompt_path(label: str, *, default: Optional[Path]) -> Optional[Path]:
164
+ display = str(default) if default else ""
165
+ reply = input(f"{label} [{display}]: ").strip()
166
+ if not reply:
167
+ return default
168
+ return Path(reply).expanduser()
169
+
170
+
171
+ def _install_shell_helpers(path: Path) -> None:
172
+ marker = "# brkraw shell helpers"
173
+ snippet = "\n".join(
174
+ [
175
+ f"{marker} (added {date.today().isoformat()})",
176
+ "brkraw-set() {",
177
+ " if [ \"$#\" -eq 0 ]; then",
178
+ " brkraw session set",
179
+ " else",
180
+ " eval \"$(brkraw session set \"$@\")\"",
181
+ " fi",
182
+ "}",
183
+ "brkraw-unset() {",
184
+ " if [ \"$#\" -eq 0 ]; then",
185
+ " eval \"$(brkraw session unset)\"",
186
+ " elif [ \"$1\" = \"-h\" ] || [ \"$1\" = \"--help\" ]; then",
187
+ " brkraw session unset \"$@\"",
188
+ " else",
189
+ " eval \"$(brkraw session unset \"$@\")\"",
190
+ " fi",
191
+ "}",
192
+ "",
193
+ ]
194
+ )
195
+ if path.exists():
196
+ content = path.read_text(encoding="utf-8")
197
+ if marker in content:
198
+ logger.info("Shell helpers already present in %s", path)
199
+ return
200
+ else:
201
+ path.parent.mkdir(parents=True, exist_ok=True)
202
+ content = ""
203
+ path.write_text(content + ("\n" if content and not content.endswith("\n") else "") + snippet, encoding="utf-8")
204
+ logger.info("Appended shell helpers to %s", path)
205
+
206
+
207
+ def _default_shell_rc() -> Optional[Path]:
208
+ shell = os.environ.get("SHELL", "")
209
+ home = Path.home()
210
+ if shell.endswith("zsh"):
211
+ return home / ".zshrc"
212
+ if shell.endswith("bash"):
213
+ return home / ".bashrc"
214
+ return None