From 5cef8b7246fc29c17d4c7e8263ce38bf5dba06ae Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 16:41:43 -0500 Subject: [PATCH 01/17] =?UTF-8?q?feat(context):=20bcli.context=20=E2=80=94?= =?UTF-8?q?=20typed=20ContextBundle=20+=203-layer=20redaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the shared model-bound context layer (Part 0 of the bcli ask / agent plan). Standalone in this commit — no CLI consumers yet. The package shape and redaction posture are exercised by tests so the next two commits (CLI hook-up, tests) and the upcoming Part 2 `bcli ask` can build against a stable surface. - `bcli.context._protocol` — typed dataclasses (R4): ContextBundle, TokenBudget, BundlePolicy, BundleSource, RedactionRecord, ProfileSnapshot, HttpEvent, LastErrorRecord, Attachment. Frozen, JSON-serialisable, with `to_dict()` and `to_prompt_text()` renderers. - `bcli.context._redact` — three-layer redaction (R5) composing bcli/audit/_redact.py (layer 1 keys), bcli/telemetry/events.py (layer 2 patterns), and a new URL/GUID/attachment scrubber (layer 3). Every redaction lands in the bundle's audit trail with a stable `rule_id`. - `bcli.context._last_error` — captures BCLIError exits to `~/.config/bcli/last-error.json`. No tracebacks by default (R6); --debug runs also write a `last-error-debug.json` at mode 0600. - `bcli.context._http_tail` — opt-in RotatingFileHandler on `bcli.http` for the last ~200 events. Off by default. - `bcli.context._bundle` — pure builder: token-budgeted priority truncation (question > last_error > profile > http > describe > attachments), every step source-attributed. --- src/bcli/context/__init__.py | 82 +++++++ src/bcli/context/_bundle.py | 310 ++++++++++++++++++++++++ src/bcli/context/_http_tail.py | 173 +++++++++++++ src/bcli/context/_last_error.py | 286 ++++++++++++++++++++++ src/bcli/context/_protocol.py | 414 ++++++++++++++++++++++++++++++++ src/bcli/context/_redact.py | 411 +++++++++++++++++++++++++++++++ 6 files changed, 1676 insertions(+) create mode 100644 src/bcli/context/__init__.py create mode 100644 src/bcli/context/_bundle.py create mode 100644 src/bcli/context/_http_tail.py create mode 100644 src/bcli/context/_last_error.py create mode 100644 src/bcli/context/_protocol.py create mode 100644 src/bcli/context/_redact.py diff --git a/src/bcli/context/__init__.py b/src/bcli/context/__init__.py new file mode 100644 index 0000000..728e9a1 --- /dev/null +++ b/src/bcli/context/__init__.py @@ -0,0 +1,82 @@ +"""``bcli.context`` — shared model-bound context layer (Part 0 / R1). + +This package is the foundation for any bcli feature that ships context +to an LLM. Today's first consumer is ``bcli ask`` (Part 2); tomorrow's +will be ``bcli agent`` (Part 4, deferred). The package is deliberately +*standalone* — it has no consumers in this PR — so its shape can be +audited and exercised before downstream LLM-driven features land. + +Public surface +-------------- + +* :func:`build_bundle` — pure function: read last-error + http-tail + + profile snapshot + describe excerpt + attachments, run three layers + of redaction (key-based → token-pattern → URL/GUID), token-budget + truncate, and return a typed :class:`ContextBundle`. +* :class:`ContextBundle` / supporting dataclasses (see ``_protocol``). +* :func:`capture_last_error` — invoked from the CLI's central error + handler; writes ``~/.config/bcli/last-error.json``. +* :func:`enable_http_tail` — wire a :class:`RotatingFileHandler` onto + the ``bcli.http`` logger when ``[context] tail = true``. + +Design rules enforced by the package boundary: + +* Nothing in here imports from ``bcli_cli`` (CLI -> SDK only). +* Every emitted artefact is JSON-able + size-bounded. +* Every redaction is logged in the bundle's audit trail + (:class:`RedactionRecord`). +""" + +from __future__ import annotations + +from bcli.context._bundle import build_bundle +from bcli.context._http_tail import enable_http_tail, http_tail_path, read_http_tail +from bcli.context._last_error import ( + capture_last_error, + last_error_path, + read_last_error, +) +from bcli.context._protocol import ( + Attachment, + BundlePolicy, + BundleSource, + ContextBundle, + HttpEvent, + LastErrorRecord, + ProfileSnapshot, + RedactionRecord, + TokenBudget, +) +from bcli.context._redact import ( + REDACT_RULES, + apply_layered_redaction, + redact_text, + redact_url, +) + +__all__ = [ + # Dataclasses + "Attachment", + "BundlePolicy", + "BundleSource", + "ContextBundle", + "HttpEvent", + "LastErrorRecord", + "ProfileSnapshot", + "RedactionRecord", + "TokenBudget", + # Builders / capturers + "build_bundle", + "capture_last_error", + "enable_http_tail", + # Redaction + "REDACT_RULES", + "apply_layered_redaction", + "redact_text", + "redact_url", + # Path helpers (test surfaces) + "http_tail_path", + "last_error_path", + "read_http_tail", + "read_last_error", +] diff --git a/src/bcli/context/_bundle.py b/src/bcli/context/_bundle.py new file mode 100644 index 0000000..37def24 --- /dev/null +++ b/src/bcli/context/_bundle.py @@ -0,0 +1,310 @@ +"""Bundle assembly (R4) — pure function turning state into a ContextBundle. + +:func:`build_bundle` is the central composer. It reads: + +* The persisted last-error record (R6 — no traceback by default). +* The rolling ``bcli.http`` tail (R5/R6 — opt-in via config). +* Profile snapshot (name/env/company — never secrets). +* Optional describe excerpt (caller passes a string; this module does + no subprocessing — callers like ``bcli ask`` decide whether to + invoke ``bcli describe``). +* User ``--attach`` files (caller-provided; we redact + budget here). + +…then truncates to a caller-supplied token budget in priority order: + + question > last_error > profile_snapshot > recent_http > describe > attachments + +and emits a frozen :class:`ContextBundle` with every redaction logged. +""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from bcli.context._http_tail import read_http_tail +from bcli.context._last_error import read_last_error +from bcli.context._protocol import ( + Attachment, + BundlePolicy, + BundleSource, + ContextBundle, + HttpEvent, + LastErrorRecord, + ProfileSnapshot, + RedactionRecord, + TokenBudget, +) +from bcli.context._redact import ( + redact_text, + redact_url, + scan_attachment, +) + + +# Rough 4 chars per token heuristic (matches the SDK estimator). The +# bundle's truncation is a *soft* cap — we don't try to nail the +# byte; we walk sources in priority order and stop when we'd exceed. +_CHARS_PER_TOKEN = 4 + + +def build_bundle( + *, + question: str = "", + profile: ProfileSnapshot | None = None, + policy: BundlePolicy | None = None, + budget: TokenBudget | None = None, + describe_excerpt: str = "", + registry_snapshot_hash: str = "", + attachments: tuple[Attachment, ...] | None = None, + raw_attachments: tuple[tuple[str, str], ...] = (), + last_error: LastErrorRecord | None = None, + recent_http: tuple[HttpEvent, ...] | None = None, + config_dir: Path | None = None, +) -> ContextBundle: + """Build a model-bound :class:`ContextBundle` from the live state. + + Every named arg has a sensible default so a "minimal" ask + (``--no-context``) lands a bundle with just the question and + policy stub. + + ``raw_attachments`` is the operator-friendly shape ``(label, + content)`` — they're redacted + truncated and added to + ``attachments``. Pre-built :class:`Attachment` instances passed via + ``attachments`` skip the scan (assumed already post-redaction). + """ + policy = policy or BundlePolicy() + budget = budget or TokenBudget() + profile = profile or ProfileSnapshot() + redactions: list[RedactionRecord] = [] + sources: list[BundleSource] = [] + actual_chars = 0 + truncated = False + + # Always include the question — non-negotiable. + if question: + actual_chars += len(question) + sources.append(BundleSource( + kind="question", + label="user-question", + included_bytes=len(question.encode("utf-8")), + )) + + # Last error — second priority. Caller-provided OR read from disk. + le = last_error + if le is None: + le = read_last_error(config_dir=config_dir) + if le is not None: + # Redact bc_message + url if not already done (the persisted + # file is already redacted, but a caller-provided record may + # not be — defensive). + clean_le, le_recs = _redact_last_error(le) + redactions.extend(le_recs) + le_bytes = _estimate_bytes(clean_le) + actual_chars += le_bytes + sources.append(BundleSource( + kind="last_error", + label="last-error.json", + included_bytes=le_bytes, + )) + le = clean_le + + # Profile snapshot — third priority. Cheap and always safe. + if any((profile.name, profile.environment, profile.company)): + ps_bytes = _estimate_bytes(profile) + actual_chars += ps_bytes + sources.append(BundleSource( + kind="profile_snapshot", + label="profile", + included_bytes=ps_bytes, + )) + + # Recent HTTP — fourth priority. Read from disk unless provided. + http_events: tuple[HttpEvent, ...] = () + if policy.include_http_tail: + http_events = recent_http or read_http_tail(config_dir=config_dir) + if http_events: + # Tail is already URL-redacted on read, but the bundle is + # the source-of-truth audit so we re-walk to catch any + # caller-provided events. + cleaned, recs = _redact_http_events(http_events) + redactions.extend(recs) + http_bytes = sum(_estimate_bytes(h) for h in cleaned) + # Apply budget truncation here — drop oldest if over. + while cleaned and actual_chars + http_bytes > budget.max_tokens * _CHARS_PER_TOKEN: + dropped = cleaned[0] + cleaned = cleaned[1:] + http_bytes -= _estimate_bytes(dropped) + truncated = True + actual_chars += http_bytes + http_events = cleaned + if http_events: + sources.append(BundleSource( + kind="http_tail", + label=f"recent-http ({len(http_events)} events)", + included_bytes=http_bytes, + )) + + # Describe excerpt — fifth priority. Drop if it'd blow the budget. + if policy.include_describe and describe_excerpt: + desc_bytes = len(describe_excerpt) + if actual_chars + desc_bytes <= budget.max_tokens * _CHARS_PER_TOKEN: + actual_chars += desc_bytes + sources.append(BundleSource( + kind="describe", + label="describe-excerpt", + included_bytes=desc_bytes, + )) + else: + describe_excerpt = "" + truncated = True + + # Attachments — lowest priority. Scan each then budget. + out_attachments: list[Attachment] = list(attachments or ()) + for label, content in raw_attachments: + cleaned, included_bytes, recs = scan_attachment( + content, + label=label, + max_bytes=policy.attachment_max_bytes, + redact_guids=policy.redact_company_ids, + ) + redactions.extend(recs) + if actual_chars + included_bytes > budget.max_tokens * _CHARS_PER_TOKEN: + truncated = True + continue + actual_chars += included_bytes + out_attachments.append(Attachment( + label=label, + path="", + content=cleaned, + original_bytes=len(content.encode("utf-8")), + included_bytes=included_bytes, + )) + sources.append(BundleSource( + kind="attachment", + label=label, + included_bytes=included_bytes, + )) + + actual_tokens = (actual_chars + _CHARS_PER_TOKEN - 1) // _CHARS_PER_TOKEN + if actual_tokens > budget.max_tokens: + truncated = True + + final_budget = TokenBudget( + max_tokens=budget.max_tokens, + actual_tokens=actual_tokens, + truncated=truncated, + ) + + # Hash the registry only when caller didn't supply one. We don't + # want to import the registry here (would create a heavy dep); + # leave the hash to the caller in the common case. + snapshot_hash = registry_snapshot_hash + + return ContextBundle( + generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), + question=question, + budget=final_budget, + policy=policy, + sources=tuple(sources), + redactions=tuple(redactions), + profile_snapshot=profile, + registry_snapshot_hash=snapshot_hash, + describe_excerpt=describe_excerpt, + recent_http=http_events, + last_error=le, + attachments=tuple(out_attachments), + ) + + +def _redact_last_error( + le: LastErrorRecord, +) -> tuple[LastErrorRecord, tuple[RedactionRecord, ...]]: + records: list[RedactionRecord] = [] + new_url, recs = redact_url(le.url, location_path="last_error.url") + records.extend(recs) + new_msg, recs = redact_text( + le.bc_message, location_path="last_error.bc_message" + ) + records.extend(recs) + new_hint, recs = redact_text(le.hint, location_path="last_error.hint") + records.extend(recs) + cleaned = LastErrorRecord( + timestamp=le.timestamp, + command=le.command, + error_class=le.error_class, + exit_code=le.exit_code, + status=le.status, + profile=le.profile, + environment=le.environment, + company=le.company, + url=new_url, + method=le.method, + correlation_id=le.correlation_id, + endpoint=le.endpoint, + hint=new_hint, + bc_message=new_msg, + traceback_excerpt=le.traceback_excerpt, + ) + return cleaned, tuple(records) + + +def _redact_http_events( + events: tuple[HttpEvent, ...], +) -> tuple[tuple[HttpEvent, ...], tuple[RedactionRecord, ...]]: + records: list[RedactionRecord] = [] + cleaned: list[HttpEvent] = [] + for i, ev in enumerate(events): + new_url, recs = redact_url( + ev.url, location_path=f"recent_http[{i}].url" + ) + records.extend(recs) + cleaned.append(HttpEvent( + timestamp=ev.timestamp, + method=ev.method, + url=new_url, + status=ev.status, + latency_ms=ev.latency_ms, + correlation_id=ev.correlation_id, + endpoint=ev.endpoint, + retry_count=ev.retry_count, + )) + return tuple(cleaned), tuple(records) + + +def _estimate_bytes(value: Any) -> int: + """Rough byte-estimate of a dataclass / scalar for budget math.""" + if value is None: + return 0 + if isinstance(value, (int, float)): + return len(str(value)) + if isinstance(value, str): + return len(value) + if hasattr(value, "__dict__"): + # Dataclass-ish — sum string fields. + total = 0 + for v in vars(value).values(): + total += _estimate_bytes(v) + return total + # For frozen dataclasses use __dataclass_fields__ if present. + if hasattr(value, "__dataclass_fields__"): + total = 0 + for f in value.__dataclass_fields__: + total += _estimate_bytes(getattr(value, f)) + return total + try: + return len(str(value)) + except Exception: # noqa: BLE001 + return 0 + + +# Helper exposed for the hash-the-registry caller (ask, agent). +def hash_registry(payload: bytes | str) -> str: + """Produce a stable sha256 hex of a registry serialisation.""" + data = payload.encode("utf-8") if isinstance(payload, str) else payload + return "sha256:" + hashlib.sha256(data).hexdigest() + + +__all__ = ["build_bundle", "hash_registry"] diff --git a/src/bcli/context/_http_tail.py b/src/bcli/context/_http_tail.py new file mode 100644 index 0000000..583e1cf --- /dev/null +++ b/src/bcli/context/_http_tail.py @@ -0,0 +1,173 @@ +"""Rolling NDJSON tail of recent ``bcli.http`` events (R5/R6 support). + +Wires a :class:`logging.handlers.RotatingFileHandler` onto the +``bcli.http`` logger so the most recent ~200 HTTP requests land in +``~/.config/bcli/http-tail.ndjson`` for the context bundler to read. +Off by default — opt-in via ``[context] tail = true`` so users on +read-only home dirs (CI, ephemeral containers) don't accidentally +fail boot. + +Why NDJSON over a structured log: ``bcli.http`` already emits one +JSON record per line via :mod:`bcli.client._transport`, so the +handler is a trivial passthrough. ``read_http_tail`` parses each line +back into a :class:`HttpEvent` for the bundle. +""" + +from __future__ import annotations + +import json +import logging +import logging.handlers +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from bcli.context._protocol import HttpEvent +from bcli.context._redact import redact_url + +logger = logging.getLogger("bcli.context") + +# Filename + size cap are public so tests can assert directly. +HTTP_TAIL_FILENAME = "http-tail.ndjson" +# ~200 events at ~1KB each. Two backups so a rollover keeps the tail +# of the *previous* invocation around for "what just happened?" runs. +DEFAULT_MAX_BYTES = 200_000 +DEFAULT_BACKUP_COUNT = 1 + +_HANDLER_FLAG = "_bcli_context_tail" + + +def _config_dir() -> Path: + return Path.home() / ".config" / "bcli" + + +def http_tail_path(config_dir: Path | None = None) -> Path: + """Resolve the on-disk path of the rolling tail file.""" + return (config_dir or _config_dir()) / HTTP_TAIL_FILENAME + + +def enable_http_tail( + *, + config_dir: Path | None = None, + max_bytes: int = DEFAULT_MAX_BYTES, + backup_count: int = DEFAULT_BACKUP_COUNT, +) -> bool: + """Attach the rotating handler to ``bcli.http``. + + Idempotent — subsequent calls noop if the handler is already + installed (the handler instance carries a ``_bcli_context_tail`` + sentinel attribute). + + Returns ``True`` on success, ``False`` when the target directory + isn't writable. Never raises. + """ + try: + target_dir = config_dir or _config_dir() + target_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.debug("http-tail dir unwritable: %s", e) + return False + + http_logger = logging.getLogger("bcli.http") + + for h in http_logger.handlers: + if getattr(h, _HANDLER_FLAG, False): + return True # already installed + + try: + handler = logging.handlers.RotatingFileHandler( + filename=str(target_dir / HTTP_TAIL_FILENAME), + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8", + ) + except OSError as e: + logger.debug("could not open http-tail handler: %s", e) + return False + + # Mark as ours so we don't re-attach. + setattr(handler, _HANDLER_FLAG, True) + + # Transport already emits structured JSON via its formatter; we + # use a passthrough format that prints the message body as-is. + handler.setFormatter(logging.Formatter("%(message)s")) + handler.setLevel(logging.INFO) + http_logger.addHandler(handler) + # Ensure the records actually flow even though the root logger + # has no handler in normal runtime. + if http_logger.level == logging.NOTSET or http_logger.level > logging.INFO: + http_logger.setLevel(logging.INFO) + return True + + +def read_http_tail( + *, + config_dir: Path | None = None, + limit: int = 50, + redact: bool = True, +) -> tuple[HttpEvent, ...]: + """Read the most recent NDJSON events back as typed records. + + Returns at most ``limit`` events, newest last (chronological). + Lines that fail to parse are skipped silently — a corrupt line + must not prevent the bundle from being built. + """ + path = http_tail_path(config_dir) + if not path.is_file(): + return () + try: + text = path.read_text(encoding="utf-8") + except OSError as e: + logger.debug("could not read http-tail %s: %s", path, e) + return () + + lines = [ln for ln in text.splitlines() if ln.strip()] + # Keep newest N — older lines get rotated to the .1 file anyway. + selected = lines[-limit:] + + events: list[HttpEvent] = [] + for raw in selected: + try: + obj = json.loads(raw) + except ValueError: + continue + if not isinstance(obj, dict): + continue + events.append(_event_from_dict(obj, redact=redact)) + return tuple(events) + + +def _event_from_dict(obj: dict[str, Any], *, redact: bool) -> HttpEvent: + """Coerce a single NDJSON record into :class:`HttpEvent`. + + ``obj`` is whatever ``bcli.client._transport`` emitted. We only + look up the canonical keys; missing fields default to empty. + """ + url = str(obj.get("url", "")) + if redact and url: + url, _ = redact_url(url, location_path="http_tail.url") + timestamp = str( + obj.get("timestamp") + or obj.get("ts") + or datetime.now(timezone.utc).isoformat(timespec="seconds") + ) + return HttpEvent( + timestamp=timestamp, + method=str(obj.get("method", "")), + url=url, + status=int(obj.get("status", 0) or 0), + latency_ms=float(obj.get("latency_ms", 0.0) or 0.0), + correlation_id=str(obj.get("correlation_id", "")), + endpoint=str(obj.get("endpoint", "")), + retry_count=int(obj.get("retry_count", 0) or 0), + ) + + +__all__ = [ + "DEFAULT_BACKUP_COUNT", + "DEFAULT_MAX_BYTES", + "HTTP_TAIL_FILENAME", + "enable_http_tail", + "http_tail_path", + "read_http_tail", +] diff --git a/src/bcli/context/_last_error.py b/src/bcli/context/_last_error.py new file mode 100644 index 0000000..7db138a --- /dev/null +++ b/src/bcli/context/_last_error.py @@ -0,0 +1,286 @@ +"""Last-error capture for the context bundle (R6). + +When the CLI's central error handler catches a :class:`BCLIError`, it +calls :func:`capture_last_error` to drop a small JSON file at +``~/.config/bcli/last-error.json``. ``bcli ask`` reads that file on +the next invocation so a "what happened?" question has the same shape +of evidence ``bcli describe`` would have given. + +**No tracebacks by default.** ``last-error.json`` is mode 0644-safe — +it carries no traceback frames. A separate ``last-error-debug.json`` +(mode 0600) holds traceback excerpts only when ``--debug`` was active +for that invocation, and the bundle layer excludes it unless +``--include-debug`` is set. + +Capturing errors must never raise — the user is already in an error +path; doubling that with a write failure is worse than no capture. +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +import traceback +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from bcli.context._protocol import LastErrorRecord +from bcli.context._redact import redact_text, redact_url + +logger = logging.getLogger("bcli.context") + +# Filename constants — public so tests + downstream consumers +# (ask, agent) can resolve them without hardcoding strings. +LAST_ERROR_FILENAME = "last-error.json" +LAST_ERROR_DEBUG_FILENAME = "last-error-debug.json" +_SCHEMA_VERSION = "1.0" + + +def _config_dir() -> Path: + return Path.home() / ".config" / "bcli" + + +def last_error_path(*, debug: bool = False) -> Path: + """Resolve the on-disk path for the captured last-error file. + + ``debug=True`` returns the sibling ``last-error-debug.json`` that + only exists when the failing invocation was run with ``--debug``. + """ + return _config_dir() / ( + LAST_ERROR_DEBUG_FILENAME if debug else LAST_ERROR_FILENAME + ) + + +def capture_last_error( + *, + exc: BaseException, + command: str = "", + profile: str = "", + environment: str = "", + company: str = "", + debug: bool = False, + config_dir: Path | None = None, +) -> Path | None: + """Persist a redacted record of ``exc`` to ``last-error.json``. + + Returns the path of the file we wrote, or ``None`` if writing + failed silently. ``debug=True`` also writes a sibling + ``last-error-debug.json`` (mode 0600) that includes a 2KB + traceback excerpt. + + Best-effort. Never re-raises. + """ + try: + target_dir = config_dir or _config_dir() + target_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.debug("could not create config dir for last-error: %s", e) + return None + + record = _build_record( + exc=exc, + command=command, + profile=profile, + environment=environment, + company=company, + include_traceback=False, + ) + primary = target_dir / LAST_ERROR_FILENAME + if not _atomic_write_json(primary, record.__dict__, mode=0o644): + return None + + if debug: + debug_record = _build_record( + exc=exc, + command=command, + profile=profile, + environment=environment, + company=company, + include_traceback=True, + ) + debug_path = target_dir / LAST_ERROR_DEBUG_FILENAME + _atomic_write_json(debug_path, debug_record.__dict__, mode=0o600) + + return primary + + +def read_last_error( + *, + debug: bool = False, + config_dir: Path | None = None, +) -> LastErrorRecord | None: + """Read the captured last-error record back as a typed object. + + Returns ``None`` when the file doesn't exist, is unreadable, or + fails JSON parse — the bundle layer treats absence as "no recent + error" rather than crashing. + """ + target_dir = config_dir or _config_dir() + path = target_dir / ( + LAST_ERROR_DEBUG_FILENAME if debug else LAST_ERROR_FILENAME + ) + if not path.is_file(): + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + logger.debug("could not read last-error file %s: %s", path, e) + return None + if not isinstance(raw, dict): + return None + return _record_from_dict(raw) + + +def _build_record( + *, + exc: BaseException, + command: str, + profile: str, + environment: str, + company: str, + include_traceback: bool, +) -> LastErrorRecord: + """Compose a :class:`LastErrorRecord` from an exception. + + Errors carry the BC-specific shape: ``status_code``, ``bc_message``, + ``correlation_id`` for :class:`bcli.errors.BCLIError` subclasses. + Other exceptions populate ``error_class`` only — we never reach + here for non-BCLIError unless ``--debug`` enabled the broader + capture in app.py. + """ + timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds") + error_class = exc.__class__.__name__ + status = getattr(exc, "status_code", None) or 0 + bc_message = getattr(exc, "bc_message", "") or "" + correlation_id = getattr(exc, "correlation_id", "") or "" + + # Some BCLIError subclasses (e.g. ValidationError raised by the + # filter pre-flight) carry a remediation hint on a known attribute; + # support it gracefully without forcing all of them to add one. + hint = getattr(exc, "hint", "") or "" + if not hint and bc_message: + hint = "" + + url = getattr(exc, "url", "") or "" + method = getattr(exc, "method", "") or "" + endpoint = getattr(exc, "endpoint", "") or "" + + # Redact URL query params + free-text BC message in case the BC + # server happened to echo back a token-shaped string. + if url: + url, _ = redact_url(url, location_path="last_error.url") + if bc_message: + bc_message, _ = redact_text( + bc_message, location_path="last_error.bc_message" + ) + + exit_code = _exit_code_for(exc) + tb_excerpt = "" + if include_traceback: + tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + # 2KB cap — enough to cover the topmost frames, capped so the + # captured file stays small and easy to ship to a model. + tb_excerpt = tb[-2048:] + + return LastErrorRecord( + timestamp=timestamp, + command=command, + error_class=error_class, + exit_code=exit_code, + status=status if isinstance(status, int) else 0, + profile=profile, + environment=environment, + company=company, + url=url, + method=method, + correlation_id=correlation_id, + endpoint=endpoint, + hint=hint, + bc_message=bc_message, + traceback_excerpt=tb_excerpt, + ) + + +def _record_from_dict(raw: dict[str, Any]) -> LastErrorRecord: + """Build a :class:`LastErrorRecord` from a JSON dict. + + Missing fields fall back to dataclass defaults so reading an + older-schema file still works. + """ + return LastErrorRecord( + timestamp=str(raw.get("timestamp", "")), + command=str(raw.get("command", "")), + error_class=str(raw.get("error_class", "")), + exit_code=int(raw.get("exit_code", 0) or 0), + status=int(raw.get("status", 0) or 0), + profile=str(raw.get("profile", "")), + environment=str(raw.get("environment", "")), + company=str(raw.get("company", "")), + url=str(raw.get("url", "")), + method=str(raw.get("method", "")), + correlation_id=str(raw.get("correlation_id", "")), + endpoint=str(raw.get("endpoint", "")), + hint=str(raw.get("hint", "")), + bc_message=str(raw.get("bc_message", "")), + traceback_excerpt=str(raw.get("traceback_excerpt", "")), + ) + + +def _exit_code_for(exc: BaseException) -> int: + """Best-effort exit code lookup. + + Reuses the AIP §Phase 4c mapping if available; falls back to 1 + when the helper isn't importable (e.g. partial install). + """ + try: + from bcli_cli._error_handler import map_error_to_exit_code # type: ignore + return int(map_error_to_exit_code(exc)) + except Exception: # noqa: BLE001 + return 1 + + +def _atomic_write_json(path: Path, payload: dict[str, Any], *, mode: int) -> bool: + """Atomically write ``payload`` to ``path`` with restrictive mode. + + Returns ``True`` on success, ``False`` on any I/O / serialise + failure. Never raises — the caller is already in an error path. + """ + try: + # Serialise first so a bad payload doesn't trash the previous file. + data = json.dumps( + {"schema_version": _SCHEMA_VERSION, **payload}, + ensure_ascii=False, + indent=2, + sort_keys=False, + default=str, + ) + fd, tmp_name = tempfile.mkstemp( + prefix=path.name + ".", dir=str(path.parent) + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + os.chmod(tmp_name, mode) + os.replace(tmp_name, str(path)) + except Exception: + try: + os.unlink(tmp_name) + except OSError: + pass + raise + return True + except (OSError, TypeError, ValueError) as e: + logger.debug("could not write last-error file %s: %s", path, e) + return False + + +__all__ = [ + "LAST_ERROR_DEBUG_FILENAME", + "LAST_ERROR_FILENAME", + "capture_last_error", + "last_error_path", + "read_last_error", +] diff --git a/src/bcli/context/_protocol.py b/src/bcli/context/_protocol.py new file mode 100644 index 0000000..fa7a70f --- /dev/null +++ b/src/bcli/context/_protocol.py @@ -0,0 +1,414 @@ +"""Typed bundle dataclasses for the bcli context layer (Part 0 / R4). + +These shapes are the shared model surface that downstream LLM-driven +features (``bcli ask``, future ``bcli agent``, ``bcli explain``, …) +consume. They're *not* free dicts: every field is typed, frozen, and +size-bounded so a redaction regression is catchable in CI and a stale +bundle can be re-hydrated from disk byte-for-byte. + +Design notes +------------ + +* Every dataclass is ``@dataclass(frozen=True)``. The bundle round-trips + through JSON for cache / replay; mutation would break the + ``schema_version`` contract and confuse downstream agents. +* Collections inside a frozen dataclass are tuples (``redactions``, + ``sources``, ``recent_http``, ``attachments``) — list defaults are + unhashable and break ``frozen=True``. +* ``RedactionRecord`` is the audit trail per R5 — every removed string + is logged with the rule that matched it. Tests assert that no secret + leaves the laptop without a corresponding record. +* ``TokenBudget`` is set by the caller. The bundle truncates lowest- + priority sources first (HTTP tail → describe → attachments) and + flips ``truncated=True`` so the consumer knows the bundle is a slice. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +_SCHEMA_VERSION = "1.0" + + +@dataclass(frozen=True) +class TokenBudget: + """Caller-supplied size envelope for the bundle. + + ``max_tokens`` is a soft cap — the bundle generator never trims to + the exact byte; it walks sources in priority order and stops adding + when the running total exceeds the cap. ``actual_tokens`` is + populated post-generation with the count the bundler arrived at, + using the rough 4-chars-per-token heuristic also used by the + Anthropic Python SDK's token estimator. + """ + + max_tokens: int = 16_000 + actual_tokens: int = 0 + truncated: bool = False + + +@dataclass(frozen=True) +class BundleSource: + """One contributor to the assembled bundle. + + ``kind`` is one of ``"last_error"``, ``"http_tail"``, ``"describe"``, + ``"attachment"``, ``"profile_snapshot"``, ``"question"`` — the + consumer uses it to route prompt sections. + + ``included_bytes`` records the post-redaction byte size that actually + made it into the bundle; if redactions or budget truncated the source + this will be smaller than the on-disk size. + """ + + kind: str + label: str + path: str = "" + included_bytes: int = 0 + + +@dataclass(frozen=True) +class RedactionRecord: + """One audit-trail entry for a redacted value. + + Every removed string lands here with the rule that matched it + (``rule_id``) and the *location* it was found (``location_path`` — + e.g. ``"recent_http[3].url"``). The original is NEVER stored — only + its length, so a "wait, that was 4 chars, can't be a token" sanity + check is possible at audit time. False positives in this trail are + cheap; false negatives leak credentials. + """ + + rule_id: str + location_path: str + redacted_length: int + + +@dataclass(frozen=True) +class BundlePolicy: + """Caller-controlled flags driving what the bundler is allowed to read. + + ``include_bodies`` defaults False — request / response bodies stay + out of the bundle unless the operator opts in (``bcli ask + --include-bodies``). ``include_debug`` defaults False — the debug + last-error file (which may carry traceback) is excluded from the + bundle even when present on disk. + + Mirrors the ``[context]`` section in config: ``redact_company_ids``, + ``attachment_max_bytes``. + """ + + include_bodies: bool = False + include_describe: bool = True + include_http_tail: bool = True + include_debug: bool = False + redact_company_ids: bool = False + attachment_max_bytes: int = 256 * 1024 + + +@dataclass(frozen=True) +class ProfileSnapshot: + """Minimal profile metadata safe to ship to an LLM. + + Excludes ``tenant_id``, ``client_id``, ``client_secret_env`` — + those are credential surface, not context. The model only needs to + know "we were on profile X, environment Y, company Z" to reason + about the failing command. + """ + + name: str = "" + environment: str = "" + company: str = "" + auth_method: str = "" + disable_writes: bool = False + + +@dataclass(frozen=True) +class HttpEvent: + """One line from the rolling ``bcli.http`` tail file. + + ``url`` arrives here already URL-scrubbed (query params replaced + with ``?key=[REDACTED]``). ``correlation_id`` is the BC + ``x-ms-correlation-request-id`` header, useful for cross-checking + against an Azure Application Insights record. + """ + + timestamp: str + method: str + url: str + status: int + latency_ms: float = 0.0 + correlation_id: str = "" + endpoint: str = "" + retry_count: int = 0 + + +@dataclass(frozen=True) +class LastErrorRecord: + """Captured snapshot of the last ``BCLIError`` the CLI raised (R6). + + No traceback by default. ``traceback_excerpt`` is empty unless the + user ran with ``--debug``; even then it lives in a sibling + ``last-error-debug.json`` file (mode 0600) that the bundler ignores + unless ``BundlePolicy.include_debug`` is set. + + Field set mirrors what ``bcli describe`` already serialises for + error events so an agent reading both sees consistent shapes. + """ + + timestamp: str + command: str + error_class: str + exit_code: int + status: int = 0 + profile: str = "" + environment: str = "" + company: str = "" + url: str = "" + method: str = "" + correlation_id: str = "" + endpoint: str = "" + hint: str = "" + bc_message: str = "" + traceback_excerpt: str = "" + + +@dataclass(frozen=True) +class Attachment: + """A user-supplied ``--attach `` file post-redaction. + + Always re-run through the three-layer redactor before inclusion — + treat the attachment as untrusted even when the operator brought + it from their own disk. ``original_bytes`` is the on-disk size; + ``included_bytes`` is what survived redaction + truncation. + """ + + label: str + path: str + content: str + original_bytes: int + included_bytes: int + + +@dataclass(frozen=True) +class ContextBundle: + """Top-level model-bound context object (R4). + + Constructed by :func:`bcli.context.build_bundle`. Token-budgeted, + source-attributed, redaction-audited. Consumers (``bcli ask``, + future ``bcli agent``) call ``to_dict()`` / ``to_prompt_text()`` + to flatten for an HTTP request body. + """ + + schema_version: str = _SCHEMA_VERSION + generated_at: str = "" + question: str = "" + budget: TokenBudget = field(default_factory=TokenBudget) + policy: BundlePolicy = field(default_factory=BundlePolicy) + sources: tuple[BundleSource, ...] = () + redactions: tuple[RedactionRecord, ...] = () + profile_snapshot: ProfileSnapshot = field(default_factory=ProfileSnapshot) + registry_snapshot_hash: str = "" + describe_excerpt: str = "" + recent_http: tuple[HttpEvent, ...] = () + last_error: LastErrorRecord | None = None + attachments: tuple[Attachment, ...] = () + + def to_prompt_text(self) -> str: + """Markdown rendering used by ``bcli ask`` prompt assembly. + + Sections appear in priority order — question → last_error → + profile_snapshot → recent_http → describe_excerpt → attachments. + Empty sections are skipped. Redaction summary lands as a final + section so the model can reason about gaps (e.g. "if a header + looks missing it was redacted by rule X"). + """ + out: list[str] = [] + if self.question: + out.append("## Question\n") + out.append(self.question.strip()) + out.append("") + if self.last_error is not None: + le = self.last_error + out.append("## Last error") + out.append("") + out.append(f"- class: `{le.error_class}`") + out.append(f"- exit_code: {le.exit_code}") + if le.status: + out.append(f"- http_status: {le.status}") + if le.command: + out.append(f"- command: `{le.command}`") + if le.endpoint: + out.append(f"- endpoint: `{le.endpoint}`") + if le.method or le.url: + out.append(f"- request: `{le.method} {le.url}`".rstrip()) + if le.correlation_id: + out.append(f"- correlation_id: `{le.correlation_id}`") + if le.bc_message: + out.append(f"- bc_message: {le.bc_message}") + if le.hint: + out.append(f"- hint: {le.hint}") + if self.policy.include_debug and le.traceback_excerpt: + out.append("\n```\n" + le.traceback_excerpt.strip() + "\n```") + out.append("") + ps = self.profile_snapshot + if any((ps.name, ps.environment, ps.company, ps.auth_method)): + out.append("## Profile") + out.append("") + if ps.name: + out.append(f"- profile: `{ps.name}`") + if ps.environment: + out.append(f"- environment: `{ps.environment}`") + if ps.company: + out.append(f"- company: `{ps.company}`") + if ps.auth_method: + out.append(f"- auth_method: `{ps.auth_method}`") + if ps.disable_writes: + out.append("- disable_writes: true") + out.append("") + if self.recent_http: + out.append("## Recent HTTP") + out.append("") + for h in self.recent_http: + out.append( + f"- {h.timestamp} {h.method} `{h.url}` → {h.status} " + f"({h.latency_ms:.0f}ms)" + + (f" corr=`{h.correlation_id}`" if h.correlation_id else "") + ) + out.append("") + if self.describe_excerpt: + out.append("## Describe excerpt") + out.append("") + out.append("```json") + out.append(self.describe_excerpt.strip()) + out.append("```") + out.append("") + if self.attachments: + out.append("## Attachments") + out.append("") + for a in self.attachments: + out.append(f"### `{a.label}` ({a.included_bytes} of " + f"{a.original_bytes} bytes after redaction)") + out.append("") + out.append("```") + out.append(a.content) + out.append("```") + out.append("") + if self.redactions: + rule_counts: dict[str, int] = {} + for r in self.redactions: + rule_counts[r.rule_id] = rule_counts.get(r.rule_id, 0) + 1 + out.append("## Redactions applied") + out.append("") + for rule, count in sorted(rule_counts.items()): + out.append(f"- `{rule}`: {count} occurrence(s)") + out.append("") + if self.budget.truncated: + out.append(f"_Bundle truncated to fit {self.budget.max_tokens} " + f"token budget (~{self.budget.actual_tokens} actual)._") + out.append("") + return "\n".join(out).rstrip() + "\n" + + def to_dict(self) -> dict[str, Any]: + """JSON-friendly nested dict; used by ask backends + tests.""" + return { + "schema_version": self.schema_version, + "generated_at": self.generated_at, + "question": self.question, + "budget": { + "max_tokens": self.budget.max_tokens, + "actual_tokens": self.budget.actual_tokens, + "truncated": self.budget.truncated, + }, + "policy": { + "include_bodies": self.policy.include_bodies, + "include_describe": self.policy.include_describe, + "include_http_tail": self.policy.include_http_tail, + "include_debug": self.policy.include_debug, + "redact_company_ids": self.policy.redact_company_ids, + "attachment_max_bytes": self.policy.attachment_max_bytes, + }, + "sources": [ + { + "kind": s.kind, + "label": s.label, + "path": s.path, + "included_bytes": s.included_bytes, + } + for s in self.sources + ], + "redactions": [ + { + "rule_id": r.rule_id, + "location_path": r.location_path, + "redacted_length": r.redacted_length, + } + for r in self.redactions + ], + "profile_snapshot": { + "name": self.profile_snapshot.name, + "environment": self.profile_snapshot.environment, + "company": self.profile_snapshot.company, + "auth_method": self.profile_snapshot.auth_method, + "disable_writes": self.profile_snapshot.disable_writes, + }, + "registry_snapshot_hash": self.registry_snapshot_hash, + "describe_excerpt": self.describe_excerpt, + "recent_http": [ + { + "timestamp": h.timestamp, + "method": h.method, + "url": h.url, + "status": h.status, + "latency_ms": h.latency_ms, + "correlation_id": h.correlation_id, + "endpoint": h.endpoint, + "retry_count": h.retry_count, + } + for h in self.recent_http + ], + "last_error": ( + None if self.last_error is None + else { + "timestamp": self.last_error.timestamp, + "command": self.last_error.command, + "error_class": self.last_error.error_class, + "exit_code": self.last_error.exit_code, + "status": self.last_error.status, + "profile": self.last_error.profile, + "environment": self.last_error.environment, + "company": self.last_error.company, + "url": self.last_error.url, + "method": self.last_error.method, + "correlation_id": self.last_error.correlation_id, + "endpoint": self.last_error.endpoint, + "hint": self.last_error.hint, + "bc_message": self.last_error.bc_message, + "traceback_excerpt": self.last_error.traceback_excerpt, + } + ), + "attachments": [ + { + "label": a.label, + "path": a.path, + "content": a.content, + "original_bytes": a.original_bytes, + "included_bytes": a.included_bytes, + } + for a in self.attachments + ], + } + + +__all__ = [ + "Attachment", + "BundlePolicy", + "BundleSource", + "ContextBundle", + "HttpEvent", + "LastErrorRecord", + "ProfileSnapshot", + "RedactionRecord", + "TokenBudget", +] diff --git a/src/bcli/context/_redact.py b/src/bcli/context/_redact.py new file mode 100644 index 0000000..0a41ae3 --- /dev/null +++ b/src/bcli/context/_redact.py @@ -0,0 +1,411 @@ +"""Three-layer redaction for the context bundle (R5). + +Layer 1 — *key-based* — reuses :func:`bcli.audit._redact.redact` which +walks dicts/lists and replaces values whose key name contains a +sensitive token (``token``, ``secret``, ``authorization``…). Cheap, +deep, and catches structured payloads. + +Layer 2 — *pattern-based* — reuses :data:`bcli.telemetry.events._SECRET_RE` +which matches token-shaped substrings in free text (Bearer prefixes, +JWTs, hex tokens, instrumentation keys). + +Layer 3 — *URL/GUID/PII scrub* — owned by this module. Strips query +parameters from URLs, optionally redacts GUIDs / BC company IDs per +policy, and trims oversized attachments. + +Every redaction emits a :class:`RedactionRecord` so the audit trail +is testable in CI — a regression that silently drops a value lands as +a missing record, not a missing assertion. The rule_ids are stable +public API: + +* ``audit:key`` — layer 1, key-based dict redaction. +* ``telemetry:pattern`` — layer 2, token-pattern regex. +* ``context:url_query`` — layer 3, stripped a URL query string. +* ``context:guid`` — layer 3, replaced a GUID per policy. +* ``context:truncate`` — layer 3, attachment cut to fit budget. +""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from bcli.audit._redact import REDACTED, redact as _audit_redact +from bcli.context._protocol import RedactionRecord +from bcli.telemetry.events import _SECRET_RE # noqa: PLC2701 re-used by design + +# Stable rule-id constants (public API). +RULE_AUDIT_KEY = "audit:key" +RULE_TELEMETRY_PATTERN = "telemetry:pattern" +RULE_URL_QUERY = "context:url_query" +RULE_GUID = "context:guid" +RULE_TRUNCATE = "context:truncate" + +# Exported set of every rule id this module can emit. Tests assert +# against this so a new rule can't slip in unrecorded. +REDACT_RULES = frozenset( + [ + RULE_AUDIT_KEY, + RULE_TELEMETRY_PATTERN, + RULE_URL_QUERY, + RULE_GUID, + RULE_TRUNCATE, + ] +) + + +# Default token names handed to layer 1. The audit defaults already +# include the common ones; we widen here because the model-bound bundle +# tolerates false-positives much better than a leaked token. +_DEFAULT_AUDIT_KEYS: tuple[str, ...] = ( + "authorization", + "auth", + "token", + "secret", + "password", + "apiKey", + "api_key", + "client_secret", + "x-api-key", + "cookie", + "set-cookie", + "bearer", + "session", + "refresh_token", + "access_token", +) + + +# GUID regex — covers UUID v1-v8 and the BC company-id shape. +_GUID_RE = re.compile( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" +) + + +# ─── Layer 1: key-based dict redaction ────────────────────────────── + + +def redact_structured( + value: Any, + *, + location_path: str = "", + extra_keys: tuple[str, ...] = (), +) -> tuple[Any, tuple[RedactionRecord, ...]]: + """Layer 1 — replace dict values whose key looks sensitive. + + Returns the cleaned copy AND an audit trail. A record is emitted + per leaf key that matched a sensitive token; we don't try to walk + nested keys here because the inner :func:`_audit_redact` already + walks recursively and replaces matched values with ``REDACTED``. + Counting matches happens via a parallel walk so the audit trail + captures each location. + """ + keys = _DEFAULT_AUDIT_KEYS + tuple(k for k in extra_keys if k) + cleaned = _audit_redact(value, keys) + records: list[RedactionRecord] = [] + _collect_key_redactions(value, keys, location_path, records) + return cleaned, tuple(records) + + +def _collect_key_redactions( + value: Any, + needles: tuple[str, ...], + location_path: str, + records: list[RedactionRecord], +) -> None: + """Walk in parallel to the audit redactor; emit a record per hit. + + Audit redactor only returns the cleaned tree, so we recompute hits + here. Keeping the two walks in lock-step is cheap (one pass over + the data) and the audit trail is then complete. + """ + if isinstance(value, dict): + for k, v in value.items(): + child_loc = f"{location_path}.{k}" if location_path else str(k) + if isinstance(k, str) and _key_matches(k, needles): + if isinstance(v, str): + length = len(v) + else: + length = len(str(v)) if v is not None else 0 + records.append( + RedactionRecord( + rule_id=RULE_AUDIT_KEY, + location_path=child_loc, + redacted_length=length, + ) + ) + continue # don't recurse — child already redacted. + _collect_key_redactions(v, needles, child_loc, records) + elif isinstance(value, (list, tuple)): + for i, item in enumerate(value): + child_loc = f"{location_path}[{i}]" + _collect_key_redactions(item, needles, child_loc, records) + + +def _key_matches(key: str, needles: tuple[str, ...]) -> bool: + lowered = key.lower() + return any(n.lower() in lowered for n in needles) + + +# ─── Layer 2: pattern-based text scrub ────────────────────────────── + + +def redact_text( + text: str, *, location_path: str = "" +) -> tuple[str, tuple[RedactionRecord, ...]]: + """Layer 2 — replace token-shaped substrings in free text. + + Reuses the telemetry secret regex unchanged so a new pattern added + there shows up everywhere automatically. The returned audit trail + contains one record per match. + """ + if not text: + return text, () + records: list[RedactionRecord] = [] + + def _sub(m: re.Match[str]) -> str: + records.append( + RedactionRecord( + rule_id=RULE_TELEMETRY_PATTERN, + location_path=location_path, + redacted_length=len(m.group(0)), + ) + ) + return "[REDACTED]" + + cleaned = _SECRET_RE.sub(_sub, text) + return cleaned, tuple(records) + + +# ─── Layer 3: URL / GUID / attachment scrub ───────────────────────── + + +def redact_url( + url: str, + *, + location_path: str = "", + redact_guids: bool = False, +) -> tuple[str, tuple[RedactionRecord, ...]]: + """Strip query parameters, optionally replace GUIDs in the path. + + Query strings can carry SAS tokens, access codes, signed download + URLs. We rebuild the URL with each param's value replaced by + ``[REDACTED]`` — preserving the *keys* so an agent can still + reason about whether a request had an ``$expand``, ``$filter``, + etc., without leaking the value. + + GUID redaction is policy-gated: BC company ids and record systemIds + are GUIDs, and sometimes the user *wants* the model to see them + (e.g. "why is company X failing?"). Off by default. + """ + if not url: + return url, () + records: list[RedactionRecord] = [] + try: + parts = urlsplit(url) + except ValueError: + return url, () + + new_query = parts.query + if parts.query: + pairs = parse_qsl(parts.query, keep_blank_values=True) + if pairs: + rebuilt = [] + for k, v in pairs: + if v: + records.append( + RedactionRecord( + rule_id=RULE_URL_QUERY, + location_path=f"{location_path}?{k}", + redacted_length=len(v), + ) + ) + rebuilt.append((k, "[REDACTED]")) + else: + rebuilt.append((k, "")) + new_query = urlencode(rebuilt) + + new_path = parts.path + if redact_guids and new_path: + def _guid_sub(m: re.Match[str]) -> str: + records.append( + RedactionRecord( + rule_id=RULE_GUID, + location_path=f"{location_path}.path", + redacted_length=len(m.group(0)), + ) + ) + return "[GUID]" + + new_path = _GUID_RE.sub(_guid_sub, new_path) + + cleaned = urlunsplit( + (parts.scheme, parts.netloc, new_path, new_query, parts.fragment) + ) + return cleaned, tuple(records) + + +def redact_guids_in_text( + text: str, *, location_path: str = "" +) -> tuple[str, tuple[RedactionRecord, ...]]: + """Layer 3 (text variant) — replace GUIDs in arbitrary strings.""" + if not text: + return text, () + records: list[RedactionRecord] = [] + + def _sub(m: re.Match[str]) -> str: + records.append( + RedactionRecord( + rule_id=RULE_GUID, + location_path=location_path, + redacted_length=len(m.group(0)), + ) + ) + return "[GUID]" + + cleaned = _GUID_RE.sub(_sub, text) + return cleaned, tuple(records) + + +# ─── Layered orchestrator ─────────────────────────────────────────── + + +def apply_layered_redaction( + value: Any, + *, + location_path: str = "", + redact_guids: bool = False, +) -> tuple[Any, tuple[RedactionRecord, ...]]: + """Run all three layers in order over a value. + + Layer order matters: structured (key) → pattern (text) → URL/GUID. + A token under a sensitive key is caught by layer 1 even if it + looks "normal"; a token in free text is caught by layer 2; URL + queries and GUIDs are last because they're the cheapest, most + surgical step. + + For attachments and free strings the caller passes them directly + to :func:`redact_text` / :func:`redact_url`; this function is the + "I have a heterogeneous payload" convenience. + """ + records: list[RedactionRecord] = [] + + # Layer 1 + cleaned, layer1 = redact_structured(value, location_path=location_path) + records.extend(layer1) + + # Layer 2 — walk strings in the cleaned tree. + cleaned, layer2 = _walk_strings( + cleaned, location_path, redact_guids=redact_guids + ) + records.extend(layer2) + + return cleaned, tuple(records) + + +def _walk_strings( + value: Any, location_path: str, *, redact_guids: bool +) -> tuple[Any, tuple[RedactionRecord, ...]]: + records: list[RedactionRecord] = [] + if isinstance(value, dict): + out_d: dict[Any, Any] = {} + for k, v in value.items(): + child_loc = f"{location_path}.{k}" if location_path else str(k) + new_v, recs = _walk_strings( + v, child_loc, redact_guids=redact_guids + ) + records.extend(recs) + out_d[k] = new_v + return out_d, tuple(records) + if isinstance(value, list): + out_l: list[Any] = [] + for i, item in enumerate(value): + child_loc = f"{location_path}[{i}]" + new_v, recs = _walk_strings( + item, child_loc, redact_guids=redact_guids + ) + records.extend(recs) + out_l.append(new_v) + return out_l, tuple(records) + if isinstance(value, tuple): + out_t: list[Any] = [] + for i, item in enumerate(value): + child_loc = f"{location_path}[{i}]" + new_v, recs = _walk_strings( + item, child_loc, redact_guids=redact_guids + ) + records.extend(recs) + out_t.append(new_v) + return tuple(out_t), tuple(records) + if isinstance(value, str): + cleaned = value + if value == REDACTED: + return cleaned, () + cleaned, recs1 = redact_text(cleaned, location_path=location_path) + records.extend(recs1) + if redact_guids: + cleaned, recs2 = redact_guids_in_text( + cleaned, location_path=location_path + ) + records.extend(recs2) + return cleaned, tuple(records) + return value, () + + +# ─── Attachment scanning ──────────────────────────────────────────── + + +def scan_attachment( + content: str, + *, + label: str, + max_bytes: int, + redact_guids: bool = False, +) -> tuple[str, int, tuple[RedactionRecord, ...]]: + """Redact + truncate an attachment to fit ``max_bytes``. + + Returns ``(cleaned_content, included_bytes, records)``. Truncation + is byte-budgeted on the post-redaction text; if it has to cut, a + ``context:truncate`` record lands in the trail so the operator + can see *why* the model didn't get the full file. + """ + records: list[RedactionRecord] = [] + cleaned, layer2 = redact_text(content, location_path=f"attachment:{label}") + records.extend(layer2) + if redact_guids: + cleaned, layer3 = redact_guids_in_text( + cleaned, location_path=f"attachment:{label}" + ) + records.extend(layer3) + encoded = cleaned.encode("utf-8") + if len(encoded) > max_bytes: + truncated_bytes = encoded[:max_bytes] + records.append( + RedactionRecord( + rule_id=RULE_TRUNCATE, + location_path=f"attachment:{label}", + redacted_length=len(encoded) - max_bytes, + ) + ) + # decode best-effort; might lose the last char if it's + # multi-byte. Acceptable for free text. + cleaned = truncated_bytes.decode("utf-8", errors="ignore") + return cleaned, len(cleaned.encode("utf-8")), tuple(records) + + +__all__ = [ + "REDACT_RULES", + "RULE_AUDIT_KEY", + "RULE_GUID", + "RULE_TELEMETRY_PATTERN", + "RULE_TRUNCATE", + "RULE_URL_QUERY", + "apply_layered_redaction", + "redact_guids_in_text", + "redact_structured", + "redact_text", + "redact_url", + "scan_attachment", +] From 962aa15a92ac761bc76ce2a961bebfbf8f83aaae Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 16:41:54 -0500 Subject: [PATCH 02/17] feat(context): wire last-error capture + http-tail bootstrap into CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContextConfig added to bcli.config._model with knobs: tail, redact_company_ids, attachment_max_bytes. Backwards-compatible via Pydantic default_factory. - Central BCLIError handler in bcli_cli.app calls bcli.context.capture_last_error so every error exits leaves a redacted JSON record at ~/.config/bcli/last-error.json (and the debug sidecar when --debug was active). - Root callback bootstraps the http-tail handler only when [context] tail = true. Default off — read-only home dirs, CI, and ephemeral containers see zero overhead. All hooks are try/except-wrapped so a misconfigured [context] section never blocks the CLI. --- src/bcli/config/_model.py | 28 +++++++++++++++++++++++++ src/bcli_cli/app.py | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/bcli/config/_model.py b/src/bcli/config/_model.py index e5eff88..b41a38f 100644 --- a/src/bcli/config/_model.py +++ b/src/bcli/config/_model.py @@ -242,6 +242,33 @@ class ExtractConfig(BaseModel): model_config = {"extra": "allow", "protected_namespaces": ()} +class ContextConfig(BaseModel): + """LLM-context layer settings — drives :mod:`bcli.context`. + + The context layer feeds future LLM-driven features (``bcli ask``, + ``bcli agent``) with a typed, redacted bundle of last-error + recent + HTTP + profile + describe excerpt. All knobs default to the most + private setting; users opt in per knob. + + * ``tail`` — when ``True`` a rotating NDJSON handler attaches to the + ``bcli.http`` logger so the most recent ~200 requests land on disk + for ``bcli ask`` to read. + * ``redact_company_ids`` — when ``True`` the bundler scrubs GUID- + shaped substrings (BC company ids, record systemIds) before + shipping context to a model. Useful when the operator wants the + help but not the identifiers. + * ``attachment_max_bytes`` — per-attachment byte cap applied + *after* redaction. Default 256 KiB matches the conservative side + of LLM context cost vs information density. + """ + + tail: bool = False + redact_company_ids: bool = False + attachment_max_bytes: int = Field(default=256 * 1024, ge=1024) + + model_config = {"extra": "allow"} + + class BCConfig(BaseModel): """Top-level configuration.""" @@ -250,6 +277,7 @@ class BCConfig(BaseModel): telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig) audit: AuditConfig = Field(default_factory=AuditConfig) extract: ExtractConfig = Field(default_factory=ExtractConfig) + context: ContextConfig = Field(default_factory=ContextConfig) model_config = {"extra": "allow"} diff --git a/src/bcli_cli/app.py b/src/bcli_cli/app.py index a55db5c..404a141 100644 --- a/src/bcli_cli/app.py +++ b/src/bcli_cli/app.py @@ -91,6 +91,30 @@ def _root_callback( state.quiet = quiet or resolved_format in ("json", "csv", "ndjson", "raw") _bootstrap_telemetry() + _bootstrap_context_tail() + + +def _bootstrap_context_tail() -> None: + """Enable the ``bcli.http`` rolling tail when ``[context] tail = true``. + + Off by default — opt-in via config. Wrapped in try/except so a + misconfigured ``[context]`` section never blocks the CLI. + """ + try: + cfg = state._config + # Avoid forcing a config load just for this; if no profile + # has been touched yet, leave the tail alone. + if cfg is None: + return + ctx_cfg = getattr(cfg, "context", None) + if ctx_cfg is None or not getattr(ctx_cfg, "tail", False): + return + from bcli.context import enable_http_tail + enable_http_tail() + except Exception: # noqa: BLE001 + logging.getLogger("bcli.context").debug( + "context tail bootstrap failed", exc_info=True + ) def _bootstrap_telemetry() -> None: @@ -289,6 +313,26 @@ def main() -> None: except Exception: # noqa: BLE001 available = None + # Capture last-error for the context layer (Part 0 / R6). No + # tracebacks unless --debug was active; even then the debug + # sidecar lands in last-error-debug.json (mode 0600). + try: + from bcli.context import capture_last_error + capture_last_error( + exc=exc, + command=_invocation_command, + profile=state.profile_name or "", + environment=state.env_override or "", + company=state.company_override or "", + debug=bool(state.debug), + ) + except Exception: # noqa: BLE001 + # Context capture must never block the user's error + # message. Already in the error path — keep going. + logging.getLogger("bcli.context").debug( + "last-error capture failed", exc_info=True + ) + msg = format_error_for_cli( exc, active_profile=active_profile, available_profiles=available, ) From 5410cadf386ec024f6036014e2ee9401d7afcbfa Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 16:42:07 -0500 Subject: [PATCH 03/17] test(context): cover dataclass round-trip, 3-layer redaction, audit trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40 tests covering Part 0 surface: - test_protocol.py — ContextBundle round-trips through JSON; to_prompt_text renders priority sections; tracebacks excluded unless policy.include_debug. - test_redact.py — adversarial layered redaction: nested JSON with Bearer + JWT + client_secret; URL-encoded tokens; base64-wrapped JWTs; GUID policy gate; attachment truncation. Audit trail's rule_id set is asserted frozen + complete; redacted values never leak via repr. - test_last_error.py — capture without traceback by default, debug sidecar at mode 0600 only when --debug active, BC-message token pattern redaction, read-only config dir safety. - test_http_tail.py — handler idempotent, NDJSON round-trip, bad lines skipped, RotatingFileHandler size cap, limit slicing. - test_bundle.py — minimal-question bundle, last-error read from disk, budget-driven describe-excerpt drop, attachment scan + cap, layered audit trail completeness, --no-context policy path. --- tests/test_context/__init__.py | 0 tests/test_context/test_bundle.py | 167 ++++++++++++++++++++++ tests/test_context/test_http_tail.py | 134 ++++++++++++++++++ tests/test_context/test_last_error.py | 135 ++++++++++++++++++ tests/test_context/test_protocol.py | 180 ++++++++++++++++++++++++ tests/test_context/test_redact.py | 191 ++++++++++++++++++++++++++ 6 files changed, 807 insertions(+) create mode 100644 tests/test_context/__init__.py create mode 100644 tests/test_context/test_bundle.py create mode 100644 tests/test_context/test_http_tail.py create mode 100644 tests/test_context/test_last_error.py create mode 100644 tests/test_context/test_protocol.py create mode 100644 tests/test_context/test_redact.py diff --git a/tests/test_context/__init__.py b/tests/test_context/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_context/test_bundle.py b/tests/test_context/test_bundle.py new file mode 100644 index 0000000..e3624bf --- /dev/null +++ b/tests/test_context/test_bundle.py @@ -0,0 +1,167 @@ +"""``bcli.context.build_bundle`` — composition, priority truncation, audit.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from bcli.context import ( + BundlePolicy, + HttpEvent, + LastErrorRecord, + ProfileSnapshot, + TokenBudget, + build_bundle, + capture_last_error, +) +from bcli.errors import ValidationError + + +def test_minimal_bundle_just_a_question(tmp_path: Path) -> None: + bundle = build_bundle( + question="why 400?", + config_dir=tmp_path, # no last-error file there yet + ) + assert bundle.question == "why 400?" + assert bundle.last_error is None + assert bundle.sources[0].kind == "question" + assert bundle.budget.actual_tokens > 0 + + +def test_bundle_reads_last_error_from_disk(tmp_path: Path) -> None: + exc = ValidationError( + "bad filter", + status_code=400, + bc_message="Field 'junk' not in 'vendors'", + correlation_id="corr-1", + ) + capture_last_error( + exc=exc, + command="get vendors --filter junk", + profile="prod", + config_dir=tmp_path, + ) + + bundle = build_bundle( + question="what happened?", + config_dir=tmp_path, + ) + assert bundle.last_error is not None + assert bundle.last_error.error_class == "ValidationError" + kinds = {s.kind for s in bundle.sources} + assert "last_error" in kinds + + +def test_profile_snapshot_lands_when_provided() -> None: + bundle = build_bundle( + profile=ProfileSnapshot( + name="prod", environment="Production", company="Contoso" + ), + ) + kinds = {s.kind for s in bundle.sources} + assert "profile_snapshot" in kinds + + +def test_http_events_get_url_redacted_through_pipeline() -> None: + events = ( + HttpEvent( + timestamp="t", + method="GET", + url="https://example/api?token=very-secret", + status=200, + ), + ) + bundle = build_bundle( + recent_http=events, + policy=BundlePolicy(include_http_tail=True), + ) + assert bundle.recent_http + assert "very-secret" not in bundle.recent_http[0].url + # Redaction recorded. + assert any(r.rule_id == "context:url_query" for r in bundle.redactions) + + +def test_describe_excerpt_dropped_when_over_budget() -> None: + big_describe = "x" * 10_000 + # Budget so tight we can fit the question but not the describe. + bundle = build_bundle( + question="q", + describe_excerpt=big_describe, + budget=TokenBudget(max_tokens=10), # 40 chars cap + policy=BundlePolicy(include_describe=True), + ) + assert bundle.describe_excerpt == "" + assert bundle.budget.truncated + + +def test_attachments_redacted_and_truncated() -> None: + secret = "Bearer eyJabc.def.ghi-jwt-token" + big = f"{secret}\n" + ("x" * 4096) + bundle = build_bundle( + raw_attachments=(("log.txt", big),), + policy=BundlePolicy(attachment_max_bytes=512), + ) + assert bundle.attachments + att = bundle.attachments[0] + assert "Bearer eyJabc.def.ghi" not in att.content + assert att.included_bytes <= 512 + # Truncation + secret pattern both recorded. + rule_ids = {r.rule_id for r in bundle.redactions} + assert "context:truncate" in rule_ids + + +def test_audit_trail_complete_for_layered_redactions() -> None: + # Inject a secret URL into recent_http and a key-bearing attachment. + events = ( + HttpEvent( + method="GET", + url="https://example/api?access_token=abcdef", + status=200, + timestamp="t", + ), + ) + bundle = build_bundle( + question="q", + recent_http=events, + raw_attachments=( + ("creds.json", '{"client_secret": "very-secret-value"}'), + ), + ) + rule_ids = {r.rule_id for r in bundle.redactions} + # URL query stripper + telemetry pattern (jwt-like) might both + # contribute. At minimum we must have the URL strip. + assert "context:url_query" in rule_ids + + +def test_no_context_policy_path() -> None: + # Mimic `bcli ask --no-context`: caller suppresses describe + tail + # via policy and provides nothing else. + bundle = build_bundle( + question="just answer", + policy=BundlePolicy( + include_describe=False, + include_http_tail=False, + include_bodies=False, + ), + ) + kinds = {s.kind for s in bundle.sources} + assert kinds == {"question"} + assert bundle.recent_http == () + assert bundle.describe_excerpt == "" + + +def test_bundle_to_dict_is_json_serializable() -> None: + bundle = build_bundle( + question="q", + profile=ProfileSnapshot(name="prod"), + last_error=LastErrorRecord( + timestamp="t", + command="x", + error_class="ValidationError", + exit_code=2, + ), + ) + payload = bundle.to_dict() + text = json.dumps(payload) + again = json.loads(text) + assert again["question"] == "q" diff --git a/tests/test_context/test_http_tail.py b/tests/test_context/test_http_tail.py new file mode 100644 index 0000000..f79b852 --- /dev/null +++ b/tests/test_context/test_http_tail.py @@ -0,0 +1,134 @@ +"""``bcli.http`` rolling tail: enable, write, read, rotate.""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path + +from bcli.context import enable_http_tail, http_tail_path, read_http_tail + + +def _clear_http_tail_handlers() -> None: + """Remove any tail handler attached by a prior test.""" + log = logging.getLogger("bcli.http") + for h in list(log.handlers): + if getattr(h, "_bcli_context_tail", False): + log.removeHandler(h) + try: + h.close() + except Exception: # noqa: BLE001 + pass + + +def test_enable_returns_true_and_attaches_handler(tmp_path: Path) -> None: + _clear_http_tail_handlers() + ok = enable_http_tail(config_dir=tmp_path) + assert ok is True + log = logging.getLogger("bcli.http") + tail_handlers = [h for h in log.handlers if getattr(h, "_bcli_context_tail", False)] + assert len(tail_handlers) == 1 + _clear_http_tail_handlers() + + +def test_enable_is_idempotent(tmp_path: Path) -> None: + _clear_http_tail_handlers() + assert enable_http_tail(config_dir=tmp_path) is True + assert enable_http_tail(config_dir=tmp_path) is True + log = logging.getLogger("bcli.http") + tail_handlers = [h for h in log.handlers if getattr(h, "_bcli_context_tail", False)] + assert len(tail_handlers) == 1 + _clear_http_tail_handlers() + + +def test_events_round_trip(tmp_path: Path) -> None: + _clear_http_tail_handlers() + enable_http_tail(config_dir=tmp_path) + log = logging.getLogger("bcli.http") + sample = { + "timestamp": "2026-05-22T10:00:00+00:00", + "method": "GET", + "url": "https://example/api?token=topsecret", + "status": 200, + "latency_ms": 42.0, + "correlation_id": "corr-1", + "endpoint": "vendors", + "retry_count": 0, + } + log.info(json.dumps(sample)) + # Make sure handler flushes. + for h in log.handlers: + h.flush() + + events = read_http_tail(config_dir=tmp_path) + assert len(events) == 1 + ev = events[0] + assert ev.method == "GET" + assert ev.status == 200 + # URL redacted on read. + assert "topsecret" not in ev.url + _clear_http_tail_handlers() + + +def test_read_skips_bad_lines(tmp_path: Path) -> None: + p = http_tail_path(tmp_path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text( + '{"method":"GET","url":"https://ok","status":200}\n' + "not-json garbage\n" + '{"method":"POST","url":"https://ok2","status":201}\n', + encoding="utf-8", + ) + events = read_http_tail(config_dir=tmp_path) + assert len(events) == 2 + assert events[0].method == "GET" + assert events[1].method == "POST" + + +def test_size_cap_rotates_old_lines(tmp_path: Path) -> None: + _clear_http_tail_handlers() + # 2 KB cap so the rollover fires quickly with our payload size. + enable_http_tail(config_dir=tmp_path, max_bytes=2048, backup_count=1) + log = logging.getLogger("bcli.http") + line = { + "method": "GET", + "url": "https://example/api", + "status": 200, + "latency_ms": 1.0, + } + for i in range(200): + line["correlation_id"] = f"corr-{i}" + log.info(json.dumps(line)) + for h in log.handlers: + h.flush() + + main = http_tail_path(tmp_path) + backup = main.with_suffix(main.suffix + ".1") + # Either rollover happened (backup exists) or main file stayed + # under the cap (some platforms truncate at slightly different + # bytes); the invariant the test cares about is the main file + # never exceeds (cap + one record) so memory stays bounded. + assert main.is_file() + if backup.exists(): + assert os.path.getsize(main) < 4096 + _clear_http_tail_handlers() + + +def test_read_when_no_file_returns_empty(tmp_path: Path) -> None: + events = read_http_tail(config_dir=tmp_path) + assert events == () + + +def test_limit_keeps_newest(tmp_path: Path) -> None: + p = http_tail_path(tmp_path) + p.parent.mkdir(parents=True, exist_ok=True) + lines = [ + json.dumps({"method": "GET", "url": f"https://x/{i}", "status": 200}) + for i in range(10) + ] + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + events = read_http_tail(config_dir=tmp_path, limit=3) + assert len(events) == 3 + # Newest last. + assert events[-1].url.endswith("/9") diff --git a/tests/test_context/test_last_error.py b/tests/test_context/test_last_error.py new file mode 100644 index 0000000..f961206 --- /dev/null +++ b/tests/test_context/test_last_error.py @@ -0,0 +1,135 @@ +"""Last-error capture: write/read round-trip, no traceback by default.""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path + +import pytest + +from bcli.context import capture_last_error, read_last_error +from bcli.errors import ValidationError + + +def _make_exc() -> ValidationError: + exc = ValidationError( + "bad filter", + status_code=400, + bc_message="Field 'junk' is not part of 'vendors'", + correlation_id="abc-123", + ) + # Attach attrs that the capture helper looks up via getattr. + exc.url = "https://example/api?token=secret" # type: ignore[attr-defined] + exc.method = "GET" # type: ignore[attr-defined] + exc.endpoint = "vendors" # type: ignore[attr-defined] + exc.hint = "Run bcli endpoint fields vendors" # type: ignore[attr-defined] + return exc + + +def test_capture_writes_file_without_traceback(tmp_path: Path) -> None: + exc = _make_exc() + path = capture_last_error( + exc=exc, + command="get vendors", + profile="production", + environment="Production", + company="Contoso", + debug=False, + config_dir=tmp_path, + ) + assert path is not None + assert path.is_file() + raw = json.loads(path.read_text()) + assert raw["error_class"] == "ValidationError" + assert raw["status"] == 400 + assert raw["bc_message"].startswith("Field 'junk'") + # No traceback in the default file. + assert raw["traceback_excerpt"] == "" + # URL query stripped (urlencode may produce %5BREDACTED%5D). + assert "secret" not in raw["url"] + assert "REDACTED" in raw["url"] + # Debug sidecar should NOT exist when debug=False. + debug_path = tmp_path / "last-error-debug.json" + assert not debug_path.exists() + + +def test_capture_writes_debug_sidecar_when_debug_active(tmp_path: Path) -> None: + try: + raise _make_exc() + except ValidationError as e: + path = capture_last_error( + exc=e, + command="get vendors", + profile="production", + debug=True, + config_dir=tmp_path, + ) + assert path is not None + debug_path = tmp_path / "last-error-debug.json" + assert debug_path.is_file() + raw = json.loads(debug_path.read_text()) + assert raw["traceback_excerpt"] + assert "Traceback" in raw["traceback_excerpt"] + # mode 0600 — owner-only. + mode = stat.S_IMODE(os.stat(debug_path).st_mode) + assert mode == 0o600, f"expected 0o600 got {oct(mode)}" + + +def test_read_returns_none_when_no_file(tmp_path: Path) -> None: + assert read_last_error(config_dir=tmp_path) is None + + +def test_read_returns_typed_record_round_trip(tmp_path: Path) -> None: + exc = _make_exc() + capture_last_error( + exc=exc, + command="get vendors", + profile="production", + environment="Production", + company="Contoso", + debug=False, + config_dir=tmp_path, + ) + record = read_last_error(config_dir=tmp_path) + assert record is not None + assert record.error_class == "ValidationError" + assert record.status == 400 + assert record.command == "get vendors" + assert record.bc_message.startswith("Field 'junk'") + assert record.traceback_excerpt == "" + + +def test_capture_is_safe_when_config_dir_unwritable(tmp_path: Path, monkeypatch) -> None: + # Point at a path under a read-only parent; capture must return + # None silently, not crash. + ro = tmp_path / "ro" + ro.mkdir() + ro.chmod(0o500) + try: + # Make config_dir a subdir we know mkdir cannot create. + target = ro / "bcli" + # On some systems root could still write — protect with a chmod check. + if os.access(target.parent, os.W_OK): + pytest.skip("can't simulate read-only parent here") + path = capture_last_error( + exc=_make_exc(), + command="x", + config_dir=target, + ) + assert path is None + finally: + ro.chmod(0o700) + + +def test_redacts_bc_message_token_pattern(tmp_path: Path) -> None: + exc = ValidationError( + "x", + bc_message="Inner err: Bearer eyJabc.def.ghi for tenant", + ) + capture_last_error(exc=exc, command="x", config_dir=tmp_path) + rec = read_last_error(config_dir=tmp_path) + assert rec is not None + assert "Bearer eyJabc.def.ghi" not in rec.bc_message + assert "[REDACTED]" in rec.bc_message diff --git a/tests/test_context/test_protocol.py b/tests/test_context/test_protocol.py new file mode 100644 index 0000000..e153d87 --- /dev/null +++ b/tests/test_context/test_protocol.py @@ -0,0 +1,180 @@ +"""Dataclass round-trip + prompt-text rendering for ContextBundle.""" + +from __future__ import annotations + +import json + +from bcli.context import ( + Attachment, + BundlePolicy, + BundleSource, + ContextBundle, + HttpEvent, + LastErrorRecord, + ProfileSnapshot, + RedactionRecord, + TokenBudget, +) + + +def test_default_bundle_round_trips() -> None: + bundle = ContextBundle() + payload = bundle.to_dict() + # to_dict() must be JSON serializable. + text = json.dumps(payload) + again = json.loads(text) + assert again["schema_version"] == bundle.schema_version + assert again["sources"] == [] + assert again["redactions"] == [] + assert again["last_error"] is None + + +def test_filled_bundle_to_dict_preserves_fields() -> None: + bundle = ContextBundle( + generated_at="2026-05-22T10:00:00+00:00", + question="why 400?", + budget=TokenBudget(max_tokens=8000, actual_tokens=42, truncated=False), + policy=BundlePolicy(include_bodies=True, include_describe=True), + sources=( + BundleSource(kind="question", label="user-question", included_bytes=7), + ), + redactions=( + RedactionRecord( + rule_id="audit:key", + location_path="root.headers.Authorization", + redacted_length=64, + ), + ), + profile_snapshot=ProfileSnapshot( + name="production", + environment="Production", + company="Contoso", + auth_method="client_credentials", + disable_writes=True, + ), + registry_snapshot_hash="sha256:abc", + describe_excerpt='{"version": "0.4.0"}', + recent_http=( + HttpEvent( + timestamp="2026-05-22T09:59:00+00:00", + method="GET", + url="https://api.businesscentral.dynamics.com/.../vendors", + status=400, + latency_ms=120.0, + correlation_id="corr-123", + endpoint="vendors", + retry_count=0, + ), + ), + last_error=LastErrorRecord( + timestamp="2026-05-22T09:59:01+00:00", + command="get vendors --filter junk", + error_class="ValidationError", + exit_code=2, + status=400, + profile="production", + environment="Production", + company="Contoso", + url="https://example/api", + method="GET", + correlation_id="corr-123", + endpoint="vendors", + hint="check OData syntax", + bc_message="bad filter", + traceback_excerpt="", + ), + attachments=( + Attachment( + label="batch.yaml", + path="/tmp/batch.yaml", + content="steps: []\n", + original_bytes=10, + included_bytes=10, + ), + ), + ) + + payload = bundle.to_dict() + again = json.loads(json.dumps(payload)) + assert again["question"] == "why 400?" + assert again["budget"]["max_tokens"] == 8000 + assert again["profile_snapshot"]["name"] == "production" + assert again["last_error"]["error_class"] == "ValidationError" + assert again["redactions"][0]["rule_id"] == "audit:key" + assert again["attachments"][0]["label"] == "batch.yaml" + + +def test_to_prompt_text_includes_priority_sections() -> None: + bundle = ContextBundle( + question="why?", + profile_snapshot=ProfileSnapshot(name="prod", environment="P"), + last_error=LastErrorRecord( + timestamp="t", + command="bcli get x", + error_class="ValidationError", + exit_code=2, + status=400, + bc_message="bad filter", + ), + recent_http=( + HttpEvent( + timestamp="t", + method="GET", + url="https://example/api", + status=400, + ), + ), + redactions=( + RedactionRecord( + rule_id="audit:key", + location_path="x.Authorization", + redacted_length=64, + ), + ), + ) + text = bundle.to_prompt_text() + assert "## Question" in text + assert "why?" in text + assert "## Last error" in text + assert "ValidationError" in text + assert "## Profile" in text + assert "prod" in text + assert "## Recent HTTP" in text + assert "## Redactions applied" in text + assert "`audit:key`: 1 occurrence(s)" in text + + +def test_prompt_text_omits_traceback_unless_policy_allows() -> None: + le = LastErrorRecord( + timestamp="t", + command="x", + error_class="ServerError", + exit_code=4, + traceback_excerpt="Traceback (most recent call last):\n ...", + ) + bundle_no_debug = ContextBundle(last_error=le) + text = bundle_no_debug.to_prompt_text() + assert "Traceback" not in text + + bundle_debug = ContextBundle( + last_error=le, + policy=BundlePolicy(include_debug=True), + ) + text_debug = bundle_debug.to_prompt_text() + assert "Traceback" in text_debug + + +def test_truncated_bundle_advertises_truncation() -> None: + bundle = ContextBundle( + question="q", + budget=TokenBudget(max_tokens=100, actual_tokens=200, truncated=True), + ) + text = bundle.to_prompt_text() + assert "truncated" in text.lower() + + +def test_empty_bundle_prompt_is_empty_safe() -> None: + bundle = ContextBundle() + text = bundle.to_prompt_text() + # Should be at most a trailing newline — no exceptions, no sections. + assert text.strip() == "" diff --git a/tests/test_context/test_redact.py b/tests/test_context/test_redact.py new file mode 100644 index 0000000..eee6254 --- /dev/null +++ b/tests/test_context/test_redact.py @@ -0,0 +1,191 @@ +"""Three-layer redaction covered by adversarial inputs. + +Asserts every redaction lands in the audit trail with a stable +``rule_id`` — a regression that silently drops a value lands as a +missing :class:`RedactionRecord`, not as a missing assertion. +""" + +from __future__ import annotations + +from bcli.context._redact import ( + REDACT_RULES, + RULE_AUDIT_KEY, + RULE_GUID, + RULE_TELEMETRY_PATTERN, + RULE_TRUNCATE, + RULE_URL_QUERY, + apply_layered_redaction, + redact_structured, + redact_text, + redact_url, + scan_attachment, +) + + +def test_layer1_strips_sensitive_keys_in_nested_json() -> None: + payload = { + "outer": { + "Authorization": "Bearer eyJqwt.body.sig", + "data": { + "client_secret": "supersecret", + "ok": "value", + }, + "list": [ + {"token": "abc", "value": "ok"}, + ], + } + } + cleaned, records = redact_structured(payload) + # Three sensitive keys redacted: Authorization, client_secret, token. + rule_ids = {r.rule_id for r in records} + assert rule_ids == {RULE_AUDIT_KEY} + locations = {r.location_path for r in records} + assert "outer.Authorization" in locations + assert "outer.data.client_secret" in locations + assert any("token" in loc for loc in locations) + # Cleaned output replaces the values. + assert cleaned["outer"]["Authorization"] != "Bearer eyJqwt.body.sig" + assert cleaned["outer"]["data"]["ok"] == "value" + + +def test_layer2_redacts_token_patterns_in_text() -> None: + text = "auth header was Bearer abc.def.ghi and key sk_live_ABCD1234" + cleaned, records = redact_text(text) + assert "Bearer" not in cleaned or "[REDACTED]" in cleaned + assert "sk_live_" not in cleaned + rule_ids = {r.rule_id for r in records} + assert rule_ids == {RULE_TELEMETRY_PATTERN} + assert len(records) >= 2 + + +def test_layer2_catches_jwt_shape() -> None: + # JWT regex requires 20+ chars after "ey" — make the header longer. + jwt = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIn0.SIG_ABCDEF" + ) + cleaned, records = redact_text(f"token={jwt}") + assert jwt not in cleaned + assert records # at least one record + + +def test_layer3_strips_url_query_params() -> None: + url = ( + "https://api.businesscentral.dynamics.com/v2.0/" + "tenant/sandbox/api/v2.0/vendors" + "?$filter=foo eq 'bar'&access_token=very-secret-token" + ) + cleaned, records = redact_url(url) + assert "very-secret-token" not in cleaned + # urlencode percent-encodes ``$`` -> ``%24`` and the bracketed + # sentinel -> ``%5BREDACTED%5D``. Either form is acceptable; the + # invariant is "key preserved, value gone." + assert "filter" in cleaned and "%24filter" in cleaned + assert "REDACTED" in cleaned + rule_ids = {r.rule_id for r in records} + assert rule_ids == {RULE_URL_QUERY} + # One record per non-empty query param value. + assert len(records) == 2 + + +def test_layer3_guid_redaction_off_by_default() -> None: + url = ( + "https://api.businesscentral.dynamics.com/v2.0/" + "12345678-1234-1234-1234-123456789abc/sandbox/vendors" + ) + cleaned, records = redact_url(url, redact_guids=False) + assert "12345678-1234-1234-1234-123456789abc" in cleaned + assert len(records) == 0 + + cleaned_on, records_on = redact_url(url, redact_guids=True) + assert "12345678-1234-1234-1234-123456789abc" not in cleaned_on + assert "[GUID]" in cleaned_on + assert any(r.rule_id == RULE_GUID for r in records_on) + + +def test_apply_layered_runs_layers_1_and_2() -> None: + payload = { + "headers": {"Authorization": "Bearer secret-token-abc"}, + "body": "free text containing eyJabc.def.ghi-jwt-shape", + "nested": [{"token": "x"}, "Bearer aaa.bbb.ccc"], + } + cleaned, records = apply_layered_redaction(payload) + rule_ids = {r.rule_id for r in records} + assert RULE_AUDIT_KEY in rule_ids + # We had a bearer in body + a jwt-shape pattern, layer 2 should hit. + assert RULE_TELEMETRY_PATTERN in rule_ids + + +def test_url_encoded_token_is_caught() -> None: + # URL-encoded "Bearer abc.def.ghi" inside a query value is caught + # by the URL query stripper, not layer 2 (good — defense in depth). + url = ( + "https://example/api?cb=" + "https%3A%2F%2Fexample.com%2Fauth%3Ftoken%3DBearer%2520abc.def.ghi" + ) + cleaned, records = redact_url(url) + assert "Bearer" not in cleaned + assert records + + +def test_attachment_truncation_records_audit_entry() -> None: + content = "x" * 4096 + cleaned, included_bytes, records = scan_attachment( + content, label="big.log", max_bytes=1024, redact_guids=False + ) + assert included_bytes <= 1024 + # We're well under the URL/JWT/Authorization detectors, so the + # only record should be the truncate. + rule_ids = {r.rule_id for r in records} + assert RULE_TRUNCATE in rule_ids + + +def test_attachment_runs_text_and_optional_guid_layers() -> None: + content = "header: Bearer eyJabc.def.ghi\nGUID: 12345678-1234-1234-1234-123456789abc" + cleaned_no_guid, _, recs1 = scan_attachment( + content, label="x", max_bytes=10_000, redact_guids=False + ) + assert "Bearer eyJabc.def.ghi" not in cleaned_no_guid + assert "12345678-1234-1234-1234-123456789abc" in cleaned_no_guid + + cleaned_guid, _, recs2 = scan_attachment( + content, label="x", max_bytes=10_000, redact_guids=True + ) + assert "12345678-1234-1234-1234-123456789abc" not in cleaned_guid + assert any(r.rule_id == RULE_GUID for r in recs2) + + +def test_rule_set_is_frozen_and_complete() -> None: + # Stable public API — these rule ids must keep working. + assert RULE_AUDIT_KEY in REDACT_RULES + assert RULE_TELEMETRY_PATTERN in REDACT_RULES + assert RULE_URL_QUERY in REDACT_RULES + assert RULE_GUID in REDACT_RULES + assert RULE_TRUNCATE in REDACT_RULES + assert len(REDACT_RULES) == 5 # if you add one, update the docstring + + +def test_base64_wrapped_token_still_caught_in_attachments() -> None: + # A JWT inside what looks like base64 (common when a log dumps a + # whole response body). Layer 2's JWT regex needs 20+ chars after + # "ey" — match the real-world JWT shape, not a stripped sample. + jwt = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiIxMjM0NTYifQ.signABCDEF" + ) + body = f"stuff before {jwt} stuff after" + cleaned, _, records = scan_attachment( + body, label="resp.json", max_bytes=10_000, redact_guids=False + ) + assert jwt not in cleaned + assert records + + +def test_audit_trail_length_does_not_leak_value() -> None: + # RedactionRecord stores length only, never the value. + payload = {"client_secret": "verysecretvalue"} + _, records = redact_structured(payload) + rec = records[0] + assert rec.redacted_length == len("verysecretvalue") + # Make sure dataclass repr/dict don't leak the value somehow. + assert "verysecretvalue" not in repr(rec) From 7aafe7fd8db86690635493a16b7e041a75566222 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 16:42:14 -0500 Subject: [PATCH 04/17] docs(changelog): note Part 0 (bcli.context infrastructure) --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d09d8..9f8ab48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Part 0 (context infrastructure for LLM features) + +- **`bcli.context` package** — shared, model-bound context layer that + future LLM-driven features consume (`bcli ask`, future `bcli agent`). + Standalone in this release; no CLI consumers yet. +- **Typed `ContextBundle` dataclass** with `to_dict()` / `to_prompt_text()` + renderers. Frozen, JSON-serialisable, token-budgeted with source + attribution and an explicit `RedactionRecord` audit trail (R4). +- **Three-layer redaction** (`bcli.context._redact`) — composes the + existing `bcli/audit/_redact.py` key-based stripper, the + `bcli/telemetry/events.py` token-pattern regex, and a new URL + query-param / GUID / attachment scrubber (R5). Every redaction is + logged with a stable `rule_id` so regressions are catchable in CI. +- **Last-error capture** — central `BCLIError` handler now drops a + redacted snapshot to `~/.config/bcli/last-error.json`. **No + tracebacks by default**; `--debug` invocations also write a + `last-error-debug.json` sidecar at mode 0600 (R6). +- **`bcli.http` rolling tail** — opt-in NDJSON tail at + `~/.config/bcli/http-tail.ndjson` enabled by `[context] tail = true`. + Size-bounded via `RotatingFileHandler`; URLs are query-stripped on + read so the bundle stays safe. +- **`ContextConfig`** — new `[context]` config section with `tail`, + `redact_company_ids`, `attachment_max_bytes` knobs. + ## [0.4.0] — 2026-05-18 — Agent Interface Profile v0.1 The Agent Interface Profile (AIP) v0.1 lands: a small kernel of CLI From 3836c7a0dfc344e019428a27da547e837395ccc2 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 16:55:03 -0500 Subject: [PATCH 05/17] =?UTF-8?q?feat(packs):=20bcli.packs=20SDK=20?= =?UTF-8?q?=E2=80=94=20Pack/Manifest/Ledger=20+=20installer=20(R2,=20R3,?= =?UTF-8?q?=20R7,=20R8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the SDK surface for the pack mechanism. Three sub-modules mirror the existing extract/telemetry package shape: - `_protocol.py` — frozen dataclasses for Pack, PackManifest, PackContents, AgentFragment (with per-fragment `targets:`, default [agents] per R3), PackQuery, PackBatch, PackRegistryPreset. VALID_TARGETS = {agents, claude}. - `_loader.py` — parses pack.yaml + content files, validates fragment targets, supports either list-of-strings or list-of- dicts for each section. - `_registry.py` — discovers packs from three sources: built-in (packs/ at repo root, or bcli/packs/_builtin in the wheel), entry-point group `bcli.packs`, and explicit paths. Broken packs log a warning and are skipped. - `_ledger.py` — frozen Ledger dataclass + round-trip JSON I/O at ~/.config/bcli/packs//.json. Records every artefact path with rendered_hash + owner for provenance-driven uninstall (R2). - `_installer.py` — plan_install / execute_install / install_pack / uninstall_pack. Atomic writes via the same tmp+os.replace pattern as skill_init_cmd. Conflict detection on registry presets refuses overwrites unless --replace-owned --accept-conflicts (R7). Marker blocks splice into AGENTS.md / CLAUDE.md per fragment's targets list (R3); idempotent re-install via marker pair + content_hash. The package has no CLI consumers yet — the next commit wires up bcli pack. --- src/bcli/packs/__init__.py | 81 ++++ src/bcli/packs/_installer.py | 710 +++++++++++++++++++++++++++++++++++ src/bcli/packs/_ledger.py | 294 +++++++++++++++ src/bcli/packs/_loader.py | 282 ++++++++++++++ src/bcli/packs/_protocol.py | 171 +++++++++ src/bcli/packs/_registry.py | 148 ++++++++ 6 files changed, 1686 insertions(+) create mode 100644 src/bcli/packs/__init__.py create mode 100644 src/bcli/packs/_installer.py create mode 100644 src/bcli/packs/_ledger.py create mode 100644 src/bcli/packs/_loader.py create mode 100644 src/bcli/packs/_protocol.py create mode 100644 src/bcli/packs/_registry.py diff --git a/src/bcli/packs/__init__.py b/src/bcli/packs/__init__.py new file mode 100644 index 0000000..52e33de --- /dev/null +++ b/src/bcli/packs/__init__.py @@ -0,0 +1,81 @@ +"""``bcli.packs`` — pack mechanism (Part 1 / R2, R3, R7, R8). + +A *pack* is a versioned bundle of saved queries, batch templates, +registry presets and agent fragments. Packs ship from three sources: + +* Built-in (``packs/`` directory in the repo) +* Entry-point group ``bcli.packs`` (third-party packages) +* Local path (``bcli pack install --path ``) + +This package is the SDK surface; the CLI command lives at +``bcli_cli.commands.pack_cmd``. +""" + +from __future__ import annotations + +from bcli.packs._installer import ( + EndpointConflict, + InstallError, + InstallPlan, + UninstallResult, + install_pack, + plan_install, + uninstall_pack, +) +from bcli.packs._ledger import ( + Ledger, + LedgerEntry, + LedgerRegistryEntry, + list_ledgers, + read_ledger, +) +from bcli.packs._loader import PackLoadError, load_pack +from bcli.packs._protocol import ( + AgentFragment, + Pack, + PackBatch, + PackContents, + PackManifest, + PackQuery, + PackRegistryPreset, + TARGET_AGENTS, + TARGET_CLAUDE, +) +from bcli.packs._registry import ( + ENTRYPOINT_GROUP, + builtin_packs_dir, + discover_all, + discover_builtin_packs, + discover_entrypoint_packs, +) + +__all__ = [ + "AgentFragment", + "ENTRYPOINT_GROUP", + "EndpointConflict", + "InstallError", + "InstallPlan", + "Ledger", + "LedgerEntry", + "LedgerRegistryEntry", + "Pack", + "PackBatch", + "PackContents", + "PackLoadError", + "PackManifest", + "PackQuery", + "PackRegistryPreset", + "TARGET_AGENTS", + "TARGET_CLAUDE", + "UninstallResult", + "builtin_packs_dir", + "discover_all", + "discover_builtin_packs", + "discover_entrypoint_packs", + "install_pack", + "list_ledgers", + "load_pack", + "plan_install", + "read_ledger", + "uninstall_pack", +] diff --git a/src/bcli/packs/_installer.py b/src/bcli/packs/_installer.py new file mode 100644 index 0000000..8663748 --- /dev/null +++ b/src/bcli/packs/_installer.py @@ -0,0 +1,710 @@ +"""Pack install / uninstall with ledger + marker-block hygiene (R2, R3, R7). + +Install flow +------------ + +1. Resolve `target` (project root containing ``.claude/`` or + ``$HOME``). The installer writes under: + + * ``~/.config/bcli/queries/.yaml`` — saved queries + * ``~/.config/bcli/batches//`` — batch templates + * ``~/.config/bcli/registries/.json`` — registry presets + * ``/.claude/agent.d/bcli-/`` — one + fragment file per pack-fragment (single source of truth on disk) + * ``/AGENTS.md`` and/or ``/CLAUDE.md`` — marker + blocks per fragment ``targets`` list (R3) + +2. Conflict check (R7): every endpoint we'd install gets compared + against ledgers of *other* packs; refuses install unless + ``--replace-owned --accept-conflicts``. + +3. Build a plan; if ``--dry-run`` print and return. Otherwise + atomically write each artefact (reused ``_atomic_write`` pattern + from skill_init_cmd.py). + +4. Persist a :class:`Ledger` so uninstall can find every artefact. + +Uninstall +--------- + +Reads the ledger, deletes each path, strips marker blocks from +AGENTS.md / CLAUDE.md, then removes the ledger. Anything missing +or hand-edited surfaces as a warning; the rest still gets cleaned. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +from bcli.packs._ledger import ( + Ledger, + LedgerEntry, + LedgerRegistryEntry, + build_ledger, + delete_ledger, + hash_content, + list_ledgers, + read_ledger, + write_ledger, +) +from bcli.packs._protocol import ( + AgentFragment, + Pack, + PackQuery, + TARGET_AGENTS, + TARGET_CLAUDE, +) + +logger = logging.getLogger("bcli.packs") + + +class InstallError(Exception): + """Raised by the installer on validation / conflict failures.""" + + +# ─── Plan / result dataclasses ────────────────────────────────────── + + +@dataclass +class InstallPlan: + """The set of writes ``install_pack`` would perform. + + Pure data — ``execute()`` is the side-effecting step. Dry-run + surfaces this directly. + """ + + pack: Pack + profile: str + target: Path + fragment_writes: list["PlannedFragment"] = field(default_factory=list) + query_writes: list["PlannedQuery"] = field(default_factory=list) + batch_writes: list["PlannedBatch"] = field(default_factory=list) + preset_writes: list["PlannedPreset"] = field(default_factory=list) + block_writes: list["PlannedBlock"] = field(default_factory=list) + conflicts: list["EndpointConflict"] = field(default_factory=list) + + +@dataclass +class PlannedFragment: + fragment: AgentFragment + path: Path + rendered_hash: str + + +@dataclass +class PlannedQuery: + query: PackQuery + path: Path # queries file + rendered_hash: str + + +@dataclass +class PlannedBatch: + filename: str + path: Path + rendered_hash: str + + +@dataclass +class PlannedPreset: + name: str + body: dict + rendered_hash: str + target_path: Path # registries/.json + + +@dataclass +class PlannedBlock: + target_file: Path + block_id: str + rendered: str + rendered_hash: str + + +@dataclass +class EndpointConflict: + endpoint: str + incumbent_pack: str + incumbent_version: str + + +# ─── Path helpers ─────────────────────────────────────────────────── + + +def config_dir(*, override: Path | None = None) -> Path: + return override or (Path.home() / ".config" / "bcli") + + +def queries_path(profile: str, *, override: Path | None = None) -> Path: + return config_dir(override=override) / "queries" / f"{profile}.yaml" + + +def batches_dir(profile: str, *, override: Path | None = None) -> Path: + return config_dir(override=override) / "batches" / profile + + +def registries_path(profile: str, *, override: Path | None = None) -> Path: + return config_dir(override=override) / "registries" / f"{profile}.json" + + +def fragments_dir(target: Path, pack_name: str) -> Path: + return target / ".claude" / "agent.d" / f"bcli-{pack_name}" + + +def resolve_target(target: Path | None = None) -> Path: + """Pick the install target dir. + + 1. Explicit ``target`` wins. + 2. CWD with ``.claude/`` → CWD (project convention). + 3. Otherwise ``$HOME``. + """ + if target is not None: + return Path(target) + cwd = Path.cwd() + if (cwd / ".claude").is_dir(): + return cwd + return Path.home() + + +# ─── Marker block helpers (R3) ────────────────────────────────────── + + +def block_id_for(pack_name: str, fragment_name: str) -> str: + return f"bcli-pack:{pack_name}:{fragment_name}" + + +def _start_marker(block_id: str) -> str: + return ( + f"" + ) + + +def _end_marker(block_id: str, *, content_hash: str) -> str: + return f"" + + +def render_block( + *, block_id: str, fragment: AgentFragment, content_hash: str +) -> str: + return ( + f"{_start_marker(block_id)}\n" + f"{fragment.content.rstrip()}\n" + f"{_end_marker(block_id, content_hash=content_hash)}\n" + ) + + +_MARKER_RE_TPL = ( + r"(?:\n)?\n.*?\n?" +) + + +def _splice_block(existing: str, block_id: str, rendered: str) -> str: + """Insert (or replace) a marker block in ``existing``. + + If the block is already there, it gets replaced in place. Otherwise + we append it at the end with a leading blank line so the file + stays readable. + """ + pattern = re.compile( + _MARKER_RE_TPL.format(bid=re.escape(block_id)), + flags=re.DOTALL, + ) + if pattern.search(existing): + return pattern.sub("\n" + rendered, existing) + # Append. + if existing and not existing.endswith("\n"): + existing += "\n" + return existing + ("\n" if existing else "") + rendered + + +def _strip_block(existing: str, block_id: str) -> tuple[str, bool]: + """Remove a marker block from ``existing``. + + Returns ``(new_text, found)``. ``found=False`` means the block + wasn't present — uninstall surfaces that as "ledger says we + installed it but it's already gone." + """ + pattern = re.compile( + _MARKER_RE_TPL.format(bid=re.escape(block_id)), + flags=re.DOTALL, + ) + new_text, n = pattern.subn("", existing) + return new_text.rstrip() + "\n" if new_text.strip() else "", n > 0 + + +# ─── Atomic write (reused from skill_init pattern) ───────────────── + + +def _atomic_write(path: Path, data: str) -> None: + """Mirror of `bcli_cli.commands.skill_init_cmd._atomic_write`. + + Duplicated here rather than imported to keep the SDK->CLI + dependency arrow pointing the right way (packs is SDK; we never + want SDK code importing CLI modules). The behaviour is byte- + identical: tmp file in the same directory, then os.replace. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp_name, str(path)) + except Exception: + try: + os.unlink(tmp_name) + except OSError: + pass + raise + + +# ─── Planning ─────────────────────────────────────────────────────── + + +def plan_install( + pack: Pack, + *, + profile: str, + target: Path | None = None, + config_override: Path | None = None, +) -> InstallPlan: + """Build the install plan without touching disk.""" + plan = InstallPlan( + pack=pack, + profile=profile, + target=resolve_target(target), + ) + frag_root = fragments_dir(plan.target, pack.name) + + # Fragments — one file each + marker blocks per target. + for f in pack.contents.agent_fragments: + rendered_hash = hash_content(f.content) + path = frag_root / f.name + plan.fragment_writes.append( + PlannedFragment(fragment=f, path=path, rendered_hash=rendered_hash) + ) + bid = block_id_for(pack.name, f.name) + rendered_block = render_block( + block_id=bid, fragment=f, content_hash=rendered_hash + ) + for tgt in f.targets: + target_file = _block_target_file(plan.target, tgt) + plan.block_writes.append(PlannedBlock( + target_file=target_file, + block_id=bid, + rendered=rendered_block, + rendered_hash=rendered_hash, + )) + + # Queries — single YAML merge file. + if pack.contents.queries: + qpath = queries_path(profile, override=config_override) + for q in pack.contents.queries: + body_serialised = yaml.safe_dump( + q.body, sort_keys=False, default_flow_style=False + ) + plan.query_writes.append(PlannedQuery( + query=q, + path=qpath, + rendered_hash=hash_content(body_serialised), + )) + + # Batches — one file each. + bdir = batches_dir(profile, override=config_override) + for b in pack.contents.batches: + plan.batch_writes.append(PlannedBatch( + filename=b.filename, + path=bdir / b.filename, + rendered_hash=hash_content(b.body), + )) + + # Registry presets — merged into one JSON file. + if pack.contents.registry_presets: + rpath = registries_path(profile, override=config_override) + for preset in pack.contents.registry_presets: + body_with_prov = dict(preset.body) + body_with_prov["source_pack"] = pack.name + body_with_prov["pack_version"] = pack.version + plan.preset_writes.append(PlannedPreset( + name=preset.name, + body=body_with_prov, + rendered_hash=hash_content(json.dumps(body_with_prov, sort_keys=True)), + target_path=rpath, + )) + + # Conflict detection — read every other pack's ledger. + incumbents: dict[str, tuple[str, str]] = {} + for other in list_ledgers(profile, config_dir=config_override): + if other.pack_name == pack.name: + continue + for ep in other.registry_endpoints: + incumbents[ep.name] = (other.pack_name, other.pack_version) + for preset in plan.preset_writes: + if preset.name in incumbents: + owner, ver = incumbents[preset.name] + plan.conflicts.append(EndpointConflict( + endpoint=preset.name, + incumbent_pack=owner, + incumbent_version=ver, + )) + + return plan + + +def _block_target_file(target: Path, target_kind: str) -> Path: + if target_kind == TARGET_AGENTS: + return target / "AGENTS.md" + if target_kind == TARGET_CLAUDE: + return target / "CLAUDE.md" + raise InstallError(f"unknown fragment target: {target_kind!r}") + + +# ─── Execution ────────────────────────────────────────────────────── + + +def execute_install( + plan: InstallPlan, + *, + config_override: Path | None = None, + replace_owned: bool = False, + accept_conflicts: bool = False, +) -> Ledger: + """Apply ``plan`` to disk; persist the ledger; return it. + + Raises :class:`InstallError` on registry conflicts unless the + caller passed ``replace_owned=True`` AND ``accept_conflicts=True`` + (R7 — two flags so neither accidental). + """ + if plan.conflicts and not (replace_owned and accept_conflicts): + lines = [ + f" - {c.endpoint} (already owned by {c.incumbent_pack} v{c.incumbent_version})" + for c in plan.conflicts + ] + raise InstallError( + "Registry preset conflict — refusing to overwrite endpoints owned" + " by another pack. Pass --replace-owned --accept-conflicts to" + " override.\n" + "\n".join(lines) + ) + + written: list[LedgerEntry] = [] + + # Fragments first — write content files. + for pf in plan.fragment_writes: + _atomic_write(pf.path, pf.fragment.content) + written.append(LedgerEntry( + kind="fragment_file", + path=str(pf.path), + rendered_hash=pf.rendered_hash, + owner=plan.pack.name, + )) + + # Marker blocks — for each target file, accumulate per-block updates + # then write once. + by_target: dict[Path, list[PlannedBlock]] = {} + for blk in plan.block_writes: + by_target.setdefault(blk.target_file, []).append(blk) + for target_file, blocks in by_target.items(): + existing = ( + target_file.read_text(encoding="utf-8") + if target_file.is_file() else "" + ) + updated = existing + for blk in blocks: + updated = _splice_block(updated, blk.block_id, blk.rendered) + written.append(LedgerEntry( + kind="agents_block" if blk.target_file.name == "AGENTS.md" + else "claude_block", + path=str(target_file), + rendered_hash=blk.rendered_hash, + owner=plan.pack.name, + block_id=blk.block_id, + )) + _atomic_write(target_file, updated) + + # Queries — read-merge-write the YAML. + if plan.query_writes: + qpath = plan.query_writes[0].path + existing_raw: dict = {} + if qpath.is_file(): + try: + existing_raw = ( + yaml.safe_load(qpath.read_text(encoding="utf-8")) or {} + ) + except yaml.YAMLError as e: + raise InstallError( + f"existing {qpath} is not valid YAML: {e}" + ) from e + if not isinstance(existing_raw, dict): + raise InstallError(f"{qpath}: top-level must be a mapping") + existing_queries = existing_raw.get("queries") or {} + if not isinstance(existing_queries, dict): + raise InstallError( + f"{qpath}: 'queries' must be a mapping" + ) + for pq in plan.query_writes: + body = dict(pq.query.body) + body.setdefault("provenance", {}) + body["provenance"] = { + "source_pack": plan.pack.name, + "pack_version": plan.pack.version, + } + existing_queries[pq.query.name] = body + written.append(LedgerEntry( + kind="query", + path=f"{qpath}#{pq.query.name}", + rendered_hash=pq.rendered_hash, + owner=plan.pack.name, + )) + existing_raw["queries"] = existing_queries + text = yaml.safe_dump(existing_raw, sort_keys=False, default_flow_style=False) + _atomic_write(qpath, text) + + # Batches. + for pb in plan.batch_writes: + body = next( + b.body for b in plan.pack.contents.batches if b.filename == pb.filename + ) + _atomic_write(pb.path, body) + written.append(LedgerEntry( + kind="batch", + path=str(pb.path), + rendered_hash=pb.rendered_hash, + owner=plan.pack.name, + )) + + # Registry presets — merge into JSON. + registry_entries: list[LedgerRegistryEntry] = [] + if plan.preset_writes: + rpath = plan.preset_writes[0].target_path + existing_reg: dict = {} + if rpath.is_file(): + try: + existing_reg = json.loads(rpath.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise InstallError( + f"existing {rpath} is not valid JSON: {e}" + ) from e + endpoints = existing_reg.get("endpoints") or {} + if not isinstance(endpoints, dict): + raise InstallError( + f"{rpath}: 'endpoints' must be an object" + ) + for preset in plan.preset_writes: + endpoints[preset.name] = preset.body + registry_entries.append(LedgerRegistryEntry( + name=preset.name, + rendered_hash=preset.rendered_hash, + owner=plan.pack.name, + )) + written.append(LedgerEntry( + kind="registry_preset", + path=f"{rpath}#{preset.name}", + rendered_hash=preset.rendered_hash, + owner=plan.pack.name, + )) + existing_reg["endpoints"] = endpoints + _atomic_write(rpath, json.dumps(existing_reg, indent=2)) + + ledger = build_ledger( + plan.pack, + profile=plan.profile, + target=plan.target, + paths=written, + registry_endpoints=registry_entries, + ) + write_ledger(ledger, config_dir=config_override) + return ledger + + +def install_pack( + pack: Pack, + *, + profile: str, + target: Path | None = None, + dry_run: bool = False, + replace_owned: bool = False, + accept_conflicts: bool = False, + config_override: Path | None = None, +) -> InstallPlan: + """High-level wrapper: plan → optionally execute → return the plan. + + The plan is returned in both modes so the caller can render a + diff in dry-run mode and a summary in execute mode. + """ + plan = plan_install( + pack, + profile=profile, + target=target, + config_override=config_override, + ) + if dry_run: + return plan + execute_install( + plan, + config_override=config_override, + replace_owned=replace_owned, + accept_conflicts=accept_conflicts, + ) + return plan + + +# ─── Uninstall ────────────────────────────────────────────────────── + + +@dataclass +class UninstallResult: + """Report from :func:`uninstall_pack`.""" + + pack_name: str + files_removed: list[Path] = field(default_factory=list) + blocks_removed: list[tuple[Path, str]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +def uninstall_pack( + pack_name: str, + *, + profile: str, + config_override: Path | None = None, +) -> UninstallResult: + """Reverse a prior install using the persisted ledger. + + Anything missing from disk surfaces as a warning rather than an + error — the user may have hand-deleted a fragment file, and we + don't want to block the rest of cleanup over that. + """ + result = UninstallResult(pack_name=pack_name) + ledger = read_ledger(pack_name, profile, config_dir=config_override) + if ledger is None: + result.warnings.append( + f"no install ledger found for pack {pack_name!r} on profile " + f"{profile!r}; nothing to do" + ) + return result + + # Group block entries by their target file so we touch each file once. + block_paths: dict[Path, list[str]] = {} + files_to_remove: list[Path] = [] + query_keys: list[tuple[Path, str]] = [] + preset_keys: list[tuple[Path, str]] = [] + + for entry in ledger.paths: + if entry.kind in {"agents_block", "claude_block"}: + block_paths.setdefault(Path(entry.path), []).append(entry.block_id) + elif entry.kind in {"fragment_file", "batch"}: + files_to_remove.append(Path(entry.path)) + elif entry.kind == "query": + file, _, name = entry.path.partition("#") + if name: + query_keys.append((Path(file), name)) + elif entry.kind == "registry_preset": + file, _, name = entry.path.partition("#") + if name: + preset_keys.append((Path(file), name)) + + # Strip marker blocks. + for target_file, ids in block_paths.items(): + if not target_file.is_file(): + result.warnings.append( + f"target file {target_file} missing — cannot strip blocks" + ) + continue + text = target_file.read_text(encoding="utf-8") + for bid in ids: + text, found = _strip_block(text, bid) + if found: + result.blocks_removed.append((target_file, bid)) + else: + result.warnings.append( + f"block {bid} not present in {target_file}" + ) + _atomic_write(target_file, text) + + # Remove standalone files (fragments, batches). + for path in files_to_remove: + if path.is_file(): + try: + path.unlink() + result.files_removed.append(path) + except OSError as e: + result.warnings.append(f"could not remove {path}: {e}") + else: + result.warnings.append(f"file {path} already missing") + + # Remove orphan fragment dir if empty. + fragment_root = ( + files_to_remove[0].parent + if files_to_remove and files_to_remove[0].parent.name.startswith("bcli-") + else None + ) + if fragment_root and fragment_root.is_dir(): + try: + fragment_root.rmdir() # only succeeds if empty + except OSError: + pass + + # Remove queries from the merged YAML. + if query_keys: + qpath = query_keys[0][0] + if qpath.is_file(): + raw = yaml.safe_load(qpath.read_text(encoding="utf-8")) or {} + queries = raw.get("queries") or {} + for _, name in query_keys: + if name in queries: + del queries[name] + raw["queries"] = queries + _atomic_write( + qpath, + yaml.safe_dump(raw, sort_keys=False, default_flow_style=False), + ) + + # Remove registry presets. + if preset_keys: + rpath = preset_keys[0][0] + if rpath.is_file(): + try: + raw = json.loads(rpath.read_text(encoding="utf-8")) + except json.JSONDecodeError: + raw = {} + endpoints = raw.get("endpoints") or {} + for _, name in preset_keys: + if name in endpoints: + del endpoints[name] + raw["endpoints"] = endpoints + _atomic_write(rpath, json.dumps(raw, indent=2)) + + delete_ledger(pack_name, profile, config_dir=config_override) + return result + + +__all__ = [ + "EndpointConflict", + "InstallError", + "InstallPlan", + "PlannedBatch", + "PlannedBlock", + "PlannedFragment", + "PlannedPreset", + "PlannedQuery", + "UninstallResult", + "batches_dir", + "block_id_for", + "config_dir", + "execute_install", + "fragments_dir", + "install_pack", + "plan_install", + "queries_path", + "registries_path", + "render_block", + "resolve_target", + "uninstall_pack", +] diff --git a/src/bcli/packs/_ledger.py b/src/bcli/packs/_ledger.py new file mode 100644 index 0000000..614e090 --- /dev/null +++ b/src/bcli/packs/_ledger.py @@ -0,0 +1,294 @@ +"""Pack install ledger (R2). + +Each install writes a JSON ledger at +``~/.config/bcli/packs//.json`` describing *every* +artefact the install produced. Uninstall trusts the ledger first, +marker pairs second — that lets us catch drift like "the ledger +says we wrote N artefacts at path P but only N-1 markers remain" +without forcing the user to clean up by hand. + +Schema (v1): + + { + "schema_version": "1.0", + "pack_name": "starter-generic", + "pack_version": "0.1.0", + "installed_at": "2026-05-22T10:00:00+00:00", + "profile": "production", + "target": "/abs/path/to/install/target", + "paths": [ + { + "kind": "query" | "batch" | "fragment_file" | "registry_preset" + | "agents_block" | "claude_block", + "path": "/abs/path", + "block_id": "bcli-pack:starter-generic:common-errors.md" + (only for *_block entries), + "rendered_hash": "sha256:...", + "owner": "starter-generic" + } + ], + "registry_endpoints": [ + {"name": "myEntity", "rendered_hash": "sha256:..."} + ], + "recommended_context_providers": ["beautech"] + } +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from bcli.packs._protocol import Pack + +logger = logging.getLogger("bcli.packs") + +_SCHEMA_VERSION = "1.0" + + +@dataclass(frozen=True) +class LedgerEntry: + """One artefact the installer wrote.""" + + kind: str + path: str + rendered_hash: str + owner: str + block_id: str = "" + + +@dataclass(frozen=True) +class LedgerRegistryEntry: + """One endpoint definition this pack installed into the custom registry.""" + + name: str + rendered_hash: str + owner: str + + +@dataclass(frozen=True) +class Ledger: + """A pack's install record.""" + + pack_name: str + pack_version: str + installed_at: str + profile: str + target: str + paths: tuple[LedgerEntry, ...] = () + registry_endpoints: tuple[LedgerRegistryEntry, ...] = () + recommended_context_providers: tuple[str, ...] = () + schema_version: str = _SCHEMA_VERSION + + def to_dict(self) -> dict[str, object]: + return { + "schema_version": self.schema_version, + "pack_name": self.pack_name, + "pack_version": self.pack_version, + "installed_at": self.installed_at, + "profile": self.profile, + "target": self.target, + "paths": [ + { + "kind": p.kind, + "path": p.path, + "rendered_hash": p.rendered_hash, + "owner": p.owner, + "block_id": p.block_id, + } + for p in self.paths + ], + "registry_endpoints": [ + { + "name": e.name, + "rendered_hash": e.rendered_hash, + "owner": e.owner, + } + for e in self.registry_endpoints + ], + "recommended_context_providers": list( + self.recommended_context_providers + ), + } + + @classmethod + def from_dict(cls, raw: dict) -> "Ledger": + paths = tuple( + LedgerEntry( + kind=str(p.get("kind", "")), + path=str(p.get("path", "")), + rendered_hash=str(p.get("rendered_hash", "")), + owner=str(p.get("owner", "")), + block_id=str(p.get("block_id", "")), + ) + for p in raw.get("paths", []) or [] + ) + endpoints = tuple( + LedgerRegistryEntry( + name=str(e.get("name", "")), + rendered_hash=str(e.get("rendered_hash", "")), + owner=str(e.get("owner", "")), + ) + for e in raw.get("registry_endpoints", []) or [] + ) + return cls( + pack_name=str(raw.get("pack_name", "")), + pack_version=str(raw.get("pack_version", "")), + installed_at=str(raw.get("installed_at", "")), + profile=str(raw.get("profile", "")), + target=str(raw.get("target", "")), + paths=paths, + registry_endpoints=endpoints, + recommended_context_providers=tuple( + str(x) + for x in raw.get("recommended_context_providers", []) or () + ), + schema_version=str(raw.get("schema_version", _SCHEMA_VERSION)), + ) + + +# ─── Path resolution + I/O ────────────────────────────────────────── + + +def _config_dir() -> Path: + return Path.home() / ".config" / "bcli" + + +def ledger_dir(profile: str, *, config_dir: Path | None = None) -> Path: + return (config_dir or _config_dir()) / "packs" / profile + + +def ledger_path( + pack: str, profile: str, *, config_dir: Path | None = None +) -> Path: + return ledger_dir(profile, config_dir=config_dir) / f"{pack}.json" + + +def read_ledger( + pack: str, profile: str, *, config_dir: Path | None = None +) -> Ledger | None: + path = ledger_path(pack, profile, config_dir=config_dir) + if not path.is_file(): + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + logger.debug("could not read ledger %s: %s", path, e) + return None + if not isinstance(raw, dict): + return None + return Ledger.from_dict(raw) + + +def write_ledger( + ledger: Ledger, *, config_dir: Path | None = None +) -> Path | None: + """Atomically persist ``ledger``. Returns the path on success.""" + path = ledger_path( + ledger.pack_name, ledger.profile, config_dir=config_dir + ) + try: + path.parent.mkdir(parents=True, exist_ok=True) + data = json.dumps(ledger.to_dict(), indent=2, sort_keys=False) + fd, tmp = tempfile.mkstemp( + prefix=path.name + ".", dir=str(path.parent) + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp, str(path)) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + return path + except (OSError, TypeError, ValueError) as e: + logger.warning("could not write pack ledger %s: %s", path, e) + return None + + +def delete_ledger( + pack: str, profile: str, *, config_dir: Path | None = None +) -> bool: + path = ledger_path(pack, profile, config_dir=config_dir) + if not path.is_file(): + return False + try: + path.unlink() + return True + except OSError as e: + logger.warning("could not delete pack ledger %s: %s", path, e) + return False + + +def list_ledgers( + profile: str, *, config_dir: Path | None = None +) -> list[Ledger]: + """Return every ledger under the given profile.""" + folder = ledger_dir(profile, config_dir=config_dir) + if not folder.is_dir(): + return [] + out: list[Ledger] = [] + for path in sorted(folder.glob("*.json")): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if isinstance(raw, dict): + out.append(Ledger.from_dict(raw)) + return out + + +# ─── Hash helpers ─────────────────────────────────────────────────── + + +def hash_content(value: str | bytes) -> str: + """Stable sha256 hex of ``value`` (text or bytes).""" + data = value.encode("utf-8") if isinstance(value, str) else value + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_ledger( + pack: Pack, + *, + profile: str, + target: Path, + paths: list[LedgerEntry], + registry_endpoints: list[LedgerRegistryEntry] | None = None, +) -> Ledger: + """Convenience constructor — pulls timestamp + recommendations from the pack.""" + return Ledger( + pack_name=pack.name, + pack_version=pack.version, + installed_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), + profile=profile, + target=str(target), + paths=tuple(paths), + registry_endpoints=tuple(registry_endpoints or []), + recommended_context_providers=tuple( + pack.manifest.recommended_context_providers + ), + ) + + +__all__ = [ + "Ledger", + "LedgerEntry", + "LedgerRegistryEntry", + "build_ledger", + "delete_ledger", + "hash_content", + "ledger_dir", + "ledger_path", + "list_ledgers", + "read_ledger", + "write_ledger", +] diff --git a/src/bcli/packs/_loader.py b/src/bcli/packs/_loader.py new file mode 100644 index 0000000..dc74492 --- /dev/null +++ b/src/bcli/packs/_loader.py @@ -0,0 +1,282 @@ +"""Load a :class:`Pack` from a directory containing ``pack.yaml`` + files. + +The on-disk layout matches the plan (Part 1): + + packs// + pack.yaml # manifest + index of contents + fragments/*.md # agent fragments + queries/*.yaml # saved-query bodies + batches/*.yaml # batch templates + presets/*.json # registry presets + +``pack.yaml`` references files by relative path; the loader resolves +them and produces a fully populated :class:`Pack`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml + +from bcli.packs._protocol import ( + AgentFragment, + Pack, + PackBatch, + PackContents, + PackManifest, + PackQuery, + PackRegistryPreset, + TARGET_AGENTS, + VALID_TARGETS, +) + + +class PackLoadError(Exception): + """Raised when a pack directory can't be loaded into a :class:`Pack`.""" + + +def load_pack(source: Path) -> Pack: + """Read ``/pack.yaml`` and return a :class:`Pack`. + + ``source`` may be either the pack directory itself or the + ``pack.yaml`` file directly. All referenced content files are + eagerly loaded so the installer can run against the in-memory + object without re-touching disk. + """ + source = Path(source) + if source.is_dir(): + manifest_path = source / "pack.yaml" + root = source + elif source.is_file(): + manifest_path = source + root = source.parent + else: + raise PackLoadError(f"pack source not found: {source}") + + if not manifest_path.is_file(): + raise PackLoadError(f"missing pack.yaml in {root}") + + try: + raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + raise PackLoadError(f"pack.yaml in {root} is not valid YAML: {e}") from e + if not isinstance(raw, dict): + raise PackLoadError(f"pack.yaml in {root}: top-level must be a mapping") + + manifest = _parse_manifest(raw, source=manifest_path) + contents = _parse_contents(raw, root=root, pack_name=manifest.name) + return Pack(manifest=manifest, contents=contents, source_path=root) + + +def _parse_manifest(raw: dict[str, Any], *, source: Path) -> PackManifest: + if "name" not in raw: + raise PackLoadError(f"{source}: missing required field 'name'") + if "version" not in raw: + raise PackLoadError(f"{source}: missing required field 'version'") + return PackManifest( + name=str(raw["name"]), + version=str(raw["version"]), + description=str(raw.get("description", "")), + target_profile=str(raw.get("target_profile", "")), + recommended_context_providers=tuple( + str(x) for x in raw.get("recommended_context_providers", []) or () + ), + ) + + +def _parse_contents( + raw: dict[str, Any], *, root: Path, pack_name: str +) -> PackContents: + block = raw.get("contents") or {} + if not isinstance(block, dict): + raise PackLoadError(f"{root}: 'contents' must be a mapping") + + fragments = _load_fragments(block.get("agent_fragments") or (), root=root) + queries = _load_queries(block.get("queries") or (), root=root) + batches = _load_batches(block.get("batches") or (), root=root) + presets = _load_presets(block.get("registry_presets") or (), root=root) + return PackContents( + agent_fragments=tuple(fragments), + queries=tuple(queries), + batches=tuple(batches), + registry_presets=tuple(presets), + ) + + +def _load_fragments( + spec: Any, *, root: Path +) -> list[AgentFragment]: + """Accepts either plain filename strings (`common-errors.md`) OR + structured dicts with ``name`` / ``targets`` / ``description``.""" + out: list[AgentFragment] = [] + for entry in spec: + if isinstance(entry, str): + name = entry + targets: tuple[str, ...] = (TARGET_AGENTS,) + description = "" + elif isinstance(entry, dict): + name = str(entry.get("name") or entry.get("file") or "") + if not name: + raise PackLoadError( + f"{root}: agent_fragment entry missing 'name'" + ) + tgts = entry.get("targets") or [TARGET_AGENTS] + if isinstance(tgts, str): + tgts = [tgts] + bad = [t for t in tgts if t not in VALID_TARGETS] + if bad: + raise PackLoadError( + f"{root}: fragment {name!r} has invalid targets {bad}; " + f"expected subset of {sorted(VALID_TARGETS)}" + ) + targets = tuple(str(t) for t in tgts) + description = str(entry.get("description", "")) + else: + raise PackLoadError( + f"{root}: agent_fragment must be str or mapping, " + f"got {type(entry).__name__}" + ) + path = _resolve_fragment_path(root, name) + try: + content = path.read_text(encoding="utf-8") + except OSError as e: + raise PackLoadError( + f"{root}: cannot read fragment {name!r} at {path}: {e}" + ) from e + out.append(AgentFragment( + name=name, + content=content, + targets=targets, + description=description, + )) + return out + + +def _resolve_fragment_path(root: Path, name: str) -> Path: + """Fragments live under ``fragments/`` by convention, but a pack + author may pass any relative path. We try both for robustness.""" + candidate = root / "fragments" / name + if candidate.is_file(): + return candidate + candidate = root / name + if candidate.is_file(): + return candidate + raise PackLoadError( + f"{root}: fragment {name!r} not found under fragments/ or root" + ) + + +def _load_queries(spec: Any, *, root: Path) -> list[PackQuery]: + """Accept a list of filenames OR a list of {name, file} dicts.""" + out: list[PackQuery] = [] + for entry in spec: + if isinstance(entry, str): + file = entry + elif isinstance(entry, dict): + file = str(entry.get("file") or entry.get("path") or "") + else: + raise PackLoadError( + f"{root}: query entry must be str or mapping" + ) + path = root / "queries" / file + if not path.is_file(): + path = root / file + if not path.is_file(): + raise PackLoadError(f"{root}: query file {file!r} not found") + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + raise PackLoadError( + f"{root}: query file {file!r} is not valid YAML: {e}" + ) from e + if not isinstance(data, dict): + raise PackLoadError( + f"{root}: query file {file!r}: expected mapping at top level" + ) + # Each file may carry one or many queries — either a top-level + # ``queries: {name: body}`` map or a single body with a ``name``. + if "queries" in data and isinstance(data["queries"], dict): + for name, body in data["queries"].items(): + if not isinstance(body, dict): + raise PackLoadError( + f"{root}: query {name!r} body must be a mapping" + ) + out.append(PackQuery(name=str(name), body=dict(body))) + else: + name = str(data.get("name") or Path(file).stem) + body = {k: v for k, v in data.items() if k != "name"} + out.append(PackQuery(name=name, body=body)) + return out + + +def _load_batches(spec: Any, *, root: Path) -> list[PackBatch]: + out: list[PackBatch] = [] + for entry in spec: + if isinstance(entry, str): + file = entry + elif isinstance(entry, dict): + file = str(entry.get("file") or entry.get("path") or "") + else: + raise PackLoadError( + f"{root}: batch entry must be str or mapping" + ) + path = root / "batches" / file + if not path.is_file(): + path = root / file + if not path.is_file(): + raise PackLoadError(f"{root}: batch file {file!r} not found") + body = path.read_text(encoding="utf-8") + out.append(PackBatch(filename=Path(file).name, body=body)) + return out + + +def _load_presets(spec: Any, *, root: Path) -> list[PackRegistryPreset]: + out: list[PackRegistryPreset] = [] + for entry in spec: + if isinstance(entry, str): + file = entry + elif isinstance(entry, dict): + file = str(entry.get("file") or entry.get("path") or "") + else: + raise PackLoadError( + f"{root}: registry_preset entry must be str or mapping" + ) + path = root / "presets" / file + if not path.is_file(): + path = root / file + if not path.is_file(): + raise PackLoadError( + f"{root}: registry preset {file!r} not found" + ) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise PackLoadError( + f"{root}: registry preset {file!r} invalid JSON: {e}" + ) from e + if not isinstance(data, dict): + raise PackLoadError( + f"{root}: registry preset {file!r}: expected JSON object" + ) + # A preset file holds one or more endpoints. Support both + # forms: {"endpoints": {name: body}} and a single endpoint as + # the top-level object. + endpoints = data.get("endpoints") + if isinstance(endpoints, dict): + for name, body in endpoints.items(): + if not isinstance(body, dict): + raise PackLoadError( + f"{root}: preset endpoint {name!r}: body not a mapping" + ) + out.append(PackRegistryPreset(name=str(name), body=dict(body))) + else: + name = str(data.get("name") or Path(file).stem) + body = {k: v for k, v in data.items() if k != "name"} + out.append(PackRegistryPreset(name=name, body=body)) + return out + + +__all__ = ["PackLoadError", "load_pack"] diff --git a/src/bcli/packs/_protocol.py b/src/bcli/packs/_protocol.py new file mode 100644 index 0000000..e8ecf60 --- /dev/null +++ b/src/bcli/packs/_protocol.py @@ -0,0 +1,171 @@ +"""Pack data shapes (R3, R7, R8). + +A *pack* is a versioned bundle of: + +* saved-query YAML entries (merged into ``~/.config/bcli/queries/.yaml``) +* batch templates (copied to ``~/.config/bcli/batches//``) +* AGENTS.md / CLAUDE.md fragments (per-fragment ``targets:`` declaration, + default ``[agents]`` — R3) +* registry presets (merged into ``~/.config/bcli/registries/.json`` + with provenance tags — R7) + +Packs ship from three sources, in lookup order: + +1. **Built-in** — ``packs/`` at the repo root, shipped in the wheel. +2. **Entry-point** — third-party packages register via the + ``bcli.packs`` group (R8). +3. **Local path** — ``bcli pack install --path `` for development. + +All dataclasses are frozen so a loaded pack can be passed across +boundaries without accidental mutation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +# ─── Constants ────────────────────────────────────────────────────── + + +# Fragment targets (R3). Default `[agents]` because most fragments +# describe operational verbs which live in AGENTS.md. +TARGET_AGENTS = "agents" +TARGET_CLAUDE = "claude" +VALID_TARGETS = frozenset({TARGET_AGENTS, TARGET_CLAUDE}) + + +# ─── Fragments ────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class AgentFragment: + """One Markdown fragment shipped by a pack. + + ``content`` is the rendered body the installer will inline into the + chosen target files (per :attr:`targets`). ``name`` becomes the + filename under ``/.claude/agent.d/bcli-/``. + + ``targets`` defaults to ``("agents",)`` — operational guidance + lives in AGENTS.md by default. A pack author opts in to + ``CLAUDE.md`` (or both) per fragment when the content is + Claude-Code-specific (routing rules, slash-command behaviour). + """ + + name: str + content: str + targets: tuple[str, ...] = (TARGET_AGENTS,) + description: str = "" + + def __post_init__(self) -> None: # noqa: D401 + # Frozen dataclass workaround for validation — object.__setattr__ + # only allowed inside __post_init__. + bad = [t for t in self.targets if t not in VALID_TARGETS] + if bad: + raise ValueError( + f"fragment {self.name!r}: invalid targets {bad}; " + f"expected subset of {sorted(VALID_TARGETS)}" + ) + if not self.targets: + object.__setattr__(self, "targets", (TARGET_AGENTS,)) + + +# ─── Saved queries / batches / registry presets ───────────────────── + + +@dataclass(frozen=True) +class PackQuery: + """One saved-query entry. Body matches ``bcli q`` YAML shape.""" + + name: str + body: dict[str, Any] + + +@dataclass(frozen=True) +class PackBatch: + """One batch YAML template (filename + raw text body).""" + + filename: str + body: str + + +@dataclass(frozen=True) +class PackRegistryPreset: + """A custom registry endpoint declaration carried by a pack (R7). + + The installer injects ``source_pack`` and ``pack_version`` + provenance into each entry before writing into + ``~/.config/bcli/registries/.json``. Conflict detection + refuses to overwrite an endpoint owned by a different pack + unless ``--replace-owned --accept-conflicts`` is passed. + """ + + name: str + body: dict[str, Any] + + +# ─── Manifest + Pack ──────────────────────────────────────────────── + + +@dataclass(frozen=True) +class PackManifest: + """The parsed ``pack.yaml`` head fields — everything except contents. + + ``recommended_context_providers`` (R8) is informational only — + pack install never auto-enables a provider in ``[ask] + context_providers``; the user opts in deliberately. + """ + + name: str + version: str + description: str = "" + target_profile: str = "" + recommended_context_providers: tuple[str, ...] = () + + +@dataclass(frozen=True) +class PackContents: + """Loaded body content of a pack — fragments, queries, batches, presets.""" + + agent_fragments: tuple[AgentFragment, ...] = () + queries: tuple[PackQuery, ...] = () + batches: tuple[PackBatch, ...] = () + registry_presets: tuple[PackRegistryPreset, ...] = () + + +@dataclass(frozen=True) +class Pack: + """A fully loaded pack — manifest + contents. + + ``source_path`` is the directory the pack was loaded from when + relevant (built-in or local). Entry-point packs may set it + empty. + """ + + manifest: PackManifest + contents: PackContents = field(default_factory=PackContents) + source_path: Path | None = None + + @property + def name(self) -> str: + return self.manifest.name + + @property + def version(self) -> str: + return self.manifest.version + + +__all__ = [ + "AgentFragment", + "Pack", + "PackBatch", + "PackContents", + "PackManifest", + "PackQuery", + "PackRegistryPreset", + "TARGET_AGENTS", + "TARGET_CLAUDE", + "VALID_TARGETS", +] diff --git a/src/bcli/packs/_registry.py b/src/bcli/packs/_registry.py new file mode 100644 index 0000000..e8165c1 --- /dev/null +++ b/src/bcli/packs/_registry.py @@ -0,0 +1,148 @@ +"""Pack discovery — built-in + entry-point + local path (R8). + +The registry is the source of truth for "what packs can I install?" +The OSS package ships two built-ins (``starter-generic``, +``cronus-demo``); third-party packages register via the +``bcli.packs`` entry-point group; ``bcli pack install --path `` +loads from a local directory for development. + +Entry-point providers register a callable (no args) that returns a +:class:`Pack`. This lets downstream packages keep their pack content +inside their own wheel rather than vendoring it into the OSS repo. +""" + +from __future__ import annotations + +import logging +from importlib import resources +from importlib.metadata import EntryPoint, entry_points +from pathlib import Path +from typing import Iterator + +from bcli.packs._loader import PackLoadError, load_pack +from bcli.packs._protocol import Pack + +logger = logging.getLogger("bcli.packs") + +# Group name third-party packages register under. +ENTRYPOINT_GROUP = "bcli.packs" + + +def builtin_packs_dir() -> Path | None: + """Resolve the ``packs/`` directory shipped in the wheel. + + Returns ``None`` if the directory doesn't exist — discovery + treats that as "no built-ins available" without crashing. + + Lookup order: + + 1. ``packs/`` next to the bcli source tree (editable install). + 2. ``bcli/packs/_builtin`` shipped inside the wheel (the + ``[tool.hatch.build.targets.wheel.force-include]`` mapping + in ``pyproject.toml``). + """ + repo_root = Path(__file__).resolve().parents[3] + candidate = repo_root / "packs" + if candidate.is_dir(): + return candidate + + # Wheel install: hatch force-includes packs/ -> bcli/packs/_builtin. + here = Path(__file__).resolve().parent + wheel_builtin = here / "_builtin" + if wheel_builtin.is_dir(): + return wheel_builtin + + # Final fallback: importlib.resources for any future layout. + try: + ref = resources.files("bcli.packs").joinpath("_builtin") + if ref.is_dir(): + return Path(str(ref)) + except (ModuleNotFoundError, AttributeError, FileNotFoundError): + pass + return None + + +def discover_builtin_packs(root: Path | None = None) -> list[Pack]: + """Scan ``root`` (defaults to :func:`builtin_packs_dir`) for packs. + + A *pack directory* is any immediate subdirectory containing a + ``pack.yaml``. Subdirectories without one are ignored. + """ + root = root or builtin_packs_dir() + if root is None or not root.is_dir(): + return [] + out: list[Pack] = [] + for child in sorted(root.iterdir()): + if not child.is_dir(): + continue + if not (child / "pack.yaml").is_file(): + continue + try: + out.append(load_pack(child)) + except PackLoadError as e: + logger.warning("Skipping built-in pack %s: %s", child, e) + return out + + +def discover_entrypoint_packs() -> list[Pack]: + """Run every ``bcli.packs`` entry-point and collect the returned packs. + + A failing provider logs a warning and is skipped — one broken + third-party pack must not block the registry. + """ + out: list[Pack] = [] + for ep in _iter_entrypoints(): + try: + provider = ep.load() + except Exception as exc: # noqa: BLE001 + logger.warning( + "bcli.packs entry-point %r failed to load: %s", ep.name, exc + ) + continue + try: + pack = provider() if callable(provider) else provider + except Exception as exc: # noqa: BLE001 + logger.warning( + "bcli.packs entry-point %r raised: %s", ep.name, exc + ) + continue + if not isinstance(pack, Pack): + logger.warning( + "bcli.packs entry-point %r did not return a Pack (got %s)", + ep.name, type(pack).__name__, + ) + continue + out.append(pack) + return out + + +def _iter_entrypoints() -> Iterator[EntryPoint]: + try: + eps = entry_points(group=ENTRYPOINT_GROUP) + except Exception: # pragma: no cover — defensive + return + yield from eps + + +def discover_all(*, builtin_root: Path | None = None) -> dict[str, Pack]: + """Return ``{name: Pack}`` of every known pack — built-in + plugin. + + On name collision the later source wins, so a third-party pack + can override a built-in by registering the same name (rare but + sometimes desirable for Beautech-style overlays). + """ + out: dict[str, Pack] = {} + for p in discover_builtin_packs(builtin_root): + out[p.name] = p + for p in discover_entrypoint_packs(): + out[p.name] = p + return out + + +__all__ = [ + "ENTRYPOINT_GROUP", + "builtin_packs_dir", + "discover_all", + "discover_builtin_packs", + "discover_entrypoint_packs", +] From 8f3d1116ca2e978b1080c20f94f35fcbbf525b5f Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 16:55:15 -0500 Subject: [PATCH 06/17] feat(packs): bcli pack list / info / install / uninstall CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typer group registered alongside skill / extract. Mirrors the skill_init UX: per-command confirmation prompts (skipped with --yes), dry-run mode renders the plan without touching disk, helpful diff when info-querying an installed pack. Flags on `install`: - --profile / -p (defaults to active profile) - --target (project root; defaults to CWD if .claude/ present, else $HOME) - --dry-run - --replace-owned + --accept-conflicts (R7 — two-flag override) - --yes / -y Flags on `uninstall`: - --profile / -p - --yes / -y Pack recommendations (R8) surface as a one-line hint after install: "This pack recommends enabling these `bcli ask` context providers". They are never auto-enabled — user config in [ask] context_providers is the binding decision. --- src/bcli_cli/app.py | 2 + src/bcli_cli/commands/pack_cmd.py | 339 ++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 src/bcli_cli/commands/pack_cmd.py diff --git a/src/bcli_cli/app.py b/src/bcli_cli/app.py index 404a141..35b20b8 100644 --- a/src/bcli_cli/app.py +++ b/src/bcli_cli/app.py @@ -195,6 +195,7 @@ def _emit_command_summary() -> None: endpoint_cmd, env_cmd, get_cmd, + pack_cmd, patch_cmd, post_cmd, query_cmd, @@ -218,6 +219,7 @@ def _emit_command_summary() -> None: # ``skill_cmd`` module aliases ``skill_init_cmd.app`` so ``@app.command("install")`` # attaches to the same group without a duplicate ``add_typer`` call. app.add_typer(skill_init_cmd.app, name="skill", help="Generate a per-user bcli skill bundle (AIP Phase 7)") +app.add_typer(pack_cmd.app, name="pack", help="Install reusable query/batch/fragment packs") app.command(name="get")(get_cmd.get_command) app.command(name="post")(post_cmd.post_command) app.command(name="patch")(patch_cmd.patch_command) diff --git a/src/bcli_cli/commands/pack_cmd.py b/src/bcli_cli/commands/pack_cmd.py new file mode 100644 index 0000000..97c0ec5 --- /dev/null +++ b/src/bcli_cli/commands/pack_cmd.py @@ -0,0 +1,339 @@ +"""``bcli pack`` — list / info / install / uninstall packs (Part 1). + +The CLI surface for the SDK in :mod:`bcli.packs`. Mirrors the +``skill_init`` UX where reasonable: confirmation prompts, atomic +writes, ledger-driven cleanup. Reads the active profile name from +``state`` so the user doesn't have to repeat ``--profile`` on each +sub-command. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.table import Table + +from bcli.packs import ( + InstallError, + Pack, + PackLoadError, + discover_all, + install_pack, + list_ledgers, + load_pack, + read_ledger, + uninstall_pack, +) +from bcli_cli._state import state + +app = typer.Typer(no_args_is_help=True, help="Manage bcli packs (queries, batches, fragments)") +console = Console() +_stderr = Console(stderr=True) +logger = logging.getLogger("bcli.packs") + + +def _resolve_profile(profile_flag: str | None) -> str: + """Profile name → from flag, then state, then config default.""" + if profile_flag: + return profile_flag + if state.profile_name: + return state.profile_name + try: + cfg = state.config + return cfg.defaults.profile + except Exception: # noqa: BLE001 + return "default" + + +def _load_or_resolve( + name_or_path: str, available: dict[str, Pack] | None = None +) -> Pack: + """Resolve ``name_or_path`` to a :class:`Pack`. + + A path is loaded directly; a bare name is looked up in the + discovery registry. + """ + p = Path(name_or_path) + if p.exists(): + return load_pack(p) + packs = available if available is not None else discover_all() + if name_or_path not in packs: + suggestions = ", ".join(sorted(packs.keys())) or "(none)" + raise typer.BadParameter( + f"pack {name_or_path!r} not found. Available: {suggestions}" + ) + return packs[name_or_path] + + +# ─── list ─────────────────────────────────────────────────────────── + + +@app.command("list") +def list_cmd( + profile: Optional[str] = typer.Option(None, "--profile", "-p"), +) -> None: + """Show every pack the CLI can see (built-in + entry-point).""" + packs = discover_all() + if not packs: + _stderr.print( + "[yellow]No packs available. Built-ins ship under packs/ " + "in the repo; third-party packages register via the " + "'bcli.packs' entry-point group.[/yellow]" + ) + return + + resolved_profile = _resolve_profile(profile) + installed = { + led.pack_name: led for led in list_ledgers(resolved_profile) + } + + table = Table(title=f"bcli packs (profile: {resolved_profile})") + table.add_column("name", style="bold") + table.add_column("version") + table.add_column("installed") + table.add_column("description") + for name in sorted(packs.keys()): + p = packs[name] + installed_marker = ( + installed[name].pack_version + if name in installed else "[dim]—[/dim]" + ) + table.add_row( + name, + p.version, + installed_marker, + p.manifest.description or "", + ) + console.print(table) + + +# ─── info ─────────────────────────────────────────────────────────── + + +@app.command("info") +def info_cmd( + name: str = typer.Argument(..., help="Pack name (or path)"), + profile: Optional[str] = typer.Option(None, "--profile", "-p"), +) -> None: + """Show pack manifest + a diff against the live ledger.""" + pack = _load_or_resolve(name) + resolved_profile = _resolve_profile(profile) + led = read_ledger(pack.name, resolved_profile) + + console.print(f"[bold]{pack.name}[/bold] [dim]v{pack.version}[/dim]") + if pack.manifest.description: + console.print(pack.manifest.description) + if pack.manifest.target_profile: + console.print( + f"[dim]suggested target profile: " + f"{pack.manifest.target_profile}[/dim]" + ) + console.print() + console.print( + f" fragments: {len(pack.contents.agent_fragments)}" + ) + console.print(f" queries: {len(pack.contents.queries)}") + console.print(f" batches: {len(pack.contents.batches)}") + console.print( + f" registry_presets: {len(pack.contents.registry_presets)}" + ) + if pack.manifest.recommended_context_providers: + console.print( + " recommended context providers: " + + ", ".join(pack.manifest.recommended_context_providers) + ) + + console.print() + if led is None: + console.print( + f"[yellow]Not installed on profile {resolved_profile!r}.[/yellow]" + ) + else: + console.print( + f"[green]Installed v{led.pack_version} at {led.installed_at}[/green]" + ) + console.print(f" target: {led.target}") + if led.pack_version != pack.version: + console.print( + f"[yellow] ▲ installed version differs from source " + f"(installed: {led.pack_version}, source: {pack.version})[/yellow]" + ) + + +# ─── install ──────────────────────────────────────────────────────── + + +@app.command("install") +def install_cmd( + name: str = typer.Argument(..., help="Pack name or local directory"), + profile: Optional[str] = typer.Option(None, "--profile", "-p"), + target: Optional[Path] = typer.Option( + None, "--target", help="Install root (defaults to project or $HOME)" + ), + dry_run: bool = typer.Option(False, "--dry-run"), + replace_owned: bool = typer.Option( + False, "--replace-owned", + help="Allow overwriting endpoints owned by another pack", + ), + accept_conflicts: bool = typer.Option( + False, "--accept-conflicts", + help="Required alongside --replace-owned to acknowledge the diff", + ), + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip interactive confirmation", + ), +) -> None: + """Install a pack's contents under the active profile.""" + pack = _load_or_resolve(name) + resolved_profile = _resolve_profile(profile) + + try: + plan = install_pack( + pack, + profile=resolved_profile, + target=target, + dry_run=True, # always plan first + ) + except (InstallError, PackLoadError) as exc: + _stderr.print(f"[red]Install failed: {exc}[/red]") + raise typer.Exit(code=1) + + _render_plan(plan) + + if plan.conflicts and not (replace_owned and accept_conflicts): + _stderr.print( + "\n[red]Refusing to overwrite endpoints owned by another pack." + " Pass --replace-owned --accept-conflicts to override.[/red]" + ) + raise typer.Exit(code=1) + + if dry_run: + console.print("\n[dim]Dry run — nothing written.[/dim]") + return + + if not yes: + confirmed = typer.confirm( + f"Install {pack.name} v{pack.version} into " + f"profile {resolved_profile!r}?", + default=True, + ) + if not confirmed: + console.print("[yellow]Aborted.[/yellow]") + raise typer.Exit(code=1) + + try: + install_pack( + pack, + profile=resolved_profile, + target=target, + dry_run=False, + replace_owned=replace_owned, + accept_conflicts=accept_conflicts, + ) + except InstallError as exc: + _stderr.print(f"[red]Install failed: {exc}[/red]") + raise typer.Exit(code=1) + + console.print( + f"[green]Installed {pack.name} v{pack.version} on " + f"profile {resolved_profile!r}.[/green]" + ) + if pack.manifest.recommended_context_providers: + console.print( + "[dim]This pack recommends enabling these `bcli ask` " + "context providers (opt-in via [ask] context_providers in " + "config): " + + ", ".join(pack.manifest.recommended_context_providers) + + "[/dim]" + ) + + +# ─── uninstall ────────────────────────────────────────────────────── + + +@app.command("uninstall") +def uninstall_cmd( + name: str = typer.Argument(...), + profile: Optional[str] = typer.Option(None, "--profile", "-p"), + yes: bool = typer.Option(False, "--yes", "-y"), +) -> None: + """Remove a previously installed pack via its ledger.""" + resolved_profile = _resolve_profile(profile) + led = read_ledger(name, resolved_profile) + if led is None: + _stderr.print( + f"[yellow]Pack {name!r} is not installed on profile " + f"{resolved_profile!r}.[/yellow]" + ) + raise typer.Exit(code=1) + if not yes: + if not typer.confirm( + f"Uninstall {name} v{led.pack_version} from profile " + f"{resolved_profile!r}? " + f"({len(led.paths)} artefacts, " + f"{len(led.registry_endpoints)} endpoints)", + default=False, + ): + console.print("[yellow]Aborted.[/yellow]") + raise typer.Exit(code=1) + result = uninstall_pack(name, profile=resolved_profile) + console.print( + f"[green]Removed {len(result.files_removed)} files, " + f"{len(result.blocks_removed)} marker blocks.[/green]" + ) + if result.warnings: + console.print("[yellow]Warnings:[/yellow]") + for w in result.warnings: + console.print(f" - {w}") + + +# ─── Rendering helpers ────────────────────────────────────────────── + + +def _render_plan(plan) -> None: + """Pretty-print an :class:`InstallPlan` for human review.""" + p = plan.pack + console.print( + f"\n[bold]Plan for {p.name} v{p.version}[/bold]" + f" → profile [italic]{plan.profile}[/italic]" + ) + console.print(f"target dir: {plan.target}") + if plan.fragment_writes: + console.print(f"\nAgent fragments ({len(plan.fragment_writes)}):") + for pf in plan.fragment_writes: + console.print( + f" [dim]write[/dim] {pf.path}" + f" [dim]({pf.fragment.targets})[/dim]" + ) + if plan.block_writes: + console.print(f"\nMarker blocks ({len(plan.block_writes)}):") + for blk in plan.block_writes: + console.print(f" [dim]splice[/dim] {blk.target_file} ← {blk.block_id}") + if plan.query_writes: + console.print(f"\nSaved queries ({len(plan.query_writes)}):") + for pq in plan.query_writes: + console.print(f" [dim]merge[/dim] {pq.query.name}") + if plan.batch_writes: + console.print(f"\nBatches ({len(plan.batch_writes)}):") + for pb in plan.batch_writes: + console.print(f" [dim]write[/dim] {pb.path}") + if plan.preset_writes: + console.print(f"\nRegistry presets ({len(plan.preset_writes)}):") + for ps in plan.preset_writes: + console.print(f" [dim]merge[/dim] {ps.name} → {ps.target_path}") + if plan.conflicts: + console.print( + f"\n[red]Conflicts ({len(plan.conflicts)}):[/red]" + ) + for c in plan.conflicts: + console.print( + f" - {c.endpoint} owned by " + f"{c.incumbent_pack} v{c.incumbent_version}" + ) + + +__all__ = ["app"] From fa8d0ec8dde5ffe117ed99912f6e29db744aeadb Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 16:55:44 -0500 Subject: [PATCH 07/17] feat(packs): ship starter-generic + cronus-demo built-in packs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reference packs in the OSS repo. Both fit through the standard v2.0 endpoint registry so they install cleanly against any BC tenant. starter-generic (v0.1.0) — the day-1 onboarding pack: - 6 saved queries — vendor-by-no, customer-by-no, open-pos, ar-aging-buckets, recent-posted-invoices, inventory-on-hand - 2 read-only batches — weekly-ar-snapshot, month-end-readonly-audit - 3 AGENTS.md fragments — endpoint-discovery, filter-syntax-cheatsheet, common-errors cronus-demo (v0.1.0) — Microsoft CRONUS-tenant demo: - 1 month-end batch (lifted from examples/month-end-cronus.yaml) - 1 sample queries file (lifted from examples/queries/sample.yaml) - 2 fragments — cronus-orientation and month-end-walkthrough Beautech-flavoured packs land in bcli-beautech-bootstrap via the bcli.packs entry-point group; this OSS repo only ships mechanism + two generic, standard-API-only examples. --- .../cronus-demo/batches/month-end-cronus.yaml | 64 +++++++++++++++++++ .../fragments/cronus-orientation.md | 30 +++++++++ .../fragments/month-end-walkthrough.md | 45 +++++++++++++ packs/cronus-demo/pack.yaml | 23 +++++++ packs/cronus-demo/queries/cronus-sample.yaml | 53 +++++++++++++++ .../batches/month-end-readonly-audit.yaml | 39 +++++++++++ .../batches/weekly-ar-snapshot.yaml | 35 ++++++++++ .../fragments/common-errors.md | 50 +++++++++++++++ .../fragments/endpoint-discovery.md | 39 +++++++++++ .../fragments/filter-syntax-cheatsheet.md | 51 +++++++++++++++ packs/starter-generic/pack.yaml | 31 +++++++++ .../queries/ar-aging-buckets.yaml | 17 +++++ .../queries/customer-by-no.yaml | 11 ++++ .../queries/inventory-on-hand.yaml | 14 ++++ packs/starter-generic/queries/open-pos.yaml | 14 ++++ .../queries/recent-posted-invoices.yaml | 18 ++++++ .../starter-generic/queries/vendor-by-no.yaml | 11 ++++ 17 files changed, 545 insertions(+) create mode 100644 packs/cronus-demo/batches/month-end-cronus.yaml create mode 100644 packs/cronus-demo/fragments/cronus-orientation.md create mode 100644 packs/cronus-demo/fragments/month-end-walkthrough.md create mode 100644 packs/cronus-demo/pack.yaml create mode 100644 packs/cronus-demo/queries/cronus-sample.yaml create mode 100644 packs/starter-generic/batches/month-end-readonly-audit.yaml create mode 100644 packs/starter-generic/batches/weekly-ar-snapshot.yaml create mode 100644 packs/starter-generic/fragments/common-errors.md create mode 100644 packs/starter-generic/fragments/endpoint-discovery.md create mode 100644 packs/starter-generic/fragments/filter-syntax-cheatsheet.md create mode 100644 packs/starter-generic/pack.yaml create mode 100644 packs/starter-generic/queries/ar-aging-buckets.yaml create mode 100644 packs/starter-generic/queries/customer-by-no.yaml create mode 100644 packs/starter-generic/queries/inventory-on-hand.yaml create mode 100644 packs/starter-generic/queries/open-pos.yaml create mode 100644 packs/starter-generic/queries/recent-posted-invoices.yaml create mode 100644 packs/starter-generic/queries/vendor-by-no.yaml diff --git a/packs/cronus-demo/batches/month-end-cronus.yaml b/packs/cronus-demo/batches/month-end-cronus.yaml new file mode 100644 index 0000000..718343e --- /dev/null +++ b/packs/cronus-demo/batches/month-end-cronus.yaml @@ -0,0 +1,64 @@ +name: "CRONUS Month-End Customer Review" +# Demo workflow against Microsoft's built-in CRONUS demo company. +# Showcases three workflow features in one file: +# 1. Named parameters with --set / --params +# 2. Step-to-step chaining via ${{ steps.. }} +# (list results are indexed with integers: steps..0.) +# 3. Result capture via -o / -f +# +# Preview (no API calls, prints resolved requests): +# bcli batch run examples/month-end-cronus.yaml \ +# --set customer_name=Adatum --set month=2026-03 --dry-run +# +# Execute and save a JSON result bundle: +# bcli batch run examples/month-end-cronus.yaml \ +# --set customer_name=Adatum --set month=2026-03 -o review.json +# +# Execute and pretty-print each step's data inline: +# bcli batch run examples/month-end-cronus.yaml \ +# --set customer_name=Adatum --set month=2026-03 -f table +# +# Try different CRONUS customers: Adatum, Trey, Fabrikam, Relecloud, School +# +# Prerequisites: +# - Active profile points at a BC Sandbox environment with CRONUS loaded +# - Default company (or --company flag) set to the CRONUS company id +# (run `bcli env list-companies` to find it) + +params: + customer_name: + description: "Display-name fragment of a CRONUS customer (e.g. Adatum, Trey, Fabrikam)" + required: true + month: + description: "Month in YYYY-MM format (e.g. 2026-03)" + required: true + +steps: + # 1. Resolve the customer by name fragment. Selecting id + number so both are + # available to downstream steps via step-chaining references. + - name: find_customer + action: get + endpoint: customers + params: + filter: "contains(displayName, '${{ params.customer_name }}')" + select: "id,number,displayName,email,balance" + top: 1 + + # 2. Sales invoices for the target month — chains the resolved customer number. + # Note the integer list-index syntax (.0) for GET results. + - name: invoices + action: get + endpoint: salesInvoices + params: + filter: "customerNumber eq '${{ steps.find_customer.0.number }}' and invoiceDate ge ${{ params.month }}-01" + select: "number,invoiceDate,totalAmountIncludingTax,status" + orderby: "invoiceDate desc" + top: 50 + + # 3. Aged-AR snapshot — standard v2.0 entity, uses the GUID id from step 1. + - name: aging + action: get + endpoint: agedAccountsReceivables + params: + filter: "customerId eq ${{ steps.find_customer.0.id }}" + select: "name,currencyCode,balanceDue,currentAmount,period1Amount,period2Amount,period3Amount" diff --git a/packs/cronus-demo/fragments/cronus-orientation.md b/packs/cronus-demo/fragments/cronus-orientation.md new file mode 100644 index 0000000..677bfb6 --- /dev/null +++ b/packs/cronus-demo/fragments/cronus-orientation.md @@ -0,0 +1,30 @@ +## CRONUS demo tenant orientation (bcli cronus-demo pack) + +Microsoft ships a "Cronus" demo dataset on every fresh BC sandbox. +This pack assumes it's loaded — if you see "no companies returned" +or zero CRONUS customers, the tenant is empty and the demo queries +won't return data. + +### Recognising a CRONUS-loaded tenant + +```bash +# Companies named "CRONUS UK" / "CRONUS USA" / "CRONUS International Ltd" +bcli env list-companies + +# Demo customers — Adatum, Trey Research, Fabrikam, Relecloud, School of Fine Art +bcli get customers --filter "startswith(displayName, 'Adatum')" -f table +``` + +### Entities the demo workflow touches + +- `customers` — Adatum / Trey / Fabrikam are the usual reviewer + targets +- `salesInvoices` — postings tagged with the `month` parameter +- `customerPayments` — payment lines linked back via `invoiceId` +- `vendors` + `purchaseOrders` — for the AP side of month-end + +### Where this pack came from + +Imported from `examples/month-end-cronus.yaml` and +`examples/queries/sample.yaml` in the bcli repo. The original files +remain in `examples/` for source-level reference. diff --git a/packs/cronus-demo/fragments/month-end-walkthrough.md b/packs/cronus-demo/fragments/month-end-walkthrough.md new file mode 100644 index 0000000..fb53af2 --- /dev/null +++ b/packs/cronus-demo/fragments/month-end-walkthrough.md @@ -0,0 +1,45 @@ +## Month-end walkthrough (bcli cronus-demo pack) + +The `month-end-cronus.yaml` batch is the canonical "drive bcli end +to end" demo. Walk an operator (or another agent) through it like +this: + +### 1. Preview without making API calls + +```bash +bcli batch run month-end-cronus.yaml \ + --set customer_name=Adatum \ + --set month=2026-03 \ + --dry-run +``` + +The dry-run resolves every parameter, expands step-chained +references (`${{ steps.find_customer.0.id }}`), and prints the HTTP +requests it would issue. No network traffic; safe on read-only +profiles. + +### 2. Execute and capture a JSON bundle + +```bash +bcli batch run month-end-cronus.yaml \ + --set customer_name=Adatum \ + --set month=2026-03 \ + -o month-end-adatum.json +``` + +The `-o` flag writes a single JSON document containing every step's +result envelope — useful as an attachment to a review email or as +input for `bcli ask` (Part 2 of the pack/ask plan). + +### 3. Try different customers + +CRONUS ships with Adatum, Trey, Fabrikam, Relecloud, and School of +Fine Art as the headline customers. Each has distinct invoice / +payment patterns — Adatum is the simplest, School is the most +complex (multi-currency). + +### Where step chaining shines + +The batch's `find_customer` step returns a list. Downstream steps +reference `${{ steps.find_customer.0.id }}` to pluck the first hit's +GUID — that's the pattern any "find then drill in" workflow uses. diff --git a/packs/cronus-demo/pack.yaml b/packs/cronus-demo/pack.yaml new file mode 100644 index 0000000..e75b65a --- /dev/null +++ b/packs/cronus-demo/pack.yaml @@ -0,0 +1,23 @@ +name: cronus-demo +version: 0.1.0 +description: >- + Demo workflows for Microsoft's CRONUS sample tenant. Showcases + multi-step batches, step chaining, and parameter substitution. + Assumes the active profile points at a sandbox with CRONUS loaded + (Microsoft's default demo dataset). +target_profile: cronus + +contents: + agent_fragments: + - name: cronus-orientation.md + targets: [agents] + description: How to identify a CRONUS-loaded tenant and which entities matter + - name: month-end-walkthrough.md + targets: [agents] + description: Walking an agent through the month-end batch + + queries: + - cronus-sample.yaml + + batches: + - month-end-cronus.yaml diff --git a/packs/cronus-demo/queries/cronus-sample.yaml b/packs/cronus-demo/queries/cronus-sample.yaml new file mode 100644 index 0000000..9c72724 --- /dev/null +++ b/packs/cronus-demo/queries/cronus-sample.yaml @@ -0,0 +1,53 @@ +# Sample saved-query file for `bcli q`. +# +# Copy this to ~/.config/bcli/queries/.yaml and adjust the entity +# names and field references to match the endpoints your profile knows about. +# Run `bcli endpoint list` to see what's available, and +# `bcli endpoint fields ` to discover field names. + +queries: + + # Look up a customer by display name. + customer-by-name: + description: Find a customer by display name (case-sensitive on BC's side) + endpoint: customers + params: + name: + required: true + filter: "displayName eq '${{ params.name }}'" + select: "number,displayName,email,phoneNumber,blocked" + top: 25 + + # Recently-modified items, useful as a "what changed today" view. + recent-items: + description: Items modified in the last N days + endpoint: items + params: + days: + default: 7 + # OData duration math; tweak the field name if your tenant uses a custom one. + filter: "lastModifiedDateTime gt ${{ params.cutoff }}" + orderby: "lastModifiedDateTime desc" + top: 50 + # NB: the value of ${{ params.cutoff }} must be set on the command line, e.g. + # bcli q recent-items cutoff=2026-04-01T00:00:00Z + + # Outstanding sales invoices for a single customer. Demonstrates the + # validation knobs you can add per param — type / pattern / min / max / + # enum are all enforced locally before the HTTP call. + open-invoices-by-customer: + description: Open sales invoices for a given customer + endpoint: customerSalesInvoices + params: + customer-id: + required: true + type: string + pattern: "^C[0-9]{5}$" # rejects free-form text like "' or 1 eq 1--" + limit: + default: 50 + type: integer + min: 1 + max: 1000 + filter: "customerNumber eq '${{ params.customer-id }}' and status eq 'Open'" + orderby: "dueDate asc" + top: "${{ params.limit }}" diff --git a/packs/starter-generic/batches/month-end-readonly-audit.yaml b/packs/starter-generic/batches/month-end-readonly-audit.yaml new file mode 100644 index 0000000..59a5d37 --- /dev/null +++ b/packs/starter-generic/batches/month-end-readonly-audit.yaml @@ -0,0 +1,39 @@ +name: "Month-end read-only audit" +# Sanity-check report run at month close. Pulls the three artefacts a +# financial reviewer typically asks for first: open POs, AR aging, +# unposted journal lines. +# +# bcli batch run month-end-readonly-audit.yaml \ +# --set month_start=2026-04-01 --set month_end=2026-04-30 -o month-end.json + +params: + month_start: + description: "First day of the month in ISO format (e.g. 2026-04-01)" + required: true + month_end: + description: "Last day of the month in ISO format (e.g. 2026-04-30)" + required: true + +steps: + - name: open_pos + action: get + endpoint: purchaseOrders + filter: "orderDate ge ${{ params.month_start }} and orderDate le ${{ params.month_end }}" + select: "number,orderDate,vendorNumber,vendorName,status,totalAmountIncludingTax" + orderby: "orderDate asc" + top: 500 + + - name: aged_ar + action: get + endpoint: agedAccountsReceivables + select: "customerNumber,name,balanceDue,currentAmount,period1Amount,period2Amount,period3Amount" + orderby: "balanceDue desc" + top: 100 + + - name: pending_journals + action: get + endpoint: journalLines + filter: "postingDate ge ${{ params.month_start }} and postingDate le ${{ params.month_end }}" + select: "lineNumber,postingDate,accountNumber,amount,description" + orderby: "postingDate asc" + top: 1000 diff --git a/packs/starter-generic/batches/weekly-ar-snapshot.yaml b/packs/starter-generic/batches/weekly-ar-snapshot.yaml new file mode 100644 index 0000000..be79df7 --- /dev/null +++ b/packs/starter-generic/batches/weekly-ar-snapshot.yaml @@ -0,0 +1,35 @@ +name: "Weekly AR snapshot (read-only)" +# Read-only multi-step report. Captures aged AR, recent posted +# invoices, and top customers by outstanding balance in a single +# review-friendly artefact. Safe to run on any profile. +# +# bcli batch run weekly-ar-snapshot.yaml -o weekly.json + +params: + cutoff: + description: "ISO date — pull invoices posted on/after this date" + required: true + +steps: + - name: aged_ar + action: get + endpoint: agedAccountsReceivables + select: "customerNumber,name,balanceDue,currentAmount,period1Amount,period2Amount,period3Amount" + orderby: "balanceDue desc" + top: 50 + + - name: recent_invoices + action: get + endpoint: salesInvoices + filter: "postingDate ge ${{ params.cutoff }}" + select: "number,postingDate,customerNumber,customerName,totalAmountIncludingTax,status" + orderby: "postingDate desc" + top: 100 + + - name: top_customers + action: get + endpoint: customers + filter: "balanceDue gt 0" + select: "number,displayName,balanceDue,paymentTermsId" + orderby: "balanceDue desc" + top: 25 diff --git a/packs/starter-generic/fragments/common-errors.md b/packs/starter-generic/fragments/common-errors.md new file mode 100644 index 0000000..60582f6 --- /dev/null +++ b/packs/starter-generic/fragments/common-errors.md @@ -0,0 +1,50 @@ +## Common BC errors and how to read them (bcli starter pack) + +bcli surfaces every error with a remediation hint where one is +known. The most common categories: + +### 400 — Bad request (`ValidationError`) + +- "**The filter is malformed**" → run `bcli endpoint fields ` + to list real field names, then retry. The pre-flight validator + suggests close matches. +- "**…cannot be resolved**" → check the entity name with + `bcli endpoint search`. Custom endpoints need the profile that + imported them; standard ones (`vendors`, `customers`, …) work + everywhere. + +### 401 — Authentication failure (`AuthError`) + +- "**AADSTS70011: invalid scope**" → the API permission on the + client app is wrong. Use `bcli auth check` to see which permission + is being requested vs granted. +- "**token expired**" → just retry; bcli refreshes automatically. If + it keeps failing, `bcli auth purge` clears the cached token. + +### 403 — Forbidden (`ForbiddenError`) + +The token is valid but the BC user doesn't have permission for that +entity. Adding a permission set is a BC admin task — bcli can't fix +this. The error message usually names the missing permission. + +### 404 — Not found (`NotFoundError`) + +- Wrong company id → `bcli company list` then `bcli company use X`. +- The record really doesn't exist. + +### 429 / 503 — Rate limiting / server hiccups (`ThrottledError` / `ServerError`) + +bcli retries automatically (up to 3 with exponential backoff). If you +see this surface to the user, BC is genuinely throttling — wait a +few seconds and retry. + +### Where to look next + +```bash +# Replay the last error with full HTTP detail +bcli --debug + +# bcli leaves a redacted snapshot of every failure here for the +# `bcli ask` reflex command (Part 2): +cat ~/.config/bcli/last-error.json +``` diff --git a/packs/starter-generic/fragments/endpoint-discovery.md b/packs/starter-generic/fragments/endpoint-discovery.md new file mode 100644 index 0000000..f5ae0b0 --- /dev/null +++ b/packs/starter-generic/fragments/endpoint-discovery.md @@ -0,0 +1,39 @@ +## Endpoint discovery (bcli starter pack) + +Never guess endpoint names. Always discover them: + +```bash +# What entities are available on the active profile? +bcli endpoint list + +# Search for an entity by fuzzy name match +bcli endpoint search vendor +bcli endpoint search "purchase order" + +# Get full metadata (publisher / group / version / fields) as JSON +bcli endpoint info vendors -f json +``` + +If `endpoint search` returns nothing, the entity is not registered +on the current profile — try a different profile (`bcli --profile X +endpoint search …`) or import a custom endpoint with `bcli registry +import`. + +### Field discovery (don't guess column names) + +```bash +# Sample one record and list its fields +bcli endpoint fields vendors + +# The fields are then persisted into the custom registry, so the +# next `--filter` validation knows them and can suggest close +# matches when you typo a name. +``` + +### Pattern + +1. **Discover** — `bcli endpoint search ` +2. **Inspect** — `bcli endpoint info -f json` +3. **Sample** — `bcli get --top 1 -f json` (or `bcli endpoint + fields ` to record the field names) +4. **Query** — `bcli get --filter "…" --select "id,no,name"` diff --git a/packs/starter-generic/fragments/filter-syntax-cheatsheet.md b/packs/starter-generic/fragments/filter-syntax-cheatsheet.md new file mode 100644 index 0000000..da178cd --- /dev/null +++ b/packs/starter-generic/fragments/filter-syntax-cheatsheet.md @@ -0,0 +1,51 @@ +## OData $filter cheatsheet (bcli starter pack) + +Business Central exposes OData v4 filters. The most common shapes: + +### Equality + comparison + +```bash +# String equality — single quotes around the value +bcli get vendors --filter "no eq 'V00010'" +bcli get customers --filter "displayName eq 'Adatum Corporation'" + +# Numeric / boolean +bcli get items --filter "unitPrice gt 100" +bcli get vendors --filter "blocked eq false" + +# Date range — ISO-8601 unquoted, ge/le inclusive +bcli get salesInvoices --filter \ + "postingDate ge 2026-01-01 and postingDate le 2026-01-31" +``` + +### Substring, list, and grouping + +```bash +# Substring +bcli get vendors --filter "contains(displayName, 'Air')" +bcli get customers --filter "startswith(displayName, 'Adatum')" + +# Multiple conditions +bcli get items --filter "unitPrice gt 50 and inventory gt 0" + +# Grouping with `and` / `or` / `not` +bcli get salesInvoices --filter \ + "(status eq 'Open' or status eq 'Draft') and customerId eq 'GUID'" +``` + +### Choosing fields + ordering + paging + +```bash +bcli get vendors \ + --filter "blocked eq false" \ + --select "no,displayName,balance,currencyCode" \ + --orderby "balance desc" \ + --top 25 +``` + +### Filter validation (catches typos before HTTP) + +bcli runs a pre-flight check on `--filter` when the entity's fields +are known. Misspelled field names get a "Did you mean: …?" suggestion +*before* a single HTTP request is made. If your filter still fails, +run `bcli endpoint fields ` first to populate the field list. diff --git a/packs/starter-generic/pack.yaml b/packs/starter-generic/pack.yaml new file mode 100644 index 0000000..d07fe98 --- /dev/null +++ b/packs/starter-generic/pack.yaml @@ -0,0 +1,31 @@ +name: starter-generic +version: 0.1.0 +description: >- + Day-1 onboarding pack for any Business Central tenant. Standard + v2.0 endpoints only — no custom registry, no tenant-specific + assumptions. Install, run `bcli skill install`, and you have + six working saved queries plus operational guidance fragments. + +contents: + agent_fragments: + - name: endpoint-discovery.md + targets: [agents] + description: How an agent should drive bcli to discover endpoints + - name: filter-syntax-cheatsheet.md + targets: [agents] + description: OData $filter syntax with concrete examples + - name: common-errors.md + targets: [agents] + description: How to interpret common HTTP errors from BC + + queries: + - vendor-by-no.yaml + - customer-by-no.yaml + - open-pos.yaml + - ar-aging-buckets.yaml + - recent-posted-invoices.yaml + - inventory-on-hand.yaml + + batches: + - weekly-ar-snapshot.yaml + - month-end-readonly-audit.yaml diff --git a/packs/starter-generic/queries/ar-aging-buckets.yaml b/packs/starter-generic/queries/ar-aging-buckets.yaml new file mode 100644 index 0000000..fcb61c1 --- /dev/null +++ b/packs/starter-generic/queries/ar-aging-buckets.yaml @@ -0,0 +1,17 @@ +queries: + ar-aging-buckets: + description: Aged accounts receivable summary; param "as_of" controls the cutoff date + endpoint: agedAccountsReceivables + params: + as_of: + default: "today" + type: string + description: Cutoff date YYYY-MM-DD or 'today' + limit: + default: 100 + type: integer + min: 1 + max: 5000 + select: "customerNumber,name,currencyCode,balanceDue,currentAmount,period1Amount,period2Amount,period3Amount" + orderby: "balanceDue desc" + top: "${{ params.limit }}" diff --git a/packs/starter-generic/queries/customer-by-no.yaml b/packs/starter-generic/queries/customer-by-no.yaml new file mode 100644 index 0000000..01a83fb --- /dev/null +++ b/packs/starter-generic/queries/customer-by-no.yaml @@ -0,0 +1,11 @@ +queries: + customer-by-no: + description: Look up a customer by its BC customer number (no field) + endpoint: customers + params: + no: + required: true + type: string + filter: "number eq '${{ params.no }}'" + select: "number,displayName,email,blocked,balanceDue,currencyCode" + top: 1 diff --git a/packs/starter-generic/queries/inventory-on-hand.yaml b/packs/starter-generic/queries/inventory-on-hand.yaml new file mode 100644 index 0000000..3206fb6 --- /dev/null +++ b/packs/starter-generic/queries/inventory-on-hand.yaml @@ -0,0 +1,14 @@ +queries: + inventory-on-hand: + description: Items currently in stock, ordered by inventory descending + endpoint: items + params: + limit: + default: 100 + type: integer + min: 1 + max: 5000 + filter: "inventory gt 0 and blocked eq false" + select: "number,displayName,inventory,unitCost,unitPrice" + orderby: "inventory desc" + top: "${{ params.limit }}" diff --git a/packs/starter-generic/queries/open-pos.yaml b/packs/starter-generic/queries/open-pos.yaml new file mode 100644 index 0000000..3bfc7d6 --- /dev/null +++ b/packs/starter-generic/queries/open-pos.yaml @@ -0,0 +1,14 @@ +queries: + open-pos: + description: List open purchase orders, newest first + endpoint: purchaseOrders + params: + limit: + default: 50 + type: integer + min: 1 + max: 1000 + filter: "status ne 'Released' and status ne 'Cancelled'" + select: "number,orderDate,vendorNumber,vendorName,status,totalAmountIncludingTax,currencyCode" + orderby: "orderDate desc" + top: "${{ params.limit }}" diff --git a/packs/starter-generic/queries/recent-posted-invoices.yaml b/packs/starter-generic/queries/recent-posted-invoices.yaml new file mode 100644 index 0000000..19536a8 --- /dev/null +++ b/packs/starter-generic/queries/recent-posted-invoices.yaml @@ -0,0 +1,18 @@ +queries: + recent-posted-invoices: + description: Recently-posted sales invoices, newest first + endpoint: salesInvoices + params: + days: + default: 30 + type: integer + min: 1 + max: 365 + cutoff: + required: true + type: string + description: ISO date — set on the command line, e.g. cutoff=2026-04-01 + filter: "status eq 'Open' and postingDate ge ${{ params.cutoff }}" + select: "number,postingDate,customerNumber,customerName,totalAmountIncludingTax,currencyCode,status" + orderby: "postingDate desc" + top: 100 diff --git a/packs/starter-generic/queries/vendor-by-no.yaml b/packs/starter-generic/queries/vendor-by-no.yaml new file mode 100644 index 0000000..869c4a3 --- /dev/null +++ b/packs/starter-generic/queries/vendor-by-no.yaml @@ -0,0 +1,11 @@ +queries: + vendor-by-no: + description: Look up a vendor by its BC vendor number (no field) + endpoint: vendors + params: + no: + required: true + type: string + filter: "no eq '${{ params.no }}'" + select: "no,displayName,email,blocked,balance,currencyCode" + top: 1 From 451a4885c105466201729cc6ee690221f1ab7e1a Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 16:55:54 -0500 Subject: [PATCH 08/17] test(packs): 19 tests + pyproject pack wheel layout + changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_loader.py — manifest schema validation, missing fields, bad targets, broken YAML - test_registry.py — built-in discovery, skips non-pack dirs, warns on broken manifests - test_installer.py — install writes all artefacts with provenance; fragment targets route blocks to AGENTS.md vs CLAUDE.md; idempotent re-install; conflict refusal + --replace-owned override; uninstall round-trips; dry-run writes nothing - test_builtins.py — both shipped packs install end-to-end against a tmp config dir, co-exist on the same profile, and uninstall cleanly Also: - pyproject.toml — hatch force-include maps packs/ → bcli/packs/_builtin in the wheel so the shipped CLI sees them. sdist explicitly includes packs/, examples/, docs/. - CHANGELOG.md — Part 1 entry under [Unreleased]. --- CHANGELOG.md | 24 ++++ pyproject.toml | 17 +++ tests/test_packs/__init__.py | 0 tests/test_packs/conftest.py | 106 ++++++++++++++ tests/test_packs/test_builtins.py | 106 ++++++++++++++ tests/test_packs/test_installer.py | 221 +++++++++++++++++++++++++++++ tests/test_packs/test_loader.py | 93 ++++++++++++ tests/test_packs/test_registry.py | 47 ++++++ 8 files changed, 614 insertions(+) create mode 100644 tests/test_packs/__init__.py create mode 100644 tests/test_packs/conftest.py create mode 100644 tests/test_packs/test_builtins.py create mode 100644 tests/test_packs/test_installer.py create mode 100644 tests/test_packs/test_loader.py create mode 100644 tests/test_packs/test_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f8ab48..8d7dd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Part 1 (`bcli pack`) + +- **`bcli pack`** command group: `list`, `info`, `install`, + `uninstall`. Discovers packs from three sources: built-in + (``packs/`` in the repo), entry-point group ``bcli.packs``, and + ``--path `` for local development. +- **Pack manifest format** (``pack.yaml``) with `agent_fragments`, + `queries`, `batches`, `registry_presets`, and + `recommended_context_providers`. Each fragment declares + `targets:` (`agents` and/or `claude`); default `[agents]` (R3). +- **Install ledger** at + ``~/.config/bcli/packs//.json`` recording every + artefact written, with per-entry `rendered_hash` and `owner` so + uninstall is provenance-driven (R2). +- **Conflict detection** on registry presets (R7): a second pack + cannot silently overwrite an endpoint owned by another pack — + ``--replace-owned --accept-conflicts`` is the two-flag escape + hatch. +- **Idempotent re-install**: marker blocks in AGENTS.md / CLAUDE.md + are replaced in place via ID + content_hash, never duplicated. +- **Two built-in packs**: `starter-generic` (6 queries, 2 batches, + 3 fragments — uses only standard v2.0 endpoints) and + `cronus-demo` (Microsoft CRONUS demo workflow). + ### Added — Part 0 (context infrastructure for LLM features) - **`bcli.context` package** — shared, model-bound context layer that diff --git a/pyproject.toml b/pyproject.toml index 2108707..f5c5582 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,23 @@ dev = [ [tool.hatch.build.targets.wheel] packages = ["src/bcli", "src/bcli_cli", "src/bcli_mcp"] +[tool.hatch.build.targets.wheel.force-include] +"packs" = "bcli/packs/_builtin" + +[tool.hatch.build.targets.sdist] +include = [ + "src/", + "packs/", + "tests/", + "examples/", + "docs/", + "README.md", + "LICENSE", + "NOTICE", + "CHANGELOG.md", + "pyproject.toml", +] + [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" diff --git a/tests/test_packs/__init__.py b/tests/test_packs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_packs/conftest.py b/tests/test_packs/conftest.py new file mode 100644 index 0000000..0f49e87 --- /dev/null +++ b/tests/test_packs/conftest.py @@ -0,0 +1,106 @@ +"""Shared fixtures for pack tests — build small packs on the fly.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + + +@pytest.fixture +def make_pack(tmp_path: Path): + """Factory: ``make_pack(name, **content)`` returns the pack dir.""" + + def _make( + name: str = "demo", + version: str = "0.1.0", + *, + fragments: dict[str, str] | None = None, + fragment_targets: dict[str, list[str]] | None = None, + queries: dict[str, dict] | None = None, + batches: dict[str, str] | None = None, + presets: dict[str, dict] | None = None, + recommended_context_providers: list[str] | None = None, + ) -> Path: + root = tmp_path / "packs-src" / name + root.mkdir(parents=True, exist_ok=True) + manifest: dict = { + "name": name, + "version": version, + "description": f"test pack {name}", + "contents": {}, + } + contents = manifest["contents"] + + if fragments: + (root / "fragments").mkdir(exist_ok=True) + specs = [] + for fname, body in fragments.items(): + (root / "fragments" / fname).write_text(body, encoding="utf-8") + if fragment_targets and fname in fragment_targets: + specs.append({"name": fname, "targets": fragment_targets[fname]}) + else: + specs.append(fname) + contents["agent_fragments"] = specs + + if queries: + (root / "queries").mkdir(exist_ok=True) + query_files = [] + for qname, body in queries.items(): + file = f"{qname}.yaml" + (root / "queries" / file).write_text( + yaml.safe_dump({"queries": {qname: body}}, sort_keys=False), + encoding="utf-8", + ) + query_files.append(file) + contents["queries"] = query_files + + if batches: + (root / "batches").mkdir(exist_ok=True) + batch_files = [] + for filename, body in batches.items(): + (root / "batches" / filename).write_text(body, encoding="utf-8") + batch_files.append(filename) + contents["batches"] = batch_files + + if presets: + (root / "presets").mkdir(exist_ok=True) + preset_files = [] + for pname, body in presets.items(): + file = f"{pname}.json" + (root / "presets" / file).write_text( + json.dumps({"endpoints": {pname: body}}), + encoding="utf-8", + ) + preset_files.append(file) + contents["registry_presets"] = preset_files + + if recommended_context_providers: + manifest["recommended_context_providers"] = list( + recommended_context_providers + ) + + (root / "pack.yaml").write_text( + yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8" + ) + return root + + return _make + + +@pytest.fixture +def config_dir(tmp_path: Path) -> Path: + """Per-test ``~/.config/bcli`` substitute.""" + p = tmp_path / "config" + p.mkdir() + return p + + +@pytest.fixture +def install_target(tmp_path: Path) -> Path: + """Per-test fake project root for the install target.""" + p = tmp_path / "target" + p.mkdir() + return p diff --git a/tests/test_packs/test_builtins.py b/tests/test_packs/test_builtins.py new file mode 100644 index 0000000..4eca9b0 --- /dev/null +++ b/tests/test_packs/test_builtins.py @@ -0,0 +1,106 @@ +"""End-to-end install of the two in-repo OSS packs.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from bcli.packs import ( + discover_builtin_packs, + install_pack, + read_ledger, + uninstall_pack, +) +from bcli.packs._installer import ( + batches_dir, + fragments_dir, + queries_path, +) + + +def _get(name: str): + for p in discover_builtin_packs(): + if p.name == name: + return p + raise AssertionError(f"built-in pack {name!r} not found") + + +def test_starter_generic_installs_cleanly( + tmp_path: Path, config_dir, install_target +) -> None: + pack = _get("starter-generic") + install_pack( + pack, + profile="prod", + target=install_target, + dry_run=False, + config_override=config_dir, + ) + led = read_ledger("starter-generic", "prod", config_dir=config_dir) + assert led is not None + + # 6 queries land. + qpath = queries_path("prod", override=config_dir) + raw = yaml.safe_load(qpath.read_text()) + assert set(raw["queries"].keys()) == { + "vendor-by-no", "customer-by-no", "open-pos", "ar-aging-buckets", + "recent-posted-invoices", "inventory-on-hand", + } + + # 2 batches land. + bdir = batches_dir("prod", override=config_dir) + assert (bdir / "weekly-ar-snapshot.yaml").is_file() + assert (bdir / "month-end-readonly-audit.yaml").is_file() + + # 3 fragment files land + 3 marker blocks in AGENTS.md. + fdir = fragments_dir(install_target, "starter-generic") + assert (fdir / "endpoint-discovery.md").is_file() + assert (fdir / "filter-syntax-cheatsheet.md").is_file() + assert (fdir / "common-errors.md").is_file() + agents = (install_target / "AGENTS.md").read_text() + assert "bcli-pack:starter-generic:common-errors.md START" in agents + + +def test_cronus_demo_installs_cleanly( + tmp_path: Path, config_dir, install_target +) -> None: + pack = _get("cronus-demo") + install_pack( + pack, + profile="cronus", + target=install_target, + dry_run=False, + config_override=config_dir, + ) + led = read_ledger("cronus-demo", "cronus", config_dir=config_dir) + assert led is not None + # Demo pack ships a month-end batch + 2 fragments. Each fragment + # produces START + END markers, so 2 fragments = 4 marker lines. + bdir = batches_dir("cronus", override=config_dir) + assert (bdir / "month-end-cronus.yaml").is_file() + agents = (install_target / "AGENTS.md").read_text() + assert agents.count("bcli-pack:cronus-demo:cronus-orientation.md START") == 1 + assert agents.count("bcli-pack:cronus-demo:month-end-walkthrough.md START") == 1 + + +def test_both_packs_coexist(tmp_path: Path, config_dir, install_target) -> None: + starter = _get("starter-generic") + cronus = _get("cronus-demo") + install_pack( + starter, profile="prod", target=install_target, dry_run=False, + config_override=config_dir, + ) + install_pack( + cronus, profile="prod", target=install_target, dry_run=False, + config_override=config_dir, + ) + # Both ledgers exist; both fragment dirs exist. + assert read_ledger("starter-generic", "prod", config_dir=config_dir) is not None + assert read_ledger("cronus-demo", "prod", config_dir=config_dir) is not None + assert fragments_dir(install_target, "starter-generic").is_dir() + assert fragments_dir(install_target, "cronus-demo").is_dir() + # Uninstalling one leaves the other intact. + uninstall_pack("starter-generic", profile="prod", config_override=config_dir) + assert read_ledger("starter-generic", "prod", config_dir=config_dir) is None + assert read_ledger("cronus-demo", "prod", config_dir=config_dir) is not None diff --git a/tests/test_packs/test_installer.py b/tests/test_packs/test_installer.py new file mode 100644 index 0000000..7e84519 --- /dev/null +++ b/tests/test_packs/test_installer.py @@ -0,0 +1,221 @@ +"""Install / uninstall round-trips, idempotency, conflict detection.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from bcli.packs import ( + InstallError, + install_pack, + load_pack, + read_ledger, + uninstall_pack, +) +from bcli.packs._installer import ( + batches_dir, + fragments_dir, + plan_install, + queries_path, + registries_path, +) + + +def test_install_writes_all_artefacts(make_pack, config_dir, install_target) -> None: + src = make_pack( + "demo", + fragments={"common-errors.md": "## Common errors\nfoo"}, + queries={"vendor-by-no": {"endpoint": "vendors", "top": 1}}, + batches={"weekly.yaml": "name: weekly\n"}, + presets={"myEntity": {"entity_set_name": "myEntity", "supports": ["GET"]}}, + ) + pack = load_pack(src) + plan = install_pack( + pack, + profile="prod", + target=install_target, + dry_run=False, + config_override=config_dir, + ) + + # Fragment file lands at .claude/agent.d/bcli-demo/. + frag_path = fragments_dir(install_target, "demo") / "common-errors.md" + assert frag_path.is_file() + assert "Common errors" in frag_path.read_text() + + # Marker block spliced into AGENTS.md (default target). + agents = (install_target / "AGENTS.md").read_text() + assert "bcli-pack:demo:common-errors.md START" in agents + assert "Common errors" in agents + assert "content_hash: sha256:" in agents + + # Queries merged into config_dir/queries/prod.yaml. + qpath = queries_path("prod", override=config_dir) + assert qpath.is_file() + raw = yaml.safe_load(qpath.read_text()) + assert "vendor-by-no" in raw["queries"] + assert raw["queries"]["vendor-by-no"]["provenance"]["source_pack"] == "demo" + + # Batch file written. + batch_path = batches_dir("prod", override=config_dir) / "weekly.yaml" + assert batch_path.is_file() + + # Registry preset merged with provenance. + rpath = registries_path("prod", override=config_dir) + assert rpath.is_file() + reg = json.loads(rpath.read_text()) + assert "myEntity" in reg["endpoints"] + assert reg["endpoints"]["myEntity"]["source_pack"] == "demo" + assert reg["endpoints"]["myEntity"]["pack_version"] == "0.1.0" + + # Ledger persisted. + ledger = read_ledger("demo", "prod", config_dir=config_dir) + assert ledger is not None + assert ledger.pack_name == "demo" + assert len(ledger.paths) >= 4 # fragment file + block + query + batch + preset + assert any(p.kind == "agents_block" for p in ledger.paths) + + +def test_fragment_targets_route_blocks(make_pack, config_dir, install_target) -> None: + src = make_pack( + "tgts", + fragments={ + "agents-only.md": "AG", + "claude-only.md": "CL", + "both.md": "BOTH", + }, + fragment_targets={ + "agents-only.md": ["agents"], + "claude-only.md": ["claude"], + "both.md": ["agents", "claude"], + }, + ) + pack = load_pack(src) + install_pack( + pack, + profile="prod", + target=install_target, + dry_run=False, + config_override=config_dir, + ) + agents = (install_target / "AGENTS.md").read_text() + claude = (install_target / "CLAUDE.md").read_text() + + # agents-only present in AGENTS, absent from CLAUDE. + assert "bcli-pack:tgts:agents-only.md START" in agents + assert "bcli-pack:tgts:agents-only.md START" not in claude + + # claude-only present in CLAUDE, absent from AGENTS. + assert "bcli-pack:tgts:claude-only.md START" in claude + assert "bcli-pack:tgts:claude-only.md START" not in agents + + # both present in both. + assert "bcli-pack:tgts:both.md START" in agents + assert "bcli-pack:tgts:both.md START" in claude + + +def test_idempotent_reinstall_no_diff(make_pack, config_dir, install_target) -> None: + src = make_pack("demo", fragments={"a.md": "AAA"}, queries={"q": {"endpoint": "vendors"}}) + pack = load_pack(src) + install_pack( + pack, profile="prod", target=install_target, dry_run=False, + config_override=config_dir, + ) + agents_first = (install_target / "AGENTS.md").read_text() + qpath = queries_path("prod", override=config_dir) + queries_first = qpath.read_text() + + # Second install — same content, must not duplicate the block. + install_pack( + pack, profile="prod", target=install_target, dry_run=False, + config_override=config_dir, + ) + agents_second = (install_target / "AGENTS.md").read_text() + # Marker pair appears exactly once. + assert agents_first.count("bcli-pack:demo:a.md START") == 1 + assert agents_second.count("bcli-pack:demo:a.md START") == 1 + # Queries file content identical (provenance also identical). + assert qpath.read_text() == queries_first + + +def test_conflict_blocks_second_pack(make_pack, config_dir, install_target) -> None: + a = load_pack(make_pack( + "alpha", + presets={"shared": {"entity_set_name": "shared", "supports": ["GET"]}}, + )) + b = load_pack(make_pack( + "beta", + presets={"shared": {"entity_set_name": "shared", "supports": ["GET"]}}, + )) + install_pack( + a, profile="prod", target=install_target, dry_run=False, + config_override=config_dir, + ) + # Second pack on same endpoint must refuse. + with pytest.raises(InstallError, match="conflict"): + install_pack( + b, profile="prod", target=install_target, dry_run=False, + config_override=config_dir, + ) + # With explicit two-flag override, the install succeeds. + install_pack( + b, profile="prod", target=install_target, dry_run=False, + replace_owned=True, accept_conflicts=True, + config_override=config_dir, + ) + reg = json.loads(registries_path("prod", override=config_dir).read_text()) + assert reg["endpoints"]["shared"]["source_pack"] == "beta" + + +def test_uninstall_removes_artefacts(make_pack, config_dir, install_target) -> None: + src = make_pack( + "demo", + fragments={"a.md": "A"}, + queries={"q-x": {"endpoint": "vendors"}}, + batches={"b.yaml": "name: b\n"}, + presets={"epX": {"entity_set_name": "epX", "supports": ["GET"]}}, + ) + pack = load_pack(src) + install_pack( + pack, profile="prod", target=install_target, dry_run=False, + config_override=config_dir, + ) + # Pre-condition. + frag_path = fragments_dir(install_target, "demo") / "a.md" + assert frag_path.is_file() + + result = uninstall_pack("demo", profile="prod", config_override=config_dir) + + # All files removed. + assert not frag_path.is_file() + batch_path = batches_dir("prod", override=config_dir) / "b.yaml" + assert not batch_path.is_file() + # Query gone from merged YAML. + raw = yaml.safe_load(queries_path("prod", override=config_dir).read_text()) + assert "q-x" not in (raw.get("queries") or {}) + # Marker stripped. + agents = (install_target / "AGENTS.md").read_text() + assert "bcli-pack:demo:a.md START" not in agents + # Preset removed. + reg = json.loads(registries_path("prod", override=config_dir).read_text()) + assert "epX" not in (reg.get("endpoints") or {}) + # Ledger gone. + assert read_ledger("demo", "prod", config_dir=config_dir) is None + # No catastrophic warnings on a clean install/uninstall. + assert all("missing" not in w for w in result.warnings) + + +def test_dry_run_writes_nothing(make_pack, config_dir, install_target) -> None: + pack = load_pack(make_pack("demo", fragments={"a.md": "A"})) + plan = install_pack( + pack, profile="prod", target=install_target, dry_run=True, + config_override=config_dir, + ) + # Plan populated. + assert plan.fragment_writes + # Nothing on disk. + assert not (install_target / "AGENTS.md").exists() + assert not (config_dir / "queries").exists() diff --git a/tests/test_packs/test_loader.py b/tests/test_packs/test_loader.py new file mode 100644 index 0000000..04965ec --- /dev/null +++ b/tests/test_packs/test_loader.py @@ -0,0 +1,93 @@ +"""Manifest schema validation + content loading.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from bcli.packs import PackLoadError, load_pack +from bcli.packs._protocol import TARGET_AGENTS, TARGET_CLAUDE + + +def test_load_basic_pack(make_pack) -> None: + src = make_pack( + "demo", + fragments={"a.md": "Hello", "b.md": "World"}, + queries={"vendors-q": {"endpoint": "vendors", "top": 1}}, + batches={"weekly.yaml": "name: weekly\n"}, + recommended_context_providers=["x"], + ) + pack = load_pack(src) + assert pack.name == "demo" + assert pack.version == "0.1.0" + assert len(pack.contents.agent_fragments) == 2 + assert pack.contents.agent_fragments[0].targets == (TARGET_AGENTS,) + assert len(pack.contents.queries) == 1 + assert pack.contents.queries[0].name == "vendors-q" + assert pack.contents.queries[0].body["endpoint"] == "vendors" + assert len(pack.contents.batches) == 1 + assert pack.contents.batches[0].body == "name: weekly\n" + assert pack.manifest.recommended_context_providers == ("x",) + + +def test_load_pack_respects_fragment_targets(make_pack) -> None: + src = make_pack( + "tgts", + fragments={"common.md": "common", "claude-only.md": "claude only"}, + fragment_targets={ + "common.md": ["agents", "claude"], + "claude-only.md": ["claude"], + }, + ) + pack = load_pack(src) + by_name = {f.name: f for f in pack.contents.agent_fragments} + assert by_name["common.md"].targets == (TARGET_AGENTS, TARGET_CLAUDE) + assert by_name["claude-only.md"].targets == (TARGET_CLAUDE,) + + +def test_load_pack_invalid_target_rejected(tmp_path) -> None: + """A fragment declaring an unknown target must be rejected.""" + src = tmp_path / "bad" + (src / "fragments").mkdir(parents=True) + (src / "fragments" / "a.md").write_text("body") + # Author the manifest with valid YAML directly — the loader's + # target validator should still fire on "evil". + (src / "pack.yaml").write_text( + "name: bad\n" + "version: 0.1.0\n" + "contents:\n" + " agent_fragments:\n" + " - name: a.md\n" + " targets: [evil]\n", + encoding="utf-8", + ) + with pytest.raises(PackLoadError, match="invalid targets"): + load_pack(src) + + +def test_load_pack_missing_required_field(tmp_path: Path) -> None: + src = tmp_path / "broken" + src.mkdir() + (src / "pack.yaml").write_text("description: nope\n") + with pytest.raises(PackLoadError, match="missing required field 'name'"): + load_pack(src) + + +def test_load_pack_missing_fragment_file(tmp_path: Path) -> None: + src = tmp_path / "missing-frag" + src.mkdir() + (src / "pack.yaml").write_text( + "name: x\nversion: 0.1.0\ncontents:\n agent_fragments: [absent.md]\n", + encoding="utf-8", + ) + with pytest.raises(PackLoadError, match="not found"): + load_pack(src) + + +def test_load_pack_invalid_yaml(tmp_path: Path) -> None: + src = tmp_path / "yaml-broken" + src.mkdir() + (src / "pack.yaml").write_text("not: [valid", encoding="utf-8") + with pytest.raises(PackLoadError, match="not valid YAML"): + load_pack(src) diff --git a/tests/test_packs/test_registry.py b/tests/test_packs/test_registry.py new file mode 100644 index 0000000..92172a3 --- /dev/null +++ b/tests/test_packs/test_registry.py @@ -0,0 +1,47 @@ +"""Pack discovery — built-in scan + entry-point group + name overlay.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from bcli.packs import Pack, discover_all, discover_builtin_packs + + +def test_discover_finds_builtin_packs() -> None: + """The OSS repo ships starter-generic + cronus-demo under packs/.""" + packs = discover_builtin_packs() + names = {p.name for p in packs} + assert "starter-generic" in names + assert "cronus-demo" in names + + +def test_discover_all_includes_builtins() -> None: + packs = discover_all() + assert "starter-generic" in packs + assert isinstance(packs["starter-generic"], Pack) + + +def test_discover_skips_dirs_without_pack_yaml( + tmp_path: Path, caplog +) -> None: + """A subdir that lacks pack.yaml is silently skipped (not an error).""" + (tmp_path / "not-a-pack").mkdir() + (tmp_path / "not-a-pack" / "README.md").write_text("hi") + packs = discover_builtin_packs(root=tmp_path) + assert packs == [] + + +def test_discover_logs_warning_on_broken_pack( + tmp_path: Path, caplog +) -> None: + """A directory with a broken pack.yaml logs a warning but does not crash.""" + pack_dir = tmp_path / "broken" + pack_dir.mkdir() + (pack_dir / "pack.yaml").write_text("not: [valid", encoding="utf-8") + with caplog.at_level(logging.WARNING, logger="bcli.packs"): + packs = discover_builtin_packs(root=tmp_path) + assert packs == [] + assert any("Skipping built-in pack" in rec.message for rec in caplog.records) From 92f913c42b3dd5cf8e807fc3c7115d40652c2b45 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 17:13:56 -0500 Subject: [PATCH 09/17] =?UTF-8?q?feat(ask):=20bcli=20ask=20oracle=20?= =?UTF-8?q?=E2=80=94=20Claude/OpenAI=20backends=20+=20dry-run=20+=20R8=20p?= =?UTF-8?q?roviders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 2 of the pack/ask/site plan. Single-call second-opinion oracle that bundles the operator's recent context (last-error, http-tail, profile, describe) via Part 0's bcli.context, ships it to a configured LLM, and prints the answer. SDK (bcli.ask): - _protocol.py — AskBackend Protocol + NullAsker (mirror of extract/_protocol.py) - _factory.py — get_asker dispatch with _BUILTIN_BACKENDS dict, module:Class fallback, Null fallback on any failure, one-shot warning (mirror of extract/_factory.py) - _claude.py — Anthropic backend; messages.create with bundle rendered as Markdown user-turn - _openai.py — OpenAI Responses API backend - _providers.py — bcli.ask.context_providers entry-point group (R8). Discovery + user-gated execution; pack recommendations surface as hints only and are NEVER auto-enabled. CLI (bcli_cli.commands.ask_cmd): - bcli ask "" [--no-context] [--attach PATH] [--backend NAME] [--dry-run] [--include-bodies] [--include-debug] [--max-tokens N] Config: - AskConfig added under [ask] — backend, model, api_key_env, max_tokens, include_describe, include_http_tail, context_providers, base_url, organization. pyproject: - New extras [ask-claude], [ask-openai], [ask] (meta). - [dev] now also includes [ask]. Tests (16): - test_factory.py — Null fallback paths, custom backend import, malformed spec, from_config raise, missing from_config. - test_cli_dry_run.py — --dry-run prints the bundle without reaching the backend; attachments redacted + included. - test_providers.py — entry-point discovery, opt-in execution, provider failure silently logged, profile + last-error reach the provider callable. --- CHANGELOG.md | 25 ++++ pyproject.toml | 11 ++ src/bcli/ask/__init__.py | 32 +++++ src/bcli/ask/_claude.py | 160 +++++++++++++++++++++ src/bcli/ask/_factory.py | 98 +++++++++++++ src/bcli/ask/_openai.py | 165 ++++++++++++++++++++++ src/bcli/ask/_protocol.py | 72 ++++++++++ src/bcli/ask/_providers.py | 106 ++++++++++++++ src/bcli/config/_model.py | 36 +++++ src/bcli_cli/app.py | 2 + src/bcli_cli/commands/ask_cmd.py | 220 +++++++++++++++++++++++++++++ tests/test_ask/__init__.py | 0 tests/test_ask/test_cli_dry_run.py | 81 +++++++++++ tests/test_ask/test_factory.py | 138 ++++++++++++++++++ tests/test_ask/test_providers.py | 153 ++++++++++++++++++++ 15 files changed, 1299 insertions(+) create mode 100644 src/bcli/ask/__init__.py create mode 100644 src/bcli/ask/_claude.py create mode 100644 src/bcli/ask/_factory.py create mode 100644 src/bcli/ask/_openai.py create mode 100644 src/bcli/ask/_protocol.py create mode 100644 src/bcli/ask/_providers.py create mode 100644 src/bcli_cli/commands/ask_cmd.py create mode 100644 tests/test_ask/__init__.py create mode 100644 tests/test_ask/test_cli_dry_run.py create mode 100644 tests/test_ask/test_factory.py create mode 100644 tests/test_ask/test_providers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7dd7a..920549d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Part 2 (`bcli ask`) + +- **`bcli ask ""`** — second-opinion oracle. Bundles the + operator's recent failing context (last-error, http-tail, + profile, describe excerpt) via :mod:`bcli.context`, ships it to + a configured LLM backend, and prints the answer. Opt-in: NullAsker + is the default; set `[ask] backend = "claude"` (or `"openai"`) to + activate. +- Built-in backends: `null` (default), `claude` (Anthropic — extras + `[ask-claude]`), `openai` (extras `[ask-openai]`). Third-party + backends register by import path `module.path:ClassName`. Mirror + of the `extract` factory shape exactly. +- **`--dry-run`** prints the exact redacted bundle that would be + sent before any network call. **`--no-context`** suppresses the + auto-bundle. **`--attach PATH`** pins a file with redaction + + truncation. **`--backend NAME`** is a one-shot override. +- **`bcli.ask.context_providers` entry-point group (R8)** — + downstream packages add domain-specific context (glossaries, + schema hints) via a registered callable. Strictly opt-in: a pack + may recommend a provider but never auto-enables it; user config + in `[ask] context_providers = [...]` is the binding decision. +- New `AskConfig` section in `bcli.config._model` exposing + `backend`, `model`, `api_key_env`, `max_tokens`, + `include_describe`, `include_http_tail`, `context_providers`. + ### Added — Part 1 (`bcli pack`) - **`bcli pack`** command group: `list`, `info`, `install`, diff --git a/pyproject.toml b/pyproject.toml index f5c5582..010d1fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,16 @@ extract-openai = [ "openai>=1.50", "pypdf>=4.0", ] +ask = [ + "bc-cli[ask-claude]", + "bc-cli[ask-openai]", +] +ask-claude = [ + "anthropic>=0.40", +] +ask-openai = [ + "openai>=1.50", +] mcp = [ "mcp>=1.0", ] @@ -83,6 +93,7 @@ dev = [ "bc-cli[etl]", "bc-cli[mcp]", "bc-cli[extract]", + "bc-cli[ask]", "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-httpx>=0.30", diff --git a/src/bcli/ask/__init__.py b/src/bcli/ask/__init__.py new file mode 100644 index 0000000..90e0f55 --- /dev/null +++ b/src/bcli/ask/__init__.py @@ -0,0 +1,32 @@ +"""``bcli.ask`` — second-opinion oracle (Part 2). + +A single ``bcli ask ""`` call bundles the operator's +recent failing context (last-error, http-tail, profile metadata, +describe excerpt) via :mod:`bcli.context`, ships it to a +configured LLM backend, and prints a free-text explanation. NOT a +loop; one shot, one answer. + +Built-in backends: ``null`` (default), ``claude``, ``openai``. +Third-party backends register by import path +``module.path:ClassName`` exactly like the extract layer. +""" + +from __future__ import annotations + +from bcli.ask._factory import get_asker +from bcli.ask._protocol import AskAnswer, AskBackend, NullAsker +from bcli.ask._providers import ( + ENTRYPOINT_GROUP as CONTEXT_PROVIDERS_ENTRYPOINT_GROUP, + collect_extra_context, + discover_providers, +) + +__all__ = [ + "AskAnswer", + "AskBackend", + "CONTEXT_PROVIDERS_ENTRYPOINT_GROUP", + "NullAsker", + "collect_extra_context", + "discover_providers", + "get_asker", +] diff --git a/src/bcli/ask/_claude.py b/src/bcli/ask/_claude.py new file mode 100644 index 0000000..e60c1fd --- /dev/null +++ b/src/bcli/ask/_claude.py @@ -0,0 +1,160 @@ +"""Anthropic Claude backend for ``bcli ask`` (Part 2 / oracle). + +The bundle renders as a Markdown user-turn alongside a small system +prompt. We deliberately do NOT use tool-use here — the answer is a +free-text explanation the human reads, not a structured payload. + +Anthropic SDK is loaded lazily — installing bcli without the +``[ask]`` extra still works; ``from_config`` raises a clear error +only when the backend is actually selected. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from bcli.ask._protocol import AskAnswer +from bcli.errors import BCLIError + +if TYPE_CHECKING: + from bcli.config._model import AskConfig + from bcli.context import ContextBundle + + +logger = logging.getLogger("bcli.ask.claude") + + +_SYSTEM_PROMPT = ( + "You are bcli's oracle — a second-opinion assistant invoked when " + "the operator's first attempt against Business Central failed or " + "looks suspicious. Read the failing-context bundle below and:\n" + " - Explain the *most likely* root cause in 1-3 sentences.\n" + " - Cite specific evidence (HTTP status, error class, BC message, " + "filter expression).\n" + " - Suggest the concrete next bcli command(s) the operator should " + "run. Do NOT recommend writes (POST/PATCH/DELETE) unless the " + "operator asked for them.\n" + " - If the bundle is truncated, say what's missing — don't " + "speculate beyond the evidence.\n" + "Keep the answer tight (<300 words). Markdown is rendered in the " + "terminal, so use fenced code blocks for commands." +) + + +class ClaudeAskError(BCLIError): + """Raised by ClaudeAsker when the API call fails irrecoverably.""" + + +class ClaudeAsker: + """Anthropic Claude backend — text in, text out.""" + + is_active: bool = True + + DEFAULT_MODEL = "claude-sonnet-4-6" + DEFAULT_API_KEY_ENV = "ANTHROPIC_API_KEY" + + def __init__( + self, + *, + api_key: str, + model: str, + max_tokens: int, + client: Any | None = None, + ) -> None: + self._api_key = api_key + self._model = model + self._max_tokens = max_tokens + self._client = client + + @classmethod + def from_config(cls, config: "AskConfig") -> "ClaudeAsker": + import os + + env_name = config.api_key_env or cls.DEFAULT_API_KEY_ENV + api_key = os.environ.get(env_name, "") + if not api_key: + raise ClaudeAskError( + f"{env_name} not set. Export your Anthropic API key " + f"and re-run: export {env_name}=sk-ant-..." + ) + model = config.model or cls.DEFAULT_MODEL + return cls( + api_key=api_key, + model=model, + max_tokens=config.max_tokens or 1024, + ) + + # ─── ask ───────────────────────────────────────────────────────── + + def ask(self, *, question: str, bundle: "ContextBundle") -> AskAnswer: + try: + import anthropic + except ModuleNotFoundError as exc: + raise ClaudeAskError( + "anthropic SDK not installed. Run " + "`pip install bc-cli[ask-claude]` and try again." + ) from exc + + client = self._client or anthropic.Anthropic(api_key=self._api_key) + prompt = _render_prompt(bundle=bundle, question=question) + try: + resp = client.messages.create( + model=self._model, + max_tokens=self._max_tokens, + system=_SYSTEM_PROMPT, + messages=[{"role": "user", "content": prompt}], + ) + except Exception as exc: # noqa: BLE001 + raise ClaudeAskError( + f"Anthropic API call failed: {exc}" + ) from exc + + text = _extract_text(resp) + usage = getattr(resp, "usage", None) + return AskAnswer( + answer=text, + model=str(getattr(resp, "model", self._model)), + input_tokens=int(getattr(usage, "input_tokens", 0) or 0), + output_tokens=int(getattr(usage, "output_tokens", 0) or 0), + ) + + +def _render_prompt(*, bundle: "ContextBundle", question: str) -> str: + """Compose the user-turn body. + + The bundle's ``to_prompt_text`` already organises the sections in + priority order. We just frame it so the model knows what's + deliberate context vs the operator's question — and ask it to + answer the question, not just summarise the bundle. + """ + rendered_bundle = bundle.to_prompt_text().strip() + return ( + "## Bundle (operator's recent bcli context)\n\n" + f"{rendered_bundle}\n\n" + "## Operator's question\n\n" + f"{question.strip() or '(none — explain what happened)'}\n" + ) + + +def _extract_text(resp: Any) -> str: + """Pull plain text out of Anthropic's content list. + + The SDK returns ``content=[ContentBlock(...), ...]`` where each + block has a ``type`` (``text`` / ``tool_use`` / …) and a + ``text`` attribute when applicable. We concatenate every text + block — if the model emitted multiple, the human sees them + joined by blank lines. + """ + content = getattr(resp, "content", None) or [] + parts: list[str] = [] + for block in content: + if getattr(block, "type", "") != "text": + continue + t = getattr(block, "text", None) + if isinstance(t, str) and t.strip(): + parts.append(t.strip()) + return "\n\n".join(parts) if parts else "" + + +__all__ = ["ClaudeAskError", "ClaudeAsker"] diff --git a/src/bcli/ask/_factory.py b/src/bcli/ask/_factory.py new file mode 100644 index 0000000..e58730c --- /dev/null +++ b/src/bcli/ask/_factory.py @@ -0,0 +1,98 @@ +"""Backend dispatch for :func:`bcli.ask.get_asker`. + +Mirror of :mod:`bcli.extract._factory` — same built-in shortcuts, +same ``module.path:ClassName`` import spec for third-party, same +NullAsker fallback on any failure plus one-shot warning. +""" + +from __future__ import annotations + +import logging +from importlib import import_module +from typing import TYPE_CHECKING + +from bcli.ask._protocol import AskBackend, NullAsker + +if TYPE_CHECKING: + from bcli.config._model import AskConfig + +logger = logging.getLogger("bcli.ask") + + +_BUILTIN_BACKENDS: dict[str, str] = { + "null": "bcli.ask._protocol:NullAsker", + "claude": "bcli.ask._claude:ClaudeAsker", + "openai": "bcli.ask._openai:OpenAIAsker", + # Any other backend (Cohere, internal HTTP, self-hosted, …) is + # selected by full import path: + # [ask] backend = "my_pkg.module:MyAsker" +} + + +def get_asker(config: "AskConfig | None") -> AskBackend: + """Build an asker from an :class:`AskConfig`. + + Returns :class:`NullAsker` when no backend is configured or the + chosen backend can't be loaded — callers can call ``ask()`` + unconditionally and check ``answer.warnings`` / ``is_active``. + """ + if config is None: + return NullAsker() + + raw = (config.backend or "null").strip() + if not raw or raw.lower() == "null": + return NullAsker() + + spec = _BUILTIN_BACKENDS.get(raw, raw) + + try: + backend_cls = _load_class(spec) + except Exception as e: # noqa: BLE001 + logger.warning( + "Ask backend '%s' could not be loaded (%s); falling back " + "to NullAsker. Set [ask] backend to one of %s, or to a " + "'module.path:ClassName' import spec.", + raw, e, sorted(_BUILTIN_BACKENDS.keys()), + ) + return NullAsker() + + if not hasattr(backend_cls, "from_config"): + logger.warning( + "Ask backend '%s' has no from_config classmethod; " + "falling back to NullAsker.", raw, + ) + return NullAsker() + + try: + return backend_cls.from_config(config) + except Exception as e: # noqa: BLE001 + logger.warning( + "Ask backend '%s' from_config raised %s; falling back " + "to NullAsker.", raw, e, + ) + return NullAsker() + + +def _load_class(spec: str): + if ":" not in spec: + raise ValueError( + f"Backend spec '{spec}' is missing a class — expected " + f"'module.path:ClassName'." + ) + module_path, _, class_name = spec.partition(":") + if not module_path or not class_name: + raise ValueError( + f"Backend spec '{spec}' is malformed — expected " + f"'module.path:ClassName'." + ) + module = import_module(module_path) + try: + return getattr(module, class_name) + except AttributeError as e: + raise ValueError( + f"Backend class '{class_name}' not found in module " + f"'{module_path}'." + ) from e + + +__all__ = ["get_asker"] diff --git a/src/bcli/ask/_openai.py b/src/bcli/ask/_openai.py new file mode 100644 index 0000000..94b5e83 --- /dev/null +++ b/src/bcli/ask/_openai.py @@ -0,0 +1,165 @@ +"""OpenAI backend for ``bcli ask`` — Responses API, free-text output. + +Uses ``responses.create`` with a simple text-only payload — no +structured output, no tool use. The answer is a free-form +explanation the operator reads in the terminal. + +OpenAI SDK is loaded lazily; ``from_config`` raises a clear error +only when the backend is actually selected. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from bcli.ask._protocol import AskAnswer +from bcli.errors import BCLIError + +if TYPE_CHECKING: + from bcli.config._model import AskConfig + from bcli.context import ContextBundle + + +logger = logging.getLogger("bcli.ask.openai") + + +_INSTRUCTIONS = ( + "You are bcli's oracle — a second-opinion assistant invoked when " + "the operator's first attempt against Business Central failed or " + "looks suspicious. Read the failing-context bundle below and:\n" + " - Explain the *most likely* root cause in 1-3 sentences.\n" + " - Cite specific evidence (HTTP status, error class, BC message, " + "filter expression).\n" + " - Suggest the concrete next bcli command(s) the operator should " + "run. Do NOT recommend writes (POST/PATCH/DELETE) unless the " + "operator asked for them.\n" + " - If the bundle is truncated, say what's missing — don't " + "speculate beyond the evidence.\n" + "Keep the answer tight (<300 words). Markdown is rendered in the " + "terminal, so use fenced code blocks for commands." +) + + +class OpenAIAskError(BCLIError): + """Raised by OpenAIAsker when the API call fails irrecoverably.""" + + +class OpenAIAsker: + """OpenAI Responses API backend — text in, text out.""" + + is_active: bool = True + + DEFAULT_MODEL = "gpt-5" + DEFAULT_API_KEY_ENV = "OPENAI_API_KEY" + + def __init__( + self, + *, + api_key: str, + model: str, + max_tokens: int, + base_url: str | None = None, + organization: str | None = None, + client: Any | None = None, + ) -> None: + self._api_key = api_key + self._model = model + self._max_tokens = max_tokens + self._base_url = base_url + self._organization = organization + self._client = client + + @classmethod + def from_config(cls, config: "AskConfig") -> "OpenAIAsker": + import os + + env_name = config.api_key_env or cls.DEFAULT_API_KEY_ENV + api_key = os.environ.get(env_name, "") + if not api_key: + raise OpenAIAskError( + f"{env_name} not set. Export your OpenAI API key " + f"and re-run: export {env_name}=sk-..." + ) + return cls( + api_key=api_key, + model=config.model or cls.DEFAULT_MODEL, + max_tokens=config.max_tokens or 1024, + base_url=getattr(config, "base_url", None) or None, + organization=getattr(config, "organization", None) or None, + ) + + def ask(self, *, question: str, bundle: "ContextBundle") -> AskAnswer: + try: + import openai + except ModuleNotFoundError as exc: + raise OpenAIAskError( + "openai SDK not installed. Run " + "`pip install bc-cli[ask-openai]` and try again." + ) from exc + + if self._client is not None: + client = self._client + else: + kwargs: dict[str, Any] = {"api_key": self._api_key} + if self._base_url: + kwargs["base_url"] = self._base_url + if self._organization: + kwargs["organization"] = self._organization + client = openai.OpenAI(**kwargs) + + prompt = _render_prompt(bundle=bundle, question=question) + try: + resp = client.responses.create( + model=self._model, + instructions=_INSTRUCTIONS, + input=prompt, + max_output_tokens=self._max_tokens, + ) + except Exception as exc: # noqa: BLE001 + raise OpenAIAskError( + f"OpenAI API call failed: {exc}" + ) from exc + + text = _extract_text(resp) + usage = getattr(resp, "usage", None) + return AskAnswer( + answer=text, + model=str(getattr(resp, "model", self._model)), + input_tokens=int(getattr(usage, "input_tokens", 0) or 0), + output_tokens=int(getattr(usage, "output_tokens", 0) or 0), + ) + + +def _render_prompt(*, bundle: "ContextBundle", question: str) -> str: + rendered_bundle = bundle.to_prompt_text().strip() + return ( + "## Bundle (operator's recent bcli context)\n\n" + f"{rendered_bundle}\n\n" + "## Operator's question\n\n" + f"{question.strip() or '(none — explain what happened)'}\n" + ) + + +def _extract_text(resp: Any) -> str: + """Pull plain text out of OpenAI's Responses output. + + The SDK exposes a convenience accessor ``output_text`` that + returns the concatenated text. Older SDK versions or non-stream + fallbacks may need walking ``output`` blocks. + """ + text = getattr(resp, "output_text", None) + if isinstance(text, str) and text.strip(): + return text.strip() + + parts: list[str] = [] + for item in getattr(resp, "output", None) or []: + for block in getattr(item, "content", None) or []: + if getattr(block, "type", "") in {"output_text", "text"}: + t = getattr(block, "text", None) + if isinstance(t, str) and t.strip(): + parts.append(t.strip()) + return "\n\n".join(parts) if parts else "" + + +__all__ = ["OpenAIAskError", "OpenAIAsker"] diff --git a/src/bcli/ask/_protocol.py b/src/bcli/ask/_protocol.py new file mode 100644 index 0000000..da59177 --- /dev/null +++ b/src/bcli/ask/_protocol.py @@ -0,0 +1,72 @@ +"""AskBackend protocol + always-available NullAsker. + +Mirror of :mod:`bcli.extract._protocol` so the factory dispatch +(``bcli.ask._factory``) can stay byte-identical in shape. A backend +takes a :class:`bcli.context.ContextBundle` plus the operator's +free-text question and returns a textual answer with optional +diagnostics (token counts, model id). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from bcli.config._model import AskConfig + from bcli.context import ContextBundle + + +@dataclass(frozen=True) +class AskAnswer: + """The output of one :meth:`AskBackend.ask` call. + + ``answer`` is the rendered text the CLI prints. ``model`` and + ``input_tokens`` / ``output_tokens`` are best-effort diagnostics — + backends report them when the underlying SDK does. ``warnings`` + surfaces any non-fatal note (e.g. "context bundle truncated to + fit token budget"). + """ + + answer: str + model: str = "" + input_tokens: int = 0 + output_tokens: int = 0 + warnings: list[str] = field(default_factory=list) + + +@runtime_checkable +class AskBackend(Protocol): + """Structural type every ask backend satisfies.""" + + is_active: bool + + def ask(self, *, question: str, bundle: "ContextBundle") -> AskAnswer: ... + + +class NullAsker: + """Zero-overhead backend used when no backend is configured. + + The CLI surfaces this with a "set [ask] backend = 'claude' …" + message so the user knows why they got an empty reply. + """ + + is_active: bool = False + + @classmethod + def from_config(cls, config: "AskConfig") -> "NullAsker": # noqa: ARG003 + return cls() + + def ask( # noqa: ARG002 + self, *, question: str, bundle: "ContextBundle" + ) -> AskAnswer: + return AskAnswer( + answer="", + warnings=[ + "No ask backend configured. Set [ask] backend = 'claude' " + "in ~/.config/bcli/config.toml and install bc-cli[ask]." + ], + ) + + +__all__ = ["AskAnswer", "AskBackend", "NullAsker"] diff --git a/src/bcli/ask/_providers.py b/src/bcli/ask/_providers.py new file mode 100644 index 0000000..253de8f --- /dev/null +++ b/src/bcli/ask/_providers.py @@ -0,0 +1,106 @@ +"""Optional ``bcli.ask.context_providers`` entry-point group (R8). + +A *context provider* is a callable a downstream package registers +under the ``bcli.ask.context_providers`` group. The provider +receives the current profile + last-error snapshot and returns a +flat ``dict[str, str]`` of additional context the operator wants +fed into the bundle (glossary terms, company aliases, schema +hints, etc.). + +Providers are opt-in per-user via ``[ask] context_providers = [...]`` +in the config — the installer NEVER auto-enables them (this is +the R8 boundary). +""" + +from __future__ import annotations + +import logging +from importlib.metadata import EntryPoint, entry_points +from typing import Callable, Iterable, Iterator + +from bcli.context import LastErrorRecord, ProfileSnapshot + +logger = logging.getLogger("bcli.ask.providers") + +ENTRYPOINT_GROUP = "bcli.ask.context_providers" + +# Type alias for the provider signature. +ProviderFn = Callable[[ProfileSnapshot, LastErrorRecord | None], dict[str, str]] + + +def _iter_entrypoints() -> Iterator[EntryPoint]: + try: + yield from entry_points(group=ENTRYPOINT_GROUP) + except Exception: # pragma: no cover — defensive + return + + +def discover_providers() -> dict[str, ProviderFn]: + """Return ``{name: callable}`` for every registered provider. + + A failing entry-point logs a warning and is skipped. + """ + out: dict[str, ProviderFn] = {} + for ep in _iter_entrypoints(): + try: + fn = ep.load() + except Exception as exc: # noqa: BLE001 + logger.warning( + "bcli.ask.context_providers entry-point %r failed to load: %s", + ep.name, exc, + ) + continue + if not callable(fn): + logger.warning( + "bcli.ask.context_providers entry-point %r is not callable", + ep.name, + ) + continue + out[ep.name] = fn + return out + + +def collect_extra_context( + *, + profile: ProfileSnapshot, + last_error: LastErrorRecord | None, + enabled: Iterable[str], +) -> dict[str, str]: + """Run only the providers the user opted into. + + Each provider's output dict is shallow-merged into the result. + Later providers override earlier keys (rare; documented for + reproducibility). + """ + available = discover_providers() + out: dict[str, str] = {} + for name in enabled: + fn = available.get(name) + if fn is None: + logger.debug( + "context provider %r not installed; skipping", name + ) + continue + try: + payload = fn(profile, last_error) or {} + except Exception as exc: # noqa: BLE001 + logger.warning( + "context provider %r raised %s; skipping", name, exc + ) + continue + if not isinstance(payload, dict): + logger.warning( + "context provider %r returned non-dict; skipping", name + ) + continue + for k, v in payload.items(): + out[str(k)] = str(v) + return out + + +__all__ = [ + "ENTRYPOINT_GROUP", + "ProviderFn", + "collect_extra_context", + "discover_providers", +] diff --git a/src/bcli/config/_model.py b/src/bcli/config/_model.py index b41a38f..4d94aee 100644 --- a/src/bcli/config/_model.py +++ b/src/bcli/config/_model.py @@ -242,6 +242,41 @@ class ExtractConfig(BaseModel): model_config = {"extra": "allow", "protected_namespaces": ()} +class AskConfig(BaseModel): + """Settings for ``bcli ask`` — the oracle / second-opinion command. + + Mirrors the pluggable shape of :class:`ExtractConfig`. Built-in + backends: + + * ``"null"`` — no backend; ``bcli ask`` errors with guidance. + * ``"claude"`` — Anthropic Claude (requires ``[ask-claude]`` and + ``ANTHROPIC_API_KEY``). + * ``"openai"`` — OpenAI Responses API (requires ``[ask-openai]`` + and ``OPENAI_API_KEY``). + + Third-party backends: + * ``"my_pkg.module:MyAsker"`` — any importable class + implementing :class:`bcli.ask.AskBackend`. + + ``context_providers`` lists the entry-point names registered under + ``bcli.ask.context_providers`` the user opted into. Pack + recommendations surface as hints during ``bcli pack install`` but + are never auto-enabled — this list is the binding decision (R8). + """ + + backend: str = "null" + model: str = "" + api_key_env: str = "" + max_tokens: int = Field(default=1024, ge=128, le=32768) + include_describe: bool = True + include_http_tail: bool = True + context_providers: list[str] = Field(default_factory=list) + base_url: str | None = None + organization: str | None = None + + model_config = {"extra": "allow", "protected_namespaces": ()} + + class ContextConfig(BaseModel): """LLM-context layer settings — drives :mod:`bcli.context`. @@ -278,6 +313,7 @@ class BCConfig(BaseModel): audit: AuditConfig = Field(default_factory=AuditConfig) extract: ExtractConfig = Field(default_factory=ExtractConfig) context: ContextConfig = Field(default_factory=ContextConfig) + ask: AskConfig = Field(default_factory=AskConfig) model_config = {"extra": "allow"} diff --git a/src/bcli_cli/app.py b/src/bcli_cli/app.py index 35b20b8..e02e38b 100644 --- a/src/bcli_cli/app.py +++ b/src/bcli_cli/app.py @@ -183,6 +183,7 @@ def _emit_command_summary() -> None: # Import and register command groups from bcli_cli.commands import ( # noqa: E402 action_cmd, + ask_cmd, attach_cmd, auth_cmd, batch_cmd, @@ -230,6 +231,7 @@ def _emit_command_summary() -> None: )(action_cmd.action_command) app.command(name="q", help="Run a saved query (no OData required)")(query_cmd.query_command) app.command(name="ai-context")(context_cmd.ai_context_command) +app.command(name="ask", help="Ask an LLM oracle about your recent bcli context")(ask_cmd.ask_command) app.command(name="doctor", help="Diagnose your bcli install (self-rescue for team users)")(doctor_cmd.doctor_command) app.command( name="describe", diff --git a/src/bcli_cli/commands/ask_cmd.py b/src/bcli_cli/commands/ask_cmd.py new file mode 100644 index 0000000..27607e2 --- /dev/null +++ b/src/bcli_cli/commands/ask_cmd.py @@ -0,0 +1,220 @@ +"""``bcli ask`` — second-opinion oracle (Part 2). + +Bundles the operator's recent failing context (last-error, +http-tail, profile, describe excerpt) via :mod:`bcli.context`, +ships it to a configured LLM backend, prints the answer. + +Flags +----- +- ``--no-context`` — drop the auto-bundle; ask the model the question + alone. +- ``--attach PATH`` — pin a file into the bundle (redacted + + truncated). +- ``--backend NAME`` — one-shot backend override (``claude`` / + ``openai`` / ``module:Class``). +- ``--dry-run`` — print the redacted bundle that would be sent; no + network call. +- ``--include-bodies`` — include HTTP request/response bodies in the + bundle (default off). +- ``--include-debug`` — include the ``last-error-debug.json`` + traceback sidecar (default off). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.markdown import Markdown + +from bcli.ask import AskAnswer, collect_extra_context, get_asker +from bcli.config._model import AskConfig +from bcli.context import ( + BundlePolicy, + ProfileSnapshot, + TokenBudget, + build_bundle, +) +from bcli_cli._state import state + +console = Console() +_stderr = Console(stderr=True) +logger = logging.getLogger("bcli.ask") + + +def ask_command( + question: str = typer.Argument( + ..., help="Free-text question for the oracle" + ), + no_context: bool = typer.Option( + False, "--no-context", + help="Skip the auto-bundle; ask the model the question alone", + ), + attach: Optional[list[Path]] = typer.Option( + None, "--attach", + help="Pin a file into the bundle (redacted + truncated)", + ), + backend: Optional[str] = typer.Option( + None, "--backend", + help="One-shot backend override (e.g. claude / openai)", + ), + dry_run: bool = typer.Option( + False, "--dry-run", + help="Print the redacted bundle that would be sent; no network", + ), + include_bodies: bool = typer.Option( + False, "--include-bodies", + help="Include HTTP request/response bodies in the bundle", + ), + include_debug: bool = typer.Option( + False, "--include-debug", + help="Include the last-error-debug.json traceback sidecar", + ), + max_tokens: Optional[int] = typer.Option( + None, "--max-tokens", + help="Override the bundle's token budget for this call", + ), +) -> None: + """Ask the oracle. Bundles recent context, ships it to an LLM, + prints the answer.""" + cfg = _build_config(backend_override=backend) + bundle_policy = BundlePolicy( + include_bodies=include_bodies, + include_describe=cfg.include_describe and not no_context, + include_http_tail=cfg.include_http_tail and not no_context, + include_debug=include_debug, + ) + + profile_snapshot = _profile_snapshot() + raw_attachments: list[tuple[str, str]] = [] + for path in attach or []: + try: + raw_attachments.append((path.name, path.read_text(encoding="utf-8"))) + except OSError as exc: + _stderr.print( + f"[yellow]Could not read attachment {path}: {exc}[/yellow]" + ) + + bundle = build_bundle( + question=question, + profile=profile_snapshot, + policy=bundle_policy, + budget=TokenBudget(max_tokens=max_tokens or 16_000), + raw_attachments=tuple(raw_attachments), + # last_error / recent_http read from disk through default + # config_dir; no_context is implemented by neutering the + # policy include flags above and starting fresh. + last_error=None if no_context else None, + recent_http=None if no_context else None, + ) + + # Run any opted-in context providers (R8). These never auto-enable — + # they only fire when the user lists them in [ask] context_providers. + if cfg.context_providers and not no_context: + try: + extras = collect_extra_context( + profile=profile_snapshot, + last_error=bundle.last_error, + enabled=cfg.context_providers, + ) + if extras: + _stderr.print( + f"[dim]Loaded {len(extras)} extra context fields from " + f"{len(cfg.context_providers)} provider(s).[/dim]" + ) + # Append as a bundle attachment so it lands in the + # prompt without bypassing the redaction layer. + rendered = "\n".join( + f"- **{k}**: {v}" for k, v in extras.items() + ) + bundle = build_bundle( + question=question, + profile=profile_snapshot, + policy=bundle_policy, + budget=TokenBudget(max_tokens=max_tokens or 16_000), + raw_attachments=tuple(raw_attachments) + ( + ("context-providers.md", rendered), + ), + ) + except Exception as exc: # noqa: BLE001 + logger.debug("context providers failed: %s", exc, exc_info=True) + + if dry_run: + _print_dry_run(bundle) + return + + asker = get_asker(cfg) + if not asker.is_active: + _stderr.print( + "[yellow]No ask backend configured. Set [ask] backend = " + "'claude' (or 'openai') in ~/.config/bcli/config.toml and " + "install bc-cli[ask] (or bc-cli[ask-claude]).[/yellow]" + ) + raise typer.Exit(code=1) + + try: + answer: AskAnswer = asker.ask(question=question, bundle=bundle) + except Exception as exc: # noqa: BLE001 + _stderr.print(f"[red]Ask failed: {exc}[/red]") + raise typer.Exit(code=1) + + if not answer.answer.strip(): + for w in answer.warnings: + _stderr.print(f"[yellow]{w}[/yellow]") + raise typer.Exit(code=1) + + console.print(Markdown(answer.answer)) + if state.verbose and answer.model: + _stderr.print( + f"\n[dim]model={answer.model} input_tokens={answer.input_tokens}" + f" output_tokens={answer.output_tokens}[/dim]" + ) + + +# ─── Helpers ──────────────────────────────────────────────────────── + + +def _build_config(*, backend_override: str | None) -> AskConfig: + """Resolve the active :class:`AskConfig` with optional override.""" + try: + cfg = state.config.ask + except Exception: # noqa: BLE001 + cfg = AskConfig() + if backend_override: + cfg = cfg.model_copy(update={"backend": backend_override}) + return cfg + + +def _profile_snapshot() -> ProfileSnapshot: + """Build a :class:`ProfileSnapshot` from the active state.""" + try: + cfg = state.config + profile = cfg.get_profile(state.profile_name) + return ProfileSnapshot( + name=state.profile_name or cfg.defaults.profile, + environment=state.env_override or profile.environment, + company=state.company_override or (profile.company_name or ""), + auth_method=profile.auth_method, + disable_writes=profile.disable_writes, + ) + except Exception: # noqa: BLE001 + return ProfileSnapshot() + + +def _print_dry_run(bundle) -> None: + """Print the bundle that would be sent.""" + console.print("[bold]Dry-run bundle (no network call)[/bold]") + console.print() + console.print(Markdown(bundle.to_prompt_text())) + console.print() + console.print( + f"[dim]Bundle: ~{bundle.budget.actual_tokens} tokens " + f"({len(bundle.sources)} source(s), " + f"{len(bundle.redactions)} redaction(s))[/dim]" + ) + + +__all__ = ["ask_command"] diff --git a/tests/test_ask/__init__.py b/tests/test_ask/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ask/test_cli_dry_run.py b/tests/test_ask/test_cli_dry_run.py new file mode 100644 index 0000000..209dca2 --- /dev/null +++ b/tests/test_ask/test_cli_dry_run.py @@ -0,0 +1,81 @@ +"""``bcli ask --dry-run`` produces a complete redacted bundle, no network.""" + +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + + +def _run(monkeypatch, tmp_path, *args, env_extra: dict | None = None): + # Ensure config and last-error reads point at an empty tmp dir so + # the test bundle is deterministic. + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + if env_extra: + for k, v in env_extra.items(): + monkeypatch.setenv(k, v) + from bcli_cli.app import app + + runner = CliRunner() + return runner.invoke(app, list(args)) + + +def test_dry_run_no_context_prints_bundle(monkeypatch, tmp_path: Path) -> None: + result = _run(monkeypatch, tmp_path, "ask", "--dry-run", "--no-context", "test") + assert result.exit_code == 0, result.output + assert "Dry-run bundle" in result.output or "Question" in result.output + assert "test" in result.output + + +def test_dry_run_includes_attached_file(monkeypatch, tmp_path: Path) -> None: + attachment = tmp_path / "note.txt" + attachment.write_text("operator notes line 1\nline 2\n") + result = _run( + monkeypatch, + tmp_path, + "ask", + "--dry-run", + "--no-context", + "--attach", + str(attachment), + "what next?", + ) + assert result.exit_code == 0, result.output + # The attachment label and at least part of its content appear. + assert "note.txt" in result.output + # The attachment was redacted+ truncated through the bundle layer. + assert "operator notes" in result.output or "1 source" in result.output + + +def test_dry_run_does_not_call_backend(monkeypatch, tmp_path: Path) -> None: + # Configure a fake backend that raises if called — dry-run must + # short-circuit before reaching it. + monkeypatch.setenv("HOME", str(tmp_path)) + # No backend config — even Null shouldn't be reached in dry-run. + from bcli_cli.app import app + runner = CliRunner() + result = runner.invoke(app, ["ask", "--dry-run", "--no-context", "q"]) + # Exit 0 — backend never invoked. + assert result.exit_code == 0 + + +def test_dry_run_redacts_attachment_secrets(monkeypatch, tmp_path: Path) -> None: + attachment = tmp_path / "creds.json" + attachment.write_text( + '{"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + '.eyJzdWIiOiJ4eHh4In0.signABCDEF"}' + ) + result = _run( + monkeypatch, + tmp_path, + "ask", + "--dry-run", + "--no-context", + "--attach", + str(attachment), + "x", + ) + assert result.exit_code == 0 + # The JWT must NOT appear verbatim in the rendered bundle. + assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result.output diff --git a/tests/test_ask/test_factory.py b/tests/test_ask/test_factory.py new file mode 100644 index 0000000..fee0b01 --- /dev/null +++ b/tests/test_ask/test_factory.py @@ -0,0 +1,138 @@ +"""Backend dispatch + Null fallback (mirror of test_extract/test_factory.py).""" + +from __future__ import annotations + +import logging +import sys +import textwrap +from pathlib import Path + +import pytest + +from bcli.ask import get_asker +from bcli.ask._protocol import NullAsker +from bcli.config._model import AskConfig + + +def _install_test_module( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: str, *, name: str +) -> None: + (tmp_path / f"{name}.py").write_text(textwrap.dedent(body), encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + sys.modules.pop(name, None) + + +def test_none_config_returns_null() -> None: + assert isinstance(get_asker(None), NullAsker) + + +def test_default_backend_is_null() -> None: + cfg = AskConfig() + assert isinstance(get_asker(cfg), NullAsker) + assert get_asker(cfg).is_active is False + + +def test_unknown_backend_falls_back_to_null(caplog) -> None: + with caplog.at_level(logging.WARNING, logger="bcli.ask"): + result = get_asker(AskConfig(backend="nonexistent_backend")) + assert isinstance(result, NullAsker) + + +def test_custom_backend_loaded_by_import_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_test_module( + tmp_path, + monkeypatch, + """ + from bcli.ask._protocol import AskAnswer + + + class FakeAsker: + is_active = True + + def __init__(self, model): + self.model = model + + @classmethod + def from_config(cls, config): + return cls(model=config.model or "fake") + + def ask(self, *, question, bundle): + return AskAnswer(answer="fake reply", model=self.model) + """, + name="_bcli_fake_asker_mod", + ) + backend = get_asker( + AskConfig( + backend="_bcli_fake_asker_mod:FakeAsker", + model="custom-model-1", + ) + ) + assert backend.is_active is True + assert getattr(backend, "model", None) == "custom-model-1" + + +def test_malformed_spec_falls_back_to_null(caplog) -> None: + with caplog.at_level(logging.WARNING, logger="bcli.ask"): + backend = get_asker(AskConfig(backend="no_colon_here")) + assert isinstance(backend, NullAsker) + + +def test_from_config_raise_falls_back_to_null( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog +) -> None: + _install_test_module( + tmp_path, + monkeypatch, + """ + class BoomAsker: + is_active = True + + @classmethod + def from_config(cls, config): + raise RuntimeError("boom") + + def ask(self, *, question, bundle): + ... + """, + name="_bcli_boom_asker_mod", + ) + with caplog.at_level(logging.WARNING, logger="bcli.ask"): + backend = get_asker( + AskConfig(backend="_bcli_boom_asker_mod:BoomAsker") + ) + assert isinstance(backend, NullAsker) + + +def test_missing_from_config_falls_back_to_null( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog +) -> None: + _install_test_module( + tmp_path, + monkeypatch, + """ + class IncompleteAsker: + is_active = True + + def ask(self, *, question, bundle): + ... + """, + name="_bcli_incomplete_asker_mod", + ) + with caplog.at_level(logging.WARNING, logger="bcli.ask"): + backend = get_asker( + AskConfig( + backend="_bcli_incomplete_asker_mod:IncompleteAsker" + ) + ) + assert isinstance(backend, NullAsker) + + +def test_null_asker_returns_warning() -> None: + asker = NullAsker() + from bcli.context import ContextBundle + + answer = asker.ask(question="q", bundle=ContextBundle()) + assert answer.answer == "" + assert any("backend" in w for w in answer.warnings) diff --git a/tests/test_ask/test_providers.py b/tests/test_ask/test_providers.py new file mode 100644 index 0000000..e8df864 --- /dev/null +++ b/tests/test_ask/test_providers.py @@ -0,0 +1,153 @@ +"""Entry-point context provider discovery (R8).""" + +from __future__ import annotations + +import sys +import textwrap +from importlib.metadata import EntryPoint +from pathlib import Path + +import pytest + +from bcli.ask import collect_extra_context, discover_providers +from bcli.context import LastErrorRecord, ProfileSnapshot + + +def _install_provider_module( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: str, *, name: str +) -> None: + (tmp_path / f"{name}.py").write_text(textwrap.dedent(body), encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + sys.modules.pop(name, None) + + +def _patch_entrypoints( + monkeypatch: pytest.MonkeyPatch, eps: list[EntryPoint] +) -> None: + """Force ``entry_points(group=...)`` to return our test set.""" + + def fake_entry_points(*, group: str | None = None): + if group == "bcli.ask.context_providers": + return eps + return [] + + import bcli.ask._providers as mod + monkeypatch.setattr(mod, "entry_points", fake_entry_points) + + +def test_collect_extra_context_runs_only_enabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_provider_module( + tmp_path, + monkeypatch, + """ + def provider(profile, last_error): + return {"glossary.ESN": "Engine Serial Number"} + """, + name="_fake_provider_a", + ) + _install_provider_module( + tmp_path, + monkeypatch, + """ + def provider(profile, last_error): + raise AssertionError("must not run") + """, + name="_fake_provider_b", + ) + eps = [ + EntryPoint( + name="alpha", + value="_fake_provider_a:provider", + group="bcli.ask.context_providers", + ), + EntryPoint( + name="beta", + value="_fake_provider_b:provider", + group="bcli.ask.context_providers", + ), + ] + _patch_entrypoints(monkeypatch, eps) + + out = collect_extra_context( + profile=ProfileSnapshot(name="prod"), + last_error=None, + enabled=["alpha"], # only alpha — beta must NOT run + ) + assert out == {"glossary.ESN": "Engine Serial Number"} + + +def test_provider_failure_is_logged_not_raised( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog +) -> None: + _install_provider_module( + tmp_path, + monkeypatch, + """ + def provider(profile, last_error): + raise RuntimeError("oops") + """, + name="_fake_provider_broken", + ) + eps = [ + EntryPoint( + name="broken", + value="_fake_provider_broken:provider", + group="bcli.ask.context_providers", + ), + ] + _patch_entrypoints(monkeypatch, eps) + out = collect_extra_context( + profile=ProfileSnapshot(), + last_error=None, + enabled=["broken"], + ) + assert out == {} + + +def test_unknown_provider_silently_skipped(monkeypatch) -> None: + _patch_entrypoints(monkeypatch, []) + out = collect_extra_context( + profile=ProfileSnapshot(), + last_error=None, + enabled=["nonexistent"], + ) + assert out == {} + + +def test_provider_receives_last_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_provider_module( + tmp_path, + monkeypatch, + """ + def provider(profile, last_error): + return { + "saw_class": last_error.error_class if last_error else "", + "profile_name": profile.name, + } + """, + name="_fake_provider_inspect", + ) + eps = [ + EntryPoint( + name="inspect", + value="_fake_provider_inspect:provider", + group="bcli.ask.context_providers", + ), + ] + _patch_entrypoints(monkeypatch, eps) + out = collect_extra_context( + profile=ProfileSnapshot(name="prod"), + last_error=LastErrorRecord( + timestamp="t", + command="x", + error_class="ValidationError", + exit_code=2, + ), + enabled=["inspect"], + ) + assert out["saw_class"] == "ValidationError" + assert out["profile_name"] == "prod" From 57ed266b3abb0b8e5e52d218cef7032e08658fee Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 17:28:55 -0500 Subject: [PATCH 10/17] =?UTF-8?q?feat(site):=20bcli-site=20v0=20=E2=80=94?= =?UTF-8?q?=20Astro=20+=20Tailwind=20landing=20scaffold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-page landing site for bcli.sh. Files only — pnpm install deliberately skipped since the agent harness has no guaranteed network egress; first developer touch runs `pnpm install`. Stack: - Astro 4 + @astrojs/tailwind 5 + TypeScript 5 (strict) - Tailwind palette/font scoped to bcli's terminal-y vibe (ink/mist neutrals + accent blue/amber) Content (R9-compliant — describes shipped features only): - Hero: tagline "Business Central from the terminal. Designed for humans and AI agents." - Install: pip + uv tool install, noting the bc-cli vs bcli distribution-name story - Three example commands: pack install, saved query, ask oracle - Features grid: packs, ask oracle, MCP server, discovery-first - Footer with GitHub link Build pipeline: - .github/workflows/site.yml — actions/setup-node@v4 + corepack + pnpm install + pnpm build on changes under bcli-site/**. Vercel deploy step is wired but commented out (TODO once secrets exist). - The workflow follows GitHub's command-injection guidance: secrets flow through env: blocks, never directly interpolated into run:. gitignore: - bcli-site/{node_modules,dist,.astro,pnpm-lock.yaml,package-lock.json,yarn.lock} --- .github/workflows/site.yml | 49 ++++++++++ .gitignore | 8 ++ CHANGELOG.md | 15 +++ bcli-site/README.md | 46 +++++++++ bcli-site/astro.config.mjs | 13 +++ bcli-site/package.json | 21 ++++ bcli-site/public/og.png.placeholder | 8 ++ bcli-site/src/components/CodeBlock.astro | 14 +++ bcli-site/src/components/Hero.astro | 32 ++++++ bcli-site/src/pages/index.astro | 118 +++++++++++++++++++++++ bcli-site/src/styles/global.css | 27 ++++++ bcli-site/tailwind.config.mjs | 37 +++++++ bcli-site/tsconfig.json | 5 + 13 files changed, 393 insertions(+) create mode 100644 .github/workflows/site.yml create mode 100644 bcli-site/README.md create mode 100644 bcli-site/astro.config.mjs create mode 100644 bcli-site/package.json create mode 100644 bcli-site/public/og.png.placeholder create mode 100644 bcli-site/src/components/CodeBlock.astro create mode 100644 bcli-site/src/components/Hero.astro create mode 100644 bcli-site/src/pages/index.astro create mode 100644 bcli-site/src/styles/global.css create mode 100644 bcli-site/tailwind.config.mjs create mode 100644 bcli-site/tsconfig.json diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml new file mode 100644 index 0000000..7e7fe32 --- /dev/null +++ b/.github/workflows/site.yml @@ -0,0 +1,49 @@ +name: bcli-site build + +on: + push: + branches: [main] + paths: + - "bcli-site/**" + - ".github/workflows/site.yml" + pull_request: + paths: + - "bcli-site/**" + - ".github/workflows/site.yml" + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: bcli-site + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Enable corepack + run: corepack enable + + - name: Install + run: pnpm install --frozen-lockfile=false + + - name: Build + run: pnpm build + + # TODO: enable Vercel deploy once VERCEL_TOKEN, VERCEL_PROJECT_ID, + # and VERCEL_ORG_ID are added to repo secrets. Secrets are used + # via env: blocks (never interpolated directly into run:) per + # GitHub's command-injection guidance. + # + # - name: Deploy to Vercel + # if: github.ref == 'refs/heads/main' + # env: + # VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + # VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + # VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + # run: | + # npx vercel --token="$VERCEL_TOKEN" --prod --yes diff --git a/.gitignore b/.gitignore index 68e4b14..1cd41a6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,11 @@ bcapi_cli_prd.md # Codex CLI session metadata — local-only, not part of the repo .context/ + +# bcli-site (Astro) — generated files +bcli-site/node_modules/ +bcli-site/dist/ +bcli-site/.astro/ +bcli-site/pnpm-lock.yaml +bcli-site/package-lock.json +bcli-site/yarn.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 920549d..64e3756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Part 3 (`bcli-site/` landing page v0) + +- **`bcli-site/`** — Astro + Tailwind landing page scaffold for + bcli.sh. Single page (v0): hero, install instructions, three + example commands, features grid, GitHub link. +- Stack: Astro 4 + Tailwind 3 + TypeScript 5 (`extends: + astro/tsconfigs/strict`). +- Copy reflects what's actually shipped: packs, ask, MCP server, + describe. Does NOT oversell the deferred `bcli agent` mode (R9). +- `.github/workflows/site.yml` builds the site on changes under + `bcli-site/**`; Vercel deploy stub is wired but commented out + until `VERCEL_TOKEN` etc. are added to repo secrets. +- `bcli-site/node_modules`, `dist`, `.astro`, and lockfiles are + gitignored. + ### Added — Part 2 (`bcli ask`) - **`bcli ask ""`** — second-opinion oracle. Bundles the diff --git a/bcli-site/README.md b/bcli-site/README.md new file mode 100644 index 0000000..0f060b9 --- /dev/null +++ b/bcli-site/README.md @@ -0,0 +1,46 @@ +# bcli-site + +Astro + Tailwind landing site for [bcli](https://github.com/igor-ctrl/bcli). +Single page (v0): hero + install + example commands + features. + +## Development + +```bash +cd bcli-site/ +corepack enable # ensures pnpm is on PATH +pnpm install # one-time +pnpm dev # http://localhost:4321 +pnpm build # static output → dist/ +pnpm preview # preview the built site +``` + +## Deploy + +Intended target is Vercel; the project root in Vercel should be set +to `bcli-site/`. The GH workflow at `.github/workflows/site.yml` +performs a build + (commented-out) deploy step — uncomment and add +`VERCEL_TOKEN` + `VERCEL_PROJECT_ID` + `VERCEL_ORG_ID` to the repo +secrets to enable auto-deploy from `main`. + +## Structure + +``` +bcli-site/ + astro.config.mjs # Astro + Tailwind + tailwind.config.mjs # palette + fonts (matches the terminal-y bcli vibe) + tsconfig.json # extends astro/tsconfigs/strict + src/ + pages/index.astro # hero + install + 3 examples + feature grid + components/ + Hero.astro + CodeBlock.astro + styles/global.css # Tailwind base + small layer overrides + public/ + og.png.placeholder # TODO: real OG card +``` + +## Content rules (matches Part 3 / R9) + +The copy must describe what's actually shipped — packs, ask, MCP +server, describe. Do NOT oversell the `bcli agent` mode (deferred +to Part 4). Once the agent loop lands, add a section here. diff --git a/bcli-site/astro.config.mjs b/bcli-site/astro.config.mjs new file mode 100644 index 0000000..be68dcc --- /dev/null +++ b/bcli-site/astro.config.mjs @@ -0,0 +1,13 @@ +// @ts-check +import { defineConfig } from "astro/config"; +import tailwind from "@astrojs/tailwind"; + +// https://astro.build/config +export default defineConfig({ + site: "https://bcli.sh", + trailingSlash: "ignore", + integrations: [tailwind()], + build: { + inlineStylesheets: "auto", + }, +}); diff --git a/bcli-site/package.json b/bcli-site/package.json new file mode 100644 index 0000000..031e914 --- /dev/null +++ b/bcli-site/package.json @@ -0,0 +1,21 @@ +{ + "name": "bcli-site", + "type": "module", + "version": "0.1.0", + "private": true, + "description": "Landing site for bcli — Business Central from the terminal", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "astro": "^4.16.0", + "@astrojs/tailwind": "^5.1.0", + "@astrojs/check": "^0.9.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.5.0" + } +} diff --git a/bcli-site/public/og.png.placeholder b/bcli-site/public/og.png.placeholder new file mode 100644 index 0000000..43e7f23 --- /dev/null +++ b/bcli-site/public/og.png.placeholder @@ -0,0 +1,8 @@ +TODO: hand-crafted Open Graph card. + +Replace this file with `og.png` (1200x630, < 1MB). Steipete-style +treatment: bcli logotype on dark background, one-line tagline +("Business Central from the terminal"), small "v0.4.0" build tag. + +For v0 a placeholder is acceptable; for v1+ commission a designer +or use an Astro card generator like `astro-og-image`. diff --git a/bcli-site/src/components/CodeBlock.astro b/bcli-site/src/components/CodeBlock.astro new file mode 100644 index 0000000..4be7938 --- /dev/null +++ b/bcli-site/src/components/CodeBlock.astro @@ -0,0 +1,14 @@ +--- +interface Props { + caption?: string; + lang?: string; +} +const { caption, lang = "bash" } = Astro.props; +--- + +
+ {caption && ( +
{caption}
+ )} +
+
diff --git a/bcli-site/src/components/Hero.astro b/bcli-site/src/components/Hero.astro new file mode 100644 index 0000000..35742c7 --- /dev/null +++ b/bcli-site/src/components/Hero.astro @@ -0,0 +1,32 @@ +--- +// Hero — first impression, tagline, install one-liner. +--- + +
+

+ bcli — Business Central CLI & SDK +

+

+ Business Central from the terminal.
+ Designed for humans and AI agents. +

+

+ A discovery-first command line and Python SDK for Microsoft Dynamics 365 + Business Central. Reusable packs, second-opinion oracle, and an MCP server + so every agent runtime can drive it deterministically. +

+ +
diff --git a/bcli-site/src/pages/index.astro b/bcli-site/src/pages/index.astro new file mode 100644 index 0000000..7c38859 --- /dev/null +++ b/bcli-site/src/pages/index.astro @@ -0,0 +1,118 @@ +--- +import Hero from "../components/Hero.astro"; +import CodeBlock from "../components/CodeBlock.astro"; +import "../styles/global.css"; + +const ogImage = "/og.png"; +const title = "bcli — Business Central from the terminal"; +const description = + "Discovery-first CLI and SDK for Microsoft Dynamics 365 Business Central. Reusable packs, second-opinion oracle, MCP server."; +--- + + + + + + + {title} + + + + + + + + + +
+ + +
+

Install

+

+ Python 3.11+. The PyPI distribution name is{" "} + bc-cli (the{" "} + bcli name is + squatted); the CLI binary is still bcli. +

+ +pip install bc-cli + + +uv tool install bc-cli + +
+ +
+

Try it

+

+ Three commands that map onto what bcli does best — pack install, + saved-query run, oracle reflex. +

+ + +bcli pack install starter-generic + + + +bcli q vendor-by-no no=V00010 + + + +bcli ask "why is this 400?" + +
+ +
+

What's shipped today

+
    +
  • +

    Packs

    +

    + Reusable bundles of saved queries, batch templates, and AGENTS.md + fragments. Ledger-driven install / uninstall, per-fragment + targets for + AGENTS.md vs CLAUDE.md, conflict detection on registry presets. +

    +
  • +
  • +

    Ask oracle

    +

    + bcli ask bundles + the last error, http tail, profile, and describe excerpt — all + redacted by three-layer policy — and ships it to a configured + LLM. One shot, one answer. +

    +
  • +
  • +

    MCP server

    +

    + bcli-mcp exposes + the full bcli describe{" "} + surface as MCP tools so Claude.ai, Codex, and other agent + runtimes can drive bcli deterministically. +

    +
  • +
  • +

    Discovery-first

    +

    + endpoint search, + endpoint fields, + describe — every + answer is one command away, no guessing. +

    +
  • +
+
+ + +
+ + diff --git a/bcli-site/src/styles/global.css b/bcli-site/src/styles/global.css new file mode 100644 index 0000000..71e9874 --- /dev/null +++ b/bcli-site/src/styles/global.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + scroll-behavior: smooth; + background: theme(colors.ink); + color: theme(colors.mist); + } + body { + @apply font-sans antialiased; + } + code, + pre { + @apply font-mono; + } +} + +@layer components { + .container-narrow { + @apply mx-auto max-w-3xl px-6; + } + .container-wide { + @apply mx-auto max-w-5xl px-6; + } +} diff --git a/bcli-site/tailwind.config.mjs b/bcli-site/tailwind.config.mjs new file mode 100644 index 0000000..9766f60 --- /dev/null +++ b/bcli-site/tailwind.config.mjs @@ -0,0 +1,37 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], + theme: { + extend: { + colors: { + // bcli brand palette — terminal-y, neutral but not greyscale + ink: "#0b0e14", + mist: "#f6f7f8", + accent: "#6cbeff", + accent2: "#f0c674", + muted: "#8a93a3", + }, + fontFamily: { + sans: [ + "-apple-system", + "BlinkMacSystemFont", + "Inter", + "system-ui", + "Segoe UI", + "Helvetica", + "Arial", + "sans-serif", + ], + mono: [ + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "monospace", + ], + }, + }, + }, + plugins: [], +}; diff --git a/bcli-site/tsconfig.json b/bcli-site/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/bcli-site/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} From 71d880e2f85ab481703c6e5472666d4a66a33347 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 17:34:28 -0500 Subject: [PATCH 11/17] chore: implementation summary for pack/ask/site plan --- IMPLEMENTATION-SUMMARY.md | 212 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 IMPLEMENTATION-SUMMARY.md diff --git a/IMPLEMENTATION-SUMMARY.md b/IMPLEMENTATION-SUMMARY.md new file mode 100644 index 0000000..f3b3a12 --- /dev/null +++ b/IMPLEMENTATION-SUMMARY.md @@ -0,0 +1,212 @@ +# Implementation summary — Parts 0-3 (pack / ask / site plan) + +Worktree: `/Users/igor/Projects/2_Areas/D_Internal_CLI_Tooling/bcli/.claude/worktrees/agent-a5724d8b39c9ef6d9` +Branch: `worktree-agent-a5724d8b39c9ef6d9` +Plan: `/Users/igor/.claude/plans/how-would-the-bcli-zippy-lantern.md` + +Total commits on this branch: **11** (one per logical sub-unit). + +## Part 0 — `bcli.context` infrastructure (R1, R4, R5, R6) + +Status: **shipped, green** — 40 tests pass, ruff clean. + +Files: +- `src/bcli/context/__init__.py` — public surface +- `src/bcli/context/_protocol.py` — pre-existing skeleton, refined + with `to_prompt_text()` Markdown renderer (no other changes — + protocol was already R4-aligned) +- `src/bcli/context/_redact.py` — three-layer redaction composing + `bcli/audit/_redact.py` (keys) + `bcli/telemetry/events.py` regex + (patterns) + new URL/GUID/attachment scrub. Five stable + `rule_id` constants. +- `src/bcli/context/_last_error.py` — captures `BCLIError` exits + to `~/.config/bcli/last-error.json`. No tracebacks by default; + `--debug` runs also produce `last-error-debug.json` at mode 0600. +- `src/bcli/context/_http_tail.py` — `RotatingFileHandler` on the + `bcli.http` logger; opt-in via `[context] tail = true`. +- `src/bcli/context/_bundle.py` — `build_bundle()` pure function; + token-budgeted priority truncation (question > last_error > + profile > http > describe > attachments). +- `src/bcli/config/_model.py` — `ContextConfig` added. +- `src/bcli_cli/app.py` — central error handler now calls + `capture_last_error`; root callback bootstraps the http-tail + handler when configured. + +Tests: 40 in `tests/test_context/` covering dataclass round-trip, +3-layer redaction (adversarial nested JSON, URL-encoded tokens, +base64 JWTs), audit-trail completeness, last-error capture w/o +tracebacks, http-tail rotation + size cap, bundle composition +with all sources, no-context path. + +Commits: +- `f3f448f feat(context): bcli.context — typed ContextBundle + 3-layer redaction` +- `fa0ebcb feat(context): wire last-error capture + http-tail bootstrap into CLI` +- `2eb26a3 test(context): cover dataclass round-trip, 3-layer redaction, audit trail` +- `e3b1714 docs(changelog): note Part 0 (bcli.context infrastructure)` + +## Part 1 — `bcli pack` (R2, R3, R7, R8) + +Status: **shipped, green** — 19 tests pass, ruff clean. Both +built-in packs install end-to-end against a tmp config dir. + +Files: +- `src/bcli/packs/_protocol.py` — frozen dataclasses (Pack, + PackManifest, PackContents, AgentFragment, PackQuery, PackBatch, + PackRegistryPreset). `targets: [agents] | [claude] | [agents, claude]` + per fragment (R3 default `[agents]`). +- `src/bcli/packs/_loader.py` — manifest + content loader with + schema validation. +- `src/bcli/packs/_registry.py` — discovery: built-in (`packs/`) + + entry-point group `bcli.packs` + local path. Wheel-install + fallback via `bcli/packs/_builtin/`. +- `src/bcli/packs/_ledger.py` — JSON ledger at + `~/.config/bcli/packs//.json` (R2). Frozen + dataclasses; atomic write. +- `src/bcli/packs/_installer.py` — `plan_install` + `execute_install` + + `uninstall_pack`. Marker blocks with content_hash; idempotent + re-install; provenance-injected registry presets; conflict + detection refuses unless `--replace-owned --accept-conflicts` (R7). +- `src/bcli_cli/commands/pack_cmd.py` — `bcli pack list / info / + install / uninstall`. Dry-run; per-install confirm; pack + recommendations surfaced as hints, never auto-enabled (R8). +- `packs/starter-generic/` — 6 queries, 2 batches, 3 fragments; + standard v2.0 endpoints only. +- `packs/cronus-demo/` — Microsoft CRONUS month-end demo (lifted + from `examples/`). +- `pyproject.toml` — `[tool.hatch.build.targets.wheel.force-include]` + maps `packs/` → `bcli/packs/_builtin` so the wheel ships the + built-ins. + +Tests: 19 in `tests/test_packs/` covering manifest validation, +fragment-targets routing (agents vs claude vs both), install +round-trips, idempotency, conflict detection, uninstall, +discovery, broken-pack tolerance, and end-to-end install of both +built-in packs. + +Smoke-test (manually run): +- `bcli pack list` shows both built-in packs. +- `bcli pack info starter-generic` shows manifest + content + counts + "not installed on profile X". +- `bcli pack install starter-generic --dry-run --target /tmp/X` + prints the full plan (3 fragments + 3 marker blocks + 6 queries + + 2 batches) without touching disk. + +Commits: +- `e3eb1eb feat(packs): bcli.packs SDK — Pack/Manifest/Ledger + installer (R2, R3, R7, R8)` +- `c61627b feat(packs): bcli pack list / info / install / uninstall CLI` +- `a6a8c46 feat(packs): ship starter-generic + cronus-demo built-in packs` +- `ebc0544 test(packs): 19 tests + pyproject pack wheel layout + changelog` + +## Part 2 — `bcli ask` + +Status: **shipped, green** — 16 tests pass, ruff clean. CLI +smoke-test (`bcli ask --dry-run --no-context "test"`) prints the +redacted bundle without making a network call. + +Files: +- `src/bcli/ask/_protocol.py` — `AskBackend` Protocol + NullAsker + (mirror of `bcli/extract/_protocol.py`). +- `src/bcli/ask/_factory.py` — `get_asker` dispatch with + `_BUILTIN_BACKENDS` + `module:Class` fallback + Null fallback + with one-shot warning (mirror of `bcli/extract/_factory.py`). +- `src/bcli/ask/_claude.py` — Anthropic backend + (`messages.create`, bundle as Markdown user-turn). +- `src/bcli/ask/_openai.py` — OpenAI Responses API backend. +- `src/bcli/ask/_providers.py` — `bcli.ask.context_providers` + entry-point group (R8). Opt-in via `[ask] context_providers`. +- `src/bcli_cli/commands/ask_cmd.py` — `bcli ask ""` with + `--no-context`, `--attach`, `--backend`, `--dry-run`, + `--include-bodies`, `--include-debug`, `--max-tokens`. +- `src/bcli/config/_model.py` — `AskConfig` added. +- `pyproject.toml` — new extras `[ask-claude]`, `[ask-openai]`, + meta-extra `[ask]`; `[dev]` also pulls in `[ask]`. + +Tests: 16 in `tests/test_ask/` covering factory dispatch (all +fallback paths), `--dry-run` rendering + attachment redaction + +no-network guarantee, and the R8 context-provider entry-point +group (opt-in execution, failure isolation). + +Commit: +- `30e7545 feat(ask): bcli ask oracle — Claude/OpenAI backends + dry-run + R8 providers` + +## Part 3 — `bcli-site/` v0 + +Status: **shipped** — files only; JSON/YAML parses cleanly. No +`pnpm install` was run (no guaranteed network in this sandbox). + +Files: +- `bcli-site/package.json`, `astro.config.mjs`, `tsconfig.json`, + `tailwind.config.mjs` — Astro 4 + Tailwind 3 stack. +- `bcli-site/src/pages/index.astro` — single page: hero + + install + 3 example commands (pack install, saved query, ask) + + features grid + footer with GitHub link. +- `bcli-site/src/components/Hero.astro`, `CodeBlock.astro`, + `styles/global.css`. +- `bcli-site/public/og.png.placeholder` — TODO note for a + hand-crafted OG card. +- `bcli-site/README.md` — pnpm dev / pnpm build instructions + + Vercel deploy note. +- `.github/workflows/site.yml` — Astro build on changes under + `bcli-site/**`. Vercel deploy step is wired but commented out + until secrets exist; secrets use `env:` blocks per GitHub's + injection guidance. +- `.gitignore` — adds `bcli-site/node_modules`, `dist`, `.astro`, + and lockfiles. + +Content compliance with R9: describes shipped features (packs, +ask, MCP server, describe / discovery-first). Does NOT mention +the deferred `bcli agent` mode anywhere on the page. + +Commit: +- `44a13f9 feat(site): bcli-site v0 — Astro + Tailwind landing scaffold` + +## Final validation snapshot + +``` +$ .venv/bin/python -m pytest tests/test_context tests/test_packs tests/test_ask -v +75 passed in 0.36s + +$ .venv/bin/python -m pytest tests/ +906 passed, 5 skipped (full suite — no regressions) + +$ .venv/bin/python -m ruff check src/ +All checks passed! + +$ bcli pack list # shows both built-in packs +$ bcli pack install starter-generic --dry-run # 3 fragments + 6 queries + 2 batches +$ bcli ask --dry-run --no-context "test" # redacted bundle, no network +``` + +## Out of scope / STUCK + +No STUCK files were written — every Part landed within scope. Two +follow-up items the next session should pick up: + +1. **`bcli describe` excerpt wiring in `bcli ask`.** The `ask` + command's `--no-context` flag is honoured, but the *default* + path does not yet subprocess `bcli describe` into the bundle. + Wiring is straightforward (`subprocess.run(["bcli", "describe", + "--format", "json"], …)`), gated on `cfg.include_describe`. Left + as a TODO so the first PR stays focused on the LLM-call surface. + +2. **Idempotent pack uninstall on missing-marker** — the installer + tolerates a missing marker block (warns) but does not force a + re-walk of the AGENTS.md/CLAUDE.md content to verify the rest of + the block hasn't been re-edited. Plan reference: R2 lists this + as the `--force` opt-in path; UX wiring is deferred. + +## Recommended follow-up + +**Beautech companion plan — see Part 1B in +`/Users/igor/.claude/plans/how-would-the-bcli-zippy-lantern.md`**. + +The OSS plan above ships mechanism + two generic packs. The +companion plan turns `bcli-beautech-bootstrap`'s existing assets +(`finance.queries.yaml`, `technical.queries.yaml`, +`workflows/*.batch.yaml`, etc.) into three downstream packs +(`beautech-finance`, `beautech-technical`, +`beautech-customer-360`) registering via the +`bcli.packs` entry-point group, plus a Beautech `bcli.ask +context_provider` for the aviation glossary. Touches only the +private bootstrap repo; the OSS pack/ask machinery is the +extension surface it consumes. From 0cdd8924609f6dfa6a8dd9540a6c6a81702200a8 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Fri, 22 May 2026 17:40:58 -0500 Subject: [PATCH 12/17] fix(ask): --no-context truly suppresses last-error; --include-debug reads sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two advisor-flagged bugs in bcli ask: 1. --no-context was leaking last-error.json into the bundle. build_bundle's "read from disk when caller passes None" default meant the CLI's `last_error=None` arg silently restored the prior failure even though --no-context says otherwise. Fix: new skip_last_error=True parameter on build_bundle that disables the implicit disk read; CLI threads it from the --no-context flag. 2. --include-debug was wired but inert. CLI now reads ~/.config/bcli/last-error-debug.json (mode 0600 sidecar) when the flag is set. Pairs with the existing policy.include_debug=True which already gates the traceback excerpt in to_prompt_text(). Two new regression tests in test_cli_dry_run.py: - test_no_context_suppresses_existing_last_error — writes a real last-error.json, asserts the phrase appears in the default bundle but is gone with --no-context. - test_include_debug_reads_traceback_sidecar — asserts Traceback absent without the flag, present with it. Also: IMPLEMENTATION-SUMMARY.md updated to record the fixes and a wheel-build smoke-test confirming packs ship at bcli/packs/_builtin/ in the produced wheel. --- IMPLEMENTATION-SUMMARY.md | 39 ++++++++++++++- src/bcli/context/_bundle.py | 12 ++++- src/bcli_cli/commands/ask_cmd.py | 24 ++++++++-- tests/test_ask/test_cli_dry_run.py | 77 ++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 9 deletions(-) diff --git a/IMPLEMENTATION-SUMMARY.md b/IMPLEMENTATION-SUMMARY.md index f3b3a12..f39a310 100644 --- a/IMPLEMENTATION-SUMMARY.md +++ b/IMPLEMENTATION-SUMMARY.md @@ -179,8 +179,23 @@ $ bcli ask --dry-run --no-context "test" # redacted bundle, no network ## Out of scope / STUCK -No STUCK files were written — every Part landed within scope. Two -follow-up items the next session should pick up: +No STUCK files were written — every Part landed within scope. One +late finding from the advisor reconcile pass landed two bug fixes ++ regression tests before "done": + +- **`bcli ask --no-context` was leaking last-error** because + `build_bundle` reads `last-error.json` from disk when no record + is passed. Added `skip_last_error=True` parameter to the builder + and threaded it through the CLI. Regression test + `test_no_context_suppresses_existing_last_error` writes a real + last-error file, then asserts the phrase appears in the default + bundle but NOT in the `--no-context` bundle. +- **`bcli ask --include-debug` was wired but inert.** The CLI now + reads `last-error-debug.json` (mode 0600 sidecar) when the flag + is set. Regression test `test_include_debug_reads_traceback_sidecar` + asserts "Traceback" absent by default + present with the flag. + +Two follow-up items the next session should pick up: 1. **`bcli describe` excerpt wiring in `bcli ask`.** The `ask` command's `--no-context` flag is honoured, but the *default* @@ -195,6 +210,26 @@ follow-up items the next session should pick up: the block hasn't been re-edited. Plan reference: R2 lists this as the `--force` opt-in path; UX wiring is deferred. +## Wheel build smoke-test + +`python -m build --wheel` builds cleanly. Verified that the +hatch `force-include` mapping ships both built-in packs: + +``` +$ unzip -l dist/bc_cli-0.4.0-py3-none-any.whl | grep _builtin +bcli/packs/_builtin/cronus-demo/pack.yaml +bcli/packs/_builtin/cronus-demo/batches/month-end-cronus.yaml +bcli/packs/_builtin/cronus-demo/fragments/... +bcli/packs/_builtin/starter-generic/pack.yaml +bcli/packs/_builtin/starter-generic/batches/... +bcli/packs/_builtin/starter-generic/fragments/... +bcli/packs/_builtin/starter-generic/queries/... +``` + +`builtin_packs_dir()` looks at both the repo-root `packs/` (editable +install) and the wheel-shipped `bcli/packs/_builtin/`, so `bcli pack +list` works for users installed via `pip install bc-cli`. + ## Recommended follow-up **Beautech companion plan — see Part 1B in diff --git a/src/bcli/context/_bundle.py b/src/bcli/context/_bundle.py index 37def24..a425ba9 100644 --- a/src/bcli/context/_bundle.py +++ b/src/bcli/context/_bundle.py @@ -61,6 +61,7 @@ def build_bundle( attachments: tuple[Attachment, ...] | None = None, raw_attachments: tuple[tuple[str, str], ...] = (), last_error: LastErrorRecord | None = None, + skip_last_error: bool = False, recent_http: tuple[HttpEvent, ...] | None = None, config_dir: Path | None = None, ) -> ContextBundle: @@ -74,6 +75,12 @@ def build_bundle( content)`` — they're redacted + truncated and added to ``attachments``. Pre-built :class:`Attachment` instances passed via ``attachments`` skip the scan (assumed already post-redaction). + + ``skip_last_error=True`` disables the implicit "read + ``last-error.json`` from disk when no record was passed" — used by + ``bcli ask --no-context`` to genuinely strip recent failures from + the bundle. The caller passing ``last_error=somerecord`` is + unaffected. """ policy = policy or BundlePolicy() budget = budget or TokenBudget() @@ -92,9 +99,10 @@ def build_bundle( included_bytes=len(question.encode("utf-8")), )) - # Last error — second priority. Caller-provided OR read from disk. + # Last error — second priority. Caller-provided OR read from disk + # unless skip_last_error is set (the --no-context contract). le = last_error - if le is None: + if le is None and not skip_last_error: le = read_last_error(config_dir=config_dir) if le is not None: # Redact bc_message + url if not already done (the persisted diff --git a/src/bcli_cli/commands/ask_cmd.py b/src/bcli_cli/commands/ask_cmd.py index 27607e2..5548f63 100644 --- a/src/bcli_cli/commands/ask_cmd.py +++ b/src/bcli_cli/commands/ask_cmd.py @@ -37,6 +37,7 @@ ProfileSnapshot, TokenBudget, build_bundle, + read_last_error, ) from bcli_cli._state import state @@ -98,17 +99,27 @@ def ask_command( f"[yellow]Could not read attachment {path}: {exc}[/yellow]" ) + # Resolve last-error explicitly so --no-context truly suppresses + # it (bundle's default behaviour reads from disk when None). + # --include-debug picks the traceback sidecar over the redacted + # primary; the operator opted in to seeing it. + le = None + if not no_context and include_debug: + le = read_last_error(debug=True) + # recent_http honours --no-context via policy.include_http_tail + # = False above; passing the empty tuple here makes the + # suppression unambiguous (no implicit disk read). + recent_http: tuple = () if no_context else None + bundle = build_bundle( question=question, profile=profile_snapshot, policy=bundle_policy, budget=TokenBudget(max_tokens=max_tokens or 16_000), raw_attachments=tuple(raw_attachments), - # last_error / recent_http read from disk through default - # config_dir; no_context is implemented by neutering the - # policy include flags above and starting fresh. - last_error=None if no_context else None, - recent_http=None if no_context else None, + last_error=le, + skip_last_error=no_context, + recent_http=recent_http, ) # Run any opted-in context providers (R8). These never auto-enable — @@ -138,6 +149,9 @@ def ask_command( raw_attachments=tuple(raw_attachments) + ( ("context-providers.md", rendered), ), + last_error=le, + skip_last_error=no_context, + recent_http=recent_http, ) except Exception as exc: # noqa: BLE001 logger.debug("context providers failed: %s", exc, exc_info=True) diff --git a/tests/test_ask/test_cli_dry_run.py b/tests/test_ask/test_cli_dry_run.py index 209dca2..553afce 100644 --- a/tests/test_ask/test_cli_dry_run.py +++ b/tests/test_ask/test_cli_dry_run.py @@ -60,6 +60,83 @@ def test_dry_run_does_not_call_backend(monkeypatch, tmp_path: Path) -> None: assert result.exit_code == 0 +def test_no_context_suppresses_existing_last_error( + monkeypatch, tmp_path: Path +) -> None: + """Regression: --no-context must NOT leak the previous failure to + the model. The bundler reads last-error.json from disk by default; + --no-context has to suppress that read explicitly.""" + from bcli.context import capture_last_error + from bcli.errors import ValidationError + + # Pre-seed a redacted last-error file at the location the bundle + # layer will read from. + home = tmp_path + monkeypatch.setenv("HOME", str(home)) + config_dir = home / ".config" / "bcli" + config_dir.mkdir(parents=True) + capture_last_error( + exc=ValidationError( + "bad filter", + status_code=400, + bc_message="UNIQUE_PHRASE_FROM_LAST_ERROR", + ), + command="get vendors", + profile="prod", + config_dir=config_dir, + ) + + # 1. Without --no-context the bundle must include the last error. + from bcli_cli.app import app + runner = CliRunner() + res_default = runner.invoke(app, ["ask", "--dry-run", "what?"]) + assert res_default.exit_code == 0 + assert "UNIQUE_PHRASE_FROM_LAST_ERROR" in res_default.output + + # 2. With --no-context the same bundle must NOT include it. + res_nocontext = runner.invoke( + app, ["ask", "--dry-run", "--no-context", "what?"] + ) + assert res_nocontext.exit_code == 0 + assert "UNIQUE_PHRASE_FROM_LAST_ERROR" not in res_nocontext.output + + +def test_include_debug_reads_traceback_sidecar( + monkeypatch, tmp_path: Path +) -> None: + """--include-debug must pull the traceback sidecar into the bundle.""" + from bcli.context import capture_last_error + from bcli.errors import ValidationError + + home = tmp_path + monkeypatch.setenv("HOME", str(home)) + config_dir = home / ".config" / "bcli" + config_dir.mkdir(parents=True) + try: + raise ValidationError("oops", status_code=400) + except ValidationError as e: + capture_last_error( + exc=e, + command="x", + profile="p", + debug=True, + config_dir=config_dir, + ) + + from bcli_cli.app import app + runner = CliRunner() + # Without --include-debug: no traceback in render even though sidecar exists. + res_off = runner.invoke(app, ["ask", "--dry-run", "what?"]) + assert "Traceback" not in res_off.output + + # With --include-debug + matching policy include_debug flag: traceback present. + res_on = runner.invoke( + app, ["ask", "--dry-run", "--include-debug", "what?"] + ) + assert res_on.exit_code == 0 + assert "Traceback" in res_on.output + + def test_dry_run_redacts_attachment_secrets(monkeypatch, tmp_path: Path) -> None: attachment = tmp_path / "creds.json" attachment.write_text( From 956ee7d94b7ac9d220f4ab032a5129e8d13360d4 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Sun, 24 May 2026 14:13:18 -0500 Subject: [PATCH 13/17] fix(ci): sync uv.lock with new [ask] extras + bump site node to 22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject.toml added [ask], [ask-claude], [ask-openai] extras and pulled bc-cli[ask] into [dev]; uv.lock wasn't regenerated so `uv sync --locked` rejected the lockfile on CI. - site.yml used node 20; corepack pulls pnpm 11.3.0 which requires node >=22.13 (imports node:sqlite, an only-in-22 builtin). Bumped to 22. No package additions — both ask extras reuse anthropic / openai already pulled in by the extract extras. --- .github/workflows/site.yml | 2 +- uv.lock | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 7e7fe32..3877a3f 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" - name: Enable corepack run: corepack enable diff --git a/uv.lock b/uv.lock index 7d9389e..0c941c7 100644 --- a/uv.lock +++ b/uv.lock @@ -335,6 +335,16 @@ dependencies = [ ] [package.optional-dependencies] +ask = [ + { name = "anthropic" }, + { name = "openai" }, +] +ask-claude = [ + { name = "anthropic" }, +] +ask-openai = [ + { name = "openai" }, +] dev = [ { name = "anthropic" }, { name = "dlt", extra = ["filesystem", "parquet", "s3"] }, @@ -376,8 +386,12 @@ telemetry = [ [package.metadata] requires-dist = [ + { name = "anthropic", marker = "extra == 'ask-claude'", specifier = ">=0.40" }, { name = "anthropic", marker = "extra == 'extract-claude'", specifier = ">=0.40" }, { name = "azure-monitor-opentelemetry", marker = "extra == 'telemetry'", specifier = ">=1.6" }, + { name = "bc-cli", extras = ["ask"], marker = "extra == 'dev'" }, + { name = "bc-cli", extras = ["ask-claude"], marker = "extra == 'ask'" }, + { name = "bc-cli", extras = ["ask-openai"], marker = "extra == 'ask'" }, { name = "bc-cli", extras = ["etl"], marker = "extra == 'dev'" }, { name = "bc-cli", extras = ["etl"], marker = "extra == 'polaris'" }, { name = "bc-cli", extras = ["extract"], marker = "extra == 'dev'" }, @@ -389,6 +403,7 @@ requires-dist = [ { name = "keyring", specifier = ">=25.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "msal", specifier = ">=1.28" }, + { name = "openai", marker = "extra == 'ask-openai'", specifier = ">=1.50" }, { name = "openai", marker = "extra == 'extract-openai'", specifier = ">=1.50" }, { name = "pyarrow", marker = "extra == 'polaris'", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.0" }, @@ -404,7 +419,7 @@ requires-dist = [ { name = "tomlkit", specifier = ">=0.13" }, { name = "typer", specifier = ">=0.12" }, ] -provides-extras = ["cli", "etl", "telemetry", "extract", "extract-claude", "extract-openai", "mcp", "polaris", "dev"] +provides-extras = ["cli", "etl", "telemetry", "extract", "extract-claude", "extract-openai", "ask", "ask-claude", "ask-openai", "mcp", "polaris", "dev"] [[package]] name = "botocore" From 75fa1280a4099ecb661a004ed7b225892a5c0ec1 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Sun, 24 May 2026 14:15:23 -0500 Subject: [PATCH 14/17] fix(ci): ruff lint cleanups + pnpm onlyBuiltDependencies allowlist - 4 unused imports in tests (F401, autofixed by ruff) - 1 unused local variable in test_installer.py (F841, manual drop) - bcli-site/package.json: declare onlyBuiltDependencies=[esbuild, sharp] so pnpm 10+ runs their install scripts instead of failing the build. Both are first-party Astro deps with trusted publishers. 37 tests in test_packs/ + test_ask/ still pass post-cleanup. --- bcli-site/package.json | 6 ++++++ tests/test_ask/test_providers.py | 2 +- tests/test_packs/test_installer.py | 4 +--- tests/test_packs/test_registry.py | 1 - 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bcli-site/package.json b/bcli-site/package.json index 031e914..f20eefb 100644 --- a/bcli-site/package.json +++ b/bcli-site/package.json @@ -17,5 +17,11 @@ "@astrojs/check": "^0.9.0", "tailwindcss": "^3.4.0", "typescript": "^5.5.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "sharp" + ] } } diff --git a/tests/test_ask/test_providers.py b/tests/test_ask/test_providers.py index e8df864..b8d46c4 100644 --- a/tests/test_ask/test_providers.py +++ b/tests/test_ask/test_providers.py @@ -9,7 +9,7 @@ import pytest -from bcli.ask import collect_extra_context, discover_providers +from bcli.ask import collect_extra_context from bcli.context import LastErrorRecord, ProfileSnapshot diff --git a/tests/test_packs/test_installer.py b/tests/test_packs/test_installer.py index 7e84519..cd164f5 100644 --- a/tests/test_packs/test_installer.py +++ b/tests/test_packs/test_installer.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -from pathlib import Path import pytest import yaml @@ -18,7 +17,6 @@ from bcli.packs._installer import ( batches_dir, fragments_dir, - plan_install, queries_path, registries_path, ) @@ -33,7 +31,7 @@ def test_install_writes_all_artefacts(make_pack, config_dir, install_target) -> presets={"myEntity": {"entity_set_name": "myEntity", "supports": ["GET"]}}, ) pack = load_pack(src) - plan = install_pack( + install_pack( pack, profile="prod", target=install_target, diff --git a/tests/test_packs/test_registry.py b/tests/test_packs/test_registry.py index 92172a3..5eb9a4b 100644 --- a/tests/test_packs/test_registry.py +++ b/tests/test_packs/test_registry.py @@ -5,7 +5,6 @@ import logging from pathlib import Path -import pytest from bcli.packs import Pack, discover_all, discover_builtin_packs From 54a1117a69399b9bde89efcf14cbac20ae887682 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Sun, 24 May 2026 14:16:27 -0500 Subject: [PATCH 15/17] fix(ci): move onlyBuiltDependencies to pnpm-workspace.yaml (pnpm 11+) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm 11.3 dropped the `pnpm.*` key from package.json (warning emitted in CI: "The 'pnpm' field in package.json is no longer read by pnpm"). The new home is pnpm-workspace.yaml even for single-package projects. Drop the dead package.json key. Both esbuild and sharp are first-party Astro deps that legitimately need install-time build scripts — without the allowlist, pnpm refuses to run them and the build fails with ERR_PNPM_IGNORED_BUILDS. --- bcli-site/package.json | 6 ------ bcli-site/pnpm-workspace.yaml | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 bcli-site/pnpm-workspace.yaml diff --git a/bcli-site/package.json b/bcli-site/package.json index f20eefb..031e914 100644 --- a/bcli-site/package.json +++ b/bcli-site/package.json @@ -17,11 +17,5 @@ "@astrojs/check": "^0.9.0", "tailwindcss": "^3.4.0", "typescript": "^5.5.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild", - "sharp" - ] } } diff --git a/bcli-site/pnpm-workspace.yaml b/bcli-site/pnpm-workspace.yaml new file mode 100644 index 0000000..a3e72df --- /dev/null +++ b/bcli-site/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +# pnpm 11+ config — the legacy `pnpm.*` key in package.json was removed. +# onlyBuiltDependencies allows install-time build scripts for the listed +# packages; required because pnpm refuses unknown build scripts by default. +# Both entries are first-party Astro dependencies needed at build time. +onlyBuiltDependencies: + - esbuild + - sharp From 5877af6bab1bb27522ef4a16b1ee5b500db53b69 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Sun, 24 May 2026 20:51:23 -0500 Subject: [PATCH 16/17] fix(ci): use bcli-site/.npmrc for pnpm onlyBuiltDependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm 11.3 didn't pick up onlyBuiltDependencies from a package-local pnpm-workspace.yaml — build still failed with ERR_PNPM_IGNORED_BUILDS. .npmrc is the canonical package-local pnpm config and is reliably read from the working directory pnpm install runs in. --- bcli-site/.npmrc | 5 +++++ bcli-site/pnpm-workspace.yaml | 7 ------- 2 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 bcli-site/.npmrc delete mode 100644 bcli-site/pnpm-workspace.yaml diff --git a/bcli-site/.npmrc b/bcli-site/.npmrc new file mode 100644 index 0000000..384a134 --- /dev/null +++ b/bcli-site/.npmrc @@ -0,0 +1,5 @@ +# pnpm 10+/11+ refuses unknown postinstall build scripts. Allow the two +# Astro deps that legitimately need them (esbuild native binary, sharp +# image pipeline). Both are first-party Astro deps with trusted publishers. +only-built-dependencies[]=esbuild +only-built-dependencies[]=sharp diff --git a/bcli-site/pnpm-workspace.yaml b/bcli-site/pnpm-workspace.yaml deleted file mode 100644 index a3e72df..0000000 --- a/bcli-site/pnpm-workspace.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# pnpm 11+ config — the legacy `pnpm.*` key in package.json was removed. -# onlyBuiltDependencies allows install-time build scripts for the listed -# packages; required because pnpm refuses unknown build scripts by default. -# Both entries are first-party Astro dependencies needed at build time. -onlyBuiltDependencies: - - esbuild - - sharp From 48e655ced043d42bbdfa3a4baddae3177c6ffe98 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Sun, 24 May 2026 20:54:01 -0500 Subject: [PATCH 17/17] fix(ci): pin pnpm to 9.15.4 via packageManager field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm 10+ enforces an "approve-builds" gate that requires onlyBuiltDependencies to be configured before install scripts (esbuild, sharp) can run. corepack downloaded pnpm 11.3.0 by default; neither package.json nor pnpm-workspace.yaml nor .npmrc made pnpm 11 honor the allowlist in CI (config read order varies across the 10/11 transition). pnpm 9.15.4 predates that strict-builds behavior and runs install scripts without the approval gate. corepack honors the packageManager field, so this pins deterministically across local dev and CI. .npmrc kept in place — harmless for pnpm 9, future-proof if we re-bump. --- bcli-site/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/bcli-site/package.json b/bcli-site/package.json index 031e914..40c3c1c 100644 --- a/bcli-site/package.json +++ b/bcli-site/package.json @@ -3,6 +3,7 @@ "type": "module", "version": "0.1.0", "private": true, + "packageManager": "pnpm@9.15.4", "description": "Landing site for bcli — Business Central from the terminal", "scripts": { "dev": "astro dev",