base-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,263 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+
7
+
8
+ __all__ = [
9
+ "CommandProtocolError",
10
+ "dumps_record",
11
+ "dumps_records",
12
+ "loads_records",
13
+ ]
14
+
15
+
16
+ PROTOCOL_HEADER = "BASE_COMMAND_PROTOCOL_V1"
17
+ MAX_RECORD_COUNT = 1_000_000
18
+
19
+
20
+ class CommandProtocolError(ValueError):
21
+ pass
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class FieldSpec:
26
+ value_type: str
27
+ nullable: bool = False
28
+
29
+
30
+ STRING = FieldSpec("string")
31
+ NULLABLE_STRING = FieldSpec("string", nullable=True)
32
+ BOOLEAN = FieldSpec("boolean")
33
+
34
+ PROJECT_REFERENCE_FIELDS = {
35
+ "project_name": STRING,
36
+ "project_root": STRING,
37
+ "manifest_path": STRING,
38
+ }
39
+ PROJECT_ROUTE_FIELDS = {
40
+ **PROJECT_REFERENCE_FIELDS,
41
+ "project_venv_dir": STRING,
42
+ "uses_uv_manager": BOOLEAN,
43
+ "manifest_command_trust_required": BOOLEAN,
44
+ }
45
+ PROJECT_SETUP_ROUTE_FIELDS = {
46
+ **PROJECT_ROUTE_FIELDS,
47
+ "requires_project_python": BOOLEAN,
48
+ }
49
+
50
+ RECORD_SCHEMAS: dict[str, dict[str, FieldSpec]] = {
51
+ "project-list-entry": {
52
+ "project_name": STRING,
53
+ "project_root": STRING,
54
+ },
55
+ "project-reference": PROJECT_REFERENCE_FIELDS,
56
+ "project-route": PROJECT_ROUTE_FIELDS,
57
+ "project-setup-route": PROJECT_SETUP_ROUTE_FIELDS,
58
+ "project-command": {
59
+ **PROJECT_ROUTE_FIELDS,
60
+ "command": STRING,
61
+ "runner": NULLABLE_STRING,
62
+ },
63
+ "named-command": {
64
+ **PROJECT_REFERENCE_FIELDS,
65
+ "command_name": STRING,
66
+ "command": STRING,
67
+ "runner": NULLABLE_STRING,
68
+ },
69
+ "build-target": {
70
+ **PROJECT_ROUTE_FIELDS,
71
+ "target_name": STRING,
72
+ "working_dir": STRING,
73
+ "command": STRING,
74
+ "description": NULLABLE_STRING,
75
+ "runner": NULLABLE_STRING,
76
+ },
77
+ "demo": {
78
+ **PROJECT_ROUTE_FIELDS,
79
+ "demo_script": STRING,
80
+ "runner": NULLABLE_STRING,
81
+ },
82
+ "activation-source": {
83
+ "source_path": STRING,
84
+ },
85
+ }
86
+
87
+ RecordValue = str | bool | None
88
+ Record = Mapping[str, RecordValue]
89
+
90
+
91
+ def dumps_record(record_type: str, record: Record) -> str:
92
+ return dumps_records(record_type, (record,))
93
+
94
+
95
+ def dumps_records(record_type: str, records: tuple[Record, ...] | list[Record]) -> str:
96
+ schema = _schema(record_type)
97
+ if len(records) > MAX_RECORD_COUNT:
98
+ raise CommandProtocolError(f"record_count exceeds protocol maximum of {MAX_RECORD_COUNT}")
99
+ lines = [
100
+ PROTOCOL_HEADER,
101
+ f"record_type={record_type}",
102
+ f"record_count={len(records)}",
103
+ ]
104
+ for index, record in enumerate(records):
105
+ _validate_record(schema, record_type, record)
106
+ lines.append(f"record={index}")
107
+ for field_name, spec in schema.items():
108
+ wire_type, payload = _encode_value(field_name, spec, record[field_name])
109
+ lines.append(f"field.{field_name}:{wire_type}={payload}")
110
+ lines.append(f"end_record={index}")
111
+ lines.append("end_protocol=")
112
+ return "\n".join(lines)
113
+
114
+
115
+ def loads_records(
116
+ payload: str,
117
+ expected_record_type: str | None = None,
118
+ ) -> tuple[str, tuple[dict[str, RecordValue], ...]]:
119
+ # The wire framing is LF-delimited. `str.splitlines()` also accepts CR,
120
+ # vertical tab, form feed, and Unicode separators, which would make the
121
+ # Python decoder more permissive than the Bash and Zsh readers.
122
+ # A CLI `print()` adds one terminal LF; accept that conventional text-file
123
+ # terminator without accepting an extra blank line or other separators.
124
+ framed_payload = payload.removesuffix("\n")
125
+ lines = framed_payload.split("\n")
126
+ cursor = 0
127
+
128
+ def take(label: str) -> str:
129
+ nonlocal cursor
130
+ if cursor >= len(lines):
131
+ raise CommandProtocolError(f"missing {label}")
132
+ line = lines[cursor]
133
+ cursor += 1
134
+ return line
135
+
136
+ if take("protocol header") != PROTOCOL_HEADER:
137
+ raise CommandProtocolError(f"unsupported protocol header; expected {PROTOCOL_HEADER}")
138
+
139
+ record_type = _metadata_value(take("record_type"), "record_type")
140
+ schema = _schema(record_type)
141
+ if expected_record_type is not None and record_type != expected_record_type:
142
+ raise CommandProtocolError(f"expected record_type '{expected_record_type}', got '{record_type}'")
143
+
144
+ record_count_text = _metadata_value(take("record_count"), "record_count")
145
+ record_count = _parse_record_count(record_count_text)
146
+
147
+ records: list[dict[str, RecordValue]] = []
148
+ for index in range(record_count):
149
+ if take(f"record {index}") != f"record={index}":
150
+ raise CommandProtocolError(f"expected record={index}")
151
+
152
+ record: dict[str, RecordValue] = {}
153
+ for _field_index in range(len(schema)):
154
+ field_name, wire_type, encoded_value = _parse_field_line(take(f"field in record {index}"))
155
+ if field_name in record:
156
+ raise CommandProtocolError(f"record {index} duplicates field '{field_name}'")
157
+ try:
158
+ spec = schema[field_name]
159
+ except KeyError as exc:
160
+ raise CommandProtocolError(
161
+ f"record {index} has unknown field '{field_name}' for '{record_type}'"
162
+ ) from exc
163
+ record[field_name] = _decode_value(field_name, spec, wire_type, encoded_value)
164
+
165
+ missing = sorted(set(schema) - set(record))
166
+ if missing:
167
+ raise CommandProtocolError(f"record {index} is missing fields: {', '.join(missing)}")
168
+ if take(f"end_record {index}") != f"end_record={index}":
169
+ raise CommandProtocolError(f"expected end_record={index}")
170
+ records.append(record)
171
+
172
+ if take("end_protocol") != "end_protocol=":
173
+ raise CommandProtocolError("expected end_protocol marker")
174
+ if cursor != len(lines):
175
+ raise CommandProtocolError("unexpected data after end_protocol marker")
176
+ return record_type, tuple(records)
177
+
178
+
179
+ def _schema(record_type: str) -> dict[str, FieldSpec]:
180
+ try:
181
+ return RECORD_SCHEMAS[record_type]
182
+ except KeyError as exc:
183
+ supported = ", ".join(sorted(RECORD_SCHEMAS))
184
+ raise CommandProtocolError(f"unsupported record_type '{record_type}'; expected one of: {supported}") from exc
185
+
186
+
187
+ def _validate_record(schema: Mapping[str, FieldSpec], record_type: str, record: Record) -> None:
188
+ missing = sorted(set(schema) - set(record))
189
+ unknown = sorted(set(record) - set(schema))
190
+ if missing:
191
+ raise CommandProtocolError(f"record for '{record_type}' is missing fields: {', '.join(missing)}")
192
+ if unknown:
193
+ raise CommandProtocolError(f"record for '{record_type}' has unknown fields: {', '.join(unknown)}")
194
+
195
+
196
+ def _encode_value(field_name: str, spec: FieldSpec, value: RecordValue) -> tuple[str, str]:
197
+ if value is None:
198
+ if not spec.nullable:
199
+ raise CommandProtocolError(f"field '{field_name}' cannot be null")
200
+ return "null", ""
201
+ if spec.value_type == "boolean":
202
+ if not isinstance(value, bool):
203
+ raise CommandProtocolError(f"field '{field_name}' must be a boolean")
204
+ return "boolean", "true" if value else "false"
205
+ if not isinstance(value, str):
206
+ raise CommandProtocolError(f"field '{field_name}' must be a string")
207
+ if "\0" in value:
208
+ raise CommandProtocolError(f"field '{field_name}' cannot contain NUL")
209
+ return "string", value.encode("utf-8").hex()
210
+
211
+
212
+ def _decode_value(field_name: str, spec: FieldSpec, wire_type: str, payload: str) -> RecordValue:
213
+ if wire_type == "null":
214
+ if not spec.nullable:
215
+ raise CommandProtocolError(f"field '{field_name}' cannot be null")
216
+ if payload:
217
+ raise CommandProtocolError(f"null field '{field_name}' must have an empty payload")
218
+ return None
219
+ if spec.value_type == "boolean":
220
+ if wire_type != "boolean" or payload not in ("true", "false"):
221
+ raise CommandProtocolError(f"field '{field_name}' must use boolean:true or boolean:false")
222
+ return payload == "true"
223
+ if wire_type != "string":
224
+ expected = "string or null" if spec.nullable else "string"
225
+ raise CommandProtocolError(f"field '{field_name}' must use {expected} encoding")
226
+ if len(payload) % 2 != 0 or re.fullmatch(r"[0-9a-f]*", payload) is None:
227
+ raise CommandProtocolError(f"field '{field_name}' has invalid lowercase hexadecimal data")
228
+ try:
229
+ value = bytes.fromhex(payload).decode("utf-8")
230
+ except UnicodeDecodeError as exc:
231
+ raise CommandProtocolError(f"field '{field_name}' has invalid UTF-8 data") from exc
232
+ if "\0" in value:
233
+ raise CommandProtocolError(f"field '{field_name}' cannot contain NUL")
234
+ return value
235
+
236
+
237
+ def _metadata_value(line: str, name: str) -> str:
238
+ prefix = f"{name}="
239
+ if not line.startswith(prefix):
240
+ raise CommandProtocolError(f"expected {name} metadata")
241
+ return line[len(prefix) :]
242
+
243
+
244
+ def _parse_record_count(record_count_text: str) -> int:
245
+ if re.fullmatch(r"0|[1-9][0-9]*", record_count_text) is None:
246
+ raise CommandProtocolError("record_count must be a canonical non-negative integer")
247
+ if len(record_count_text) > len(str(MAX_RECORD_COUNT)):
248
+ raise CommandProtocolError(f"record_count exceeds protocol maximum of {MAX_RECORD_COUNT}")
249
+ record_count = int(record_count_text)
250
+ if record_count > MAX_RECORD_COUNT:
251
+ raise CommandProtocolError(f"record_count exceeds protocol maximum of {MAX_RECORD_COUNT}")
252
+ return record_count
253
+
254
+
255
+ def _parse_field_line(line: str) -> tuple[str, str, str]:
256
+ key, separator, payload = line.partition("=")
257
+ if not separator or not key.startswith("field."):
258
+ raise CommandProtocolError("expected field.<name>:<type>=<payload>")
259
+ descriptor = key.removeprefix("field.")
260
+ field_name, separator, wire_type = descriptor.partition(":")
261
+ if not separator or not field_name or not wire_type:
262
+ raise CommandProtocolError("expected field.<name>:<type>=<payload>")
263
+ return field_name, wire_type, payload
base_cli/config.py ADDED
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ from typing import Any
8
+
9
+ from .ide_schema import SUPPORTED_IDES
10
+ from .ide_schema import parse_ide_extensions
11
+ from .ide_schema import parse_ide_settings
12
+ from .paths import base_state_root
13
+
14
+
15
+ __all__ = [
16
+ "UserConfig",
17
+ "UserGithubConfig",
18
+ "UserIdeConfig",
19
+ "UserIdePreference",
20
+ "UserWorkspaceConfig",
21
+ ]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class UserIdePreference:
26
+ enabled: bool | None
27
+ install: bool | None
28
+ extra_extensions: tuple[str, ...]
29
+ settings: dict[str, Any]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class UserIdeConfig:
34
+ enabled: bool | None
35
+ preferences: dict[str, UserIdePreference]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class UserWorkspaceConfig:
40
+ root: Path | None
41
+ manifest: Path | None = None
42
+ manifest_source: str | None = None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class UserGithubConfig:
47
+ default_owner: str | None
48
+ clone_protocol: str | None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class UserConfig:
53
+ raw: dict[str, Any]
54
+ ide: UserIdeConfig
55
+ workspace: UserWorkspaceConfig = UserWorkspaceConfig(root=None)
56
+ github: UserGithubConfig = UserGithubConfig(default_owner=None, clone_protocol=None)
57
+
58
+
59
+ def user_config_path(home: Path | None = None) -> Path:
60
+ return base_state_root(home) / "config.yaml"
61
+
62
+
63
+ def load_yaml_file(path: Path) -> dict[str, Any]:
64
+ if not path.is_file():
65
+ return {}
66
+
67
+ try:
68
+ import yaml
69
+ except ImportError as exc:
70
+ raise RuntimeError("PyYAML is required to load base_cli configuration.") from exc
71
+
72
+ try:
73
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
74
+ except yaml.YAMLError as exc:
75
+ raise ValueError(f"Config file '{path}' contains invalid YAML: {exc}") from exc
76
+ if data is None:
77
+ return {}
78
+ if not isinstance(data, dict):
79
+ raise ValueError(f"Config file '{path}' must contain a YAML mapping.")
80
+ return data
81
+
82
+
83
+ def load_user_config(home: Path | None = None) -> dict[str, Any]:
84
+ return load_yaml_file(user_config_path(home))
85
+
86
+
87
+ def read_user_config(home: Path | None = None) -> UserConfig:
88
+ raw = load_user_config(home)
89
+ path = user_config_path(home)
90
+ return UserConfig(
91
+ raw=raw,
92
+ workspace=_read_user_workspace_config(path, raw.get("workspace")),
93
+ github=_read_user_github_config(path, raw.get("github")),
94
+ ide=_read_user_ide_config(path, raw.get("ide")),
95
+ )
96
+
97
+
98
+ def _read_user_workspace_config(path: Path, workspace_data: Any) -> UserWorkspaceConfig:
99
+ if workspace_data is None:
100
+ return UserWorkspaceConfig(root=None)
101
+ if not isinstance(workspace_data, dict):
102
+ raise ValueError(f"{path}: workspace must be a mapping when provided.")
103
+
104
+ allowed_keys = {"root", "manifest", "manifest_source"}
105
+ unknown_keys = sorted(set(workspace_data) - allowed_keys)
106
+ if unknown_keys:
107
+ raise ValueError(f"{path}: workspace has unsupported keys: {', '.join(unknown_keys)}.")
108
+
109
+ return UserWorkspaceConfig(
110
+ root=_optional_path(path, "workspace.root", workspace_data.get("root")),
111
+ manifest=_optional_path(path, "workspace.manifest", workspace_data.get("manifest")),
112
+ manifest_source=_optional_non_empty_string(
113
+ path,
114
+ "workspace.manifest_source",
115
+ workspace_data.get("manifest_source"),
116
+ ),
117
+ )
118
+
119
+
120
+ def _read_user_github_config(path: Path, github_data: Any) -> UserGithubConfig:
121
+ if github_data is None:
122
+ return UserGithubConfig(default_owner=None, clone_protocol=None)
123
+ if not isinstance(github_data, dict):
124
+ raise ValueError(f"{path}: github must be a mapping when provided.")
125
+
126
+ allowed_keys = {"default_owner", "clone_protocol"}
127
+ unknown_keys = sorted(set(github_data) - allowed_keys)
128
+ if unknown_keys:
129
+ raise ValueError(f"{path}: github has unsupported keys: {', '.join(unknown_keys)}.")
130
+
131
+ return UserGithubConfig(
132
+ default_owner=_optional_github_owner(path, github_data.get("default_owner")),
133
+ clone_protocol=_optional_github_clone_protocol(path, github_data.get("clone_protocol")),
134
+ )
135
+
136
+
137
+ def _optional_github_owner(path: Path, value: Any) -> str | None:
138
+ owner = _optional_non_empty_string(path, "github.default_owner", value)
139
+ if owner is None:
140
+ return None
141
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]*", owner):
142
+ raise ValueError(
143
+ f"{path}: github.default_owner must start with a letter or digit and contain only "
144
+ "letters, digits, and dash."
145
+ )
146
+ return owner
147
+
148
+
149
+ def _optional_github_clone_protocol(path: Path, value: Any) -> str | None:
150
+ protocol = _optional_non_empty_string(path, "github.clone_protocol", value)
151
+ if protocol is None:
152
+ return None
153
+ if protocol not in {"ssh", "https"}:
154
+ raise ValueError(f"{path}: github.clone_protocol must be 'ssh' or 'https'.")
155
+ return protocol
156
+
157
+
158
+ def _optional_path(path: Path, key: str, value: Any) -> Path | None:
159
+ if value is None:
160
+ return None
161
+ candidate = _optional_non_empty_string(path, key, value)
162
+ if candidate is None:
163
+ return None
164
+
165
+ candidate_path = Path(candidate).expanduser()
166
+ if not candidate_path.is_absolute():
167
+ raise ValueError(f"{path}: {key} must be an absolute path or start with '~'.")
168
+ return candidate_path.resolve(strict=False)
169
+
170
+
171
+ def _optional_non_empty_string(path: Path, key: str, value: Any) -> str | None:
172
+ if value is None:
173
+ return None
174
+ if not isinstance(value, str) or not value.strip():
175
+ raise ValueError(f"{path}: {key} must be a non-empty string when provided.")
176
+ return value.strip()
177
+
178
+
179
+ def _read_user_ide_config(path: Path, ide_data: Any) -> UserIdeConfig:
180
+ if ide_data is None:
181
+ return UserIdeConfig(enabled=None, preferences={})
182
+ if not isinstance(ide_data, dict):
183
+ raise ValueError(f"{path}: ide must be a mapping when provided.")
184
+
185
+ allowed_keys = SUPPORTED_IDES | {"enabled"}
186
+ unknown_keys = sorted(set(ide_data) - allowed_keys)
187
+ if unknown_keys:
188
+ raise ValueError(f"{path}: unsupported ide keys: {', '.join(unknown_keys)}.")
189
+
190
+ enabled = _optional_bool(path, "ide.enabled", ide_data.get("enabled"))
191
+ preferences = {
192
+ ide_name: _read_user_ide_preference(path, ide_name, ide_data.get(ide_name))
193
+ for ide_name in sorted(SUPPORTED_IDES)
194
+ if ide_name in ide_data
195
+ }
196
+ return UserIdeConfig(enabled=enabled, preferences=preferences)
197
+
198
+
199
+ def _read_user_ide_preference(path: Path, ide_name: str, preference_data: Any) -> UserIdePreference:
200
+ if preference_data is None:
201
+ preference_data = {}
202
+ if not isinstance(preference_data, dict):
203
+ raise ValueError(f"{path}: ide.{ide_name} must be a mapping when provided.")
204
+
205
+ allowed_keys = {"enabled", "install", "extra_extensions", "settings"}
206
+ unknown_keys = sorted(set(preference_data) - allowed_keys)
207
+ if unknown_keys:
208
+ raise ValueError(f"{path}: ide.{ide_name} has unsupported keys: {', '.join(unknown_keys)}.")
209
+
210
+ enabled = _optional_bool(path, f"ide.{ide_name}.enabled", preference_data.get("enabled"))
211
+ install = _optional_bool(path, f"ide.{ide_name}.install", preference_data.get("install"))
212
+ extra_extensions = _read_extra_extensions(path, ide_name, preference_data.get("extra_extensions", []))
213
+ settings = _read_user_ide_settings(path, ide_name, preference_data.get("settings", {}))
214
+ return UserIdePreference(
215
+ enabled=enabled,
216
+ install=install,
217
+ extra_extensions=extra_extensions,
218
+ settings=settings,
219
+ )
220
+
221
+
222
+ def _optional_bool(path: Path, key: str, value: Any) -> bool | None:
223
+ if value is None:
224
+ return None
225
+ if not isinstance(value, bool):
226
+ raise ValueError(f"{path}: {key} must be a boolean when provided.")
227
+ return value
228
+
229
+
230
+ def _read_extra_extensions(path: Path, ide_name: str, extensions_data: Any) -> tuple[str, ...]:
231
+ return parse_ide_extensions(f"{path}: ide.{ide_name}.extra_extensions", extensions_data)
232
+
233
+
234
+ def _read_user_ide_settings(path: Path, ide_name: str, settings_data: Any) -> dict[str, Any]:
235
+ return parse_ide_settings(f"{path}: ide.{ide_name}.settings", settings_data)
236
+
237
+
238
+ def merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
239
+ merged = dict(base)
240
+ for key, value in override.items():
241
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
242
+ merged[key] = merge_dicts(merged[key], value)
243
+ else:
244
+ merged[key] = value
245
+ return merged
246
+
247
+
248
+ def load_config(
249
+ project_root: Path | None,
250
+ explicit_config: Path | None,
251
+ home: Path | None = None,
252
+ ) -> dict[str, Any]:
253
+ root = home or Path.home()
254
+ config: dict[str, Any] = {}
255
+ config = merge_dicts(config, load_user_config(root))
256
+ if project_root is not None:
257
+ config = merge_dicts(config, load_yaml_file(project_root / ".base" / "config.yaml"))
258
+ if explicit_config is not None:
259
+ config = merge_dicts(config, load_yaml_file(explicit_config))
260
+
261
+ env_config: dict[str, Any] = {}
262
+ if "BASE_CLI_ENVIRONMENT" in os.environ:
263
+ env_config["environment"] = os.environ["BASE_CLI_ENVIRONMENT"]
264
+ if "BASE_CLI_LOG_LEVEL" in os.environ:
265
+ env_config["log_level"] = os.environ["BASE_CLI_LOG_LEVEL"]
266
+ elif os.environ.get("LOG_DEBUG", "").lower() in ("1", "true"):
267
+ env_config["log_level"] = "debug"
268
+ if "BASE_CLI_KEEP_TEMP" in os.environ:
269
+ env_config["keep_temp"] = os.environ["BASE_CLI_KEEP_TEMP"].lower() == "true"
270
+ return merge_dicts(config, env_config)
base_cli/context.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ import logging
5
+ import shutil
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from .config import UserConfig, UserIdeConfig
11
+
12
+
13
+ _current_context: contextvars.ContextVar[Context | None] = contextvars.ContextVar(
14
+ "base_cli_current_context",
15
+ default=None,
16
+ )
17
+
18
+
19
+ def _default_user_config() -> UserConfig:
20
+ return UserConfig(raw={}, ide=UserIdeConfig(enabled=None, preferences={}))
21
+
22
+
23
+ @dataclass
24
+ class Context:
25
+ """Runtime state and cleanup hooks available to an active Base CLI command."""
26
+
27
+ cli_name: str
28
+ run_id: str
29
+ state_dir: Path
30
+ log_dir: Path
31
+ cache_dir: Path
32
+ temp_dir: Path
33
+ log_file: Path | None
34
+ config: dict[str, Any]
35
+ environment: str
36
+ debug: bool
37
+ keep_temp: bool
38
+ log: logging.Logger
39
+ dry_run: bool = False
40
+ base_home: Path | None = None
41
+ project_root: Path | None = None
42
+ manifest_path: Path | None = None
43
+ project_name: str | None = None
44
+ history_scope: str = "primary"
45
+ history_parent_run_id: str | None = None
46
+ user_config: UserConfig = field(default_factory=_default_user_config)
47
+ cleanup_hooks: list[Callable[[], None]] = field(default_factory=list)
48
+ workspace_root: Path | None = None
49
+ quiet: bool = False
50
+ runtime_owner: str = "base"
51
+ owner_root: Path | None = None
52
+ run_root: Path | None = None
53
+
54
+ def on_cleanup(self, hook: Callable[[], None]) -> None:
55
+ self.cleanup_hooks.append(hook)
56
+
57
+ def bind_project(self, project_name: str | None, project_root: Path, manifest_path: Path | None = None) -> None:
58
+ """Bind the selected project to this invocation's history context."""
59
+ self.project_name = project_name
60
+ self.project_root = project_root.resolve()
61
+ self.manifest_path = manifest_path.resolve() if manifest_path is not None else None
62
+
63
+ def cleanup(self) -> None:
64
+ for hook in self.cleanup_hooks:
65
+ try:
66
+ hook()
67
+ except Exception as exc: # pylint: disable=broad-exception-caught
68
+ self.log.warning("Cleanup hook failed: %s", exc)
69
+ if not self.keep_temp and self.temp_dir.exists():
70
+ try:
71
+ shutil.rmtree(self.temp_dir)
72
+ for parent in (self.temp_dir.parent, self.temp_dir.parent.parent):
73
+ try:
74
+ parent.rmdir()
75
+ except OSError:
76
+ break
77
+ except OSError as exc:
78
+ self.log.warning("Temp directory cleanup failed for '%s': %s", self.temp_dir, exc)
79
+ for handler in list(self.log.handlers):
80
+ try:
81
+ handler.flush()
82
+ except Exception as exc: # pylint: disable=broad-exception-caught
83
+ self.log.warning("Log handler flush failed: %s", exc)
84
+ try:
85
+ handler.close()
86
+ except Exception as exc: # pylint: disable=broad-exception-caught
87
+ self.log.warning("Log handler close failed: %s", exc)
88
+ self.log.removeHandler(handler)
89
+
90
+
91
+ def set_current_context(context: Context | None) -> contextvars.Token[Context | None]:
92
+ return _current_context.set(context)
93
+
94
+
95
+ def reset_current_context(token: contextvars.Token[Context | None]) -> None:
96
+ _current_context.reset(token)
97
+
98
+
99
+ def get_current_context() -> Context:
100
+ context = _current_context.get()
101
+ if context is None:
102
+ raise RuntimeError("base_cli context is not active. Run inside a base_cli.App command.")
103
+ return context
base_cli/exit_codes.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class ExitCode:
5
+ """Base-standard command exit code constants."""
6
+
7
+ SUCCESS = 0
8
+ FAILURE = 1
9
+ USAGE_ERROR = 2