From 8156fca7ba76de144efb2c9a8b603a0d23502143 Mon Sep 17 00:00:00 2001 From: cvetty Date: Mon, 6 Jul 2026 09:05:53 +0300 Subject: [PATCH 1/2] Add interactive, arrow-key guided setup to `whygraph init` Prompts for the agent, analyze/rationale LLMs (+ per-provider API keys), and the source-control provider (+ token), shows a secret-masked summary panel to confirm, then writes both whygraph.example.toml (no secrets) and a ready-to-run whygraph.toml. --yes and any non-TTY invocation skip all prompts and preserve today's behavior. --- docs/getting-started/quickstart.md | 13 +- docs/reference/cli.md | 10 +- docs/reference/configuration.md | 7 +- pyproject.toml | 1 + src/whygraph/cli/commands/init.py | 118 ++++- src/whygraph/cli/interactive.py | 433 ++++++++++++++++++ src/whygraph/core/config.py | 230 +++++++++- src/whygraph/core/default_config.toml.tmpl | 82 ++++ .../fixtures/default_config_golden.toml | 0 tests/test_cli_init_interactive.py | 290 ++++++++++++ tests/test_core_config_scaffold.py | 114 ++++- tests/test_init_agents.py | 92 ++++ uv.lock | 35 ++ 13 files changed, 1392 insertions(+), 33 deletions(-) create mode 100644 src/whygraph/cli/interactive.py create mode 100644 src/whygraph/core/default_config.toml.tmpl rename src/whygraph/core/default_config.toml => tests/fixtures/default_config_golden.toml (100%) create mode 100644 tests/test_cli_init_interactive.py diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 0b2257c..55fc849 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -11,9 +11,16 @@ From the repo you want to analyze: whygraph init ``` -This creates `.whygraph/whygraph.db`, writes a commented `whygraph.example.toml`, and adds the right -`.gitignore` entries. It's idempotent - run it again any time. It does *not* index CodeGraph yet; -that's the next step. +On a terminal this runs a short guided setup - pick your agent, the analyze/rationale LLMs (with +optional API keys), and the source-control provider (with an optional GitHub token), then review a +summary that masks every secret and confirm. It creates `.whygraph/whygraph.db`, writes a commented +`whygraph.example.toml` (never any secrets) and a ready-to-run `whygraph.toml` (with the secrets you +entered), and adds the right `.gitignore` entries. Every prompt is defaulted, so a bare Enter accepts +it. It's idempotent - run it again any time; an existing `whygraph.toml` is only touched if you ask. +It does *not* index CodeGraph yet; that's the next step. + +Prefer no prompts? `whygraph init --yes` (and any non-interactive shell - pipes, CI, the git hooks) +accepts every default without asking, writing a default `whygraph.toml` only if none exists. ## 2. Scan diff --git a/docs/reference/cli.md b/docs/reference/cli.md index add9123..01d3267 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -27,6 +27,13 @@ Bootstrap the WhyGraph database under `.whygraph/whygraph.db`, write a committab `whygraph.example.toml` documenting every tunable, and add the right `.gitignore` entries. It's idempotent - re-running on an initialized project just confirms both databases are present. +On a terminal, `init` runs a guided, arrow-key setup: pick the agent, the analyze/rationale LLMs +(with optional API keys), and the source-control provider (with an optional GitHub token). It shows a +summary that masks every secret, asks *"Write these files?"*, then writes both `whygraph.example.toml` +(secret-free) and a ready-to-run `whygraph.toml` (with the secrets you entered). Every prompt is +defaulted. `--yes` (and any non-TTY invocation) skips the prompts, uses defaults, and never clobbers +an existing `whygraph.toml`. + `init` does **not** index CodeGraph. That happens on [`scan`](#whygraph-scan). With `--agent X`, it also wires the WhyGraph MCP server into that agent's config. All supported @@ -34,7 +41,8 @@ agents are project-scoped, so the config file is written inside the repo. | Option | Description | |---|---| -| `--agent [claude\|codex\|copilot\|cursor\|vscode]` | Wire the MCP server into the named agent's config. | +| `--agent [claude\|codex\|copilot\|cursor\|vscode]` | Wire the MCP server into the named agent's config. On a terminal, skips the interactive agent prompt. | +| `--yes` / `-y` | Accept all defaults without prompting (also implied off a TTY). Writes a default `whygraph.toml` only if none exists. | | `--print` | Print the MCP snippet to stdout instead of writing any config file. | | `--list-agents` | List supported agents (with config-file paths) and exit. | | `--install-assets / --no-install-assets` | Copy the chosen agent's bundled assets into the project. Default: enabled. No-op for agents that ship no asset tree. | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 51abbc7..651989e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,8 +1,11 @@ # Configuration WhyGraph reads an optional `whygraph.toml` at your repo root. Every field has a built-in default, so -an unedited file behaves exactly as if none were present. `whygraph init` scaffolds a fully-commented -`whygraph.example.toml` for you - copy it to `whygraph.toml` and edit what you need. +an unedited file behaves exactly as if none were present. On a terminal, `whygraph init` walks you +through the common choices (agent, analyze/rationale LLMs + keys, source-control provider + token) +and writes both a fully-commented `whygraph.example.toml` (secret-free, committable) and a ready-to-run +`whygraph.toml` (with any secrets you entered). You can always edit `whygraph.toml` by hand afterwards, +or start from the example. `whygraph init --yes` skips the prompts and uses defaults. !!! warning "`whygraph.toml` is gitignored - never commit a token" `init` adds `whygraph.toml` to `.gitignore` precisely because it can hold API keys. Keep it that diff --git a/pyproject.toml b/pyproject.toml index a9e4a57..530a2b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "openai>=1.40", "ollama>=0.3", "tomli-w>=1.0", + "questionary>=2.0", ] [project.scripts] diff --git a/src/whygraph/cli/commands/init.py b/src/whygraph/cli/commands/init.py index c8f12df..d480542 100644 --- a/src/whygraph/cli/commands/init.py +++ b/src/whygraph/cli/commands/init.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from pathlib import Path import click @@ -60,6 +61,18 @@ " left alone." ), ) +@click.option( + "--yes", + "-y", + "yes", + is_flag=True, + help=( + "Accept all defaults without prompting. Also implied whenever" + " stdin is not a TTY (pipes, CI, the git hooks). Writes a" + " default whygraph.toml if none exists and never clobbers an" + " existing one." + ), +) def init_cmd( agent_name: str | None, print_only: bool, @@ -67,14 +80,26 @@ def init_cmd( install_assets: bool, skip_preflight: bool, force: bool, + yes: bool, ) -> None: """Initialize the WhyGraph database under ``.whygraph/whygraph.db``. - Also writes a committable ``whygraph.example.toml`` at the project root - (it documents every tunable and ships the built-in defaults — copy it - to ``whygraph.toml`` and edit to customize) and ensures the project's - ``.gitignore`` keeps the user-owned config and generated caches out of - git (``whygraph.toml``, ``.whygraph/``, ``.codegraph/``). + On a terminal this runs a guided, arrow-key setup — pick the agent, + the analyze/rationale LLMs (+ API keys), and the source-control + provider (+ token), review a summary that masks every secret, then + confirm. It writes a committable ``whygraph.example.toml`` (never any + secrets) and, once confirmed, a ready-to-run ``whygraph.toml`` (with + the secrets you entered). Every prompt is defaulted, so a bare Enter + accepts it. + + ``--yes`` (and any non-TTY invocation: pipes, CI, the git hooks) + skips all prompts and uses defaults — writing a default + ``whygraph.toml`` only if none exists and never clobbering an existing + one. A bare non-TTY ``init`` refreshes only the example, as before. + + Either way it ensures the project's ``.gitignore`` keeps the + user-owned config and generated caches out of git (``whygraph.toml``, + ``.whygraph/``, ``.codegraph/``). Does **not** index CodeGraph — that happens on ``whygraph scan``, which populates ``.codegraph/codegraph.db`` (and refreshes it on every @@ -103,20 +128,28 @@ def init_cmd( if not skip_preflight: _run_preflight() + # Prompt only on a real terminal and only when not told to accept + # defaults. Pipes / CI / the git hooks have no TTY and fall straight + # through to defaults — identical to the pre-interactive behaviour. + interactive = sys.stdin.isatty() and not yes + answers = _gather_answers(project_root, agent_name, interactive=interactive) + db_path = _ensure_db_initialized() click.echo(f"Initialized WhyGraph database at {db_path}") - _scaffold_example_config(project_root) + _scaffold_example_config(project_root, answers) + _maybe_write_user_config(project_root, answers, write_user=interactive or yes) _ensure_gitignore(project_root) - if agent_name is None: + resolved_agent = answers.agent or agent_name + if resolved_agent is None: click.echo( "Tip: run `whygraph init --list-agents` to see supported agents," " then `whygraph init --agent ` to wire it up." ) return - target = agents.resolve_agent(agent_name) + target = agents.resolve_agent(resolved_agent) snippet = agents.render_snippet(target) if print_only or not agents.is_write_supported(target): @@ -144,21 +177,82 @@ def _run_preflight() -> None: fail(str(exc)) -def _scaffold_example_config(project_root: Path) -> None: +def _gather_answers(project_root: Path, agent_name: str | None, *, interactive: bool): + """Collect the init choices — interactively, or from defaults. + + In interactive mode this runs the guided flow (agent + LLMs + scan + + a summary panel + confirm) and returns its answers. A Ctrl-C / EOF at + any prompt or a declined confirm raises :class:`InitAborted`, which we + turn into a clean non-zero exit **before** any file is written or the + DB is bootstrapped. Non-interactive callers get the defaults with the + ``--agent`` value threaded in. + + Lazy-imports the interactive module so lightweight surfaces stay fast. + """ + from whygraph.core.config import DEFAULT_ANSWERS, InitAnswers + + if not interactive: + return InitAnswers(agent=agent_name, reconfigure_toml=False) + + from whygraph.cli.interactive import InitAborted, prompt_for_init + + try: + return prompt_for_init( + project_root, + preset_agent=agent_name, + on_summary=_render_summary_panel, + ) + except InitAborted: + fail("Aborted — no changes written.") + # Unreachable (fail raises); satisfies type-checkers. + return DEFAULT_ANSWERS + + +def _render_summary_panel(summary: str) -> None: + """Render the pre-write review summary as a Rich panel on stderr.""" + from rich.panel import Panel + + from ..console import console + + console.print( + Panel(summary, title="Review your choices", border_style="cyan", expand=False) + ) + + +def _scaffold_example_config(project_root: Path, answers) -> None: """Write the committable ``whygraph.example.toml`` at the project root. - Always refreshed so it tracks the shipped defaults. Lazy-imports the - config helper so lightweight surfaces like ``--list-agents`` and + Always refreshed so it tracks the shipped defaults and any non-secret + choices from ``answers``. Secrets are never written here. Lazy-imports + the config helper so lightweight surfaces like ``--list-agents`` and ``--help`` don't pay the cost. """ from whygraph.core.config import write_example_config - path = write_example_config(project_root) + path = write_example_config(project_root, answers) click.echo( f"Wrote example config to {path} — copy to whygraph.toml and edit to customize" ) +def _maybe_write_user_config(project_root: Path, answers, *, write_user: bool) -> None: + """Write ``whygraph.toml`` when appropriate, else preserve an existing one. + + Writes only when ``write_user`` (interactive or ``--yes``) **and** the + file is absent or the user chose to reconfigure it. A bare non-TTY + ``init`` never writes it (``write_user`` is ``False``), preserving the + historical scaffold-only behaviour. + """ + from whygraph.core.config import CONFIG_FILENAME, write_user_config + + user_path = project_root / CONFIG_FILENAME + if write_user and (not user_path.exists() or answers.reconfigure_toml): + path = write_user_config(project_root, answers) + click.echo(f"Wrote {path} — gitignored; holds any secrets you entered") + elif user_path.exists(): + click.echo(f"Kept existing {user_path}") + + def _ensure_gitignore(project_root: Path) -> None: """Keep the user config and generated caches out of git. diff --git a/src/whygraph/cli/interactive.py b/src/whygraph/cli/interactive.py new file mode 100644 index 0000000..0df43bd --- /dev/null +++ b/src/whygraph/cli/interactive.py @@ -0,0 +1,433 @@ +"""Interactive ``whygraph init`` prompt flow. + +``whygraph init`` becomes a create-next-app-style guided setup: arrow-key +menus for the agent, the analyze/rationale LLMs (+ per-provider API keys), +and the source-control provider (+ GitHub token), followed by a summary +panel that **masks every secret** and a final *"Write these files?"* +confirmation before anything is written. + +Every prompt is defaulted so a bare Enter accepts it. The flow is pure +collection — it returns an :class:`~whygraph.core.config.InitAnswers` and +performs **no** file writes; the command owns writing (so a Ctrl-C or a +declined confirm aborts with zero side effects). + +The questionary calls sit behind a small :class:`Prompter` protocol so +tests inject a :class:`ScriptedPrompter` without a TTY (mirrors the +``GraphBackend`` protocol reasoning — a second concrete consumer, tests, +justifies the seam). + +Notes +----- +The provider→default-model and provider→env-var maps are derived from the +config sub-dataclasses (:mod:`whygraph.core.config`), never re-hardcoded. +Provider tags use the **hyphen** form (``"claude-cli"``) to match the LLM +factory tag written into ``[analyze]/[rationale].provider``. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Protocol, runtime_checkable + +from whygraph.core.config import ( + CONFIG_FILENAME, + EXAMPLE_CONFIG_FILENAME, + AnthropicConfig, + ClaudeCliConfig, + DeepSeekConfig, + InitAnswers, + OllamaConfig, + OpenAIConfig, +) + +# Provider tags (hyphen form — matches the LLM factory tag). +_PROVIDERS = ("anthropic", "openai", "deepseek", "ollama", "claude-cli") + +# Provider → its built-in default model, read straight from the config +# sub-dataclasses so the prompt defaults never drift from the real ones. +_PROVIDER_DEFAULT_MODEL: dict[str, str] = { + "anthropic": AnthropicConfig().model, + "openai": OpenAIConfig().model, + "deepseek": DeepSeekConfig().model, + "ollama": OllamaConfig().model, + "claude-cli": ClaudeCliConfig().model, +} + +# Key-bearing providers and the env var each falls back to when no key is +# typed. ``ollama`` (local) and ``claude-cli`` (subscription billing) carry +# no key, so they never prompt. +_PROVIDER_ENV_VAR: dict[str, str] = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", +} + +_SCAN_PROVIDERS = ("off", "github", "auto") +_SCAN_ENV_VARS = ("GH_TOKEN", "GITHUB_TOKEN") + + +class InitAborted(Exception): + """Raised when the user aborts the interactive init flow. + + Signals either a Ctrl-C / EOF at a prompt (questionary's ``.ask()`` + returns ``None``) or a declined *"Write these files?"* confirmation. + The command catches it and exits non-zero **before** any file write + or DB bootstrap, so an abort leaves the project untouched. + """ + + +@runtime_checkable +class Prompter(Protocol): + """The four prompt primitives the flow needs. + + A protocol so tests can drive the flow with a scripted stand-in and + the real implementation can wrap ``questionary`` without the flow + knowing which is in play. A ``None`` return from any method signals an + abort (Ctrl-C / EOF); the flow turns it into :class:`InitAborted`. + """ + + def select(self, message: str, choices: list[str], default: str) -> str | None: + """Arrow-key single choice; ``default`` is pre-selected.""" + ... + + def text(self, message: str, default: str) -> str | None: + """Free-text entry; a bare Enter yields ``default``.""" + ... + + def password(self, message: str) -> str | None: + """No-echo secret entry; a bare Enter yields the empty string.""" + ... + + def confirm(self, message: str, *, default: bool) -> bool | None: + """Yes/No; a bare Enter yields ``default``.""" + ... + + +class QuestionaryPrompter: + """Default :class:`Prompter` backed by ``questionary`` (real TTY). + + Each method delegates to the matching ``questionary`` widget and + returns its ``.ask()`` result, which is ``None`` on Ctrl-C / EOF — + passed straight through so the flow can abort. + """ + + def select(self, message: str, choices: list[str], default: str) -> str | None: + import questionary + + return questionary.select(message, choices=choices, default=default).ask() + + def text(self, message: str, default: str) -> str | None: + import questionary + + return questionary.text(message, default=default).ask() + + def password(self, message: str) -> str | None: + import questionary + + return questionary.password(message).ask() + + def confirm(self, message: str, *, default: bool) -> bool | None: + import questionary + + return questionary.confirm(message, default=default).ask() + + +def _require(value: str | bool | None) -> str | bool: + """Return ``value`` or abort — turns a ``None`` prompt result into abort.""" + if value is None: + raise InitAborted("interactive init aborted") + return value + + +def _key_status(provider: str, answers: InitAnswers) -> str: + """Masked status string for one provider's API key (never the value).""" + if answers.api_keys.get(provider): + return "set (hidden)" + env_var = _PROVIDER_ENV_VAR.get(provider) + if env_var and os.environ.get(env_var): + return f"from ${env_var}" + return "not set" + + +def _scan_token_status(answers: InitAnswers) -> str: + """Masked status string for the scan token (never the value).""" + if answers.scan_token: + return "set (hidden)" + for env_var in _SCAN_ENV_VARS: + if os.environ.get(env_var): + return f"from ${env_var}" + return "not set" + + +def _file_flag(path: Path, *, will_write: bool) -> str: + """``(new)`` / ``(overwrite)`` / ``(kept)`` / ``(skipped)`` for a target.""" + if will_write: + return "(overwrite)" if path.exists() else "(new)" + return "(kept)" if path.exists() else "(skipped)" + + +def render_summary( + answers: InitAnswers, + *, + example_path: Path, + user_path: Path, + write_user: bool, +) -> str: + """Build the pre-write review text — secrets shown as status only. + + This is the single place secret status is rendered. It is **never** + handed a raw key/token to interpolate: for every secret it emits one + of ``set (hidden)`` / ``from $ENV`` / ``not set``. Returning a plain + string (rather than a Rich object) keeps it unit-testable without a + TTY — the command wraps the result in a ``Panel``. + + Parameters + ---------- + answers : InitAnswers + The collected choices. + example_path, user_path : Path + Absolute targets for ``whygraph.example.toml`` / ``whygraph.toml``. + write_user : bool + Whether the command will (over)write ``whygraph.toml`` — drives + the file flag shown next to ``user_path``. + + Returns + ------- + str + The multi-line summary body for the confirmation panel. + """ + agent = answers.agent or "— (none)" + + lines = [ + f"Agent: {agent}", + f"Analyze: {answers.analyze_provider} · {answers.analyze_model or 'provider default'}", + f"Rationale: {answers.rationale_provider} · {answers.rationale_model or 'provider default'}", + ] + + # API-key status, once per unique key-bearing provider in use. + key_providers = [ + p + for p in (answers.analyze_provider, answers.rationale_provider) + if p in _PROVIDER_ENV_VAR + ] + seen: set[str] = set() + for provider in key_providers: + if provider in seen: + continue + seen.add(provider) + lines.append(f"API key ({provider}): {_key_status(provider, answers)}") + + lines.append(f"Scan: {answers.scan_provider}") + if answers.scan_provider != "off": + lines.append(f"Scan token: {_scan_token_status(answers)}") + + lines.append("") + lines.append(f"{example_path} {_file_flag(example_path, will_write=True)}") + lines.append(f"{user_path} {_file_flag(user_path, will_write=write_user)}") + + return "\n".join(lines) + + +def _prompt_agent(prompter: Prompter, preset_agent: str | None) -> str | None: + """Return the resolved agent name, prompting when not preset.""" + if preset_agent is not None: + return preset_agent + from whygraph import agents + + choices = sorted(agents.AGENTS) + default = "claude" if "claude" in choices else choices[0] + return str(_require(prompter.select("Which agent?", choices, default))) + + +def _prompt_llm(prompter: Prompter) -> tuple[str, str, str, str]: + """Prompt analyze + rationale provider/model (steps 2-5). + + Rationale provider defaults to the analyze provider; the rationale + model defaults to the analyze model when the provider is unchanged, + else to the rationale provider's own default model. + """ + analyze_provider = str( + _require( + prompter.select("Analyze LLM provider?", list(_PROVIDERS), "anthropic") + ) + ) + analyze_model = str( + _require( + prompter.text( + "Analyze model?", _PROVIDER_DEFAULT_MODEL[analyze_provider] + ) + ) + ).strip() + + rationale_provider = str( + _require( + prompter.select( + "Rationale LLM provider?", list(_PROVIDERS), analyze_provider + ) + ) + ) + rationale_default_model = ( + analyze_model + if rationale_provider == analyze_provider + else _PROVIDER_DEFAULT_MODEL[rationale_provider] + ) + rationale_model = str( + _require(prompter.text("Rationale model?", rationale_default_model)) + ).strip() + + return analyze_provider, analyze_model, rationale_provider, rationale_model + + +def _prompt_api_keys( + prompter: Prompter, analyze_provider: str, rationale_provider: str +) -> dict[str, str]: + """Prompt one password per unique key-bearing provider in use (step 6).""" + api_keys: dict[str, str] = {} + seen: set[str] = set() + for provider in (analyze_provider, rationale_provider): + if provider in seen or provider not in _PROVIDER_ENV_VAR: + continue + seen.add(provider) + env_var = _PROVIDER_ENV_VAR[provider] + key = str( + _require( + prompter.password( + f"API key for {provider} (blank → read ${env_var})" + ) + ) + ).strip() + if key: + api_keys[provider] = key + return api_keys + + +def _prompt_scan(prompter: Prompter) -> tuple[str, str | None]: + """Prompt the scan provider and (for github/auto) the token (step 7).""" + scan_provider = str( + _require( + prompter.select( + "Source-control provider?", list(_SCAN_PROVIDERS), "off" + ) + ) + ) + scan_token: str | None = None + if scan_provider != "off": + token = str( + _require( + prompter.password( + "GitHub token (blank → use $GH_TOKEN / `gh auth login`)" + ) + ) + ).strip() + scan_token = token or None + return scan_provider, scan_token + + +def prompt_for_init( + project_root: Path, + *, + preset_agent: str | None, + prompter: Prompter | None = None, + on_summary=None, +) -> InitAnswers: + """Run the guided init flow and return the collected answers. + + Order (§3 of the plan): overwrite gate → agent → analyze/rationale + LLMs (+ keys) → scan (+ token) → **review & confirm**. The confirm is + the last thing this function does, so a **No** (or a Ctrl-C at any + prompt) raises :class:`InitAborted` *before* the caller writes + anything. + + Parameters + ---------- + project_root : Path + Repo root — used to resolve the two target paths and detect an + existing ``whygraph.toml`` (which triggers the overwrite gate). + preset_agent : str or None + Agent from ``--agent``; when set, the agent prompt is skipped. + prompter : Prompter, optional + Prompt backend. Defaults to :class:`QuestionaryPrompter`; tests + pass a :class:`ScriptedPrompter`. + on_summary : callable, optional + Hook receiving the summary text just before the final confirm — + the command uses it to render the Rich panel. Kept as a callback + so this module needs no Rich import and stays pure. + + Returns + ------- + InitAnswers + The user's choices, with ``reconfigure_toml`` reflecting the + overwrite decision. Only returned once the user confirms the + write. + + Raises + ------ + InitAborted + On Ctrl-C / EOF at any prompt, or a declined final confirm. + """ + prompter = prompter or QuestionaryPrompter() + user_path = project_root / CONFIG_FILENAME + example_path = project_root / EXAMPLE_CONFIG_FILENAME + + # Step 0 — overwrite gate (only when whygraph.toml already exists). + reconfigure = True + if user_path.exists(): + reconfigure = bool( + _require( + prompter.confirm( + "whygraph.toml already exists. Reconfigure it?", default=False + ) + ) + ) + + # Step 1 — agent (independent of the overwrite decision; MCP wiring + # does not touch whygraph.toml). + agent = _prompt_agent(prompter, preset_agent) + + # Steps 2-7 — only when (re)configuring whygraph.toml. + if reconfigure: + analyze_provider, analyze_model, rationale_provider, rationale_model = ( + _prompt_llm(prompter) + ) + api_keys = _prompt_api_keys(prompter, analyze_provider, rationale_provider) + scan_provider, scan_token = _prompt_scan(prompter) + answers = InitAnswers( + agent=agent, + analyze_provider=analyze_provider, + analyze_model=analyze_model, + rationale_provider=rationale_provider, + rationale_model=rationale_model, + api_keys=api_keys, + scan_provider=scan_provider, + scan_token=scan_token, + reconfigure_toml=True, + ) + else: + # Keep the existing whygraph.toml; still refresh the example and + # wire the agent. + answers = InitAnswers(agent=agent, reconfigure_toml=False) + + # Step 8 — review & confirm (the single gate before any write). + will_write_user = (not user_path.exists()) or answers.reconfigure_toml + summary = render_summary( + answers, + example_path=example_path, + user_path=user_path, + write_user=will_write_user, + ) + if on_summary is not None: + on_summary(summary) + if not _require(prompter.confirm("Write these files?", default=True)): + raise InitAborted("declined at confirmation") + + return answers + + +__all__ = [ + "InitAborted", + "Prompter", + "QuestionaryPrompter", + "prompt_for_init", + "render_summary", +] diff --git a/src/whygraph/core/config.py b/src/whygraph/core/config.py index 7b773cd..b6fc5bb 100644 --- a/src/whygraph/core/config.py +++ b/src/whygraph/core/config.py @@ -19,6 +19,7 @@ from dataclasses import dataclass, field, fields from importlib import resources from pathlib import Path +from string import Template from whygraph.core.logger import LogLevel @@ -613,37 +614,217 @@ def defaults(cls) -> Config: return cls() +@dataclass(frozen=True) +class InitAnswers: + """User choices collected by ``whygraph init`` (interactive or defaulted). + + A plain data holder passed to :func:`render_config` to produce both + the committable ``whygraph.example.toml`` (secrets omitted) and the + ready-to-run ``whygraph.toml`` (secrets included). It lives in + ``core`` rather than the CLI so ``core/config`` never imports upward + into ``cli`` — the interactive prompt layer imports *this*. + + Attributes + ---------- + agent : str or None + Canonical agent name to wire (``"claude"``, …), or ``None`` to + skip MCP wiring. Not written into either TOML — used only by the + command to drive agent wiring. + analyze_provider : str + Provider tag for ``[analyze].provider``. **Hyphen form** for the + CLI adapter (``"claude-cli"``), matching the factory tag. + analyze_model : str + Model for ``[analyze].model``. Empty string means "no override" + — the rendered line stays the commented hint so the provider's + own ``[llm.].model`` applies. + rationale_provider : str + Provider tag for ``[rationale].provider`` (hyphen form). + rationale_model : str + Model for ``[rationale].model``; empty means "no override". + api_keys : dict[str, str] + ``{provider: key}`` for key-bearing providers the user supplied a + key for (``anthropic`` / ``openai`` / ``deepseek``). Rendered as + an active ``api_key`` line **only** into ``whygraph.toml``. + scan_provider : str + Value for ``[scan].provider`` — ``"off"`` / ``"github"`` / + ``"auto"``. + scan_token : str or None + Value for ``[scan].token``; rendered active **only** into + ``whygraph.toml`` when present. + reconfigure_toml : bool + ``True`` when the command should (over)write ``whygraph.toml``. + ``False`` (default, and always in non-interactive runs) preserves + an existing ``whygraph.toml``. + """ + + agent: str | None = None + analyze_provider: str = "anthropic" + analyze_model: str = "" + rationale_provider: str = "anthropic" + rationale_model: str = "" + api_keys: dict[str, str] = field(default_factory=dict) + scan_provider: str = "off" + scan_token: str | None = None + reconfigure_toml: bool = False + + +DEFAULT_ANSWERS = InitAnswers() +"""Non-interactive baseline: every provider ``anthropic``, no overrides, no +secrets, scan ``off``. :func:`render_config` with these + ``include_tokens= +False`` reproduces the bundled template byte-for-byte (golden test).""" + + +# Verbatim commented-hint lines from the template. Kept here (not in the +# ``.tmpl``) because each is a *whole-line* placeholder that flips between +# this hint (secret omitted) and an active assignment (secret written). The +# golden fixture test guards these against drift. +_SCAN_TOKEN_HINT = ( + '# token = "ghp_..." ' + "# GitHub token for the gh CLI during the remote crawl." +) +_ANALYZE_MODEL_HINT = ( + '# model = "claude-haiku-4-5" ' + "# override the provider's model for analysis only" +) +_RATIONALE_MODEL_HINT = ( + '# model = "claude-haiku-4-5" ' + "# override the provider's model for rationale only" +) +_LLM_KEY_HINTS: dict[str, str] = { + "anthropic": '# api_key = "sk-ant-..." # default: read ANTHROPIC_API_KEY from env', + "openai": '# api_key = "sk-..." # default: read OPENAI_API_KEY from env', + "deepseek": '# api_key = "sk-..." # default: read DEEPSEEK_API_KEY from env', + "claude_cli": '# api_key = "sk-ant-..." # default: subscription billing (strips env var)', +} + + +def _template_text() -> str: + """Return the raw ``default_config.toml.tmpl`` resource text.""" + return (resources.files("whygraph.core") / "default_config.toml.tmpl").read_text( + encoding="utf-8" + ) + + +def _model_line(model: str, hint: str, purpose: str) -> str: + """Render an ``[analyze]/[rationale]`` model line. + + ``model`` empty → the commented ``hint`` verbatim (byte-exact + default). Otherwise an active override line whose trailing comment + (``for only``) stays accurate. + """ + if model: + return f'model = "{model}" # override the provider\'s model for {purpose} only' + return hint + + +def _scan_token_line(answers: InitAnswers, include_tokens: bool) -> str: + """Active ``token = "…"`` only when writing secrets and one was given.""" + if include_tokens and answers.scan_token: + return f'token = "{answers.scan_token}"' + return _SCAN_TOKEN_HINT + + +def _key_line(provider: str, answers: InitAnswers, include_tokens: bool) -> str: + """Active ``api_key = "…"`` only when writing secrets and one was given. + + ``claude_cli`` never carries a key (subscription billing), so it + always renders its hint. + """ + if include_tokens and answers.api_keys.get(provider): + return f'api_key = "{answers.api_keys[provider]}"' + return _LLM_KEY_HINTS[provider] + + +def render_config(answers: InitAnswers, *, include_tokens: bool) -> str: + """Render ``whygraph.toml`` text from ``answers``. + + A single renderer feeds both outputs: the committable example + (``include_tokens=False`` — every secret line stays a commented hint) + and the real config (``include_tokens=True`` — a secret is written as + an active line only when the user supplied one). The full commented + reference is always preserved; non-chosen ``[llm.*]`` sections keep + their default model so the file stays a complete reference. + + Parameters + ---------- + answers : InitAnswers + The collected choices. + include_tokens : bool + When ``True``, active ``api_key`` / ``token`` lines are emitted + for any secret present in ``answers``; when ``False``, all secret + lines stay commented (used for the committable example). + + Returns + ------- + str + The full rendered config, including comments and trailing newline. + + Notes + ----- + ``render_config(DEFAULT_ANSWERS, include_tokens=False)`` reproduces the + bundled ``default_config.toml.tmpl`` in its unfilled form byte-for-byte + (pinned by the golden fixture test). + """ + subs = { + "scan_provider": answers.scan_provider, + "analyze_provider": answers.analyze_provider, + "rationale_provider": answers.rationale_provider, + "llm_anthropic_model": AnthropicConfig().model, + "llm_openai_model": OpenAIConfig().model, + "llm_deepseek_model": DeepSeekConfig().model, + "llm_ollama_model": OllamaConfig().model, + "llm_claude_cli_model": ClaudeCliConfig().model, + "scan_token_line": _scan_token_line(answers, include_tokens), + "analyze_model_line": _model_line( + answers.analyze_model, _ANALYZE_MODEL_HINT, "analysis" + ), + "rationale_model_line": _model_line( + answers.rationale_model, _RATIONALE_MODEL_HINT, "rationale" + ), + "llm_anthropic_key_line": _key_line("anthropic", answers, include_tokens), + "llm_openai_key_line": _key_line("openai", answers, include_tokens), + "llm_deepseek_key_line": _key_line("deepseek", answers, include_tokens), + # claude_cli is never key-prompted — always its hint. + "llm_claude_cli_key_line": _LLM_KEY_HINTS["claude_cli"], + } + return Template(_template_text()).substitute(subs) + + def default_config_text() -> str: """Return the bundled commented default config as text. - Read from the packaged ``whygraph/core/default_config.toml`` resource - (same ``importlib.resources`` mechanism as the analyze prompt - templates). The shown values match the :class:`Config` defaults, so a - copy with no edits behaves exactly as if no config were present. + Rendered from ``whygraph/core/default_config.toml.tmpl`` with the + non-interactive baseline (:data:`DEFAULT_ANSWERS`) and no secrets, so + the shown values match the :class:`Config` defaults and an unedited + copy behaves exactly as if no config were present. Returns ------- str The full template, including comments and a trailing newline. """ - return (resources.files("whygraph.core") / "default_config.toml").read_text( - encoding="utf-8" - ) + return render_config(DEFAULT_ANSWERS, include_tokens=False) -def write_example_config(project_root: Path) -> Path: +def write_example_config( + project_root: Path, answers: InitAnswers = DEFAULT_ANSWERS +) -> Path: """Scaffold :data:`EXAMPLE_CONFIG_FILENAME` into ``project_root``. The example is a committable, package-owned reference (like ``.env.example``): users copy it to :data:`CONFIG_FILENAME` and edit. - Because it tracks the package defaults rather than user edits, it is - **always (re)written** — re-running ``whygraph init`` refreshes it so - it stays in sync with the shipped defaults. + Secrets are **never** written here — key/token lines stay commented + hints regardless of ``answers``. It is **always (re)written** so a + re-run of ``whygraph init`` keeps it in sync with the chosen (or + default) non-secret values. Parameters ---------- project_root : Path Directory to write the example into (usually the repo root). + answers : InitAnswers + Non-secret choices to bake in (provider/model/scan). Defaults to + :data:`DEFAULT_ANSWERS`, reproducing the shipped template. Returns ------- @@ -651,5 +832,30 @@ def write_example_config(project_root: Path) -> Path: The path of the written example config. """ path = project_root / EXAMPLE_CONFIG_FILENAME - path.write_text(default_config_text(), encoding="utf-8") + path.write_text(render_config(answers, include_tokens=False), encoding="utf-8") + return path + + +def write_user_config(project_root: Path, answers: InitAnswers) -> Path: + """Write the ready-to-run :data:`CONFIG_FILENAME` into ``project_root``. + + Unlike :func:`write_example_config`, this emits active ``api_key`` / + ``token`` lines for any secret the user supplied in ``answers``. The + file is gitignored by ``whygraph init`` before it is written, so a + secret here is never committed. + + Parameters + ---------- + project_root : Path + Directory to write ``whygraph.toml`` into (usually the repo root). + answers : InitAnswers + The collected choices, including any secrets. + + Returns + ------- + Path + The path of the written config. + """ + path = project_root / CONFIG_FILENAME + path.write_text(render_config(answers, include_tokens=True), encoding="utf-8") return path diff --git a/src/whygraph/core/default_config.toml.tmpl b/src/whygraph/core/default_config.toml.tmpl new file mode 100644 index 0000000..c83f5e1 --- /dev/null +++ b/src/whygraph/core/default_config.toml.tmpl @@ -0,0 +1,82 @@ +# WhyGraph configuration — whygraph.toml +# +# Scaffolded by `whygraph init`. Every field is optional and the values shown +# are the built-in defaults, so an unedited file behaves exactly as if no +# config were present. Uncomment and edit to override. + +log_level = "INFO" # DEBUG | INFO | WARN | ERROR | CRITICAL + +[scan] +max_workers = 2 # parallel LLM calls in the diff-analyzer crawler +provider = "${scan_provider}" # source-control backend for the PR/issue crawl: + # "off" — skip remote crawl (default) + # "github" — pull PRs/issues from the GitHub remote + # "auto" — detect from the remote URL (github only, for now) +remote = "origin" # git remote whose URL is inspected for provider/auto +${scan_token_line} + # Default: read GH_TOKEN / GITHUB_TOKEN from env (or an + # existing `gh auth login`). Set here to pin a per-project + # token — handy when one shared container scans repos + # across different orgs. whygraph.toml is gitignored, so a + # token here is never committed. + +[analyze] +# LLM that writes a per-commit "git diff" description during `whygraph scan`. +provider = "${analyze_provider}" # which [llm.*] adapter to use +${analyze_model_line} +# max_diff_chars = 50000 # diff truncated past this length before prompting +# large_commit_file_count = 30 # commits touching more files than this are described + # per-file on demand (not whole-diff) — keeps a bulk + # import / squash commit from costing a repo-wide pass +# timeout_sec = 60 # per-call timeout; default: the adapter's own + +[rationale] +# LLM that writes a structured "why this code exists" rationale card. +provider = "${rationale_provider}" # which [llm.*] adapter to use +${rationale_model_line} +# timeout_sec = 60 # per-call timeout; default: the adapter's own + +# Uncomment to override default DB locations (resolved relative to this file): +# whygraph_db = ".whygraph/whygraph.db" +# codegraph_db = ".codegraph/codegraph.db" + +# Optional rotating file log. Console (stderr) logging is always on. Uncomment +# this section to add an *additional* file handler; leave it out (or leave +# `file` unset) to keep file logging off. +# [logging] +# file = ".whygraph/logs/whygraph.log" # resolved relative to this file +# level = "DEBUG" # default: inherit top-level log_level +# max_bytes = 5_000_000 +# backup_count = 3 + +# Per-provider LLM client settings. Each table is consumed by the matching +# adapter in whygraph.services.llm via Adapter.from_config. Omit api_key to +# read the standard env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, +# DEEPSEEK_API_KEY); omit a section entirely to use the defaults shown. + +[llm.anthropic] +model = "${llm_anthropic_model}" +${llm_anthropic_key_line} +timeout_sec = 60 + +[llm.openai] +model = "${llm_openai_model}" +${llm_openai_key_line} +# base_url = "..." # default: https://api.openai.com/v1 +timeout_sec = 60 + +[llm.deepseek] +model = "${llm_deepseek_model}" +${llm_deepseek_key_line} +timeout_sec = 60 + +[llm.ollama] +model = "${llm_ollama_model}" +# host = "http://localhost:11434" +timeout_sec = 120 + +# `claude_cli` (Python attribute) and `claude-cli` (TOML idiom) both parse. +[llm.claude_cli] +model = "${llm_claude_cli_model}" +${llm_claude_cli_key_line} +timeout_sec = 120 diff --git a/src/whygraph/core/default_config.toml b/tests/fixtures/default_config_golden.toml similarity index 100% rename from src/whygraph/core/default_config.toml rename to tests/fixtures/default_config_golden.toml diff --git a/tests/test_cli_init_interactive.py b/tests/test_cli_init_interactive.py new file mode 100644 index 0000000..8c8d60b --- /dev/null +++ b/tests/test_cli_init_interactive.py @@ -0,0 +1,290 @@ +"""Tests for the interactive ``whygraph init`` flow (no TTY required). + +Drives :func:`whygraph.cli.interactive.prompt_for_init` through a +:class:`ScriptedPrompter` stand-in for the ``Prompter`` protocol, so the +whole guided flow — overwrite gate, rationale-defaults-to-analyze, per- +provider key prompting, the token gate, the abort paths, and the +secret-masking summary — is exercised without a terminal. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from whygraph.cli.interactive import InitAborted, prompt_for_init, render_summary +from whygraph.core.config import InitAnswers + +# Sentinels the scripted prompter understands. +DEFAULT = object() # accept the shown default (a bare Enter) +ABORT = object() # simulate Ctrl-C / EOF (questionary returns None) + + +class ScriptedPrompter: + """A :class:`~whygraph.cli.interactive.Prompter` driven by fixed scripts. + + Each prompt kind pops from its own queue in call order. An exhausted + queue (or the :data:`DEFAULT` sentinel) yields the prompt's default; + the :data:`ABORT` sentinel yields ``None`` (a Ctrl-C). Every call is + recorded in :attr:`calls` for assertions on what was asked. + """ + + def __init__( + self, *, selects=(), texts=(), passwords=(), confirms=() + ) -> None: + self.selects = list(selects) + self.texts = list(texts) + self.passwords = list(passwords) + self.confirms = list(confirms) + self.calls: list[tuple[str, str]] = [] + + def select(self, message, choices, default): + self.calls.append(("select", message)) + if not self.selects: + return default + v = self.selects.pop(0) + return None if v is ABORT else default if v is DEFAULT else v + + def text(self, message, default): + self.calls.append(("text", message)) + if not self.texts: + return default + v = self.texts.pop(0) + return None if v is ABORT else default if v is DEFAULT else v + + def password(self, message): + self.calls.append(("password", message)) + if not self.passwords: + return "" + v = self.passwords.pop(0) + return None if v is ABORT else "" if v is DEFAULT else v + + def confirm(self, message, *, default): + self.calls.append(("confirm", message)) + if not self.confirms: + return default + v = self.confirms.pop(0) + return None if v is ABORT else default if v is DEFAULT else v + + +def _kinds(prompter: ScriptedPrompter, kind: str) -> list[str]: + return [msg for k, msg in prompter.calls if k == kind] + + +# ---------- 5. rationale defaults to the analyze choice ---------------------- + + +def test_rationale_defaults_to_analyze(tmp_path: Path) -> None: + prompter = ScriptedPrompter( + # agent, analyze_provider, rationale_provider(default), scan + selects=["claude", "openai", DEFAULT, "off"], + # analyze_model(default), rationale_model(default) + texts=[DEFAULT, DEFAULT], + confirms=[True], # final "Write these files?" + ) + answers = prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + assert answers.analyze_provider == "openai" + assert answers.rationale_provider == "openai" + assert answers.analyze_model == "gpt-4o" + # Same provider → rationale model defaults to the analyze model. + assert answers.rationale_model == "gpt-4o" + + +# ---------- 6. API key prompted once per unique provider -------------------- + + +def test_api_key_prompted_once_when_shared(tmp_path: Path) -> None: + prompter = ScriptedPrompter( + selects=["claude", "openai", DEFAULT, "off"], + texts=[DEFAULT, DEFAULT], + passwords=["sk-shared"], + confirms=[True], + ) + answers = prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + assert len(_kinds(prompter, "password")) == 1 + assert answers.api_keys == {"openai": "sk-shared"} + + +def test_api_key_prompted_twice_when_providers_differ(tmp_path: Path) -> None: + prompter = ScriptedPrompter( + selects=["claude", "openai", "deepseek", "off"], + texts=[DEFAULT, DEFAULT], + passwords=["sk-openai", "sk-deepseek"], + confirms=[True], + ) + answers = prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + assert len(_kinds(prompter, "password")) == 2 + assert answers.api_keys == {"openai": "sk-openai", "deepseek": "sk-deepseek"} + + +def test_no_key_prompt_for_non_key_bearing_providers(tmp_path: Path) -> None: + """ollama / claude-cli carry no key, so they never prompt one.""" + prompter = ScriptedPrompter( + selects=["claude", "claude-cli", DEFAULT, "off"], + texts=[DEFAULT, DEFAULT], + confirms=[True], + ) + answers = prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + assert _kinds(prompter, "password") == [] + assert answers.api_keys == {} + + +# ---------- 7. token prompt only for github/auto ---------------------------- + + +def test_token_not_prompted_when_scan_off(tmp_path: Path) -> None: + prompter = ScriptedPrompter( + selects=["claude", "claude-cli", DEFAULT, "off"], + texts=[DEFAULT, DEFAULT], + confirms=[True], + ) + answers = prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + assert _kinds(prompter, "password") == [] + assert answers.scan_token is None + + +def test_token_prompted_for_github(tmp_path: Path) -> None: + prompter = ScriptedPrompter( + selects=["claude", "claude-cli", DEFAULT, "github"], + texts=[DEFAULT, DEFAULT], + passwords=["ghp_real"], + confirms=[True], + ) + answers = prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + assert len(_kinds(prompter, "password")) == 1 + assert answers.scan_provider == "github" + assert answers.scan_token == "ghp_real" + + +# ---------- 8. overwrite gate ----------------------------------------------- + + +def test_overwrite_gate_no_skips_config_prompts(tmp_path: Path) -> None: + (tmp_path / "whygraph.toml").write_text("x = 1\n", encoding="utf-8") + prompter = ScriptedPrompter( + selects=["claude"], # only the agent prompt should run + confirms=[False, True], # overwrite? No ; Write these files? Yes + ) + answers = prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + assert answers.reconfigure_toml is False + assert answers.agent == "claude" + # No LLM/scan selects — only the agent select happened. + assert _kinds(prompter, "select") == ["Which agent?"] + + +def test_overwrite_gate_yes_runs_full_flow(tmp_path: Path) -> None: + (tmp_path / "whygraph.toml").write_text("x = 1\n", encoding="utf-8") + prompter = ScriptedPrompter( + selects=["claude", "openai", DEFAULT, "off"], + texts=[DEFAULT, DEFAULT], + confirms=[True, True], # overwrite? Yes ; Write? Yes + ) + answers = prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + assert answers.reconfigure_toml is True + assert answers.analyze_provider == "openai" + + +def test_preset_agent_skips_agent_prompt(tmp_path: Path) -> None: + prompter = ScriptedPrompter( + selects=["openai", DEFAULT, "off"], # no agent select + texts=[DEFAULT, DEFAULT], + confirms=[True], + ) + answers = prompt_for_init(tmp_path, preset_agent="cursor", prompter=prompter) + + assert answers.agent == "cursor" + assert "Which agent?" not in _kinds(prompter, "select") + + +# ---------- 9. abort paths -------------------------------------------------- + + +def test_abort_on_ctrl_c_at_prompt(tmp_path: Path) -> None: + prompter = ScriptedPrompter(selects=[ABORT]) + with pytest.raises(InitAborted): + prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + +def test_abort_on_declined_final_confirm(tmp_path: Path) -> None: + prompter = ScriptedPrompter( + selects=["claude", "openai", DEFAULT, "off"], + texts=[DEFAULT, DEFAULT], + confirms=[False], # decline "Write these files?" + ) + with pytest.raises(InitAborted): + prompt_for_init(tmp_path, preset_agent=None, prompter=prompter) + + +# ---------- 9b. summary masks secrets --------------------------------------- + + +def test_render_summary_masks_secrets(tmp_path: Path) -> None: + answers = InitAnswers( + agent="claude", + analyze_provider="anthropic", + analyze_model="claude-opus-4-7", + rationale_provider="anthropic", + rationale_model="claude-opus-4-7", + api_keys={"anthropic": "sk-secret"}, + scan_provider="github", + scan_token="ghp-secret-token", + ) + text = render_summary( + answers, + example_path=tmp_path / "whygraph.example.toml", + user_path=tmp_path / "whygraph.toml", + write_user=True, + ) + # Never the raw values. + assert "sk-secret" not in text + assert "ghp-secret-token" not in text + # Only status. + assert "set (hidden)" in text + + +def test_render_summary_reports_env_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") + answers = InitAnswers( + analyze_provider="openai", + analyze_model="gpt-4o", + rationale_provider="openai", + rationale_model="gpt-4o", + ) + text = render_summary( + answers, + example_path=tmp_path / "whygraph.example.toml", + user_path=tmp_path / "whygraph.toml", + write_user=True, + ) + assert "from $OPENAI_API_KEY" in text + assert "sk-from-env" not in text + + +def test_summary_hook_receives_text(tmp_path: Path) -> None: + seen: list[str] = [] + prompter = ScriptedPrompter( + selects=["claude", "anthropic", DEFAULT, "off"], + texts=[DEFAULT, DEFAULT], + passwords=[""], # blank anthropic key → env fallback + confirms=[True], + ) + prompt_for_init( + tmp_path, + preset_agent=None, + prompter=prompter, + on_summary=seen.append, + ) + assert len(seen) == 1 + assert "Review" not in seen[0] # the body only; the panel title is the command's + assert "Analyze:" in seen[0] diff --git a/tests/test_core_config_scaffold.py b/tests/test_core_config_scaffold.py index c3e27b3..9b35934 100644 --- a/tests/test_core_config_scaffold.py +++ b/tests/test_core_config_scaffold.py @@ -1,8 +1,10 @@ """Tests for the ``whygraph.example.toml`` scaffold helpers. -Covers :func:`default_config_text` (the bundled template) and -:func:`write_example_config` (the always-refresh write used by -``whygraph init``). +Covers :func:`default_config_text` (the bundled template), +:func:`write_example_config` / :func:`write_user_config` (the writers used +by ``whygraph init``), and :func:`render_config` (the single renderer that +feeds both — including the byte-exact golden reproduction of the shipped +template and the secret-vs-comment line handling). """ from __future__ import annotations @@ -11,11 +13,17 @@ from whygraph.core.config import ( EXAMPLE_CONFIG_FILENAME, + DEFAULT_ANSWERS, Config, + InitAnswers, default_config_text, + render_config, write_example_config, + write_user_config, ) +_GOLDEN = Path(__file__).parent / "fixtures" / "default_config_golden.toml" + def test_default_config_text_is_valid_and_matches_defaults(tmp_path: Path) -> None: """The bundled template parses and yields the same Config as no file.""" @@ -51,3 +59,103 @@ def test_write_example_config_refreshes_existing(tmp_path: Path) -> None: assert path == existing assert existing.read_text(encoding="utf-8") == default_config_text() + + +# ---------- render_config: golden + injection + round-trip ------------------- + + +def test_render_defaults_matches_golden_byte_for_byte() -> None: + """The unfilled render reproduces the shipped template exactly (§9.1).""" + golden = _GOLDEN.read_text(encoding="utf-8") + assert render_config(DEFAULT_ANSWERS, include_tokens=False) == golden + assert default_config_text() == golden + + +def test_render_injects_non_default_provider_and_model() -> None: + """Chosen analyze/rationale provider+model appear as active lines (§9.2).""" + answers = InitAnswers( + analyze_provider="openai", + analyze_model="gpt-4o-mini", + rationale_provider="deepseek", + rationale_model="deepseek-reasoner", + ) + out = render_config(answers, include_tokens=False) + assert 'provider = "openai"' in out + assert 'model = "gpt-4o-mini"' in out + assert 'provider = "deepseek"' in out + assert 'model = "deepseek-reasoner"' in out + + +def test_render_example_leaves_secret_lines_commented() -> None: + """include_tokens=False keeps key/token lines as commented hints (§9.2).""" + answers = InitAnswers( + api_keys={"anthropic": "sk-ant-real"}, + scan_provider="github", + scan_token="ghp_real", + ) + out = render_config(answers, include_tokens=False) + # Secrets never leak into the example. + assert "sk-ant-real" not in out + assert "ghp_real" not in out + # The hints stay commented. + assert '# api_key = "sk-ant-..."' in out + assert '# token = "ghp_..."' in out + + +def test_render_user_emits_active_secret_lines_only_when_present() -> None: + """include_tokens=True writes active lines for supplied secrets (§9.2).""" + answers = InitAnswers( + api_keys={"openai": "sk-openai-real"}, + scan_provider="github", + scan_token="ghp_real", + ) + out = render_config(answers, include_tokens=True) + assert 'api_key = "sk-openai-real"' in out + assert 'token = "ghp_real"' in out + # A provider with no supplied key keeps its commented hint. + assert '# api_key = "sk-ant-..."' in out + + +def test_write_user_config_round_trips(tmp_path: Path) -> None: + """write_user_config output parses back to the chosen values (§9.3).""" + answers = InitAnswers( + analyze_provider="openai", + analyze_model="gpt-4o", + rationale_provider="anthropic", + rationale_model="claude-opus-4-7", + api_keys={"openai": "sk-openai-real", "anthropic": "sk-ant-real"}, + scan_provider="github", + scan_token="ghp_real", + reconfigure_toml=True, + ) + path = write_user_config(tmp_path, answers) + assert path == tmp_path / "whygraph.toml" + + cfg = Config.from_toml(path) + assert cfg.analyze.provider == "openai" + assert cfg.analyze.model == "gpt-4o" + assert cfg.rationale.provider == "anthropic" + assert cfg.rationale.model == "claude-opus-4-7" + assert cfg.scan_provider == "github" + assert cfg.scan_token == "ghp_real" + assert cfg.llm.openai.api_key == "sk-openai-real" + assert cfg.llm.anthropic.api_key == "sk-ant-real" + + +def test_render_claude_cli_tag_parses(tmp_path: Path) -> None: + """provider written hyphenated; the [llm.claude_cli] header parses (§9.4).""" + answers = InitAnswers( + analyze_provider="claude-cli", + analyze_model="claude-opus-4-7", + rationale_provider="claude-cli", + rationale_model="claude-opus-4-7", + reconfigure_toml=True, + ) + out = render_config(answers, include_tokens=True) + assert 'provider = "claude-cli"' in out + assert "[llm.claude_cli]" in out + + path = write_user_config(tmp_path, answers) + cfg = Config.from_toml(path) + assert cfg.analyze.provider == "claude-cli" + assert cfg.rationale.provider == "claude-cli" diff --git a/tests/test_init_agents.py b/tests/test_init_agents.py index b8d27ed..6a94f92 100644 --- a/tests/test_init_agents.py +++ b/tests/test_init_agents.py @@ -584,3 +584,95 @@ def test_init_unknown_agent_errors(stub_init, tmp_path: Path) -> None: assert result.exit_code != 0 # Click's Choice produces a usage error mentioning the bad value. assert "emacs" in result.output or "Invalid value" in result.output + + +# ---------- --yes / non-interactive config writing -------------------------- + + +def test_init_yes_writes_both_files_with_defaults(stub_init, tmp_path: Path) -> None: + """``init --yes`` (non-TTY) writes both files with defaults, no prompts.""" + result, cwd = _invoke_in(tmp_path, "init", "--yes") + assert result.exit_code == 0, result.output + + example = cwd / "whygraph.example.toml" + user = cwd / "whygraph.toml" + assert example.exists() + assert user.exists() + with user.open("rb") as f: + data = tomllib.load(f) + assert data["analyze"]["provider"] == "anthropic" + # No secrets in the default whygraph.toml. + assert "sk-" not in user.read_text(encoding="utf-8").replace("sk-ant-...", "").replace( + "sk-...", "" + ) + + +def test_init_yes_preserves_existing_whygraph_toml(stub_init, tmp_path: Path) -> None: + """``--yes`` never clobbers an existing whygraph.toml.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + cwd = Path.cwd() + (cwd / "whygraph.toml").write_text('log_level = "DEBUG"\n', encoding="utf-8") + result = runner.invoke(whygraph_main, ["init", "--yes"]) + assert result.exit_code == 0, result.output + # Untouched. + assert (cwd / "whygraph.toml").read_text(encoding="utf-8") == ( + 'log_level = "DEBUG"\n' + ) + assert "Kept existing" in result.output + + +def test_bare_init_non_tty_writes_no_whygraph_toml(stub_init, tmp_path: Path) -> None: + """A bare (no --yes) non-TTY init keeps the scaffold-only behaviour.""" + result, cwd = _invoke_in(tmp_path, "init") + assert result.exit_code == 0, result.output + assert (cwd / "whygraph.example.toml").exists() + assert not (cwd / "whygraph.toml").exists() + + +def test_init_agent_claude_yes_wires_and_writes_config( + stub_init, tmp_path: Path +) -> None: + """``init --agent claude --yes`` wires MCP + assets and writes both files.""" + result, cwd = _invoke_in(tmp_path, "init", "--agent", "claude", "--yes") + assert result.exit_code == 0, result.output + assert (cwd / ".mcp.json").exists() + assert (cwd / "whygraph.toml").exists() + assert (cwd / ".claude" / "agents" / "planner.md").is_file() + + +def test_init_interactive_abort_writes_nothing( + stub_init, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A Ctrl-C / declined confirm exits non-zero and touches no files or DB.""" + import types + + from whygraph.cli.interactive import InitAborted + + # Force the interactive branch (CliRunner swaps the real sys.stdin, so we + # replace the module's `sys` reference — init only reads sys.stdin.isatty). + fake_sys = types.SimpleNamespace( + stdin=types.SimpleNamespace(isatty=lambda: True) + ) + monkeypatch.setattr("whygraph.cli.commands.init.sys", fake_sys) + called = {"db": 0} + monkeypatch.setattr( + "whygraph.cli.commands.init._ensure_db_initialized", + lambda: called.__setitem__("db", called["db"] + 1) or (tmp_path / "x.db"), + ) + + def _abort(*_a, **_k): + raise InitAborted("boom") + + monkeypatch.setattr("whygraph.cli.interactive.prompt_for_init", _abort) + + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + cwd = Path.cwd() + result = runner.invoke(whygraph_main, ["init", "--skip-preflight"]) + assert result.exit_code != 0 + assert "Aborted" in result.output + # No files written, and the DB was never bootstrapped. + assert not (cwd / "whygraph.toml").exists() + assert not (cwd / "whygraph.example.toml").exists() + assert called["db"] == 0 diff --git a/uv.lock b/uv.lock index d91154f..7dde668 100644 --- a/uv.lock +++ b/uv.lock @@ -1187,6 +1187,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1495,6 +1507,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -2056,6 +2080,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + [[package]] name = "webencodings" version = "0.5.1" @@ -2076,6 +2109,7 @@ dependencies = [ { name = "mcp", extra = ["cli"] }, { name = "ollama" }, { name = "openai" }, + { name = "questionary" }, { name = "rich" }, { name = "scikit-learn" }, { name = "sqlmodel" }, @@ -2100,6 +2134,7 @@ requires-dist = [ { name = "mcp", extras = ["cli"], specifier = ">=1.2" }, { name = "ollama", specifier = ">=0.3" }, { name = "openai", specifier = ">=1.40" }, + { name = "questionary", specifier = ">=2.0" }, { name = "rich", specifier = ">=13" }, { name = "scikit-learn", specifier = ">=1.3" }, { name = "sqlmodel", specifier = ">=0.0.22" }, From d5cf87e72add0cff2a0cb3b8d184f818edeba849 Mon Sep 17 00:00:00 2001 From: cvetty Date: Tue, 7 Jul 2026 20:21:04 +0300 Subject: [PATCH 2/2] Fix ruff format check failure; document pre-push checks in CLAUDE.md CI checks ruff format --check in addition to ruff check, which wasn't run before the last push. Reformat the affected files and add a "Before pushing" section to CLAUDE.md spelling out all three commands CI gates on, so this doesn't happen again. --- CLAUDE.md | 12 ++++++++++++ src/whygraph/cli/interactive.py | 12 +++--------- src/whygraph/core/config.py | 6 ++---- tests/test_cli_init_interactive.py | 4 +--- tests/test_init_agents.py | 10 ++++------ 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f35b593..985b0ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,18 @@ uv run whygraph-mcp # launch MCP server on stdio (Ctrl-C to exit) A root `Makefile` wraps these plus dev-only tooling — `make` lists targets; `make db` / `make db-down` run a DBGate viewer for both databases (via `docker-compose.example.yml`), `make inspect` launches the MCP Inspector. +## Before pushing + +CI (`ci-code-checks`) gates every PR on two parallel jobs: **lint** (`uv run ruff check src/ tests/` *and* `uv run ruff format --check src/ tests/` — both, not just the first) and **tests** (`uv run pytest`). Run all three locally before pushing or opening a PR: + +```bash +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ # `ruff check` passing does NOT imply this passes +uv run pytest +``` + +If `ruff format --check` fails, run `uv run ruff format src/ tests/` to fix it in place, then re-run the check before pushing. + ## Architecture Top-level packages under `src/whygraph/`: diff --git a/src/whygraph/cli/interactive.py b/src/whygraph/cli/interactive.py index 0df43bd..7facd49 100644 --- a/src/whygraph/cli/interactive.py +++ b/src/whygraph/cli/interactive.py @@ -254,9 +254,7 @@ def _prompt_llm(prompter: Prompter) -> tuple[str, str, str, str]: ) analyze_model = str( _require( - prompter.text( - "Analyze model?", _PROVIDER_DEFAULT_MODEL[analyze_provider] - ) + prompter.text("Analyze model?", _PROVIDER_DEFAULT_MODEL[analyze_provider]) ) ).strip() @@ -292,9 +290,7 @@ def _prompt_api_keys( env_var = _PROVIDER_ENV_VAR[provider] key = str( _require( - prompter.password( - f"API key for {provider} (blank → read ${env_var})" - ) + prompter.password(f"API key for {provider} (blank → read ${env_var})") ) ).strip() if key: @@ -306,9 +302,7 @@ def _prompt_scan(prompter: Prompter) -> tuple[str, str | None]: """Prompt the scan provider and (for github/auto) the token (step 7).""" scan_provider = str( _require( - prompter.select( - "Source-control provider?", list(_SCAN_PROVIDERS), "off" - ) + prompter.select("Source-control provider?", list(_SCAN_PROVIDERS), "off") ) ) scan_token: str | None = None diff --git a/src/whygraph/core/config.py b/src/whygraph/core/config.py index b6fc5bb..7c03a33 100644 --- a/src/whygraph/core/config.py +++ b/src/whygraph/core/config.py @@ -683,12 +683,10 @@ class InitAnswers: "# GitHub token for the gh CLI during the remote crawl." ) _ANALYZE_MODEL_HINT = ( - '# model = "claude-haiku-4-5" ' - "# override the provider's model for analysis only" + '# model = "claude-haiku-4-5" # override the provider\'s model for analysis only' ) _RATIONALE_MODEL_HINT = ( - '# model = "claude-haiku-4-5" ' - "# override the provider's model for rationale only" + '# model = "claude-haiku-4-5" # override the provider\'s model for rationale only' ) _LLM_KEY_HINTS: dict[str, str] = { "anthropic": '# api_key = "sk-ant-..." # default: read ANTHROPIC_API_KEY from env', diff --git a/tests/test_cli_init_interactive.py b/tests/test_cli_init_interactive.py index 8c8d60b..d1ff600 100644 --- a/tests/test_cli_init_interactive.py +++ b/tests/test_cli_init_interactive.py @@ -30,9 +30,7 @@ class ScriptedPrompter: recorded in :attr:`calls` for assertions on what was asked. """ - def __init__( - self, *, selects=(), texts=(), passwords=(), confirms=() - ) -> None: + def __init__(self, *, selects=(), texts=(), passwords=(), confirms=()) -> None: self.selects = list(selects) self.texts = list(texts) self.passwords = list(passwords) diff --git a/tests/test_init_agents.py b/tests/test_init_agents.py index 6a94f92..c365833 100644 --- a/tests/test_init_agents.py +++ b/tests/test_init_agents.py @@ -602,9 +602,9 @@ def test_init_yes_writes_both_files_with_defaults(stub_init, tmp_path: Path) -> data = tomllib.load(f) assert data["analyze"]["provider"] == "anthropic" # No secrets in the default whygraph.toml. - assert "sk-" not in user.read_text(encoding="utf-8").replace("sk-ant-...", "").replace( - "sk-...", "" - ) + assert "sk-" not in user.read_text(encoding="utf-8").replace( + "sk-ant-...", "" + ).replace("sk-...", "") def test_init_yes_preserves_existing_whygraph_toml(stub_init, tmp_path: Path) -> None: @@ -651,9 +651,7 @@ def test_init_interactive_abort_writes_nothing( # Force the interactive branch (CliRunner swaps the real sys.stdin, so we # replace the module's `sys` reference — init only reads sys.stdin.isatty). - fake_sys = types.SimpleNamespace( - stdin=types.SimpleNamespace(isatty=lambda: True) - ) + fake_sys = types.SimpleNamespace(stdin=types.SimpleNamespace(isatty=lambda: True)) monkeypatch.setattr("whygraph.cli.commands.init.sys", fake_sys) called = {"db": 0} monkeypatch.setattr(