diff --git a/.gitignore b/.gitignore index 341571c..1275392 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ htmlcov/ .env .DS_Store -.cursor/ \ No newline at end of file +.cursor/ +.pickled-cache/ \ No newline at end of file diff --git a/docs/decisions/0002-cache-and-budget-in-pickled-config.md b/docs/decisions/0002-cache-and-budget-in-pickled-config.md new file mode 100644 index 0000000..7b910c8 --- /dev/null +++ b/docs/decisions/0002-cache-and-budget-in-pickled-config.md @@ -0,0 +1,94 @@ +# ADR-0002: Cache and budget in pickled.config.yaml + +- **Status:** Accepted +- **Date:** 2026-05-19 +- **Deciders:** pickled-spec contributors + +## Context + +`pickled-core` already ships disk-backed `LLMCache` and a `BudgetGuard` that +can cap cumulative LLM spend per process. Until now, leaf MCP servers and +package CLIs called `build_client(...)` directly without passing `cache=`, so +every `draft_*` and `validate_*` tool hit the live API on each rerun. No +bootstrap path installed a budget guard, so agent-driven loops had no +deterministic cost ceiling. + +Dogfood workflows and Cursor-native MCP sessions repeat the same prompts +across iterations. Without wiring cache and budget at client construction, +token spend scales linearly with retries and a runaway tool loop can exhaust +quota before an operator notices. + +## Decision drivers + +- Reduce token cost on repeated dogfood runs without changing gate semantics. +- Provide an upper bound on runaway LLM loops in long-lived MCP processes. +- One construction path shared by bdd, schema, and iac (no duplicated factory + parsing in each leaf). +- No new third-party dependencies; existing configs must keep working. +- Relative cache paths should stay stable when the repo root moves (resolve + against the loaded YAML, not the shell CWD). + +## Considered options + +1. **Env vars only** — `PICKLED_CACHE_DIR`, `PICKLED_MAX_COST_USD`, etc., with + no schema change. Rejected: easy to forget in docs; no single file checked + into the repo for dogfood; harder to share defaults across teammates. + +2. **YAML schema extension + env overrides (chosen)** — optional `cache:` and + `budget:` blocks in `pickled.config.yaml`, with env winning on conflict. + Central `build_default_client()` in `pickled-core` wires cache, budget, and + provider; leaf `_build_llm_client()` functions delegate to it. + +3. **Per-leaf YAML keys** — each package defines its own cache/budget section. + Rejected: duplication, drift risk, and no shared semantics for + `pickled-spec mcp` umbrella behavior. + +## Decision outcome + +Adopt option 2. Extend `PickledConfig` with `CacheSettings`, `BudgetSettings`, +and `source_path` (set by `load_config` when a file is read). Add +`build_default_client()` in `pickled_core.llm.bootstrap` that: + +- Honors `PICKLED_*_LLM_FACTORY` for tests (no cache/budget wiring). +- Installs `BudgetGuard` when `budget.max_cost_usd` or `PICKLED_MAX_COST_USD` + is set. +- Builds `LLMCache` unless cache mode is `off`. +- Passes the cache into `build_client(provider, config=cfg, cache=cache)`. + +Leaf MCP CLIs (`pickled-bdd`, `pickled-schema`, `pickled-iac`) and +`pickled-bdd` CLI replace inline factory parsing with a single bootstrap call. + +## Consequences + +**Positive** + +- Dogfood reruns can reuse cached completions (large reduction in repeat + provider calls when inputs are unchanged). +- A configured `max_cost_usd` aborts further billed calls once the guard + trips. +- Four copies of `_build_llm_client()` logic collapse to one helper. + +**Negative** + +- `PickledConfig` grows (`source_path`, `budget`); callers that construct + configs manually must accept new defaults. + +**Neutral** + +- Configs without `cache:` / `budget:` behave as before: cache on at + `.pickled-cache` (resolved next to the YAML), no budget cap. + +## Path semantics + +- Relative `cache.dir` resolves against the directory containing the loaded + `pickled.config.yaml` (or XDG path), not the process CWD. +- `PICKLED_CACHE_DIR` overrides the directory and keeps **CWD-relative** + resolution when the env value is relative (shell ergonomics). +- Absolute `cache.dir` values are unchanged. + +## Future work + +- Per-run budget reset for long-lived MCP servers (today the guard persists + for the process lifetime). +- Programmatic cache invalidation API (delete by key prefix or provider). +- Optional YAML knob for budget reset cadence (per tool call vs per session). diff --git a/docs/decisions/0003-cache-budget-and-model-resolution.md b/docs/decisions/0003-cache-budget-and-model-resolution.md new file mode 100644 index 0000000..c39f335 --- /dev/null +++ b/docs/decisions/0003-cache-budget-and-model-resolution.md @@ -0,0 +1,89 @@ +# ADR-0003: Cache, budget, and model resolution for leaf MCP and CLI + +- **Status:** Accepted +- **Date:** 2026-05-25 +- **Deciders:** pickled-spec contributors + +## Context + +`pickled-core` already ships disk-backed `LLMCache`, a `BudgetGuard`, and +per-provider `default_model` entries in `pickled.config.yaml`. Until this +change, leaf MCP servers and package CLIs called `build_client(...)` without +`cache=`, so cached completions were never reused. No bootstrap path installed +a budget guard, so agent-driven loops had no deterministic cost ceiling. + +`complete_prompt` hardcoded `DEFAULT_MODEL = "claude-3-5-sonnet-20241022"`, +which is deprecated upstream. MCP-invoked drafters (`FeatureDrafter`, +`OpenAPIDrafter`, `IaCDrafter`) therefore 404ed even when the YAML named a +current model. The provider's `default_model` was parsed but not stored on the +client or consulted by `complete_prompt`. + +## Decision drivers + +- Reduce token spend on repeated dogfood runs without changing gate semantics. +- Provide an upper bound on runaway LLM loops in long-lived MCP processes. +- Honor `default_model` from configuration for every one-shot drafter. +- One construction path shared by bdd, schema, and iac (no duplicated factory + parsing in each leaf). +- No new third-party dependencies; existing configs must keep working. +- Relative `cache.dir` should resolve against the loaded YAML directory. + +## Considered options + +1. **Env vars only** — cache and budget via `PICKLED_*` with no schema change. + Rejected: easy to omit in docs; no checked-in defaults for dogfood. + +2. **YAML schema extension + env overrides (chosen)** — optional `cache:` and + `budget:` on `PickledConfig`, env wins on conflict; `build_default_client` + composes cache, budget, and provider; `default_model` threaded through + `build_client` to each provider client; `complete_prompt` resolves model + from the client when not passed explicitly. + +3. **Per-leaf YAML keys** — duplicate cache/budget blocks in each package. + Rejected: four copies of the same parsing and drift risk. + +## Decision + +Extend `PickledConfig` with optional `cache:` and `budget:` blocks and +`source_path` when loading a file. Add `build_default_client` in +`pickled-core` that installs `BudgetGuard`, builds `LLMCache` unless mode is +`off`, and calls `build_client(provider, config=cfg, cache=cache)`. + +Each provider client accepts `default_model` (with a package-local default). +`complete_prompt` resolves `model` as: explicit argument, then +`client.default_model`, then module `DEFAULT_MODEL`. + +Leaf MCP CLIs and `pickled-bdd` CLI delegate `_build_llm_client()` to +`build_default_client` with package-specific `PICKLED_*_LLM_FACTORY` env vars. +Umbrella `build_server()` paths suppress `click.ClickException` so missing +optional deps or config still register deterministic tools. + +## Consequences + +**Positive** + +- Large reduction in token spend on dogfood reruns when cache is enabled. +- Deterministic cost ceiling when `budget.max_cost_usd` or env cap is set. +- LLM drafters use the configured model; no stale hardcoded model string. +- Four duplicated `_build_llm_client()` implementations collapse to one helper + pattern plus shared bootstrap. + +**Negative** + +- `PickledConfig.source_path` adds mild API surface growth. +- Long-lived MCP servers share one process-wide budget guard until reset + (documented future work). +- Relative `PICKLED_CACHE_DIR` env override remains CWD-relative by design. + +## Path semantics + +Relative `cache.dir` in YAML resolves against `source_path.parent` (the +directory containing `pickled.config.yaml`). When `PICKLED_CACHE_DIR` is set, +relative values resolve against the process CWD. Absolute paths are unchanged. + +## Future work + +- Per-run budget reset for long-lived MCP servers. +- Programmatic cache invalidation API. +- Thread `build_default_client` through rules, data, and diff when those + packages gain LLM-backed MCP tools. diff --git a/docs/mcp.md b/docs/mcp.md index f5042f2..b9afe9b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,6 +4,11 @@ Each pickled-* package exposes workflows over the [Model Context Protocol](https so tools like Cursor and Claude Desktop can draft artifacts, run gates, and inspect run telemetry without bespoke UIs. +LLM-backed tools in **pickled-rules**, **pickled-data**, and **pickled-diff** build +clients via `pickled_core.llm.bootstrap.build_default_client`, so they honor the +`cache:` and `budget:` settings in `pickled.config.yaml` (and env overrides) from +the bootstrap PR. + ## Install From the monorepo root: @@ -20,6 +25,36 @@ pip install 'pickled-core[mcp]' Dependencies: `mcp>=1.27.1`, `fastmcp>=3.3.1,<4.0`. +## Response cache, budget cap, and model + +LLM completions are cached on disk so reruns of `draft_*` and `validate_*` +tools do not re-bill the provider. A budget cap can be installed to abort +LLM calls once a cumulative cost ceiling is reached. +The model used by `complete_prompt`-based drafters is taken from the +provider's `default_model` in `pickled.config.yaml`. + +```yaml +providers: + anthropic: + type: anthropic + default_model: claude-sonnet-4-5-20250929 # used by every drafter + api_key_env: ANTHROPIC_API_KEY +cache: + dir: .pickled-cache # relative paths resolve + mode: read_write # off | read_write | read_only +budget: + max_cost_usd: "5.00" # omit or null for no cap +``` + +Env overrides (env wins over YAML): + +- `PICKLED_CACHE_DIR` — directory for cached JSON entries (CWD-relative) +- `PICKLED_CACHE_MODE` — `off` disables, `read_only` forbids new writes +- `PICKLED_MAX_COST_USD` — decimal string cap +- `PICKLED_LLM_PROVIDER` — pick a provider when multiple are configured + +Add `.pickled-cache/` to `.gitignore` if you keep the default location. + ## Stdio hygiene On **stdio** transport, the MCP server must use **stdout only for JSON-RPC** @@ -61,11 +96,29 @@ uv run pickled-data mcp serve --transport stdio uv run pickled-diff mcp serve --transport stdio ``` -`pickled-diff` exposes **`verify_against_oracle`** (deterministic; no LLM). On the -umbrella server it is mounted as **`diff_verify_against_oracle`** (namespace `diff`). - `pickled-bdd serve` remains a **deprecated** alias for `pickled-bdd mcp serve`. +## Tool reference (umbrella prefixes) + +| Prefix | Tool | Description | +|--------|------|-------------| +| `rules_` | `list_rules` | List rules from YAML text | +| `rules_` | `check_ruleset_coverage` | Coverage gate over feature texts | +| `rules_` | `draft_ruleset_from_brief` | Draft a YAML rule set from a brief | +| `data_` | `parse_sql_migration` | Parse SQL to AST summary | +| `data_` | `apply_sql_to_sandbox` | Apply SQL in-memory | +| `data_` | `check_migration_drift` | Compare migration schema to YAML | +| `data_` | `draft_sql_migration_from_intent` | Draft SQL DDL from intent | +| `diff_` | `verify_against_oracle` | Differential check (deterministic) | +| `diff_` | `draft_corpus_from_examples` | Expand seed examples into a corpus | +| `iac_` | `draft_terraform_module` | Draft Terraform from a user story | +| `iac_` | `validate_terraform_dir` | Validate Terraform file contents | +| `iac_` | `diff_terraform_plans` | Compare plan JSON | +| `iac_` | `explain_plan_diff` | Summarise plan JSON, flag risky actions | +| `iac_` | `suggest_security_remediation` | Patch hints for Trivy findings | + +Other prefixes (`bdd_`, `schema_`) are documented in their package READMEs. + ## Cursor configuration Replace `/ABSOLUTE/PATH/TO/pickled-spec` with your clone path: @@ -126,7 +179,8 @@ numbers. uv run python scripts/smoke_mcp_stdio.py ``` -Expect at least a dozen tools from the umbrella list. +Expect at least a dozen tools from the umbrella list (19 with rules/data/diff +draft and iac advisor tools). ## Workspace gates vs MCP tools diff --git a/packages/pickled-bdd/src/pickled_bdd/cli.py b/packages/pickled-bdd/src/pickled_bdd/cli.py index 06e67dc..45b39b1 100644 --- a/packages/pickled-bdd/src/pickled_bdd/cli.py +++ b/packages/pickled-bdd/src/pickled_bdd/cli.py @@ -2,10 +2,7 @@ from __future__ import annotations -import importlib -import os from pathlib import Path -from typing import cast import click from pickled_core.llm import LLMClient @@ -137,24 +134,11 @@ def serve() -> None: def _build_llm_client() -> LLMClient: """Build an LLM client. Override via PICKLED_BDD_LLM_FACTORY for tests.""" - factory = os.environ.get("PICKLED_BDD_LLM_FACTORY") - if factory: - module_name, sep, attr = factory.partition(":") - if not sep: - raise click.ClickException( - "PICKLED_BDD_LLM_FACTORY must be 'module:callable' " - "(e.g. pickled_bdd.testing:build_fake_llm)" - ) - module = importlib.import_module(module_name) - builder = getattr(module, attr) - return cast(LLMClient, builder()) - - from pickled_core.llm.config import ConfigError, load_config - from pickled_core.llm.factory import build_client - - provider = os.environ.get("PICKLED_LLM_PROVIDER", "anthropic") + from pickled_core.llm.bootstrap import build_default_client + from pickled_core.llm.config import ConfigError + try: - return build_client(provider, config=load_config()) + return build_default_client(factory_env="PICKLED_BDD_LLM_FACTORY") except ConfigError as exc: raise click.ClickException(str(exc)) from exc diff --git a/packages/pickled-bdd/src/pickled_bdd/mcp_cli.py b/packages/pickled-bdd/src/pickled_bdd/mcp_cli.py index 6d9cded..5913ac7 100644 --- a/packages/pickled-bdd/src/pickled_bdd/mcp_cli.py +++ b/packages/pickled-bdd/src/pickled_bdd/mcp_cli.py @@ -3,9 +3,6 @@ from __future__ import annotations import contextlib -import importlib -import os -from typing import cast import click from fastmcp import FastMCP @@ -17,20 +14,11 @@ def _build_llm_client() -> LLMClient: - factory = os.environ.get("PICKLED_BDD_LLM_FACTORY") - if factory: - module_name, sep, attr = factory.partition(":") - if not sep: - raise click.ClickException("PICKLED_BDD_LLM_FACTORY must be 'module:callable'") - module = importlib.import_module(module_name) - return cast(LLMClient, getattr(module, attr)()) + from pickled_core.llm.bootstrap import build_default_client + from pickled_core.llm.config import ConfigError - from pickled_core.llm.config import ConfigError, load_config - from pickled_core.llm.factory import build_client - - provider = os.environ.get("PICKLED_LLM_PROVIDER", "anthropic") try: - return build_client(provider, config=load_config()) + return build_default_client(factory_env="PICKLED_BDD_LLM_FACTORY") except ConfigError as exc: raise click.ClickException(str(exc)) from exc diff --git a/packages/pickled-diff/README.md b/packages/pickled-diff/README.md index de82db8..9f62062 100644 --- a/packages/pickled-diff/README.md +++ b/packages/pickled-diff/README.md @@ -79,7 +79,24 @@ Domain-specific equivalence (tolerant numerics, AST shapes, and so on) belongs i `pickled_diff.mcp_tools.register(server)` adds **`verify_against_oracle`** to a `PickledMCPServer`. The umbrella server mounts this package as namespace **`diff`** -(`diff_verify_against_oracle`). No LLM is required. See [`docs/mcp.md`](../../docs/mcp.md). +(`diff_verify_against_oracle`). See [`docs/mcp.md`](../../docs/mcp.md). + +| MCP tool | Description | +|----------|-------------| +| `verify_against_oracle` | Differential check (deterministic) | +| `draft_corpus_from_examples` | Expand seed examples into a corpus | + +## Drafting a corpus from seed examples + +```bash +pickled-diff draft-corpus \ + --seeds path/to/seed_corpus.json \ + --target-size 10 \ + --notes path/to/notes.txt +``` + +Use `-` for `--seeds` or `--notes` to read from stdin. LLM cache and budget +settings follow [docs/mcp.md](../../docs/mcp.md). ### `pickled-spec check-all` diff --git a/packages/pickled-diff/src/pickled_diff/cli.py b/packages/pickled-diff/src/pickled_diff/cli.py index 0e7c203..be13e8e 100644 --- a/packages/pickled-diff/src/pickled_diff/cli.py +++ b/packages/pickled-diff/src/pickled_diff/cli.py @@ -10,9 +10,11 @@ import click from pickled_core import Verdict +from pickled_core.llm import LLMClient from pickled_diff.comparator import ExactEqComparator, StructuralJsonComparator from pickled_diff.corpus import CorpusItem, InMemoryCorpus +from pickled_diff.drafter import CorpusDrafter from pickled_diff.gate import DifferentialOracleGate from pickled_diff.runner import SubprocessRunner @@ -23,6 +25,45 @@ def _comparator(name: str) -> ExactEqComparator | StructuralJsonComparator: return ExactEqComparator() +def _build_llm_client() -> LLMClient: + from pickled_core.llm.bootstrap import build_default_client + from pickled_core.llm.config import ConfigError + + try: + return build_default_client(factory_env="PICKLED_DIFF_LLM_FACTORY") + except ConfigError as exc: + raise click.ClickException(str(exc)) from exc + + +def _read_text_arg(path: str) -> str: + if path == "-": + return sys.stdin.read() + return Path(path).read_text(encoding="utf-8") + + +def _emit_corpus_output( + *, + items: tuple[dict[str, str], ...], + rationale: str, + warnings: tuple[str, ...], + output: Path | None, +) -> None: + body = json.dumps(list(items), indent=2, ensure_ascii=False) + "\n" + if output is not None: + output.write_text(body, encoding="utf-8") + else: + click.echo(body, nl=False) + if not body.endswith("\n"): + click.echo() + if rationale: + for line in rationale.splitlines(): + click.echo(f"rationale: {line}", err=True) + for warning in warnings: + click.echo(f"warning: {warning}", err=True) + if warnings: + raise SystemExit(1) + + def _gate_result_to_json(result: Any) -> dict[str, Any]: from pickled_diff.types import DifferentialFinding @@ -100,6 +141,69 @@ def verify( sys.exit(exit_codes[result.verdict]) +@main.command("draft-corpus") +@click.option( + "--seeds", + required=True, + help="JSON file with seed items, or '-' for stdin.", +) +@click.option("--target-size", required=True, type=int, help="Total corpus size.") +@click.option( + "--notes", + default=None, + help="Optional notes file path or '-' for stdin.", +) +@click.option( + "-o", + "--output", + type=click.Path(dir_okay=False, path_type=Path), + default=None, + help="Write corpus JSON to this path. Default: stdout.", +) +def draft_corpus( + seeds: str, + target_size: int, + notes: str | None, + output: Path | None, +) -> None: + """Expand seed examples into a larger differential corpus.""" + if target_size < 1: + raise click.ClickException("--target-size must be a positive integer") + raw = json.loads(_read_text_arg(seeds)) + if not isinstance(raw, list): + raise click.ClickException("seeds JSON must be a list") + seed_examples: list[dict[str, str]] = [] + for entry in raw: + if not isinstance(entry, dict): + raise click.ClickException("each seed must be an object") + name = entry.get("name") + payload = entry.get("payload") + if not isinstance(name, str) or not isinstance(payload, str): + raise click.ClickException("each seed needs string name and payload") + seed_examples.append({"name": name, "payload": payload}) + notes_text: str | None = None + if notes is not None: + notes_text = _read_text_arg(notes) + try: + llm = _build_llm_client() + result = CorpusDrafter(llm).draft_from_examples( + seed_examples=seed_examples, + target_size=target_size, + notes=notes_text, + ) + except click.ClickException: + raise + except Exception as exc: + click.echo(str(exc), err=True) + raise SystemExit(2) from exc + _emit_corpus_output( + items=result.items, + rationale=result.rationale, + warnings=result.warnings, + output=output, + ) + + @main.group() def mcp() -> None: """MCP server commands.""" diff --git a/packages/pickled-diff/src/pickled_diff/drafter.py b/packages/pickled-diff/src/pickled_diff/drafter.py new file mode 100644 index 0000000..352f453 --- /dev/null +++ b/packages/pickled-diff/src/pickled_diff/drafter.py @@ -0,0 +1,118 @@ +"""LLM-driven drafter: seed examples → expanded differential corpus.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from pickled_core.llm.base import LLMClient, Message + +RATIONALE_SENTINEL = "---RATIONALE---" +_DRAFT_MODEL = "claude-sonnet-4-5-20250929" + + +@dataclass(frozen=True, slots=True) +class CorpusDraftResult: + items: tuple[dict[str, str], ...] + rationale: str + warnings: tuple[str, ...] + + +class CorpusDrafter: + """Expand seed examples into a larger test corpus.""" + + def __init__(self, llm: LLMClient) -> None: + self._llm = llm + + def draft_from_examples( + self, + *, + seed_examples: list[dict[str, str]], + target_size: int, + notes: str | None = None, + ) -> CorpusDraftResult: + prompt = self._build_prompt( + seed_examples=seed_examples, + target_size=target_size, + notes=notes, + ) + completion = self._llm.complete( + messages=[Message(role="user", content=prompt)], + model=_DRAFT_MODEL, + max_tokens=4000, + temperature=0.0, + stop=None, + extras=None, + ) + text, rationale = self._split_output(completion.text) + items, warnings = self._validate(text, target_size=target_size) + return CorpusDraftResult( + items=items, + rationale=rationale, + warnings=warnings, + ) + + def _build_prompt( + self, + *, + seed_examples: list[dict[str, str]], + target_size: int, + notes: str | None, + ) -> str: + seeds_json = json.dumps(seed_examples, indent=2) + notes_line = notes.strip() if notes else "" + return ( + "You are expanding a corpus of test inputs for differential testing.\n" + f"Given {len(seed_examples)} seed examples, produce a corpus of exactly " + f"{target_size} total items (you may include the seeds verbatim and add " + "new ones). Each item is " + '`{"name": "", "payload": ""}`.\n' + "Aim for diversity: edge cases (empty, large, unicode, boundary " + "numerics), realistic mid-size inputs, and degenerate cases. Output " + "ONLY a JSON array of objects — no preamble, no markdown fences.\n" + f"Seed examples:\n{seeds_json}\n" + + (f"Notes:\n{notes_line}\n" if notes_line else "") + + f"After the JSON, emit {RATIONALE_SENTINEL!r} then 1-3 sentences." + ) + + def _split_output(self, raw: str) -> tuple[str, str]: + if RATIONALE_SENTINEL in raw: + text, _, rationale = raw.partition(RATIONALE_SENTINEL) + return text.strip(), rationale.strip() + return raw.strip(), "" + + def _validate( + self, text: str, *, target_size: int + ) -> tuple[tuple[dict[str, str], ...], tuple[str, ...]]: + warnings: list[str] = [] + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + warnings.append(str(exc)) + return (), tuple(warnings) + + if not isinstance(parsed, list): + warnings.append("corpus JSON must be an array") + return (), tuple(warnings) + + items: list[dict[str, str]] = [] + for index, element in enumerate(parsed): + if not isinstance(element, dict): + warnings.append(f"item {index} must be an object") + continue + name = element.get("name") + payload = element.get("payload") + if not isinstance(name, str) or not isinstance(payload, str): + warnings.append(f"item {index} must have string name and payload") + continue + items.append({"name": name, "payload": payload}) + + if len(items) != target_size: + warnings.append( + f"corpus has {len(items)} items; expected {target_size}" + ) + + return tuple(items), tuple(warnings) + + +__all__ = ["CorpusDraftResult", "CorpusDrafter"] diff --git a/packages/pickled-diff/src/pickled_diff/mcp_cli.py b/packages/pickled-diff/src/pickled_diff/mcp_cli.py index d64694a..357f41d 100644 --- a/packages/pickled-diff/src/pickled_diff/mcp_cli.py +++ b/packages/pickled-diff/src/pickled_diff/mcp_cli.py @@ -4,16 +4,19 @@ import click from fastmcp import FastMCP +from pickled_core.mcp.llm_client import build_llm_client, optional_llm_client from pickled_core.mcp.stdio_logging import setup_logging_for_stdio from pickled_core.mcp.transport import resolve_transport from pickled_diff.mcp_tools import register_with_fastmcp +_FACTORY_ENV = "PICKLED_DIFF_LLM_FACTORY" + def build_server() -> FastMCP: """Entry point for ``pickled.mcp.subservers`` (returns FastMCP app).""" app = FastMCP("pickled-diff") - register_with_fastmcp(app) + register_with_fastmcp(app, llm=optional_llm_client(factory_env=_FACTORY_ENV)) return app @@ -36,7 +39,8 @@ def cli( """Run the pickled-diff MCP server.""" if transport == "stdio": setup_logging_for_stdio() - app = build_server() + app = FastMCP("pickled-diff") + register_with_fastmcp(app, llm=build_llm_client(factory_env=_FACTORY_ENV)) kwargs = resolve_transport(transport, host, port, allow_public) # type: ignore[arg-type] transport_name = kwargs.pop("transport") app.run(transport=transport_name, **kwargs) diff --git a/packages/pickled-diff/src/pickled_diff/mcp_tools.py b/packages/pickled-diff/src/pickled_diff/mcp_tools.py index e59125d..b806979 100644 --- a/packages/pickled-diff/src/pickled_diff/mcp_tools.py +++ b/packages/pickled-diff/src/pickled_diff/mcp_tools.py @@ -5,12 +5,14 @@ from typing import TYPE_CHECKING, Any from pickled_core import PickledMCPServer +from pickled_core.llm import LLMClient if TYPE_CHECKING: from fastmcp import FastMCP from pickled_diff.comparator import ExactEqComparator, StructuralJsonComparator from pickled_diff.corpus import CorpusItem, InMemoryCorpus +from pickled_diff.drafter import CorpusDrafter from pickled_diff.gate import DifferentialOracleGate from pickled_diff.runner import SubprocessRunner from pickled_diff.types import DifferentialFinding @@ -70,8 +72,9 @@ def _verify_against_oracle( } -def register_with_fastmcp(app: FastMCP) -> None: +def register_with_fastmcp(app: FastMCP, *, llm: LLMClient | None = None) -> None: """Register tools directly on a FastMCP application.""" + drafter: CorpusDrafter | None = CorpusDrafter(llm) if llm is not None else None @app.tool(name="verify_against_oracle") def _tool( @@ -91,6 +94,43 @@ def _tool( timeout_seconds=timeout_seconds, ) + if drafter is None: + + @app.tool(name="draft_corpus_from_examples") + def _draft_corpus_from_examples_stub( + *, + seed_examples: list[dict[str, str]], + target_size: int, + notes: str | None = None, + ) -> dict[str, Any]: + _ = seed_examples, target_size, notes + msg = ( + "LLM client not configured (set pickled.config.yaml or " + "PICKLED_DIFF_LLM_FACTORY)" + ) + raise RuntimeError(msg) + + else: + + @app.tool(name="draft_corpus_from_examples") + def _draft_corpus_from_examples( + *, + seed_examples: list[dict[str, str]], + target_size: int, + notes: str | None = None, + ) -> dict[str, Any]: + """Expand a small set of seed examples into a larger differential corpus.""" + result = drafter.draft_from_examples( + seed_examples=seed_examples, + target_size=target_size, + notes=notes, + ) + return { + "corpus_items": list(result.items), + "rationale": result.rationale, + "warnings": list(result.warnings), + } + def register(server: PickledMCPServer) -> None: """Register pickled-diff's MCP tools. diff --git a/packages/pickled-diff/tests/fixtures/seed_corpus.json b/packages/pickled-diff/tests/fixtures/seed_corpus.json new file mode 100644 index 0000000..2cc3286 --- /dev/null +++ b/packages/pickled-diff/tests/fixtures/seed_corpus.json @@ -0,0 +1,5 @@ +[ + {"name": "empty", "payload": ""}, + {"name": "ascii-word", "payload": "hello"}, + {"name": "unicode", "payload": "café"} +] diff --git a/packages/pickled-diff/tests/test_cli_draft.py b/packages/pickled-diff/tests/test_cli_draft.py new file mode 100644 index 0000000..a24614c --- /dev/null +++ b/packages/pickled-diff/tests/test_cli_draft.py @@ -0,0 +1,157 @@ +"""CLI draft-corpus command tests for pickled-diff.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner +from pickled_core.cost.models import TokenUsage +from pickled_core.llm.base import Completion, LLMClient, Message +from pickled_diff.cli import main +from pickled_diff.drafter import RATIONALE_SENTINEL + +_VALID_CORPUS = json.dumps( + [ + {"name": "empty", "payload": ""}, + {"name": "one", "payload": "1"}, + ] +) + + +class _CannedDiffLLM(LLMClient): + provider_key = "canned" + + def __init__(self, response: str) -> None: + self._response = response + + def complete( + self, + *, + messages: list[Message], + model: str, + max_tokens: int, + temperature: float | None, + stop: list[str] | None, + extras: Mapping[str, Any] | None, + ) -> Completion: + _ = messages, model, max_tokens, temperature, stop, extras + return Completion( + text=self._response, + usage=TokenUsage(), + model_id_resolved=model, + raw_response=None, + ) + + def count_tokens(self, messages: list[Message], model: str) -> int: + _ = messages, model + return 1 + + +class _RaisingDiffLLM(LLMClient): + provider_key = "canned" + + def complete( + self, + *, + messages: list[Message], + model: str, + max_tokens: int, + temperature: float | None, + stop: list[str] | None, + extras: Mapping[str, Any] | None, + ) -> Completion: + _ = messages, model, max_tokens, temperature, stop, extras + msg = "LLM complete failed" + raise RuntimeError(msg) + + def count_tokens(self, messages: list[Message], model: str) -> int: + _ = messages, model + return 1 + + +def _fake_diff_llm() -> LLMClient: + body = f"{_VALID_CORPUS}\n{RATIONALE_SENTINEL}\nok\n" + return _CannedDiffLLM(body) + + +def test_draft_writes_artifact_to_stdout_by_default( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr("pickled_diff.cli._build_llm_client", _fake_diff_llm) + seeds = tmp_path / "seeds.json" + seeds.write_text('[{"name": "a", "payload": "1"}]', encoding="utf-8") + runner = CliRunner(mix_stderr=False) + result = runner.invoke( + main, + ["draft-corpus", "--seeds", str(seeds), "--target-size", "2"], + ) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert len(parsed) == 2 + + +def test_draft_writes_artifact_to_output_file_with_dash_o( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr("pickled_diff.cli._build_llm_client", _fake_diff_llm) + seeds = tmp_path / "seeds.json" + seeds.write_text('[{"name": "a", "payload": "1"}]', encoding="utf-8") + out = tmp_path / "corpus.json" + runner = CliRunner(mix_stderr=False) + result = runner.invoke( + main, + [ + "draft-corpus", + "--seeds", + str(seeds), + "--target-size", + "2", + "-o", + str(out), + ], + ) + assert result.exit_code == 0 + assert len(json.loads(out.read_text(encoding="utf-8"))) == 2 + + +def test_draft_exits_1_when_drafter_emits_warning( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + "pickled_diff.cli._build_llm_client", + lambda: _CannedDiffLLM("not-json"), + ) + seeds = tmp_path / "seeds.json" + seeds.write_text("[]", encoding="utf-8") + runner = CliRunner(mix_stderr=False) + result = runner.invoke( + main, + ["draft-corpus", "--seeds", str(seeds), "--target-size", "2"], + ) + assert result.exit_code == 1 + assert "warning:" in result.stderr + + +def test_draft_exits_2_on_drafter_exception( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + "pickled_diff.cli._build_llm_client", + lambda: _RaisingDiffLLM(), + ) + seeds = tmp_path / "seeds.json" + seeds.write_text("[]", encoding="utf-8") + runner = CliRunner(mix_stderr=False) + result = runner.invoke( + main, + ["draft-corpus", "--seeds", str(seeds), "--target-size", "2"], + ) + assert result.exit_code == 2 diff --git a/packages/pickled-diff/tests/test_drafter.py b/packages/pickled-diff/tests/test_drafter.py new file mode 100644 index 0000000..54b5bc1 --- /dev/null +++ b/packages/pickled-diff/tests/test_drafter.py @@ -0,0 +1,70 @@ +"""Tests for :class:`pickled_diff.drafter.CorpusDrafter`.""" + +from __future__ import annotations + +import json + +from pickled_bdd.testing import CannedLLMClient +from pickled_diff.drafter import RATIONALE_SENTINEL, CorpusDrafter + +_VALID_CORPUS = json.dumps( + [ + {"name": "empty", "payload": ""}, + {"name": "one", "payload": "1"}, + {"name": "two", "payload": "2"}, + ] +) + + +def _with_rationale(body: str, rationale: str = "Expanded seeds.") -> str: + return f"{body}\n{RATIONALE_SENTINEL}\n{rationale}" + + +def test_drafts_valid_json_corpus() -> None: + llm = CannedLLMClient(_with_rationale(_VALID_CORPUS)) + result = CorpusDrafter(llm).draft_from_examples( + seed_examples=[{"name": "empty", "payload": ""}], + target_size=3, + ) + assert len(result.items) == 3 + assert result.warnings == () + assert result.rationale == "Expanded seeds." + + +def test_warns_on_invalid_json() -> None: + llm = CannedLLMClient(_with_rationale("not json")) + result = CorpusDrafter(llm).draft_from_examples( + seed_examples=[], + target_size=2, + ) + assert result.items == () + assert len(result.warnings) >= 1 + + +def test_warns_on_missing_fields() -> None: + bad = json.dumps([{"name": "x"}, {"payload": "y"}]) + llm = CannedLLMClient(_with_rationale(bad)) + result = CorpusDrafter(llm).draft_from_examples( + seed_examples=[], + target_size=2, + ) + assert len(result.warnings) >= 1 + + +def test_warns_on_size_mismatch() -> None: + llm = CannedLLMClient(_with_rationale(_VALID_CORPUS)) + result = CorpusDrafter(llm).draft_from_examples( + seed_examples=[], + target_size=5, + ) + assert any("expected 5" in w for w in result.warnings) + + +def test_handles_missing_rationale_sentinel() -> None: + llm = CannedLLMClient(_VALID_CORPUS) + result = CorpusDrafter(llm).draft_from_examples( + seed_examples=[], + target_size=3, + ) + assert result.rationale == "" + assert len(result.items) == 3 diff --git a/packages/pickled-diff/tests/test_mcp_tools.py b/packages/pickled-diff/tests/test_mcp_tools.py index b2fc514..e96213c 100644 --- a/packages/pickled-diff/tests/test_mcp_tools.py +++ b/packages/pickled-diff/tests/test_mcp_tools.py @@ -1,13 +1,34 @@ from __future__ import annotations +import json + import pytest pytest.importorskip("fastmcp") +from pickled_bdd.testing import CannedLLMClient from pickled_core import PickledMCPServer, ToolAlreadyRegisteredError from pickled_diff import mcp_tools from pickled_diff.runner import CallableRunner +class _FakeApp: + def __init__(self) -> None: + self.tools: dict[str, object] = {} + + def tool(self, *, name: str | None = None): # type: ignore[no-untyped-def] + def deco(fn): # type: ignore[no-untyped-def] + assert name is not None + self.tools[name] = fn + return fn + + return deco + + +_CORPUS_JSON = json.dumps( + [{"name": "a", "payload": "1"}, {"name": "b", "payload": "2"}] +) + "\n---RATIONALE---\nok\n" + + def test_register_adds_one_tool() -> None: server = PickledMCPServer("test-diff") mcp_tools.register(server) @@ -46,3 +67,25 @@ def test_register_twice_raises() -> None: mcp_tools.register(server) with pytest.raises(ToolAlreadyRegisteredError): mcp_tools.register(server) + + +def test_register_with_llm_adds_draft_tool() -> None: + llm = CannedLLMClient(_CORPUS_JSON) + app = _FakeApp() + mcp_tools.register_with_fastmcp(app, llm=llm) # type: ignore[arg-type] + assert set(app.tools.keys()) == { + "verify_against_oracle", + "draft_corpus_from_examples", + } + + +def test_register_without_llm_stub_tool_raises() -> None: + app = _FakeApp() + mcp_tools.register_with_fastmcp(app, llm=None) # type: ignore[arg-type] + handler = app.tools["draft_corpus_from_examples"] + try: + handler(seed_examples=[], target_size=2) # type: ignore[operator] + except RuntimeError as exc: + assert "PICKLED_DIFF_LLM_FACTORY" in str(exc) + else: + raise AssertionError("expected RuntimeError when llm is None") diff --git a/packages/pickled-schema/src/pickled_schema/mcp_cli.py b/packages/pickled-schema/src/pickled_schema/mcp_cli.py index 631bd82..4d6eb4a 100644 --- a/packages/pickled-schema/src/pickled_schema/mcp_cli.py +++ b/packages/pickled-schema/src/pickled_schema/mcp_cli.py @@ -3,13 +3,10 @@ from __future__ import annotations import contextlib -import importlib -import os -from typing import cast import click from fastmcp import FastMCP -from pickled_core import LLMClient +from pickled_core.llm import LLMClient from pickled_core.mcp.stdio_logging import setup_logging_for_stdio from pickled_core.mcp.transport import resolve_transport @@ -17,22 +14,11 @@ def _build_llm_client() -> LLMClient: - factory = os.environ.get("PICKLED_SCHEMA_LLM_FACTORY") - if factory: - module_name, sep, attr = factory.partition(":") - if not sep: - raise click.ClickException( - "PICKLED_SCHEMA_LLM_FACTORY must be 'module:callable'" - ) - module = importlib.import_module(module_name) - return cast(LLMClient, getattr(module, attr)()) + from pickled_core.llm.bootstrap import build_default_client + from pickled_core.llm.config import ConfigError - from pickled_core.llm.config import ConfigError, load_config - from pickled_core.llm.factory import build_client - - provider = os.environ.get("PICKLED_LLM_PROVIDER", "anthropic") try: - return build_client(provider, config=load_config()) + return build_default_client(factory_env="PICKLED_SCHEMA_LLM_FACTORY") except ConfigError as exc: raise click.ClickException(str(exc)) from exc diff --git a/pickled.config.yaml b/pickled.config.yaml new file mode 100644 index 0000000..916b5e4 --- /dev/null +++ b/pickled.config.yaml @@ -0,0 +1,7 @@ +# Auto-generated by scripts/setup_local_dev.py. +# Re-run with --force to regenerate. +providers: + anthropic: + type: anthropic + default_model: claude-sonnet-4-5-20250929 + api_key_env: ANTHROPIC_API_KEY diff --git a/pyproject.toml b/pyproject.toml index 5b8d493..f584ac2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ dev-dependencies = [ "mypy>=1.10", "gitlint>=0.19.1", "pre-commit>=4.0", + "pickled-core[all-providers]", + "pickled-core[mcp]", ] [tool.uv.sources] diff --git a/scripts/setup_local_dev.py b/scripts/setup_local_dev.py new file mode 100755 index 0000000..8e9c071 --- /dev/null +++ b/scripts/setup_local_dev.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Local development onboarding for the pickled-spec monorepo. + +Writes ``pickled.config.yaml``, merges a ``pickled-spec`` MCP server entry into +Cursor config, and optionally runs the stdio smoke test. Stdlib only. +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +ActionKind = Literal[ + "CREATE", + "UPDATE", + "REPLACE", + "SKIP", + "BACKUP", + "INSERT", + "ABORT", +] + + +@dataclass(frozen=True, slots=True) +class PlanRow: + """One planned or completed filesystem action.""" + + path: str + action: ActionKind + detail: str = "" + + +def log(level: str, message: str) -> None: + """Write a timestamped line to stderr.""" + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + sys.stderr.write(f"{ts} [{level}] {message}\n") + + +def resolve_repo_root() -> Path: + """Return monorepo root (parent of ``scripts/``).""" + root = Path(__file__).resolve().parent.parent + if not (root / "pyproject.toml").is_file(): + log("ERROR", f"pyproject.toml not found under {root}") + sys.exit(2) + return root + + +def require_uv() -> None: + """Abort if ``uv`` is not on PATH.""" + if shutil.which("uv") is None: + log( + "ERROR", + "uv not found on PATH. Install: curl -LsSf https://astral.sh/uv/install.sh | sh", + ) + sys.exit(2) + + +def cursor_config_path(kind: str) -> Path | None: + """Resolve Cursor MCP JSON path for ``global``, ``project``, or ``none``.""" + if kind == "none": + return None + if kind == "project": + return resolve_repo_root() / ".cursor" / "mcp.json" + home = Path(os.environ.get("USERPROFILE", Path.home())) + if os.name == "nt": + return home / ".cursor" / "mcp.json" + return Path.home() / ".cursor" / "mcp.json" + + +def config_yaml_body(provider: str, model: str, api_key_env: str) -> str: + """Emit fixed-shape ``pickled.config.yaml`` without a YAML library.""" + return ( + "# Auto-generated by scripts/setup_local_dev.py.\n" + "# Re-run with --force to regenerate.\n" + "providers:\n" + f" {provider}:\n" + f" type: {provider}\n" + f" default_model: {model}\n" + f" api_key_env: {api_key_env}\n" + ) + + +def mcp_server_entry(repo_root: Path) -> dict[str, Any]: + """Build the ``pickled-spec`` MCP server object for Cursor.""" + return { + "command": "uv", + "args": [ + "run", + "--directory", + str(repo_root.resolve()), + "--extra", + "mcp", + "pickled-spec", + "mcp", + "--transport", + "stdio", + ], + } + + +def backup_path(target: Path) -> Path: + """Return a UTC-stamped backup path next to ``target``.""" + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + return target.with_name(f"{target.name}.bak.{stamp}") + + +def write_pickled_config( + repo_root: Path, + *, + provider: str, + model: str, + api_key_env: str, + force: bool, + dry_run: bool, + rows: list[PlanRow], +) -> None: + """Create or skip ``pickled.config.yaml`` at the repo root.""" + path = repo_root / "pickled.config.yaml" + rel = str(path) + body = config_yaml_body(provider, model, api_key_env) + if path.is_file() and path.read_text(encoding="utf-8").strip() and not force: + log("INFO", f"SKIP {rel} (already exists)") + rows.append(PlanRow(rel, "SKIP", "config exists")) + return + action: ActionKind = "REPLACE" if path.is_file() and force else "CREATE" + if dry_run: + log("INFO", f"DRY-RUN would {action} {rel}") + rows.append(PlanRow(rel, action)) + return + path.write_text(body, encoding="utf-8") + log("INFO", f"{action} {rel}") + rows.append(PlanRow(rel, action)) + + +def _load_mcp_json(path: Path) -> dict[str, Any]: + """Parse Cursor MCP config; abort on malformed JSON.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + log("ERROR", f"malformed JSON in {path}: {exc}") + sys.exit(2) + if not isinstance(data, dict): + log("ERROR", f"expected JSON object at root of {path}") + sys.exit(2) + return data + + +def _diff_entry(existing: dict[str, Any], desired: dict[str, Any]) -> str: + """Unified diff between two MCP server entries.""" + old_lines = json.dumps(existing, indent=2, sort_keys=True).splitlines(keepends=True) + new_lines = json.dumps(desired, indent=2, sort_keys=True).splitlines(keepends=True) + return "".join( + difflib.unified_diff(old_lines, new_lines, fromfile="existing", tofile="desired") + ) + + +def merge_cursor_mcp( + config_path: Path, + *, + server_name: str, + entry: dict[str, Any], + force: bool, + dry_run: bool, + rows: list[PlanRow], +) -> None: + """Insert or update one MCP server entry; preserve other servers.""" + rel = str(config_path) + desired_root: dict[str, Any] = {"mcpServers": {server_name: entry}} + + if not config_path.is_file(): + if dry_run: + log("INFO", f"DRY-RUN would CREATE {rel}") + rows.append(PlanRow(rel, "CREATE")) + return + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps(desired_root, indent=2, sort_keys=False) + "\n", + encoding="utf-8", + ) + log("INFO", f"CREATE {rel}") + rows.append(PlanRow(rel, "CREATE")) + return + + data = _load_mcp_json(config_path) + servers = data.get("mcpServers") + if servers is None: + servers = {} + data["mcpServers"] = servers + if not isinstance(servers, dict): + log("ERROR", f"mcpServers must be an object in {rel}") + sys.exit(2) + + current = servers.get(server_name) + if current is None: + action: ActionKind = "INSERT" + servers[server_name] = entry + elif current == entry: + log("INFO", f"SKIP {rel} entry {server_name!r} (unchanged)") + rows.append(PlanRow(rel, "SKIP", server_name)) + return + elif not force: + log("ERROR", f"conflict for mcpServers[{server_name!r}] in {rel} (use --force)") + sys.stderr.write(_diff_entry(current, entry)) + rows.append(PlanRow(rel, "ABORT", server_name)) + sys.exit(2) + else: + action = "REPLACE" + servers[server_name] = entry + + out_text = json.dumps(data, indent=2, sort_keys=False) + "\n" + if dry_run: + log("INFO", f"DRY-RUN would {action} {rel} ({server_name})") + rows.append(PlanRow(rel, action, server_name)) + return + + bak = backup_path(config_path) + shutil.copy2(config_path, bak) + log("INFO", f"BACKUP {bak}") + rows.append(PlanRow(str(bak), "BACKUP")) + + config_path.write_text(out_text, encoding="utf-8") + log("INFO", f"{action} {rel} entry {server_name!r}") + rows.append(PlanRow(rel, action, server_name)) + + +def run_smoke(repo_root: Path, dry_run: bool, rows: list[PlanRow]) -> int: + """Run ``scripts/smoke_mcp_stdio.py`` via uv.""" + script = repo_root / "scripts" / "smoke_mcp_stdio.py" + rel = str(script) + if dry_run: + log("INFO", f"DRY-RUN would run smoke test: {rel}") + rows.append(PlanRow(rel, "SKIP", "dry-run")) + return 0 + log("INFO", f"running smoke test: {rel}") + completed = subprocess.run( + ["uv", "run", "python", str(script)], + cwd=str(repo_root), + check=False, + ) + code = int(completed.returncode) + smoke_action: ActionKind = "SKIP" if code == 0 else "ABORT" + rows.append(PlanRow(rel, smoke_action, f"exit {code}")) + if code != 0: + log("WARN", f"smoke test exited {code}") + else: + log("INFO", "smoke test passed") + return code + + +def print_summary(rows: list[PlanRow]) -> None: + """Print a two-column action table to stderr.""" + if not rows: + return + width = max(len(r.path) for r in rows) + sys.stderr.write(f"\n{'path'.ljust(width)} | action\n") + sys.stderr.write(f"{'-' * width}-|--------\n") + for row in rows: + detail = f" ({row.detail})" if row.detail else "" + sys.stderr.write(f"{row.path.ljust(width)} | {row.action}{detail}\n") + + +def print_next_steps(api_key_env: str) -> None: + """Print post-setup instructions to stderr.""" + sys.stderr.write( + "\nNext steps:\n" + f"- Set {api_key_env} in your shell rc if not already set.\n" + "- Restart Cursor (fully quit, reopen).\n" + "- In a fresh Cursor chat, ask: 'List available MCP tools.' — expect at " + "least a dozen prefixed bdd_/rules_/schema_/iac_/data_/diff_.\n" + ) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Build CLI argument parser.""" + parser = argparse.ArgumentParser( + description="Onboard local dev: pickled.config.yaml, Cursor MCP, optional smoke.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned actions; write nothing.", + ) + parser.add_argument( + "--cursor-config", + choices=("global", "project", "none"), + default="global", + help="Where to merge the pickled-spec MCP entry.", + ) + parser.add_argument( + "--server-name", + default="pickled-spec", + help="mcpServers key in Cursor config.", + ) + parser.add_argument("--provider", default="anthropic", help="LLM provider id.") + parser.add_argument( + "--default-model", + default="claude-sonnet-4-5-20250929", + help="default_model in pickled.config.yaml.", + ) + parser.add_argument( + "--api-key-env", + default="ANTHROPIC_API_KEY", + help="Environment variable name for the API key.", + ) + parser.add_argument("--skip-config", action="store_true", help="Skip pickled.config.yaml.") + parser.add_argument("--skip-smoke", action="store_true", help="Skip smoke_mcp_stdio.py.") + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing config and replace conflicting MCP entries.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Run onboarding steps; return process exit code.""" + args = parse_args(argv) + rows: list[PlanRow] = [] + warn = False + + repo_root = resolve_repo_root() + require_uv() + + if not os.environ.get(args.api_key_env): + log("WARN", f"{args.api_key_env} is not set in the environment") + warn = True + + if not args.skip_config: + write_pickled_config( + repo_root, + provider=args.provider, + model=args.default_model, + api_key_env=args.api_key_env, + force=args.force, + dry_run=args.dry_run, + rows=rows, + ) + + mcp_path = cursor_config_path(args.cursor_config) + if mcp_path is not None: + merge_cursor_mcp( + mcp_path, + server_name=args.server_name, + entry=mcp_server_entry(repo_root), + force=args.force, + dry_run=args.dry_run, + rows=rows, + ) + + smoke_code = 0 + if not args.skip_smoke and not args.dry_run: + smoke_code = run_smoke(repo_root, args.dry_run, rows) + if smoke_code != 0: + warn = True + + print_summary(rows) + print_next_steps(args.api_key_env) + + if args.dry_run: + plan = [{"path": r.path, "action": r.action, "detail": r.detail} for r in rows] + sys.stdout.write(json.dumps({"plan": plan}) + "\n") + return 1 if warn else 0 + + return 1 if warn else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 1d1f3c8..6029bf3 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,8 @@ members = [ dev = [ { name = "gitlint", specifier = ">=0.19.1" }, { name = "mypy", specifier = ">=1.10" }, + { name = "pickled-core", extras = ["all-providers"], editable = "packages/pickled-core" }, + { name = "pickled-core", extras = ["mcp"], editable = "packages/pickled-core" }, { name = "pre-commit", specifier = ">=4.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "ruff", specifier = ">=0.5" },