diff --git a/.gitignore b/.gitignore
index 8834d4ad..98321599 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,7 @@ ENV/
*.swo
*~
.claude/
+.audit/
# Testing
.pytest_cache/
@@ -44,6 +45,9 @@ kbc-explorer/catalog.js
kbc-explorer/orchestrations.json
kbc-explorer/orchestrations.js
+# Docs (plans, reports, PDFs - local only)
+docs/
+
# OS
.DS_Store
Thumbs.db
diff --git a/CLAUDE.md b/CLAUDE.md
index e62d8c6e..5a5c981e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,7 +65,7 @@ src/keboola_agent_cli/
lineage.py # LAYER 1: CLI commands for cross-project data lineage
org.py # LAYER 1: CLI commands for organization bulk onboarding
tool.py # LAYER 1: CLI commands for MCP tool list/call (supports --branch)
- branch.py # LAYER 1: CLI commands for development branch listing
+ branch.py # LAYER 1: CLI commands for branch lifecycle (list/create/use/reset/delete/merge)
explorer.py # LAYER 1: CLI commands for KBC Explorer dashboard generation
context.py # LAYER 1: Agent usage instructions
doctor.py # LAYER 1: Health check command
@@ -77,7 +77,7 @@ src/keboola_agent_cli/
lineage_service.py # LAYER 2: Cross-project lineage via bucket sharing
org_service.py # LAYER 2: Organization setup orchestration
mcp_service.py # LAYER 2: MCP tool integration (keboola-mcp-server wrapper)
- branch_service.py # LAYER 2: Development branch listing across projects
+ branch_service.py # LAYER 2: Branch lifecycle (create/use/reset/delete/merge, async job polling)
explorer_service.py # LAYER 2: KBC Explorer catalog/orchestration generation
doctor_service.py # LAYER 2: Health check business logic
@@ -95,7 +95,7 @@ tests/
test_base_service.py # BaseService unit tests (resolve, workers, parallel)
test_lineage_service.py # Lineage service tests
test_mcp_service.py # MCP service tests (incl. branch_id propagation)
- test_branch_service.py # Branch service tests (multi-project, errors)
+ test_branch_service.py # Branch service tests (lifecycle, multi-project, errors)
test_org_service.py # Org service tests (slugify, setup, idempotency)
test_explorer_service.py # Explorer service tests (tier assignment, job stats, generation)
test_doctor_service.py # Doctor service tests
@@ -185,6 +185,11 @@ kbagent tool list [--project NAME] [--branch ID]
kbagent tool call TOOL_NAME [--project NAME] [--input JSON] [--branch ID]
kbagent branch list [--project NAME]
+kbagent branch create --project ALIAS --name "..." [--description "..."]
+kbagent branch use --project ALIAS --branch ID
+kbagent branch reset --project ALIAS
+kbagent branch delete --project ALIAS --branch ID
+kbagent branch merge --project ALIAS [--branch ID]
kbagent explorer [--project NAME] [--output-dir DIR] [--job-limit N] [--tiers FILE] [--no-open]
diff --git a/docs/PLAN-catalog.md b/docs/PLAN-catalog.md
deleted file mode 100644
index 97f85360..00000000
--- a/docs/PLAN-catalog.md
+++ /dev/null
@@ -1,553 +0,0 @@
-# PLAN: `kbagent catalog generate` — Automated Catalog Generation
-
-## Problem Statement
-
-KBC Explorer (`kbc-explorer/index.html`) is a powerful CDO-grade dashboard for
-visualizing a Keboola project ecosystem. However, its data file — `catalog.json`
-(including orchestrations) — is currently produced through a **manual 7-step
-process** described in `kbc-explorer/README.md`, typically executed by giving a
-long prompt to an AI assistant.
-
-This is unsustainable because:
-- **No reproducibility** — each generation run depends on AI interpretation.
-- **No scheduling** — data goes stale; a CDO needs daily/weekly refreshes.
-- **No validation** — manual assembly can produce schema-violating output.
-- **No diffing** — no way to compare two snapshots and see what changed.
-- **Slow** — a human-in-the-loop process takes 10-20 minutes per run.
-
-## Goal
-
-A single CLI command that produces a complete, schema-valid catalog snapshot:
-
-```bash
-# Generate catalog from all registered projects
-kbagent catalog generate --output kbc-explorer/
-
-# With custom tier configuration
-kbagent catalog generate --tiers tiers.yaml --output kbc-explorer/
-
-# Dry run — show what would be fetched without writing files
-kbagent catalog generate --dry-run
-
-# JSON output of the catalog (pipe-friendly)
-kbagent --json catalog generate
-```
-
-Expected output files:
-- `catalog.json` — plain JSON (for tooling, validation), includes orchestrations under the `orchestrations` key
-- `catalog.js` — JS wrapper (`const CATALOG = {...};`) for the HTML viewer
-
----
-
-## Architecture
-
-### New Files
-
-```
-src/keboola_agent_cli/
- commands/catalog.py # LAYER 1: CLI command
- services/catalog_service.py # LAYER 2: Orchestration + aggregation logic
-```
-
-### Layer Responsibilities
-
-Following the existing 3-layer architecture:
-
-```
-commands/catalog.py → CatalogService → KeboolaClient
- CLI args, output Orchestration HTTP calls
- Aggregation
- Schema validation
-```
-
-`CatalogService` reuses the existing service layer:
-- `ConfigService.list_configs()` — configuration data
-- `JobService.list_jobs()` — job data (raw, for aggregation)
-- `LineageService.get_lineage()` — sharing/lineage data
-- `ConfigService.get_config_detail()` — orchestration flow details
-
-No new API endpoints are needed. All data is already fetchable.
-
----
-
-## Tier Configuration
-
-Tiers (L0/L1/L2) cannot be inferred from the Keboola API — they represent a
-business-level classification. The user must provide a tier mapping.
-
-### Option A: YAML config file (recommended)
-
-```yaml
-# tiers.yaml
-description: "IR (Internal Reporting) project ecosystem"
-
-tiers:
- L0:
- name: "Data Sources / Extraction"
- description: "Raw data extraction from external systems"
- L1:
- name: "Data Processing / Transformation"
- description: "Business logic, transformations, data modeling"
- L2:
- name: "Data Output / Delivery"
- description: "Final outputs - BI dashboards, data sharing"
-
-# Mapping: project alias -> tier
-projects:
- ir-l0-finance: L0
- ir-l0-kbc-telemetry-to-catalog: L0
- ir-l0-marketing: L0
- ir-l1-data-processes-product: L1
- ir-l2-internal-bi-output: L2
- # ...
-```
-
-### Option B: Convention-based auto-detection
-
-If no tier config is provided, attempt to infer tier from alias patterns:
-- `*-l0-*` or `*-l0` → L0
-- `*-l1-*` or `*-l1` → L1
-- `*-l2-*` or `*-l2` → L2
-- Everything else → a configurable default tier or `UNCLASSIFIED`
-
-### Decision
-
-Support **both**: auto-detection as fallback, `--tiers` file as override. If a
-project appears in `--tiers` file, use that. If not, try alias-based convention.
-If neither works, assign `UNCLASSIFIED` and emit a warning.
-
----
-
-## Data Collection Pipeline
-
-### Phase 1: Parallel fetch (per-project)
-
-Uses `BaseService._run_parallel()` with `ThreadPoolExecutor`:
-
-```
-For each registered project (in parallel):
- ├── 1. config list → raw component/config data
- ├── 2. job list --limit 500 → raw job data
- └── 3. lineage → sharing_out, sharing_in, edges
-```
-
-All three calls per project can also run in parallel (they are independent),
-but the current `_run_parallel` pattern groups by project. This is fine for
-the initial implementation. A future optimization could issue all three per
-project concurrently via `asyncio` or nested thread pools.
-
-### Phase 2: Orchestration detail fetch (selective, parallel)
-
-From Phase 1 config data, identify all `keboola.orchestrator` configurations.
-Then fetch their details in parallel:
-
-```
-For each orchestrator config across all projects (in parallel):
- └── config detail (component_id=keboola.orchestrator, config_id=X)
- → phase/task structure, mermaid graph
-```
-
-### Phase 3: Aggregation (local, CPU-bound)
-
-No API calls. Pure computation:
-
-```
-For each project:
- ├── Configurations: group by type, count totals
- ├── Job stats: compute aggregates from raw jobs
- │ ├── total_jobs
- │ ├── status_counts (success, error, warning, cancelled, terminated)
- │ ├── success_rate_pct = success / total * 100
- │ ├── avg_duration_seconds = sum(durations) / total
- │ ├── date_range (earliest, latest timestamps)
- │ ├── component_stats (per component_id: success, error, other, total)
- │ └── failing_configs (configs with error_rate > 0, sorted desc)
- ├── Sharing: already structured from lineage service
- └── Tier: look up from tiers config or infer from alias
-
-Global:
- ├── lineage.edges: deduplicated cross-project edges
- └── lineage.summary: count aggregates
-```
-
-### Phase 4: Assembly & Validation
-
-```
-1. Assemble catalog.json structure (metadata + tiers + projects + lineage + orchestrations)
-2. Validate against schema.json (using jsonschema library)
-3. Write output files:
- ├── catalog.json
- └── catalog.js (wrapped: const CATALOG = ...;)
-```
-
----
-
-## Job Statistics Aggregation Logic
-
-This is the most complex transformation. Given raw jobs from Queue API:
-
-```python
-def aggregate_job_stats(jobs: list[dict]) -> dict:
- """
- Input: raw job list from Queue API
- Each job has: status, durationSeconds, createdTime, component.id,
- config.id, etc.
-
- Output: job_stats object matching schema.json#/definitions/job_stats
- """
- if not jobs:
- return {
- "total_jobs": 0,
- "status_counts": {},
- "success_rate_pct": 0,
- "avg_duration_seconds": 0,
- "date_range": {"earliest": None, "latest": None},
- "component_stats": {},
- "failing_configs": []
- }
-
- # Status counts
- status_counts = Counter(j["status"] for j in jobs)
- total = len(jobs)
- success = status_counts.get("success", 0)
-
- # Success rate
- success_rate = (success / total * 100) if total > 0 else 0
-
- # Average duration
- durations = [j.get("durationSeconds", 0) for j in jobs if j.get("durationSeconds")]
- avg_duration = sum(durations) / len(durations) if durations else 0
-
- # Date range
- timestamps = [j["createdTime"] for j in jobs if j.get("createdTime")]
- earliest = min(timestamps) if timestamps else None
- latest = max(timestamps) if timestamps else None
-
- # Component stats
- comp_stats = defaultdict(lambda: {"success": 0, "error": 0, "other": 0, "total": 0})
- for j in jobs:
- comp_id = j.get("component", {}).get("id", "unknown")
- comp_stats[comp_id]["total"] += 1
- if j["status"] == "success":
- comp_stats[comp_id]["success"] += 1
- elif j["status"] == "error":
- comp_stats[comp_id]["error"] += 1
- else:
- comp_stats[comp_id]["other"] += 1
-
- # Failing configs
- config_runs = defaultdict(lambda: {"error": 0, "total": 0, "last_run": None, "component_id": ""})
- for j in jobs:
- comp_id = j.get("component", {}).get("id", "unknown")
- cfg_id = j.get("config", {}).get("id", "unknown")
- key = f"{comp_id}/{cfg_id}"
- config_runs[key]["total"] += 1
- config_runs[key]["component_id"] = comp_id
- if j["status"] == "error":
- config_runs[key]["error"] += 1
- ts = j.get("createdTime")
- if ts and (not config_runs[key]["last_run"] or ts > config_runs[key]["last_run"]):
- config_runs[key]["last_run"] = ts
-
- failing = [
- {
- "config_key": key,
- "component_id": data["component_id"],
- "error_count": data["error"],
- "total_runs": data["total"],
- "error_rate_pct": round(data["error"] / data["total"] * 100, 1),
- "last_run": data["last_run"]
- }
- for key, data in config_runs.items()
- if data["error"] > 0
- ]
- failing.sort(key=lambda x: x["error_rate_pct"], reverse=True)
-
- return {
- "total_jobs": total,
- "status_counts": dict(status_counts),
- "success_rate_pct": round(success_rate, 1),
- "avg_duration_seconds": round(avg_duration),
- "date_range": {"earliest": earliest, "latest": latest},
- "component_stats": dict(comp_stats),
- "failing_configs": failing
- }
-```
-
----
-
-## Orchestration Assembly Logic
-
-For each `keboola.orchestrator` config, the detail response contains phases
-and tasks. The assembly transforms this into the `orchestrations` entry format:
-
-```python
-def assemble_orchestration(alias: str, config_detail: dict) -> dict:
- """
- Transform config detail response into orchestration catalog entry.
-
- Input: raw response from client.get_config_detail()
- Output: orchestration entry with phases, tasks, mermaid graph
- """
- config = config_detail
- rows = config.get("rows", []) # phases come from config rows or configuration.phases
-
- # Extract phases and tasks from the orchestrator configuration
- phases = config.get("configuration", {}).get("phases", [])
-
- assembled_phases = []
- for phase in phases:
- tasks = []
- for task in phase.get("tasks", []):
- comp_id = task.get("task", {}).get("componentId", "")
- comp_short = comp_id.replace("keboola.", "").replace("ex-generic-v2", "generic-extractor")
- type_map = {
- "extractor": "EX", "writer": "WR",
- "transformation": "TR", "application": "AP"
- }
- # Determine type icon from component type
- type_icon = type_map.get(task.get("task", {}).get("type", ""), "OT")
-
- tasks.append({
- "name": task.get("name", ""),
- "component_id": comp_id,
- "component_short": comp_short,
- "config_id": task.get("task", {}).get("configId", ""),
- "enabled": task.get("enabled", True),
- "continue_on_failure": task.get("continueOnFailure", False),
- "type_icon": type_icon
- })
-
- assembled_phases.append({
- "id": phase.get("id", 0),
- "name": phase.get("name", f"Phase {phase.get('id', '?')}"),
- "depends_on": phase.get("dependsOn", []),
- "tasks": tasks
- })
-
- total_tasks = sum(len(p["tasks"]) for p in assembled_phases)
-
- # Generate Mermaid graph
- mermaid = generate_mermaid(assembled_phases)
-
- return {
- "project_alias": alias,
- "config_id": config.get("id", ""),
- "name": config.get("name", ""),
- "description": config.get("description", ""),
- "is_disabled": config.get("isDisabled", False),
- "version": config.get("version", 0),
- "last_modified": config.get("changeDescription", ""),
- "last_modified_by": config.get("creatorToken", {}).get("description", ""),
- "phases": assembled_phases,
- "total_tasks": total_tasks,
- "total_phases": len(assembled_phases),
- "mermaid": mermaid
- }
-```
-
----
-
-## CLI Command Interface
-
-### `kbagent catalog generate`
-
-```
-Usage: kbagent catalog generate [OPTIONS]
-
- Generate catalog.json (with orchestrations) for KBC Explorer.
-
-Options:
- --output DIR Output directory (default: ./kbc-explorer/)
- --tiers FILE Tier configuration YAML file (optional)
- --job-limit N Max jobs to fetch per project (default: 500)
- --skip-orchestrations Skip fetching orchestration details (faster)
- --validate-only Only validate existing catalog against schema
- --dry-run Show what would be fetched, don't write files
- --project ALIAS Only generate for specific project(s) (repeatable)
-```
-
-### `kbagent catalog validate`
-
-```
-Usage: kbagent catalog validate [OPTIONS]
-
- Validate catalog.json against the JSON schema.
-
-Options:
- --catalog FILE Path to catalog.json (default: ./kbc-explorer/catalog.json)
- --schema FILE Path to schema.json (default: ./kbc-explorer/schema.json)
-```
-
-### `kbagent catalog diff`
-
-```
-Usage: kbagent catalog diff [OPTIONS] OLD_CATALOG NEW_CATALOG
-
- Compare two catalog snapshots and show what changed.
-
-Options:
- --format TEXT Output format: text, json (default: text)
-
-Output:
- - New/removed projects
- - Config count changes per project
- - Success rate changes
- - New/removed lineage edges
- - New/removed orchestrations
-```
-
----
-
-## Implementation Plan
-
-### Step 1: Tier configuration loader
-
-**File:** `src/keboola_agent_cli/services/catalog_service.py`
-
-- Parse `tiers.yaml` if provided
-- Fallback to alias-based convention detection (`*-l0-*` → L0)
-- Validate that every registered project has a tier assignment
-- Emit warnings for unclassified projects
-
-### Step 2: CatalogService core — data fetching
-
-**File:** `src/keboola_agent_cli/services/catalog_service.py`
-
-- `generate(output_dir, tiers_config, job_limit, skip_orchestrations)` — main entry point
-- `_fetch_all_projects()` — parallel fetch configs + jobs + lineage for all projects
-- `_fetch_orchestrations(orchestrator_configs)` — parallel fetch config details
-- Reuse existing services via composition (not inheritance):
- ```python
- class CatalogService:
- def __init__(self, config_store, client_factory):
- self.config_svc = ConfigService(config_store, client_factory)
- self.job_svc = JobService(config_store, client_factory)
- self.lineage_svc = LineageService(config_store, client_factory)
- ```
-
-### Step 3: Aggregation functions
-
-**File:** `src/keboola_agent_cli/services/catalog_service.py`
-
-- `_aggregate_job_stats(raw_jobs)` — compute all job_stats fields
-- `_build_configurations(raw_configs)` — group by type, count
-- `_build_lineage(all_project_lineage)` — deduplicate edges, compute summary
-- `_build_orchestrations(flow_details)` — assemble orchestration entries for catalog
-
-### Step 4: Schema validation
-
-**File:** `src/keboola_agent_cli/services/catalog_service.py`
-
-- Load `kbc-explorer/schema.json`
-- Validate assembled catalog against it using `jsonschema` library
-- Report all validation errors with JSONPath locations
-- New dependency: `jsonschema` (add to pyproject.toml)
-
-### Step 5: Output writers
-
-**File:** `src/keboola_agent_cli/services/catalog_service.py`
-
-- `_write_json(data, path)` — write JSON with consistent formatting
-- `_write_js_wrapper(data, variable_name, path)` — write `const X = {...};`
-- Write both files atomically (write to .tmp, then rename)
-
-### Step 6: CLI command
-
-**File:** `src/keboola_agent_cli/commands/catalog.py`
-
-- Typer command group: `catalog`
-- Subcommands: `generate`, `validate`, `diff`
-- Wire into `cli.py` app
-- Rich progress display: show per-project progress during fetch
-- JSON mode support via `OutputFormatter`
-
-### Step 7: Tests
-
-**File:** `tests/test_catalog_service.py`
-
-- Unit tests for `_aggregate_job_stats()` with various job distributions
-- Unit tests for tier assignment (YAML + convention fallback)
-- Unit tests for configuration grouping
-- Unit tests for lineage edge deduplication
-- Integration test: mock all API calls, verify full catalog output matches schema
-- Validate that generated catalog.json passes schema.json validation
-
----
-
-## Future Enhancements (Tier 2 & 3)
-
-### KBC Explorer — Visualization Gaps
-
-These represent CDO questions that the current explorer cannot answer:
-
-| Feature | CDO Question | Data Source |
-|---------|-------------|-------------|
-| **Data freshness** | "When did jobs last run? Is data current?" | `job_stats.date_range.latest` (already in catalog) + real-time check |
-| **Cost/volume metrics** | "How many credits per tier? Costliest project?" | Keboola Telemetry API (new data source) |
-| **Change timeline** | "What changed this week? New configs, deleted flows?" | `catalog diff` between snapshots |
-| **Alerting rules view** | "Which projects have no monitoring?" | Custom metadata (not in API) |
-| **Flow Gantt chart** | "What runs in parallel vs sequential?" | `catalog.orchestrations` phases (already available) |
-| **Table-level lineage** | "Where does table X originate and flow to?" | MCP `get_lineage` tool or Storage API metadata |
-| **Config parameter audit** | "Which extractors target which external systems?" | Would require config parameter access (security risk) |
-
-### kbagent CLI — Agent Gaps
-
-| Feature | Command | Purpose |
-|---------|---------|---------|
-| `kbagent catalog watch` | `catalog generate --watch --interval 6h` | Scheduled regeneration with delta detection |
-| `kbagent project health` | `project health [--project ALIAS]` | Composite health score: success_rate * freshness * config_coverage |
-| `kbagent flow list` | `flow list [--project ALIAS]` | Dedicated flow listing (currently buried in config detail) |
-| `kbagent flow run` | `flow run --project ALIAS --flow-id ID` | Trigger flow execution via Queue API |
-| `kbagent catalog publish` | `catalog publish --to s3://...` | Upload catalog snapshot to S3/GCS for hosted explorer |
-
----
-
-## Dependencies
-
-### New Python packages
-
-| Package | Purpose | Already in project? |
-|---------|---------|---------------------|
-| `jsonschema` | Validate catalog against schema.json | No — add to pyproject.toml |
-| `pyyaml` | Parse tiers.yaml config | No — add to pyproject.toml |
-
-### Existing packages (no changes)
-
-- `httpx` — HTTP client (used by KeboolaClient)
-- `typer` — CLI framework
-- `rich` — Progress bars, tables
-- `pydantic` — Models
-
----
-
-## Estimated Effort
-
-| Step | Description | Size |
-|------|-------------|------|
-| 1 | Tier config loader + YAML parsing | Small |
-| 2 | CatalogService data fetching (reuses existing services) | Medium |
-| 3 | Aggregation functions (job stats, lineage, configs) | Medium |
-| 4 | Schema validation integration | Small |
-| 5 | Output writers (JSON + JS wrappers) | Small |
-| 6 | CLI command + Rich progress | Medium |
-| 7 | Tests | Medium |
-| **Total** | | **~400-600 lines of new code + tests** |
-
-The biggest risk is in Step 3 (aggregation) — the job stats calculation has
-many edge cases (empty projects, projects with no jobs, malformed timestamps).
-The reference implementation above covers these, but thorough testing is needed.
-
----
-
-## Success Criteria
-
-1. `kbagent catalog generate` produces identical output to the current manually
- generated `catalog.json` (modulo timestamp and ordering differences).
-2. Output passes `jsonschema` validation against `schema.json`.
-3. Full generation for 27 projects completes in under 60 seconds.
-4. `--dry-run` shows expected API call count without making requests.
-5. Tier auto-detection correctly classifies all `ir-l0-*`, `ir-l1-*`, `ir-l2-*`
- projects without a config file.
-6. Error in one project does not block generation for others (error accumulation
- pattern, consistent with existing multi-project commands).
diff --git a/kbc-explorer/index.html b/kbc-explorer/index.html
index f1466181..53d8a807 100644
--- a/kbc-explorer/index.html
+++ b/kbc-explorer/index.html
@@ -992,6 +992,11 @@
KBC Explorer
'use strict';
const $ = id => document.getElementById(id);
+ const escapeHtml = (str) => {
+ const div = document.createElement('div');
+ div.textContent = String(str ?? '');
+ return div.innerHTML;
+ };
const h = (tag, attrs, ...children) => {
const el = document.createElement(tag);
if (attrs) Object.entries(attrs).forEach(([k, v]) => {
@@ -2344,12 +2349,12 @@ KBC Explorer
const sharesOut = (adjOut[ne.data.alias] || []).length;
const sharesIn = (adjIn[ne.data.alias] || []).length;
- tooltip.innerHTML = `${proj.name}
- Tier${ne.data.tier}
- Configs${configs}
- Success rate${rateStr}
- Shares out${sharesOut}
- Shares in${sharesIn}
`;
+ tooltip.innerHTML = `${escapeHtml(proj.name)}
+ Tier${escapeHtml(ne.data.tier)}
+ Configs${escapeHtml(configs)}
+ Success rate${escapeHtml(rateStr)}
+ Shares out${escapeHtml(sharesOut)}
+ Shares in${escapeHtml(sharesIn)}
`;
tooltip.style.display = 'block';
}
}
@@ -2645,9 +2650,9 @@ KBC Explorer
if (selectedNode) return;
const s = nodeMap[ee.data.source];
const t = nodeMap[ee.data.target];
- tooltip.innerHTML = `${s?.name || ee.data.source} \u2192 ${t?.name || ee.data.target}
+ tooltip.innerHTML = `${escapeHtml(s?.name || ee.data.source)} \u2192 ${escapeHtml(t?.name || ee.data.target)}
- ${ee.data.buckets.map(b => `${b}`).join('')}
+ ${ee.data.buckets.map(b => `${escapeHtml(b)}`).join('')}
`;
tooltip.style.display = 'block';
});
diff --git a/pyproject.toml b/pyproject.toml
index 4d552a70..7713be35 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "keboola-agent-cli"
-version = "0.5.0"
+version = "0.6.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
@@ -54,4 +54,6 @@ dev = [
"pytest-httpx>=0.30",
"pytest-asyncio>=0.23",
"ruff>=0.8",
+ "bandit>=1.9.4",
+ "pip-audit>=2.10.0",
]
diff --git a/src/keboola_agent_cli/__init__.py b/src/keboola_agent_cli/__init__.py
index 9986b929..7f9807e7 100644
--- a/src/keboola_agent_cli/__init__.py
+++ b/src/keboola_agent_cli/__init__.py
@@ -1,3 +1,3 @@
"""Keboola Agent CLI - AI-friendly interface to Keboola projects."""
-__version__ = "0.5.0"
+__version__ = "0.6.0"
diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py
index c7794080..b580c58a 100644
--- a/src/keboola_agent_cli/cli.py
+++ b/src/keboola_agent_cli/cli.py
@@ -12,9 +12,11 @@
from .commands.explorer import explorer_app
from .commands.job import job_app
from .commands.lineage import lineage_app
+from .commands.llm import llm_app
from .commands.org import org_app
from .commands.project import project_app
from .commands.tool import tool_app
+from .commands.version import version_command
from .config_store import ConfigStore
from .output import OutputFormatter
from .services.branch_service import BranchService
@@ -22,10 +24,12 @@
from .services.doctor_service import DoctorService
from .services.explorer_service import ExplorerService
from .services.job_service import JobService
+from .services.kbc_service import KbcService
from .services.lineage_service import LineageService
from .services.mcp_service import McpService
from .services.org_service import OrgService
from .services.project_service import ProjectService
+from .services.version_service import VersionService
app = typer.Typer(
name="kbagent",
@@ -41,8 +45,10 @@
app.add_typer(tool_app, name="tool")
app.add_typer(branch_app, name="branch")
app.add_typer(explorer_app, name="explorer")
+app.add_typer(llm_app, name="llm")
app.command("context")(context_command)
app.command("doctor")(doctor_command)
+app.command("version")(version_command)
@app.callback()
@@ -92,6 +98,7 @@ def main(
org_service = OrgService(config_store=config_store)
mcp_service = McpService(config_store=config_store)
branch_service = BranchService(config_store=config_store)
+ kbc_service = KbcService(config_store=config_store)
doctor_service = DoctorService(config_store=config_store, mcp_service=mcp_service)
explorer_service = ExplorerService(
config_store=config_store,
@@ -99,6 +106,7 @@ def main(
job_service=job_service,
lineage_service=lineage_service,
)
+ version_service = VersionService()
ctx.ensure_object(dict)
ctx.obj["formatter"] = formatter
@@ -113,5 +121,7 @@ def main(
ctx.obj["org_service"] = org_service
ctx.obj["mcp_service"] = mcp_service
ctx.obj["branch_service"] = branch_service
+ ctx.obj["kbc_service"] = kbc_service
ctx.obj["doctor_service"] = doctor_service
ctx.obj["explorer_service"] = explorer_service
+ ctx.obj["version_service"] = version_service
diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py
index fe498488..796440fc 100644
--- a/src/keboola_agent_cli/client.py
+++ b/src/keboola_agent_cli/client.py
@@ -8,13 +8,20 @@
"""
import logging
+import time
from typing import Any
from urllib.parse import quote, urlparse, urlunparse
import httpx
from . import __version__
-from .constants import DEFAULT_JOB_LIMIT, DEFAULT_TIMEOUT
+from .constants import (
+ DEFAULT_JOB_LIMIT,
+ DEFAULT_TIMEOUT,
+ STORAGE_JOB_MAX_WAIT,
+ STORAGE_JOB_POLL_INTERVAL,
+)
+from .errors import KeboolaApiError
from .http_base import BaseHttpClient
from .models import TokenVerifyResponse
@@ -144,6 +151,83 @@ def get_config_detail(self, component_id: str, config_id: str) -> dict[str, Any]
)
return response.json()
+ def _wait_for_storage_job(self, job: dict[str, Any]) -> dict[str, Any]:
+ """Poll a Storage API job until it reaches a terminal state.
+
+ Branch create/delete are async operations that return a job object.
+ This method polls until the job completes or fails.
+
+ Args:
+ job: Initial job response from POST/DELETE.
+
+ Returns:
+ Completed job dict (with results on success).
+
+ Raises:
+ KeboolaApiError: If the job fails or times out.
+ """
+ job_id = job.get("id")
+ if job.get("status") in ("success", "error"):
+ return job
+
+ deadline = time.monotonic() + STORAGE_JOB_MAX_WAIT
+ while time.monotonic() < deadline:
+ time.sleep(STORAGE_JOB_POLL_INTERVAL)
+ response = self._request("GET", f"/v2/storage/jobs/{job_id}")
+ job = response.json()
+ status = job.get("status")
+ if status == "success":
+ return job
+ if status == "error":
+ error_msg = job.get("error", {}).get("message", "Storage job failed")
+ raise KeboolaApiError(
+ message=error_msg,
+ status_code=500,
+ error_code="STORAGE_JOB_FAILED",
+ retryable=False,
+ )
+ raise KeboolaApiError(
+ message=f"Storage job {job_id} did not complete within {STORAGE_JOB_MAX_WAIT}s",
+ status_code=504,
+ error_code="STORAGE_JOB_TIMEOUT",
+ retryable=True,
+ )
+
+ def create_dev_branch(self, name: str, description: str = "") -> dict[str, Any]:
+ """Create a new development branch (waits for async job to complete).
+
+ The Storage API returns an async job. This method polls until the job
+ completes and returns the branch data from the job results.
+
+ Args:
+ name: Branch name.
+ description: Optional branch description.
+
+ Returns:
+ Branch dict with id, name, description, created, etc.
+
+ Raises:
+ KeboolaApiError: If the API call or job fails.
+ """
+ body: dict[str, str] = {"name": name}
+ if description:
+ body["description"] = description
+ response = self._request("POST", "/v2/storage/dev-branches", json=body)
+ job = self._wait_for_storage_job(response.json())
+ return job.get("results", {})
+
+ def delete_dev_branch(self, branch_id: int) -> None:
+ """Delete a development branch (waits for async job to complete).
+
+ Args:
+ branch_id: The branch ID to delete.
+
+ Raises:
+ KeboolaApiError: If the API call or job fails.
+ """
+ response = self._request("DELETE", f"/v2/storage/dev-branches/{branch_id}")
+ self._wait_for_storage_job(response.json())
+
def list_dev_branches(self) -> list[dict[str, Any]]:
"""List development branches for the project.
diff --git a/src/keboola_agent_cli/commands/branch.py b/src/keboola_agent_cli/commands/branch.py
index 39f63502..f60dd413 100644
--- a/src/keboola_agent_cli/commands/branch.py
+++ b/src/keboola_agent_cli/commands/branch.py
@@ -1,4 +1,4 @@
-"""Branch commands - list development branches.
+"""Branch commands - list, create, use, reset, delete, and merge development branches.
Thin CLI layer: parses arguments, calls BranchService, formats output.
No business logic belongs here.
@@ -6,9 +6,9 @@
import typer
-from ..errors import ConfigError
+from ..errors import ConfigError, KeboolaApiError
from ..output import format_branches_table
-from ._helpers import emit_project_warnings, get_formatter, get_service
+from ._helpers import emit_project_warnings, get_formatter, get_service, map_error_to_exit_code
branch_app = typer.Typer(help="Manage development branches")
@@ -37,3 +37,204 @@ def branch_list(
else:
format_branches_table(formatter.console, result)
emit_project_warnings(formatter, result)
+
+
+@branch_app.command("create")
+def branch_create(
+ ctx: typer.Context,
+ project: str = typer.Option(
+ ...,
+ "--project",
+ help="Project alias to create the branch in",
+ ),
+ name: str = typer.Option(
+ ...,
+ "--name",
+ help="Name for the new development branch",
+ ),
+ description: str = typer.Option(
+ "",
+ "--description",
+ help="Optional description for the branch",
+ ),
+) -> None:
+ """Create a new development branch and auto-activate it.
+
+ The created branch becomes the active branch for the project,
+ so subsequent tool calls will automatically use it.
+ """
+ formatter = get_formatter(ctx)
+ service = get_service(ctx, "branch_service")
+
+ try:
+ result = service.create_branch(alias=project, name=name, description=description)
+ formatter.output(
+ result,
+ lambda c, d: c.print(
+ f"[bold green]Success:[/bold green] {d['message']}"
+ ),
+ )
+ except KeboolaApiError as exc:
+ exit_code = map_error_to_exit_code(exc)
+ formatter.error(
+ message=exc.message,
+ error_code=exc.error_code,
+ retryable=exc.retryable,
+ )
+ raise typer.Exit(code=exit_code) from None
+ except ConfigError as exc:
+ formatter.error(message=exc.message, error_code="CONFIG_ERROR")
+ raise typer.Exit(code=5) from None
+
+
+@branch_app.command("use")
+def branch_use(
+ ctx: typer.Context,
+ project: str = typer.Option(
+ ...,
+ "--project",
+ help="Project alias to set the active branch for",
+ ),
+ branch: int = typer.Option(
+ ...,
+ "--branch",
+ help="Branch ID to activate",
+ ),
+) -> None:
+ """Set an existing development branch as active.
+
+ Validates the branch exists via the API before activating it.
+ Subsequent tool calls will automatically use this branch.
+ """
+ formatter = get_formatter(ctx)
+ service = get_service(ctx, "branch_service")
+
+ try:
+ result = service.set_active_branch(alias=project, branch_id=branch)
+ formatter.output(
+ result,
+ lambda c, d: c.print(
+ f"[bold green]Success:[/bold green] {d['message']}"
+ ),
+ )
+ except KeboolaApiError as exc:
+ exit_code = map_error_to_exit_code(exc)
+ formatter.error(
+ message=exc.message,
+ error_code=exc.error_code,
+ retryable=exc.retryable,
+ )
+ raise typer.Exit(code=exit_code) from None
+ except ConfigError as exc:
+ formatter.error(message=exc.message, error_code="CONFIG_ERROR")
+ raise typer.Exit(code=5) from None
+
+
+@branch_app.command("reset")
+def branch_reset(
+ ctx: typer.Context,
+ project: str = typer.Option(
+ ...,
+ "--project",
+ help="Project alias to reset the active branch for",
+ ),
+) -> None:
+ """Reset the active branch back to main/production.
+
+ Clears the active development branch so subsequent tool calls
+ operate on the main branch.
+ """
+ formatter = get_formatter(ctx)
+ service = get_service(ctx, "branch_service")
+
+ try:
+ result = service.reset_branch(alias=project)
+ formatter.output(
+ result,
+ lambda c, d: c.print(
+ f"[bold green]Success:[/bold green] {d['message']}"
+ ),
+ )
+ except ConfigError as exc:
+ formatter.error(message=exc.message, error_code="CONFIG_ERROR")
+ raise typer.Exit(code=5) from None
+
+
+@branch_app.command("delete")
+def branch_delete(
+ ctx: typer.Context,
+ project: str = typer.Option(
+ ...,
+ "--project",
+ help="Project alias to delete the branch from",
+ ),
+ branch: int = typer.Option(
+ ...,
+ "--branch",
+ help="Branch ID to delete",
+ ),
+) -> None:
+ """Delete a development branch.
+
+ If the deleted branch was the active branch, it is automatically
+ reset to main/production.
+ """
+ formatter = get_formatter(ctx)
+ service = get_service(ctx, "branch_service")
+
+ try:
+ result = service.delete_branch(alias=project, branch_id=branch)
+ formatter.output(
+ result,
+ lambda c, d: c.print(
+ f"[bold green]Success:[/bold green] {d['message']}"
+ ),
+ )
+ except KeboolaApiError as exc:
+ exit_code = map_error_to_exit_code(exc)
+ formatter.error(
+ message=exc.message,
+ error_code=exc.error_code,
+ retryable=exc.retryable,
+ )
+ raise typer.Exit(code=exit_code) from None
+ except ConfigError as exc:
+ formatter.error(message=exc.message, error_code="CONFIG_ERROR")
+ raise typer.Exit(code=5) from None
+
+
+@branch_app.command("merge")
+def branch_merge(
+ ctx: typer.Context,
+ project: str = typer.Option(
+ ...,
+ "--project",
+ help="Project alias",
+ ),
+ branch: int | None = typer.Option(
+ None,
+ "--branch",
+ help="Branch ID to merge (uses active branch if not set)",
+ ),
+) -> None:
+ """Get the KBC UI merge URL for a development branch.
+
+ Does NOT perform the merge via API. Instead, generates the URL
+ to the Keboola UI where you can review and merge safely.
+ After displaying the URL, resets the active branch to main.
+ """
+ formatter = get_formatter(ctx)
+ service = get_service(ctx, "branch_service")
+
+ try:
+ result = service.get_merge_url(alias=project, branch_id=branch)
+ formatter.output(
+ result,
+ lambda c, d: (
+ c.print(f"\n[bold]Merge URL:[/bold] {d['url']}"),
+ c.print(f"\n{d['message']}"),
+ ),
+ )
+ except ConfigError as exc:
+ formatter.error(message=exc.message, error_code="CONFIG_ERROR")
+ raise typer.Exit(code=5) from None
diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py
index d0930b72..abb90bbc 100644
--- a/src/keboola_agent_cli/commands/config.py
+++ b/src/keboola_agent_cli/commands/config.py
@@ -6,14 +6,13 @@
import typer
+from ..constants import VALID_COMPONENT_TYPES
from ..errors import ConfigError, KeboolaApiError
from ..output import format_config_detail, format_configs_table
from ._helpers import emit_project_warnings, get_formatter, get_service, map_error_to_exit_code
config_app = typer.Typer(help="Browse and inspect configurations")
-VALID_COMPONENT_TYPES = ["extractor", "writer", "transformation", "application"]
-
@config_app.command("list")
def config_list(
diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py
index c06a9453..646007e5 100644
--- a/src/keboola_agent_cli/commands/context.py
+++ b/src/keboola_agent_cli/commands/context.py
@@ -188,17 +188,85 @@
my-l1-transform: L1
unclassified-project: L0 # TODO: assign correct tier
+### LLM Export (Project Context for AI)
+
+ kbagent llm export --project ALIAS [--with-samples] [--sample-limit N] [--max-samples N]
+ Export project to "Twin Format" -- an AI-optimized directory of JSON files
+ containing table schemas, transformation SQL code, internal lineage graph,
+ job statistics, and component configurations.
+ Output is written to ./${{ALIAS}}/ directory (created automatically).
+ Requires the kbc CLI binary (brew install keboola-cli).
+
+ After export, start by reading ./${{ALIAS}}/ai/AGENT_INSTRUCTIONS.md -- it explains
+ the directory structure, how to interpret each file, and recommended workflows.
+
+ The twin format includes:
+ - ai/AGENT_INSTRUCTIONS.md -- how to read and use the exported data
+ - buckets/*/metadata.json -- table schemas, columns, row counts
+ - transformations/*/metadata.json -- SQL/Python code, input/output tables
+ - indices/graph.jsonl -- internal lineage (table->transformation->table)
+ - jobs/index.json -- job execution stats
+ - components/*/ -- extractor/writer configurations
+
+ Use --with-samples to include actual data samples (CSV) from tables.
+
+ Examples:
+ kbagent llm export --project prod
+ kbagent llm export --project prod --with-samples --sample-limit 50
+
+### Version Information
+
+ kbagent version
+ Show kbagent version and check for updates of dependencies (kbc, keboola-mcp-server).
+ Example:
+ kbagent --json version
+
### Development Branches
kbagent branch list [--project NAME]
List development branches from connected projects.
--project can be repeated to query multiple projects.
- Each branch shows: ID, name, whether it is the default branch, and creation date.
+ Each branch shows: ID, name, whether it is the default branch, active marker, and creation date.
Examples:
kbagent --json branch list
kbagent --json branch list --project prod
kbagent --json branch list --project prod --project dev
+ kbagent branch create --project ALIAS --name "branch-name" [--description "..."]
+ Create a new development branch and auto-activate it.
+ Branch creation is an async operation on the Keboola API -- the CLI waits
+ for the job to complete (typically 1-3 seconds) before returning.
+ The created branch becomes the active branch for the project, so subsequent
+ tool calls will automatically use it (no need to pass --branch every time).
+ Example:
+ kbagent --json branch create --project prod --name "fix-transform-x"
+
+ kbagent branch use --project ALIAS --branch ID
+ Set an existing development branch as active.
+ Validates the branch exists via the API before activating it.
+ Example:
+ kbagent --json branch use --project prod --branch 456
+
+ kbagent branch reset --project ALIAS
+ Reset the active branch back to main/production.
+ Subsequent tool calls will operate on the main branch.
+ Example:
+ kbagent --json branch reset --project prod
+
+ kbagent branch delete --project ALIAS --branch ID
+ Delete a development branch via API (async operation, CLI waits for completion).
+ If the deleted branch was active, it is automatically reset to main.
+ Example:
+ kbagent --json branch delete --project prod --branch 456
+
+ kbagent branch merge --project ALIAS [--branch ID]
+ Get the KBC UI merge URL for a development branch.
+ Does NOT merge via API -- generates the URL for safe review and merge in the UI.
+ If --branch is not set, uses the active branch from config.
+ After displaying the URL, resets the active branch to main.
+ Example:
+ kbagent --json branch merge --project prod
+
### Utility Commands
kbagent context
@@ -281,7 +349,29 @@
KBC_STORAGE_API_URL - Default stack URL (fallback for --url in project add, org setup)
KBC_MANAGE_API_TOKEN - Manage API token (for org setup)
-11. Setting up projects -- two approaches:
+11. Branch workflow -- develop on a branch without passing --branch every time:
+ kbagent --json branch create --project prod --name "fix-transform-x"
+ # ^ creates the branch AND sets it as "active" for the project
+ # all subsequent tool calls on this project auto-use this branch
+ kbagent --json tool call list_configs --project prod # auto-uses active branch
+ kbagent --json tool call update_sql_transformation --project prod --input '{{...}}' # auto-uses active branch
+ kbagent --json branch merge --project prod
+ # ^ does NOT merge! Returns a URL to Keboola UI where you review and merge manually.
+ # After displaying the URL, resets the active branch back to main.
+
+12. Branch create and delete are async operations on the Keboola API.
+ The CLI handles this transparently -- it waits for the async job to complete
+ before returning (typically 1-3 seconds). You do NOT need to poll or retry.
+ If the job takes too long (>60s), the CLI returns an error.
+
+13. Project context for AI -- get a full offline snapshot of a project:
+ kbagent llm export --project prod
+ # Creates ./prod/ directory with Twin Format JSON files
+ # FIRST read ./prod/ai/AGENT_INSTRUCTIONS.md -- it explains the structure
+ # and how to interpret each file (schemas, transformations, lineage, etc.)
+ # This is much faster than querying each piece via MCP tool calls.
+
+14. Setting up projects -- two approaches:
a) Single project (you have a Storage API token):
kbagent --json project add --alias my-proj --url https://connection.keboola.com --token 901-xxxxx
diff --git a/src/keboola_agent_cli/commands/explorer.py b/src/keboola_agent_cli/commands/explorer.py
index c6fa99ec..d5b706cf 100644
--- a/src/keboola_agent_cli/commands/explorer.py
+++ b/src/keboola_agent_cli/commands/explorer.py
@@ -5,7 +5,6 @@
"""
from pathlib import Path
-from typing import Optional
import typer
@@ -18,12 +17,12 @@
@explorer_app.callback(invoke_without_command=True)
def explorer(
ctx: typer.Context,
- project: Optional[list[str]] = typer.Option(
+ project: list[str] | None = typer.Option(
None,
"--project",
help="Project alias(es) to include (repeatable, default: all)",
),
- output_dir: Optional[Path] = typer.Option(
+ output_dir: Path | None = typer.Option(
None,
"--output-dir",
help="Directory to write catalog/orchestration files (default: kbc-explorer/)",
@@ -33,7 +32,7 @@ def explorer(
"--job-limit",
help="Max jobs per project for statistics (default: 500)",
),
- tiers: Optional[Path] = typer.Option(
+ tiers: Path | None = typer.Option(
None,
"--tiers",
help="Path to YAML tier config file for project tier assignments",
diff --git a/src/keboola_agent_cli/commands/job.py b/src/keboola_agent_cli/commands/job.py
index 1f4d59af..4ac71f18 100644
--- a/src/keboola_agent_cli/commands/job.py
+++ b/src/keboola_agent_cli/commands/job.py
@@ -6,15 +6,13 @@
import typer
-from ..constants import DEFAULT_JOB_LIMIT, MAX_JOB_LIMIT
+from ..constants import DEFAULT_JOB_LIMIT, MAX_JOB_LIMIT, VALID_STATUSES
from ..errors import ConfigError, KeboolaApiError
from ..output import format_job_detail, format_jobs_table
from ._helpers import emit_project_warnings, get_formatter, get_service, map_error_to_exit_code
job_app = typer.Typer(help="Browse job history")
-VALID_STATUSES = ["processing", "terminated", "cancelled", "success", "error"]
-
@job_app.command("list")
def job_list(
diff --git a/src/keboola_agent_cli/commands/llm.py b/src/keboola_agent_cli/commands/llm.py
new file mode 100644
index 00000000..27c620f1
--- /dev/null
+++ b/src/keboola_agent_cli/commands/llm.py
@@ -0,0 +1,84 @@
+"""LLM commands - AI-optimized project export via kbc CLI.
+
+Thin CLI layer: parses arguments, calls KbcService, formats output.
+No business logic belongs here.
+"""
+
+import typer
+
+from ..errors import ConfigError
+from ._helpers import get_formatter, get_service
+
+llm_app = typer.Typer(help="LLM tools - AI-optimized project export")
+
+
+@llm_app.command("export")
+def llm_export(
+ ctx: typer.Context,
+ project: str | None = typer.Option(
+ None,
+ "--project",
+ help="Project alias to export (required if multiple projects configured)",
+ ),
+ with_samples: bool = typer.Option(
+ False,
+ "--with-samples",
+ help="Include data samples (CSV) from tables",
+ ),
+ sample_limit: int | None = typer.Option(
+ None,
+ "--sample-limit",
+ help="Max rows per table sample (requires --with-samples)",
+ ),
+ max_samples: int | None = typer.Option(
+ None,
+ "--max-samples",
+ help="Max number of tables to sample (requires --with-samples)",
+ ),
+) -> None:
+ """Export project to Twin Format for AI consumption.
+
+ Creates an AI-optimized directory of JSON files containing table schemas,
+ transformation SQL code, internal lineage graph, job statistics, and
+ component configurations. Requires the kbc CLI (brew install keboola-cli).
+ """
+ formatter = get_formatter(ctx)
+
+ # Validate sample options require --with-samples
+ if (sample_limit is not None or max_samples is not None) and not with_samples:
+ formatter.error(
+ message="--sample-limit and --max-samples require --with-samples",
+ error_code="USAGE_ERROR",
+ )
+ raise typer.Exit(code=2)
+
+ kbc_service = get_service(ctx, "kbc_service")
+
+ try:
+ exit_code = kbc_service.run_llm_export(
+ alias=project,
+ with_samples=with_samples,
+ sample_limit=sample_limit,
+ max_samples=max_samples,
+ )
+ except ConfigError as exc:
+ formatter.error(message=exc.message, error_code="CONFIG_ERROR")
+ raise typer.Exit(code=5) from None
+
+ if exit_code != 0:
+ formatter.error(
+ message=f"kbc llm export failed with exit code {exit_code}",
+ error_code="KBC_ERROR",
+ )
+ raise typer.Exit(code=1) from None
+
+ # Success output
+ if formatter.json_mode:
+ projects = kbc_service.resolve_projects([project] if project else None)
+ resolved_alias = next(iter(projects.keys()))
+ formatter.output({
+ "message": f"LLM export completed for project '{resolved_alias}'",
+ "output_dir": str(resolved_alias),
+ })
+ else:
+ formatter.console.print("[bold green]LLM export completed successfully.[/bold green]")
diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py
index 12f71c8f..461984f5 100644
--- a/src/keboola_agent_cli/commands/project.py
+++ b/src/keboola_agent_cli/commands/project.py
@@ -31,9 +31,12 @@ def _format_project_table(console: Console, projects: list[dict[str, Any]]) -> N
table.add_column("Stack URL")
table.add_column("Token", style="dim")
table.add_column("Default", justify="center")
+ table.add_column("Branch", justify="center")
for p in projects:
default_marker = "*" if p.get("is_default") else ""
+ branch_id = p.get("active_branch_id")
+ branch_display = str(branch_id) if branch_id is not None else "[dim]main[/dim]"
table.add_row(
p["alias"],
p.get("project_name", ""),
@@ -41,6 +44,7 @@ def _format_project_table(console: Console, projects: list[dict[str, Any]]) -> N
p["stack_url"],
p["token"],
default_marker,
+ branch_display,
)
console.print(table)
@@ -58,6 +62,7 @@ def _format_status_table(console: Console, statuses: list[dict[str, Any]]) -> No
table.add_column("Response Time", justify="right")
table.add_column("Project Name")
table.add_column("Stack URL")
+ table.add_column("Branch", justify="center")
for s in statuses:
if s["status"] == "ok":
@@ -65,12 +70,15 @@ def _format_status_table(console: Console, statuses: list[dict[str, Any]]) -> No
else:
status_str = f"[bold red]ERROR[/bold red]: {s.get('error', 'Unknown')}"
response_time = f"{s.get('response_time_ms', 0)}ms"
+ branch_id = s.get("active_branch_id")
+ branch_display = str(branch_id) if branch_id is not None else "[dim]main[/dim]"
table.add_row(
s["alias"],
status_str,
response_time,
s.get("project_name", ""),
s["stack_url"],
+ branch_display,
)
console.print(table)
diff --git a/src/keboola_agent_cli/commands/tool.py b/src/keboola_agent_cli/commands/tool.py
index 9f49a915..6ecbf972 100644
--- a/src/keboola_agent_cli/commands/tool.py
+++ b/src/keboola_agent_cli/commands/tool.py
@@ -8,13 +8,93 @@
import typer
+from ..config_store import ConfigStore
from ..errors import ConfigError
-from ..output import format_tool_result, format_tools_table
+from ..output import OutputFormatter, format_tool_result, format_tools_table
from ._helpers import emit_project_warnings, get_formatter, get_service
tool_app = typer.Typer(help="MCP tools - interact with Keboola via MCP server")
+def _validate_branch_requires_project(
+ formatter: OutputFormatter,
+ branch: int | None,
+ project: str | None,
+) -> None:
+ """Validate that --branch is always accompanied by --project.
+
+ Raises:
+ typer.Exit: With code 2 if branch is set but project is not.
+ """
+ if branch is not None and not project:
+ formatter.error(
+ message="--branch requires --project (branch ID is per-project)",
+ error_code="INVALID_ARGUMENT",
+ )
+ raise typer.Exit(code=2) from None
+
+
+def _resolve_branch(
+ config_store: ConfigStore,
+ formatter: OutputFormatter,
+ project: str | None,
+ branch: int | None,
+) -> tuple[str | None, str | None]:
+ """Resolve the effective branch and project for tool commands.
+
+ Resolution order:
+ 1. Explicit --branch always wins (no change)
+ 2. If no --branch, check active_branch_id from config for the resolved project
+ 3. If active branch found, use it and print info message in human mode
+
+ When an active branch is resolved from config, --project is also set
+ to the project alias (branch is per-project).
+
+ Args:
+ config_store: Config store for looking up project configs.
+ formatter: Output formatter for info messages.
+ project: Explicit --project alias or None.
+ branch: Explicit --branch integer or None.
+
+ Returns:
+ Tuple of (effective_project, effective_branch_as_str).
+ """
+ if branch is not None:
+ return project, str(branch)
+
+ if project is not None:
+ # Specific project requested - check its active branch
+ proj_config = config_store.get_project(project)
+ if proj_config and proj_config.active_branch_id is not None:
+ branch_id_str = str(proj_config.active_branch_id)
+ if not formatter.json_mode:
+ formatter.err_console.print(
+ f"[bold blue]Info:[/bold blue] Using active branch "
+ f"(ID: {proj_config.active_branch_id}) for project '{project}'"
+ )
+ return project, branch_id_str
+ else:
+ # No project specified - check all projects for an active branch.
+ # If exactly one project has an active branch, use it to avoid ambiguity.
+ config = config_store.load()
+ active_projects = [
+ (alias, proj)
+ for alias, proj in config.projects.items()
+ if proj.active_branch_id is not None
+ ]
+ if len(active_projects) == 1:
+ alias, proj = active_projects[0]
+ branch_id_str = str(proj.active_branch_id)
+ if not formatter.json_mode:
+ formatter.err_console.print(
+ f"[bold blue]Info:[/bold blue] Using active branch "
+ f"(ID: {proj.active_branch_id}) for project '{alias}'"
+ )
+ return alias, branch_id_str
+
+ return project, None
+
+
@tool_app.command("list")
def tool_list(
ctx: typer.Context,
@@ -23,17 +103,23 @@ def tool_list(
"--project",
help="Project alias to query tools from (uses first available if not set)",
),
- branch: str | None = typer.Option(
+ branch: int | None = typer.Option(
None,
"--branch",
- help="Development branch ID (requires --project)",
+ help="Development branch ID (requires --project or active branch)",
),
) -> None:
"""List available MCP tools from the keboola-mcp-server."""
formatter = get_formatter(ctx)
service = get_service(ctx, "mcp_service")
+ config_store: ConfigStore = ctx.obj["config_store"]
+
+ _validate_branch_requires_project(formatter, branch, project)
+
+ # Auto-resolve active branch from config
+ project, branch_str = _resolve_branch(config_store, formatter, project, branch)
- if branch and not project:
+ if branch_str and not project:
formatter.error(
message="--branch requires --project (branch ID is per-project)",
error_code="INVALID_ARGUMENT",
@@ -43,7 +129,7 @@ def tool_list(
aliases = [project] if project else None
try:
- result = service.list_tools(aliases=aliases, branch_id=branch)
+ result = service.list_tools(aliases=aliases, branch_id=branch_str)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5) from None
@@ -69,10 +155,10 @@ def tool_call(
"--input",
help="Tool input as JSON string (e.g. '{\"query\": \"test\"}')",
),
- branch: str | None = typer.Option(
+ branch: int | None = typer.Option(
None,
"--branch",
- help="Development branch ID (requires --project, forces single-project mode)",
+ help="Development branch ID (forces single-project mode)",
),
) -> None:
"""Call an MCP tool on keboola-mcp-server.
@@ -83,13 +169,19 @@ def tool_call(
Write tools (create_*, update_*, delete_*, add_*) run on a single project.
Use --project to specify the target, or the default project is used.
- Use --branch to scope the tool call to a specific development branch.
- This forces single-project mode (branch ID is per-project).
+ If an active branch is set for the project, it is used automatically.
+ Use --branch to override or scope the call to a specific development branch.
"""
formatter = get_formatter(ctx)
service = get_service(ctx, "mcp_service")
+ config_store: ConfigStore = ctx.obj["config_store"]
+
+ _validate_branch_requires_project(formatter, branch, project)
+
+ # Auto-resolve active branch from config
+ project, branch_str = _resolve_branch(config_store, formatter, project, branch)
- if branch and not project:
+ if branch_str and not project:
formatter.error(
message="--branch requires --project (branch ID is per-project)",
error_code="INVALID_ARGUMENT",
@@ -121,7 +213,7 @@ def tool_call(
tool_name=tool_name,
tool_input=parsed_input,
aliases=[project] if project else None,
- branch_id=branch,
+ branch_id=branch_str,
)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
@@ -144,7 +236,7 @@ def tool_call(
tool_name=tool_name,
tool_input=parsed_input,
alias=project,
- branch_id=branch,
+ branch_id=branch_str,
)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
diff --git a/src/keboola_agent_cli/commands/version.py b/src/keboola_agent_cli/commands/version.py
new file mode 100644
index 00000000..0473c9b3
--- /dev/null
+++ b/src/keboola_agent_cli/commands/version.py
@@ -0,0 +1,86 @@
+"""Version command - show kbagent version and dependency update checks.
+
+Thin CLI layer: calls VersionService and formats output.
+No business logic belongs here.
+"""
+
+import typer
+from rich.console import Console
+from rich.panel import Panel
+from rich.text import Text
+
+from ._helpers import get_formatter, get_service
+
+
+def _format_dep_standard(text: Text, dep: dict) -> None:
+ """Format a standard dependency (local install with upgrade check)."""
+ name = dep["name"]
+ desc = dep["description"]
+ local = dep.get("local_version")
+ latest = dep.get("latest_version")
+ up_to_date = dep.get("up_to_date")
+
+ if local is None:
+ text.append(f" {name:<28}", style="dim")
+ text.append("not installed\n", style="red")
+ return
+
+ label = f"{name} ({desc})"
+ text.append(f" {label:<28}")
+ text.append(f"v{local}")
+
+ if up_to_date is False and latest is not None:
+ text.append(f" -> v{latest} available", style="yellow")
+ text.append(f" ({dep['upgrade_command']})", style="dim")
+ elif up_to_date is True:
+ text.append(" up to date", style="green")
+ else:
+ text.append(" (update check failed)", style="dim")
+
+ text.append("\n")
+
+
+def _format_dep_auto_update(text: Text, dep: dict) -> None:
+ """Format an auto-updating dependency (runs via uvx @latest)."""
+ name = dep["name"]
+ desc = dep["description"]
+ latest = dep.get("latest_version")
+ uvx_available = dep.get("uvx_available", False)
+
+ label = f"{name} ({desc})"
+ text.append(f" {label:<28}")
+
+ if not uvx_available:
+ text.append("uvx not found", style="red")
+ text.append(" (install: brew install uv)", style="dim")
+ elif latest:
+ text.append(f"v{latest}", style="green")
+ text.append(" auto-updates", style="dim")
+ else:
+ text.append("available", style="green")
+ text.append(" (version check failed)", style="dim")
+
+ text.append("\n")
+
+
+def _format_version_panel(console: Console, data: dict) -> None:
+ """Render version information as a Rich panel."""
+ text = Text()
+ text.append(f"kbagent v{data['kbagent']['version']}", style="bold")
+ text.append("\n\nDependencies:\n")
+
+ for dep in data["dependencies"]:
+ if dep.get("auto_updates"):
+ _format_dep_auto_update(text, dep)
+ else:
+ _format_dep_standard(text, dep)
+
+ console.print(Panel(text, title="Version Info", border_style="blue"))
+
+
+def version_command(ctx: typer.Context) -> None:
+ """Show kbagent version and check for dependency updates."""
+ formatter = get_formatter(ctx)
+ version_service = get_service(ctx, "version_service")
+ result = version_service.get_versions()
+ formatter.output(result, _format_version_panel)
diff --git a/src/keboola_agent_cli/config_store.py b/src/keboola_agent_cli/config_store.py
index b4d4c910..72ad25eb 100644
--- a/src/keboola_agent_cli/config_store.py
+++ b/src/keboola_agent_cli/config_store.py
@@ -2,10 +2,12 @@
Manages reading and writing of config.json with project connections.
File permissions are set to 0600 to protect stored tokens.
+Uses atomic writes to prevent TOCTOU race conditions.
"""
import json
import logging
+import os
from pathlib import Path
import platformdirs
@@ -86,6 +88,8 @@ def save(self, config: AppConfig) -> None:
"""Save configuration to disk with secure file permissions (0600).
Creates the config directory if it does not exist.
+ Uses atomic write to ensure the file is never on disk with
+ permissions broader than 0600 (prevents TOCTOU race condition).
Raises:
ConfigError: If the file cannot be written.
@@ -94,8 +98,18 @@ def save(self, config: AppConfig) -> None:
try:
self._config_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
json_str = config.model_dump_json(indent=2)
- self._config_path.write_text(json_str + "\n", encoding="utf-8")
- self._config_path.chmod(0o600)
+ data = (json_str + "\n").encode("utf-8")
+
+ # Write to a temp file created with 0600 from the start,
+ # then atomically rename into place. This avoids any window
+ # where the config file exists with world-readable permissions.
+ tmp_path = self._config_path.with_suffix(".tmp")
+ fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ try:
+ os.write(fd, data)
+ finally:
+ os.close(fd)
+ os.replace(str(tmp_path), str(self._config_path))
except OSError as exc:
raise ConfigError(f"Cannot write config file {self._config_path}: {exc}") from exc
@@ -143,6 +157,22 @@ def get_project(self, alias: str) -> ProjectConfig | None:
config = self.load()
return config.projects.get(alias)
+ def set_project_branch(self, alias: str, branch_id: int | None) -> None:
+ """Set or clear the active development branch for a project.
+
+ Args:
+ alias: The project alias.
+ branch_id: Branch ID to activate, or None to reset to main.
+
+ Raises:
+ ConfigError: If the alias does not exist.
+ """
+ config = self.load()
+ if alias not in config.projects:
+ raise ConfigError(f"Project '{alias}' not found.")
+ config.projects[alias].active_branch_id = branch_id
+ self.save(config)
+
def edit_project(self, alias: str, **kwargs: str | int | None) -> None:
"""Update fields on an existing project.
diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py
index f1870a77..216e76d4 100644
--- a/src/keboola_agent_cli/constants.py
+++ b/src/keboola_agent_cli/constants.py
@@ -37,6 +37,10 @@
DEFAULT_MCP_TOOL_TIMEOUT: int = 60
DEFAULT_MCP_INIT_TIMEOUT: int = 30
+# --- Storage Job Polling ---
+STORAGE_JOB_POLL_INTERVAL: float = 1.0 # seconds between polls
+STORAGE_JOB_MAX_WAIT: float = 60.0 # max seconds to wait for a storage job
+
# --- Parallel Workers ---
MAX_PARALLEL_WORKERS_LIMIT: int = 100
@@ -47,3 +51,15 @@
ENV_KBC_MANAGE_API_TOKEN: str = "KBC_MANAGE_API_TOKEN"
ENV_MCP_TOOL_TIMEOUT: str = "KBAGENT_MCP_TOOL_TIMEOUT"
ENV_MCP_INIT_TIMEOUT: str = "KBAGENT_MCP_INIT_TIMEOUT"
+
+# --- Version Check ---
+VERSION_CHECK_TIMEOUT: float = 4.0 # seconds for fetching latest version from remote
+KBC_SUBPROCESS_TIMEOUT: float = 5.0 # seconds for running kbc/mcp subprocess commands
+KBC_GITHUB_RELEASES_URL: str = (
+ "https://api.github.com/repos/keboola/keboola-as-code/releases/latest"
+)
+MCP_PYPI_URL: str = "https://pypi.org/pypi/keboola-mcp-server/json"
+
+# --- Domain Validation Constants ---
+VALID_COMPONENT_TYPES: list[str] = ["extractor", "writer", "transformation", "application"]
+VALID_STATUSES: list[str] = ["processing", "terminated", "cancelled", "success", "error"]
diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py
index 7b0f831d..1933dd07 100644
--- a/src/keboola_agent_cli/models.py
+++ b/src/keboola_agent_cli/models.py
@@ -14,6 +14,10 @@ class ProjectConfig(BaseModel):
default="", description="Human-readable project name (populated on add)"
)
project_id: int | None = Field(default=None, description="Keboola project ID (populated on add)")
+ active_branch_id: int | None = Field(
+ default=None,
+ description="Active development branch ID (None = main/production branch)",
+ )
@field_validator("stack_url")
@classmethod
diff --git a/src/keboola_agent_cli/output.py b/src/keboola_agent_cli/output.py
index 5a8d1abc..1d3f89e3 100644
--- a/src/keboola_agent_cli/output.py
+++ b/src/keboola_agent_cli/output.py
@@ -712,11 +712,15 @@ def format_branches_table(console: Console, data: dict[str, Any]) -> None:
console.print("No branches retrieved (all projects failed).")
return
+ active_branches = data.get("active_branches", {})
+
table = Table(title="Development Branches")
table.add_column("Project", style="bold magenta")
table.add_column("Branch ID", justify="right")
table.add_column("Name", style="bold cyan")
table.add_column("Default", justify="center")
+ table.add_column("Active", justify="center")
+ table.add_column("Description", style="dim", max_width=40)
table.add_column("Created", style="dim")
prev_alias = None
@@ -725,6 +729,16 @@ def format_branches_table(console: Console, data: dict[str, Any]) -> None:
is_default = branch.get("isDefault", False)
default_display = "[green]yes[/green]" if is_default else "[dim]no[/dim]"
+ branch_id = branch.get("id")
+ active_id = active_branches.get(alias)
+ # Compare as int to handle potential type mismatch from API
+ is_active = (
+ branch_id is not None
+ and active_id is not None
+ and int(branch_id) == int(active_id)
+ )
+ active_display = "[bold green]>>>[/bold green]" if is_active else ""
+
display_alias = alias if alias != prev_alias else ""
prev_alias = alias
@@ -733,6 +747,8 @@ def format_branches_table(console: Console, data: dict[str, Any]) -> None:
str(branch.get("id", "")),
branch.get("name", ""),
default_display,
+ active_display,
+ branch.get("description", ""),
branch.get("created", ""),
)
diff --git a/src/keboola_agent_cli/services/branch_service.py b/src/keboola_agent_cli/services/branch_service.py
index fd41a7be..1fa3b7a8 100644
--- a/src/keboola_agent_cli/services/branch_service.py
+++ b/src/keboola_agent_cli/services/branch_service.py
@@ -1,23 +1,27 @@
-"""Branch listing service - business logic for listing development branches.
+"""Branch service - business logic for branch lifecycle management.
Orchestrates multi-project branch retrieval in parallel, annotates with
-project alias, and aggregates results.
+project alias, and aggregates results. Provides create, activate, reset,
+delete, and merge URL generation for development branches.
"""
from typing import Any
-from ..errors import KeboolaApiError
+from ..errors import ConfigError, KeboolaApiError
from ..models import ProjectConfig
from .base import BaseService
class BranchService(BaseService):
- """Business logic for listing Keboola development branches.
+ """Business logic for managing Keboola development branches.
Supports multi-project aggregation: queries multiple projects in parallel
using ThreadPoolExecutor, collects results, and reports per-project errors
without stopping others.
+ Provides branch lifecycle operations: create, activate (use), reset,
+ delete, and merge URL generation.
+
Uses dependency injection for config_store and client_factory.
"""
@@ -78,6 +82,9 @@ def list_branches(
flattens them into a unified list. Per-project errors are collected
but do not stop other projects from being queried.
+ Includes active_branches dict mapping alias -> active_branch_id for
+ display purposes.
+
Args:
aliases: Project aliases to query. None means all projects.
@@ -87,12 +94,18 @@ def list_branches(
id, name, isDefault, created, description
- "errors": list of error dicts with project_alias,
error_code, message
+ - "active_branches": dict mapping alias -> active_branch_id
Raises:
ConfigError: If a specified alias is not found (before querying).
"""
projects = self.resolve_projects(aliases)
+ # Collect active branch IDs for display
+ active_branches: dict[str, int | None] = {}
+ for alias, project in projects.items():
+ active_branches[alias] = project.active_branch_id
+
def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]:
return self._fetch_project_branches(alias, project)
@@ -109,4 +122,223 @@ def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]:
)
errors.sort(key=lambda e: e.get("project_alias", ""))
- return {"branches": all_branches, "errors": errors}
+ return {
+ "branches": all_branches,
+ "errors": errors,
+ "active_branches": active_branches,
+ }
+
+ def create_branch(
+ self,
+ alias: str,
+ name: str,
+ description: str = "",
+ ) -> dict[str, Any]:
+ """Create a new development branch and auto-activate it.
+
+ Args:
+ alias: Project alias.
+ name: Branch name.
+ description: Optional branch description.
+
+ Returns:
+ Dict with branch details and activation info.
+
+ Raises:
+ ConfigError: If the project alias is not found.
+ KeboolaApiError: If the API call fails.
+ """
+ projects = self.resolve_projects([alias])
+ project = projects[alias]
+
+ client = self._client_factory(project.stack_url, project.token)
+ try:
+ # create_dev_branch waits for the async job and returns branch data
+ # from job.results (contains the real branch ID)
+ branch_data = client.create_dev_branch(name=name, description=description)
+ finally:
+ client.close()
+
+ branch_id = int(branch_data["id"])
+
+ # Auto-activate the created branch
+ self._config_store.set_project_branch(alias, branch_id)
+
+ return {
+ "project_alias": alias,
+ "branch_id": branch_id,
+ "branch_name": branch_data.get("name", name),
+ "description": branch_data.get("description", description),
+ "created": branch_data.get("created", ""),
+ "activated": True,
+ "message": (
+ f"Branch '{name}' (ID: {branch_id}) created and activated "
+ f"for project '{alias}'."
+ ),
+ }
+
+ def set_active_branch(self, alias: str, branch_id: int) -> dict[str, Any]:
+ """Validate and set an existing branch as active.
+
+ Calls the API to verify the branch exists before setting it.
+
+ Args:
+ alias: Project alias.
+ branch_id: Branch ID to activate.
+
+ Returns:
+ Dict with activation details.
+
+ Raises:
+ ConfigError: If the project alias is not found or branch does not exist.
+ KeboolaApiError: If the API call fails.
+ """
+ projects = self.resolve_projects([alias])
+ project = projects[alias]
+
+ client = self._client_factory(project.stack_url, project.token)
+ try:
+ raw_branches = client.list_dev_branches()
+ finally:
+ client.close()
+
+ # Find the branch by ID
+ target_branch = None
+ for branch in raw_branches:
+ if branch.get("id") == branch_id:
+ target_branch = branch
+ break
+
+ if target_branch is None:
+ raise ConfigError(
+ f"Branch ID {branch_id} not found in project '{alias}'. "
+ f"Use 'kbagent branch list --project {alias}' to see available branches."
+ )
+
+ self._config_store.set_project_branch(alias, branch_id)
+
+ branch_name = target_branch.get("name", "")
+ return {
+ "project_alias": alias,
+ "branch_id": branch_id,
+ "branch_name": branch_name,
+ "message": (
+ f"Active branch set to '{branch_name}' (ID: {branch_id}) "
+ f"for project '{alias}'."
+ ),
+ }
+
+ def reset_branch(self, alias: str) -> dict[str, Any]:
+ """Clear the active branch, reverting to the main/production branch.
+
+ Args:
+ alias: Project alias.
+
+ Returns:
+ Dict confirming the reset.
+
+ Raises:
+ ConfigError: If the project alias is not found.
+ """
+ projects = self.resolve_projects([alias])
+ previous_branch = projects[alias].active_branch_id
+
+ self._config_store.set_project_branch(alias, None)
+
+ return {
+ "project_alias": alias,
+ "previous_branch_id": previous_branch,
+ "message": (
+ f"Active branch reset to main for project '{alias}'."
+ ),
+ }
+
+ def delete_branch(self, alias: str, branch_id: int) -> dict[str, Any]:
+ """Delete a development branch via API. Auto-resets if it was active.
+
+ Args:
+ alias: Project alias.
+ branch_id: Branch ID to delete.
+
+ Returns:
+ Dict confirming the deletion.
+
+ Raises:
+ ConfigError: If the project alias is not found.
+ KeboolaApiError: If the API call fails.
+ """
+ projects = self.resolve_projects([alias])
+ project = projects[alias]
+
+ client = self._client_factory(project.stack_url, project.token)
+ try:
+ client.delete_dev_branch(branch_id)
+ finally:
+ client.close()
+
+ # Auto-reset if the deleted branch was active
+ was_active = project.active_branch_id == branch_id
+ if was_active:
+ self._config_store.set_project_branch(alias, None)
+
+ return {
+ "project_alias": alias,
+ "branch_id": branch_id,
+ "was_active": was_active,
+ "message": (
+ f"Branch ID {branch_id} deleted from project '{alias}'."
+ + (" Active branch reset to main." if was_active else "")
+ ),
+ }
+
+ def get_merge_url(self, alias: str, branch_id: int | None = None) -> dict[str, Any]:
+ """Generate KBC UI merge URL for a development branch.
+
+ Does not call any API. Constructs the URL from stored project config.
+ If no branch_id is provided, uses the active branch from config.
+ After generating the URL, resets the active branch to main.
+
+ Args:
+ alias: Project alias.
+ branch_id: Branch ID. If None, uses active_branch_id from config.
+
+ Returns:
+ Dict with merge URL and instructions.
+
+ Raises:
+ ConfigError: If project not found or no branch ID available.
+ """
+ projects = self.resolve_projects([alias])
+ project = projects[alias]
+
+ effective_branch_id = branch_id if branch_id is not None else project.active_branch_id
+ if effective_branch_id is None:
+ raise ConfigError(
+ f"No branch specified and no active branch set for project '{alias}'. "
+ f"Use --branch ID or set an active branch with 'kbagent branch use'."
+ )
+
+ if project.project_id is None:
+ raise ConfigError(
+ f"Project '{alias}' has no project_id stored. "
+ "Re-add the project with 'kbagent project edit' to populate it."
+ )
+
+ stack_url = project.stack_url.rstrip("/")
+ merge_url = (
+ f"{stack_url}/admin/projects/{project.project_id}"
+ f"/branch/{effective_branch_id}/development-overview"
+ )
+
+ # Reset active branch to main after generating merge URL
+ self._config_store.set_project_branch(alias, None)
+
+ return {
+ "project_alias": alias,
+ "branch_id": effective_branch_id,
+ "url": merge_url,
+ "message": (
+ f"Open this URL to review and merge branch {effective_branch_id} "
+ f"in project '{alias}'. Active branch has been reset to main."
+ ),
+ }
diff --git a/src/keboola_agent_cli/services/explorer_service.py b/src/keboola_agent_cli/services/explorer_service.py
index b8dfcf12..9be91f5d 100644
--- a/src/keboola_agent_cli/services/explorer_service.py
+++ b/src/keboola_agent_cli/services/explorer_service.py
@@ -10,7 +10,7 @@
import logging
import os
import webbrowser
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -19,7 +19,8 @@
from .. import __version__
from ..config_store import ConfigStore
-from ..errors import ConfigError, KeboolaApiError
+from ..constants import MAX_JOB_LIMIT
+from ..errors import ConfigError
from .base import BaseService, ClientFactory
from .config_service import ConfigService
from .job_service import JobService
@@ -27,8 +28,10 @@
logger = logging.getLogger(__name__)
-DEFAULT_JOB_LIMIT = 500
-DEFAULT_OUTPUT_DIR = Path(__file__).resolve().parent.parent.parent.parent / "kbc-explorer"
+
+def _default_output_dir() -> Path:
+ """Return the default output directory for explorer files (relative to CWD)."""
+ return Path.cwd() / "kbc-explorer"
def _assign_tier(alias: str, tier_map: dict[str, str] | None = None) -> tuple[str, bool]:
@@ -267,7 +270,7 @@ def generate(
self,
aliases: list[str] | None = None,
output_dir: Path | None = None,
- job_limit: int = DEFAULT_JOB_LIMIT,
+ job_limit: int = MAX_JOB_LIMIT,
open_browser: bool = True,
tiers_config: Path | None = None,
) -> dict[str, Any]:
@@ -284,7 +287,7 @@ def generate(
Dict with generation summary and any errors.
"""
if output_dir is None:
- output_dir = DEFAULT_OUTPUT_DIR
+ output_dir = _default_output_dir()
projects = self.resolve_projects(aliases)
if not projects:
@@ -463,7 +466,7 @@ def generate(
catalog = {
"metadata": {
- "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "generated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
"tool": f"kbagent CLI v{__version__}",
"stack_url": stack_url,
"description": description,
diff --git a/src/keboola_agent_cli/services/kbc_service.py b/src/keboola_agent_cli/services/kbc_service.py
new file mode 100644
index 00000000..4a2fd807
--- /dev/null
+++ b/src/keboola_agent_cli/services/kbc_service.py
@@ -0,0 +1,184 @@
+"""KBC CLI integration service - wraps the kbc Go binary for LLM export.
+
+Provides detection of the kbc binary and execution of 'kbc llm export'
+with credentials auto-resolved from the config store.
+"""
+
+import logging
+import re
+import shutil
+import subprocess
+from pathlib import Path
+from urllib.parse import urlparse
+
+from ..config_store import ConfigStore
+from ..constants import KBC_SUBPROCESS_TIMEOUT
+from ..errors import ConfigError
+from .base import BaseService
+
+logger = logging.getLogger(__name__)
+
+
+def detect_kbc_command() -> str | None:
+ """Detect the kbc binary on PATH.
+
+ Returns:
+ The path to kbc binary, or None if not found.
+ """
+ return shutil.which("kbc")
+
+
+def _extract_host_from_url(stack_url: str) -> str:
+ """Extract hostname from a Keboola stack URL.
+
+ Args:
+ stack_url: Full URL like 'https://connection.keboola.com'.
+
+ Returns:
+ Hostname like 'connection.keboola.com'.
+
+ Raises:
+ ValueError: If the URL has no hostname.
+ """
+ hostname = urlparse(stack_url).hostname
+ if not hostname:
+ msg = f"Cannot extract hostname from URL: {stack_url}"
+ raise ValueError(msg)
+ return hostname
+
+
+class KbcService(BaseService):
+ """Business logic for kbc CLI integration.
+
+ Wraps the kbc Go binary for operations that are not available
+ through the Storage API or MCP server.
+ """
+
+ def __init__(self, config_store: ConfigStore) -> None:
+ super().__init__(config_store=config_store)
+
+ def check_kbc_available(self) -> dict:
+ """Check if kbc binary is available (doctor-compatible format).
+
+ Returns:
+ Dict with check name, status, and message.
+ """
+ kbc_path = detect_kbc_command()
+ if kbc_path is None:
+ return {
+ "check": "kbc_binary",
+ "name": "kbc CLI",
+ "status": "warn",
+ "message": (
+ "kbc binary not found on PATH. "
+ "Install with: brew install keboola-cli"
+ ),
+ }
+
+ version = self.get_kbc_version()
+ version_info = f" v{version}" if version else ""
+ return {
+ "check": "kbc_binary",
+ "name": "kbc CLI",
+ "status": "pass",
+ "message": f"kbc{version_info} found at {kbc_path}",
+ }
+
+ @staticmethod
+ def get_kbc_version() -> str | None:
+ """Parse the kbc version from 'kbc --version' output.
+
+ Returns:
+ Version string like '2.44.0', or None if detection fails.
+ """
+ kbc_path = detect_kbc_command()
+ if kbc_path is None:
+ return None
+
+ try:
+ result = subprocess.run(
+ [kbc_path, "--version"],
+ capture_output=True,
+ text=True,
+ timeout=KBC_SUBPROCESS_TIMEOUT,
+ check=False,
+ )
+ match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
+ return match.group(1) if match else None
+ except (subprocess.TimeoutExpired, OSError):
+ logger.debug("Failed to detect kbc version", exc_info=True)
+ return None
+
+ def run_llm_export(
+ self,
+ alias: str | None = None,
+ with_samples: bool = False,
+ sample_limit: int | None = None,
+ max_samples: int | None = None,
+ ) -> int:
+ """Run 'kbc llm export' for a specific project.
+
+ Resolves credentials from config store, creates output directory,
+ and streams kbc output directly to terminal.
+
+ Args:
+ alias: Project alias. If None, uses the single configured project.
+ with_samples: Include data samples in export.
+ sample_limit: Max rows per table sample.
+ max_samples: Max number of tables to sample.
+
+ Returns:
+ Exit code from the kbc process.
+
+ Raises:
+ ConfigError: If kbc binary is not found or project not found.
+ """
+ kbc_path = detect_kbc_command()
+ if kbc_path is None:
+ raise ConfigError(
+ "kbc binary not found on PATH. Install with: brew install keboola-cli"
+ )
+
+ # Resolve the single project
+ projects = self.resolve_projects([alias] if alias else None)
+ if len(projects) != 1:
+ raise ConfigError(
+ "LLM export requires exactly one project. "
+ f"Use --project to specify one of: {', '.join(projects.keys())}"
+ )
+
+ resolved_alias, project = next(iter(projects.items()))
+ host = _extract_host_from_url(project.stack_url)
+
+ # Create output directory
+ output_dir = Path.cwd() / resolved_alias
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ # Build command
+ cmd = [
+ kbc_path,
+ "llm",
+ "export",
+ "--storage-api-host",
+ host,
+ "--storage-api-token",
+ project.token,
+ "--force",
+ "--non-interactive",
+ "--version-check=false",
+ "--working-dir",
+ str(output_dir),
+ ]
+
+ if with_samples:
+ cmd.append("--with-samples")
+ if sample_limit is not None:
+ cmd.extend(["--sample-limit", str(sample_limit)])
+ if max_samples is not None:
+ cmd.extend(["--max-samples", str(max_samples)])
+
+ logger.debug("Running kbc command: %s", " ".join(cmd[:6]) + " ...")
+
+ # Stream directly to terminal (no capture)
+ result = subprocess.run(cmd, check=False)
+ return result.returncode
diff --git a/src/keboola_agent_cli/services/lineage_service.py b/src/keboola_agent_cli/services/lineage_service.py
index 46ef3667..d616c88f 100644
--- a/src/keboola_agent_cli/services/lineage_service.py
+++ b/src/keboola_agent_cli/services/lineage_service.py
@@ -5,12 +5,15 @@
all projects in parallel using BaseService._run_parallel().
"""
+import logging
from typing import Any
from ..errors import KeboolaApiError
from ..models import ProjectConfig
from .base import BaseService
+logger = logging.getLogger(__name__)
+
class LineageService(BaseService):
"""Business logic for cross-project data lineage via bucket sharing.
@@ -98,6 +101,9 @@ def get_lineage(self, aliases: list[str] | None = None) -> dict[str, Any]:
# Build project_id -> alias lookup for cross-referencing
project_id_to_alias: dict[int, str] = {}
for alias, project in projects.items():
+ if project.project_id is None:
+ logger.warning("Project '%s' has no project_id; skipping lineage", alias)
+ continue
project_id_to_alias[project.project_id] = alias
all_shared_buckets: list[dict[str, Any]] = []
diff --git a/src/keboola_agent_cli/services/mcp_service.py b/src/keboola_agent_cli/services/mcp_service.py
index 4549ed78..330e803a 100644
--- a/src/keboola_agent_cli/services/mcp_service.py
+++ b/src/keboola_agent_cli/services/mcp_service.py
@@ -28,14 +28,14 @@
logger = logging.getLogger(__name__)
-# Timeout for individual MCP operations (seconds)
-MCP_TOOL_TIMEOUT_SECONDS = int(
- os.environ.get(ENV_MCP_TOOL_TIMEOUT, DEFAULT_MCP_TOOL_TIMEOUT)
-)
-# Timeout for MCP session initialization (seconds)
-MCP_INIT_TIMEOUT_SECONDS = int(
- os.environ.get(ENV_MCP_INIT_TIMEOUT, DEFAULT_MCP_INIT_TIMEOUT)
-)
+def _get_tool_timeout() -> int:
+ """Get MCP tool timeout (seconds), reading env var at call time."""
+ return int(os.environ.get(ENV_MCP_TOOL_TIMEOUT, DEFAULT_MCP_TOOL_TIMEOUT))
+
+
+def _get_init_timeout() -> int:
+ """Get MCP init timeout (seconds), reading env var at call time."""
+ return int(os.environ.get(ENV_MCP_INIT_TIMEOUT, DEFAULT_MCP_INIT_TIMEOUT))
# Prefixes that indicate write/mutating tools
WRITE_PREFIXES = (
@@ -69,7 +69,7 @@ def detect_mcp_server_command() -> list[str] | None:
"""Detect the best way to run keboola-mcp-server.
Checks in order:
- 1. uvx keboola_mcp_server (if uvx is available)
+ 1. uvx keboola_mcp_server@latest (if uvx is available -- always latest version)
2. keboola_mcp_server (if installed as standalone command)
3. python -m keboola_mcp_server (last resort)
@@ -77,7 +77,7 @@ def detect_mcp_server_command() -> list[str] | None:
List of command parts, or None if no method is available.
"""
if shutil.which("uvx"):
- return ["uvx", "keboola_mcp_server"]
+ return ["uvx", "keboola_mcp_server@latest"]
if shutil.which("keboola_mcp_server"):
return ["keboola_mcp_server"]
if shutil.which("python"):
@@ -144,16 +144,16 @@ async def _connect_and_list_tools(
exit_stack.enter_async_context(
stdio_client(params, errlog=subprocess.DEVNULL)
),
- timeout=MCP_INIT_TIMEOUT_SECONDS,
+ timeout=_get_init_timeout(),
)
session = await exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
- await asyncio.wait_for(session.initialize(), timeout=MCP_INIT_TIMEOUT_SECONDS)
+ await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout())
response = await asyncio.wait_for(
- session.list_tools(), timeout=MCP_TOOL_TIMEOUT_SECONDS
+ session.list_tools(), timeout=_get_tool_timeout()
)
tools = []
@@ -199,13 +199,13 @@ async def _open_session(
exit_stack.enter_async_context(
stdio_client(params, errlog=subprocess.DEVNULL)
),
- timeout=MCP_INIT_TIMEOUT_SECONDS,
+ timeout=_get_init_timeout(),
)
session = await exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
- await asyncio.wait_for(session.initialize(), timeout=MCP_INIT_TIMEOUT_SECONDS)
+ await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout())
return session
@@ -233,7 +233,7 @@ async def _connect_and_call_tool(
result = await asyncio.wait_for(
session.call_tool(tool_name, tool_input),
- timeout=MCP_TOOL_TIMEOUT_SECONDS,
+ timeout=_get_tool_timeout(),
)
return {
@@ -278,7 +278,7 @@ async def _connect_and_auto_expand(
# Step 1: Call resolve tool (e.g. list_buckets)
resolve_result = await asyncio.wait_for(
session.call_tool(resolve_tool, {}),
- timeout=MCP_TOOL_TIMEOUT_SECONDS,
+ timeout=_get_tool_timeout(),
)
if resolve_result.isError:
@@ -302,7 +302,7 @@ async def _connect_and_auto_expand(
call_input = {**tool_input, param_name: item_id}
result = await asyncio.wait_for(
session.call_tool(tool_name, call_input),
- timeout=MCP_TOOL_TIMEOUT_SECONDS,
+ timeout=_get_tool_timeout(),
)
content = _parse_content(result)
diff --git a/src/keboola_agent_cli/services/org_service.py b/src/keboola_agent_cli/services/org_service.py
index 4b2e6ea2..bd2ed7a3 100644
--- a/src/keboola_agent_cli/services/org_service.py
+++ b/src/keboola_agent_cli/services/org_service.py
@@ -101,7 +101,7 @@ def setup_organization(
# Load existing config to check for already-registered projects
config = self._config_store.load()
existing_project_ids = {
- p.project_id for p in config.projects.values()
+ p.project_id for p in config.projects.values() if p.project_id is not None
}
# Track used aliases for uniqueness
diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py
index 86d2e8f5..1b43bc7a 100644
--- a/src/keboola_agent_cli/services/project_service.py
+++ b/src/keboola_agent_cli/services/project_service.py
@@ -153,6 +153,7 @@ def list_projects(self) -> list[dict[str, Any]]:
"stack_url": project.stack_url,
"token": mask_token(project.token),
"is_default": alias == config.default_project,
+ "active_branch_id": project.active_branch_id,
}
)
return result
@@ -174,6 +175,7 @@ def _check_project_status(
"alias": alias,
"stack_url": project.stack_url,
"token": mask_token(project.token),
+ "active_branch_id": project.active_branch_id,
}
client = self._client_factory(project.stack_url, project.token)
diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py
new file mode 100644
index 00000000..216e2dd9
--- /dev/null
+++ b/src/keboola_agent_cli/services/version_service.py
@@ -0,0 +1,189 @@
+"""Version service - detect local versions and check for updates.
+
+Provides version information for kbagent and its dependencies:
+- kbc (Go CLI) - local version via subprocess, latest via GitHub API
+- keboola-mcp-server - always runs latest via 'uvx keboola_mcp_server@latest',
+ version resolved from PyPI
+"""
+
+import logging
+import re
+import shutil
+import subprocess
+from concurrent.futures import ThreadPoolExecutor
+from typing import Any
+
+import httpx
+from packaging.version import InvalidVersion, Version
+
+from .. import __version__
+from ..constants import (
+ KBC_GITHUB_RELEASES_URL,
+ KBC_SUBPROCESS_TIMEOUT,
+ MCP_PYPI_URL,
+ VERSION_CHECK_TIMEOUT,
+)
+from .kbc_service import detect_kbc_command
+
+logger = logging.getLogger(__name__)
+
+
+def _get_kbc_local_version() -> str | None:
+ """Get locally installed kbc version by running 'kbc --version'.
+
+ Returns:
+ Version string like '2.44.0', or None if not installed/detectable.
+ """
+ kbc_path = detect_kbc_command()
+ if kbc_path is None:
+ return None
+
+ try:
+ result = subprocess.run(
+ [kbc_path, "--version"],
+ capture_output=True,
+ text=True,
+ timeout=KBC_SUBPROCESS_TIMEOUT,
+ check=False,
+ )
+ match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
+ return match.group(1) if match else None
+ except (subprocess.TimeoutExpired, OSError):
+ logger.debug("Failed to detect kbc version", exc_info=True)
+ return None
+
+
+def _is_uvx_available() -> bool:
+ """Check if uvx is available on PATH."""
+ return shutil.which("uvx") is not None
+
+
+def _fetch_kbc_latest_version(timeout: float = VERSION_CHECK_TIMEOUT) -> str | None:
+ """Fetch latest kbc version from GitHub releases API.
+
+ Args:
+ timeout: HTTP request timeout in seconds.
+
+ Returns:
+ Version string like '2.44.2', or None on failure.
+ """
+ try:
+ response = httpx.get(
+ KBC_GITHUB_RELEASES_URL,
+ timeout=timeout,
+ follow_redirects=True,
+ )
+ response.raise_for_status()
+ data = response.json()
+ tag = data.get("tag_name", "")
+ # Strip 'v' prefix if present
+ version = tag.lstrip("v")
+ if re.match(r"\d+\.\d+\.\d+", version):
+ return version
+ return None
+ except (httpx.HTTPError, KeyError, ValueError):
+ logger.debug("Failed to fetch latest kbc version", exc_info=True)
+ return None
+
+
+def _fetch_mcp_latest_version(timeout: float = VERSION_CHECK_TIMEOUT) -> str | None:
+ """Fetch latest keboola-mcp-server version from PyPI.
+
+ Args:
+ timeout: HTTP request timeout in seconds.
+
+ Returns:
+ Version string like '1.46.0', or None on failure.
+ """
+ try:
+ response = httpx.get(
+ MCP_PYPI_URL,
+ timeout=timeout,
+ follow_redirects=True,
+ )
+ response.raise_for_status()
+ data = response.json()
+ version = data.get("info", {}).get("version", "")
+ if re.match(r"\d+\.\d+\.\d+", version):
+ return version
+ return None
+ except (httpx.HTTPError, KeyError, ValueError):
+ logger.debug("Failed to fetch latest MCP server version", exc_info=True)
+ return None
+
+
+def _is_up_to_date(local: str | None, latest: str | None) -> bool | None:
+ """Compare local and latest versions.
+
+ Args:
+ local: Locally installed version string.
+ latest: Latest available version string.
+
+ Returns:
+ True if up to date, False if update available, None if comparison not possible.
+ """
+ if local is None or latest is None:
+ return None
+ try:
+ return Version(local) >= Version(latest)
+ except InvalidVersion:
+ return None
+
+
+class VersionService:
+ """Business logic for version detection and update checks.
+
+ Detects local versions of kbagent dependencies and checks
+ for available updates in parallel.
+ """
+
+ def get_versions(self) -> dict[str, Any]:
+ """Get version information for kbagent and all dependencies.
+
+ kbc: local install checked against GitHub releases.
+ keboola-mcp-server: always runs latest via 'uvx ... @latest',
+ so we only need to check PyPI for the current latest version
+ and whether uvx is available.
+
+ Returns:
+ Structured dict with kbagent version and dependency info.
+ """
+ # Step 1: Detect local kbc version + check uvx availability
+ kbc_local = _get_kbc_local_version()
+ uvx_available = _is_uvx_available()
+
+ # Step 2: Fetch latest versions in parallel
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ kbc_future = executor.submit(_fetch_kbc_latest_version)
+ mcp_future = executor.submit(_fetch_mcp_latest_version)
+
+ kbc_latest = kbc_future.result()
+ mcp_latest = mcp_future.result()
+
+ # Step 3: Build result
+ # MCP server: runs via 'uvx keboola_mcp_server@latest' (always latest)
+ # so there's no local vs remote mismatch -- just show availability
+ mcp_entry: dict[str, Any] = {
+ "name": "keboola-mcp-server",
+ "description": "Keboola MCP Server (via uvx @latest)",
+ "uvx_available": uvx_available,
+ "latest_version": mcp_latest,
+ "auto_updates": True,
+ }
+
+ return {
+ "kbagent": {
+ "version": __version__,
+ },
+ "dependencies": [
+ {
+ "name": "kbc",
+ "description": "Keboola CLI (Go)",
+ "local_version": kbc_local,
+ "latest_version": kbc_latest,
+ "up_to_date": _is_up_to_date(kbc_local, kbc_latest),
+ "upgrade_command": "brew upgrade keboola-cli",
+ },
+ mcp_entry,
+ ],
+ }
diff --git a/tests/test_branch_service.py b/tests/test_branch_service.py
index 8fdd3f3e..2f7212d6 100644
--- a/tests/test_branch_service.py
+++ b/tests/test_branch_service.py
@@ -176,3 +176,305 @@ def test_list_branches_unexpected_error(self, tmp_config_dir: Path) -> None:
assert len(result["errors"]) == 1
assert result["errors"][0]["error_code"] == "UNEXPECTED_ERROR"
assert "Something broke" in result["errors"][0]["message"]
+
+
+class TestCreateBranch:
+ """Tests for BranchService.create_branch()."""
+
+ def test_create_branch_success(self, tmp_config_dir: Path) -> None:
+ """create_branch returns branch data and auto-activates it in config."""
+ mock_client = MagicMock()
+ # create_dev_branch now waits for async job and returns branch data
+ # from job.results (with the real branch ID)
+ mock_client.create_dev_branch.return_value = {
+ "id": 777,
+ "name": "my-feature",
+ "description": "A feature branch",
+ "created": "2025-07-01T12:00:00Z",
+ }
+
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ result = svc.create_branch(alias="prod", name="my-feature", description="A feature branch")
+
+ assert result["project_alias"] == "prod"
+ assert result["branch_id"] == 777
+ assert result["branch_name"] == "my-feature"
+ assert result["activated"] is True
+
+ # Verify auto-activation persisted in config
+ project = store.get_project("prod")
+ assert project is not None
+ assert project.active_branch_id == 777
+
+ mock_client.create_dev_branch.assert_called_once_with(
+ name="my-feature", description="A feature branch"
+ )
+
+ def test_create_branch_unknown_project(self, tmp_config_dir: Path) -> None:
+ """create_branch raises ConfigError for an unknown project alias."""
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(config_store=store)
+
+ with pytest.raises(ConfigError, match="Project 'nonexistent' not found"):
+ svc.create_branch(alias="nonexistent", name="some-branch")
+
+ def test_create_branch_api_error(self, tmp_config_dir: Path) -> None:
+ """create_branch propagates KeboolaApiError from the client."""
+ mock_client = MagicMock()
+ mock_client.create_dev_branch.side_effect = KeboolaApiError(
+ message="Branch name already exists",
+ error_code="CONFLICT",
+ status_code=409,
+ retryable=False,
+ )
+
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ with pytest.raises(KeboolaApiError, match="Branch name already exists"):
+ svc.create_branch(alias="prod", name="duplicate-branch")
+
+
+class TestSetActiveBranch:
+ """Tests for BranchService.set_active_branch()."""
+
+ def test_set_active_branch_success(self, tmp_config_dir: Path) -> None:
+ """set_active_branch validates branch exists and stores it in config."""
+ mock_client = MagicMock()
+ mock_client.list_dev_branches.return_value = SAMPLE_BRANCHES
+
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ result = svc.set_active_branch(alias="prod", branch_id=456)
+
+ assert result["project_alias"] == "prod"
+ assert result["branch_id"] == 456
+ assert result["branch_name"] == "feature-x"
+
+ # Verify config was updated
+ project = store.get_project("prod")
+ assert project is not None
+ assert project.active_branch_id == 456
+
+ def test_set_active_branch_not_found(self, tmp_config_dir: Path) -> None:
+ """set_active_branch raises ConfigError when branch ID does not exist."""
+ mock_client = MagicMock()
+ mock_client.list_dev_branches.return_value = SAMPLE_BRANCHES
+
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ with pytest.raises(ConfigError, match="Branch ID 999 not found"):
+ svc.set_active_branch(alias="prod", branch_id=999)
+
+ def test_set_active_branch_api_error(self, tmp_config_dir: Path) -> None:
+ """set_active_branch propagates KeboolaApiError from the client."""
+ mock_client = MagicMock()
+ mock_client.list_dev_branches.side_effect = KeboolaApiError(
+ message="Forbidden",
+ error_code="AUTH_ERROR",
+ status_code=403,
+ retryable=False,
+ )
+
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ with pytest.raises(KeboolaApiError, match="Forbidden"):
+ svc.set_active_branch(alias="prod", branch_id=456)
+
+
+class TestResetBranch:
+ """Tests for BranchService.reset_branch()."""
+
+ def test_reset_branch_success(self, tmp_config_dir: Path) -> None:
+ """reset_branch clears the active branch, reverting to main."""
+ store = setup_single_project(tmp_config_dir)
+
+ # Set an active branch first
+ store.set_project_branch("prod", 456)
+ project = store.get_project("prod")
+ assert project is not None
+ assert project.active_branch_id == 456
+
+ svc = BranchService(config_store=store)
+
+ result = svc.reset_branch(alias="prod")
+
+ assert result["project_alias"] == "prod"
+ assert result["previous_branch_id"] == 456
+
+ # Verify config was cleared
+ project = store.get_project("prod")
+ assert project is not None
+ assert project.active_branch_id is None
+
+ def test_reset_branch_unknown_project(self, tmp_config_dir: Path) -> None:
+ """reset_branch raises ConfigError for an unknown project alias."""
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(config_store=store)
+
+ with pytest.raises(ConfigError, match="Project 'ghost' not found"):
+ svc.reset_branch(alias="ghost")
+
+
+class TestDeleteBranch:
+ """Tests for BranchService.delete_branch()."""
+
+ def test_delete_branch_success(self, tmp_config_dir: Path) -> None:
+ """delete_branch calls API and returns confirmation."""
+ mock_client = MagicMock()
+
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ result = svc.delete_branch(alias="prod", branch_id=456)
+
+ assert result["project_alias"] == "prod"
+ assert result["branch_id"] == 456
+ assert result["was_active"] is False
+ mock_client.delete_dev_branch.assert_called_once_with(456)
+
+ def test_delete_branch_auto_reset(self, tmp_config_dir: Path) -> None:
+ """delete_branch resets active_branch_id when deleting the active branch."""
+ mock_client = MagicMock()
+
+ store = setup_single_project(tmp_config_dir)
+ # Set the branch as active first
+ store.set_project_branch("prod", 456)
+
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ result = svc.delete_branch(alias="prod", branch_id=456)
+
+ assert result["was_active"] is True
+ assert "reset to main" in result["message"]
+
+ # Verify active branch was cleared
+ project = store.get_project("prod")
+ assert project is not None
+ assert project.active_branch_id is None
+
+ def test_delete_branch_api_error(self, tmp_config_dir: Path) -> None:
+ """delete_branch propagates KeboolaApiError from the client."""
+ mock_client = MagicMock()
+ mock_client.delete_dev_branch.side_effect = KeboolaApiError(
+ message="Branch not found",
+ error_code="NOT_FOUND",
+ status_code=404,
+ retryable=False,
+ )
+
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ with pytest.raises(KeboolaApiError, match="Branch not found"):
+ svc.delete_branch(alias="prod", branch_id=999)
+
+
+class TestGetMergeUrl:
+ """Tests for BranchService.get_merge_url()."""
+
+ def test_get_merge_url_with_explicit_branch(self, tmp_config_dir: Path) -> None:
+ """get_merge_url generates correct URL when branch_id is provided."""
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(config_store=store)
+
+ result = svc.get_merge_url(alias="prod", branch_id=456)
+
+ expected_url = (
+ "https://connection.keboola.com/admin/projects/258"
+ "/branch/456/development-overview"
+ )
+ assert result["url"] == expected_url
+ assert result["project_alias"] == "prod"
+ assert result["branch_id"] == 456
+
+ def test_get_merge_url_uses_active_branch(self, tmp_config_dir: Path) -> None:
+ """get_merge_url falls back to active_branch_id when no branch_id is given."""
+ store = setup_single_project(tmp_config_dir)
+ store.set_project_branch("prod", 789)
+
+ svc = BranchService(config_store=store)
+
+ result = svc.get_merge_url(alias="prod")
+
+ expected_url = (
+ "https://connection.keboola.com/admin/projects/258"
+ "/branch/789/development-overview"
+ )
+ assert result["url"] == expected_url
+ assert result["branch_id"] == 789
+
+ def test_get_merge_url_no_branch_raises(self, tmp_config_dir: Path) -> None:
+ """get_merge_url raises ConfigError when no branch_id and no active branch."""
+ store = setup_single_project(tmp_config_dir)
+ svc = BranchService(config_store=store)
+
+ with pytest.raises(ConfigError, match="No branch specified and no active branch"):
+ svc.get_merge_url(alias="prod")
+
+ def test_get_merge_url_resets_active_branch(self, tmp_config_dir: Path) -> None:
+ """get_merge_url resets active_branch_id to None after generating the URL."""
+ store = setup_single_project(tmp_config_dir)
+ store.set_project_branch("prod", 456)
+
+ svc = BranchService(config_store=store)
+
+ result = svc.get_merge_url(alias="prod")
+ assert result["branch_id"] == 456
+
+ # After generating URL, active branch should be reset
+ project = store.get_project("prod")
+ assert project is not None
+ assert project.active_branch_id is None
+
+
+class TestListBranchesActiveBranches:
+ """Tests for active_branches key in list_branches() response."""
+
+ def test_list_branches_includes_active_branches(self, tmp_config_dir: Path) -> None:
+ """list_branches result contains an active_branches dict mapping alias to branch ID."""
+ mock_client = MagicMock()
+ mock_client.list_dev_branches.return_value = SAMPLE_BRANCHES
+
+ store = setup_single_project(tmp_config_dir)
+ # Set an active branch
+ store.set_project_branch("prod", 456)
+
+ svc = BranchService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ result = svc.list_branches(aliases=["prod"])
+
+ assert "active_branches" in result
+ assert result["active_branches"] == {"prod": 456}
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 372d2ec8..d3268ab9 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -4777,3 +4777,504 @@ def test_non_tty_error(self) -> None:
_resolve_manage_token()
assert exc_info.value.exit_code == 2
+
+
+# ---------------------------------------------------------------------------
+# Branch lifecycle commands (create, use, reset, delete, merge)
+# ---------------------------------------------------------------------------
+
+
+class TestBranchCreate:
+ """Tests for `kbagent branch create` command."""
+
+ def test_branch_create_json(self, tmp_path: Path) -> None:
+ """branch create --json returns structured JSON with branch details."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.BranchService") as MockBranchService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_branch = MagicMock()
+ mock_branch.create_branch.return_value = {
+ "project_alias": "prod",
+ "branch_id": 789,
+ "branch_name": "feature-abc",
+ "description": "My feature branch",
+ "created": "2026-03-03T12:00:00Z",
+ "activated": True,
+ "message": "Branch 'feature-abc' (ID: 789) created and activated for project 'prod'.",
+ }
+ MockBranchService.return_value = mock_branch
+
+ result = runner.invoke(
+ app,
+ [
+ "--json",
+ "branch",
+ "create",
+ "--project",
+ "prod",
+ "--name",
+ "feature-abc",
+ "--description",
+ "My feature branch",
+ ],
+ )
+
+ assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}"
+ output = json.loads(result.output)
+ assert output["status"] == "ok"
+ assert output["data"]["branch_id"] == 789
+ assert output["data"]["branch_name"] == "feature-abc"
+ assert output["data"]["activated"] is True
+ assert output["data"]["project_alias"] == "prod"
+ mock_branch.create_branch.assert_called_once_with(
+ alias="prod", name="feature-abc", description="My feature branch"
+ )
+
+ def test_branch_create_api_error(self, tmp_path: Path) -> None:
+ """branch create with API error returns exit code 1."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.BranchService") as MockBranchService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_branch = MagicMock()
+ mock_branch.create_branch.side_effect = KeboolaApiError(
+ message="Branch name already exists",
+ status_code=400,
+ error_code="BRANCH_EXISTS",
+ retryable=False,
+ )
+ MockBranchService.return_value = mock_branch
+
+ result = runner.invoke(
+ app,
+ [
+ "--json",
+ "branch",
+ "create",
+ "--project",
+ "prod",
+ "--name",
+ "feature-abc",
+ ],
+ )
+
+ assert result.exit_code == 1
+ output = json.loads(result.output)
+ assert output["status"] == "error"
+ assert output["error"]["code"] == "BRANCH_EXISTS"
+ assert "Branch name already exists" in output["error"]["message"]
+
+
+class TestBranchUse:
+ """Tests for `kbagent branch use` command."""
+
+ def test_branch_use_json(self, tmp_path: Path) -> None:
+ """branch use --json returns structured JSON confirming activation."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.BranchService") as MockBranchService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_branch = MagicMock()
+ mock_branch.set_active_branch.return_value = {
+ "project_alias": "prod",
+ "branch_id": 456,
+ "branch_name": "feature-x",
+ "message": "Active branch set to 'feature-x' (ID: 456) for project 'prod'.",
+ }
+ MockBranchService.return_value = mock_branch
+
+ result = runner.invoke(
+ app,
+ ["--json", "branch", "use", "--project", "prod", "--branch", "456"],
+ )
+
+ assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}"
+ output = json.loads(result.output)
+ assert output["status"] == "ok"
+ assert output["data"]["branch_id"] == 456
+ assert output["data"]["branch_name"] == "feature-x"
+ assert output["data"]["project_alias"] == "prod"
+ mock_branch.set_active_branch.assert_called_once_with(alias="prod", branch_id=456)
+
+ def test_branch_use_branch_not_found(self, tmp_path: Path) -> None:
+ """branch use with nonexistent branch returns exit code 5."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.BranchService") as MockBranchService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_branch = MagicMock()
+ mock_branch.set_active_branch.side_effect = ConfigError(
+ "Branch ID 999 not found in project 'prod'. "
+ "Use 'kbagent branch list --project prod' to see available branches."
+ )
+ MockBranchService.return_value = mock_branch
+
+ result = runner.invoke(
+ app,
+ ["--json", "branch", "use", "--project", "prod", "--branch", "999"],
+ )
+
+ assert result.exit_code == 5
+ output = json.loads(result.output)
+ assert output["status"] == "error"
+ assert output["error"]["code"] == "CONFIG_ERROR"
+ assert "Branch ID 999 not found" in output["error"]["message"]
+
+
+class TestBranchReset:
+ """Tests for `kbagent branch reset` command."""
+
+ def test_branch_reset_json(self, tmp_path: Path) -> None:
+ """branch reset --json returns structured JSON confirming reset."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.BranchService") as MockBranchService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_branch = MagicMock()
+ mock_branch.reset_branch.return_value = {
+ "project_alias": "prod",
+ "previous_branch_id": 456,
+ "message": "Active branch reset to main for project 'prod'.",
+ }
+ MockBranchService.return_value = mock_branch
+
+ result = runner.invoke(
+ app,
+ ["--json", "branch", "reset", "--project", "prod"],
+ )
+
+ assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}"
+ output = json.loads(result.output)
+ assert output["status"] == "ok"
+ assert output["data"]["project_alias"] == "prod"
+ assert output["data"]["previous_branch_id"] == 456
+ assert "reset to main" in output["data"]["message"]
+ mock_branch.reset_branch.assert_called_once_with(alias="prod")
+
+
+class TestBranchDelete:
+ """Tests for `kbagent branch delete` command."""
+
+ def test_branch_delete_json(self, tmp_path: Path) -> None:
+ """branch delete --json returns structured JSON confirming deletion."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.BranchService") as MockBranchService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_branch = MagicMock()
+ mock_branch.delete_branch.return_value = {
+ "project_alias": "prod",
+ "branch_id": 456,
+ "was_active": True,
+ "message": "Branch ID 456 deleted from project 'prod'. Active branch reset to main.",
+ }
+ MockBranchService.return_value = mock_branch
+
+ result = runner.invoke(
+ app,
+ ["--json", "branch", "delete", "--project", "prod", "--branch", "456"],
+ )
+
+ assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}"
+ output = json.loads(result.output)
+ assert output["status"] == "ok"
+ assert output["data"]["branch_id"] == 456
+ assert output["data"]["was_active"] is True
+ assert output["data"]["project_alias"] == "prod"
+ assert "deleted" in output["data"]["message"]
+ mock_branch.delete_branch.assert_called_once_with(alias="prod", branch_id=456)
+
+
+class TestBranchMerge:
+ """Tests for `kbagent branch merge` command."""
+
+ def test_branch_merge_json(self, tmp_path: Path) -> None:
+ """branch merge --json returns structured JSON with merge URL."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.BranchService") as MockBranchService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_branch = MagicMock()
+ mock_branch.get_merge_url.return_value = {
+ "project_alias": "prod",
+ "branch_id": 456,
+ "url": "https://connection.keboola.com/admin/projects/1234/branch/456/development-overview",
+ "message": (
+ "Open this URL to review and merge branch 456 "
+ "in project 'prod'. Active branch has been reset to main."
+ ),
+ }
+ MockBranchService.return_value = mock_branch
+
+ result = runner.invoke(
+ app,
+ ["--json", "branch", "merge", "--project", "prod", "--branch", "456"],
+ )
+
+ assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}"
+ output = json.loads(result.output)
+ assert output["status"] == "ok"
+ assert output["data"]["branch_id"] == 456
+ assert "connection.keboola.com" in output["data"]["url"]
+ assert "/branch/456/" in output["data"]["url"]
+ assert output["data"]["project_alias"] == "prod"
+ mock_branch.get_merge_url.assert_called_once_with(alias="prod", branch_id=456)
+
+ def test_branch_merge_no_branch(self, tmp_path: Path) -> None:
+ """branch merge with no active branch and no --branch returns exit code 5."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.BranchService") as MockBranchService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_branch = MagicMock()
+ mock_branch.get_merge_url.side_effect = ConfigError(
+ "No branch specified and no active branch set for project 'prod'. "
+ "Use --branch ID or set an active branch with 'kbagent branch use'."
+ )
+ MockBranchService.return_value = mock_branch
+
+ result = runner.invoke(
+ app,
+ ["--json", "branch", "merge", "--project", "prod"],
+ )
+
+ assert result.exit_code == 5
+ output = json.loads(result.output)
+ assert output["status"] == "error"
+ assert output["error"]["code"] == "CONFIG_ERROR"
+ assert "No branch specified" in output["error"]["message"]
+
+
+# ---------------------------------------------------------------------------
+# Tool auto-resolve active branch
+# ---------------------------------------------------------------------------
+
+
+class TestToolAutoResolveBranch:
+ """Tests for tool commands auto-resolving active_branch_id from config."""
+
+ def test_tool_list_auto_resolves_active_branch(self, tmp_path: Path) -> None:
+ """tool list auto-resolves active_branch_id from project config."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ # Set up a project with an active branch
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+ store.set_project_branch("prod", 456)
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.McpService") as MockMcpService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_mcp = MagicMock()
+ mock_mcp.list_tools.return_value = {
+ "tools": SAMPLE_TOOLS,
+ "errors": [],
+ }
+ MockMcpService.return_value = mock_mcp
+
+ # No --branch flag, but project has active_branch_id=456
+ result = runner.invoke(app, ["--json", "tool", "list"])
+
+ assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}"
+ output = json.loads(result.output)
+ assert output["status"] == "ok"
+ # Verify the service was called with the auto-resolved branch
+ mock_mcp.list_tools.assert_called_once_with(
+ aliases=["prod"], branch_id="456"
+ )
+
+ def test_tool_call_auto_resolves_active_branch(self, tmp_path: Path) -> None:
+ """tool call auto-resolves active_branch_id from project config."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+
+ # Set up a project with an active branch
+ store = _setup_config_test(
+ config_dir,
+ {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}},
+ )
+ store.set_project_branch("prod", 456)
+
+ with (
+ patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
+ patch("keboola_agent_cli.cli.ProjectService") as MockProjService,
+ patch("keboola_agent_cli.cli.ConfigService") as MockCfgService,
+ patch("keboola_agent_cli.cli.JobService") as MockJobService,
+ patch("keboola_agent_cli.cli.McpService") as MockMcpService,
+ ):
+ MockStore.return_value = store
+ MockProjService.return_value = ProjectService(config_store=store)
+ MockCfgService.return_value = ConfigService(config_store=store)
+ MockJobService.return_value = JobService(config_store=store)
+
+ mock_mcp = MagicMock()
+ mock_mcp.validate_tool_input.return_value = []
+ mock_mcp.call_tool.return_value = {
+ "results": [
+ {
+ "content": [{"configs": ["cfg1"]}],
+ "isError": False,
+ "project_alias": "prod",
+ }
+ ],
+ "errors": [],
+ }
+ MockMcpService.return_value = mock_mcp
+
+ # No --branch flag, but project has active_branch_id=456
+ result = runner.invoke(
+ app,
+ ["--json", "tool", "call", "list_configs"],
+ )
+
+ assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}"
+ output = json.loads(result.output)
+ assert output["status"] == "ok"
+ # Verify the service was called with auto-resolved branch
+ mock_mcp.call_tool.assert_called_once_with(
+ tool_name="list_configs",
+ tool_input={},
+ alias="prod",
+ branch_id="456",
+ )
diff --git a/tests/test_client.py b/tests/test_client.py
index 4d5691a3..c95ff908 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1390,3 +1390,112 @@ def test_standard_url_no_warning(self) -> None:
_ = client._queue_base_url
mock_logger.warning.assert_not_called()
client.close()
+
+
+class TestCreateDevBranch:
+ """Tests for create_dev_branch() - async Storage API branch creation."""
+
+ def test_create_dev_branch_success(self, httpx_mock) -> None:
+ """create_dev_branch() polls job and returns branch data from results."""
+ # POST returns an async job
+ httpx_mock.add_response(
+ url="https://connection.keboola.com/v2/storage/dev-branches",
+ json={
+ "id": 999999,
+ "status": "success",
+ "operationName": "devBranchCreate",
+ "results": {"id": 789, "name": "my-feature", "description": "", "isDefault": False},
+ },
+ status_code=201,
+ method="POST",
+ )
+
+ with KeboolaClient(
+ stack_url="https://connection.keboola.com",
+ token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k",
+ ) as client:
+ result = client.create_dev_branch("my-feature")
+ assert result["id"] == 789
+ assert result["name"] == "my-feature"
+
+ def test_create_dev_branch_with_description(self, httpx_mock) -> None:
+ """create_dev_branch() sends description in the request body."""
+ httpx_mock.add_response(
+ url="https://connection.keboola.com/v2/storage/dev-branches",
+ json={
+ "id": 999998,
+ "status": "success",
+ "operationName": "devBranchCreate",
+ "results": {"id": 790, "name": "my-feature", "description": "A feature branch"},
+ },
+ status_code=201,
+ method="POST",
+ )
+
+ with KeboolaClient(
+ stack_url="https://connection.keboola.com",
+ token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k",
+ ) as client:
+ result = client.create_dev_branch("my-feature", description="A feature branch")
+ assert result["id"] == 790
+ assert result["description"] == "A feature branch"
+
+ # Verify the POST request body contained the description
+ request = httpx_mock.get_requests()[0]
+ import json
+ body = json.loads(request.content)
+ assert body["name"] == "my-feature"
+ assert body["description"] == "A feature branch"
+
+ def test_create_dev_branch_polls_waiting_job(self, httpx_mock) -> None:
+ """create_dev_branch() polls a waiting job until success."""
+ # POST returns a waiting job
+ httpx_mock.add_response(
+ url="https://connection.keboola.com/v2/storage/dev-branches",
+ json={"id": 111, "status": "waiting"},
+ status_code=201,
+ method="POST",
+ )
+ # First poll: still processing
+ httpx_mock.add_response(
+ url="https://connection.keboola.com/v2/storage/jobs/111",
+ json={"id": 111, "status": "processing"},
+ method="GET",
+ )
+ # Second poll: success
+ httpx_mock.add_response(
+ url="https://connection.keboola.com/v2/storage/jobs/111",
+ json={
+ "id": 111,
+ "status": "success",
+ "results": {"id": 555, "name": "polled-branch"},
+ },
+ method="GET",
+ )
+
+ from unittest.mock import patch
+ with patch("keboola_agent_cli.client.time.sleep"), KeboolaClient(
+ stack_url="https://connection.keboola.com",
+ token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k",
+ ) as client:
+ result = client.create_dev_branch("polled-branch")
+ assert result["id"] == 555
+
+
+class TestDeleteDevBranch:
+ """Tests for delete_dev_branch() - async Storage API branch deletion."""
+
+ def test_delete_dev_branch_success(self, httpx_mock) -> None:
+ """delete_dev_branch() polls job until success."""
+ httpx_mock.add_response(
+ url="https://connection.keboola.com/v2/storage/dev-branches/789",
+ json={"id": 222, "status": "success", "operationName": "devBranchDelete"},
+ method="DELETE",
+ )
+
+ with KeboolaClient(
+ stack_url="https://connection.keboola.com",
+ token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k",
+ ) as client:
+ # Should not raise any exception
+ client.delete_dev_branch(789)
diff --git a/tests/test_config_store.py b/tests/test_config_store.py
index 9a34afe0..97e1769b 100644
--- a/tests/test_config_store.py
+++ b/tests/test_config_store.py
@@ -4,6 +4,7 @@
import os
import stat
from pathlib import Path
+from unittest.mock import patch
import pytest
@@ -99,6 +100,38 @@ def test_permissions_preserved_on_resave(self, tmp_config_dir: Path) -> None:
assert mode == 0o600
+ def test_file_never_created_with_broad_permissions(self, tmp_config_dir: Path) -> None:
+ """Config file is never on disk with permissions broader than 0600 (TOCTOU fix).
+
+ Verifies that os.open is called with 0o600 mode, ensuring the file
+ descriptor is created with restricted permissions from the start,
+ rather than creating with default umask and then chmod-ing.
+ """
+ store = ConfigStore(config_dir=tmp_config_dir)
+
+ original_os_open = os.open
+ open_modes_seen: list[int] = []
+
+ def tracking_os_open(path: str, flags: int, mode: int = 0o777) -> int:
+ if "config" in path:
+ open_modes_seen.append(mode)
+ return original_os_open(path, flags, mode)
+
+ with patch("keboola_agent_cli.config_store.os.open", side_effect=tracking_os_open):
+ store.save(AppConfig())
+
+ # The file must have been opened with 0o600 mode
+ assert len(open_modes_seen) == 1
+ assert open_modes_seen[0] == 0o600
+
+ def test_temp_file_cleaned_up_after_save(self, tmp_config_dir: Path) -> None:
+ """Temporary file used during atomic write is not left behind."""
+ store = ConfigStore(config_dir=tmp_config_dir)
+ store.save(AppConfig())
+
+ tmp_file = tmp_config_dir / "config.tmp"
+ assert not tmp_file.exists()
+
class TestAddProject:
"""Tests for add_project()."""
@@ -596,3 +629,54 @@ def test_multiple_save_load_cycles(self, tmp_config_dir: Path) -> None:
assert config.projects["project-0"].project_name == "Project 0"
assert config.projects["project-9"].project_id == 9
assert config.default_project == "project-0"
+
+
+class TestSetProjectBranch:
+ """Tests for set_project_branch()."""
+
+ def test_set_project_branch_set(self, tmp_config_dir: Path) -> None:
+ """Setting a branch ID stores it on the project config."""
+ store = ConfigStore(config_dir=tmp_config_dir)
+ store.add_project(
+ "test",
+ ProjectConfig(
+ stack_url="https://connection.keboola.com",
+ token="901-abcdef-12345678",
+ ),
+ )
+
+ store.set_project_branch("test", 456)
+
+ project = store.get_project("test")
+ assert project is not None
+ assert project.active_branch_id == 456
+
+ def test_set_project_branch_clear(self, tmp_config_dir: Path) -> None:
+ """Setting branch_id to None clears the active branch."""
+ store = ConfigStore(config_dir=tmp_config_dir)
+ store.add_project(
+ "test",
+ ProjectConfig(
+ stack_url="https://connection.keboola.com",
+ token="901-abcdef-12345678",
+ ),
+ )
+
+ # First set a branch
+ store.set_project_branch("test", 789)
+ project = store.get_project("test")
+ assert project is not None
+ assert project.active_branch_id == 789
+
+ # Then clear it
+ store.set_project_branch("test", None)
+ project = store.get_project("test")
+ assert project is not None
+ assert project.active_branch_id is None
+
+ def test_set_project_branch_unknown_alias(self, tmp_config_dir: Path) -> None:
+ """Setting branch on a nonexistent alias raises ConfigError."""
+ store = ConfigStore(config_dir=tmp_config_dir)
+
+ with pytest.raises(ConfigError, match="not found"):
+ store.set_project_branch("nonexistent", 456)
diff --git a/tests/test_explorer_service.py b/tests/test_explorer_service.py
index 0d1148ee..87e9b0bc 100644
--- a/tests/test_explorer_service.py
+++ b/tests/test_explorer_service.py
@@ -6,17 +6,16 @@
import pytest
+from helpers import setup_single_project
from keboola_agent_cli.services.explorer_service import (
ExplorerService,
_assign_tier,
_build_mermaid,
_compute_job_stats,
+ _default_output_dir,
_type_icon,
)
-from helpers import setup_single_project
-
-
# ---------------------------------------------------------------------------
# Pure function tests: _assign_tier
# ---------------------------------------------------------------------------
@@ -145,6 +144,21 @@ def test_application(self) -> None:
assert _type_icon("keboola.app-something") == "AP"
+# ---------------------------------------------------------------------------
+# Pure function tests: _default_output_dir
+# ---------------------------------------------------------------------------
+
+class TestDefaultOutputDir:
+
+ def test_returns_cwd_based_path(self) -> None:
+ result = _default_output_dir()
+ assert result == Path.cwd() / "kbc-explorer"
+
+ def test_returns_path_instance(self) -> None:
+ result = _default_output_dir()
+ assert isinstance(result, Path)
+
+
# ---------------------------------------------------------------------------
# Pure function tests: _build_mermaid
# ---------------------------------------------------------------------------
diff --git a/tests/test_kbc_service.py b/tests/test_kbc_service.py
new file mode 100644
index 00000000..77c6cafc
--- /dev/null
+++ b/tests/test_kbc_service.py
@@ -0,0 +1,235 @@
+"""Tests for KbcService - kbc CLI integration for LLM export."""
+
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from helpers import setup_single_project, setup_two_projects
+from keboola_agent_cli.errors import ConfigError
+from keboola_agent_cli.services.kbc_service import (
+ KbcService,
+ _extract_host_from_url,
+ detect_kbc_command,
+)
+
+
+class TestDetectKbcCommand:
+ """Tests for detect_kbc_command()."""
+
+ @patch("keboola_agent_cli.services.kbc_service.shutil.which")
+ def test_kbc_found(self, mock_which: MagicMock) -> None:
+ mock_which.return_value = "/usr/local/bin/kbc"
+ assert detect_kbc_command() == "/usr/local/bin/kbc"
+ mock_which.assert_called_once_with("kbc")
+
+ @patch("keboola_agent_cli.services.kbc_service.shutil.which")
+ def test_kbc_not_found(self, mock_which: MagicMock) -> None:
+ mock_which.return_value = None
+ assert detect_kbc_command() is None
+
+
+class TestExtractHostFromUrl:
+ """Tests for _extract_host_from_url()."""
+
+ def test_standard_url(self) -> None:
+ assert _extract_host_from_url("https://connection.keboola.com") == "connection.keboola.com"
+
+ def test_azure_url(self) -> None:
+ result = _extract_host_from_url("https://connection.north-europe.azure.keboola.com")
+ assert result == "connection.north-europe.azure.keboola.com"
+
+ def test_url_with_path(self) -> None:
+ assert _extract_host_from_url("https://connection.keboola.com/v2/") == "connection.keboola.com"
+
+ def test_empty_url_raises(self) -> None:
+ with pytest.raises(ValueError, match="Cannot extract hostname"):
+ _extract_host_from_url("")
+
+
+class TestKbcServiceCheckAvailable:
+ """Tests for KbcService.check_kbc_available()."""
+
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_kbc_not_installed(self, mock_detect: MagicMock, tmp_config_dir: Path) -> None:
+ mock_detect.return_value = None
+ store = setup_single_project(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ result = svc.check_kbc_available()
+ assert result["status"] == "warn"
+ assert "not found" in result["message"]
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_kbc_installed_with_version(
+ self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path
+ ) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(stdout="Version: 2.44.0\nGit commit: abc123\n")
+
+ store = setup_single_project(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ result = svc.check_kbc_available()
+ assert result["status"] == "pass"
+ assert "v2.44.0" in result["message"]
+
+
+class TestKbcServiceGetVersion:
+ """Tests for KbcService.get_kbc_version()."""
+
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_not_installed(self, mock_detect: MagicMock) -> None:
+ mock_detect.return_value = None
+ assert KbcService.get_kbc_version() is None
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_version_parsed(self, mock_detect: MagicMock, mock_run: MagicMock) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(stdout="Version: 2.44.0\nGit commit: abc123\n")
+ assert KbcService.get_kbc_version() == "2.44.0"
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_version_unparseable(self, mock_detect: MagicMock, mock_run: MagicMock) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(stdout="unknown output")
+ assert KbcService.get_kbc_version() is None
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_timeout(self, mock_detect: MagicMock, mock_run: MagicMock) -> None:
+ import subprocess
+
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.side_effect = subprocess.TimeoutExpired(cmd="kbc", timeout=5)
+ assert KbcService.get_kbc_version() is None
+
+
+class TestKbcServiceRunLlmExport:
+ """Tests for KbcService.run_llm_export()."""
+
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_kbc_not_found_raises(self, mock_detect: MagicMock, tmp_config_dir: Path) -> None:
+ mock_detect.return_value = None
+ store = setup_single_project(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ with pytest.raises(ConfigError, match="kbc binary not found"):
+ svc.run_llm_export(alias="prod")
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_basic_export(
+ self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path
+ ) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(returncode=0)
+
+ store = setup_single_project(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path):
+ exit_code = svc.run_llm_export(alias="prod")
+
+ assert exit_code == 0
+ # Verify the command was built correctly
+ cmd = mock_run.call_args[0][0]
+ assert cmd[0] == "/usr/local/bin/kbc"
+ assert cmd[1:3] == ["llm", "export"]
+ assert "--storage-api-host" in cmd
+ assert "connection.keboola.com" in cmd
+ assert "--force" in cmd
+ assert "--non-interactive" in cmd
+ assert "--version-check=false" in cmd
+ assert "--with-samples" not in cmd
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_export_with_samples(
+ self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path
+ ) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(returncode=0)
+
+ store = setup_single_project(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path):
+ exit_code = svc.run_llm_export(
+ alias="prod", with_samples=True, sample_limit=50, max_samples=10
+ )
+
+ assert exit_code == 0
+ cmd = mock_run.call_args[0][0]
+ assert "--with-samples" in cmd
+ assert "--sample-limit" in cmd
+ assert "50" in cmd
+ assert "--max-samples" in cmd
+ assert "10" in cmd
+
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_multiple_projects_requires_alias(
+ self, mock_detect: MagicMock, tmp_config_dir: Path
+ ) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ store = setup_two_projects(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ with pytest.raises(ConfigError, match="exactly one project"):
+ svc.run_llm_export()
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_export_nonzero_exit_code(
+ self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path
+ ) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(returncode=1)
+
+ store = setup_single_project(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path):
+ exit_code = svc.run_llm_export(alias="prod")
+
+ assert exit_code == 1
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_output_dir_created(
+ self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path
+ ) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(returncode=0)
+
+ store = setup_single_project(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path):
+ svc.run_llm_export(alias="prod")
+
+ # Verify working-dir was set to tmp_path/prod
+ cmd = mock_run.call_args[0][0]
+ working_dir_idx = cmd.index("--working-dir")
+ assert cmd[working_dir_idx + 1] == str(tmp_path / "prod")
+ assert (tmp_path / "prod").is_dir()
+
+ @patch("keboola_agent_cli.services.kbc_service.subprocess.run")
+ @patch("keboola_agent_cli.services.kbc_service.detect_kbc_command")
+ def test_single_project_auto_resolve(
+ self, mock_detect: MagicMock, mock_run: MagicMock, tmp_config_dir: Path, tmp_path: Path
+ ) -> None:
+ """When only one project exists, alias=None should auto-resolve."""
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(returncode=0)
+
+ store = setup_single_project(tmp_config_dir)
+ svc = KbcService(config_store=store)
+
+ with patch("keboola_agent_cli.services.kbc_service.Path.cwd", return_value=tmp_path):
+ exit_code = svc.run_llm_export() # No alias
+
+ assert exit_code == 0
diff --git a/tests/test_lineage_service.py b/tests/test_lineage_service.py
index e2d70d07..41c1f73d 100644
--- a/tests/test_lineage_service.py
+++ b/tests/test_lineage_service.py
@@ -8,6 +8,7 @@
from helpers import setup_single_project, setup_two_projects
from keboola_agent_cli.config_store import ConfigStore
from keboola_agent_cli.errors import ConfigError, KeboolaApiError
+from keboola_agent_cli.models import ProjectConfig
from keboola_agent_cli.services.base import ENV_MAX_PARALLEL_WORKERS
from keboola_agent_cli.services.lineage_service import LineageService
@@ -778,3 +779,42 @@ def test_invalid_env_var_falls_back_to_config(self, tmp_config_dir: Path, monkey
monkeypatch.setenv(ENV_MAX_PARALLEL_WORKERS, "not-a-number")
service = LineageService(config_store=store)
assert service._resolve_max_workers() == 15
+
+
+class TestLineageProjectIdNone:
+ """Tests for projects with project_id=None being skipped in lineage."""
+
+ def test_project_with_none_project_id_skipped(self, tmp_config_dir: Path) -> None:
+ """A project with project_id=None is excluded from project_id_to_alias lookup."""
+ store = ConfigStore(config_dir=tmp_config_dir)
+ store.add_project(
+ "no-id",
+ ProjectConfig(
+ stack_url="https://connection.keboola.com",
+ token="901-xxx",
+ project_name="No ID Project",
+ # project_id defaults to None
+ ),
+ )
+ store.add_project(
+ "with-id",
+ ProjectConfig(
+ stack_url="https://connection.keboola.com",
+ token="901-yyy",
+ project_name="With ID Project",
+ project_id=999,
+ ),
+ )
+
+ mock_client = _make_lineage_client([])
+
+ service = LineageService(
+ config_store=store,
+ client_factory=lambda url, token: mock_client,
+ )
+
+ result = service.get_lineage()
+
+ # Both projects are queried, but project_id_to_alias only has the one with an ID
+ assert result["summary"]["projects_queried"] == 2
+ assert result["summary"]["projects_with_errors"] == 0
diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py
index 49b8087a..1dec69fc 100644
--- a/tests/test_mcp_service.py
+++ b/tests/test_mcp_service.py
@@ -141,10 +141,10 @@ class TestDetectMcpServerCommand:
@patch("keboola_agent_cli.services.mcp_service.shutil.which")
def test_uvx_available(self, mock_which: MagicMock) -> None:
- """When uvx is available, returns ['uvx', 'keboola_mcp_server']."""
+ """When uvx is available, returns ['uvx', 'keboola_mcp_server@latest']."""
mock_which.side_effect = lambda cmd: "/usr/local/bin/uvx" if cmd == "uvx" else None
result = detect_mcp_server_command()
- assert result == ["uvx", "keboola_mcp_server"]
+ assert result == ["uvx", "keboola_mcp_server@latest"]
@patch("keboola_agent_cli.services.mcp_service.shutil.which")
def test_keboola_mcp_server_available(self, mock_which: MagicMock) -> None:
@@ -697,48 +697,43 @@ class TestMcpTimeoutFromEnv:
def test_mcp_timeout_from_env(self) -> None:
"""KBAGENT_MCP_TOOL_TIMEOUT and KBAGENT_MCP_INIT_TIMEOUT env vars override defaults."""
- import importlib
import os
- import keboola_agent_cli.services.mcp_service as mcp_mod
-
- try:
- with patch.dict(
- os.environ,
- {
- "KBAGENT_MCP_TOOL_TIMEOUT": "120",
- "KBAGENT_MCP_INIT_TIMEOUT": "45",
- },
- ):
- importlib.reload(mcp_mod)
- assert mcp_mod.MCP_TOOL_TIMEOUT_SECONDS == 120
- assert mcp_mod.MCP_INIT_TIMEOUT_SECONDS == 45
- finally:
- # Reload to restore original state
- importlib.reload(mcp_mod)
+ from keboola_agent_cli.services.mcp_service import (
+ _get_init_timeout,
+ _get_tool_timeout,
+ )
+
+ with patch.dict(
+ os.environ,
+ {
+ "KBAGENT_MCP_TOOL_TIMEOUT": "120",
+ "KBAGENT_MCP_INIT_TIMEOUT": "45",
+ },
+ ):
+ assert _get_tool_timeout() == 120
+ assert _get_init_timeout() == 45
def test_mcp_timeout_defaults(self) -> None:
"""Without env vars, MCP timeouts use default values from constants."""
- import importlib
import os
- import keboola_agent_cli.services.mcp_service as mcp_mod
from keboola_agent_cli.constants import (
DEFAULT_MCP_INIT_TIMEOUT,
DEFAULT_MCP_TOOL_TIMEOUT,
)
+ from keboola_agent_cli.services.mcp_service import (
+ _get_init_timeout,
+ _get_tool_timeout,
+ )
- try:
- # Clear any env vars that might be set
- env = os.environ.copy()
- env.pop("KBAGENT_MCP_TOOL_TIMEOUT", None)
- env.pop("KBAGENT_MCP_INIT_TIMEOUT", None)
- with patch.dict(os.environ, env, clear=True):
- importlib.reload(mcp_mod)
- assert mcp_mod.MCP_TOOL_TIMEOUT_SECONDS == DEFAULT_MCP_TOOL_TIMEOUT
- assert mcp_mod.MCP_INIT_TIMEOUT_SECONDS == DEFAULT_MCP_INIT_TIMEOUT
- finally:
- importlib.reload(mcp_mod)
+ # Clear any env vars that might be set
+ env = os.environ.copy()
+ env.pop("KBAGENT_MCP_TOOL_TIMEOUT", None)
+ env.pop("KBAGENT_MCP_INIT_TIMEOUT", None)
+ with patch.dict(os.environ, env, clear=True):
+ assert _get_tool_timeout() == DEFAULT_MCP_TOOL_TIMEOUT
+ assert _get_init_timeout() == DEFAULT_MCP_INIT_TIMEOUT
# ---------------------------------------------------------------------------
diff --git a/tests/test_models.py b/tests/test_models.py
index 772bd695..bb7cde57 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -359,3 +359,30 @@ def test_max_workers_at_1_is_valid(self) -> None:
"""max_parallel_workers = 1 is accepted."""
config = AppConfig(max_parallel_workers=1)
assert config.max_parallel_workers == 1
+
+
+class TestProjectConfigBackwardCompat:
+ """Tests for backward compatibility of ProjectConfig with active_branch_id."""
+
+ def test_project_config_without_active_branch_id(self) -> None:
+ """ProjectConfig created from dict without active_branch_id defaults to None."""
+ data = {
+ "stack_url": "https://connection.keboola.com",
+ "token": "901-secret-token",
+ "project_name": "My Project",
+ "project_id": 1234,
+ }
+ config = ProjectConfig.model_validate(data)
+ assert config.active_branch_id is None
+
+ def test_project_config_with_active_branch_id(self) -> None:
+ """ProjectConfig created with active_branch_id preserves the value."""
+ data = {
+ "stack_url": "https://connection.keboola.com",
+ "token": "901-secret-token",
+ "project_name": "My Project",
+ "project_id": 1234,
+ "active_branch_id": 123,
+ }
+ config = ProjectConfig.model_validate(data)
+ assert config.active_branch_id == 123
diff --git a/tests/test_org_service.py b/tests/test_org_service.py
index 5dc70837..9fe9c2e5 100644
--- a/tests/test_org_service.py
+++ b/tests/test_org_service.py
@@ -387,6 +387,46 @@ def test_custom_token_description(self, tmp_path: Path) -> None:
) or "my-custom-prefix" in str(call_args)
+class TestExistingProjectIdNone:
+ """Tests for existing projects with project_id=None not polluting the set."""
+
+ def test_none_project_id_not_in_existing_set(self, tmp_path: Path) -> None:
+ """A pre-registered project with project_id=None does not block new projects."""
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+ store = ConfigStore(config_dir=config_dir)
+
+ # Pre-register a project without project_id (defaults to None)
+ store.add_project(
+ "legacy",
+ ProjectConfig(
+ stack_url="https://connection.keboola.com",
+ token="901-legacy-tokenValue12345678901",
+ project_name="Legacy",
+ # project_id is None
+ ),
+ )
+
+ projects = [{"id": 100, "name": "Alpha"}]
+
+ service = OrgService(
+ config_store=store,
+ manage_client_factory=_make_manage_client(projects),
+ storage_client_factory=_make_storage_client(),
+ )
+
+ result = service.setup_organization(
+ stack_url="https://connection.keboola.com",
+ manage_token="manage-token-123456789012345678",
+ org_id=42,
+ )
+
+ # Project 100 should be added, not skipped
+ assert len(result["projects_added"]) == 1
+ assert result["projects_added"][0]["project_id"] == 100
+ assert len(result["projects_skipped"]) == 0
+
+
class TestUniqueAlias:
"""Tests for OrgService._unique_alias() static method."""
diff --git a/tests/test_version_service.py b/tests/test_version_service.py
new file mode 100644
index 00000000..aeaff5f9
--- /dev/null
+++ b/tests/test_version_service.py
@@ -0,0 +1,269 @@
+"""Tests for VersionService - version detection and update checks."""
+
+from unittest.mock import MagicMock, patch
+
+from keboola_agent_cli.services.version_service import (
+ VersionService,
+ _fetch_kbc_latest_version,
+ _fetch_mcp_latest_version,
+ _get_kbc_local_version,
+ _is_up_to_date,
+ _is_uvx_available,
+)
+
+
+class TestGetKbcLocalVersion:
+ """Tests for _get_kbc_local_version()."""
+
+ @patch("keboola_agent_cli.services.version_service.detect_kbc_command")
+ def test_not_installed(self, mock_detect: MagicMock) -> None:
+ mock_detect.return_value = None
+ assert _get_kbc_local_version() is None
+
+ @patch("keboola_agent_cli.services.version_service.subprocess.run")
+ @patch("keboola_agent_cli.services.version_service.detect_kbc_command")
+ def test_version_parsed(self, mock_detect: MagicMock, mock_run: MagicMock) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(stdout="Version: 2.44.0\nGit commit: abc\n")
+ assert _get_kbc_local_version() == "2.44.0"
+
+ @patch("keboola_agent_cli.services.version_service.subprocess.run")
+ @patch("keboola_agent_cli.services.version_service.detect_kbc_command")
+ def test_unparseable_output(self, mock_detect: MagicMock, mock_run: MagicMock) -> None:
+ mock_detect.return_value = "/usr/local/bin/kbc"
+ mock_run.return_value = MagicMock(stdout="no version here")
+ assert _get_kbc_local_version() is None
+
+
+class TestIsUvxAvailable:
+ """Tests for _is_uvx_available()."""
+
+ @patch("keboola_agent_cli.services.version_service.shutil.which")
+ def test_uvx_found(self, mock_which: MagicMock) -> None:
+ mock_which.return_value = "/usr/local/bin/uvx"
+ assert _is_uvx_available() is True
+
+ @patch("keboola_agent_cli.services.version_service.shutil.which")
+ def test_uvx_not_found(self, mock_which: MagicMock) -> None:
+ mock_which.return_value = None
+ assert _is_uvx_available() is False
+
+
+class TestFetchKbcLatestVersion:
+ """Tests for _fetch_kbc_latest_version()."""
+
+ @patch("keboola_agent_cli.services.version_service.httpx.get")
+ def test_success(self, mock_get: MagicMock) -> None:
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"tag_name": "v2.44.2"}
+ mock_response.raise_for_status = MagicMock()
+ mock_get.return_value = mock_response
+
+ assert _fetch_kbc_latest_version() == "2.44.2"
+
+ @patch("keboola_agent_cli.services.version_service.httpx.get")
+ def test_no_v_prefix(self, mock_get: MagicMock) -> None:
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"tag_name": "2.44.2"}
+ mock_response.raise_for_status = MagicMock()
+ mock_get.return_value = mock_response
+
+ assert _fetch_kbc_latest_version() == "2.44.2"
+
+ @patch("keboola_agent_cli.services.version_service.httpx.get")
+ def test_http_error(self, mock_get: MagicMock) -> None:
+ import httpx
+
+ mock_get.side_effect = httpx.HTTPError("connection failed")
+ assert _fetch_kbc_latest_version() is None
+
+ @patch("keboola_agent_cli.services.version_service.httpx.get")
+ def test_invalid_tag(self, mock_get: MagicMock) -> None:
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"tag_name": "not-a-version"}
+ mock_response.raise_for_status = MagicMock()
+ mock_get.return_value = mock_response
+
+ assert _fetch_kbc_latest_version() is None
+
+
+class TestFetchMcpLatestVersion:
+ """Tests for _fetch_mcp_latest_version()."""
+
+ @patch("keboola_agent_cli.services.version_service.httpx.get")
+ def test_success(self, mock_get: MagicMock) -> None:
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"info": {"version": "1.46.0"}}
+ mock_response.raise_for_status = MagicMock()
+ mock_get.return_value = mock_response
+
+ assert _fetch_mcp_latest_version() == "1.46.0"
+
+ @patch("keboola_agent_cli.services.version_service.httpx.get")
+ def test_http_error(self, mock_get: MagicMock) -> None:
+ import httpx
+
+ mock_get.side_effect = httpx.HTTPError("connection failed")
+ assert _fetch_mcp_latest_version() is None
+
+
+class TestIsUpToDate:
+ """Tests for _is_up_to_date()."""
+
+ def test_same_version(self) -> None:
+ assert _is_up_to_date("2.44.0", "2.44.0") is True
+
+ def test_newer_available(self) -> None:
+ assert _is_up_to_date("2.44.0", "2.44.2") is False
+
+ def test_local_newer(self) -> None:
+ assert _is_up_to_date("2.45.0", "2.44.2") is True
+
+ def test_local_none(self) -> None:
+ assert _is_up_to_date(None, "2.44.2") is None
+
+ def test_latest_none(self) -> None:
+ assert _is_up_to_date("2.44.0", None) is None
+
+ def test_both_none(self) -> None:
+ assert _is_up_to_date(None, None) is None
+
+ def test_invalid_version(self) -> None:
+ assert _is_up_to_date("not-a-version", "2.44.0") is None
+
+
+class TestVersionService:
+ """Tests for VersionService.get_versions()."""
+
+ @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version")
+ @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version")
+ @patch("keboola_agent_cli.services.version_service._is_uvx_available")
+ @patch("keboola_agent_cli.services.version_service._get_kbc_local_version")
+ def test_kbc_update_available_mcp_auto(
+ self,
+ mock_kbc_local: MagicMock,
+ mock_uvx: MagicMock,
+ mock_kbc_latest: MagicMock,
+ mock_mcp_latest: MagicMock,
+ ) -> None:
+ mock_kbc_local.return_value = "2.44.0"
+ mock_uvx.return_value = True
+ mock_kbc_latest.return_value = "2.44.2"
+ mock_mcp_latest.return_value = "1.46.0"
+
+ svc = VersionService()
+ result = svc.get_versions()
+
+ assert result["kbagent"]["version"] is not None
+ deps = result["dependencies"]
+ assert len(deps) == 2
+
+ kbc_dep = deps[0]
+ assert kbc_dep["name"] == "kbc"
+ assert kbc_dep["local_version"] == "2.44.0"
+ assert kbc_dep["latest_version"] == "2.44.2"
+ assert kbc_dep["up_to_date"] is False
+
+ mcp_dep = deps[1]
+ assert mcp_dep["name"] == "keboola-mcp-server"
+ assert mcp_dep["auto_updates"] is True
+ assert mcp_dep["uvx_available"] is True
+ assert mcp_dep["latest_version"] == "1.46.0"
+
+ @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version")
+ @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version")
+ @patch("keboola_agent_cli.services.version_service._is_uvx_available")
+ @patch("keboola_agent_cli.services.version_service._get_kbc_local_version")
+ def test_all_up_to_date(
+ self,
+ mock_kbc_local: MagicMock,
+ mock_uvx: MagicMock,
+ mock_kbc_latest: MagicMock,
+ mock_mcp_latest: MagicMock,
+ ) -> None:
+ mock_kbc_local.return_value = "2.44.2"
+ mock_uvx.return_value = True
+ mock_kbc_latest.return_value = "2.44.2"
+ mock_mcp_latest.return_value = "1.46.0"
+
+ svc = VersionService()
+ result = svc.get_versions()
+
+ kbc_dep = result["dependencies"][0]
+ assert kbc_dep["up_to_date"] is True
+
+ mcp_dep = result["dependencies"][1]
+ assert mcp_dep["auto_updates"] is True
+ assert mcp_dep["latest_version"] == "1.46.0"
+
+ @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version")
+ @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version")
+ @patch("keboola_agent_cli.services.version_service._is_uvx_available")
+ @patch("keboola_agent_cli.services.version_service._get_kbc_local_version")
+ def test_kbc_not_installed(
+ self,
+ mock_kbc_local: MagicMock,
+ mock_uvx: MagicMock,
+ mock_kbc_latest: MagicMock,
+ mock_mcp_latest: MagicMock,
+ ) -> None:
+ mock_kbc_local.return_value = None
+ mock_uvx.return_value = True
+ mock_kbc_latest.return_value = "2.44.2"
+ mock_mcp_latest.return_value = "1.46.0"
+
+ svc = VersionService()
+ result = svc.get_versions()
+
+ kbc_dep = result["dependencies"][0]
+ assert kbc_dep["local_version"] is None
+ assert kbc_dep["up_to_date"] is None
+
+ @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version")
+ @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version")
+ @patch("keboola_agent_cli.services.version_service._is_uvx_available")
+ @patch("keboola_agent_cli.services.version_service._get_kbc_local_version")
+ def test_uvx_not_available(
+ self,
+ mock_kbc_local: MagicMock,
+ mock_uvx: MagicMock,
+ mock_kbc_latest: MagicMock,
+ mock_mcp_latest: MagicMock,
+ ) -> None:
+ mock_kbc_local.return_value = "2.44.0"
+ mock_uvx.return_value = False
+ mock_kbc_latest.return_value = "2.44.0"
+ mock_mcp_latest.return_value = "1.46.0"
+
+ svc = VersionService()
+ result = svc.get_versions()
+
+ mcp_dep = result["dependencies"][1]
+ assert mcp_dep["uvx_available"] is False
+ assert mcp_dep["auto_updates"] is True
+
+ @patch("keboola_agent_cli.services.version_service._fetch_mcp_latest_version")
+ @patch("keboola_agent_cli.services.version_service._fetch_kbc_latest_version")
+ @patch("keboola_agent_cli.services.version_service._is_uvx_available")
+ @patch("keboola_agent_cli.services.version_service._get_kbc_local_version")
+ def test_remote_check_fails(
+ self,
+ mock_kbc_local: MagicMock,
+ mock_uvx: MagicMock,
+ mock_kbc_latest: MagicMock,
+ mock_mcp_latest: MagicMock,
+ ) -> None:
+ mock_kbc_local.return_value = "2.44.0"
+ mock_uvx.return_value = True
+ mock_kbc_latest.return_value = None
+ mock_mcp_latest.return_value = None
+
+ svc = VersionService()
+ result = svc.get_versions()
+
+ kbc_dep = result["dependencies"][0]
+ assert kbc_dep["latest_version"] is None
+ assert kbc_dep["up_to_date"] is None
+
+ mcp_dep = result["dependencies"][1]
+ assert mcp_dep["latest_version"] is None
diff --git a/uv.lock b/uv.lock
index 6ef6ffc6..a7d945d3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -42,6 +42,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
+[[package]]
+name = "bandit"
+version = "1.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "pyyaml" },
+ { name = "rich" },
+ { name = "stevedore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" },
+]
+
+[[package]]
+name = "boolean-py"
+version = "5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
+]
+
+[[package]]
+name = "cachecontrol"
+version = "0.14.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "msgpack" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
+]
+
+[package.optional-dependencies]
+filecache = [
+ { name = "filelock" },
+]
+
[[package]]
name = "certifi"
version = "2026.2.25"
@@ -108,6 +150,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+]
+
[[package]]
name = "click"
version = "8.3.1"
@@ -182,6 +281,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
]
+[[package]]
+name = "cyclonedx-python-lib"
+version = "11.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "license-expression" },
+ { name = "packageurl-python" },
+ { name = "py-serializable" },
+ { name = "sortedcontainers" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" },
+]
+
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -275,7 +408,7 @@ wheels = [
[[package]]
name = "keboola-agent-cli"
-version = "0.5.0"
+version = "0.6.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -290,6 +423,8 @@ dependencies = [
[package.dev-dependencies]
dev = [
+ { name = "bandit" },
+ { name = "pip-audit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-httpx" },
@@ -310,12 +445,26 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
+ { name = "bandit", specifier = ">=1.9.4" },
+ { name = "pip-audit", specifier = ">=2.10.0" },
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=0.23" },
{ name = "pytest-httpx", specifier = ">=0.30" },
{ name = "ruff", specifier = ">=0.8" },
]
+[[package]]
+name = "license-expression"
+version = "30.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "boolean-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
+]
+
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -362,6 +511,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
+[[package]]
+name = "msgpack"
+version = "1.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
+ { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
+ { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
+ { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
+ { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
+ { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
+ { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
+ { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
+ { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
+ { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
+ { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
+ { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
+ { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
+ { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
+ { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
+ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
+]
+
+[[package]]
+name = "packageurl-python"
+version = "0.17.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
+]
+
[[package]]
name = "packaging"
version = "26.0"
@@ -371,6 +573,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
+[[package]]
+name = "pip"
+version = "26.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
+]
+
+[[package]]
+name = "pip-api"
+version = "0.0.34"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pip" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" },
+]
+
+[[package]]
+name = "pip-audit"
+version = "2.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cachecontrol", extra = ["filecache"] },
+ { name = "cyclonedx-python-lib" },
+ { name = "packaging" },
+ { name = "pip-api" },
+ { name = "pip-requirements-parser" },
+ { name = "platformdirs" },
+ { name = "requests" },
+ { name = "rich" },
+ { name = "tomli" },
+ { name = "tomli-w" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" },
+]
+
+[[package]]
+name = "pip-requirements-parser"
+version = "32.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.9.2"
@@ -389,6 +646,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "py-serializable"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "defusedxml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
+]
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -521,6 +790,15 @@ crypto = [
{ name = "cryptography" },
]
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
[[package]]
name = "pytest"
version = "9.0.2"
@@ -657,6 +935,21 @@ 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 = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
[[package]]
name = "rich"
version = "14.3.3"
@@ -785,6 +1078,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
[[package]]
name = "sse-starlette"
version = "3.2.0"
@@ -811,6 +1113,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
+[[package]]
+name = "stevedore"
+version = "5.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
+ { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
+ { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
+ { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
+ { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
+ { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
+ { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
+ { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
+ { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
+ { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
+ { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
+ { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
+]
+
+[[package]]
+name = "tomli-w"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
+]
+
[[package]]
name = "typer"
version = "0.24.1"
@@ -847,6 +1212,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
[[package]]
name = "uvicorn"
version = "0.41.0"