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.
base_cli/history.py ADDED
@@ -0,0 +1,426 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ try:
11
+ import fcntl as _fcntl
12
+ except ImportError: # pragma: no cover - fcntl is unavailable on Windows.
13
+ _fcntl = None # type: ignore[assignment]
14
+
15
+ from .config import load_yaml_file
16
+ from .context import Context
17
+ from .paths import base_cache_root
18
+ from .redaction import REDACTED, is_secret_key, option_name_to_parameter, redact_argv, redact_text_value
19
+
20
+
21
+ __all__ = [
22
+ "HISTORY_PATH",
23
+ "HISTORY_SCOPE_INTERNAL",
24
+ "HISTORY_SCOPE_PRIMARY",
25
+ "SCHEMA_VERSION",
26
+ "base_setup_action",
27
+ "base_version",
28
+ "build_finished_record",
29
+ "compact_home_text",
30
+ "compact_optional_path",
31
+ "compact_path",
32
+ "display_command",
33
+ "duration_ms",
34
+ "format_timestamp",
35
+ "optional_int",
36
+ "optional_string",
37
+ "parse_finished_history_record_line",
38
+ "parse_positive_int",
39
+ "project_name",
40
+ "redact_history_argv",
41
+ "redact_history_text",
42
+ "runtime_bundle_path",
43
+ "utc_now",
44
+ "write_finished_record",
45
+ "write_history_record",
46
+ "write_primary_record",
47
+ ]
48
+
49
+
50
+ SCHEMA_VERSION = 1
51
+ HISTORY_PATH = Path("base") / "history" / "runs.jsonl"
52
+ HISTORY_SCOPE_PRIMARY = "primary"
53
+ HISTORY_SCOPE_INTERNAL = "internal"
54
+
55
+ # Only these names are Base-owned Python entry points. A standalone caller
56
+ # may legitimately choose a name beginning with ``base_`` and should not have
57
+ # that name rewritten by the shared framework.
58
+ _BASE_DISPLAY_COMMANDS = frozenset(
59
+ {
60
+ "base_activate",
61
+ "base_build",
62
+ "base_check",
63
+ "base_ci",
64
+ "base_clean",
65
+ "base_config",
66
+ "base_demo",
67
+ "base_dev",
68
+ "base_devcontainer",
69
+ "base_devenv",
70
+ "base_devenv_report",
71
+ "base_docs",
72
+ "base_export_context",
73
+ "base_gh",
74
+ "base_github_projects",
75
+ "base_history",
76
+ "base_logs",
77
+ "base_onboard",
78
+ "base_pr_policy",
79
+ "base_projects",
80
+ "base_prompt",
81
+ "base_release",
82
+ "base_repo",
83
+ "base_run",
84
+ "base_setup",
85
+ "base_test",
86
+ "base_trust",
87
+ "base_update",
88
+ "base_update_profile",
89
+ "base_workspace",
90
+ }
91
+ )
92
+
93
+
94
+ def utc_now() -> datetime:
95
+ return datetime.now(timezone.utc)
96
+
97
+
98
+ def write_finished_record(
99
+ context: Context,
100
+ argv: list[str],
101
+ sensitive_options: set[str],
102
+ started_at: datetime,
103
+ exit_code: int,
104
+ ) -> None:
105
+ # Base-dispatched child commands share the parent's run bundle and
106
+ # diagnostic stream. Their completion is an implementation detail, so
107
+ # keep history at the public-invocation level as well.
108
+ if context.dry_run or context.log_file is None or context.history_scope == HISTORY_SCOPE_INTERNAL:
109
+ return
110
+ try:
111
+ record = build_finished_record(context, argv, sensitive_options, started_at, exit_code)
112
+ write_history_record(record)
113
+ if context.run_root is not None:
114
+ update_run_metadata(context.run_root, record)
115
+ except Exception as exc: # pylint: disable=broad-exception-caught
116
+ context.log.debug("Unable to write command history record: %s", exc)
117
+
118
+
119
+ def build_finished_record(
120
+ context: Context,
121
+ argv: list[str],
122
+ sensitive_options: set[str],
123
+ started_at: datetime,
124
+ exit_code: int,
125
+ ) -> dict[str, Any]:
126
+ ended_at = utc_now()
127
+ record: dict[str, Any] = {
128
+ "schema_version": SCHEMA_VERSION,
129
+ "run_id": context.run_id,
130
+ "event": "finished",
131
+ "command": display_command(context.cli_name, argv),
132
+ "raw_command": context.cli_name,
133
+ "argv": redact_history_argv(argv, sensitive_options),
134
+ "started_at": format_timestamp(started_at),
135
+ "ended_at": format_timestamp(ended_at),
136
+ "duration_ms": duration_ms(started_at, ended_at),
137
+ "exit_code": exit_code,
138
+ "status": "ok" if exit_code == 0 else "error",
139
+ "log_path": compact_path(context.log_file),
140
+ "owner": context.runtime_owner,
141
+ "bundle_path": compact_path(context.run_root or context.state_dir),
142
+ "os": normalized_os(),
143
+ }
144
+ optional_fields = {
145
+ "project": project_name(context),
146
+ "project_root": compact_optional_path(context.project_root),
147
+ "manifest": compact_optional_path(context.manifest_path),
148
+ "workspace_root": compact_optional_path(context.workspace_root),
149
+ "base_version": base_version(context.base_home),
150
+ "shell": os.environ.get("SHELL"),
151
+ "scope": context.history_scope,
152
+ "parent_run_id": context.history_parent_run_id,
153
+ }
154
+ record.update({key: value for key, value in optional_fields.items() if value})
155
+ return record
156
+
157
+
158
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
159
+ def write_primary_record(
160
+ command: str,
161
+ argv: list[str],
162
+ started_at: datetime,
163
+ exit_code: int,
164
+ run_id: str,
165
+ scope: str = HISTORY_SCOPE_PRIMARY,
166
+ project: str | None = None,
167
+ project_root: str | None = None,
168
+ manifest: str | None = None,
169
+ log_path: str | None = None,
170
+ owner: str = "base",
171
+ bundle_path: str | None = None,
172
+ *,
173
+ raw_command: str = "basectl",
174
+ ) -> None:
175
+ """Write the user-facing record for a Bash-dispatched command."""
176
+ ended_at = utc_now()
177
+ record: dict[str, Any] = {
178
+ "schema_version": SCHEMA_VERSION,
179
+ "run_id": run_id,
180
+ "event": "finished",
181
+ "command": command,
182
+ "raw_command": raw_command,
183
+ "argv": redact_history_argv(argv, sensitive_options=set()),
184
+ "started_at": format_timestamp(started_at),
185
+ "ended_at": format_timestamp(ended_at),
186
+ "duration_ms": duration_ms(started_at, ended_at),
187
+ "exit_code": exit_code,
188
+ "status": "ok" if exit_code == 0 else "error",
189
+ "os": normalized_os(),
190
+ "scope": scope,
191
+ }
192
+ resolved_bundle = Path(bundle_path).expanduser() if bundle_path else runtime_bundle_path()
193
+ resolved_log = Path(log_path).expanduser() if log_path else (
194
+ resolved_bundle / "logs" / "primary.log" if resolved_bundle is not None else None
195
+ )
196
+ optional_fields = {
197
+ "project": project,
198
+ "project_root": compact_optional_path(Path(project_root)) if project_root else None,
199
+ "manifest": compact_optional_path(Path(manifest)) if manifest else None,
200
+ "log_path": compact_optional_path(resolved_log),
201
+ "owner": owner,
202
+ "bundle_path": compact_optional_path(resolved_bundle),
203
+ }
204
+ record.update({key: value for key, value in optional_fields.items() if value})
205
+ write_history_record(record)
206
+ if resolved_bundle is not None:
207
+ update_run_metadata(resolved_bundle, record)
208
+
209
+
210
+ def write_history_record(record: dict[str, Any]) -> None:
211
+ path = base_cache_root() / HISTORY_PATH
212
+ path.parent.mkdir(parents=True, exist_ok=True)
213
+ append_history_line(path, f"{json.dumps(record, sort_keys=True)}\n")
214
+ path.chmod(0o600)
215
+
216
+
217
+ def runtime_bundle_path() -> Path | None:
218
+ value = os.environ.get("BASE_CLI_RUN_ROOT")
219
+ if not value:
220
+ return None
221
+ return Path(value).expanduser().resolve(strict=False)
222
+
223
+
224
+ def update_run_metadata(run_root: Path, record: dict[str, Any]) -> None:
225
+ metadata_path = run_root / "run.json"
226
+ metadata: dict[str, Any] = {}
227
+ try:
228
+ if metadata_path.is_file():
229
+ loaded = json.loads(metadata_path.read_text(encoding="utf-8"))
230
+ if isinstance(loaded, dict):
231
+ metadata = loaded
232
+ metadata.update(
233
+ {
234
+ "run_id": record.get("run_id"),
235
+ "owner": record.get("owner", metadata.get("owner", "base")),
236
+ "status": record.get("status"),
237
+ "exit_code": record.get("exit_code"),
238
+ "ended_at": record.get("ended_at"),
239
+ "command": record.get("command"),
240
+ }
241
+ )
242
+ for key in (
243
+ "argv",
244
+ "manifest",
245
+ "parent_run_id",
246
+ "project",
247
+ "project_root",
248
+ "raw_command",
249
+ "scope",
250
+ "workspace_root",
251
+ ):
252
+ if key in record and record[key] is not None:
253
+ metadata[key] = record[key]
254
+ metadata_path.parent.mkdir(parents=True, exist_ok=True)
255
+ metadata_path.write_text(json.dumps(metadata, sort_keys=True) + "\n", encoding="utf-8")
256
+ metadata_path.chmod(0o600)
257
+ except (OSError, TypeError, ValueError):
258
+ pass
259
+
260
+
261
+ def append_history_line(path: Path, line: str) -> None:
262
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
263
+ try:
264
+ lock_history_file(fd)
265
+ try:
266
+ write_all(fd, line.encode("utf-8"))
267
+ finally:
268
+ unlock_history_file(fd)
269
+ finally:
270
+ os.close(fd)
271
+
272
+
273
+ def lock_history_file(fd: int) -> None:
274
+ if _fcntl is not None:
275
+ _fcntl.flock(fd, _fcntl.LOCK_EX)
276
+
277
+
278
+ def unlock_history_file(fd: int) -> None:
279
+ if _fcntl is not None:
280
+ _fcntl.flock(fd, _fcntl.LOCK_UN)
281
+
282
+
283
+ def write_all(fd: int, data: bytes) -> None:
284
+ remaining = data
285
+ while remaining:
286
+ written = os.write(fd, remaining)
287
+ if written == 0:
288
+ raise OSError("history append wrote zero bytes")
289
+ remaining = remaining[written:]
290
+
291
+
292
+ def format_timestamp(value: datetime) -> str:
293
+ normalized = value.astimezone(timezone.utc)
294
+ return normalized.isoformat(timespec="seconds").replace("+00:00", "Z")
295
+
296
+
297
+ def duration_ms(started_at: datetime, ended_at: datetime) -> int:
298
+ return max(0, round((ended_at - started_at).total_seconds() * 1000))
299
+
300
+
301
+ def display_command(cli_name: str, argv: list[str]) -> str:
302
+ if cli_name == "base_setup":
303
+ return base_setup_action(argv) or "setup"
304
+ if cli_name in _BASE_DISPLAY_COMMANDS:
305
+ return cli_name.removeprefix("base_").replace("_", "-")
306
+ return cli_name.replace("_", "-")
307
+
308
+
309
+ def parse_positive_int(option: str, value: str) -> int:
310
+ if not value.isdigit():
311
+ raise ValueError(f"Option '{option}' must be a positive integer.")
312
+ amount = int(value)
313
+ if amount <= 0:
314
+ raise ValueError(f"Option '{option}' must be greater than zero.")
315
+ return amount
316
+
317
+
318
+ def parse_finished_history_record_line(line: str) -> dict[str, Any] | None:
319
+ try:
320
+ payload = json.loads(line)
321
+ except json.JSONDecodeError:
322
+ return None
323
+ if not isinstance(payload, dict) or payload.get("schema_version") != SCHEMA_VERSION:
324
+ return None
325
+ if payload.get("event") != "finished":
326
+ return None
327
+ return payload
328
+
329
+
330
+ def optional_string(value: Any) -> str | None:
331
+ return value if isinstance(value, str) and value else None
332
+
333
+
334
+ def optional_int(value: Any) -> int | None:
335
+ return value if isinstance(value, int) else None
336
+
337
+
338
+ def base_setup_action(argv: list[str]) -> str | None:
339
+ for index, arg in enumerate(argv):
340
+ if arg == "--action" and index + 1 < len(argv):
341
+ return argv[index + 1]
342
+ if arg.startswith("--action="):
343
+ return arg.partition("=")[2]
344
+ return None
345
+
346
+
347
+ def project_name(context: Context) -> str | None:
348
+ if context.project_name:
349
+ return context.project_name
350
+ if context.manifest_path is None:
351
+ return None
352
+ try:
353
+ data = load_yaml_file(context.manifest_path)
354
+ except (OSError, RuntimeError, ValueError):
355
+ return None
356
+ project_data = data.get("project")
357
+ if not isinstance(project_data, dict):
358
+ return None
359
+ value = project_data.get("name")
360
+ return value if isinstance(value, str) and value else None
361
+
362
+
363
+ def base_version(base_home: Path | None) -> str | None:
364
+ if base_home is None:
365
+ return None
366
+ try:
367
+ version = (base_home / "VERSION").read_text(encoding="utf-8").splitlines()[0].strip()
368
+ except (IndexError, OSError):
369
+ return None
370
+ return version or None
371
+
372
+
373
+ def normalized_os() -> str:
374
+ system = platform.system().lower()
375
+ if system == "darwin":
376
+ return "macos"
377
+ return system or platform.platform()
378
+
379
+
380
+ def redact_history_argv(argv: list[str], sensitive_options: set[str]) -> list[str]:
381
+ redacted = redact_argv(argv, sensitive_options)
382
+ result: list[str] = []
383
+ redact_next = False
384
+ for arg in redacted:
385
+ if redact_next:
386
+ result.append(REDACTED)
387
+ redact_next = False
388
+ continue
389
+
390
+ option, separator, _value = arg.partition("=")
391
+ normalized = option_name_to_parameter(option) if option.startswith("--") else option
392
+ if option.startswith("--") and is_secret_key(normalized):
393
+ if separator:
394
+ result.append(f"{option}={REDACTED}")
395
+ else:
396
+ result.append(option)
397
+ redact_next = True
398
+ continue
399
+ result.append(redact_history_text(arg))
400
+ return result
401
+
402
+
403
+ def redact_history_text(value: str) -> str:
404
+ key, separator, _value = value.partition("=")
405
+ if separator and is_secret_key(key):
406
+ return f"{key}={REDACTED}"
407
+ return compact_home_text(redact_text_value(value))
408
+
409
+
410
+ def compact_optional_path(path: Path | None) -> str | None:
411
+ if path is None:
412
+ return None
413
+ return compact_path(path)
414
+
415
+
416
+ def compact_path(path: Path) -> str:
417
+ return compact_home_text(str(path.expanduser().resolve(strict=False)))
418
+
419
+
420
+ def compact_home_text(value: str) -> str:
421
+ home = str(Path.home().expanduser().resolve(strict=False))
422
+ if value == home:
423
+ return "~"
424
+ if value.startswith(f"{home}/"):
425
+ return f"~/{value[len(home) + 1:]}"
426
+ return value
base_cli/ide_schema.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class IdeDefinition:
10
+ name: str
11
+ label: str
12
+ cli: str
13
+ cask: str
14
+ settings_app_dir: str
15
+
16
+
17
+ IDE_DEFINITIONS = {
18
+ "vscode": IdeDefinition(
19
+ name="vscode",
20
+ label="VS Code",
21
+ cli="code",
22
+ cask="visual-studio-code",
23
+ settings_app_dir="Code",
24
+ ),
25
+ "cursor": IdeDefinition(
26
+ name="cursor",
27
+ label="Cursor",
28
+ cli="cursor",
29
+ cask="cursor",
30
+ settings_app_dir="Cursor",
31
+ ),
32
+ }
33
+
34
+ SUPPORTED_IDES = frozenset(IDE_DEFINITIONS)
35
+ PROJECT_AUTO_SETTING_KEYS = frozenset({"python.defaultInterpreterPath"})
36
+
37
+
38
+ def parse_ide_extensions(context: str, extensions_data: Any) -> tuple[str, ...]:
39
+ if extensions_data is None:
40
+ return ()
41
+ if not isinstance(extensions_data, list):
42
+ raise ValueError(f"{context} must be a list when provided.")
43
+
44
+ extensions: list[str] = []
45
+ for index, extension in enumerate(extensions_data, start=1):
46
+ if not isinstance(extension, str) or not extension.strip():
47
+ raise ValueError(f"{context}[{index}] must be a non-empty string.")
48
+ extensions.append(extension.strip())
49
+ return tuple(extensions)
50
+
51
+
52
+ def parse_ide_settings(
53
+ context: str,
54
+ settings_data: Any,
55
+ *,
56
+ auto_setting_keys: frozenset[str] | None = None,
57
+ ) -> dict[str, Any]:
58
+ if settings_data is None:
59
+ return {}
60
+ if not isinstance(settings_data, dict):
61
+ raise ValueError(f"{context} must be a mapping when provided.")
62
+
63
+ settings: dict[str, Any] = {}
64
+ for key, value in settings_data.items():
65
+ if not isinstance(key, str) or not key:
66
+ raise ValueError(f"{context} keys must be non-empty strings.")
67
+ if auto_setting_keys is not None and value == "auto" and key not in auto_setting_keys:
68
+ raise ValueError(f"{context}.{key} does not support the special value 'auto'.")
69
+ try:
70
+ json.dumps(value)
71
+ except TypeError as exc:
72
+ raise ValueError(f"{context}.{key} must be JSON-serializable.") from exc
73
+ settings[key] = value
74
+ return settings
base_cli/inspection.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+ from typing import Literal
7
+
8
+ InspectionStatus = Literal["ok", "warn", "error"]
9
+
10
+
11
+ def inspection_envelope(
12
+ *,
13
+ command: str,
14
+ status: InspectionStatus,
15
+ data: Mapping[str, Any],
16
+ error: Mapping[str, Any] | None = None,
17
+ ) -> dict[str, Any]:
18
+ """Build the stable v1 envelope for read-only inspection commands."""
19
+ if status not in ("ok", "warn", "error"):
20
+ raise ValueError(f"Unsupported inspection status: {status}")
21
+ return {
22
+ "schema_version": 1,
23
+ "command": command,
24
+ "status": status,
25
+ "data": dict(data),
26
+ "error": None if error is None else dict(error),
27
+ }
28
+
29
+
30
+ def render_inspection_json(
31
+ *,
32
+ command: str,
33
+ status: InspectionStatus,
34
+ data: Mapping[str, Any],
35
+ error: Mapping[str, Any] | None = None,
36
+ ) -> str:
37
+ """Serialize the stable inspection envelope with Python's JSON encoder."""
38
+ return (
39
+ json.dumps(
40
+ inspection_envelope(command=command, status=status, data=data, error=error),
41
+ ensure_ascii=False,
42
+ indent=2,
43
+ )
44
+ + "\n"
45
+ )