Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/consumer-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ base_cli.App + Context + logging + cleanup
+-- user configuration -> CliProfile.load_user_config
+-- project/explicit config -> CliProfile.load_config
+-- runtime placement -> CliProfile.resolve_runtime
+-- history command labels -> CliProfile.history_display_command
+-- optional history -> CliProfile.history_writer
```

Expand Down Expand Up @@ -70,6 +71,11 @@ The callback types are deliberately small. A consumer can wrap an existing
project library, use a different serialization format, or return no project
metadata at all.

If a consumer persists history, it can provide `history_display_command` to
translate internal entry-point names into user-facing labels. The generic
default only replaces underscores with hyphens; it does not know any product's
command aliases.

## Compatibility profile

For the migration period, `App()` without an explicit profile selects
Expand All @@ -84,7 +90,8 @@ The legacy profile contains the current Base conventions, including:
- `BASE_HOME`, `BASE_CACHE_DIR`, and Base owner/runtime environment variables;
- `~/.base.d/config.yaml` and project `.base/config.yaml`;
- Base's owner-aware cache and run layout;
- Base history persistence and delegation metadata.
- Base history persistence and delegation metadata. Base's command-label policy
is supplied by Base rather than encoded in `base_cli.history`.

These conventions are intentionally isolated behind one profile so they can be
moved into the Base consumer without changing command lifecycle code.
Expand Down
1 change: 1 addition & 0 deletions lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str],
keep_temp=keep_temp,
log=logger,
user_config=user_config,
history_display_command=self.profile.history_display_command,
dry_run=dry_run,
history_scope=runtime.history_scope,
history_parent_run_id=runtime.history_parent_run_id,
Expand Down
31 changes: 24 additions & 7 deletions lib/python/base_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from typing import Any

from ._dependencies import require_yaml
from .ide_schema import SUPPORTED_IDES
from .ide_schema import parse_ide_extensions
from .ide_schema import parse_ide_settings
from .paths import base_state_root
Expand Down Expand Up @@ -82,14 +81,18 @@ def load_user_config(home: Path | None = None) -> dict[str, Any]:
return load_yaml_file(user_config_path(home))


def read_user_config(home: Path | None = None) -> UserConfig:
def read_user_config(
home: Path | None = None,
*,
supported_ides: frozenset[str] | None = None,
) -> UserConfig:
raw = load_user_config(home)
path = user_config_path(home)
return UserConfig(
raw=raw,
workspace=_read_user_workspace_config(path, raw.get("workspace")),
github=_read_user_github_config(path, raw.get("github")),
ide=_read_user_ide_config(path, raw.get("ide")),
ide=_read_user_ide_config(path, raw.get("ide"), supported_ides=supported_ides),
)


Expand Down Expand Up @@ -174,21 +177,35 @@ def _optional_non_empty_string(path: Path, key: str, value: Any) -> str | None:
return value.strip()


def _read_user_ide_config(path: Path, ide_data: Any) -> UserIdeConfig:
def _read_user_ide_config(
path: Path,
ide_data: Any,
*,
supported_ides: frozenset[str] | None,
) -> UserIdeConfig:
if ide_data is None:
return UserIdeConfig(enabled=None, preferences={})
if not isinstance(ide_data, dict):
raise ValueError(f"{path}: ide must be a mapping when provided.")

allowed_keys = SUPPORTED_IDES | {"enabled"}
unknown_keys = sorted(set(ide_data) - allowed_keys)
invalid_keys = sorted(
str(key) for key in ide_data if not isinstance(key, str) or not key.strip()
)
if invalid_keys:
raise ValueError(f"{path}: ide keys must be non-empty strings.")

unknown_keys = (
sorted(set(ide_data) - supported_ides - {"enabled"})
if supported_ides is not None
else []
)
if unknown_keys:
raise ValueError(f"{path}: unsupported ide keys: {', '.join(unknown_keys)}.")

enabled = _optional_bool(path, "ide.enabled", ide_data.get("enabled"))
preferences = {
ide_name: _read_user_ide_preference(path, ide_name, ide_data.get(ide_name))
for ide_name in sorted(SUPPORTED_IDES)
for ide_name in sorted(set(ide_data) - {"enabled"})
if ide_name in ide_data
}
return UserIdeConfig(enabled=enabled, preferences=preferences)
Expand Down
5 changes: 5 additions & 0 deletions lib/python/base_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ def _default_user_config() -> UserConfig:
return UserConfig(raw={}, ide=UserIdeConfig(enabled=None, preferences={}))


def _default_history_display_command(cli_name: str, _argv: list[str]) -> str:
return cli_name.replace("_", "-")


@dataclass
class Context:
"""Runtime state and cleanup hooks available to an active CLI command."""
Expand All @@ -45,6 +49,7 @@ class Context:
history_scope: str = "primary"
history_parent_run_id: str | None = None
user_config: UserConfig = field(default_factory=_default_user_config)
history_display_command: Callable[[str, list[str]], str] = _default_history_display_command
cleanup_hooks: list[Callable[[], None]] = field(default_factory=list)
workspace_root: Path | None = None
quiet: bool = False
Expand Down
59 changes: 4 additions & 55 deletions lib/python/base_cli/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
_msvcrt = None # type: ignore[assignment]

from ._private_files import restrict_file, write_private_json
from .config import load_yaml_file
from .context import Context
from .paths import base_cache_root
from .redaction import REDACTED, is_secret_key, option_name_to_parameter, redact_argv, redact_text_value
Expand All @@ -29,7 +28,6 @@
"HISTORY_SCOPE_INTERNAL",
"HISTORY_SCOPE_PRIMARY",
"SCHEMA_VERSION",
"base_setup_action",
"base_version",
"build_finished_record",
"compact_home_text",
Expand Down Expand Up @@ -58,45 +56,6 @@
HISTORY_SCOPE_PRIMARY = "primary"
HISTORY_SCOPE_INTERNAL = "internal"

# Only these names are Base-owned Python entry points. A standalone caller
# may legitimately choose a name beginning with ``base_`` and should not have
# that name rewritten by the shared framework.
_BASE_DISPLAY_COMMANDS = frozenset(
{
"base_activate",
"base_build",
"base_check",
"base_ci",
"base_clean",
"base_config",
"base_demo",
"base_dev",
"base_devcontainer",
"base_devenv",
"base_devenv_report",
"base_docs",
"base_export_context",
"base_gh",
"base_github_projects",
"base_history",
"base_logs",
"base_onboard",
"base_pr_policy",
"base_projects",
"base_prompt",
"base_release",
"base_repo",
"base_run",
"base_setup",
"base_test",
"base_trust",
"base_update",
"base_update_profile",
"base_workspace",
}
)


def utc_now() -> datetime:
return datetime.now(timezone.utc)

Expand Down Expand Up @@ -134,7 +93,7 @@ def build_finished_record(
"schema_version": SCHEMA_VERSION,
"run_id": context.run_id,
"event": "finished",
"command": display_command(context.cli_name, argv),
"command": context.history_display_command(context.cli_name, argv),
"raw_command": context.cli_name,
"argv": redact_history_argv(argv, sensitive_options),
"started_at": format_timestamp(started_at),
Expand Down Expand Up @@ -320,10 +279,7 @@ def duration_ms(started_at: datetime, ended_at: datetime) -> int:


def display_command(cli_name: str, argv: list[str]) -> str:
if cli_name == "base_setup":
return base_setup_action(argv) or "setup"
if cli_name in _BASE_DISPLAY_COMMANDS:
return cli_name.removeprefix("base_").replace("_", "-")
del argv
return cli_name.replace("_", "-")


Expand Down Expand Up @@ -356,21 +312,14 @@ def optional_int(value: Any) -> int | None:
return value if isinstance(value, int) else None


def base_setup_action(argv: list[str]) -> str | None:
for index, arg in enumerate(argv):
if arg == "--action" and index + 1 < len(argv):
return argv[index + 1]
if arg.startswith("--action="):
return arg.partition("=")[2]
return None


def project_name(context: Context) -> str | None:
if context.project_name:
return context.project_name
if context.manifest_path is None:
return None
try:
from .config import load_yaml_file

data = load_yaml_file(context.manifest_path)
except (OSError, RuntimeError, ValueError):
return None
Expand Down
33 changes: 4 additions & 29 deletions lib/python/base_cli/ide_schema.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,13 @@
from __future__ import annotations

from dataclasses import dataclass
import json
from typing import Any


@dataclass(frozen=True)
class IdeDefinition:
name: str
label: str
cli: str
cask: str
settings_app_dir: str


IDE_DEFINITIONS = {
"vscode": IdeDefinition(
name="vscode",
label="VS Code",
cli="code",
cask="visual-studio-code",
settings_app_dir="Code",
),
"cursor": IdeDefinition(
name="cursor",
label="Cursor",
cli="cursor",
cask="cursor",
settings_app_dir="Cursor",
),
}

SUPPORTED_IDES = frozenset(IDE_DEFINITIONS)
PROJECT_AUTO_SETTING_KEYS = frozenset({"python.defaultInterpreterPath"})
__all__ = [
"parse_ide_extensions",
"parse_ide_settings",
]


def parse_ide_extensions(context: str, extensions_data: Any) -> tuple[str, ...]:
Expand Down
9 changes: 9 additions & 0 deletions lib/python/base_cli/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,17 @@ class RuntimeBinding:
RuntimeResolver = Callable[[str, ProjectInfo | None], RuntimeBinding]
HistoryWriter = Callable[[Any, list[str], set[str], datetime, int], None]
DisplayCommandResolver = Callable[[], str | None]
HistoryDisplayResolver = Callable[[str, list[str]], str]


def _no_display_command() -> str | None:
return None


def _generic_history_display_command(cli_name: str, _argv: list[str]) -> str:
return cli_name.replace("_", "-")


@dataclass(frozen=True)
class CliProfile:
"""Policy boundary between the generic CLI lifecycle and its consumer.
Expand All @@ -68,6 +73,7 @@ class CliProfile:
resolve_runtime: RuntimeResolver
history_writer: HistoryWriter | None = None
display_command: DisplayCommandResolver = _no_display_command
history_display_command: HistoryDisplayResolver = _generic_history_display_command

@classmethod
def generic(
Expand All @@ -78,6 +84,7 @@ def generic(
discover_project: ProjectDiscovery | None = None,
load_user_config: UserConfigLoader | None = None,
load_config: ConfigLoader | None = None,
history_display_command: HistoryDisplayResolver | None = None,
) -> CliProfile:
"""Create a profile with consumer-neutral defaults.

Expand All @@ -90,6 +97,7 @@ def generic(
load_config=load_config or _load_explicit_config,
resolve_runtime=_generic_runtime_resolver(cache_root, application_home),
display_command=_no_display_command,
history_display_command=history_display_command or _generic_history_display_command,
)

@classmethod
Expand Down Expand Up @@ -179,6 +187,7 @@ def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBindin
resolve_runtime=resolve_runtime,
history_writer=write_finished_record,
display_command=_legacy_display_command,
history_display_command=_generic_history_display_command,
)


Expand Down
15 changes: 13 additions & 2 deletions tests/test_base_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ def test_read_user_config_rejects_non_mapping_ide(self) -> None:
with self.assertRaisesRegex(ValueError, "ide must be a mapping"):
read_user_config(home)

def test_read_user_config_rejects_unknown_ide_key(self) -> None:
def test_read_user_config_accepts_consumer_defined_ide_key(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir)
path = user_config_path(home)
Expand All @@ -418,8 +418,19 @@ def test_read_user_config_rejects_unknown_ide_key(self) -> None:
encoding="utf-8",
)

config = read_user_config(home)

self.assertTrue(config.ide.preferences["windswept"].enabled)

def test_read_user_config_can_validate_consumer_supported_ide_names(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir)
path = user_config_path(home)
path.parent.mkdir(parents=True)
path.write_text("ide:\n windswept:\n enabled: true\n", encoding="utf-8")

with self.assertRaisesRegex(ValueError, "unsupported ide keys: windswept"):
read_user_config(home)
read_user_config(home, supported_ides=frozenset({"vscode"}))

def test_read_user_config_rejects_non_boolean_ide_enabled(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ def test_shared_history_helpers_parse_records_and_display_paths(self) -> None:
json.dumps({**payload, "event": "started"})
)
)
self.assertEqual(history_helpers.display_command("base_setup", ["--action", "check"]), "check")
self.assertEqual(history_helpers.display_command("base_history", []), "history")
self.assertEqual(history_helpers.display_command("base_setup", ["--action", "check"]), "base-setup")
self.assertEqual(history_helpers.display_command("base_history", []), "base-history")
self.assertEqual(history_helpers.display_command("base_something", []), "base-something")
self.assertEqual(history_helpers.display_command("my_tool", []), "my-tool")
self.assertEqual(history_helpers.compact_path(inside_home), "~/logs/run.log")
Expand Down
Loading