diff --git a/IMPLEMENTATION-SUMMARY.md b/IMPLEMENTATION-SUMMARY.md index f39a310..0c9728a 100644 --- a/IMPLEMENTATION-SUMMARY.md +++ b/IMPLEMENTATION-SUMMARY.md @@ -1,247 +1,173 @@ -# Implementation summary — Parts 0-3 (pack / ask / site plan) - -Worktree: `/Users/igor/Projects/2_Areas/D_Internal_CLI_Tooling/bcli/.claude/worktrees/agent-a5724d8b39c9ef6d9` -Branch: `worktree-agent-a5724d8b39c9ef6d9` -Plan: `/Users/igor/.claude/plans/how-would-the-bcli-zippy-lantern.md` - -Total commits on this branch: **11** (one per logical sub-unit). - -## Part 0 — `bcli.context` infrastructure (R1, R4, R5, R6) - -Status: **shipped, green** — 40 tests pass, ruff clean. - -Files: -- `src/bcli/context/__init__.py` — public surface -- `src/bcli/context/_protocol.py` — pre-existing skeleton, refined - with `to_prompt_text()` Markdown renderer (no other changes — - protocol was already R4-aligned) -- `src/bcli/context/_redact.py` — three-layer redaction composing - `bcli/audit/_redact.py` (keys) + `bcli/telemetry/events.py` regex - (patterns) + new URL/GUID/attachment scrub. Five stable - `rule_id` constants. -- `src/bcli/context/_last_error.py` — captures `BCLIError` exits - to `~/.config/bcli/last-error.json`. No tracebacks by default; - `--debug` runs also produce `last-error-debug.json` at mode 0600. -- `src/bcli/context/_http_tail.py` — `RotatingFileHandler` on the - `bcli.http` logger; opt-in via `[context] tail = true`. -- `src/bcli/context/_bundle.py` — `build_bundle()` pure function; - token-budgeted priority truncation (question > last_error > - profile > http > describe > attachments). -- `src/bcli/config/_model.py` — `ContextConfig` added. -- `src/bcli_cli/app.py` — central error handler now calls - `capture_last_error`; root callback bootstraps the http-tail - handler when configured. - -Tests: 40 in `tests/test_context/` covering dataclass round-trip, -3-layer redaction (adversarial nested JSON, URL-encoded tokens, -base64 JWTs), audit-trail completeness, last-error capture w/o -tracebacks, http-tail rotation + size cap, bundle composition -with all sources, no-context path. - -Commits: -- `f3f448f feat(context): bcli.context — typed ContextBundle + 3-layer redaction` -- `fa0ebcb feat(context): wire last-error capture + http-tail bootstrap into CLI` -- `2eb26a3 test(context): cover dataclass round-trip, 3-layer redaction, audit trail` -- `e3b1714 docs(changelog): note Part 0 (bcli.context infrastructure)` - -## Part 1 — `bcli pack` (R2, R3, R7, R8) - -Status: **shipped, green** — 19 tests pass, ruff clean. Both -built-in packs install end-to-end against a tmp config dir. - -Files: -- `src/bcli/packs/_protocol.py` — frozen dataclasses (Pack, - PackManifest, PackContents, AgentFragment, PackQuery, PackBatch, - PackRegistryPreset). `targets: [agents] | [claude] | [agents, claude]` - per fragment (R3 default `[agents]`). -- `src/bcli/packs/_loader.py` — manifest + content loader with - schema validation. -- `src/bcli/packs/_registry.py` — discovery: built-in (`packs/`) - + entry-point group `bcli.packs` + local path. Wheel-install - fallback via `bcli/packs/_builtin/`. -- `src/bcli/packs/_ledger.py` — JSON ledger at - `~/.config/bcli/packs//.json` (R2). Frozen - dataclasses; atomic write. -- `src/bcli/packs/_installer.py` — `plan_install` + `execute_install` - + `uninstall_pack`. Marker blocks with content_hash; idempotent - re-install; provenance-injected registry presets; conflict - detection refuses unless `--replace-owned --accept-conflicts` (R7). -- `src/bcli_cli/commands/pack_cmd.py` — `bcli pack list / info / - install / uninstall`. Dry-run; per-install confirm; pack - recommendations surfaced as hints, never auto-enabled (R8). -- `packs/starter-generic/` — 6 queries, 2 batches, 3 fragments; - standard v2.0 endpoints only. -- `packs/cronus-demo/` — Microsoft CRONUS month-end demo (lifted - from `examples/`). -- `pyproject.toml` — `[tool.hatch.build.targets.wheel.force-include]` - maps `packs/` → `bcli/packs/_builtin` so the wheel ships the - built-ins. - -Tests: 19 in `tests/test_packs/` covering manifest validation, -fragment-targets routing (agents vs claude vs both), install -round-trips, idempotency, conflict detection, uninstall, -discovery, broken-pack tolerance, and end-to-end install of both -built-in packs. - -Smoke-test (manually run): -- `bcli pack list` shows both built-in packs. -- `bcli pack info starter-generic` shows manifest + content - counts + "not installed on profile X". -- `bcli pack install starter-generic --dry-run --target /tmp/X` - prints the full plan (3 fragments + 3 marker blocks + 6 queries - + 2 batches) without touching disk. - -Commits: -- `e3eb1eb feat(packs): bcli.packs SDK — Pack/Manifest/Ledger + installer (R2, R3, R7, R8)` -- `c61627b feat(packs): bcli pack list / info / install / uninstall CLI` -- `a6a8c46 feat(packs): ship starter-generic + cronus-demo built-in packs` -- `ebc0544 test(packs): 19 tests + pyproject pack wheel layout + changelog` - -## Part 2 — `bcli ask` - -Status: **shipped, green** — 16 tests pass, ruff clean. CLI -smoke-test (`bcli ask --dry-run --no-context "test"`) prints the -redacted bundle without making a network call. - -Files: -- `src/bcli/ask/_protocol.py` — `AskBackend` Protocol + NullAsker - (mirror of `bcli/extract/_protocol.py`). -- `src/bcli/ask/_factory.py` — `get_asker` dispatch with - `_BUILTIN_BACKENDS` + `module:Class` fallback + Null fallback - with one-shot warning (mirror of `bcli/extract/_factory.py`). -- `src/bcli/ask/_claude.py` — Anthropic backend - (`messages.create`, bundle as Markdown user-turn). -- `src/bcli/ask/_openai.py` — OpenAI Responses API backend. -- `src/bcli/ask/_providers.py` — `bcli.ask.context_providers` - entry-point group (R8). Opt-in via `[ask] context_providers`. -- `src/bcli_cli/commands/ask_cmd.py` — `bcli ask ""` with - `--no-context`, `--attach`, `--backend`, `--dry-run`, - `--include-bodies`, `--include-debug`, `--max-tokens`. -- `src/bcli/config/_model.py` — `AskConfig` added. -- `pyproject.toml` — new extras `[ask-claude]`, `[ask-openai]`, - meta-extra `[ask]`; `[dev]` also pulls in `[ask]`. - -Tests: 16 in `tests/test_ask/` covering factory dispatch (all -fallback paths), `--dry-run` rendering + attachment redaction + -no-network guarantee, and the R8 context-provider entry-point -group (opt-in execution, failure isolation). - -Commit: -- `30e7545 feat(ask): bcli ask oracle — Claude/OpenAI backends + dry-run + R8 providers` - -## Part 3 — `bcli-site/` v0 - -Status: **shipped** — files only; JSON/YAML parses cleanly. No -`pnpm install` was run (no guaranteed network in this sandbox). - -Files: -- `bcli-site/package.json`, `astro.config.mjs`, `tsconfig.json`, - `tailwind.config.mjs` — Astro 4 + Tailwind 3 stack. -- `bcli-site/src/pages/index.astro` — single page: hero + - install + 3 example commands (pack install, saved query, ask) - + features grid + footer with GitHub link. -- `bcli-site/src/components/Hero.astro`, `CodeBlock.astro`, - `styles/global.css`. -- `bcli-site/public/og.png.placeholder` — TODO note for a - hand-crafted OG card. -- `bcli-site/README.md` — pnpm dev / pnpm build instructions + - Vercel deploy note. -- `.github/workflows/site.yml` — Astro build on changes under - `bcli-site/**`. Vercel deploy step is wired but commented out - until secrets exist; secrets use `env:` blocks per GitHub's - injection guidance. -- `.gitignore` — adds `bcli-site/node_modules`, `dist`, `.astro`, - and lockfiles. - -Content compliance with R9: describes shipped features (packs, -ask, MCP server, describe / discovery-first). Does NOT mention -the deferred `bcli agent` mode anywhere on the page. - -Commit: -- `44a13f9 feat(site): bcli-site v0 — Astro + Tailwind landing scaffold` - -## Final validation snapshot +# Implementation Summary — `bcli` agent mode (Part 4 of the roadmap) + +Branch: `feat/agent-mode`. Implements the approved plan +(`~/.claude/plans/i-attempted-to-create-swift-melody.md`): a Claude Code / +Codex-style interactive agent where an LLM drives bcli's own verbs as tools, +with three backends, a Textual TUI, a first-run wizard, a consent gate, +plan-mode write safety, and per-profile `BC.md` memory. + +This continued an interrupted run that had committed a Part-1 skeleton +(`4a71910`) with no tests, extras, or CLI wiring. The skeleton imported cleanly +and matched the plan; it was built on (one real bug fixed) rather than rewritten. + +## What was built, per part + +### Part 1 — engine + tools + pydantic-ai backend (`3f88923`) + +- **Reviewed + corrected the WIP skeleton.** Fixed a latent bug: + `backends/_pydantic_ai.py` imported `AgentRunResultEvent` from + `pydantic_ai.messages` (it lives on the top-level `pydantic_ai`); the WIP would + have `ImportError`ed on the first turn. Verified the corrected backend + end-to-end against `TestModel`. +- **Engine** (already in the skeleton, validated): `AgentSessionBackend` protocol + + frozen `AgentEvent` (`_protocol.py`); factory with `NullAgentBackend` fallback + + one-shot warning (`_factory.py`, mirror of `bcli.ask._factory`); `AgentRuntime` + with the write-safety approval seam (`_runtime.py`); `ToolRegistry` (read/write + tiers, plan-mode `draft_batch` swap, `from_describe` rebuild, `bcli_mcp` parity) + + curated overlay (`tools/_definitions.py`, `_registry.py`); in-process handlers + with the safety gate enforced inside them (`tools/_impl.py`); three projections + (`tools/_projections.py`); system-prompt assembly (`_prompt.py`); `BC.md` loader + (`memory/_bc_md.py`); auth detection (`_auth_detect.py`). +- **Config**: `AgentConfig` wired into `BCConfig` (`config/_model.py`); + `update_config_section()` for surgical tomlkit writes (`config/_loader.py`). +- **CLI**: wired `bcli agent run` (headless one-shot, streams answer to stdout / + tool activity to stderr) + `bcli agent init` into `app.py`. +- **pyproject extras**: `agent-local` (`pydantic-ai-slim[anthropic,openai]>=1.107,<2`), + `agent-claude-code`, `agent-codex`, `agent` meta-extra (+ `textual>=8.2`); + added to `dev`. +- **repl package**: `repl/__init__.py` (lazy `launch_repl`) + console setup wizard + (`repl/_wizard.py`). +- **Tests** — `tests/test_agent/` (factory dispatch, registry/parity, safety + matrix, read handlers, pydantic-ai event stream via `TestModel`, wizard logic, + consent gate, headless run + plan-mode resolution). + +### Part 2 — Textual chat REPL (`05f0bba`) + +- **Bare-`bcli` entry**: `app.py` `no_args_is_help=False` + + `invoke_without_command=True` callback; branch on `ctx.invoked_subcommand is + None` — dual-TTY → lazy-import REPL, non-TTY → help (regression-tested so + scripted/piped callers are unaffected). Agent stack never imported for + subcommands. +- **`repl/_app.py`** — `ChatApp` (Textual): scrolling transcript, streaming + `MarkdownStream`, `ToolCallPanel` cards, `StatusBar`, modal approval dialog; + turns run in an exclusive worker consuming `AgentEvent`s; long-lived + `AsyncBCClient` + `AgentRuntime`; first-run wizard via `run_repl`. +- **`repl/_widgets.py`** — `StatusBar` / `ToolCallPanel` / `ApprovalScreen` + (y/n + buttons; resolves the runtime gate future). +- **`repl/_commands.py`** — pure slash parser (`/model /profile /company /plan + /yes /context /clear /help /exit` + aliases). +- **`repl/_plan_mode.py`** — drafted batch YAML → temp file → gated + `bcli batch run` (dry-run then real), same path `bcli extract` uses. +- **Tests** — `tests/test_repl/` (bare-entry regression, slash parsing, plan-mode + argv + round-trip, wizard config-write, Textual `App.run_test()` pilots feeding + canned `AgentEvent` streams for text / tool / approval). +- Also fixed a test side-effect: pinned the text-only pydantic-ai test to + `call_tools=[]` so it no longer shells out to a real `bcli batch` subprocess + (that was polluting `test_context`'s last-error read under full-suite ordering). + +### Part 3 — Claude Code backend (`8a57726`) + +- **`backends/_claude_sdk.py`** — `ClaudeCodeBackend` over `ClaudeSDKClient`. + bcli verbs become an in-process SDK MCP server from the SAME `_impl.py` + handlers; `allowed_tools` restricted to `mcp__bcli__*` (built-in coding tools + never allowed); `can_use_tool` coarse fence on top of the per-handler write + gate. Handles the documented Python quirk: streaming `AsyncIterable` prompt + + dummy `PreToolUse` hook returning `{"continue_": True}` so `can_use_tool` + fires. Translates `AssistantMessage` / `TextBlock` / `ToolUseBlock` / + `ToolResultBlock` / `ResultMessage` → `AgentEvent`s. +- Consent flow (`repl/_consent.py`, present since the skeleton) covers + claude-code subscription auth — literal `yes`, persisted; API keys never + prompt. +- **Tests** — fake `claude_agent_sdk` injected into `sys.modules` (package not + installed): factory build, event translation, `can_use_tool` fence, dummy + hook, bcli-only `allowed_tools`. + +### Part 4 — Codex backend (`3680517`) + +- **`backends/_codex.py`** — `CodexBackend` over the `openai-codex` SDK. + Codex is an MCP client → `to_mcp_config()` registers the existing `bcli-mcp` + server (no new tool code; the write gate runs one layer down in the bcli + subprocess + codex `approval_mode`). `base_instructions` carries bcli's prompt; + approval escalates to `on_request` under production/plan mode. Notifications + mapped best-effort to `AgentEvent`s; final answer from `TurnResult`. +- **`[tool.uv] prerelease = "allow"`** so the universal lock resolves the beta's + pinned prerelease runtime (`openai-codex-cli-bin`); core deps stay stable. +- **Tests** — fake `openai_codex` injected into `sys.modules`: `to_mcp_config`, + factory build, notification mapping + final answer, `thread_start` config + + instructions, production approval escalation. + +### Docs + +`docs/agent.md` (end-to-end guide), `agent` section in +`docs/command-reference.md`, README docs-table entry, and the Agent Mode +architecture section in the (untracked, local) `CLAUDE.md`. + +## Commit list (on `feat/agent-mode`, local only — not pushed) ``` -$ .venv/bin/python -m pytest tests/test_context tests/test_packs tests/test_ask -v -75 passed in 0.36s - -$ .venv/bin/python -m pytest tests/ -906 passed, 5 skipped (full suite — no regressions) - -$ .venv/bin/python -m ruff check src/ -All checks passed! - -$ bcli pack list # shows both built-in packs -$ bcli pack install starter-generic --dry-run # 3 fragments + 6 queries + 2 batches -$ bcli ask --dry-run --no-context "test" # redacted bundle, no network -``` - -## Out of scope / STUCK - -No STUCK files were written — every Part landed within scope. One -late finding from the advisor reconcile pass landed two bug fixes -+ regression tests before "done": - -- **`bcli ask --no-context` was leaking last-error** because - `build_bundle` reads `last-error.json` from disk when no record - is passed. Added `skip_last_error=True` parameter to the builder - and threaded it through the CLI. Regression test - `test_no_context_suppresses_existing_last_error` writes a real - last-error file, then asserts the phrase appears in the default - bundle but NOT in the `--no-context` bundle. -- **`bcli ask --include-debug` was wired but inert.** The CLI now - reads `last-error-debug.json` (mode 0600 sidecar) when the flag - is set. Regression test `test_include_debug_reads_traceback_sidecar` - asserts "Traceback" absent by default + present with the flag. - -Two follow-up items the next session should pick up: - -1. **`bcli describe` excerpt wiring in `bcli ask`.** The `ask` - command's `--no-context` flag is honoured, but the *default* - path does not yet subprocess `bcli describe` into the bundle. - Wiring is straightforward (`subprocess.run(["bcli", "describe", - "--format", "json"], …)`), gated on `cfg.include_describe`. Left - as a TODO so the first PR stays focused on the LLM-call surface. - -2. **Idempotent pack uninstall on missing-marker** — the installer - tolerates a missing marker block (warns) but does not force a - re-walk of the AGENTS.md/CLAUDE.md content to verify the rest of - the block hasn't been re-edited. Plan reference: R2 lists this - as the `--force` opt-in path; UX wiring is deferred. - -## Wheel build smoke-test - -`python -m build --wheel` builds cleanly. Verified that the -hatch `force-include` mapping ships both built-in packs: - -``` -$ unzip -l dist/bc_cli-0.4.0-py3-none-any.whl | grep _builtin -bcli/packs/_builtin/cronus-demo/pack.yaml -bcli/packs/_builtin/cronus-demo/batches/month-end-cronus.yaml -bcli/packs/_builtin/cronus-demo/fragments/... -bcli/packs/_builtin/starter-generic/pack.yaml -bcli/packs/_builtin/starter-generic/batches/... -bcli/packs/_builtin/starter-generic/fragments/... -bcli/packs/_builtin/starter-generic/queries/... + docs(agent): agent mode guide, command reference, README, summary +3680517 feat(agent): Part 4 — Codex backend (openai-codex SDK) +8a57726 feat(agent): Part 3 — Claude Code backend (claude-agent-sdk) +05f0bba feat(agent): Part 2 — Textual chat REPL, bare-bcli entry, plan mode +3f88923 feat(agent): complete Part 1 — engine, tools, pydantic-ai backend, headless run +4a71910 wip(agent): Part 1 engine skeleton (pre-existing checkpoint) ``` -`builtin_packs_dir()` looks at both the repo-root `packs/` (editable -install) and the wheel-shipped `bcli/packs/_builtin/`, so `bcli pack -list` works for users installed via `pip install bc-cli`. - -## Recommended follow-up - -**Beautech companion plan — see Part 1B in -`/Users/igor/.claude/plans/how-would-the-bcli-zippy-lantern.md`**. - -The OSS plan above ships mechanism + two generic packs. The -companion plan turns `bcli-beautech-bootstrap`'s existing assets -(`finance.queries.yaml`, `technical.queries.yaml`, -`workflows/*.batch.yaml`, etc.) into three downstream packs -(`beautech-finance`, `beautech-technical`, -`beautech-customer-360`) registering via the -`bcli.packs` entry-point group, plus a Beautech `bcli.ask -context_provider` for the aviation glossary. Touches only the -private bootstrap repo; the OSS pack/ask machinery is the -extension surface it consumes. +## Test results + +- `tests/test_agent/`: **61 passed** (factory, registry/parity, safety matrix, + read handlers, pydantic-ai stream, wizard, consent, headless run, claude-code + backend, codex backend). +- `tests/test_repl/`: **22 passed** (bare-entry, slash commands, plan mode, + wizard write, Textual pilots). +- **Full suite: 1030 passed, 5 skipped** (`uv run pytest tests/`). +- `uv run ruff check src/`: **clean**. + +No network in any test: pydantic-ai uses `TestModel`; the claude-agent-sdk and +openai-codex packages (not installed) are faked in `sys.modules`. + +## Deviations from the plan (and why) + +1. **Codex SDK shape.** The plan assumed `import codex` driving `codex + app-server` over JSON-RPC (`thread/turn/item` events). The actually-published + package is `openai-codex` (import `openai_codex`, beta `0.1.0b3`), exposing a + higher-level `AsyncCodex().thread_start(...) -> thread.turn(input) -> + AsyncTurnHandle.stream()` + `TurnResult`. I inspected the live PyPI metadata + and the GitHub `sdk/python/docs/api-reference.md` and targeted the real API. + Notification → `AgentEvent` mapping is intentionally defensive (attribute + probing, not isinstance on concrete beta types) since the item/notification + shape is not yet 1.0-stable; the final answer always arrives via `TurnResult` + regardless. +2. **`[tool.uv] prerelease = "allow"`** added so the universal lockfile can + resolve `[agent-codex]` (its runtime `openai-codex-cli-bin` is a pinned + prerelease). Every other dependency still pins a stable release. +3. **Setup wizard is rich-prompt based, not Textual screens.** It must work + identically from `bcli agent init` in a bare terminal and from the REPL's + first-run path; a plain-prompt flow with pure, unit-testable decision logic + (`detect_backends`, `build_agent_section`) was the simpler, more testable + choice. The chat itself is full Textual as specified. +4. **`pydantic-ai` backend test** uses `TestModel(call_tools=[...])` rather than a + `FunctionModel` script — `run_stream_events` requires a `stream_function` for + `FunctionModel`, whereas `TestModel` streams deterministically and lets us + pick exactly which tool is called. + +## Manual follow-ups (live smoke tests — need real keys / installed CLIs) + +These are NOT in the automated suite (no network / no installed `claude` / +`codex` binaries in CI). From the plan's Verification section: + +1. `bcli` on a TTY with no `[agent]` config → wizard; configure Ollama (no key) + → chat opens. +2. BYOK: `[agent] backend=pydantic-ai model=anthropic:claude-sonnet-4-5` → ask + "how many vendors does LLC have?" → watch the tool panel run `get vendors`, + streamed answer. +3. Write safety: a `disable_writes=true` sandbox profile → ask the agent to + create a vendor → approval dialog / plan-mode draft; decline → refusal. +4. Claude Code: a machine with `claude` installed and no `ANTHROPIC_API_KEY` → + wizard offers it, consent text shown, literal `yes` required, chat works on + subscription credit. (Requires `pip install "bc-cli[agent-claude-code]"`.) +5. Codex: `codex` installed → backend registers `bcli-mcp`, tool calls + round-trip, approval policy surfaces writes. (Requires + `pip install "bc-cli[agent-codex]"`; verify the live notification shape maps + cleanly — `_notification_to_event` is defensive but unverified against a real + stream.) diff --git a/README.md b/README.md index b8f434b..150b765 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ pip install -e ".[dev,etl]" | [Batch Operations](docs/batch-operations.md) | YAML batch files | | [SDK Usage](docs/sdk-usage.md) | Python SDK for developers and MCP servers | | [MCP Server](docs/mcp-server.md) | Drive bcli from Claude Desktop via the `bcli-mcp` server (preview) | +| [Agent Mode](docs/agent.md) | Bare `bcli` chat REPL — ask BC questions in plain language (BYOK / Claude Code / Codex) | | [Command Reference](docs/command-reference.md) | Complete CLI command reference | | [For AI Agents](AGENTS.md) | Quick discovery recipes for Claude Code, Cursor, etc. driving bcli on a user's behalf | | [Contributing](docs/contributing.md) | Development setup, architecture, testing | diff --git a/docs/agent.md b/docs/agent.md new file mode 100644 index 0000000..e7687dc --- /dev/null +++ b/docs/agent.md @@ -0,0 +1,164 @@ +# Agent Mode — the `bcli` chat REPL + +Agent mode turns `bcli` into an interactive assistant for Business Central: an +LLM drives bcli's own verbs as tools, so you ask questions in plain language and +watch it run `get`, `endpoint search`, `post`, and friends — with the same write +safety the CLI enforces. + +``` +$ bcli + bcli agent — model: anthropic:claude-sonnet-4-5 · profile: finance · env: sandbox + +› how many vendors does LLC have? + → bcli_get {"endpoint": "vendors", "company": "LLC", "top": 1, "count": true} + ✓ bcli_get + LLC has 312 vendors. +``` + +Bare `bcli` on an interactive terminal launches the chat. Piped or scripted +(`echo … | bcli`, `bcli | cat`) it still prints help — existing automation is +unaffected. + +## Quick start + +```bash +# Install the agent extra (BYOK loop + the Textual TUI): +uv tool install -e ".[agent]" --force # or: pip install "bc-cli[agent]" + +# First launch runs a setup wizard (also: bcli agent init): +bcli +``` + +The wizard asks which LLM to use, stores any API key in your OS keychain, writes +the `[agent]` config section, and drops you into chat. + +## Backends (BYOK or bring your own CLI) + +| `[agent] backend` | What it is | Extra | +|---|---|---| +| `pydantic-ai` | In-process loop — any Anthropic / OpenAI / local OpenAI-compatible model (Ollama, vLLM, LM Studio) via `provider:model` strings + `base_url`. The default BYOK path. | `[agent]` / `[agent-local]` | +| `claude-code` | Drives your installed Claude Code through the Claude Agent SDK; bcli's verbs become an in-process MCP server. | `[agent-claude-code]` | +| `codex` | Drives your installed Codex CLI through the `openai-codex` SDK; Codex consumes bcli's existing `bcli-mcp` server. | `[agent-codex]` | +| `null` (default) | No backend; the REPL prints a setup hint. | — | +| `my_pkg.module:MyBackend` | Any class implementing `bcli.agent.AgentSessionBackend` with a `from_config` classmethod. | your own | + +All three first-party backends emit the **same** `AgentEvent` stream, so the +chat UI, the write-safety gate, and plan mode behave identically regardless of +which one you pick. Switching is a one-line config change. + +### `[agent]` config + +```toml +[agent] +backend = "pydantic-ai" # null | pydantic-ai | claude-code | codex | module:Class +model = "anthropic:claude-sonnet-4-5" # provider:model (pydantic-ai); bare name → OpenAI-compatible +api_key_env = "ANTHROPIC_API_KEY" # optional override of the key env var +base_url = "" # Ollama / OpenAI-compatible endpoint +max_steps = 20 # tool-call budget per turn +memory = true # load per-profile BC.md into the prompt +plan_mode_default = "auto" # auto (on for production) | on | off +``` + +### Local models (no API key) + +```toml +[agent] +backend = "pydantic-ai" +model = "ollama:llama3.1" +base_url = "http://localhost:11434/v1" +``` + +## Credentials + +Resolution order matches the rest of bcli (`bcli.auth._credentials`): explicit +`api_key_env` → OS keychain (service `bcli`, key `llm:`) → the +provider's default env var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). The wizard +writes keys to the keychain; nothing sensitive lands in the config file. + +### Subscription auth + the consent gate + +`claude-code` and `codex` can ride your personal Claude / ChatGPT subscription +instead of an API key. That's individual-use territory at both vendors +(Anthropic's per-plan Agent SDK credit is sized for one person; Codex +subscription access uses an undocumented endpoint with rate windows). So bcli +**never defaults to it**: when a subscription login is the only credential, the +first run shows an explicit notice and requires you to type literal `yes`. +Consent is persisted as `subscription_authorized = true` + a timestamp under +`[agent]` — visible in plain text, revocable by deleting the line. **Teams +should use API keys**, which never prompt. + +## Write safety + +Writes are gated **inside the tool implementations**, never just in the prompt: + +- `disable_writes = true` profiles, `caution: high` endpoints, and **production** + targets pause the write and raise an approval dialog (or `--yes` in headless + mode). Decline → the model gets a typed refusal and is told not to retry. +- Every approved write goes through `SafeContext` with an explicit + environment + company. +- **Plan mode** (default ON for production): the write tier is replaced by a + single `draft_batch` tool. The agent proposes a `bcli batch` YAML; you review + it, then it's promoted through the normal gated `bcli batch run` path + (dry-run first) — exactly like `bcli extract`. Toggle with `/plan`. + +## Chat commands + +| Command | Effect | +|---|---| +| `/model [name]` | Show / note a model switch (persist via config) | +| `/profile [name]` | Switch the bcli profile (re-resolves env, company, registry) | +| `/company ` | Set the default company for tool calls | +| `/plan` | Toggle plan mode | +| `/yes` | Approve a pending write | +| `/context` | Show the resolved profile / env / plan-mode context | +| `/clear` | Clear the transcript | +| `/help` | List commands | +| `/exit` (`/quit`, Ctrl+C) | Leave the chat | + +## Memory (BC.md) + +When `memory = true`, agent mode loads a `BC.md` file into the system prompt +after the base instructions: a project-local `./BC.md` (discovered by walking up +from the working directory) wins over the per-profile +`~/.config/bcli/profiles//BC.md`. Use it to pin durable context — "our +vendors are keyed by `displayName`, never by number". Read-only in v1. + +## Headless one-shot + +```bash +bcli agent run "how many open sales orders are there?" +bcli agent run "draft a vendor for Acme" --plan # force plan mode +bcli agent run "…" --yes # auto-approve writes (scripted; careful) +``` + +`bcli agent run` streams the answer to stdout and tool activity to stderr — +testable without a TTY and the same engine the chat REPL uses. + +## Architecture (engine / renderer split) + +The seam between bcli and the LLM is the **session**, not the model call. +`src/bcli/agent/` is the SDK engine: a backend implements +`AgentSessionBackend` and streams uniform `AgentEvent`s (`text_delta`, +`tool_call_started`, `tool_result`, `awaiting_approval`, `turn_complete`, +`error`). `src/bcli_cli/repl/` is one renderer (the Textual app); the headless +`bcli agent run` printer is another. The engine never imports `bcli_cli`. + +Tools come from a single source — the same `bcli describe --format json` payload +`bcli_mcp` consumes — projected three ways: in-process pydantic-ai tools, an +in-process Claude SDK MCP server, and (for codex) the existing `bcli-mcp` +subprocess. All paths share the handlers in `src/bcli/agent/tools/_impl.py`, so +write safety lives in exactly one place. + +## Verification smoke tests (need real keys / binaries) + +These aren't in the automated suite (no network / no installed CLIs in CI): + +1. `bcli` on a TTY with no `[agent]` → wizard; configure Ollama → chat opens. +2. BYOK: `[agent] backend=pydantic-ai model=anthropic:claude-sonnet-4-5` → ask + "how many vendors does LLC have?" → watch the tool panel run `get vendors`. +3. Write safety: a `disable_writes=true` sandbox profile → ask the agent to + create a vendor → approval dialog (or plan-mode draft); decline → refusal. +4. Claude Code: a machine with `claude` installed and no `ANTHROPIC_API_KEY` → + wizard offers it, consent text shown, literal `yes` required. +5. Codex: `codex` installed → backend registers `bcli-mcp`, tool calls + round-trip, approval policy surfaces writes. diff --git a/docs/command-reference.md b/docs/command-reference.md index c2fa64b..7513634 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -406,3 +406,42 @@ bcli etl sync --destination filesystem bcli etl sync --entities customers,vendors --destination duckdb bcli etl sync --full-refresh --destination iceberg ``` + +--- + +## agent (optional — requires `bc-cli[agent]`) + +Interactive chat REPL where an LLM drives bcli's verbs as tools. Bare `bcli` on +a TTY launches the chat; non-TTY prints help. See [Agent Mode](agent.md). + +### bcli (bare, on a TTY) + +```bash +bcli # launch the chat REPL (or the first-run setup wizard) +bcli --profile finance +``` + +### agent run + +Headless one-shot turn — stream the answer to stdout, tool activity to stderr. + +```bash +bcli agent run "" [options] +``` + +| Option | Short | Description | +|--------|-------|-------------| +| `--backend ` | | One-shot backend override (`pydantic-ai` / `claude-code` / `codex` / `module:Class`) | +| `--model ` | | One-shot model override (e.g. `anthropic:claude-sonnet-4-5`) | +| `--plan` | | Force plan mode on (writes become `draft_batch`) | +| `--no-plan` | | Force plan mode off | +| `--yes` | `-y` | Auto-approve gated writes (scripted use; be careful) | + +### agent init + +Re-run the setup wizard — pick a backend, store the API key in the OS keychain, +write the `[agent]` config section. + +```bash +bcli agent init +``` diff --git a/pyproject.toml b/pyproject.toml index b11093d..c8ed6e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,25 @@ ask-openai = [ mcp = [ "mcp>=1.0", ] +# Agent mode (the interactive chat REPL + headless `bcli agent run`). +# Three backends, each behind its own extra; `agent` is the meta-extra +# that installs the BYOK loop + the Textual TUI. The harness-owned +# backends (claude-code / codex) are additive opt-ins. +agent-local = [ + "pydantic-ai-slim[anthropic,openai]>=1.107,<2", +] +agent-claude-code = [ + "claude-agent-sdk>=0.2", +] +agent-codex = [ + # openai-codex is in beta (0.1.x); allow prereleases so the pin + # resolves. It pulls in its own openai-codex-cli-bin runtime. + "openai-codex>=0.1.0b3", +] +agent = [ + "bc-cli[agent-local]", + "textual>=8.2", +] polaris = [ "bc-cli[etl]", "pyarrow>=16.0", @@ -94,6 +113,7 @@ dev = [ "bc-cli[mcp]", "bc-cli[extract]", "bc-cli[ask]", + "bc-cli[agent]", "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-httpx>=0.30", @@ -120,6 +140,15 @@ include = [ "pyproject.toml", ] +[tool.uv] +# The optional [agent-codex] extra depends on openai-codex, which is +# beta-only and pulls a pinned prerelease runtime +# (openai-codex-cli-bin==0.137.0a4). Enabling prereleases lets the +# universal lock resolve that extra. Every other dependency has a stable +# release satisfying its constraint, so uv still pins stable versions for +# them — the lockfile records exact versions regardless. +prerelease = "allow" + [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" diff --git a/src/bcli/agent/__init__.py b/src/bcli/agent/__init__.py new file mode 100644 index 0000000..eb38249 --- /dev/null +++ b/src/bcli/agent/__init__.py @@ -0,0 +1,45 @@ +"""``bcli.agent`` — the agent-mode engine (Part 4 of the roadmap). + +Engine emits events, renderer consumes them: every backend (pydantic-ai +BYOK, claude-agent-sdk, codex) streams uniform :class:`AgentEvent` +records through the :class:`AgentSessionBackend` protocol, and one +renderer — the Textual REPL in ``bcli_cli.repl`` or the headless +``bcli agent run`` printer — consumes them. Write safety lives inside +the tool implementations (:mod:`bcli.agent.tools._impl`), gated by +:class:`AgentRuntime` and resolved through ``awaiting_approval`` events. + +Design rules enforced by the package boundary: + +* Nothing in here imports from ``bcli_cli`` or ``bcli_mcp`` + (CLI → SDK only; the MCP server stays a subprocess concern). +* Optional LLM SDKs are imported lazily inside backends; the factory + falls back to :class:`NullAgentBackend` with a one-shot warning. +""" + +from __future__ import annotations + +from bcli.agent._factory import get_agent_backend +from bcli.agent._prompt import build_system_prompt +from bcli.agent._protocol import ( + AgentEvent, + AgentSessionBackend, + EventKind, + NullAgentBackend, +) +from bcli.agent._runtime import AgentRuntime, WriteGateDecision +from bcli.agent.memory import load_bc_md +from bcli.agent.tools import ToolRegistry, ToolSpec + +__all__ = [ + "AgentEvent", + "AgentRuntime", + "AgentSessionBackend", + "EventKind", + "NullAgentBackend", + "ToolRegistry", + "ToolSpec", + "WriteGateDecision", + "build_system_prompt", + "get_agent_backend", + "load_bc_md", +] diff --git a/src/bcli/agent/_auth_detect.py b/src/bcli/agent/_auth_detect.py new file mode 100644 index 0000000..3e3aa8d --- /dev/null +++ b/src/bcli/agent/_auth_detect.py @@ -0,0 +1,66 @@ +"""Credential detection for the harness-owned backends. + +Pure environment / filesystem checks — no optional SDK imports — so the +setup wizard and the consent gate can classify the auth path before +anything heavy loads. + +Classification: + +* ``"api_key"`` — a sanctioned programmatic key is present; no + consent needed. +* ``"subscription"`` — only subscription credentials are detectable + (Claude Code login / ``~/.codex/auth.json``); + the explicit consent gate applies. +* ``"none"`` — nothing usable found. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +AuthKind = str # "api_key" | "subscription" | "none" + + +def detect_claude_auth(*, home: Path | None = None) -> AuthKind: + """Classify how a claude-code backend session would authenticate.""" + if os.environ.get("ANTHROPIC_API_KEY"): + return "api_key" + if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): + return "subscription" + base = home or Path.home() + # Claude Code stores login credentials under ~/.claude (keychain on + # macOS, credentials file elsewhere); the binary on PATH with a + # config dir is the practical signal that a subscription login exists. + if (base / ".claude" / ".credentials.json").is_file(): + return "subscription" + if shutil.which("claude") and (base / ".claude").is_dir(): + return "subscription" + return "none" + + +def detect_codex_auth(*, home: Path | None = None) -> AuthKind: + """Classify how a codex backend session would authenticate.""" + if os.environ.get("CODEX_API_KEY") or os.environ.get("OPENAI_API_KEY"): + return "api_key" + base = home or Path.home() + if (base / ".codex" / "auth.json").is_file(): + return "subscription" + return "none" + + +def claude_code_available() -> bool: + return shutil.which("claude") is not None + + +def codex_available() -> bool: + return shutil.which("codex") is not None + + +__all__ = [ + "claude_code_available", + "codex_available", + "detect_claude_auth", + "detect_codex_auth", +] diff --git a/src/bcli/agent/_factory.py b/src/bcli/agent/_factory.py new file mode 100644 index 0000000..f7de343 --- /dev/null +++ b/src/bcli/agent/_factory.py @@ -0,0 +1,99 @@ +"""Backend dispatch for :func:`bcli.agent.get_agent_backend`. + +Mirror of :mod:`bcli.ask._factory` — same built-in shortcuts, same +``module.path:ClassName`` import spec for third-party, same +NullAgentBackend fallback on any failure plus one-shot warning. +""" + +from __future__ import annotations + +import logging +from importlib import import_module +from typing import TYPE_CHECKING + +from bcli.agent._protocol import AgentSessionBackend, NullAgentBackend + +if TYPE_CHECKING: + from bcli.config._model import AgentConfig + +logger = logging.getLogger("bcli.agent") + + +_BUILTIN_BACKENDS: dict[str, str] = { + "null": "bcli.agent._protocol:NullAgentBackend", + "pydantic-ai": "bcli.agent.backends._pydantic_ai:PydanticAIBackend", + "claude-code": "bcli.agent.backends._claude_sdk:ClaudeCodeBackend", + "codex": "bcli.agent.backends._codex:CodexBackend", + # Any other backend (pi.dev shim, internal gateway, self-hosted, …) + # is selected by full import path: + # [agent] backend = "my_pkg.module:MyBackend" +} + + +def get_agent_backend(config: "AgentConfig | None") -> AgentSessionBackend: + """Build an agent session backend from an :class:`AgentConfig`. + + Returns :class:`NullAgentBackend` when no backend is configured or + the chosen backend can't be loaded — callers can start a session + unconditionally and check ``is_active``. + """ + if config is None: + return NullAgentBackend() + + raw = (config.backend or "null").strip() + if not raw or raw.lower() == "null": + return NullAgentBackend() + + spec = _BUILTIN_BACKENDS.get(raw, raw) + + try: + backend_cls = _load_class(spec) + except Exception as e: # noqa: BLE001 + logger.warning( + "Agent backend '%s' could not be loaded (%s); falling back " + "to NullAgentBackend. Set [agent] backend to one of %s, or " + "to a 'module.path:ClassName' import spec.", + raw, e, sorted(_BUILTIN_BACKENDS.keys()), + ) + return NullAgentBackend() + + if not hasattr(backend_cls, "from_config"): + logger.warning( + "Agent backend '%s' has no from_config classmethod; " + "falling back to NullAgentBackend.", raw, + ) + return NullAgentBackend() + + try: + return backend_cls.from_config(config) + except Exception as e: # noqa: BLE001 + logger.warning( + "Agent backend '%s' from_config raised %s; falling back " + "to NullAgentBackend.", raw, e, + ) + return NullAgentBackend() + + +def _load_class(spec: str): + if ":" not in spec: + raise ValueError( + f"Backend spec '{spec}' is missing a class — expected " + f"'module.path:ClassName'." + ) + module_path, _, class_name = spec.partition(":") + if not module_path or not class_name: + raise ValueError( + f"Backend spec '{spec}' is malformed — expected " + f"'module.path:ClassName'." + ) + module = import_module(module_path) + try: + return getattr(module, class_name) + except AttributeError as e: + raise ValueError( + f"Backend class '{class_name}' not found in module " + f"'{module_path}'." + ) from e + + +__all__ = ["get_agent_backend"] diff --git a/src/bcli/agent/_prompt.py b/src/bcli/agent/_prompt.py new file mode 100644 index 0000000..e091642 --- /dev/null +++ b/src/bcli/agent/_prompt.py @@ -0,0 +1,63 @@ +"""System prompt assembly for the bcli agent. + +Order (pi.dev lesson: short prompt, strong tools): + +1. Base instructions — who the agent is, discovery-first tool habits, + the write-safety contract. +2. BC.md memory (when ``[agent] memory = true`` and a file exists). +3. The redacted context bundle (profile snapshot + recent errors) via + :meth:`bcli.context.ContextBundle.to_prompt_text` — token-budgeted by + the context layer itself. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bcli.context import ContextBundle + +BASE_INSTRUCTIONS = """\ +You are the bcli agent: an operator's assistant for Microsoft Dynamics \ +365 Business Central, working exclusively through bcli tools. + +Rules: +- Discovery first. If you are not certain an endpoint or field name \ +exists, call bcli_endpoint_search / bcli_endpoint_fields before \ +querying. Never guess field names in filters. +- Prefer narrow queries: use top, select, and filter instead of \ +fetching everything. +- Writes are serious. State what you are about to change and why \ +before calling a write tool. A refusal result means the operator \ +declined — do not retry; ask how to proceed. +- When plan mode is active, draft changes with draft_batch instead of \ +writing. The operator reviews and runs the batch. +- You cannot run interactive commands (auth login, config init). Tell \ +the operator to run them in their own terminal. +- Be concise. Answer with the data, cite record counts, and show \ +amounts with their currency. +""" + + +def build_system_prompt( + *, + memory_text: str = "", + bundle: "ContextBundle | None" = None, + plan_mode: bool = False, +) -> str: + parts: list[str] = [BASE_INSTRUCTIONS] + if plan_mode: + parts.append( + "PLAN MODE is ON: write tools are unavailable; propose all " + "changes through draft_batch." + ) + if memory_text.strip(): + parts.append("## Operator memory (BC.md)\n\n" + memory_text.strip()) + if bundle is not None: + text = bundle.to_prompt_text().strip() + if text: + parts.append("## Session context\n\n" + text) + return "\n\n".join(parts) + + +__all__ = ["BASE_INSTRUCTIONS", "build_system_prompt"] diff --git a/src/bcli/agent/_protocol.py b/src/bcli/agent/_protocol.py new file mode 100644 index 0000000..0a39290 --- /dev/null +++ b/src/bcli/agent/_protocol.py @@ -0,0 +1,131 @@ +"""AgentSessionBackend protocol + always-available NullAgentBackend. + +Mirror of :mod:`bcli.ask._protocol` so the factory dispatch +(:mod:`bcli.agent._factory`) can stay byte-identical in shape with the +ask/extract factories. + +The seam is the *session*, not the model call: pydantic-ai is a loop +bcli owns, while claude-agent-sdk and codex are loops the harness owns. +Every backend therefore reduces to the same contract — start a session +with a system prompt + tool registry + runtime, then ``send()`` user +messages and stream uniform :class:`AgentEvent` records back. One +renderer (the Textual REPL or the headless ``bcli agent run`` printer) +consumes events from any backend. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable + +if TYPE_CHECKING: + from bcli.agent._runtime import AgentRuntime + from bcli.agent.tools._registry import ToolRegistry + from bcli.config._model import AgentConfig + + +EventKind = Literal[ + "text_delta", + "tool_call_started", + "tool_result", + "awaiting_approval", + "turn_complete", + "error", +] + + +@dataclass(frozen=True) +class AgentEvent: + """One uniform event in an agent turn. + + ``kind`` drives which payload fields are meaningful: + + * ``text_delta`` — ``text`` carries an incremental chunk of + assistant output. + * ``tool_call_started`` — ``tool_name`` / ``tool_call_id`` / + ``tool_args`` describe the call about to run. + * ``tool_result`` — ``tool_name`` / ``tool_call_id`` plus + ``result`` (JSON-able payload the model saw). + * ``awaiting_approval`` — a gated write is paused on a human decision; + ``approval_id`` is resolved via + :meth:`bcli.agent.AgentRuntime.resolve_approval`, + ``reason`` explains why the gate fired. + * ``turn_complete`` — the turn finished; ``text`` carries the + final assembled answer when available. + * ``error`` — ``error`` carries a human-readable message. + """ + + kind: EventKind + text: str = "" + tool_name: str = "" + tool_call_id: str = "" + tool_args: Mapping[str, Any] = field(default_factory=dict) + result: Any = None + approval_id: str = "" + reason: str = "" + error: str = "" + + +@runtime_checkable +class AgentSessionBackend(Protocol): + """Structural type every agent backend satisfies.""" + + is_active: bool + + async def start_session( + self, + *, + system_prompt: str, + tools: "ToolRegistry", + runtime: "AgentRuntime", + ) -> None: ... + + def send(self, user_msg: str) -> AsyncIterator[AgentEvent]: ... + + async def close(self) -> None: ... + + +class NullAgentBackend: + """Zero-overhead backend used when no backend is configured. + + The REPL / ``bcli agent run`` surface this with a "set [agent] + backend = 'pydantic-ai' …" message so the user knows why nothing + happened. + """ + + is_active: bool = False + + SETUP_HINT = ( + "No agent backend configured. Run 'bcli agent init' to set one " + "up, or set [agent] backend = 'pydantic-ai' in " + "~/.config/bcli/config.toml and install bc-cli[agent-local]." + ) + + @classmethod + def from_config(cls, config: "AgentConfig") -> "NullAgentBackend": # noqa: ARG003 + return cls() + + async def start_session( # noqa: ARG002 + self, + *, + system_prompt: str, + tools: "ToolRegistry", + runtime: "AgentRuntime", + ) -> None: + return None + + async def send(self, user_msg: str) -> AsyncIterator[AgentEvent]: # noqa: ARG002 + yield AgentEvent(kind="error", error=self.SETUP_HINT) + yield AgentEvent(kind="turn_complete") + + async def close(self) -> None: + return None + + +__all__ = [ + "AgentEvent", + "AgentSessionBackend", + "EventKind", + "NullAgentBackend", +] diff --git a/src/bcli/agent/_runtime.py b/src/bcli/agent/_runtime.py new file mode 100644 index 0000000..5311365 --- /dev/null +++ b/src/bcli/agent/_runtime.py @@ -0,0 +1,208 @@ +"""AgentRuntime — the per-session execution context shared by tool handlers. + +Carries the live :class:`~bcli.client._async.AsyncBCClient`, the resolved +profile, the endpoint registry, and the write-safety approval seam. Tool +implementations (:mod:`bcli.agent.tools._impl`) receive the runtime as +their first argument — they never touch CLI state or global singletons, +which keeps the SDK/CLI split intact and the handlers testable with a +fake client. + +The approval seam +----------------- + +Write handlers call :meth:`AgentRuntime.gate_write`. When the gate fires +(``disable_writes``, ``caution == "high"``, or a production target) an +``awaiting_approval`` :class:`~bcli.agent._protocol.AgentEvent` is emitted +through the bound emitter and the handler awaits an :class:`asyncio.Future`. +The consumer (Textual approval dialog, ``/yes``, or the headless prompt) +resolves it via :meth:`resolve_approval`. A decline returns a typed refusal +the model sees — safety is enforced *inside the tool implementations*, +never only in the prompt. +""" + +from __future__ import annotations + +import asyncio +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from bcli.agent._protocol import AgentEvent + +if TYPE_CHECKING: + from bcli.client._async import AsyncBCClient + from bcli.client._safety import SafeContext + from bcli.config._model import BCProfile + from bcli.registry._registry import EndpointRegistry + + +_PRODUCTION_NAMES = ("production", "prod") + + +@dataclass(frozen=True) +class WriteGateDecision: + """Outcome of :meth:`AgentRuntime.gate_write`.""" + + approved: bool + reasons: tuple[str, ...] = () + + def refusal(self) -> dict[str, Any]: + """Typed refusal payload returned to the model on decline.""" + return { + "status": "refused", + "reason": ( + "The operator declined this write (" + + "; ".join(self.reasons) + + "). Do not retry. Explain what was attempted and ask " + "the operator how to proceed." + ), + } + + +class AgentRuntime: + """Execution context handed to every tool handler. + + ``auto_approve=True`` (the headless ``--yes`` path) resolves every + gate without emitting an approval event. With no emitter bound and + no auto-approve, gated writes are *denied* — fail closed. + """ + + def __init__( + self, + *, + client: "AsyncBCClient", + profile: "BCProfile", + profile_name: str = "", + registry: "EndpointRegistry | None" = None, + plan_mode: bool = False, + auto_approve: bool = False, + ) -> None: + self.client = client + self.profile = profile + self.profile_name = profile_name + self.registry = registry + self.plan_mode = plan_mode + self.auto_approve = auto_approve + self._emit: Callable[[AgentEvent], Awaitable[None]] | None = None + self._pending: dict[str, asyncio.Future[bool]] = {} + + # ── event plumbing ──────────────────────────────────────────────── + + def bind_emitter( + self, emit: Callable[[AgentEvent], Awaitable[None]] | None, + ) -> None: + """Backends bind their event queue here at send() time.""" + self._emit = emit + + async def emit(self, event: AgentEvent) -> None: + if self._emit is not None: + await self._emit(event) + + # ── write safety ────────────────────────────────────────────────── + + @property + def is_production(self) -> bool: + env = (self.profile.environment or "").lower() + return env in _PRODUCTION_NAMES + + def caution_for(self, endpoint: str) -> str: + """Endpoint caution level from the registry (``low`` when unknown).""" + if self.registry is None: + return "low" + meta = self.registry.get(endpoint) + return getattr(meta, "caution", "low") if meta is not None else "low" + + def domain_for(self, endpoint: str) -> str: + """Endpoint domain tag from the registry (``standard`` when unknown).""" + if self.registry is None: + return "standard" + meta = self.registry.get(endpoint) + return getattr(meta, "domain", "standard") if meta is not None else "standard" + + def write_gate_reasons(self, *, endpoint: str) -> tuple[str, ...]: + """Why a write to ``endpoint`` needs human approval (empty = none).""" + reasons: list[str] = [] + if getattr(self.profile, "disable_writes", False): + reasons.append( + f"profile '{self.profile_name or 'active'}' is read-only " + "(disable_writes = true)" + ) + if self.caution_for(endpoint) == "high": + reasons.append(f"endpoint '{endpoint}' is tagged caution: high") + if self.is_production: + reasons.append( + f"target environment '{self.profile.environment}' is production" + ) + return tuple(reasons) + + async def gate_write( + self, + *, + method: str, + endpoint: str, + payload: Any = None, + ) -> WriteGateDecision: + """Run the write-safety gate for one mutating tool call. + + No gate reasons → approved immediately. Otherwise emit + ``awaiting_approval`` and wait for :meth:`resolve_approval`. + """ + reasons = self.write_gate_reasons(endpoint=endpoint) + if not reasons: + return WriteGateDecision(approved=True) + if self.auto_approve: + return WriteGateDecision(approved=True, reasons=reasons) + if self._emit is None: + # Fail closed: no human is listening. + return WriteGateDecision(approved=False, reasons=reasons) + + approval_id = uuid.uuid4().hex + fut: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + self._pending[approval_id] = fut + try: + await self.emit(AgentEvent( + kind="awaiting_approval", + approval_id=approval_id, + tool_name=method, + tool_args={"endpoint": endpoint, "payload": payload}, + reason="; ".join(reasons), + )) + approved = await fut + finally: + self._pending.pop(approval_id, None) + return WriteGateDecision(approved=approved, reasons=reasons) + + def resolve_approval(self, approval_id: str, approved: bool) -> bool: + """Resolve a pending approval. Returns ``False`` if unknown/expired.""" + fut = self._pending.get(approval_id) + if fut is None or fut.done(): + return False + fut.set_result(approved) + return True + + def pending_approvals(self) -> list[str]: + return [k for k, f in self._pending.items() if not f.done()] + + # ── write execution ─────────────────────────────────────────────── + + def safe_context(self, company: str | None = None) -> "SafeContext": + """Build a :class:`SafeContext` bound to the resolved env + company. + + ``confirm_production=True`` is intentional here: the *human* + confirmation already happened through the approval gate (or + ``auto_approve``); SafeContext re-asserts the explicit env + + company invariant on every write. + """ + from bcli.client._safety import SafeContext + + company_id, _ = self.profile.resolve_company(company) + return SafeContext( + client=self.client, + environment=self.profile.environment, + company_id=company_id, + confirm_production=True, + ) + + +__all__ = ["AgentRuntime", "WriteGateDecision"] diff --git a/src/bcli/agent/backends/__init__.py b/src/bcli/agent/backends/__init__.py new file mode 100644 index 0000000..05ddeab --- /dev/null +++ b/src/bcli/agent/backends/__init__.py @@ -0,0 +1 @@ +"""Agent session backends — loaded lazily by :mod:`bcli.agent._factory`.""" diff --git a/src/bcli/agent/backends/_claude_sdk.py b/src/bcli/agent/backends/_claude_sdk.py new file mode 100644 index 0000000..4b1f969 --- /dev/null +++ b/src/bcli/agent/backends/_claude_sdk.py @@ -0,0 +1,229 @@ +"""Claude Code backend — the loop the harness owns. + +Drives the user's installed Claude Code through the ``claude-agent-sdk`` +package (Python 0.2.x). bcli's verbs are exposed as an in-process SDK MCP +server built from the SAME ``tools/_impl.py`` handlers the pydantic-ai +backend uses (via :func:`bcli.agent.tools._projections.to_claude_sdk_tools`), +so write safety lives in one place regardless of backend. + +Design notes (from the SDK docs + the documented Python quirks): + +* ``ClaudeAgentOptions(system_prompt=…)`` carries bcli's prompt; + ``allowed_tools`` is restricted to our ``mcp__bcli__*`` tools and the + built-in coding tools are never allowed — the agent can only touch BC + through bcli. +* The write gate already lives inside the tool handlers (they emit + ``awaiting_approval`` and await the runtime future). The SDK's + ``can_use_tool`` callback is a second, coarser fence: allow only the + bcli MCP tools, deny anything else. +* **Quirk**: ``can_use_tool`` only fires in *streaming* mode (an + ``AsyncIterable`` prompt), and even then needs a dummy ``PreToolUse`` + hook returning ``{"continue_": True}`` to keep the stream open. Both + are wired below. + +Requires the ``[agent-claude-code]`` extra. Import failure falls back to +NullAgentBackend via the factory. Auth: ``ANTHROPIC_API_KEY`` (sanctioned) +or, with explicit consent, ``CLAUDE_CODE_OAUTH_TOKEN`` / +subscription login — both picked up from the environment by the SDK. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any + +from bcli.agent._protocol import AgentEvent + +if TYPE_CHECKING: + from bcli.agent._runtime import AgentRuntime + from bcli.agent.tools._registry import ToolRegistry + from bcli.config._model import AgentConfig + +logger = logging.getLogger("bcli.agent") + +MCP_SERVER_NAME = "bcli" +_SENTINEL: Any = object() + + +class ClaudeCodeBackend: + """AgentSessionBackend over claude-agent-sdk's ClaudeSDKClient.""" + + is_active: bool = True + + def __init__(self, *, model: str = "", max_turns: int = 20) -> None: + self._model = model + self._max_turns = max_turns + self._runtime: "AgentRuntime | None" = None + self._tools: "ToolRegistry | None" = None + self._system_prompt = "" + self.model_label = model or "claude-code" + + @classmethod + def from_config(cls, config: "AgentConfig") -> "ClaudeCodeBackend": + import claude_agent_sdk # noqa: F401 — fail fast when the extra is missing + + return cls(model=config.model, max_turns=config.max_steps) + + # ── session ─────────────────────────────────────────────────────── + + async def start_session( + self, + *, + system_prompt: str, + tools: "ToolRegistry", + runtime: "AgentRuntime", + ) -> None: + # No long-lived client: claude-agent-sdk's ClaudeSDKClient is a + # per-turn async context manager. Stash the inputs; build the + # client in send(). + self._system_prompt = system_prompt + self._tools = tools + self._runtime = runtime + + def _build_options(self) -> Any: + from claude_agent_sdk import ClaudeAgentOptions, HookMatcher, create_sdk_mcp_server + + from bcli.agent.tools._projections import to_claude_sdk_tools + + assert self._tools is not None and self._runtime is not None + sdk_tools = to_claude_sdk_tools( + self._tools, self._runtime, plan_mode=self._runtime.plan_mode, + ) + server = create_sdk_mcp_server( + name=MCP_SERVER_NAME, version="1.0.0", tools=sdk_tools, + ) + allowed = [ + f"mcp__{MCP_SERVER_NAME}__{name}" + for name in self._tools.tool_names(plan_mode=self._runtime.plan_mode) + ] + + async def _dummy_pre_tool_hook(input_data, tool_use_id, context): # noqa: ANN001, ARG001 + # Required to make can_use_tool fire in streaming mode. + return {"continue_": True} + + options_kwargs: dict[str, Any] = { + "system_prompt": self._system_prompt, + "mcp_servers": {MCP_SERVER_NAME: server}, + "allowed_tools": allowed, + "can_use_tool": self._make_can_use_tool(allowed), + "hooks": {"PreToolUse": [HookMatcher(hooks=[_dummy_pre_tool_hook])]}, + "max_turns": self._max_turns, + } + if self._model: + options_kwargs["model"] = self._model + return ClaudeAgentOptions(**options_kwargs) + + def _make_can_use_tool(self, allowed: list[str]): + """Coarse fence: allow only the bcli MCP tools, deny the rest. + + The fine-grained write gate (disable_writes / caution / prod / + approval) runs *inside* the tool handler, so this callback just + keeps the agent from reaching for any non-bcli capability. + """ + from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny + + allowed_set = set(allowed) + + async def can_use_tool(tool_name, input_data, context): # noqa: ANN001, ARG001 + if tool_name in allowed_set: + return PermissionResultAllow(updated_input=input_data) + return PermissionResultDeny( + message=( + f"'{tool_name}' is not a bcli tool. This agent may only " + "use bcli's Business Central tools." + ) + ) + + return can_use_tool + + async def send(self, user_msg: str) -> AsyncIterator[AgentEvent]: + if self._runtime is None or self._tools is None: + yield AgentEvent(kind="error", + error="Session not started — call start_session first.") + yield AgentEvent(kind="turn_complete") + return + + queue: asyncio.Queue[Any] = asyncio.Queue() + self._runtime.bind_emitter(queue.put) + task = asyncio.ensure_future(self._run_turn(user_msg, queue)) + try: + while True: + item = await queue.get() + if item is _SENTINEL: + break + yield item + finally: + self._runtime.bind_emitter(None) + if not task.done(): + task.cancel() + else: + task.result() + + async def _run_turn(self, user_msg: str, queue: asyncio.Queue[Any]) -> None: + from claude_agent_sdk import ( + AssistantMessage, + ClaudeSDKClient, + ResultMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + ) + + async def _prompt_stream(): + # AsyncIterable prompt — required for can_use_tool to fire. + yield { + "type": "user", + "message": {"role": "user", "content": user_msg}, + } + + final_text_parts: list[str] = [] + try: + async with ClaudeSDKClient(options=self._build_options()) as client: + await client.query(_prompt_stream()) + async for message in client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + final_text_parts.append(block.text) + await queue.put(AgentEvent( + kind="text_delta", text=block.text, + )) + elif isinstance(block, ToolUseBlock): + await queue.put(AgentEvent( + kind="tool_call_started", + tool_name=_strip_mcp_prefix(block.name), + tool_call_id=block.id, + tool_args=dict(block.input or {}), + )) + elif isinstance(block, ToolResultBlock): + await queue.put(AgentEvent( + kind="tool_result", + tool_call_id=getattr(block, "tool_use_id", ""), + result=getattr(block, "content", None), + )) + elif isinstance(message, ResultMessage): + text = getattr(message, "result", "") or "".join(final_text_parts) + await queue.put(AgentEvent(kind="turn_complete", text=text)) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.debug("claude-code turn failed", exc_info=True) + await queue.put(AgentEvent(kind="error", error=str(exc))) + await queue.put(AgentEvent(kind="turn_complete")) + finally: + await queue.put(_SENTINEL) + + async def close(self) -> None: + self._runtime = None + self._tools = None + + +def _strip_mcp_prefix(tool_name: str) -> str: + """``mcp__bcli__bcli_get`` → ``bcli_get`` for display parity.""" + prefix = f"mcp__{MCP_SERVER_NAME}__" + return tool_name[len(prefix):] if tool_name.startswith(prefix) else tool_name + + +__all__ = ["ClaudeCodeBackend", "MCP_SERVER_NAME"] diff --git a/src/bcli/agent/backends/_codex.py b/src/bcli/agent/backends/_codex.py new file mode 100644 index 0000000..da82467 --- /dev/null +++ b/src/bcli/agent/backends/_codex.py @@ -0,0 +1,243 @@ +"""Codex backend — the loop the harness owns, via the openai-codex SDK. + +DEVIATION FROM THE PLAN: the plan assumed an ``import codex`` JSON-RPC +``thread/turn/item`` surface. The actually-published package is +``openai-codex`` (import name ``openai_codex``, beta 0.1.x) exposing a +higher-level client: ``AsyncCodex().thread_start(...) -> +thread.turn(input) -> AsyncTurnHandle.stream()`` yielding notifications, +plus a ``TurnResult`` with ``final_response`` / ``items``. This backend +targets that real API and maps its notifications onto bcli's uniform +:class:`AgentEvent` stream. + +Codex is itself an MCP *client*, so it consumes bcli's existing +``bcli_mcp`` stdio server — no new tool code. :func:`to_mcp_config` +builds the ``mcp_servers`` config entry codex needs (command + args + +profile env). The write gate runs one layer down, inside the bcli +subprocess the MCP server drives (``confirm_write_or_exit`` + +``disable_writes``), reinforced by codex's own ``approval_mode``. + +Requires the ``[agent-codex]`` extra. Import failure → NullAgentBackend +via the factory. Auth: ``CODEX_API_KEY`` / ``OPENAI_API_KEY`` (sanctioned) +or, with explicit consent, the ChatGPT subscription login in +``~/.codex/auth.json`` — both reused automatically by the SDK. +""" + +from __future__ import annotations + +import asyncio +import logging +import shutil +import sys +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any + +from bcli.agent._protocol import AgentEvent + +if TYPE_CHECKING: + from bcli.agent._runtime import AgentRuntime + from bcli.agent.tools._registry import ToolRegistry + from bcli.config._model import AgentConfig + +logger = logging.getLogger("bcli.agent") + +MCP_SERVER_NAME = "bcli" +_SENTINEL: Any = object() + + +def to_mcp_config(profile_name: str = "") -> dict[str, Any]: + """Build the codex ``mcp_servers`` entry that registers ``bcli_mcp``. + + Codex launches the server as a stdio subprocess. Prefer the installed + ``bcli-mcp`` console script; fall back to ``python -m bcli_mcp`` when + it isn't on PATH (e.g. an editable checkout without the script). + The active profile is passed through ``BCLI_PROFILE`` so the MCP + server's tools resolve the same registry + safety constraints. + """ + if shutil.which("bcli-mcp"): + command, args = "bcli-mcp", [] + else: + command, args = sys.executable, ["-m", "bcli_mcp"] + env: dict[str, str] = {} + if profile_name: + env["BCLI_PROFILE"] = profile_name + return { + MCP_SERVER_NAME: { + "command": command, + "args": args, + "env": env, + } + } + + +def _approval_mode(runtime: "AgentRuntime", sdk: Any) -> Any: + """Map bcli's plan/production posture onto codex's ApprovalMode. + + Production or plan mode → the most cautious mode codex offers (review + every action); otherwise codex's auto-review default. We probe the + enum defensively so a renamed member in a beta release degrades to + the default rather than crashing. + """ + mode = getattr(sdk, "ApprovalMode", None) + if mode is None: + return None + cautious = runtime.is_production or runtime.plan_mode + if cautious: + for name in ("on_request", "always", "untrusted", "auto_review"): + member = getattr(mode, name, None) + if member is not None: + return member + return getattr(mode, "auto_review", None) + + +class CodexBackend: + """AgentSessionBackend over the openai-codex AsyncCodex client.""" + + is_active: bool = True + + def __init__(self, *, model: str = "", max_turns: int = 20) -> None: + self._model = model + self._max_turns = max_turns + self._runtime: "AgentRuntime | None" = None + self._system_prompt = "" + self._codex: Any = None + self._thread: Any = None + self.model_label = model or "codex" + + @classmethod + def from_config(cls, config: "AgentConfig") -> "CodexBackend": + import openai_codex # noqa: F401 — fail fast when the extra is missing + + return cls(model=config.model, max_turns=config.max_steps) + + async def start_session( + self, + *, + system_prompt: str, + tools: "ToolRegistry", # noqa: ARG002 — codex uses bcli_mcp, not the projection + runtime: "AgentRuntime", + ) -> None: + self._system_prompt = system_prompt + self._runtime = runtime + + async def _ensure_thread(self) -> None: + import openai_codex + + if self._codex is None: + self._codex = openai_codex.AsyncCodex() + await self._codex.__aenter__() + if self._thread is None: + assert self._runtime is not None + config = {"mcp_servers": to_mcp_config(self._runtime.profile_name)} + kwargs: dict[str, Any] = { + "base_instructions": self._system_prompt, + "config": config, + } + approval = _approval_mode(self._runtime, openai_codex) + if approval is not None: + kwargs["approval_mode"] = approval + if self._model: + kwargs["model"] = self._model + self._thread = await self._codex.thread_start(**kwargs) + + async def send(self, user_msg: str) -> AsyncIterator[AgentEvent]: + if self._runtime is None: + yield AgentEvent(kind="error", + error="Session not started — call start_session first.") + yield AgentEvent(kind="turn_complete") + return + + queue: asyncio.Queue[Any] = asyncio.Queue() + self._runtime.bind_emitter(queue.put) + task = asyncio.ensure_future(self._run_turn(user_msg, queue)) + try: + while True: + item = await queue.get() + if item is _SENTINEL: + break + yield item + finally: + self._runtime.bind_emitter(None) + if not task.done(): + task.cancel() + else: + task.result() + + async def _run_turn(self, user_msg: str, queue: asyncio.Queue[Any]) -> None: + try: + await self._ensure_thread() + handle = await self._thread.turn(user_msg) + async for notification in handle.stream(): + ev = _notification_to_event(notification) + if ev is not None: + await queue.put(ev) + result = await handle.run() + final = getattr(result, "final_response", None) or "" + await queue.put(AgentEvent(kind="turn_complete", text=final)) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.debug("codex turn failed", exc_info=True) + await queue.put(AgentEvent(kind="error", error=str(exc))) + await queue.put(AgentEvent(kind="turn_complete")) + finally: + await queue.put(_SENTINEL) + + async def close(self) -> None: + self._thread = None + if self._codex is not None: + try: + await self._codex.__aexit__(None, None, None) + except Exception: # noqa: BLE001 + pass + self._codex = None + + +def _notification_to_event(notification: Any) -> AgentEvent | None: + """Best-effort map of a codex stream notification → an AgentEvent. + + The notification/item shape is beta and not fully pinned, so we probe + common attribute names rather than isinstance-matching concrete types: + assistant text → ``text_delta``; a tool/command/MCP item → + ``tool_call_started``. Unknown notifications are dropped (the final + answer still arrives via the TurnResult in :meth:`_run_turn`). + """ + item = getattr(notification, "item", notification) + item_type = ( + getattr(item, "type", None) + or getattr(item, "item_type", None) + or getattr(notification, "type", None) + or "" + ) + item_type = str(item_type).lower() + + text = getattr(item, "text", None) or getattr(item, "content", None) + if isinstance(text, str) and text and ( + "message" in item_type or "assistant" in item_type or "text" in item_type + ): + return AgentEvent(kind="text_delta", text=text) + + if any(k in item_type for k in ("tool", "command", "mcp", "exec", "function")): + name = ( + getattr(item, "name", None) + or getattr(item, "tool_name", None) + or getattr(item, "command", None) + or item_type + ) + args = getattr(item, "arguments", None) or getattr(item, "input", None) or {} + if not isinstance(args, dict): + args = {"raw": args} + return AgentEvent( + kind="tool_call_started", + tool_name=_strip_mcp_prefix(str(name)), + tool_call_id=str(getattr(item, "id", "")), + tool_args=args, + ) + return None + + +def _strip_mcp_prefix(name: str) -> str: + prefix = f"mcp__{MCP_SERVER_NAME}__" + return name[len(prefix):] if name.startswith(prefix) else name + + +__all__ = ["CodexBackend", "MCP_SERVER_NAME", "to_mcp_config"] diff --git a/src/bcli/agent/backends/_pydantic_ai.py b/src/bcli/agent/backends/_pydantic_ai.py new file mode 100644 index 0000000..53b403f --- /dev/null +++ b/src/bcli/agent/backends/_pydantic_ai.py @@ -0,0 +1,259 @@ +"""BYOK backend — pydantic-ai in-process agent loop. + +This is the loop *bcli owns*: any Anthropic / OpenAI / local +OpenAI-compatible model (Ollama, vLLM, LM Studio, …) via pydantic-ai's +``provider:model`` strings and ``base_url`` override. Tools are the +in-process handlers from :mod:`bcli.agent.tools._impl` projected with +``Tool.from_schema`` — the describe-derived JSON schemas go to the model +verbatim. + +Key resolution mirrors :mod:`bcli.auth._credentials`: explicit env var +(``api_key_env``) → OS keychain under the existing ``bcli`` service with +the ``llm:`` namespace → the provider's default env var. + +Requires the ``[agent-local]`` extra (``pydantic-ai-slim``, pinned +``<2``). Import failures fall back to NullAgentBackend via the factory. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any + +from bcli.agent._protocol import AgentEvent + +if TYPE_CHECKING: + from bcli.agent._runtime import AgentRuntime + from bcli.agent.tools._registry import ToolRegistry + from bcli.config._model import AgentConfig + +logger = logging.getLogger("bcli.agent") + +DEFAULT_MODEL = "anthropic:claude-sonnet-4-5" + +_DEFAULT_KEY_ENVS: dict[str, str] = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", +} + +_SENTINEL: Any = object() + + +def resolve_llm_key(provider: str, api_key_env: str = "") -> str | None: + """direct env (``api_key_env``) → keyring ``llm:`` → default env. + + Mirrors the secret-resolution order in ``bcli.auth._credentials``. + """ + if api_key_env: + value = os.environ.get(api_key_env) + if value: + return value + try: + import keyring + + from bcli.auth._credentials import KEYRING_SERVICE + + value = keyring.get_password(KEYRING_SERVICE, f"llm:{provider}") + if value: + return value + except Exception: # noqa: BLE001 — keyring is best-effort + pass + default_env = _DEFAULT_KEY_ENVS.get(provider) + if default_env: + return os.environ.get(default_env) + return None + + +def store_llm_key(provider: str, key: str) -> bool: + """Persist an LLM API key in the OS keychain. Returns True on success.""" + try: + import keyring + + from bcli.auth._credentials import KEYRING_SERVICE + + keyring.set_password(KEYRING_SERVICE, f"llm:{provider}", key) + return True + except Exception: # noqa: BLE001 + return False + + +def _build_model(config: "AgentConfig") -> Any: + """Turn ``[agent] model / base_url / api_key_env`` into a model object.""" + raw = (config.model or DEFAULT_MODEL).strip() + provider, _, model_name = raw.partition(":") + if not model_name: + provider, model_name = "openai", raw # bare name → OpenAI-compatible + + key = resolve_llm_key(provider, config.api_key_env) + + if config.base_url or provider == "ollama": + from pydantic_ai.models.openai import OpenAIChatModel + from pydantic_ai.providers.openai import OpenAIProvider + + base_url = config.base_url or "http://localhost:11434/v1" + return OpenAIChatModel( + model_name, + provider=OpenAIProvider(base_url=base_url, api_key=key or "local"), + ) + + if provider == "anthropic": + from pydantic_ai.models.anthropic import AnthropicModel + from pydantic_ai.providers.anthropic import AnthropicProvider + + if not key: + raise ValueError( + "No Anthropic API key found. Set ANTHROPIC_API_KEY, run " + "'bcli agent init', or set [agent] api_key_env." + ) + return AnthropicModel(model_name, provider=AnthropicProvider(api_key=key)) + + if provider == "openai": + from pydantic_ai.models.openai import OpenAIChatModel + from pydantic_ai.providers.openai import OpenAIProvider + + if not key: + raise ValueError( + "No OpenAI API key found. Set OPENAI_API_KEY, run " + "'bcli agent init', or set [agent] api_key_env." + ) + return OpenAIChatModel(model_name, provider=OpenAIProvider(api_key=key)) + + # Other providers (groq:…, mistral:…) — let pydantic-ai resolve from + # its own env-var conventions. + return raw + + +class PydanticAIBackend: + """AgentSessionBackend over a pydantic-ai ``Agent``.""" + + is_active: bool = True + + def __init__(self, *, model: Any, max_steps: int = 20) -> None: + self._model = model + self._max_steps = max_steps + self._agent: Any = None + self._runtime: "AgentRuntime | None" = None + self._history: list[Any] | None = None + self.model_label = getattr(model, "model_name", None) or str(model) + + @classmethod + def from_config(cls, config: "AgentConfig") -> "PydanticAIBackend": + import pydantic_ai # noqa: F401 — fail fast when extra missing + + return cls(model=_build_model(config), max_steps=config.max_steps) + + # ── session ─────────────────────────────────────────────────────── + + async def start_session( + self, + *, + system_prompt: str, + tools: "ToolRegistry", + runtime: "AgentRuntime", + ) -> None: + from pydantic_ai import Agent + + from bcli.agent.tools._projections import to_pydantic_ai + + self._runtime = runtime + self._agent = Agent( + self._model, + instructions=system_prompt, + tools=to_pydantic_ai(tools, runtime, plan_mode=runtime.plan_mode), + ) + self._history = None + + async def send(self, user_msg: str) -> AsyncIterator[AgentEvent]: + if self._agent is None or self._runtime is None: + yield AgentEvent(kind="error", + error="Session not started — call start_session first.") + yield AgentEvent(kind="turn_complete") + return + + queue: asyncio.Queue[Any] = asyncio.Queue() + self._runtime.bind_emitter(queue.put) + task = asyncio.ensure_future(self._run_turn(user_msg, queue)) + try: + while True: + item = await queue.get() + if item is _SENTINEL: + break + yield item + finally: + self._runtime.bind_emitter(None) + if not task.done(): + task.cancel() + else: + task.result() # surface unexpected crashes in tests + + async def _run_turn(self, user_msg: str, queue: asyncio.Queue[Any]) -> None: + from pydantic_ai import AgentRunResultEvent + from pydantic_ai.messages import ( + FunctionToolCallEvent, + FunctionToolResultEvent, + PartDeltaEvent, + PartStartEvent, + ) + from pydantic_ai.usage import UsageLimits + + final_text = "" + try: + async with self._agent.run_stream_events( + user_msg, + message_history=self._history, + usage_limits=UsageLimits(request_limit=self._max_steps), + ) as stream: + async for ev in stream: + if isinstance(ev, PartStartEvent): + content = getattr(ev.part, "content", None) + if isinstance(content, str) and content: + await queue.put(AgentEvent(kind="text_delta", text=content)) + elif isinstance(ev, PartDeltaEvent): + delta = getattr(ev.delta, "content_delta", None) + if isinstance(delta, str) and delta: + await queue.put(AgentEvent(kind="text_delta", text=delta)) + elif isinstance(ev, FunctionToolCallEvent): + await queue.put(AgentEvent( + kind="tool_call_started", + tool_name=ev.part.tool_name, + tool_call_id=ev.part.tool_call_id, + tool_args=_args_as_dict(ev.part), + )) + elif isinstance(ev, FunctionToolResultEvent): + await queue.put(AgentEvent( + kind="tool_result", + tool_name=getattr(ev.part, "tool_name", ""), + tool_call_id=getattr(ev.part, "tool_call_id", ""), + result=getattr(ev.part, "content", None), + )) + elif isinstance(ev, AgentRunResultEvent): + self._history = ev.result.all_messages() + output = ev.result.output + final_text = output if isinstance(output, str) else str(output) + await queue.put(AgentEvent(kind="turn_complete", text=final_text)) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 — surfaced as an event + logger.debug("pydantic-ai turn failed", exc_info=True) + await queue.put(AgentEvent(kind="error", error=str(exc))) + await queue.put(AgentEvent(kind="turn_complete")) + finally: + await queue.put(_SENTINEL) + + async def close(self) -> None: + self._agent = None + self._history = None + + +def _args_as_dict(part: Any) -> dict[str, Any]: + try: + return dict(part.args_as_dict()) + except Exception: # noqa: BLE001 + args = getattr(part, "args", None) + return args if isinstance(args, dict) else {"raw": args} + + +__all__ = ["DEFAULT_MODEL", "PydanticAIBackend", "resolve_llm_key", "store_llm_key"] diff --git a/src/bcli/agent/memory/__init__.py b/src/bcli/agent/memory/__init__.py new file mode 100644 index 0000000..49a3b22 --- /dev/null +++ b/src/bcli/agent/memory/__init__.py @@ -0,0 +1,7 @@ +"""Per-profile BC.md agent memory.""" + +from __future__ import annotations + +from bcli.agent.memory._bc_md import load_bc_md, profile_bc_md_path + +__all__ = ["load_bc_md", "profile_bc_md_path"] diff --git a/src/bcli/agent/memory/_bc_md.py b/src/bcli/agent/memory/_bc_md.py new file mode 100644 index 0000000..332c48f --- /dev/null +++ b/src/bcli/agent/memory/_bc_md.py @@ -0,0 +1,66 @@ +"""Per-profile BC.md memory loader (manual-first, read-only in v1). + +Resolution order (first hit wins): + +1. Project-local ``./BC.md`` — walks up from the current directory, the + same way ``.bcli.toml`` is discovered. Lets a repo pin agent context + ("our vendors are keyed by displayName, never by number"). +2. ``~/.config/bcli/profiles//BC.md`` — the per-profile memory. + +The loaded text is injected into the system prompt after the base +instructions and before the context bundle. Size-capped so a runaway +file can't blow the prompt budget. +""" + +from __future__ import annotations + +from pathlib import Path + +from bcli.config._defaults import CONFIG_DIR + +MAX_BC_MD_BYTES = 16 * 1024 + + +def profile_bc_md_path(profile_name: str, config_dir: Path | None = None) -> Path: + base = config_dir or CONFIG_DIR + return base / "profiles" / profile_name / "BC.md" + + +def _find_project_bc_md(start: Path) -> Path | None: + current = start.resolve() + for candidate in (current, *current.parents): + path = candidate / "BC.md" + if path.is_file(): + return path + return None + + +def load_bc_md( + profile_name: str, + *, + cwd: Path | None = None, + config_dir: Path | None = None, +) -> str: + """Load BC.md memory text (empty string when none exists).""" + candidates: list[Path] = [] + project = _find_project_bc_md(cwd or Path.cwd()) + if project is not None: + candidates.append(project) + if profile_name: + candidates.append(profile_bc_md_path(profile_name, config_dir)) + + for path in candidates: + try: + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="replace") + if len(text.encode("utf-8")) > MAX_BC_MD_BYTES: + text = text.encode("utf-8")[:MAX_BC_MD_BYTES].decode( + "utf-8", errors="ignore", + ) + "\n\n[BC.md truncated at 16 KiB]" + return text + except OSError: + continue + return "" + + +__all__ = ["MAX_BC_MD_BYTES", "load_bc_md", "profile_bc_md_path"] diff --git a/src/bcli/agent/tools/__init__.py b/src/bcli/agent/tools/__init__.py new file mode 100644 index 0000000..bbc5118 --- /dev/null +++ b/src/bcli/agent/tools/__init__.py @@ -0,0 +1,7 @@ +"""Agent tool surface — single definition, three projections.""" + +from __future__ import annotations + +from bcli.agent.tools._registry import Tier, ToolRegistry, ToolSpec + +__all__ = ["Tier", "ToolRegistry", "ToolSpec"] diff --git a/src/bcli/agent/tools/_definitions.py b/src/bcli/agent/tools/_definitions.py new file mode 100644 index 0000000..a97701b --- /dev/null +++ b/src/bcli/agent/tools/_definitions.py @@ -0,0 +1,288 @@ +"""Curated tool definitions for the bcli agent. + +Single source of truth, three projections (pydantic-ai / claude-agent-sdk +in-process tools / the existing ``bcli_mcp`` server for codex). The +entries below use the exact command shape ``bcli describe --format json`` +emits — ``path`` / ``summary`` / ``positionals`` / ``options`` / +``effects`` — so :meth:`bcli.agent.tools.ToolRegistry.from_describe` +can rebuild the same registry from a live describe payload, and so the +argv-building parity tests against :mod:`bcli_mcp._tool_generator` +hold. + +The *summaries* here are the curated overlay: richer, LLM-oriented +descriptions (discovery-first guidance, field-name warnings, examples) +than the terse CLI help describe carries. ``CURATED_OVERLAY`` maps a +command path to its enriched description so the same enrichment applies +when the registry is rebuilt from a live describe payload. + +Tiers +----- + +* ``READ_PATHS`` — always allowed, no approval gate. +* ``WRITE_PATHS`` — gated by the runtime write gate + (``disable_writes`` / ``caution == high`` / production target) and + replaced by the single ``draft_batch`` tool in plan mode. + +Interactive commands (``auth login``, ``config init``) are excluded by +construction — the agent tells the human to run them. +""" + +from __future__ import annotations + +from typing import Any + +# ── tier membership (command paths) ─────────────────────────────────── + +READ_PATHS: frozenset[tuple[str, ...]] = frozenset({ + ("get",), + ("endpoint", "search"), + ("endpoint", "info"), + ("endpoint", "fields"), + ("company", "list"), + ("env", "list"), + ("describe",), +}) + +WRITE_PATHS: frozenset[tuple[str, ...]] = frozenset({ + ("post",), + ("patch",), + ("delete",), + ("action",), + ("attach", "upload"), + ("batch", "run"), +}) + +# ── curated descriptions (the overlay) ──────────────────────────────── + +CURATED_OVERLAY: dict[tuple[str, ...], str] = { + ("get",): ( + "GET records from a Business Central entity. Discovery-first: " + "if you are not certain the endpoint or a field name exists, " + "call bcli_endpoint_search / bcli_endpoint_fields first — never " + "guess field names in --filter or --select. Returns JSON " + "records. Example: endpoint='vendors', filter=\"displayName eq " + "'Acme'\", top=5." + ), + ("endpoint", "search"): ( + "Fuzzy-search available Business Central endpoints by name " + "fragment. Use this before bcli_get whenever you are unsure an " + "endpoint exists or how it is spelled. Returns matching " + "endpoint names with descriptions." + ), + ("endpoint", "info"): ( + "Structured metadata for one endpoint: supported verbs, route, " + "domain, caution level, key field, and known field names." + ), + ("endpoint", "fields"): ( + "Discover the real field names on an endpoint (from registry " + "metadata, falling back to sampling one record). Call this " + "before building a --filter or --select expression — BC field " + "names are camelCase and rarely what you would guess." + ), + ("company", "list"): ( + "List the companies (legal entities) available on the active " + "environment, including configured aliases." + ), + ("env", "list"): ( + "List the Business Central environments visible to the active " + "profile (e.g. production / sandbox)." + ), + ("describe",): ( + "Project the active bcli surface: available endpoints, profile " + "constraints (read-only? category-scoped?), and the resolved " + "target environment + company. Call this when you need to know " + "what you are allowed to do." + ), + ("post",): ( + "POST (create) a record. data is a JSON object string of the " + "new record's fields. Gated by write safety: read-only " + "profiles, high-caution endpoints, and production targets " + "require explicit operator approval — a refusal result means " + "the operator declined; do not retry." + ), + ("patch",): ( + "PATCH (update) fields on an existing record by id. data is a " + "JSON object string of only the fields to change. Same write-" + "safety gating as bcli_post." + ), + ("delete",): ( + "DELETE a record by id. Irreversible — prefer asking the " + "operator before proposing deletes. Same write-safety gating " + "as bcli_post." + ), + ("action",): ( + "Invoke an OData v4 bound action on a record (e.g. " + "Microsoft.NAV.post on a draft invoice). Bound actions often " + "post/finalize documents — treat them as high-impact writes." + ), + ("attach", "upload"): ( + "Upload a local file as a documentAttachment linked to a parent " + "record (two-phase BC upload). parent_type examples: 'Job', " + "'Purchase Invoice'." + ), + ("batch", "run"): ( + "Run a multi-step batch YAML file through the bcli batch " + "runner (dry-run first when unsure). Use for multi-record or " + "chained writes instead of many single posts." + ), +} + +# ── built-in definitions (describe-shaped) ──────────────────────────── + + +def _cmd( + path: list[str], + *, + effects: list[str], + positionals: list[dict[str, Any]] | None = None, + options: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "path": path, + "summary": CURATED_OVERLAY.get(tuple(path), ""), + "positionals": positionals or [], + "options": options or [], + "effects": effects, + "emits_result_envelope": effects == ["mutating"], + } + + +BUILTIN_DEFINITIONS: list[dict[str, Any]] = [ + _cmd( + ["get"], + effects=["read"], + positionals=[ + {"name": "endpoint", "type": "str", "required": True}, + {"name": "record_id", "type": "str", "required": False}, + ], + options=[ + {"name": "--filter", "type": "str"}, + {"name": "--select", "type": "str"}, + {"name": "--expand", "type": "str"}, + {"name": "--orderby", "type": "str"}, + {"name": "--top", "type": "int", + "limits": {"default": 50, "minimum": 1, "maximum": 1000}}, + {"name": "--skip", "type": "int", "limits": {"minimum": 0}}, + {"name": "--company", "type": "str"}, + ], + ), + _cmd( + ["endpoint", "search"], + effects=["read"], + positionals=[{"name": "pattern", "type": "str", "required": True}], + ), + _cmd( + ["endpoint", "info"], + effects=["read"], + positionals=[{"name": "name", "type": "str", "required": True}], + ), + _cmd( + ["endpoint", "fields"], + effects=["read"], + positionals=[{"name": "name", "type": "str", "required": True}], + ), + _cmd(["company", "list"], effects=["read"]), + _cmd(["env", "list"], effects=["read"]), + _cmd(["describe"], effects=["read"]), + _cmd( + ["post"], + effects=["mutating"], + positionals=[{"name": "endpoint", "type": "str", "required": True}], + options=[ + {"name": "--data", "type": "str", "required": True}, + {"name": "--company", "type": "str"}, + ], + ), + _cmd( + ["patch"], + effects=["mutating"], + positionals=[ + {"name": "endpoint", "type": "str", "required": True}, + {"name": "record_id", "type": "str", "required": True}, + ], + options=[ + {"name": "--data", "type": "str", "required": True}, + {"name": "--etag", "type": "str"}, + {"name": "--company", "type": "str"}, + ], + ), + _cmd( + ["delete"], + effects=["mutating"], + positionals=[ + {"name": "endpoint", "type": "str", "required": True}, + {"name": "record_id", "type": "str", "required": True}, + ], + options=[ + {"name": "--etag", "type": "str"}, + {"name": "--company", "type": "str"}, + ], + ), + _cmd( + ["action"], + effects=["mutating"], + positionals=[ + {"name": "endpoint", "type": "str", "required": True}, + {"name": "record_id", "type": "str", "required": True}, + {"name": "action_name", "type": "str", "required": True}, + ], + options=[ + {"name": "--data", "type": "str"}, + {"name": "--company", "type": "str"}, + ], + ), + _cmd( + ["attach", "upload"], + effects=["mutating"], + positionals=[ + {"name": "parent_type", "type": "str", "required": True}, + {"name": "parent_id", "type": "str", "required": True}, + {"name": "file_path", "type": "path", "required": True}, + ], + options=[ + {"name": "--file-name", "type": "str"}, + {"name": "--company", "type": "str"}, + ], + ), + _cmd( + ["batch", "run"], + effects=["mutating"], + positionals=[{"name": "file", "type": "path", "required": True}], + options=[ + {"name": "--dry-run", "type": "bool"}, + {"name": "--set", "type": "str"}, + ], + ), +] + + +# ── plan-mode replacement tool ──────────────────────────────────────── + +DRAFT_BATCH_TOOL: dict[str, Any] = { + "path": ["agent", "draft-batch"], + "summary": ( + "Plan mode is active: direct writes are disabled. Draft the " + "intended changes as a bcli batch YAML instead. steps is a JSON " + "array of {name, action: post|patch|delete, endpoint, " + "record_id?, data?} objects executed in order; later steps can " + "reference earlier results with ${{ steps.. }}. " + "The operator reviews the YAML and runs it through 'bcli batch " + "run' (dry-run first) — nothing is written until then." + ), + "positionals": [ + {"name": "name", "type": "str", "required": True}, + {"name": "steps", "type": "str", "required": True}, + ], + "options": [], + "effects": ["read"], # drafting writes nothing + "emits_result_envelope": False, +} + + +__all__ = [ + "BUILTIN_DEFINITIONS", + "CURATED_OVERLAY", + "DRAFT_BATCH_TOOL", + "READ_PATHS", + "WRITE_PATHS", +] diff --git a/src/bcli/agent/tools/_impl.py b/src/bcli/agent/tools/_impl.py new file mode 100644 index 0000000..1d6c8a6 --- /dev/null +++ b/src/bcli/agent/tools/_impl.py @@ -0,0 +1,485 @@ +"""In-process tool handlers — write safety is enforced HERE. + +Every handler takes an :class:`~bcli.agent._runtime.AgentRuntime` as its +first argument plus the kwargs named by the tool's input schema, and +returns a JSON-able payload the model sees. Handlers never raise to the +model loop: errors come back as ``{"status": "error", "message": …}`` so +the model can self-correct (wrong endpoint name → use the suggestion) +instead of crashing the turn. + +Write handlers run the runtime write gate *before* any HTTP call: +``disable_writes``, ``caution == "high"``, and production targets emit +``awaiting_approval`` events resolved by the REPL (or the headless +prompt). A decline returns a typed refusal. Approved writes execute +through :class:`bcli.client._safety.SafeContext` with an explicit +environment + company — never the profile-implied target. + +The ``draft_batch`` handler is the plan-mode replacement for the whole +write tier: it renders a bcli batch YAML for human review and writes +nothing. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import yaml + +from bcli.errors import BCLIError +from bcli.odata import Query + +if TYPE_CHECKING: + from bcli.agent._runtime import AgentRuntime + + +# ── helpers ─────────────────────────────────────────────────────────── + + +def _error(message: str) -> dict[str, Any]: + return {"status": "error", "message": message} + + +def _parse_json_object(data: str, *, field_name: str = "data") -> dict[str, Any]: + try: + parsed = json.loads(data) + except (json.JSONDecodeError, TypeError) as exc: + raise ValueError(f"{field_name} is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise ValueError(f"{field_name} must be a JSON object, got {type(parsed).__name__}") + return parsed + + +def _meta_summary(meta: Any) -> dict[str, Any]: + return { + "entity_set_name": meta.entity_set_name, + "description": meta.description, + "supports": list(meta.supports), + "domain": meta.domain, + "caution": meta.caution, + "key_field": meta.key_field, + "is_custom": meta.is_custom, + } + + +# ── read tier ───────────────────────────────────────────────────────── + + +async def handle_get( + runtime: "AgentRuntime", + *, + endpoint: str, + record_id: str | None = None, + filter: str | None = None, # noqa: A002 — matches the CLI flag name + select: str | None = None, + expand: str | None = None, + orderby: str | None = None, + top: int | None = None, + skip: int | None = None, + company: str | None = None, +) -> Any: + query = Query() + if filter: + query = query.filter(filter) + if select: + query = query.select(*[f.strip() for f in select.split(",") if f.strip()]) + if expand: + query = query.expand(*[f.strip() for f in expand.split(",") if f.strip()]) + if orderby: + query = query.orderby(orderby) + if top is not None: + query = query.top(int(top)) + if skip is not None: + query = query.skip(int(skip)) + + client = runtime.client + try: + company_id, _ = runtime.profile.resolve_company(company) + url = client._resolve_url_for_target( + runtime.profile.environment, company_id, endpoint, record_id=record_id, + ) + transport = client._ensure_transport() + data = await transport.get(url, params=query.to_params()) + except BCLIError as exc: + return _error(str(exc)) + except ValueError: + return _error("company='all' is not supported in agent tool calls; " + "pass one company alias at a time.") + if record_id is not None: + return data + value = data.get("value", data) if isinstance(data, dict) else data + out: dict[str, Any] = {"value": value} + if isinstance(value, list): + out["returned"] = len(value) + if isinstance(data, dict) and "@odata.count" in data: + out["total_count"] = data["@odata.count"] + return out + + +async def handle_endpoint_search( + runtime: "AgentRuntime", *, pattern: str, +) -> Any: + if runtime.registry is None: + return _error("No endpoint registry available for this session.") + matches = runtime.registry.search(pattern)[:15] + if not matches: + return {"matches": [], "hint": f"No endpoints matched '{pattern}'."} + return {"matches": [_meta_summary(m) for m in matches]} + + +async def handle_endpoint_info(runtime: "AgentRuntime", *, name: str) -> Any: + if runtime.registry is None: + return _error("No endpoint registry available for this session.") + try: + meta = runtime.registry.resolve(name) + except BCLIError as exc: + return _error(str(exc)) + info = _meta_summary(meta) + info["field_names"] = list(meta.field_names) + info["route"] = ( + {"publisher": meta.api_publisher, "group": meta.api_group, + "version": meta.api_version} + if meta.is_custom else {"standard": "api/v2.0"} + ) + return info + + +async def handle_endpoint_fields(runtime: "AgentRuntime", *, name: str) -> Any: + if runtime.registry is None: + return _error("No endpoint registry available for this session.") + try: + meta = runtime.registry.resolve(name) + except BCLIError as exc: + return _error(str(exc)) + if meta.field_names: + return {"endpoint": meta.entity_set_name, + "field_names": list(meta.field_names), + "source": "registry"} + # Fall back to sampling one record (same trick `bcli endpoint fields` + # uses) — the live record's keys are the ground truth. + sample = await handle_get(runtime, endpoint=name, top=1) + if isinstance(sample, dict) and sample.get("status") == "error": + return sample + records = sample.get("value") if isinstance(sample, dict) else None + if not records: + return _error( + f"Endpoint '{name}' has no records to sample and no captured " + "field list — field names unknown." + ) + fields = [k for k in records[0] if not k.startswith("@")] + return {"endpoint": meta.entity_set_name, "field_names": fields, + "source": "sampled_record"} + + +async def handle_company_list(runtime: "AgentRuntime") -> Any: + try: + companies = await runtime.client.list_companies() + except BCLIError as exc: + return _error(str(exc)) + aliases = { + alias: {"id": c.id, "name": c.name} + for alias, c in runtime.profile.companies.items() + } + return {"companies": companies, "aliases": aliases} + + +async def handle_env_list(runtime: "AgentRuntime") -> Any: + try: + return {"environments": await runtime.client.list_environments()} + except BCLIError as exc: + return _error(str(exc)) + + +async def handle_describe(runtime: "AgentRuntime") -> Any: + profile = runtime.profile + out: dict[str, Any] = { + "profile": runtime.profile_name, + "environment": profile.environment, + "is_production": runtime.is_production, + "company_id": profile.company_id, + "company_name": profile.company_name, + "constraints": { + "disable_writes": getattr(profile, "disable_writes", False), + "disable_standard_api": getattr(profile, "disable_standard_api", False), + "allowed_categories": list(profile.allowed_categories or []), + }, + "plan_mode": runtime.plan_mode, + } + if runtime.registry is not None: + endpoints = runtime.registry.list_all() + out["endpoint_count"] = len(endpoints) + out["endpoints"] = [ + {"name": m.entity_set_name, "caution": m.caution, "domain": m.domain} + for m in endpoints[:200] + ] + return out + + +# ── write tier (gated) ──────────────────────────────────────────────── + + +async def _gated_write( + runtime: "AgentRuntime", + *, + method: str, + endpoint: str, + payload: Any, + company: str | None, + operation, +) -> Any: + """Common gate → SafeContext → execute path for all write handlers.""" + decision = await runtime.gate_write( + method=method, endpoint=endpoint, payload=payload, + ) + if not decision.approved: + return decision.refusal() + try: + sw = runtime.safe_context(company) + return await operation(sw) + except BCLIError as exc: + return _error(str(exc)) + except ValueError: + return _error("company='all' is not supported for writes; " + "pass one company alias at a time.") + + +async def handle_post( + runtime: "AgentRuntime", + *, + endpoint: str, + data: str, + company: str | None = None, +) -> Any: + try: + body = _parse_json_object(data) + except ValueError as exc: + return _error(str(exc)) + domain = runtime.domain_for(endpoint) + return await _gated_write( + runtime, method="POST", endpoint=endpoint, payload=body, company=company, + operation=lambda sw: sw.post(endpoint, body, domain=domain), + ) + + +async def handle_patch( + runtime: "AgentRuntime", + *, + endpoint: str, + record_id: str, + data: str, + etag: str | None = None, + company: str | None = None, +) -> Any: + try: + body = _parse_json_object(data) + except ValueError as exc: + return _error(str(exc)) + domain = runtime.domain_for(endpoint) + return await _gated_write( + runtime, method="PATCH", endpoint=endpoint, payload=body, company=company, + operation=lambda sw: sw.patch( + endpoint, record_id, body, domain=domain, etag=etag or "*", + ), + ) + + +async def handle_delete( + runtime: "AgentRuntime", + *, + endpoint: str, + record_id: str, + etag: str | None = None, + company: str | None = None, +) -> Any: + domain = runtime.domain_for(endpoint) + return await _gated_write( + runtime, method="DELETE", endpoint=endpoint, + payload={"record_id": record_id}, company=company, + operation=lambda sw: sw.delete( + endpoint, record_id, domain=domain, etag=etag or "*", + ), + ) + + +async def handle_action( + runtime: "AgentRuntime", + *, + endpoint: str, + record_id: str, + action_name: str, + data: str | None = None, + company: str | None = None, +) -> Any: + body: dict[str, Any] = {} + if data: + try: + body = _parse_json_object(data) + except ValueError as exc: + return _error(str(exc)) + ns_action = action_name if "." in action_name else f"Microsoft.NAV.{action_name}" + composed = f"{endpoint}({record_id})/{ns_action}" + domain = runtime.domain_for(endpoint) + return await _gated_write( + runtime, method=f"ACTION {ns_action}", endpoint=endpoint, + payload={"record_id": record_id, "body": body}, company=company, + operation=lambda sw: sw.post(composed, body, domain=domain), + ) + + +async def handle_attach_upload( + runtime: "AgentRuntime", + *, + parent_type: str, + parent_id: str, + file_path: str, + file_name: str | None = None, + company: str | None = None, # noqa: ARG001 — upload binds to profile company +) -> Any: + decision = await runtime.gate_write( + method="ATTACH UPLOAD", endpoint="documentAttachments", + payload={"parent_type": parent_type, "parent_id": parent_id, + "file_path": file_path}, + ) + if not decision.approved: + return decision.refusal() + try: + return await runtime.client.upload_attachment( + parent_type, parent_id, file_path, file_name=file_name, + ) + except (BCLIError, OSError) as exc: + return _error(str(exc)) + + +async def handle_batch_run( + runtime: "AgentRuntime", + *, + file: str, + dry_run: bool | None = None, + set: str | None = None, # noqa: A002 — matches the CLI flag name +) -> Any: + """Run a batch YAML through the real ``bcli batch run`` CLI. + + The batch engine (ledger, step chaining, rollback) lives in the CLI + layer, which the SDK must not import — so this handler shells out to + the installed ``bcli`` binary, exactly like the MCP server does. + The agent-side approval gate runs first; ``--yes`` is passed only + *after* the human approved (or the run is a dry-run). + """ + if not dry_run: + decision = await runtime.gate_write( + method="BATCH RUN", endpoint="batch", + payload={"file": file, "set": set}, + ) + if not decision.approved: + return decision.refusal() + + import asyncio as _asyncio + import shutil + import sys as _sys + + if shutil.which("bcli"): + argv = ["bcli"] + else: + argv = [_sys.executable, "-m", "bcli_cli.app"] + if runtime.profile_name: + argv += ["--profile", runtime.profile_name] + argv += ["batch", "run", file, "--format", "json"] + if dry_run: + argv.append("--dry-run") + else: + argv.append("--yes") + if set: + argv += ["--set", set] + + proc = await _asyncio.create_subprocess_exec( + *argv, + stdout=_asyncio.subprocess.PIPE, + stderr=_asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + out_text = stdout.decode(errors="replace").strip() + err_text = stderr.decode(errors="replace").strip() + if proc.returncode != 0: + return _error( + f"bcli batch run exited {proc.returncode}: {err_text or out_text}" + ) + return {"status": "ok", "dry_run": bool(dry_run), + "output": out_text or err_text} + + +# ── plan mode ───────────────────────────────────────────────────────── + + +async def handle_draft_batch( + runtime: "AgentRuntime", *, name: str, steps: str, +) -> Any: + """Render proposed writes as a bcli batch YAML — writes nothing.""" + try: + parsed = json.loads(steps) + except (json.JSONDecodeError, TypeError) as exc: + return _error(f"steps is not valid JSON: {exc}") + if not isinstance(parsed, list) or not parsed: + return _error("steps must be a non-empty JSON array of step objects.") + + rendered_steps: list[dict[str, Any]] = [] + for i, step in enumerate(parsed): + if not isinstance(step, dict): + return _error(f"steps[{i}] must be a JSON object.") + action = step.get("action", "") + if action not in ("post", "patch", "delete"): + return _error( + f"steps[{i}].action must be one of post/patch/delete, " + f"got {action!r}." + ) + if not step.get("endpoint"): + return _error(f"steps[{i}].endpoint is required.") + entry: dict[str, Any] = { + "name": step.get("name") or f"step_{i + 1}", + "action": action, + "endpoint": step["endpoint"], + } + if step.get("record_id"): + entry["record_id"] = step["record_id"] + if step.get("data") is not None: + entry["data"] = step["data"] + rendered_steps.append(entry) + + doc = {"name": name, "steps": rendered_steps} + yaml_text = yaml.safe_dump(doc, sort_keys=False, allow_unicode=True) + return { + "status": "drafted", + "batch_yaml": yaml_text, + "next_step": ( + "Present this YAML to the operator. Nothing has been " + "written. The operator reviews and runs it with " + "'bcli batch run --dry-run' then without --dry-run." + ), + } + + +# ── dispatch ────────────────────────────────────────────────────────── + +HANDLERS: dict[tuple[str, ...], Any] = { + ("get",): handle_get, + ("endpoint", "search"): handle_endpoint_search, + ("endpoint", "info"): handle_endpoint_info, + ("endpoint", "fields"): handle_endpoint_fields, + ("company", "list"): handle_company_list, + ("env", "list"): handle_env_list, + ("describe",): handle_describe, + ("post",): handle_post, + ("patch",): handle_patch, + ("delete",): handle_delete, + ("action",): handle_action, + ("attach", "upload"): handle_attach_upload, + ("batch", "run"): handle_batch_run, + ("agent", "draft-batch"): handle_draft_batch, +} + + +def get_handler(path: tuple[str, ...]): + """Handler for a tool path; raises ``KeyError`` for unknown paths.""" + return HANDLERS[path] + + +__all__ = ["HANDLERS", "get_handler"] diff --git a/src/bcli/agent/tools/_projections.py b/src/bcli/agent/tools/_projections.py new file mode 100644 index 0000000..49f2962 --- /dev/null +++ b/src/bcli/agent/tools/_projections.py @@ -0,0 +1,104 @@ +"""Projections: one tool surface, three runtimes. + +* :func:`to_pydantic_ai` — ``pydantic_ai.tools.Tool`` objects built from + the describe-derived JSON schemas (``Tool.from_schema``), each wrapping + the same in-process handler from :mod:`bcli.agent.tools._impl`. +* :func:`to_claude_sdk_tools` — ``claude_agent_sdk.tool``-decorated + handlers for ``create_sdk_mcp_server`` (used by the claude-code + backend; same ``_impl`` handlers, same gate). +* The codex backend needs no projection: codex is an MCP client and + consumes the existing ``bcli_mcp`` server (see + ``bcli.agent.backends._codex.to_mcp_config``). + +All imports of optional SDKs are local to the projection functions so +this module imports cleanly with no extras installed. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from bcli.agent.tools._impl import get_handler + +if TYPE_CHECKING: + from bcli.agent._runtime import AgentRuntime + from bcli.agent.tools._registry import ToolRegistry, ToolSpec + +logger = logging.getLogger("bcli.agent") + + +def _wrap_handler(spec: "ToolSpec", runtime: "AgentRuntime"): + """Bind one handler to the runtime; never raise into the model loop.""" + handler = get_handler(spec.path) + + async def call(**kwargs: Any) -> Any: + try: + return await handler(runtime, **kwargs) + except Exception as exc: # noqa: BLE001 — surfaced to the model + logger.debug("tool %s failed", spec.name, exc_info=True) + return {"status": "error", "message": f"{type(exc).__name__}: {exc}"} + + call.__name__ = spec.name + call.__doc__ = spec.description + return call + + +def to_pydantic_ai( + registry: "ToolRegistry", + runtime: "AgentRuntime", + *, + plan_mode: bool = False, +) -> list[Any]: + """Project the active tool surface as pydantic-ai ``Tool`` objects.""" + from pydantic_ai.tools import Tool + + tools: list[Any] = [] + for spec in registry.specs(plan_mode=plan_mode): + tools.append(Tool.from_schema( + _wrap_handler(spec, runtime), + name=spec.name, + description=spec.description, + json_schema=spec.input_schema, + )) + return tools + + +def to_claude_sdk_tools( + registry: "ToolRegistry", + runtime: "AgentRuntime", + *, + plan_mode: bool = False, +) -> list[Any]: + """Project the active tool surface as claude-agent-sdk tools. + + Returns ``SdkMcpTool`` definitions ready for + ``create_sdk_mcp_server(name="bcli", tools=...)``. The SDK expects + handlers that take a single ``args`` dict and return an MCP-shaped + ``{"content": [{"type": "text", "text": …}]}`` payload. + """ + import json as _json + + from claude_agent_sdk import tool as sdk_tool + + tools: list[Any] = [] + for spec in registry.specs(plan_mode=plan_mode): + wrapped = _wrap_handler(spec, runtime) + + def make(inner): + async def mcp_handler(args: dict[str, Any]) -> dict[str, Any]: + result = await inner(**(args or {})) + text = ( + result if isinstance(result, str) + else _json.dumps(result, ensure_ascii=False, default=str) + ) + return {"content": [{"type": "text", "text": text}]} + return mcp_handler + + tools.append(sdk_tool( + spec.name, spec.description, spec.input_schema, + )(make(wrapped))) + return tools + + +__all__ = ["to_claude_sdk_tools", "to_pydantic_ai"] diff --git a/src/bcli/agent/tools/_registry.py b/src/bcli/agent/tools/_registry.py new file mode 100644 index 0000000..9a0237d --- /dev/null +++ b/src/bcli/agent/tools/_registry.py @@ -0,0 +1,203 @@ +"""ToolRegistry — the agent's tool surface, derived from describe shape. + +The registry is built from describe-shaped command entries (see +:mod:`bcli.agent.tools._definitions`) and classifies each tool into a +``read`` or ``write`` tier. In plan mode the write tier is replaced by +the single ``draft_batch`` tool — the model can only *propose* changes +as a reviewable batch YAML. + +Schema derivation intentionally matches +:mod:`bcli_mcp._tool_generator` (``bcli_`` names, +JSON Schema from positionals + options with limits carried through). +The MCP module cannot be imported here — the package contract is +"no imports from ``bcli_mcp`` into ``bcli``" — so the small mapping is +mirrored locally and pinned by parity tests in +``tests/test_agent/test_registry.py``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +from bcli.agent.tools._definitions import ( + BUILTIN_DEFINITIONS, + CURATED_OVERLAY, + DRAFT_BATCH_TOOL, + READ_PATHS, + WRITE_PATHS, +) + +Tier = Literal["read", "write", "plan"] + + +# describe ``type`` strings → JSON Schema primitive types +# (mirror of bcli_mcp._tool_generator._TYPE_MAP — parity-tested). +_TYPE_MAP: dict[str, str] = { + "str": "string", + "string": "string", + "int": "integer", + "integer": "integer", + "bool": "boolean", + "boolean": "boolean", + "float": "number", + "path": "string", +} + + +def _path_to_tool_name(path: list[str] | tuple[str, ...]) -> str: + """``["batch", "run"]`` → ``"bcli_batch_run"`` (mirror of bcli_mcp).""" + flat = "_".join(p.replace("-", "_") for p in path) + return f"bcli_{flat}" + + +def _flag_to_kwarg(flag: str) -> str: + """``"--file-name"`` → ``"file_name"`` (mirror of bcli_mcp).""" + return flag.lstrip("-").replace("-", "_") + + +def _option_property(opt: dict[str, Any]) -> dict[str, Any]: + json_type = _TYPE_MAP.get(opt.get("type", "string"), "string") + prop: dict[str, Any] = {"type": json_type} + limits = opt.get("limits") or {} + for key in ("default", "minimum", "maximum"): + if key in limits: + prop[key] = limits[key] + return prop + + +def _build_input_schema( + positionals: list[dict[str, Any]], options: list[dict[str, Any]], +) -> dict[str, Any]: + properties: dict[str, Any] = {} + required: list[str] = [] + for pos in positionals: + properties[pos["name"]] = { + "type": _TYPE_MAP.get(pos.get("type", "string"), "string"), + } + if pos.get("required"): + required.append(pos["name"]) + for opt in options: + kwarg = _flag_to_kwarg(opt["name"]) + properties[kwarg] = _option_property(opt) + if opt.get("required"): + required.append(kwarg) + return {"type": "object", "properties": properties, "required": required} + + +@dataclass(frozen=True) +class ToolSpec: + """One agent tool, projected from a describe-shaped command entry.""" + + name: str + description: str + path: tuple[str, ...] + tier: Tier + positionals: tuple[dict[str, Any], ...] = () + options: tuple[dict[str, Any], ...] = () + input_schema: dict[str, Any] = field(default_factory=dict) + + @property + def is_write(self) -> bool: + return self.tier == "write" + + +def _spec_from_entry(entry: dict[str, Any], tier: Tier) -> ToolSpec: + positionals = list(entry.get("positionals") or []) + options = list(entry.get("options") or []) + path = tuple(entry["path"]) + description = CURATED_OVERLAY.get(path) or entry.get("summary", "") or "" + return ToolSpec( + name=_path_to_tool_name(entry["path"]), + description=description, + path=path, + tier=tier, + positionals=tuple(positionals), + options=tuple(options), + input_schema=_build_input_schema(positionals, options), + ) + + +_DRAFT_BATCH_SPEC = ToolSpec( + name="draft_batch", + description=DRAFT_BATCH_TOOL["summary"], + path=tuple(DRAFT_BATCH_TOOL["path"]), + tier="plan", + positionals=tuple(DRAFT_BATCH_TOOL["positionals"]), + options=tuple(DRAFT_BATCH_TOOL["options"]), + input_schema=_build_input_schema( + list(DRAFT_BATCH_TOOL["positionals"]), [], + ), +) + + +class ToolRegistry: + """The agent's tool surface: read tier + gated write tier. + + Build with :meth:`default` (built-in curated definitions) or + :meth:`from_describe` (live ``bcli describe --format json`` payload, + filtered to the supported paths, curated overlay applied). + """ + + def __init__(self, specs: list[ToolSpec]) -> None: + self._specs = list(specs) + + # ── constructors ────────────────────────────────────────────────── + + @classmethod + def default(cls) -> "ToolRegistry": + specs = [ + _spec_from_entry(e, "read" if tuple(e["path"]) in READ_PATHS else "write") + for e in BUILTIN_DEFINITIONS + ] + return cls(specs) + + @classmethod + def from_describe(cls, payload: dict[str, Any]) -> "ToolRegistry": + """Build from a live describe payload. + + Only commands whose path is in the supported read/write sets are + projected; everything else (interactive commands, plumbing) is + excluded by construction. Falls back to :meth:`default` when the + payload carries no usable commands. + """ + specs: list[ToolSpec] = [] + for cmd in payload.get("commands", []): + path = tuple(cmd.get("path") or ()) + if path in READ_PATHS: + specs.append(_spec_from_entry(cmd, "read")) + elif path in WRITE_PATHS: + specs.append(_spec_from_entry(cmd, "write")) + if not specs: + return cls.default() + return cls(specs) + + # ── views ───────────────────────────────────────────────────────── + + def specs(self, *, plan_mode: bool = False) -> list[ToolSpec]: + """The active tool list. Plan mode swaps writes for draft_batch.""" + if not plan_mode: + return list(self._specs) + out = [s for s in self._specs if s.tier == "read"] + out.append(_DRAFT_BATCH_SPEC) + return out + + def read_specs(self) -> list[ToolSpec]: + return [s for s in self._specs if s.tier == "read"] + + def write_specs(self) -> list[ToolSpec]: + return [s for s in self._specs if s.tier == "write"] + + def get(self, name: str) -> ToolSpec | None: + if name == _DRAFT_BATCH_SPEC.name: + return _DRAFT_BATCH_SPEC + for s in self._specs: + if s.name == name: + return s + return None + + def tool_names(self, *, plan_mode: bool = False) -> list[str]: + return [s.name for s in self.specs(plan_mode=plan_mode)] + + +__all__ = ["Tier", "ToolRegistry", "ToolSpec"] diff --git a/src/bcli/config/_loader.py b/src/bcli/config/_loader.py index 957f9ee..d85a403 100644 --- a/src/bcli/config/_loader.py +++ b/src/bcli/config/_loader.py @@ -188,3 +188,26 @@ def save_config(config: BCConfig) -> Path: CONFIG_FILE.write_text(tomlkit.dumps(doc)) return CONFIG_FILE + + +def update_config_section(section: str, values: dict) -> Path: + """Surgically update one section of the global config file. + + Unlike :func:`save_config` (which rebuilds the whole document from a + :class:`BCConfig` and would drop sections it doesn't model into TOML, + e.g. ``[agent]`` / ``[ask]``), this loads the existing file with + tomlkit — preserving comments and unrelated sections — and only + sets the given keys. Used by the agent setup wizard and the + subscription-consent flow. + """ + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + if CONFIG_FILE.exists(): + doc = tomlkit.parse(CONFIG_FILE.read_text()) + else: + doc = tomlkit.document() + if section not in doc: + doc[section] = tomlkit.table() + for key, value in values.items(): + doc[section][key] = value + CONFIG_FILE.write_text(tomlkit.dumps(doc)) + return CONFIG_FILE diff --git a/src/bcli/config/_model.py b/src/bcli/config/_model.py index 2775357..d760214 100644 --- a/src/bcli/config/_model.py +++ b/src/bcli/config/_model.py @@ -277,6 +277,50 @@ class AskConfig(BaseModel): model_config = {"extra": "allow", "protected_namespaces": ()} +class AgentConfig(BaseModel): + """Settings for ``bcli`` agent mode — the interactive chat REPL. + + Mirrors the pluggable shape of :class:`AskConfig`. Built-in + backends: + + * ``"null"`` — no backend; the REPL errors with setup guidance. + * ``"pydantic-ai"`` — BYOK in-process loop (requires ``[agent-local]``). + ``model`` takes ``provider:model`` strings — + ``anthropic:claude-sonnet-4-5``, ``openai:gpt-5``, + or any OpenAI-compatible server via ``base_url`` + (Ollama, vLLM, …). + * ``"claude-code"`` — drive the user's installed Claude Code via the + Agent SDK (requires ``[agent-claude-code]``). + * ``"codex"`` — drive the user's installed Codex CLI (requires + ``[agent-codex]``). + + Third-party backends: + * ``"my_pkg.module:MyBackend"`` — any importable class implementing + :class:`bcli.agent.AgentSessionBackend` with a ``from_config`` + classmethod. + + ``subscription_authorized`` is the persisted consent flag for riding + a Claude / ChatGPT subscription instead of an API key. It is only + written by the explicit consent flow (literal ``yes``) and is never + set by default — see ``docs/agent.md``. API-key auth never consults it. + + ``plan_mode_default`` — ``"auto"`` turns plan mode on when the + resolved environment is production; ``"on"`` / ``"off"`` force it. + """ + + backend: str = "null" + model: str = "" # backend-specific default applied at from_config + api_key_env: str = "" # backend-specific default applied at from_config + base_url: str = "" # Ollama / OpenAI-compatible endpoint + max_steps: int = Field(default=20, ge=1, le=100) + memory: bool = True # load per-profile BC.md into the system prompt + plan_mode_default: str = "auto" # auto | on | off + subscription_authorized: bool = False + subscription_authorized_at: str = "" + + model_config = {"extra": "allow", "protected_namespaces": ()} + + class ContextConfig(BaseModel): """LLM-context layer settings — drives :mod:`bcli.context`. @@ -335,6 +379,7 @@ class BCConfig(BaseModel): extract: ExtractConfig = Field(default_factory=ExtractConfig) context: ContextConfig = Field(default_factory=ContextConfig) ask: AskConfig = Field(default_factory=AskConfig) + agent: AgentConfig = Field(default_factory=AgentConfig) etl: EtlConfig = Field(default_factory=EtlConfig) model_config = {"extra": "allow"} diff --git a/src/bcli_cli/app.py b/src/bcli_cli/app.py index e02e38b..2f86766 100644 --- a/src/bcli_cli/app.py +++ b/src/bcli_cli/app.py @@ -50,7 +50,10 @@ def _enable_debug_logging() -> None: " --profile alone is enough — environment, company, and\n" " client_id resolve from the profile. Pass -e only to [italic]override[/italic]." ), - no_args_is_help=True, + # Bare ``bcli`` on a TTY launches the agent chat REPL (handled in the + # root callback below); on a non-TTY it must still print help, so we + # turn off Typer's built-in no-args-is-help and branch ourselves. + no_args_is_help=False, rich_markup_mode="rich", ) @@ -61,8 +64,9 @@ def version_callback(value: bool) -> None: raise typer.Exit() -@app.callback() +@app.callback(invoke_without_command=True) def _root_callback( + ctx: typer.Context, profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Connection profile name"), env: Optional[str] = typer.Option(None, "--env", "-e", help="Override environment name"), company: Optional[str] = typer.Option(None, "--company", "-c", help="Override company ID"), @@ -93,6 +97,37 @@ def _root_callback( _bootstrap_telemetry() _bootstrap_context_tail() + # Bare ``bcli`` (no subcommand). On an interactive TTY this launches + # the agent chat REPL; otherwise (piped/scripted) print help and exit + # so existing non-interactive callers are unaffected. + if ctx.invoked_subcommand is None: + _launch_bare_repl_or_help(ctx, profile) + + +def _stdio_is_tty() -> bool: + """True only when both stdin and stdout are real terminals. + + The REPL needs a TTY on both ends; a pipe on either side (``echo | + bcli``, ``bcli | cat``, agents capturing output) means we fall back + to help so nothing hangs waiting on a Textual app that can't draw. + """ + try: + return sys.stdin.isatty() and sys.stdout.isatty() + except Exception: # noqa: BLE001 + return False + + +def _launch_bare_repl_or_help(ctx: typer.Context, profile: Optional[str]) -> None: + """Bare-``bcli`` dispatch: TTY → chat REPL, non-TTY → help.""" + if not _stdio_is_tty(): + typer.echo(ctx.get_help()) + raise typer.Exit() + # Lazy import: Textual + the agent engine never load for subcommands. + from bcli_cli.repl import launch_repl + + code = launch_repl(profile=profile) + raise typer.Exit(code=code or 0) + def _bootstrap_context_tail() -> None: """Enable the ``bcli.http`` rolling tail when ``[context] tail = true``. @@ -183,6 +218,7 @@ def _emit_command_summary() -> None: # Import and register command groups from bcli_cli.commands import ( # noqa: E402 action_cmd, + agent_cmd, ask_cmd, attach_cmd, auth_cmd, @@ -232,6 +268,11 @@ def _emit_command_summary() -> None: app.command(name="q", help="Run a saved query (no OData required)")(query_cmd.query_command) app.command(name="ai-context")(context_cmd.ai_context_command) app.command(name="ask", help="Ask an LLM oracle about your recent bcli context")(ask_cmd.ask_command) +app.add_typer( + agent_cmd.app, + name="agent", + help="Agent chat mode — 'agent run \"...\"' headless, 'agent init' setup (requires bc-cli\\[agent])", +) app.command(name="doctor", help="Diagnose your bcli install (self-rescue for team users)")(doctor_cmd.doctor_command) app.command( name="describe", diff --git a/src/bcli_cli/commands/agent_cmd.py b/src/bcli_cli/commands/agent_cmd.py new file mode 100644 index 0000000..638385d --- /dev/null +++ b/src/bcli_cli/commands/agent_cmd.py @@ -0,0 +1,208 @@ +"""``bcli agent`` — agent-mode commands. + +* ``bcli agent run ""`` — headless one-shot turn: stream the + answer to stdout, tool activity to stderr. Testable without a TTY; + the same engine the chat REPL uses. +* ``bcli agent init`` — (re)run the setup wizard that writes the + ``[agent]`` config section. + +The interactive chat REPL itself is launched by bare ``bcli`` on a TTY +(see ``bcli_cli.repl``). +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from typing import Optional + +import typer +from rich.console import Console + +from bcli_cli._state import state + +app = typer.Typer(help="Agent mode — chat REPL engine and setup") + +console = Console() +_stderr = Console(stderr=True) + + +def _agent_config(backend: str | None, model: str | None): + from bcli.config._model import AgentConfig + + try: + cfg = state.config.agent + except Exception: # noqa: BLE001 + cfg = AgentConfig() + updates: dict = {} + if backend: + updates["backend"] = backend + if model: + updates["model"] = model + return cfg.model_copy(update=updates) if updates else cfg + + +def resolve_plan_mode( + plan_mode_default: str, *, is_production: bool, + force_on: bool = False, force_off: bool = False, +) -> bool: + """``auto`` = on-for-production; explicit flags win.""" + if force_on: + return True + if force_off: + return False + mode = (plan_mode_default or "auto").strip().lower() + if mode == "on": + return True + if mode == "off": + return False + return is_production + + +@app.command("run") +def run_command( + prompt: str = typer.Argument(..., help="One-shot prompt for the agent"), + backend: Optional[str] = typer.Option( + None, "--backend", help="One-shot backend override " + "(pydantic-ai / claude-code / codex / module:Class)", + ), + model: Optional[str] = typer.Option( + None, "--model", help="One-shot model override (e.g. anthropic:claude-sonnet-4-5)", + ), + plan: bool = typer.Option( + False, "--plan", help="Force plan mode on (writes become draft_batch)", + ), + no_plan: bool = typer.Option( + False, "--no-plan", help="Force plan mode off", + ), + yes: bool = typer.Option( + False, "--yes", "-y", + help="Auto-approve gated writes (scripted use; be careful)", + ), +) -> None: + """Run one agent turn headlessly and print the streamed answer.""" + from bcli.agent import get_agent_backend + + cfg = _agent_config(backend, model) + session = get_agent_backend(cfg) + if not session.is_active: + from bcli.agent import NullAgentBackend + + _stderr.print(f"[yellow]{NullAgentBackend.SETUP_HINT}[/yellow]") + raise typer.Exit(code=1) + + # Consent gate for subscription-credential backends (claude-code / + # codex without an API key). API-key auth never prompts. + from bcli_cli.repl._consent import ensure_subscription_consent + + if not ensure_subscription_consent(cfg, interactive=sys.stdin.isatty()): + raise typer.Exit(code=1) + + exit_code = asyncio.run(_drive(session, cfg, prompt, plan, no_plan, yes)) + if exit_code: + raise typer.Exit(code=exit_code) + + +async def _drive(session, cfg, prompt: str, plan: bool, no_plan: bool, yes: bool) -> int: + from bcli.agent import ( + AgentRuntime, + ToolRegistry, + build_system_prompt, + load_bc_md, + ) + from bcli.context import ProfileSnapshot, build_bundle + + profile = state.profile + profile_name = state.active_profile_name + client = state.make_async_client() + + async with client: + runtime = AgentRuntime( + client=client, + profile=profile, + profile_name=profile_name, + registry=state.registry, + auto_approve=yes, + ) + runtime.plan_mode = resolve_plan_mode( + cfg.plan_mode_default, + is_production=runtime.is_production, + force_on=plan, force_off=no_plan, + ) + memory = load_bc_md(profile_name) if cfg.memory else "" + bundle = build_bundle(profile=ProfileSnapshot( + name=profile_name, + environment=profile.environment, + company=profile.company_name or "", + auth_method=profile.auth_method, + disable_writes=getattr(profile, "disable_writes", False), + )) + system_prompt = build_system_prompt( + memory_text=memory, bundle=bundle, plan_mode=runtime.plan_mode, + ) + await session.start_session( + system_prompt=system_prompt, + tools=ToolRegistry.default(), + runtime=runtime, + ) + had_error = False + streamed_any = False + try: + async for ev in session.send(prompt): + if ev.kind == "text_delta": + streamed_any = True + sys.stdout.write(ev.text) + sys.stdout.flush() + elif ev.kind == "tool_call_started": + _stderr.print( + f"[dim]→ {ev.tool_name} " + f"{json.dumps(dict(ev.tool_args), default=str)}[/dim]" + ) + elif ev.kind == "tool_result": + _stderr.print(f"[dim]← {ev.tool_name} done[/dim]") + elif ev.kind == "awaiting_approval": + approved = _prompt_approval(ev) + runtime.resolve_approval(ev.approval_id, approved) + elif ev.kind == "error": + had_error = True + _stderr.print(f"[red]Error: {ev.error}[/red]") + elif ev.kind == "turn_complete": + if not streamed_any and ev.text: + sys.stdout.write(ev.text) + sys.stdout.write("\n") + sys.stdout.flush() + finally: + await session.close() + return 1 if had_error else 0 + + +def _prompt_approval(ev) -> bool: + """Headless approval: literal ``yes`` on a TTY, deny otherwise.""" + _stderr.print( + f"[yellow]⚠ Write approval required — {ev.tool_name} " + f"{json.dumps(dict(ev.tool_args), default=str)}[/yellow]\n" + f"[yellow] Reason: {ev.reason}[/yellow]" + ) + if not sys.stdin.isatty(): + _stderr.print( + "[red]✗ Denied: non-interactive session and --yes was not " + "passed.[/red]" + ) + return False + answer = typer.prompt( + "Type 'yes' to approve, anything else to deny", + default="", show_default=False, + ) + return answer.strip().lower() == "yes" + + +@app.command("init") +def init_command() -> None: + """(Re)run the agent setup wizard — pick a backend, store the key.""" + from bcli_cli.repl._wizard import run_setup_wizard + + run_setup_wizard(force=True) + + +__all__ = ["app", "resolve_plan_mode", "run_command"] diff --git a/src/bcli_cli/repl/__init__.py b/src/bcli_cli/repl/__init__.py new file mode 100644 index 0000000..f0f381a --- /dev/null +++ b/src/bcli_cli/repl/__init__.py @@ -0,0 +1,27 @@ +"""``bcli_cli.repl`` — the interactive agent chat front-end. + +The renderer half of the engine/renderer split: :mod:`bcli.agent` +(the SDK engine) emits :class:`~bcli.agent.AgentEvent` records, and this +package consumes them. Bare ``bcli`` on a TTY lazy-imports +:func:`launch_repl` here so the agent stack never loads for ordinary +subcommands. + +Nothing in :mod:`bcli.agent` imports this package — the dependency only +ever points CLI → SDK. +""" + +from __future__ import annotations + + +def launch_repl(*, profile: str | None = None) -> int: + """Launch the Textual chat REPL. Returns a process exit code. + + Imported lazily by the bare-``bcli`` branch so Textual + the agent + engine are only loaded when a human actually opens the chat. + """ + from bcli_cli.repl._app import run_repl + + return run_repl(profile=profile) + + +__all__ = ["launch_repl"] diff --git a/src/bcli_cli/repl/_app.py b/src/bcli_cli/repl/_app.py new file mode 100644 index 0000000..f3f06da --- /dev/null +++ b/src/bcli_cli/repl/_app.py @@ -0,0 +1,402 @@ +"""The Textual chat REPL — renderer half of the engine/renderer split. + +:mod:`bcli.agent` emits :class:`~bcli.agent.AgentEvent` records; this app +consumes them and paints the chat. It owns no model logic: a backend is +built from ``[agent]`` config, a long-lived :class:`AgentRuntime` holds +the BC client, and each user turn streams events that update widgets. + +Write safety surfaces here as the only interactive seam: +``awaiting_approval`` events raise the :class:`ApprovalScreen` modal and +resolve the runtime's pending future. In plan mode the model can only +``draft_batch``; the rendered YAML is promoted through ``bcli batch +run`` via :mod:`_plan_mode`. + +Bare ``bcli`` on a TTY calls :func:`run_repl`. The first launch with no +usable ``[agent]`` backend drops into the setup wizard first. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from textual import work +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Footer, Input, Markdown + +from bcli_cli.repl._commands import help_text, parse_slash +from bcli_cli.repl._widgets import ApprovalScreen, StatusBar, ToolCallPanel + +if TYPE_CHECKING: + from bcli.agent import AgentEvent, AgentRuntime, AgentSessionBackend + + +class _TurnState: + """Per-turn flags (did we stream any text yet?).""" + + __slots__ = ("wrote",) + + def __init__(self) -> None: + self.wrote = False + + +class ChatApp(App[int]): + """Single-screen chat: scrolling transcript + input + status bar.""" + + TITLE = "bcli agent" + CSS = """ + #transcript { height: 1fr; padding: 1 0; } + #prompt { dock: bottom; margin-bottom: 1; } + .user-msg { margin: 0 2 1 2; color: $success; } + .agent-msg { margin: 0 2 1 2; } + .notice { margin: 0 2 1 2; color: $text-muted; } + .error { margin: 0 2 1 2; color: $error; } + """ + + BINDINGS = [("ctrl+c", "quit", "Quit")] + + def __init__(self, *, profile: str | None = None) -> None: + super().__init__() + self._profile_arg = profile + self._backend: "AgentSessionBackend | None" = None + self._runtime: "AgentRuntime | None" = None + self._client: Any = None + self._client_ctx: Any = None + self._busy = False + self._model_label = "" + self._profile_name = "" + self._environment = "" + self._plan_mode = False + self._pending_draft: tuple[str, str] | None = None # (name, yaml) + + # ── layout ───────────────────────────────────────────────────────── + + def compose(self) -> ComposeResult: + yield VerticalScroll(id="transcript") + yield Input(placeholder="Ask about Business Central… (/help)", id="prompt") + yield StatusBar() + yield Footer() + + async def on_mount(self) -> None: + self.query_one("#prompt", Input).focus() + await self._start_session() + + # ── session lifecycle ────────────────────────────────────────────── + + async def _start_session(self) -> None: + from bcli.agent import ( + AgentRuntime, + ToolRegistry, + build_system_prompt, + get_agent_backend, + load_bc_md, + ) + from bcli.context import ProfileSnapshot, build_bundle + from bcli_cli._state import state + from bcli_cli.commands.agent_cmd import resolve_plan_mode + + if self._profile_arg: + state.profile_name = self._profile_arg + + cfg = state.config.agent + self._backend = get_agent_backend(cfg) + if not self._backend.is_active: + from bcli.agent import NullAgentBackend + + await self._notice(NullAgentBackend.SETUP_HINT, kind="error") + return + + profile = state.profile + self._profile_name = state.active_profile_name + self._environment = profile.environment + self._model_label = getattr(self._backend, "model_label", "") or cfg.model + + self._client = state.make_async_client() + self._client_ctx = self._client + await self._client.__aenter__() + + self._runtime = AgentRuntime( + client=self._client, + profile=profile, + profile_name=self._profile_name, + registry=state.registry, + ) + self._plan_mode = resolve_plan_mode( + cfg.plan_mode_default, is_production=self._runtime.is_production, + ) + self._runtime.plan_mode = self._plan_mode + + memory = load_bc_md(self._profile_name) if cfg.memory else "" + bundle = build_bundle(profile=ProfileSnapshot( + name=self._profile_name, + environment=profile.environment, + company=profile.company_name or "", + auth_method=profile.auth_method, + disable_writes=getattr(profile, "disable_writes", False), + )) + system_prompt = build_system_prompt( + memory_text=memory, bundle=bundle, plan_mode=self._plan_mode, + ) + await self._backend.start_session( + system_prompt=system_prompt, + tools=ToolRegistry.default(), + runtime=self._runtime, + ) + self.query_one(StatusBar).update_state( + model=self._model_label, profile=self._profile_name, + environment=self._environment, plan_mode=self._plan_mode, + ) + await self._notice( + f"Connected. profile={self._profile_name} env={self._environment}" + + (" · PLAN MODE" if self._plan_mode else ""), + ) + + async def _teardown(self) -> None: + if self._backend is not None: + try: + await self._backend.close() + except Exception: # noqa: BLE001 + pass + if self._client_ctx is not None: + try: + await self._client_ctx.__aexit__(None, None, None) + except Exception: # noqa: BLE001 + pass + self._client_ctx = None + + # ── input handling ───────────────────────────────────────────────── + + async def on_input_submitted(self, event: Input.Submitted) -> None: + line = event.value.strip() + self.query_one("#prompt", Input).value = "" + if not line: + return + cmd = parse_slash(line) + if cmd is not None: + await self._handle_slash(cmd) + return + if self._busy: + await self._notice("Still working on the previous turn…") + return + if self._backend is None or not self._backend.is_active: + await self._notice("No active agent backend. Run 'bcli agent init'.", kind="error") + return + await self._add_message(line, css="user-msg", prefix="› ") + self._run_turn(line) + + async def _handle_slash(self, cmd) -> None: # noqa: ANN001 + from bcli_cli._state import state + + name = cmd.name + if name == "help": + await self._add_markdown(help_text()) + elif name == "__unknown__": + await self._notice(f"Unknown command: {cmd.arg} (/help)", kind="error") + elif name in ("exit",): + await self._teardown() + self.exit(0) + elif name == "clear": + await self.query_one("#transcript", VerticalScroll).remove_children() + if self._backend is not None: + # Reset turn history by restarting the session lazily. + await self._notice("Transcript cleared.") + elif name == "context": + await self._add_markdown(self._context_markdown()) + elif name == "plan": + self._plan_mode = not self._plan_mode + if self._runtime is not None: + self._runtime.plan_mode = self._plan_mode + self.query_one(StatusBar).update_state(plan_mode=self._plan_mode) + await self._notice( + f"Plan mode {'ON — writes become draft proposals' if self._plan_mode else 'OFF'}." + ) + elif name == "model": + if cmd.arg: + await self._notice( + f"Model switch to '{cmd.arg}' takes effect on restart; " + "set [agent] model in config to persist." + ) + else: + await self._notice(f"Current model: {self._model_label or '—'}") + elif name == "profile": + if cmd.arg: + state.profile_name = cmd.arg + await self._teardown() + await self.query_one("#transcript", VerticalScroll).remove_children() + self._profile_arg = cmd.arg + await self._start_session() + else: + await self._notice(f"Current profile: {self._profile_name or '—'}") + elif name == "company": + await self._notice( + f"Default company hint set to '{cmd.arg}'. Pass company per tool call " + "or mention it in your message." if cmd.arg + else "Specify a company alias: /company LLC" + ) + elif name == "yes": + await self._notice("No pending approval to confirm.") + else: + await self._notice(f"/{name} is not wired yet.") + + # ── turn driving (worker) ────────────────────────────────────────── + + @work(exclusive=True) + async def _run_turn(self, user_msg: str) -> None: + assert self._backend is not None and self._runtime is not None + self._busy = True + md = Markdown("") + await self.query_one("#transcript", VerticalScroll).mount(md) + md.add_class("agent-msg") + stream = Markdown.get_stream(md) + panels: dict[str, ToolCallPanel] = {} + turn = _TurnState() + try: + async for ev in self._backend.send(user_msg): + await self._on_event(ev, stream, panels, turn) + except Exception as exc: # noqa: BLE001 + await self._notice(f"Turn failed: {exc}", kind="error") + finally: + await stream.stop() + self._busy = False + self._scroll_end() + + async def _on_event(self, ev: "AgentEvent", stream, panels, turn) -> None: # noqa: ANN001 + kind = ev.kind + if kind == "text_delta": + turn.wrote = True + await stream.write(ev.text) + elif kind == "tool_call_started": + panel = ToolCallPanel(ev.tool_name, dict(ev.tool_args)) + panels[ev.tool_call_id or ev.tool_name] = panel + await self.query_one("#transcript", VerticalScroll).mount(panel) + self._scroll_end() + elif kind == "tool_result": + panel = panels.get(ev.tool_call_id or ev.tool_name) + if panel is not None: + panel.set_result(ev.result) + await self._maybe_capture_draft(ev.result) + elif kind == "awaiting_approval": + approved = await self.push_screen_wait(ApprovalScreen( + tool_name=ev.tool_name, reason=ev.reason, args=dict(ev.tool_args), + )) + if self._runtime is not None: + self._runtime.resolve_approval(ev.approval_id, bool(approved)) + elif kind == "error": + await self._notice(ev.error, kind="error") + elif kind == "turn_complete": + if ev.text and not turn.wrote: + # Backend gave a final answer without streaming deltas. + await stream.write(ev.text) + if self._pending_draft is not None: + await self._offer_plan_promotion() + + async def _maybe_capture_draft(self, result: Any) -> None: + """Stash a draft_batch result so we can offer to run it after the turn.""" + payload = result + if isinstance(result, str): + try: + import json + + payload = json.loads(result) + except Exception: # noqa: BLE001 + return + if isinstance(payload, dict) and payload.get("status") == "drafted": + self._pending_draft = (payload.get("name", "agent-plan"), + payload.get("batch_yaml", "")) + + async def _offer_plan_promotion(self) -> None: + from bcli_cli.repl._plan_mode import run_batch, write_draft + + name, yaml_text = self._pending_draft or ("", "") + self._pending_draft = None + if not yaml_text: + return + path = write_draft(yaml_text, name=name) + await self._add_markdown( + f"**Plan drafted** → `{path}`\n\n```yaml\n{yaml_text}```\n\n" + "Approve to dry-run, then run for real." + ) + approved = await self.push_screen_wait(ApprovalScreen( + tool_name="batch run (dry-run)", reason="review the drafted plan", + args={"file": str(path)}, + )) + if not approved: + await self._notice("Plan not run. The YAML is saved for manual review.") + return + ok, out = await run_batch(path, profile_name=self._profile_name, dry_run=True) + await self._notice(f"Dry-run {'ok' if ok else 'failed'}: {out[:500]}") + if not ok: + return + confirm = await self.push_screen_wait(ApprovalScreen( + tool_name="batch run", reason="execute the plan for real", + args={"file": str(path)}, + )) + if not confirm: + await self._notice("Plan not executed.") + return + ok, out = await run_batch(path, profile_name=self._profile_name, dry_run=False) + await self._notice(f"Batch run {'ok' if ok else 'failed'}: {out[:500]}", + kind="error" if not ok else "notice") + + # ── transcript helpers ───────────────────────────────────────────── + + async def _add_message(self, text: str, *, css: str, prefix: str = "") -> None: + from textual.widgets import Static + + w = Static(prefix + text) + w.add_class(css) + await self.query_one("#transcript", VerticalScroll).mount(w) + self._scroll_end() + + async def _add_markdown(self, markdown: str) -> None: + md = Markdown(markdown) + md.add_class("notice") + await self.query_one("#transcript", VerticalScroll).mount(md) + self._scroll_end() + + async def _notice(self, text: str, *, kind: str = "notice") -> None: + await self._add_message(text, css=kind) + + def _context_markdown(self) -> str: + return ( + "**Session context**\n\n" + f"- model: `{self._model_label or '—'}`\n" + f"- profile: `{self._profile_name or '—'}`\n" + f"- environment: `{self._environment or '—'}`\n" + f"- plan mode: `{'on' if self._plan_mode else 'off'}`\n" + ) + + def _scroll_end(self) -> None: + try: + self.query_one("#transcript", VerticalScroll).scroll_end(animate=False) + except Exception: # noqa: BLE001 + pass + + async def action_quit(self) -> None: + await self._teardown() + self.exit(0) + + +def run_repl(*, profile: str | None = None) -> int: + """Run the wizard if needed, then the chat app. Returns an exit code.""" + from bcli_cli._state import state + from bcli_cli.repl._wizard import has_usable_backend, run_setup_wizard + + try: + configured = has_usable_backend(state.config.agent) + except Exception: # noqa: BLE001 + configured = False + if not configured: + if not run_setup_wizard(force=False): + return 1 + # Force a config reload so the freshly-written [agent] section is + # picked up by the session about to start. + state._config = None + state._registry = None + + app = ChatApp(profile=profile) + result = app.run() + return int(result or 0) + + +__all__ = ["ChatApp", "run_repl"] diff --git a/src/bcli_cli/repl/_commands.py b/src/bcli_cli/repl/_commands.py new file mode 100644 index 0000000..d434358 --- /dev/null +++ b/src/bcli_cli/repl/_commands.py @@ -0,0 +1,73 @@ +"""Slash-command parsing for the chat REPL. + +Pure parsing + a small command table, kept separate from the Textual app +so the dispatch logic is unit-testable without a running UI. The app +calls :func:`parse_slash` on each submitted line; a non-``None`` result +is a command to handle, ``None`` means "send to the agent as a message". +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SlashCommand: + """A parsed slash command: ``name`` + the rest of the line as ``arg``.""" + + name: str + arg: str = "" + + +# name → one-line help, in display order. +COMMANDS: dict[str, str] = { + "model": "Switch the model for this session (e.g. /model anthropic:claude-opus-4-1)", + "profile": "Switch the bcli profile (re-resolves env, company, registry)", + "company": "Set the default company alias for tool calls", + "plan": "Toggle plan mode (writes become draft_batch proposals)", + "yes": "Approve the pending write (same as the dialog's Approve)", + "context": "Show the resolved profile / env / plan-mode context", + "clear": "Clear the chat transcript and start a fresh turn history", + "help": "List the slash commands", + "exit": "Leave the chat (also: /quit, Ctrl+C)", +} + +# Aliases that map onto a canonical command name. +_ALIASES: dict[str, str] = { + "quit": "exit", + "q": "exit", + "?": "help", +} + + +def parse_slash(line: str) -> SlashCommand | None: + """Parse a submitted line. Returns a :class:`SlashCommand` or ``None``. + + ``None`` means the line is an ordinary chat message. A line that + starts with ``/`` but names an unknown command parses to + ``SlashCommand("__unknown__", original)`` so the app can show a hint + rather than silently sending a stray ``/typo`` to the model. + """ + stripped = line.strip() + if not stripped.startswith("/"): + return None + body = stripped[1:].strip() + if not body: + return SlashCommand("help") + head, _, rest = body.partition(" ") + name = head.lower() + name = _ALIASES.get(name, name) + if name not in COMMANDS: + return SlashCommand("__unknown__", stripped) + return SlashCommand(name, rest.strip()) + + +def help_text() -> str: + """Markdown help block listing the commands.""" + lines = ["**Slash commands**", ""] + for name, desc in COMMANDS.items(): + lines.append(f"- `/{name}` — {desc}") + return "\n".join(lines) + + +__all__ = ["COMMANDS", "SlashCommand", "help_text", "parse_slash"] diff --git a/src/bcli_cli/repl/_consent.py b/src/bcli_cli/repl/_consent.py new file mode 100644 index 0000000..d57d3f3 --- /dev/null +++ b/src/bcli_cli/repl/_consent.py @@ -0,0 +1,134 @@ +"""Subscription-auth consent gate (claude-code / codex backends). + +Riding a personal Claude or ChatGPT subscription from a third-party app +is *individual-use* territory at both vendors: Anthropic's per-plan +Agent SDK credit explicitly covers third-party apps but is sized for one +person; Codex subscription auth uses an undocumented endpoint with 5-hour +rate windows. Teams must use API keys. + +This gate therefore fires only when: + +1. the configured backend is ``claude-code`` or ``codex``, AND +2. no API key is detectable (subscription credentials only), AND +3. consent was not already persisted. + +The user must type the literal ``yes``. Consent is persisted as +``subscription_authorized = true`` + timestamp under ``[agent]`` in the +global config via tomlkit — visible in a plain-text file and revocable +by deleting the line. API-key auth never prompts. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from rich.console import Console + +if TYPE_CHECKING: + from bcli.config._model import AgentConfig + +_stderr = Console(stderr=True) + + +_CONSENT_TEXT = { + "claude-code": ( + "The claude-code backend found no ANTHROPIC_API_KEY — it would " + "run on your personal Claude subscription (Agent SDK credit).\n\n" + " • This credit is sized for individual use. Anthropic requires " + "teams and shared deployments to use API keys.\n" + " • Your subscription's rate limits apply to everything bcli " + "does here.\n\n" + "If this is your own machine and your own subscription, you may " + "authorize it. Otherwise set ANTHROPIC_API_KEY (or run " + "'bcli agent init' and pick the API-key path)." + ), + "codex": ( + "The codex backend found no CODEX_API_KEY / OPENAI_API_KEY — it " + "would run on your ChatGPT subscription login " + "(~/.codex/auth.json).\n\n" + " • Subscription access for third-party apps uses an " + "undocumented endpoint with 5-hour rate windows; OpenAI's " + "sanctioned programmatic path is an API key.\n" + " • Your subscription's rate limits apply to everything bcli " + "does here.\n\n" + "If this is your own machine and your own subscription, you may " + "authorize it. Otherwise set CODEX_API_KEY (or run " + "'bcli agent init' and pick the API-key path)." + ), +} + + +def needs_consent(cfg: "AgentConfig") -> bool: + """True when the consent gate must run before starting a session.""" + backend = (cfg.backend or "").strip() + if backend not in _CONSENT_TEXT: + return False + if cfg.subscription_authorized: + return False + from bcli.agent._auth_detect import detect_claude_auth, detect_codex_auth + + detect = detect_claude_auth if backend == "claude-code" else detect_codex_auth + return detect() == "subscription" + + +def persist_consent() -> None: + """Record consent (flag + UTC timestamp) in the global config.""" + from bcli.config._loader import update_config_section + + update_config_section("agent", { + "subscription_authorized": True, + "subscription_authorized_at": datetime.now(timezone.utc).isoformat( + timespec="seconds", + ), + }) + + +def ensure_subscription_consent( + cfg: "AgentConfig", + *, + interactive: bool | None = None, + input_func=input, +) -> bool: + """Run the consent gate if needed. Returns True when OK to proceed. + + Non-interactive sessions can never grant consent — they get a hint + and ``False``. ``input_func`` is injectable for tests and for the + Textual wizard, which collects the answer through its own widget. + """ + if not needs_consent(cfg): + return True + + backend = (cfg.backend or "").strip() + if interactive is None: + interactive = sys.stdin.isatty() + if not interactive: + _stderr.print( + "[red]✗ Subscription authorization required for the " + f"'{backend}' backend. Run 'bcli agent init' once " + "interactively to authorize, or configure an API key.[/red]" + ) + return False + + _stderr.print(f"[yellow]{_CONSENT_TEXT[backend]}[/yellow]") + try: + answer = input_func( + "Type 'yes' to authorize subscription use (anything else cancels): " + ) + except (EOFError, KeyboardInterrupt): + return False + if answer.strip() != "yes": + _stderr.print("[red]✗ Not authorized.[/red]") + return False + + persist_consent() + _stderr.print( + "[green]✓ Authorized. Recorded in ~/.config/bcli/config.toml " + "([agent] subscription_authorized) — delete the line to " + "revoke.[/green]" + ) + return True + + +__all__ = ["ensure_subscription_consent", "needs_consent", "persist_consent"] diff --git a/src/bcli_cli/repl/_plan_mode.py b/src/bcli_cli/repl/_plan_mode.py new file mode 100644 index 0000000..aa2c9ef --- /dev/null +++ b/src/bcli_cli/repl/_plan_mode.py @@ -0,0 +1,69 @@ +"""Plan-mode promotion: a drafted batch YAML → reviewed → run. + +When plan mode is active the agent's write tier is replaced by the +``draft_batch`` tool, which renders a bcli batch YAML (writing nothing). +This module turns that drafted YAML into a file the operator can review +and then promotes it through the *real* gated path — ``bcli batch run`` +— exactly as ``bcli extract`` does. No write ever bypasses the batch +runner's own safety (disable_writes, production confirm, audit log). + +The functions here are deliberately UI-free so they unit-test without +Textual: the app calls :func:`write_draft` then, on confirm, +:func:`run_batch`. +""" + +from __future__ import annotations + +import asyncio +import shutil +import sys +import tempfile +from pathlib import Path + + +def write_draft(batch_yaml: str, *, name: str = "agent-plan") -> Path: + """Persist a drafted batch YAML to a temp file for review. Returns path.""" + safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in name) or "plan" + fd, path_str = tempfile.mkstemp(prefix=f"bcli-{safe}-", suffix=".batch.yaml") + path = Path(path_str) + with open(fd, "w", encoding="utf-8") as f: + f.write(batch_yaml) + return path + + +async def run_batch( + path: Path, + *, + profile_name: str = "", + dry_run: bool = False, +) -> tuple[bool, str]: + """Run a batch YAML through the real ``bcli batch run`` CLI. + + Returns ``(ok, output)``. ``--yes`` is only passed for real runs + (the human already confirmed in the REPL); dry-runs never mutate. + The batch runner re-applies its own disable_writes / production + gating, so this is defense-in-depth, not a bypass. + """ + if shutil.which("bcli"): + argv = ["bcli"] + else: + argv = [sys.executable, "-m", "bcli_cli.app"] + if profile_name: + argv += ["--profile", profile_name] + argv += ["batch", "run", str(path), "--format", "json"] + argv.append("--dry-run" if dry_run else "--yes") + + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + out = stdout.decode(errors="replace").strip() + err = stderr.decode(errors="replace").strip() + if proc.returncode != 0: + return False, err or out or f"batch run exited {proc.returncode}" + return True, out or err + + +__all__ = ["run_batch", "write_draft"] diff --git a/src/bcli_cli/repl/_widgets.py b/src/bcli_cli/repl/_widgets.py new file mode 100644 index 0000000..c229a83 --- /dev/null +++ b/src/bcli_cli/repl/_widgets.py @@ -0,0 +1,172 @@ +"""Textual widgets for the agent chat REPL. + +* :class:`StatusBar` — model / profile / env / plan-mode indicator. +* :class:`ToolCallPanel` — a collapsible card showing one tool call and + its result, updated in place as events arrive. +* :class:`ApprovalScreen` — the modal write-approval dialog; resolves an + :class:`asyncio.Future` so the agent runtime's gate can continue. + +These render :class:`~bcli.agent.AgentEvent` data; they hold no engine +logic. The app (:mod:`bcli_cli.repl._app`) owns the event loop and feeds +them. +""" + +from __future__ import annotations + +import json +from typing import Any + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Label, Static + + +class StatusBar(Static): + """One-line indicator of the active backend / profile / env / plan mode.""" + + DEFAULT_CSS = """ + StatusBar { + dock: bottom; + height: 1; + background: $panel; + color: $text-muted; + padding: 0 1; + } + """ + + def __init__( + self, + *, + model: str = "", + profile: str = "", + environment: str = "", + plan_mode: bool = False, + ) -> None: + self._model = model + self._profile = profile + self._environment = environment + self._plan_mode = plan_mode + super().__init__(self._compose_text()) + + def update_state( + self, + *, + model: str | None = None, + profile: str | None = None, + environment: str | None = None, + plan_mode: bool | None = None, + ) -> None: + if model is not None: + self._model = model + if profile is not None: + self._profile = profile + if environment is not None: + self._environment = environment + if plan_mode is not None: + self._plan_mode = plan_mode + self.update(self._compose_text()) + + def _compose_text(self) -> str: + parts = [ + f"model: {self._model or '—'}", + f"profile: {self._profile or '—'}", + f"env: {self._environment or '—'}", + ] + if self._plan_mode: + parts.append("[bold yellow]PLAN MODE[/bold yellow]") + return " · ".join(parts) + " (Ctrl+C to quit, /help for commands)" + + +class ToolCallPanel(Static): + """A card for one tool call: name + args, then its result.""" + + DEFAULT_CSS = """ + ToolCallPanel { + border: round $accent; + margin: 0 2 1 2; + padding: 0 1; + color: $text-muted; + } + """ + + def __init__(self, tool_name: str, tool_args: dict[str, Any]) -> None: + self._tool_name = tool_name + self._args = dict(tool_args or {}) + self._result_text = "" + self._done = False + super().__init__(self._renderable()) + + def set_result(self, result: Any) -> None: + self._result_text = _short_json(result) + self._done = True + self.update(self._renderable()) + + def _renderable(self) -> str: + marker = "✓" if self._done else "→" + args = _short_json(self._args) if self._args else "" + head = f"[bold]{marker} {self._tool_name}[/bold]" + if args: + head += f" [dim]{args}[/dim]" + body = f"\n[dim]{self._result_text}[/dim]" if self._result_text else "" + return head + body + + +class ApprovalScreen(ModalScreen[bool]): + """Modal write-approval dialog. Dismisses with True (approve) / False.""" + + DEFAULT_CSS = """ + ApprovalScreen { + align: center middle; + } + #approval-box { + width: 70%; + max-width: 90; + height: auto; + border: thick $warning; + background: $surface; + padding: 1 2; + } + #approval-title { text-style: bold; color: $warning; } + #approval-buttons { height: auto; align: center middle; padding-top: 1; } + Button { margin: 0 1; } + """ + + def __init__(self, *, tool_name: str, reason: str, args: dict[str, Any]) -> None: + super().__init__() + self._tool_name = tool_name + self._reason = reason + self._args = dict(args or {}) + + def compose(self) -> ComposeResult: + with Vertical(id="approval-box"): + yield Label("Write approval required", id="approval-title") + yield Static(f"\nTool: [bold]{self._tool_name}[/bold]") + yield Static(f"Reason: {self._reason}") + if self._args: + yield Static(f"\n[dim]{_short_json(self._args, limit=400)}[/dim]") + with Horizontal(id="approval-buttons"): + yield Button("Approve (y)", variant="success", id="approve") + yield Button("Decline (n)", variant="error", id="decline") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "approve") + + def on_key(self, event) -> None: # noqa: ANN001 + if event.key in ("y", "Y"): + self.dismiss(True) + elif event.key in ("n", "N", "escape"): + self.dismiss(False) + + +def _short_json(value: Any, *, limit: int = 200) -> str: + try: + text = value if isinstance(value, str) else json.dumps(value, default=str) + except Exception: # noqa: BLE001 + text = str(value) + if len(text) > limit: + return text[:limit] + "…" + return text + + +__all__ = ["ApprovalScreen", "StatusBar", "ToolCallPanel"] diff --git a/src/bcli_cli/repl/_wizard.py b/src/bcli_cli/repl/_wizard.py new file mode 100644 index 0000000..b1850d1 --- /dev/null +++ b/src/bcli_cli/repl/_wizard.py @@ -0,0 +1,285 @@ +"""First-run setup wizard for ``bcli`` agent mode. + +Reachable two ways: + +* automatically, the first time bare ``bcli`` is launched on a TTY with + no usable ``[agent]`` backend configured; +* explicitly, via ``bcli agent init``. + +The wizard detects which backends are available on this machine +(installed ``claude`` / ``codex`` binaries, subscription logins, API +keys in the environment), lets the operator pick one, stores any API key +in the OS keychain, and writes the ``[agent]`` section to the global +config with tomlkit (preserving comments + unrelated sections). + +The interactive shell is intentionally plain rich prompts, not Textual: +it must work the same whether reached from ``bcli agent init`` in a bare +terminal or from the REPL's startup path. The pure decision logic +(:func:`detect_backends`, :func:`build_agent_section`) is split out so it +is unit-testable without a TTY. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from rich.console import Console + +if TYPE_CHECKING: + from bcli.config._model import AgentConfig + +console = Console() +_stderr = Console(stderr=True) + + +# ── backend options ──────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class BackendOption: + """One pickable backend in the wizard.""" + + key: str # menu key the user types + backend: str # value written to [agent] backend + label: str # human description + needs_key: bool = False # prompt for + store an API key + key_provider: str = "" # keyring namespace (llm:) + default_model: str = "" # written to [agent] model + subscription: bool = False # consent gate applies + available: bool = True # detected on this machine + note: str = "" # extra guidance shown in the menu + extra_hint: str = "" # pip extra to install when missing + + +def detect_backends() -> list[BackendOption]: + """Enumerate the backend choices, flagged with what's available here. + + Pure detection (PATH + env + ``~/.codex`` / ``~/.claude`` probes via + :mod:`bcli.agent._auth_detect`); no heavy SDK imports. + """ + from bcli.agent._auth_detect import ( + claude_code_available, + codex_available, + detect_claude_auth, + detect_codex_auth, + ) + + claude_auth = detect_claude_auth() + codex_auth = detect_codex_auth() + + return [ + BackendOption( + key="1", + backend="pydantic-ai", + label="Anthropic API key (Claude) — recommended for teams", + needs_key=True, + key_provider="anthropic", + default_model="anthropic:claude-sonnet-4-5", + extra_hint="bc-cli[agent]", + ), + BackendOption( + key="2", + backend="pydantic-ai", + label="OpenAI API key (GPT)", + needs_key=True, + key_provider="openai", + default_model="openai:gpt-5", + extra_hint="bc-cli[agent]", + ), + BackendOption( + key="3", + backend="pydantic-ai", + label="Local model (Ollama / OpenAI-compatible) — no API key", + default_model="ollama:llama3.1", + note="uses base_url, defaults to http://localhost:11434/v1", + extra_hint="bc-cli[agent]", + ), + BackendOption( + key="4", + backend="claude-code", + label="Installed Claude Code CLI", + subscription=(claude_auth == "subscription"), + available=claude_code_available(), + note=("subscription login detected — consent required" + if claude_auth == "subscription" + else "uses ANTHROPIC_API_KEY" + if claude_auth == "api_key" else "not signed in"), + extra_hint="bc-cli[agent-claude-code]", + ), + BackendOption( + key="5", + backend="codex", + label="Installed Codex CLI", + subscription=(codex_auth == "subscription"), + available=codex_available(), + note=("subscription login detected — consent required" + if codex_auth == "subscription" + else "uses CODEX_API_KEY / OPENAI_API_KEY" + if codex_auth == "api_key" else "not signed in"), + extra_hint="bc-cli[agent-codex]", + ), + ] + + +# ── config assembly (pure) ───────────────────────────────────────────── + + +@dataclass +class WizardResult: + """Outcome of the wizard, ready to persist.""" + + agent_section: dict = field(default_factory=dict) + stored_key_provider: str = "" + chosen: BackendOption | None = None + + +def build_agent_section( + option: BackendOption, *, base_url: str = "", model: str = "", +) -> dict: + """Build the ``[agent]`` config dict for a chosen backend option.""" + section: dict = {"backend": option.backend} + resolved_model = (model or option.default_model).strip() + if resolved_model: + section["model"] = resolved_model + if base_url: + section["base_url"] = base_url + return section + + +def has_usable_backend(cfg: "AgentConfig | None") -> bool: + """True when ``[agent]`` names a non-null backend (wizard not needed).""" + if cfg is None: + return False + backend = (cfg.backend or "").strip().lower() + return bool(backend) and backend != "null" + + +# ── interactive flow ─────────────────────────────────────────────────── + + +def run_setup_wizard(*, force: bool = False, input_func=None) -> bool: + """Run the interactive wizard. Returns True when an agent is configured. + + ``force`` re-runs even when a backend is already set (``bcli agent + init``). ``input_func`` is injectable for tests; defaults to rich + prompts. + """ + from bcli.config._loader import update_config_section + from bcli_cli._state import state + + if not force: + try: + if has_usable_backend(state.config.agent): + return True + except Exception: # noqa: BLE001 + pass + + options = detect_backends() + _print_menu(options) + + choice = _ask(input_func, "Pick a backend [1-5]", default="1") + option = next((o for o in options if o.key == choice.strip()), None) + if option is None: + _stderr.print("[red]Unknown choice — aborting setup.[/red]") + return False + + if not option.available: + _stderr.print( + f"[red]{option.label} is not available on this machine " + f"(install it, then re-run 'bcli agent init').[/red]" + ) + return False + + base_url = "" + model = "" + if option.backend == "pydantic-ai" and not option.needs_key: + # Local / OpenAI-compatible. + base_url = _ask( + input_func, "Base URL", default="http://localhost:11434/v1", + ).strip() + model = _ask( + input_func, "Model name", default=option.default_model, + ).strip() + + if option.needs_key: + from bcli.agent.backends._pydantic_ai import store_llm_key + + key = _ask( + input_func, + f"{option.key_provider.title()} API key " + "(stored in your OS keychain)", + password=True, + ).strip() + if key: + if store_llm_key(option.key_provider, key): + console.print( + f"[green]Stored {option.key_provider} key in the OS " + "keychain.[/green]" + ) + else: + _stderr.print( + "[yellow]Could not reach the OS keychain. Set the key " + f"in the environment instead " + f"({_env_for(option.key_provider)}).[/yellow]" + ) + + section = build_agent_section(option, base_url=base_url, model=model) + update_config_section("agent", section) + console.print( + f"[green]Configured [agent] backend = '{option.backend}'" + + (f" model = '{section['model']}'" if section.get("model") else "") + + ".[/green]" + ) + + # Subscription consent (claude-code / codex on a subscription login). + if option.subscription: + from bcli.config._model import AgentConfig + from bcli_cli.repl._consent import ensure_subscription_consent + + cfg = AgentConfig(backend=option.backend) + if not ensure_subscription_consent( + cfg, interactive=True, + input_func=(input_func or input), + ): + return False + + return True + + +def _env_for(provider: str) -> str: + return { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + }.get(provider, f"{provider.upper()}_API_KEY") + + +def _print_menu(options: list[BackendOption]) -> None: + console.print("\n[bold]Set up bcli agent mode[/bold]") + console.print( + "Pick how the agent talks to an LLM. You can change this later " + "in ~/.config/bcli/config.toml or with 'bcli agent init'.\n" + ) + for o in options: + status = "" if o.available else " [dim](not installed)[/dim]" + note = f" [dim]— {o.note}[/dim]" if o.note else "" + console.print(f" [cyan]{o.key}[/cyan]. {o.label}{status}{note}") + console.print("") + + +def _ask(input_func, prompt: str, *, default: str = "", password: bool = False) -> str: + if input_func is not None: + return input_func(prompt) + from rich.prompt import Prompt + + return Prompt.ask(prompt, default=default or None, password=password) or default + + +__all__ = [ + "BackendOption", + "WizardResult", + "build_agent_section", + "detect_backends", + "has_usable_backend", + "run_setup_wizard", +] diff --git a/tests/test_agent/__init__.py b/tests/test_agent/__init__.py new file mode 100644 index 0000000..9923c7f --- /dev/null +++ b/tests/test_agent/__init__.py @@ -0,0 +1 @@ +"""Tests for the bcli agent engine (Part 4 of the roadmap).""" diff --git a/tests/test_agent/_helpers.py b/tests/test_agent/_helpers.py new file mode 100644 index 0000000..6295a13 --- /dev/null +++ b/tests/test_agent/_helpers.py @@ -0,0 +1,95 @@ +"""Shared fakes for the agent engine tests — no network, no real client. + +Imported as a sibling module (the test dir has no ``__init__.py``, so +pytest's default prepend import mode puts it on ``sys.path``). +""" + +from __future__ import annotations + +from typing import Any + +from bcli.agent._runtime import AgentRuntime + + +class FakeProfile: + """Minimal stand-in for :class:`bcli.config._model.BCProfile`.""" + + def __init__( + self, + *, + environment: str = "sandbox", + disable_writes: bool = False, + company_id: str = "company-1", + company_name: str = "Test Co", + auth_method: str = "client_credentials", + ) -> None: + self.environment = environment + self.disable_writes = disable_writes + self.company_id = company_id + self.company_name = company_name + self.auth_method = auth_method + self.companies: dict[str, Any] = {} + self.allowed_categories: list[str] = [] + self.disable_standard_api = False + + def resolve_company(self, alias_or_id: str | None = None): + if alias_or_id and alias_or_id.lower() == "all": + raise ValueError("all") + return self.company_id, self.company_name + + +class FakeMeta: + """Stand-in for an EndpointMetadata record.""" + + def __init__(self, name: str, *, caution: str = "low", domain: str = "standard"): + self.entity_set_name = name + self.description = f"{name} endpoint" + self.supports = ["GET"] + self.domain = domain + self.caution = caution + self.key_field = "id" + self.is_custom = False + self.field_names: list[str] = [] + self.api_publisher = None + self.api_group = None + self.api_version = None + + +class FakeRegistry: + """Stand-in registry returning canned metadata.""" + + def __init__(self, metas: dict[str, FakeMeta] | None = None): + self._metas = metas or {} + + def get(self, name: str) -> FakeMeta | None: + return self._metas.get(name) + + def resolve(self, name: str) -> FakeMeta: + if name in self._metas: + return self._metas[name] + from bcli.errors import RegistryError + + raise RegistryError(f"Unknown endpoint '{name}'.") + + def search(self, pattern: str) -> list[FakeMeta]: + return [m for n, m in self._metas.items() if pattern.lower() in n.lower()] + + def list_all(self, **_kw) -> list[FakeMeta]: + return list(self._metas.values()) + + +def make_runtime( + *, + profile: FakeProfile | None = None, + registry: FakeRegistry | None = None, + plan_mode: bool = False, + auto_approve: bool = False, +) -> AgentRuntime: + return AgentRuntime( + client=None, # type: ignore[arg-type] — handlers under test never hit it + profile=profile or FakeProfile(), # type: ignore[arg-type] + profile_name="test", + registry=registry, # type: ignore[arg-type] + plan_mode=plan_mode, + auto_approve=auto_approve, + ) diff --git a/tests/test_agent/conftest.py b/tests/test_agent/conftest.py new file mode 100644 index 0000000..1de5f66 --- /dev/null +++ b/tests/test_agent/conftest.py @@ -0,0 +1,29 @@ +"""Fixtures wrapping the shared fakes in :mod:`_helpers`. + +The test dir has no ``__init__.py``; conftest runs before pytest adds the +dir to ``sys.path``, so add it here first, then import the sibling. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +import pytest # noqa: E402 + +from _helpers import FakeMeta, FakeProfile, FakeRegistry # noqa: E402 + + +@pytest.fixture +def fake_profile() -> FakeProfile: + return FakeProfile() + + +@pytest.fixture +def fake_registry() -> FakeRegistry: + return FakeRegistry({ + "vendors": FakeMeta("vendors", caution="low"), + "journalLines": FakeMeta("journalLines", caution="high", domain="finance"), + }) diff --git a/tests/test_agent/test_claude_sdk_backend.py b/tests/test_agent/test_claude_sdk_backend.py new file mode 100644 index 0000000..0b1e565 --- /dev/null +++ b/tests/test_agent/test_claude_sdk_backend.py @@ -0,0 +1,220 @@ +"""Claude Code backend — mocked claude-agent-sdk (package not installed). + +A fake ``claude_agent_sdk`` module is injected into ``sys.modules`` so the +backend's lazy imports resolve to controllable stand-ins. Asserts the +AgentEvent translation (TextBlock → text_delta, ToolUseBlock → +tool_call_started, ResultMessage → turn_complete), the can_use_tool fence +(allow bcli MCP tools, deny others), and the streaming-mode dummy hook. +""" + +from __future__ import annotations + +import sys +import types +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from _helpers import make_runtime + +from bcli.agent import ToolRegistry + + +# ── fake claude_agent_sdk ────────────────────────────────────────────── + + +@dataclass +class _TextBlock: + text: str + + +@dataclass +class _ToolUseBlock: + name: str + id: str + input: dict + + +@dataclass +class _ToolResultBlock: + tool_use_id: str + content: Any + + +@dataclass +class _AssistantMessage: + content: list + + +@dataclass +class _ResultMessage: + result: str = "" + subtype: str = "success" + + +@dataclass +class _PermissionResultAllow: + updated_input: dict | None = None + behavior: str = "allow" + + +@dataclass +class _PermissionResultDeny: + message: str = "" + behavior: str = "deny" + + +@dataclass +class _HookMatcher: + hooks: list = field(default_factory=list) + matcher: Any = None + + +@dataclass +class _ClaudeAgentOptions: + system_prompt: str = "" + mcp_servers: dict = field(default_factory=dict) + allowed_tools: list = field(default_factory=list) + can_use_tool: Any = None + hooks: dict = field(default_factory=dict) + max_turns: int = 20 + model: str = "" + + +class _FakeClient: + """Replays a scripted message list from receive_response().""" + + SCRIPT: list = [] + + def __init__(self, *, options) -> None: + self.options = options + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def query(self, prompt): + # Drain the async-iterable prompt to mimic the SDK's streaming mode. + if hasattr(prompt, "__aiter__"): + async for _ in prompt: + pass + + async def receive_response(self): + for msg in type(self).SCRIPT: + yield msg + + +def _tool_decorator(name, description, input_schema, annotations=None): + def wrap(fn): + fn._tool_name = name + return fn + return wrap + + +def _create_sdk_mcp_server(*, name, version, tools): + return {"name": name, "version": version, "tools": tools} + + +@pytest.fixture +def fake_sdk(monkeypatch): + mod = types.ModuleType("claude_agent_sdk") + mod.ClaudeSDKClient = _FakeClient + mod.ClaudeAgentOptions = _ClaudeAgentOptions + mod.HookMatcher = _HookMatcher + mod.create_sdk_mcp_server = _create_sdk_mcp_server + mod.tool = _tool_decorator + mod.AssistantMessage = _AssistantMessage + mod.ResultMessage = _ResultMessage + mod.TextBlock = _TextBlock + mod.ToolUseBlock = _ToolUseBlock + mod.ToolResultBlock = _ToolResultBlock + mod.PermissionResultAllow = _PermissionResultAllow + mod.PermissionResultDeny = _PermissionResultDeny + monkeypatch.setitem(sys.modules, "claude_agent_sdk", mod) + yield mod + + +# ── tests ────────────────────────────────────────────────────────────── + + +def test_factory_builds_claude_backend(fake_sdk) -> None: + from bcli.agent import get_agent_backend + from bcli.config._model import AgentConfig + + backend = get_agent_backend(AgentConfig(backend="claude-code", model="claude-x")) + from bcli.agent.backends._claude_sdk import ClaudeCodeBackend + + assert isinstance(backend, ClaudeCodeBackend) + assert backend.is_active is True + + +async def test_event_translation(fake_sdk) -> None: + from bcli.agent.backends._claude_sdk import ClaudeCodeBackend + + _FakeClient.SCRIPT = [ + _AssistantMessage(content=[_TextBlock(text="There are ")]), + _AssistantMessage(content=[ + _ToolUseBlock(name="mcp__bcli__bcli_get", id="t1", + input={"endpoint": "vendors"}), + ]), + _AssistantMessage(content=[_TextBlock(text="42 vendors.")]), + _ResultMessage(result="There are 42 vendors."), + ] + backend = ClaudeCodeBackend(model="claude-x", max_turns=5) + runtime = make_runtime() + await backend.start_session(system_prompt="s", tools=ToolRegistry.default(), runtime=runtime) + events = [ev async for ev in backend.send("how many vendors?")] + await backend.close() + + kinds = [e.kind for e in events] + assert "text_delta" in kinds + assert "tool_call_started" in kinds + assert kinds[-1] == "turn_complete" + started = next(e for e in events if e.kind == "tool_call_started") + # MCP prefix stripped for display. + assert started.tool_name == "bcli_get" + assert events[-1].text == "There are 42 vendors." + + +async def test_can_use_tool_fence(fake_sdk) -> None: + from bcli.agent.backends._claude_sdk import ClaudeCodeBackend + + backend = ClaudeCodeBackend(max_turns=5) + runtime = make_runtime() + await backend.start_session(system_prompt="s", tools=ToolRegistry.default(), runtime=runtime) + options = backend._build_options() + + can_use = options.can_use_tool + allow = await can_use("mcp__bcli__bcli_get", {"endpoint": "vendors"}, None) + assert allow.behavior == "allow" + deny = await can_use("Bash", {"command": "rm -rf /"}, None) + assert deny.behavior == "deny" + + +async def test_options_have_dummy_pre_tool_hook(fake_sdk) -> None: + from bcli.agent.backends._claude_sdk import ClaudeCodeBackend + + backend = ClaudeCodeBackend(max_turns=5) + runtime = make_runtime() + await backend.start_session(system_prompt="s", tools=ToolRegistry.default(), runtime=runtime) + options = backend._build_options() + + assert "PreToolUse" in options.hooks + hook = options.hooks["PreToolUse"][0].hooks[0] + result = await hook({}, "tid", None) + assert result == {"continue_": True} + + +async def test_allowed_tools_are_only_bcli(fake_sdk) -> None: + from bcli.agent.backends._claude_sdk import ClaudeCodeBackend + + backend = ClaudeCodeBackend(max_turns=5) + runtime = make_runtime() + await backend.start_session(system_prompt="s", tools=ToolRegistry.default(), runtime=runtime) + options = backend._build_options() + + assert all(t.startswith("mcp__bcli__") for t in options.allowed_tools) + assert "mcp__bcli__bcli_get" in options.allowed_tools diff --git a/tests/test_agent/test_cli_run.py b/tests/test_agent/test_cli_run.py new file mode 100644 index 0000000..42d40c2 --- /dev/null +++ b/tests/test_agent/test_cli_run.py @@ -0,0 +1,40 @@ +"""Headless ``bcli agent run`` + plan-mode resolution.""" + +from __future__ import annotations + +from typer.testing import CliRunner + +from bcli_cli.app import app +from bcli_cli.commands.agent_cmd import resolve_plan_mode + +runner = CliRunner() + + +def test_plan_mode_auto_on_for_production() -> None: + assert resolve_plan_mode("auto", is_production=True) is True + assert resolve_plan_mode("auto", is_production=False) is False + + +def test_plan_mode_explicit_flags_win() -> None: + assert resolve_plan_mode("off", is_production=True, force_on=True) is True + assert resolve_plan_mode("on", is_production=False, force_off=True) is False + + +def test_plan_mode_on_off_strings() -> None: + assert resolve_plan_mode("on", is_production=False) is True + assert resolve_plan_mode("off", is_production=True) is False + + +def test_agent_run_with_null_backend_exits_nonzero() -> None: + """No backend configured → setup hint on stderr, exit 1.""" + result = runner.invoke(app, ["agent", "run", "hello"]) + assert result.exit_code == 1 + # NullAgentBackend setup hint is printed (combined stdout+stderr). + assert "backend" in result.output.lower() + + +def test_agent_subcommands_registered() -> None: + result = runner.invoke(app, ["agent", "--help"]) + assert result.exit_code == 0 + assert "run" in result.output + assert "init" in result.output diff --git a/tests/test_agent/test_codex_backend.py b/tests/test_agent/test_codex_backend.py new file mode 100644 index 0000000..4d8cb7c --- /dev/null +++ b/tests/test_agent/test_codex_backend.py @@ -0,0 +1,174 @@ +"""Codex backend — mocked openai-codex (package not installed). + +A fake ``openai_codex`` module is injected into ``sys.modules``. Asserts +the to_mcp_config registration of bcli_mcp, the notification → AgentEvent +mapping, approval-mode escalation under production/plan mode, and the +TurnResult → turn_complete final answer. +""" + +from __future__ import annotations + +import sys +import types +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from _helpers import FakeProfile, make_runtime + +from bcli.agent import ToolRegistry + + +# ── fake openai_codex ────────────────────────────────────────────────── + + +class _ApprovalMode: + auto_review = "auto_review" + on_request = "on_request" + + +@dataclass +class _Item: + type: str = "" + text: str = "" + name: str = "" + input: dict = field(default_factory=dict) + id: str = "" + + +@dataclass +class _Notification: + item: Any + + +@dataclass +class _TurnResult: + final_response: str = "" + items: list = field(default_factory=list) + status: str = "completed" + + +class _TurnHandle: + NOTIFICATIONS: list = [] + RESULT = _TurnResult(final_response="done") + + async def stream(self): + for n in type(self).NOTIFICATIONS: + yield n + + async def run(self): + return type(self).RESULT + + +class _Thread: + LAST_KWARGS: dict = {} + + async def turn(self, user_msg): + type(self).LAST_INPUT = user_msg + return _TurnHandle() + + +class _AsyncCodex: + LAST_START_KWARGS: dict = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def thread_start(self, **kwargs): + type(self).LAST_START_KWARGS = kwargs + return _Thread() + + +@pytest.fixture +def fake_codex(monkeypatch): + mod = types.ModuleType("openai_codex") + mod.AsyncCodex = _AsyncCodex + mod.ApprovalMode = _ApprovalMode + monkeypatch.setitem(sys.modules, "openai_codex", mod) + yield mod + + +# ── tests ────────────────────────────────────────────────────────────── + + +def test_to_mcp_config_registers_bcli_server() -> None: + from bcli.agent.backends._codex import MCP_SERVER_NAME, to_mcp_config + + cfg = to_mcp_config("finance") + assert MCP_SERVER_NAME in cfg + entry = cfg[MCP_SERVER_NAME] + assert "command" in entry and "args" in entry + assert entry["env"]["BCLI_PROFILE"] == "finance" + + +def test_factory_builds_codex_backend(fake_codex) -> None: + from bcli.agent import get_agent_backend + from bcli.agent.backends._codex import CodexBackend + from bcli.config._model import AgentConfig + + backend = get_agent_backend(AgentConfig(backend="codex", model="gpt-5")) + assert isinstance(backend, CodexBackend) + assert backend.is_active is True + + +async def test_notification_mapping_and_final_answer(fake_codex) -> None: + from bcli.agent.backends._codex import CodexBackend + + _TurnHandle.NOTIFICATIONS = [ + _Notification(item=_Item(type="assistant_message", text="Looking… ")), + _Notification(item=_Item(type="mcp_tool_call", name="mcp__bcli__bcli_get", + input={"endpoint": "vendors"}, id="i1")), + ] + _TurnHandle.RESULT = _TurnResult(final_response="There are 42 vendors.") + + backend = CodexBackend(model="gpt-5", max_turns=5) + runtime = make_runtime() + await backend.start_session(system_prompt="s", tools=ToolRegistry.default(), runtime=runtime) + events = [ev async for ev in backend.send("how many vendors?")] + await backend.close() + + kinds = [e.kind for e in events] + assert "text_delta" in kinds + assert "tool_call_started" in kinds + assert kinds[-1] == "turn_complete" + started = next(e for e in events if e.kind == "tool_call_started") + assert started.tool_name == "bcli_get" # mcp prefix stripped + assert events[-1].text == "There are 42 vendors." + + +async def test_thread_start_passes_mcp_config_and_instructions(fake_codex) -> None: + from bcli.agent.backends._codex import CodexBackend, MCP_SERVER_NAME + + _TurnHandle.NOTIFICATIONS = [] + _TurnHandle.RESULT = _TurnResult(final_response="ok") + + backend = CodexBackend(max_turns=5) + runtime = make_runtime(profile=FakeProfile()) + runtime.profile_name = "sandbox" + await backend.start_session(system_prompt="SYSTEM", tools=ToolRegistry.default(), runtime=runtime) + _ = [ev async for ev in backend.send("hi")] + await backend.close() + + kwargs = _AsyncCodex.LAST_START_KWARGS + assert kwargs["base_instructions"] == "SYSTEM" + assert MCP_SERVER_NAME in kwargs["config"]["mcp_servers"] + + +async def test_production_escalates_approval_mode(fake_codex) -> None: + from bcli.agent.backends._codex import CodexBackend + + _TurnHandle.NOTIFICATIONS = [] + _TurnHandle.RESULT = _TurnResult(final_response="ok") + + backend = CodexBackend(max_turns=5) + runtime = make_runtime(profile=FakeProfile(environment="production")) + await backend.start_session(system_prompt="s", tools=ToolRegistry.default(), runtime=runtime) + _ = [ev async for ev in backend.send("hi")] + await backend.close() + + # Cautious posture → on_request, not the auto_review default. + assert _AsyncCodex.LAST_START_KWARGS.get("approval_mode") == "on_request" diff --git a/tests/test_agent/test_consent.py b/tests/test_agent/test_consent.py new file mode 100644 index 0000000..1e895a3 --- /dev/null +++ b/tests/test_agent/test_consent.py @@ -0,0 +1,67 @@ +"""Subscription-consent gate: fires only on subscription auth, persists.""" + +from __future__ import annotations + +import pytest + +from bcli.config._model import AgentConfig +from bcli_cli.repl import _consent + + +def test_no_consent_for_pydantic_ai() -> None: + assert _consent.needs_consent(AgentConfig(backend="pydantic-ai")) is False + + +def test_no_consent_when_already_authorized() -> None: + cfg = AgentConfig(backend="claude-code", subscription_authorized=True) + assert _consent.needs_consent(cfg) is False + + +def test_consent_needed_only_on_subscription_auth(monkeypatch) -> None: + cfg = AgentConfig(backend="claude-code") + monkeypatch.setattr(_consent, "detect_claude_auth", lambda: "subscription", raising=False) + # Patch the lazily-imported function at its source module too. + import bcli.agent._auth_detect as ad + + monkeypatch.setattr(ad, "detect_claude_auth", lambda **_kw: "subscription") + assert _consent.needs_consent(cfg) is True + + monkeypatch.setattr(ad, "detect_claude_auth", lambda **_kw: "api_key") + assert _consent.needs_consent(cfg) is False + + +def test_ensure_consent_non_interactive_denies(monkeypatch) -> None: + cfg = AgentConfig(backend="codex") + import bcli.agent._auth_detect as ad + + monkeypatch.setattr(ad, "detect_codex_auth", lambda **_kw: "subscription") + assert _consent.ensure_subscription_consent(cfg, interactive=False) is False + + +def test_ensure_consent_accepts_literal_yes_and_persists(monkeypatch) -> None: + cfg = AgentConfig(backend="codex") + import bcli.agent._auth_detect as ad + + monkeypatch.setattr(ad, "detect_codex_auth", lambda **_kw: "subscription") + + persisted = {} + monkeypatch.setattr(_consent, "persist_consent", lambda: persisted.update(done=True)) + + ok = _consent.ensure_subscription_consent( + cfg, interactive=True, input_func=lambda _p: "yes", + ) + assert ok is True + assert persisted.get("done") is True + + +def test_ensure_consent_rejects_non_yes(monkeypatch) -> None: + cfg = AgentConfig(backend="codex") + import bcli.agent._auth_detect as ad + + monkeypatch.setattr(ad, "detect_codex_auth", lambda **_kw: "subscription") + monkeypatch.setattr(_consent, "persist_consent", lambda: pytest.fail("should not persist")) + + ok = _consent.ensure_subscription_consent( + cfg, interactive=True, input_func=lambda _p: "y", + ) + assert ok is False diff --git a/tests/test_agent/test_factory.py b/tests/test_agent/test_factory.py new file mode 100644 index 0000000..4fcc14f --- /dev/null +++ b/tests/test_agent/test_factory.py @@ -0,0 +1,129 @@ +"""Backend dispatch + Null fallback (mirror of test_ask/test_factory.py).""" + +from __future__ import annotations + +import logging +import sys +import textwrap +from pathlib import Path + +import pytest + +from bcli.agent import NullAgentBackend, get_agent_backend +from bcli.config._model import AgentConfig + + +def _install_test_module( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: str, *, name: str +) -> None: + (tmp_path / f"{name}.py").write_text(textwrap.dedent(body), encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + sys.modules.pop(name, None) + + +def test_none_config_returns_null() -> None: + assert isinstance(get_agent_backend(None), NullAgentBackend) + + +def test_default_backend_is_null() -> None: + cfg = AgentConfig() + backend = get_agent_backend(cfg) + assert isinstance(backend, NullAgentBackend) + assert backend.is_active is False + + +def test_unknown_backend_falls_back_to_null(caplog) -> None: + with caplog.at_level(logging.WARNING, logger="bcli.agent"): + result = get_agent_backend(AgentConfig(backend="nonexistent_backend")) + assert isinstance(result, NullAgentBackend) + + +def test_custom_backend_loaded_by_import_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_test_module( + tmp_path, + monkeypatch, + """ + class FakeBackend: + is_active = True + + def __init__(self, model): + self.model = model + + @classmethod + def from_config(cls, config): + return cls(model=config.model or "fake") + + async def start_session(self, *, system_prompt, tools, runtime): + ... + + async def send(self, user_msg): + from bcli.agent import AgentEvent + yield AgentEvent(kind="turn_complete", text="ok") + + async def close(self): + ... + """, + name="_bcli_fake_agent_mod", + ) + backend = get_agent_backend( + AgentConfig(backend="_bcli_fake_agent_mod:FakeBackend", model="custom-1") + ) + assert backend.is_active is True + assert getattr(backend, "model", None) == "custom-1" + + +def test_malformed_spec_falls_back_to_null(caplog) -> None: + with caplog.at_level(logging.WARNING, logger="bcli.agent"): + backend = get_agent_backend(AgentConfig(backend="no_colon_here")) + assert isinstance(backend, NullAgentBackend) + + +def test_from_config_raise_falls_back_to_null( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog +) -> None: + _install_test_module( + tmp_path, + monkeypatch, + """ + class BoomBackend: + is_active = True + + @classmethod + def from_config(cls, config): + raise RuntimeError("boom") + """, + name="_bcli_boom_agent_mod", + ) + with caplog.at_level(logging.WARNING, logger="bcli.agent"): + backend = get_agent_backend(AgentConfig(backend="_bcli_boom_agent_mod:BoomBackend")) + assert isinstance(backend, NullAgentBackend) + + +def test_missing_from_config_falls_back_to_null( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog +) -> None: + _install_test_module( + tmp_path, + monkeypatch, + """ + class IncompleteBackend: + is_active = True + """, + name="_bcli_incomplete_agent_mod", + ) + with caplog.at_level(logging.WARNING, logger="bcli.agent"): + backend = get_agent_backend( + AgentConfig(backend="_bcli_incomplete_agent_mod:IncompleteBackend") + ) + assert isinstance(backend, NullAgentBackend) + + +async def test_null_backend_emits_setup_hint() -> None: + backend = NullAgentBackend() + events = [ev async for ev in backend.send("hi")] + kinds = [e.kind for e in events] + assert "error" in kinds + assert any("backend" in e.error for e in events if e.kind == "error") + assert kinds[-1] == "turn_complete" diff --git a/tests/test_agent/test_pydantic_ai_backend.py b/tests/test_agent/test_pydantic_ai_backend.py new file mode 100644 index 0000000..f375dfc --- /dev/null +++ b/tests/test_agent/test_pydantic_ai_backend.py @@ -0,0 +1,93 @@ +"""PydanticAIBackend driven by FunctionModel / TestModel — no network. + +Asserts the uniform AgentEvent stream shape: a tool call surfaces as +tool_call_started → tool_result, text streams as text_delta, and every +turn ends with exactly one turn_complete carrying the final answer. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("pydantic_ai") + +from pydantic_ai.models.test import TestModel # noqa: E402 + +from _helpers import FakeMeta, FakeRegistry, make_runtime # noqa: E402 + +from bcli.agent import ToolRegistry # noqa: E402 +from bcli.agent.backends._pydantic_ai import PydanticAIBackend # noqa: E402 + + +async def _drive(backend, runtime, msg: str): + await backend.start_session( + system_prompt="sys", tools=ToolRegistry.default(), runtime=runtime, + ) + events = [ev async for ev in backend.send(msg)] + await backend.close() + return events + + +async def test_text_only_turn_streams_and_completes() -> None: + # call_tools=[] → the model answers directly without invoking any tool + # (so no handler shells out to a real `bcli batch` subprocess). + backend = PydanticAIBackend( + model=TestModel(call_tools=[], custom_output_text="42 vendors"), + max_steps=5, + ) + runtime = make_runtime() + events = await _drive(backend, runtime, "how many vendors?") + kinds = [e.kind for e in events] + assert kinds[-1] == "turn_complete" + assert events[-1].text == "42 vendors" + + +async def test_tool_call_event_shape() -> None: + """A model that calls one tool then answers → ordered event stream. + + ``TestModel(call_tools=[...])`` deterministically calls just the named + tool once, then returns its default text — no network, no live model. + """ + backend = PydanticAIBackend( + model=TestModel(call_tools=["bcli_endpoint_search"]), max_steps=5, + ) + runtime = make_runtime(registry=FakeRegistry({"vendors": FakeMeta("vendors")})) + events = await _drive(backend, runtime, "find vendor endpoints") + kinds = [e.kind for e in events] + + assert "tool_call_started" in kinds + assert "tool_result" in kinds + assert kinds.index("tool_call_started") < kinds.index("tool_result") + assert kinds[-1] == "turn_complete" + + started = next(e for e in events if e.kind == "tool_call_started") + assert started.tool_name == "bcli_endpoint_search" + + # The tool result the model saw carries the registry match. + tool_result = next(e for e in events if e.kind == "tool_result") + assert tool_result.tool_name == "bcli_endpoint_search" + + +async def test_send_before_start_emits_error() -> None: + backend = PydanticAIBackend(model=TestModel(), max_steps=5) + events = [ev async for ev in backend.send("hi")] + assert events[0].kind == "error" + assert events[-1].kind == "turn_complete" + + +async def test_build_model_local_uses_base_url(monkeypatch) -> None: + from bcli.agent.backends import _pydantic_ai as mod + from bcli.config._model import AgentConfig + + model = mod._build_model(AgentConfig( + backend="pydantic-ai", model="llama3.1", base_url="http://localhost:11434/v1", + )) + # OpenAI-compatible local model object, not a bare string. + assert not isinstance(model, str) + + +def test_resolve_llm_key_prefers_explicit_env(monkeypatch) -> None: + from bcli.agent.backends._pydantic_ai import resolve_llm_key + + monkeypatch.setenv("MY_CUSTOM_KEY", "sk-explicit") + assert resolve_llm_key("anthropic", "MY_CUSTOM_KEY") == "sk-explicit" diff --git a/tests/test_agent/test_read_tools.py b/tests/test_agent/test_read_tools.py new file mode 100644 index 0000000..fe97c52 --- /dev/null +++ b/tests/test_agent/test_read_tools.py @@ -0,0 +1,55 @@ +"""Read-tier handlers: registry-backed lookups, no client required.""" + +from __future__ import annotations + +from _helpers import FakeProfile, make_runtime + +from bcli.agent.tools._impl import ( + handle_describe, + handle_endpoint_info, + handle_endpoint_search, +) + + +async def test_endpoint_search_returns_matches(fake_registry) -> None: + runtime = make_runtime(registry=fake_registry) + result = await handle_endpoint_search(runtime, pattern="vend") + names = [m["entity_set_name"] for m in result["matches"]] + assert "vendors" in names + + +async def test_endpoint_search_no_match_gives_hint(fake_registry) -> None: + runtime = make_runtime(registry=fake_registry) + result = await handle_endpoint_search(runtime, pattern="zzz") + assert result["matches"] == [] + assert "hint" in result + + +async def test_endpoint_info_unknown_returns_error(fake_registry) -> None: + runtime = make_runtime(registry=fake_registry) + result = await handle_endpoint_info(runtime, name="nope") + assert result["status"] == "error" + + +async def test_endpoint_info_known(fake_registry) -> None: + runtime = make_runtime(registry=fake_registry) + result = await handle_endpoint_info(runtime, name="vendors") + assert result["entity_set_name"] == "vendors" + assert result["caution"] == "low" + + +async def test_describe_reports_constraints(fake_registry) -> None: + runtime = make_runtime( + profile=FakeProfile(environment="production", disable_writes=True), + registry=fake_registry, + ) + result = await handle_describe(runtime) + assert result["is_production"] is True + assert result["constraints"]["disable_writes"] is True + assert result["endpoint_count"] == 2 + + +async def test_search_without_registry_errors() -> None: + runtime = make_runtime(registry=None) + result = await handle_endpoint_search(runtime, pattern="x") + assert result["status"] == "error" diff --git a/tests/test_agent/test_registry.py b/tests/test_agent/test_registry.py new file mode 100644 index 0000000..859d38b --- /dev/null +++ b/tests/test_agent/test_registry.py @@ -0,0 +1,93 @@ +"""ToolRegistry: tier classification, describe round-trip, MCP parity. + +The agent registry mirrors :mod:`bcli_mcp._tool_generator` in shape +(``bcli_`` names, the same JSON-Schema type map) but cannot import +it (SDK must not depend on the MCP package). These tests pin the parity +so the two never silently drift. +""" + +from __future__ import annotations + +from bcli.agent.tools._registry import ( + ToolRegistry, + _build_input_schema, + _path_to_tool_name, +) +from bcli_mcp._tool_generator import ( + _build_input_schema as mcp_build_input_schema, + _path_to_tool_name as mcp_path_to_tool_name, +) + + +def test_default_registry_has_read_and_write_tiers() -> None: + reg = ToolRegistry.default() + read = {s.name for s in reg.read_specs()} + write = {s.name for s in reg.write_specs()} + assert "bcli_get" in read + assert "bcli_endpoint_search" in read + assert "bcli_post" in write + assert "bcli_delete" in write + assert read.isdisjoint(write) + + +def test_plan_mode_swaps_writes_for_draft_batch() -> None: + reg = ToolRegistry.default() + plan_names = reg.tool_names(plan_mode=True) + assert "draft_batch" in plan_names + assert "bcli_post" not in plan_names + assert "bcli_delete" not in plan_names + # reads survive + assert "bcli_get" in plan_names + + +def test_name_mapping_matches_mcp() -> None: + for path in (["get"], ["endpoint", "search"], ["batch", "run"], ["attach", "upload"]): + assert _path_to_tool_name(path) == mcp_path_to_tool_name(path) + + +def test_input_schema_matches_mcp_for_get() -> None: + positionals = [ + {"name": "endpoint", "type": "str", "required": True}, + {"name": "record_id", "type": "str", "required": False}, + ] + options = [ + {"name": "--filter", "type": "str"}, + {"name": "--top", "type": "int", "limits": {"default": 50, "minimum": 1, "maximum": 1000}}, + ] + ours = _build_input_schema(positionals, options) + theirs = mcp_build_input_schema(positionals, options) + assert ours == theirs + + +def test_from_describe_filters_to_supported_paths() -> None: + payload = { + "commands": [ + {"path": ["get"], "summary": "g", "positionals": [ + {"name": "endpoint", "type": "str", "required": True}], "options": []}, + {"path": ["post"], "summary": "p", "positionals": [], "options": [ + {"name": "--data", "type": "str", "required": True}]}, + # Not in supported sets — must be excluded. + {"path": ["auth", "login"], "summary": "x", "positionals": [], "options": []}, + {"path": ["registry", "import"], "summary": "y", "positionals": [], "options": []}, + ] + } + reg = ToolRegistry.from_describe(payload) + names = set(reg.tool_names()) + assert names == {"bcli_get", "bcli_post"} + + +def test_from_describe_empty_falls_back_to_default() -> None: + reg = ToolRegistry.from_describe({"commands": []}) + assert "bcli_get" in reg.tool_names() + + +def test_curated_overlay_applied_on_describe_rebuild() -> None: + payload = {"commands": [ + {"path": ["get"], "summary": "terse cli help", "positionals": [ + {"name": "endpoint", "type": "str", "required": True}], "options": []}, + ]} + reg = ToolRegistry.from_describe(payload) + spec = reg.get("bcli_get") + assert spec is not None + # The curated overlay (richer LLM description) wins over the terse summary. + assert "Discovery-first" in spec.description diff --git a/tests/test_agent/test_safety.py b/tests/test_agent/test_safety.py new file mode 100644 index 0000000..88a0bf9 --- /dev/null +++ b/tests/test_agent/test_safety.py @@ -0,0 +1,131 @@ +"""Write-safety seam — enforced inside the tool runtime, not the prompt. + +Covers the gate matrix (disable_writes / caution=high / production), +decline → typed refusal, auto-approve, fail-closed with no emitter, and +the plan-mode draft_batch replacement (writes nothing). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from bcli.agent._protocol import AgentEvent +from _helpers import FakeMeta, FakeProfile, FakeRegistry, make_runtime + +from bcli.agent.tools._impl import handle_draft_batch, handle_post + + +async def _collect_and_resolve(runtime, coro, *, approve: bool): + """Drive a gated handler: capture the approval event, resolve it.""" + events: list[AgentEvent] = [] + + async def emit(ev: AgentEvent) -> None: + events.append(ev) + if ev.kind == "awaiting_approval": + # Resolve on the next loop tick so the handler is awaiting. + asyncio.get_running_loop().call_soon( + runtime.resolve_approval, ev.approval_id, approve, + ) + + runtime.bind_emitter(emit) + result = await coro + runtime.bind_emitter(None) + return result, events + + +async def test_readonly_profile_gates_write_and_decline_refuses() -> None: + runtime = make_runtime(profile=FakeProfile(disable_writes=True)) + result, events = await _collect_and_resolve( + runtime, + handle_post(runtime, endpoint="vendors", data='{"name": "Acme"}'), + approve=False, + ) + assert any(e.kind == "awaiting_approval" for e in events) + assert result["status"] == "refused" + assert "declined" in result["reason"] + assert "read-only" in result["reason"] + + +async def test_high_caution_endpoint_gates_write() -> None: + runtime = make_runtime(registry=FakeRegistry({ + "journalLines": FakeMeta("journalLines", caution="high"), + })) + _, events = await _collect_and_resolve( + runtime, + handle_post(runtime, endpoint="journalLines", data="{}"), + approve=False, + ) + reasons = [e.reason for e in events if e.kind == "awaiting_approval"] + assert reasons and "caution: high" in reasons[0] + + +async def test_production_target_gates_write() -> None: + runtime = make_runtime(profile=FakeProfile(environment="production")) + assert runtime.is_production is True + _, events = await _collect_and_resolve( + runtime, + handle_post(runtime, endpoint="vendors", data="{}"), + approve=False, + ) + reasons = [e.reason for e in events if e.kind == "awaiting_approval"] + assert reasons and "production" in reasons[0] + + +async def test_auto_approve_skips_gate_event() -> None: + runtime = make_runtime(profile=FakeProfile(disable_writes=True), auto_approve=True) + # No emitter bound, auto_approve=True → gate approves without an event. + decision = await runtime.gate_write(method="POST", endpoint="vendors") + assert decision.approved is True + + +async def test_fail_closed_without_emitter() -> None: + runtime = make_runtime(profile=FakeProfile(disable_writes=True)) + # Gated write, no emitter, no auto_approve → denied (fail closed). + decision = await runtime.gate_write(method="POST", endpoint="vendors") + assert decision.approved is False + + +async def test_no_gate_reasons_approves_immediately() -> None: + runtime = make_runtime(profile=FakeProfile(environment="sandbox", disable_writes=False)) + decision = await runtime.gate_write(method="POST", endpoint="vendors") + assert decision.approved is True + assert decision.reasons == () + + +async def test_invalid_json_body_returns_error_not_raise() -> None: + runtime = make_runtime() + result = await handle_post(runtime, endpoint="vendors", data="not json") + assert result["status"] == "error" + assert "JSON" in result["message"] + + +async def test_draft_batch_renders_yaml_and_writes_nothing() -> None: + runtime = make_runtime(plan_mode=True) + steps = ( + '[{"name": "create_vendor", "action": "post", "endpoint": "vendors", ' + '"data": {"displayName": "Acme"}}]' + ) + result = await handle_draft_batch(runtime, name="onboard", steps=steps) + assert result["status"] == "drafted" + assert "vendors" in result["batch_yaml"] + assert "name: onboard" in result["batch_yaml"] + + +async def test_draft_batch_rejects_non_write_action() -> None: + runtime = make_runtime(plan_mode=True) + result = await handle_draft_batch( + runtime, name="x", steps='[{"action": "get", "endpoint": "vendors"}]', + ) + assert result["status"] == "error" + + +def test_safe_context_requires_env_and_company() -> None: + from bcli.client._safety import SafeContext + from bcli.errors import SafetyError + + with pytest.raises(SafetyError): + SafeContext(client=object(), environment="", company_id="c1") + with pytest.raises(SafetyError): + SafeContext(client=object(), environment="sandbox", company_id="") diff --git a/tests/test_agent/test_wizard.py b/tests/test_agent/test_wizard.py new file mode 100644 index 0000000..3e7af83 --- /dev/null +++ b/tests/test_agent/test_wizard.py @@ -0,0 +1,43 @@ +"""Setup-wizard pure logic: backend detection + [agent] section assembly.""" + +from __future__ import annotations + +from bcli.config._model import AgentConfig +from bcli_cli.repl._wizard import ( + build_agent_section, + detect_backends, + has_usable_backend, +) + + +def test_detect_backends_lists_all_choices() -> None: + options = detect_backends() + backends = {o.backend for o in options} + assert "pydantic-ai" in backends + assert "claude-code" in backends + assert "codex" in backends + # Three pydantic-ai entries (anthropic key / openai key / local). + assert sum(o.backend == "pydantic-ai" for o in options) == 3 + + +def test_build_agent_section_api_key_option() -> None: + option = next(o for o in detect_backends() if o.key == "1") + section = build_agent_section(option) + assert section["backend"] == "pydantic-ai" + assert section["model"] == "anthropic:claude-sonnet-4-5" + assert "base_url" not in section + + +def test_build_agent_section_local_includes_base_url() -> None: + option = next(o for o in detect_backends() if o.key == "3") + section = build_agent_section( + option, base_url="http://localhost:11434/v1", model="ollama:qwen2.5", + ) + assert section["base_url"] == "http://localhost:11434/v1" + assert section["model"] == "ollama:qwen2.5" + + +def test_has_usable_backend() -> None: + assert has_usable_backend(None) is False + assert has_usable_backend(AgentConfig()) is False # default null + assert has_usable_backend(AgentConfig(backend="pydantic-ai")) is True diff --git a/tests/test_repl/test_app_pilot.py b/tests/test_repl/test_app_pilot.py new file mode 100644 index 0000000..a19740a --- /dev/null +++ b/tests/test_repl/test_app_pilot.py @@ -0,0 +1,139 @@ +"""Textual pilot tests: drive ChatApp with a canned AgentEvent stream. + +No model, no BC client — a fake backend yields a scripted event stream +and a fake runtime stands in for the write gate. Asserts the renderer +turns events into widgets and that the approval modal resolves the +runtime future. +""" + +from __future__ import annotations + +import pytest + +from bcli.agent import AgentEvent +from bcli_cli.repl._app import ChatApp +from bcli_cli.repl._widgets import ApprovalScreen, ToolCallPanel + + +class FakeRuntime: + """Minimal stand-in for AgentRuntime — records approvals.""" + + def __init__(self) -> None: + self.plan_mode = False + self.is_production = False + self.resolved: list[tuple[str, bool]] = [] + + def resolve_approval(self, approval_id: str, approved: bool) -> bool: + self.resolved.append((approval_id, approved)) + return True + + +class ScriptedBackend: + """AgentSessionBackend that replays a fixed list of events per turn.""" + + is_active = True + model_label = "scripted" + + def __init__(self, events: list[AgentEvent]) -> None: + self._events = events + + async def start_session(self, **_kw) -> None: # noqa: ANN003 + ... + + async def send(self, user_msg: str): # noqa: ANN201 + for ev in self._events: + yield ev + + async def close(self) -> None: + ... + + +def _make_app(events: list[AgentEvent], runtime: FakeRuntime | None = None) -> ChatApp: + app = ChatApp() + # Short-circuit _start_session: inject the fake backend + runtime so + # the app never touches config / network. + app._backend = ScriptedBackend(events) + app._runtime = runtime or FakeRuntime() + app._model_label = "scripted" + app._profile_name = "test" + app._environment = "sandbox" + + async def _noop_start() -> None: + return None + + app._start_session = _noop_start # type: ignore[assignment] + return app + + +async def test_text_turn_renders_answer() -> None: + events = [ + AgentEvent(kind="text_delta", text="There are "), + AgentEvent(kind="text_delta", text="42 vendors."), + AgentEvent(kind="turn_complete", text="There are 42 vendors."), + ] + app = _make_app(events) + async with app.run_test() as pilot: + app.query_one("#prompt").value = "how many vendors?" + await pilot.press("enter") + # Let the worker drain. + for _ in range(8): + await pilot.pause() + from textual.widgets import Markdown + + markdowns = app.query(Markdown) + combined = " ".join(m.source for m in markdowns) + assert "42 vendors" in combined + + +async def test_tool_call_renders_panel() -> None: + events = [ + AgentEvent(kind="tool_call_started", tool_name="bcli_get", + tool_call_id="c1", tool_args={"endpoint": "vendors"}), + AgentEvent(kind="tool_result", tool_name="bcli_get", + tool_call_id="c1", result={"returned": 3}), + AgentEvent(kind="turn_complete", text="done"), + ] + app = _make_app(events) + async with app.run_test() as pilot: + app.query_one("#prompt").value = "list vendors" + await pilot.press("enter") + for _ in range(6): + await pilot.pause() + panels = app.query(ToolCallPanel) + assert len(panels) >= 1 + + +async def test_slash_help_renders_without_backend_turn() -> None: + app = _make_app([]) + async with app.run_test() as pilot: + app.query_one("#prompt").value = "/help" + await pilot.press("enter") + await pilot.pause() + from textual.widgets import Markdown + + combined = " ".join(m.source for m in app.query(Markdown)) + assert "/model" in combined + + +async def test_approval_modal_resolves_runtime_future() -> None: + runtime = FakeRuntime() + events = [ + AgentEvent(kind="awaiting_approval", approval_id="a1", + tool_name="bcli_post", reason="production target", + tool_args={"endpoint": "vendors"}), + AgentEvent(kind="turn_complete", text="declined"), + ] + app = _make_app(events, runtime=runtime) + async with app.run_test() as pilot: + app.query_one("#prompt").value = "create a vendor" + await pilot.press("enter") + # Wait for the modal to appear. + for _ in range(8): + await pilot.pause() + if isinstance(app.screen, ApprovalScreen): + break + assert isinstance(app.screen, ApprovalScreen) + await pilot.press("n") # decline + for _ in range(6): + await pilot.pause() + assert runtime.resolved == [("a1", False)] diff --git a/tests/test_repl/test_bare_entry.py b/tests/test_repl/test_bare_entry.py new file mode 100644 index 0000000..332e3da --- /dev/null +++ b/tests/test_repl/test_bare_entry.py @@ -0,0 +1,60 @@ +"""Bare-``bcli`` dispatch: non-TTY → help (regression), TTY → REPL launch. + +The contract that protects every scripted/piped caller of bcli: a bare +invocation with no subcommand must still print help and exit 0 when +stdout/stdin aren't both TTYs. Only an interactive terminal opens the +chat REPL. +""" + +from __future__ import annotations + +import bcli_cli.app as app_mod +from bcli_cli.app import app +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_bare_bcli_non_tty_prints_help() -> None: + # CliRunner pipes stdio → not a TTY → help path. + result = runner.invoke(app, []) + assert result.exit_code == 0 + assert "Usage" in result.output + assert "Business Central" in result.output + + +def test_bare_bcli_tty_launches_repl(monkeypatch) -> None: + monkeypatch.setattr(app_mod, "_stdio_is_tty", lambda: True) + launched = {} + + def fake_launch(*, profile=None): + launched["profile"] = profile + return 0 + + # The lazy import resolves bcli_cli.repl.launch_repl. + import bcli_cli.repl as repl_mod + + monkeypatch.setattr(repl_mod, "launch_repl", fake_launch) + + result = runner.invoke(app, []) + assert result.exit_code == 0 + assert "profile" in launched + + +def test_bare_bcli_tty_passes_profile(monkeypatch) -> None: + monkeypatch.setattr(app_mod, "_stdio_is_tty", lambda: True) + seen = {} + import bcli_cli.repl as repl_mod + + monkeypatch.setattr(repl_mod, "launch_repl", lambda *, profile=None: seen.update(p=profile) or 0) + + result = runner.invoke(app, ["--profile", "finance"]) + assert result.exit_code == 0 + assert seen["p"] == "finance" + + +def test_subcommand_unaffected_by_bare_branch() -> None: + # A real subcommand must never trigger the REPL branch. + result = runner.invoke(app, ["endpoint", "--help"]) + assert result.exit_code == 0 + assert "search" in result.output diff --git a/tests/test_repl/test_commands.py b/tests/test_repl/test_commands.py new file mode 100644 index 0000000..a975082 --- /dev/null +++ b/tests/test_repl/test_commands.py @@ -0,0 +1,48 @@ +"""Slash-command parsing.""" + +from __future__ import annotations + +from bcli_cli.repl._commands import COMMANDS, help_text, parse_slash + + +def test_plain_message_is_not_a_command() -> None: + assert parse_slash("how many vendors?") is None + assert parse_slash(" what about /tmp paths ") is None + + +def test_bare_slash_is_help() -> None: + cmd = parse_slash("/") + assert cmd is not None and cmd.name == "help" + + +def test_known_commands_parse() -> None: + for name in COMMANDS: + cmd = parse_slash(f"/{name}") + assert cmd is not None + assert cmd.name == name + + +def test_command_with_argument() -> None: + cmd = parse_slash("/model anthropic:claude-opus-4-1") + assert cmd is not None + assert cmd.name == "model" + assert cmd.arg == "anthropic:claude-opus-4-1" + + +def test_aliases() -> None: + assert parse_slash("/quit").name == "exit" + assert parse_slash("/q").name == "exit" + assert parse_slash("/?").name == "help" + + +def test_unknown_command_is_flagged() -> None: + cmd = parse_slash("/frobnicate now") + assert cmd is not None + assert cmd.name == "__unknown__" + assert cmd.arg == "/frobnicate now" + + +def test_help_text_lists_all_commands() -> None: + text = help_text() + for name in COMMANDS: + assert f"/{name}" in text diff --git a/tests/test_repl/test_plan_mode.py b/tests/test_repl/test_plan_mode.py new file mode 100644 index 0000000..a741375 --- /dev/null +++ b/tests/test_repl/test_plan_mode.py @@ -0,0 +1,95 @@ +"""Plan-mode draft → file round-trip and batch-run invocation.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from bcli_cli.repl import _plan_mode + + +def test_write_draft_round_trips_yaml(tmp_path, monkeypatch) -> None: + doc = { + "name": "onboard", + "steps": [ + {"name": "create_vendor", "action": "post", "endpoint": "vendors", + "data": {"displayName": "Acme"}}, + ], + } + yaml_text = yaml.safe_dump(doc, sort_keys=False) + path = _plan_mode.write_draft(yaml_text, name="onboard vendor!") + assert path.exists() + assert path.suffix == ".yaml" + # Sanitised, recognisable filename. + assert "onboard" in path.name + reloaded = yaml.safe_load(path.read_text()) + assert reloaded == doc + + +async def test_run_batch_builds_correct_argv(monkeypatch) -> None: + captured = {} + + class FakeProc: + returncode = 0 + + async def communicate(self): + return (b'{"ok": true}', b"") + + async def fake_exec(*argv, **kwargs): + captured["argv"] = list(argv) + return FakeProc() + + monkeypatch.setattr(_plan_mode.asyncio, "create_subprocess_exec", fake_exec) + monkeypatch.setattr(_plan_mode.shutil, "which", lambda _x: "/usr/bin/bcli") + + ok, out = await _plan_mode.run_batch( + Path("/tmp/x.batch.yaml"), profile_name="sandbox", dry_run=True, + ) + assert ok is True + argv = captured["argv"] + assert argv[0] == "bcli" + assert "--profile" in argv and "sandbox" in argv + assert "batch" in argv and "run" in argv + assert "--dry-run" in argv + assert "--yes" not in argv # dry-run never auto-approves a write + + +async def test_run_batch_real_run_passes_yes(monkeypatch) -> None: + class FakeProc: + returncode = 0 + + async def communicate(self): + return (b"done", b"") + + captured = {} + + async def fake_exec(*argv, **kwargs): + captured["argv"] = list(argv) + return FakeProc() + + monkeypatch.setattr(_plan_mode.asyncio, "create_subprocess_exec", fake_exec) + monkeypatch.setattr(_plan_mode.shutil, "which", lambda _x: "/usr/bin/bcli") + + ok, _ = await _plan_mode.run_batch(Path("/tmp/x.yaml"), dry_run=False) + assert ok is True + assert "--yes" in captured["argv"] + assert "--dry-run" not in captured["argv"] + + +async def test_run_batch_reports_failure(monkeypatch) -> None: + class FakeProc: + returncode = 2 + + async def communicate(self): + return (b"", b"boom") + + async def fake_exec(*argv, **kwargs): + return FakeProc() + + monkeypatch.setattr(_plan_mode.asyncio, "create_subprocess_exec", fake_exec) + monkeypatch.setattr(_plan_mode.shutil, "which", lambda _x: "/usr/bin/bcli") + + ok, out = await _plan_mode.run_batch(Path("/tmp/x.yaml")) + assert ok is False + assert "boom" in out diff --git a/tests/test_repl/test_wizard_write.py b/tests/test_repl/test_wizard_write.py new file mode 100644 index 0000000..07a6973 --- /dev/null +++ b/tests/test_repl/test_wizard_write.py @@ -0,0 +1,54 @@ +"""Wizard: end-to-end config write with mocked keychain + config IO.""" + +from __future__ import annotations + +import bcli_cli.repl._wizard as wizard + + +def test_wizard_writes_local_backend_section(monkeypatch) -> None: + """Choice 3 (local model, no key) writes backend + base_url, no keychain.""" + written = {} + monkeypatch.setattr( + "bcli.config._loader.update_config_section", + lambda section, values: written.update({section: values}), + ) + + # Drive the prompts: pick 3, accept base_url default, accept model default. + answers = iter(["3", "http://localhost:11434/v1", "ollama:llama3.1"]) + monkeypatch.setattr(wizard, "_ask", lambda *a, **k: next(answers)) + + ok = wizard.run_setup_wizard(force=True, input_func=lambda _p: next(answers)) + assert ok is True + assert written["agent"]["backend"] == "pydantic-ai" + assert written["agent"]["base_url"] == "http://localhost:11434/v1" + + +def test_wizard_stores_api_key_in_keychain(monkeypatch) -> None: + written = {} + stored = {} + monkeypatch.setattr( + "bcli.config._loader.update_config_section", + lambda section, values: written.update({section: values}), + ) + monkeypatch.setattr( + "bcli.agent.backends._pydantic_ai.store_llm_key", + lambda provider, key: stored.update({provider: key}) or True, + ) + + answers = iter(["1", "sk-test-123"]) + monkeypatch.setattr(wizard, "_ask", lambda *a, **k: next(answers)) + + ok = wizard.run_setup_wizard(force=True) + assert ok is True + assert written["agent"]["backend"] == "pydantic-ai" + assert written["agent"]["model"] == "anthropic:claude-sonnet-4-5" + assert stored["anthropic"] == "sk-test-123" + + +def test_wizard_aborts_on_unknown_choice(monkeypatch) -> None: + monkeypatch.setattr( + "bcli.config._loader.update_config_section", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not write")), + ) + monkeypatch.setattr(wizard, "_ask", lambda *a, **k: "99") + assert wizard.run_setup_wizard(force=True) is False diff --git a/uv.lock b/uv.lock index e05695f..1740a05 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,9 @@ resolution-markers = [ "(python_full_version < '3.14' and platform_python_implementation == 'PyPy') or (python_full_version < '3.14' and sys_platform == 'emscripten')", ] +[options] +prerelease-mode = "allow" + [[package]] name = "aiobotocore" version = "3.4.0" @@ -179,7 +182,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.101.0" +version = "0.109.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -191,9 +194,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/cb/9d0123243e749ac3a579972b2c398971bce1dc57bcc4efb08066df610360/anthropic-0.101.0.tar.gz", hash = "sha256:1116a6a87c55757e0fbe3e1ba40804fbd04de7963601a6dd6b539a889f18de3e", size = 758603, upload-time = "2026-05-11T15:46:33.944Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/0b/ce24a4f275573f5e436ca954faca60c759d58ed152b8fa36a1e3b888e261/anthropic-0.109.1.tar.gz", hash = "sha256:83e06b3d9d40ff5898f588020e0cc4e42187de954549a3b5fbe6e2685a09c785", size = 927569, upload-time = "2026-06-09T23:55:24.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/b2/74ff06762d005ecf1658929a292df0acb786d025f6a6c54fcb30e2dc7761/anthropic-0.101.0-py3-none-any.whl", hash = "sha256:cc3cc6576989471e2aa9132258034ad0ff0d8fe500b04ac499e4e46ed68c5ed0", size = 753594, upload-time = "2026-05-11T15:46:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/91/0f/a6110d713370bc92f074a622f8a5ebdec7e92360149b1048dca258a07b2f/anthropic-0.109.1-py3-none-any.whl", hash = "sha256:ce7d94a7657f2aa29338cca448945eac621b4f62c1794cf461cb32847223e9b8", size = 923851, upload-time = "2026-06-09T23:55:23.348Z" }, ] [[package]] @@ -335,6 +338,19 @@ dependencies = [ ] [package.optional-dependencies] +agent = [ + { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, + { name = "textual" }, +] +agent-claude-code = [ + { name = "claude-agent-sdk" }, +] +agent-codex = [ + { name = "openai-codex" }, +] +agent-local = [ + { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, +] ask = [ { name = "anthropic" }, { name = "openai" }, @@ -350,11 +366,13 @@ dev = [ { name = "dlt", extra = ["filesystem", "parquet", "s3"] }, { name = "mcp" }, { name = "openai" }, + { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, { name = "pypdf" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-httpx" }, { name = "ruff" }, + { name = "textual" }, ] etl = [ { name = "dlt", extra = ["filesystem", "parquet", "s3"] }, @@ -389,6 +407,8 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'ask-claude'", specifier = ">=0.40" }, { name = "anthropic", marker = "extra == 'extract-claude'", specifier = ">=0.40" }, { name = "azure-monitor-opentelemetry", marker = "extra == 'telemetry'", specifier = ">=1.6" }, + { name = "bc-cli", extras = ["agent"], marker = "extra == 'dev'" }, + { name = "bc-cli", extras = ["agent-local"], marker = "extra == 'agent'" }, { name = "bc-cli", extras = ["ask"], marker = "extra == 'dev'" }, { name = "bc-cli", extras = ["ask-claude"], marker = "extra == 'ask'" }, { name = "bc-cli", extras = ["ask-openai"], marker = "extra == 'ask'" }, @@ -398,6 +418,7 @@ requires-dist = [ { name = "bc-cli", extras = ["extract-claude"], marker = "extra == 'extract'" }, { name = "bc-cli", extras = ["extract-openai"], marker = "extra == 'extract'" }, { name = "bc-cli", extras = ["mcp"], marker = "extra == 'dev'" }, + { name = "claude-agent-sdk", marker = "extra == 'agent-claude-code'", specifier = ">=0.2" }, { name = "dlt", extras = ["parquet", "filesystem", "s3"], marker = "extra == 'etl'", specifier = ">=1.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "keyring", specifier = ">=25.0" }, @@ -405,8 +426,10 @@ requires-dist = [ { name = "msal", specifier = ">=1.28" }, { name = "openai", marker = "extra == 'ask-openai'", specifier = ">=1.50" }, { name = "openai", marker = "extra == 'extract-openai'", specifier = ">=1.50" }, + { name = "openai-codex", marker = "extra == 'agent-codex'", specifier = ">=0.1.0b3" }, { name = "pyarrow", marker = "extra == 'polaris'", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'agent-local'", specifier = ">=1.107,<2" }, { name = "pyiceberg", extras = ["s3fs"], marker = "extra == 'polaris'", specifier = ">=0.7" }, { name = "pypdf", marker = "extra == 'extract-claude'", specifier = ">=4.0" }, { name = "pypdf", marker = "extra == 'extract-openai'", specifier = ">=4.0" }, @@ -416,10 +439,11 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, + { name = "textual", marker = "extra == 'agent'", specifier = ">=8.2" }, { name = "tomlkit", specifier = ">=0.13" }, { name = "typer", specifier = ">=0.12" }, ] -provides-extras = ["cli", "etl", "telemetry", "extract", "extract-claude", "extract-openai", "ask", "ask-claude", "ask-openai", "mcp", "polaris", "dev"] +provides-extras = ["cli", "etl", "telemetry", "extract", "extract-claude", "extract-openai", "ask", "ask-claude", "ask-openai", "mcp", "agent-local", "agent-claude-code", "agent-codex", "agent", "polaris", "dev"] [[package]] name = "botocore" @@ -612,6 +636,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "claude-agent-sdk" +version = "0.2.101" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/df/c60107c0b5f246017ab81d628b50d0b4d7a8f43230a3a978085829754cef/claude_agent_sdk-0.2.101.tar.gz", hash = "sha256:f9663e3b8b3a79c20bffd3196525fa5734a1908c1a69ea01aea4de877901a097", size = 255630, upload-time = "2026-06-13T01:37:22.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/3e/f4dacad00720da9362675faaf08f0c3d5e9abab09ed1b21590279f1cb16c/claude_agent_sdk-0.2.101-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c07981e7fbf0cdbf2944a19f852f784c72de9764ea510cf602480eaf8749c969", size = 66210240, upload-time = "2026-06-13T01:37:25.7Z" }, + { url = "https://files.pythonhosted.org/packages/24/00/61213b804921325d378b27a1fbd1864775a136713d87873a321fce2b50c4/claude_agent_sdk-0.2.101-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:3c03f59de5d7a78509be4f804fd73a2ff67935f13b159b1d89becb3a21ff2d78", size = 68255953, upload-time = "2026-06-13T01:37:29.082Z" }, + { url = "https://files.pythonhosted.org/packages/52/ea/88e793dcadd7f3b429156f6a648bc191d1f1800aa059e309848b2b7c6bc0/claude_agent_sdk-0.2.101-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:97019d8db2d0aad5632d2f121ea88fb0310e27fc9181cf9f6bc758de640a4c35", size = 75795256, upload-time = "2026-06-13T01:37:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/40/04/768545299593e963605e99b4a97cb43565e742e994992e69b74999e126ab/claude_agent_sdk-0.2.101-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:ac43f1e0cb7980b447bcbaef7614f6cd068cfd4653c555e3dead2d72d9117133", size = 75968983, upload-time = "2026-06-13T01:37:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/00/8b/b4c906d8b18aa39afdf6f4a32f94040b9eaf2ef0481b9cd54591bab24d0d/claude_agent_sdk-0.2.101-py3-none-win_amd64.whl", hash = "sha256:9d994518a4939e17c389ae063f06c236528b4ddc23557d22f1a2685a6dc8d7a6", size = 76570206, upload-time = "2026-06-13T01:37:39.593Z" }, +] + [[package]] name = "click" version = "8.3.2" @@ -874,6 +916,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, ] +[[package]] +name = "genai-prices" +version = "0.0.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx2" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/7c/c50fc8d18b283e9b56ff625d45dbe9577c676e1d16636c1011967182d813/genai_prices-0.0.66.tar.gz", hash = "sha256:f087dfe56da28a4c3933dcf846cf2b7111ba733cef674c0cbc66de80212bcd6b", size = 71130, upload-time = "2026-06-09T21:51:14.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/87/1a36166f91906e47f430f6d4150ec6bfc0001032a363e49fc389021c0609/genai_prices-0.0.66-py3-none-any.whl", hash = "sha256:86b83f107c1cf04bb449a120cd8d4439ceb6843660d9128cde560eb511686d7b", size = 73745, upload-time = "2026-06-09T21:51:13.523Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -907,6 +962,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f9/9ff5a301459f804a885f237453ba81564bc6ee54740e9f2676c2642043f6/giturlparse-0.14.0-py2.py3-none-any.whl", hash = "sha256:04fd9c262ca9a4db86043d2ef32b2b90bfcbcdefc4f6a260fd9402127880931d", size = 16299, upload-time = "2025-10-22T09:21:10.818Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -929,6 +993,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -953,6 +1030,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "httpx2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, +] + [[package]] name = "humanize" version = "4.15.0" @@ -1202,6 +1294,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "logfire-api" +version = "4.37.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/04/471b916249fe7e22056818ca734af46418cd3ff9b9b920c1829c3627b4d2/logfire_api-4.37.0.tar.gz", hash = "sha256:0f62debd6ed593d51307277bd6d5636b57bda07935b5604b96db10fe64441af4", size = 88906, upload-time = "2026-06-12T20:47:08.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/2f/23e5b8fa22f75f73965c72e5c29e6fb8715263457394601e254fe26fbe31/logfire_api-4.37.0-py3-none-any.whl", hash = "sha256:1d756f8ba23aa56d438e0ba2c0f529a00fcac975b8785c561b058267f9465088", size = 138710, upload-time = "2026-06-12T20:47:05.526Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1214,6 +1327,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "mcp" version = "1.27.0" @@ -1239,6 +1357,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1542,6 +1672,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, ] +[[package]] +name = "openai-codex" +version = "0.1.0b3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai-codex-cli-bin" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1c/1e5e8b83ea72164d32b1f4e67fc703c8b83591f498a7aaf96f39d352b453/openai_codex-0.1.0b3.tar.gz", hash = "sha256:b76b7afe97953ac65648e9b8ca116b5ff273de91086549bd7ec88037cdc16cab", size = 58995, upload-time = "2026-06-03T19:17:34.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ef/f77037d9ccde80a688a17a06aea5a56813ad9c365d49b3f1c7913422af8b/openai_codex-0.1.0b3-py3-none-any.whl", hash = "sha256:8d1f9d346667aeecb435c6a45d0edb3f016187276ec452cf8094d813896276c4", size = 65639, upload-time = "2026-06-03T19:17:33.208Z" }, +] + +[[package]] +name = "openai-codex-cli-bin" +version = "0.137.0a4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/60/af73ef1676cd477fa83ed4b889bf3b57c63c47dd87025b2cc4262793cff6/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b33c3917e0b58d527ee11a11a78ad390f7d8e6aa25577dd21665ab3c8bf5cf9a", size = 94300191, upload-time = "2026-06-03T18:44:36.312Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/d1a5f8c87176e00ef6a85798794f4530f5eb04e5a1a13468b5b3c3a361f9/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3d0f0bc5becc88c61952fbfa9bd792ac9d74fa78b3a6bd40f545b612048b07eb", size = 83924479, upload-time = "2026-06-03T18:44:40.854Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/fc00bcdc0c302208317d5eb1d0bfaab3024f351cd0121400f19baa6b19aa/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:2f1656339e2736868c4cce59f6d9e5c633879123687169b03b1137d42bf2c11a", size = 83363315, upload-time = "2026-06-03T18:44:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/39362e944ebeb12fcbfb86881fbb4dd6e806f77f7541c1f1f993bb9351a0/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6454f838d44c56c1ed07a29b391fa412785e5dd2ffd06db0b62e62478c19bb64", size = 90611239, upload-time = "2026-06-03T18:44:49.338Z" }, + { url = "https://files.pythonhosted.org/packages/fa/38/87b1247fdfe95cddce7f7fe8331d6843cf037e14292c0f5004e23247133b/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f5ae7401d00c65d56a75d9645d7bf87d809566a12d238e4b2a8b328a02f2316e", size = 83363315, upload-time = "2026-06-03T18:44:53.428Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c4/3c693ad07e587f6b3a28128c417f2e831d81a40cdbd85c0e5f0f36aaff82/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3dcec1e649448be498d6e7ec0e1f71dca83efa76063d90890dafb41e987069b7", size = 90611238, upload-time = "2026-06-03T18:44:57.612Z" }, + { url = "https://files.pythonhosted.org/packages/9e/26/81e037066b9b8d312a6f9e09015e452ce17630d5ab88e02a4c1d9503e4e8/openai_codex_cli_bin-0.137.0a4-py3-none-win_amd64.whl", hash = "sha256:9e13bf68e18e36bd3a0efd51213281c83e9f6ec22bdb7a45bd2e0211822733a9", size = 94744969, upload-time = "2026-06-03T18:45:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/952bc2a5d62373a51fea161effe3b338b3417c2f6e65fe467ed91b205e2b/openai_codex_cli_bin-0.137.0a4-py3-none-win_arm64.whl", hash = "sha256:5ec4303ca2dcb5f838e0de3ca7f44050b6bcdd41d281a178c3a1420a985a515d", size = 86963504, upload-time = "2026-06-03T18:45:07.131Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.40.0" @@ -1932,6 +2090,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2151,6 +2318,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] +[[package]] +name = "pydantic-ai-slim" +version = "1.107.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/26/ced63dfaabbc77f3beb86d59689cdea748e7ccffb6b419dbaf4780f211e8/pydantic_ai_slim-1.107.0.tar.gz", hash = "sha256:4616f689a92fcfecfecf2a7af27aca22f139a873cf6d7a8929eaeee9c0eedbb4", size = 779902, upload-time = "2026-06-10T14:53:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/57/71044e17f931b08cc3930bc0fe5a1e1fd37fa474ae826be004729ef1cb4a/pydantic_ai_slim-1.107.0-py3-none-any.whl", hash = "sha256:1af49bbae06a6c598f72c54d4734ba377100cac493c9a05fa8e089bebeae0da6", size = 964046, upload-time = "2026-06-10T14:53:03.333Z" }, +] + +[package.optional-dependencies] +anthropic = [ + { name = "anthropic" }, +] +openai = [ + { name = "openai" }, + { name = "tiktoken" }, +] + [[package]] name = "pydantic-core" version = "2.41.5" @@ -2248,6 +2442,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pydantic-graph" +version = "1.107.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/c3/6e8c2d13b8701041f1b3eac5deb41f25d4dbfa479a190d5c6becc23f2a49/pydantic_graph-1.107.0.tar.gz", hash = "sha256:278dd89b3e33f3a2963ac949f27a53aef705c5d883a8ce5d06d23e6e3cfbd972", size = 62564, upload-time = "2026-06-10T14:53:13.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/72/621556e3f5068400d43a0375d38e5963de30256eaa5a702aba12e82ed0ff/pydantic_graph-1.107.0-py3-none-any.whl", hash = "sha256:71add94fe7e14c703977a895117c475aae6c0b02a774a036c4d00d9a63c78b00", size = 80106, upload-time = "2026-06-10T14:53:06.543Z" }, +] + [[package]] name = "pydantic-settings" version = "2.14.0" @@ -2597,6 +2806,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, + { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, + { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, + { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -2980,6 +3293,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "textual" +version = "8.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, + { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + [[package]] name = "tomlkit" version = "0.14.0" @@ -3001,6 +3385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -3046,6 +3439,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"