diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..93a3082
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,31 @@
+version: 2
+updates:
+- package-ecosystem: uv
+ directory: /
+ schedule:
+ interval: cron
+ cronjob: 15 5 * * *
+ timezone: Europe/Warsaw
+ open-pull-requests-limit: 1
+ versioning-strategy: increase-if-necessary
+ allow:
+ - dependency-name: costs
+ dependency-type: all
+ - dependency-name: goal
+ dependency-type: all
+ - dependency-name: pfix
+ dependency-type: all
+ - dependency-name: clickmd
+ dependency-type: all
+ - dependency-name: code2llm
+ dependency-type: all
+ groups:
+ internal-packages:
+ patterns:
+ - costs
+ - goal
+ - pfix
+ - clickmd
+ - code2llm
+ commit-message:
+ prefix: "chore(deps)"
diff --git a/.github/internal-dependencies.json b/.github/internal-dependencies.json
new file mode 100644
index 0000000..ce582b0
--- /dev/null
+++ b/.github/internal-dependencies.json
@@ -0,0 +1,35 @@
+{
+ "schema": "goal.internal-dependencies/v1",
+ "packages": [
+ {
+ "name": "costs",
+ "repository": "semcod/costs",
+ "registry": "pypi",
+ "versioning": "semver"
+ },
+ {
+ "name": "goal",
+ "repository": "semcod/goal",
+ "registry": "pypi",
+ "versioning": "semver"
+ },
+ {
+ "name": "pfix",
+ "repository": "semcod/pfix",
+ "registry": "pypi",
+ "versioning": "semver"
+ },
+ {
+ "name": "clickmd",
+ "repository": "semcod/clickmd",
+ "registry": "pypi",
+ "versioning": "semver"
+ },
+ {
+ "name": "code2llm",
+ "repository": "semcod/code2llm",
+ "registry": "pypi",
+ "versioning": "semver"
+ }
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ad6128e..0506e79 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,29 +5,48 @@ on:
branches: [main]
pull_request:
branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
jobs:
test:
runs-on: ubuntu-latest
+ timeout-minutes: 15
strategy:
+ fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
use-op3: [0, 1]
steps:
- - uses: actions/checkout@v4
-
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
-
- - name: Install uv
- uses: astral-sh/setup-uv@v5
-
- - name: Install dependencies
- run: uv sync --frozen
-
- - name: Run tests (REDEPLOY_USE_OP3=${{ matrix.use-op3 }})
+ - name: Check out immutable documentation standard
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ repository: wellmanifest/docs
+ ref: ebe7501063ef4f3e63ded610c2d3183010ca636e # v0.1.1
+ path: .docs-standard
+ token: ${{ secrets.ORG_SYNC_PAT }}
+ persist-credentials: false
+ - name: Validate documentation placement
+ shell: bash
+ env:
+ DOCS_BASE: ${{ github.event.pull_request.base.sha }}
+ run: |
+ set -euo pipefail
+ args=(--root . --standard-revision ebe7501063ef4f3e63ded610c2d3183010ca636e)
+ if [[ -n "$DOCS_BASE" ]]; then args+=(--base "$DOCS_BASE"); fi
+ python .docs-standard/docs/standard/check.py "${args[@]}"
+ - run: python -m pip install uv
+ - name: Install locked test dependencies
+ run: uv sync --locked --extra dev --extra op3 --python "${{ matrix.python-version }}"
+ - name: Test supported Python versions
env:
REDEPLOY_USE_OP3: ${{ matrix.use-op3 }}
- run: uv run pytest tests/ tests/contract/ tests/integration/ -v --tb=short
+ run: uv run --no-sync python -m pytest tests redeploy/tests -q
diff --git a/.github/workflows/internal-dependency-freshness.yml b/.github/workflows/internal-dependency-freshness.yml
new file mode 100644
index 0000000..1f0eaf2
--- /dev/null
+++ b/.github/workflows/internal-dependency-freshness.yml
@@ -0,0 +1,36 @@
+name: Internal dependency freshness
+
+on:
+ schedule:
+ - cron: "24 6 * * *"
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - pyproject.toml
+ - uv.lock
+ - .github/internal-dependencies.json
+ - .github/workflows/internal-dependency-freshness.yml
+
+permissions:
+ contents: read
+
+jobs:
+ freshness:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.12"
+ - name: Install released checker
+ run: python -m pip install "goal==2.2.0"
+ - name: Verify published stable targets
+ run: goal dependencies --catalog .github/internal-dependencies.json --check > dependency-freshness.json
+ - name: Retain freshness evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: dependency-freshness
+ path: dependency-freshness.json
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 346b99a..83e035f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,3 +92,6 @@ apply-results.yaml
# Environment variables
.env
+
+# Linked development checkouts
+/worktrees/
diff --git a/.governance/docs.json b/.governance/docs.json
new file mode 100644
index 0000000..908b39e
--- /dev/null
+++ b/.governance/docs.json
@@ -0,0 +1,7 @@
+{
+ "schema": "wellmanifest.docs/adoption/v1",
+ "repository": "semcod/redeploy",
+ "standard": "wellmanifest/docs",
+ "source_revision": "ebe7501063ef4f3e63ded610c2d3183010ca636e",
+ "policy_sha256": "f6ba9c011ea1d9260e7fac3a1638a767d5ebc9f7d9b32ed51cc3aea22fe95d8c"
+}
diff --git a/docs/README.md b/docs/README.md
index 645f57e..170c37a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1232,4 +1232,5 @@ pytest
| `README.md` | Project overview (this file) | — |
| `examples` | Usage examples and code samples | [View](./examples) |
-
\ No newline at end of file
+
+- [Aktualizacja i kontrola zależności wewnętrznych](information/internal-dependencies.md)
diff --git a/docs/information/internal-dependencies.md b/docs/information/internal-dependencies.md
new file mode 100644
index 0000000..3e73b85
--- /dev/null
+++ b/docs/information/internal-dependencies.md
@@ -0,0 +1,83 @@
+---
+{
+ "schema": "wellmanifest.docs/document/v1",
+ "id": "internal-dependencies",
+ "kind": "information",
+ "version": 1,
+ "title": "Aktualizacja i kontrola zależności wewnętrznych",
+ "status": "implemented",
+ "owner": "semcod/redeploy",
+ "created": "2026-09-06",
+ "updated": "2026-09-06",
+ "review_after": "2026-09-13",
+ "source_revision": "91671e551248177894de1fdc57dfef453e00d85d",
+ "affected_repositories": [
+ "semcod/redeploy"
+ ],
+ "evidence": [
+ "https://github.com/semcod/redeploy/blob/91671e551248177894de1fdc57dfef453e00d85d/pyproject.toml",
+ "https://pypi.org/project/goal/2.2.0/",
+ "https://docs.astral.sh/uv/concepts/projects/dependencies/",
+ "https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference"
+ ]
+}
+---
+
+# Aktualizacja i kontrola zależności wewnętrznych
+
+
+## Cel
+
+Utrzymywać aktualne, przetestowane zależności projektu `semcod/redeploy` przy zachowaniu Pythona 3.11 jako minimalnej wersji aplikacji.
+
+
+## Zakres
+
+Właścicielem lockfile i CI jest `semcod/redeploy`. Katalog [.github/internal-dependencies.json](../../.github/internal-dependencies.json) obejmuje pięć jawnie wskazanych dystrybucji PyPI: costs, goal, pfix, clickmd i code2llm. Audyt sprawdza te z nich, które występują w uv.lock. Nie obejmuje dowolnego pakietu tylko na podstawie nazwy organizacji.
+
+
+## Dowody
+
+Przed zmianą workflow CI używał uv sync --frozen bez dodatku dev, a późniejsze uv run mogło ponownie rozwiązać zależności. Teraz CI instaluje uv sync --locked --extra dev --extra op3 i uruchamia testy przez uv run --no-sync. Zachowano macierz Python 3.11/3.12/3.13 i oba ustawienia REDEPLOY_USE_OP3. W odczycie PyPI z 2026-09-06 aktualnymi stabilnymi wersjami były costs 0.2.0, goal 2.2.0, pfix 0.1.79 i clickmd 1.1.15; te wersje zapisano w uv.lock.
+
+Testy obejmują tests/ i redeploy/tests/, w tym regresje wykonawcy. Lokalna macierz dała 1568 passed, 17 skipped w każdym z sześciu wariantów; pominięcia obejmują integracje wymagające zewnętrznych usług. Wyniki publikacji i kolejnych kontroli są dostępne w [GitHub Actions](https://github.com/semcod/redeploy/actions).
+
+Przywrócono pełną implementację redeploy/apply/handlers.py z [ostatniej wersji przed wyzerowaniem](https://github.com/semcod/redeploy/blob/d9c7dd873cd2cedb6bb082bf2757661f4f3f3da9/redeploy/apply/handlers.py). [Commit wydania 0.2.80](https://github.com/semcod/redeploy/commit/257a616) usunął jej treść, blokując import aplikacji. Testy CLI korzystają teraz z izolowanego katalogu i jawnych plików wejściowych; testy schematu nie wymagają prywatnego checkoutu c2004.
+
+
+## Obsługa
+
+Goal jest narzędziem automatyzacji, bez importów w kodzie aplikacji. Usunięto go z runtime i dodatku dev. Grupa `automation` wymaga Pythona >=3.12 i nie jest domyślnie instalowana z aplikacją ani dodatkiem `dev`. Zastosowano [oddzielny zakres Pythona grupy uv](https://docs.astral.sh/uv/concepts/projects/dependencies/#group-requires-python).
+
+```bash
+uv sync --locked --extra dev --extra op3 --python 3.11
+uv run --no-sync python -m pytest tests redeploy/tests -q
+```
+
+Dependabot codziennie proponuje aktualizację wewnętrznych pakietów w jednym PR. Testy uruchamiają zatwierdzony lockfile na Pythonie 3.11, 3.12 i 3.13. Osobny workflow codziennie oraz po zmianie zależności w PR porównuje lockfile z najwyższymi stabilnymi wydaniami w katalogu; zachowuje raport jako artefakt. Obie automatyzacje można uruchomić ręcznie.
+
+Ręczny audyt używa grupy narzędziowej:
+
+```bash
+uv run --locked --group automation --python 3.12 goal dependencies --catalog .github/internal-dependencies.json --check
+```
+
+Aktualizacja wybranych pakietów:
+
+```bash
+uv lock --upgrade-package costs --upgrade-package goal --upgrade-package pfix --upgrade-package clickmd --upgrade-package code2llm
+```
+
+Po aktualizacji uruchom testy, opublikuj PR, a po jego sprawdzeniu i scaleniu zsynchronizuj środowisko. CI kontroluje też położenie i metadane dokumentacji według wellmanifest/docs 0.1.1, przypiętego do `ebe7501063ef4f3e63ded610c2d3183010ca636e` w `.governance/docs.json`.
+
+
+## Ograniczenia
+
+Ograniczenie `>=` dopuszcza nowszą wersję, lecz nie zmienia istniejącego środowiska. `uv sync --locked` odtwarza wersje z lockfile. Codzienny PR nie jest automatycznie scalany ani wdrażany. Opóźnienie zależy od harmonogramu, testów i procesu publikacji.
+
+Checker w CI jest przypięty do Goal 2.2.0. Jego aktualizację trzeba wykonać jawnie. Audyt tej wersji porównuje stabilne wersje x.y.z i nie jest pełnym resolverem wszystkich formatów wersji ani kontrolą pochodzenia każdego pakietu. Dokładne przypięcie standardu dokumentacji podlega osobnej, sprawdzanej aktualizacji.
+
+
+## Utrzymanie
+
+Sprawdzaj nieudane harmonogramy i zaległe PR-y. Nową dystrybucję dodaj do katalogu dopiero po potwierdzeniu jej właściciela i sposobu wersjonowania. Wyniki przekrojowe rozwijaj w [kanonicznym raporcie subactor/docs](https://github.com/subactor/docs/blob/main/architecture/analysis/internal-dependencies.md). Zwiększ wersję tego dokumentu po zmianie mechanizmu.
diff --git a/pyproject.toml b/pyproject.toml
index 2d74ebc..116f4a8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,9 +18,8 @@ dependencies = [
"httpx>=0.25",
"rich>=13.0",
"jmespath>=1.0",
- "goal>=2.1.0",
- "costs>=0.1.20",
- "pfix>=0.1.60",
+ "costs>=0.2.0",
+ "pfix>=0.1.79",
]
license = "Apache-2.0"
@@ -30,9 +29,8 @@ redeploy-mcp = "redeploy.mcp_server:serve"
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.4",
- "goal>=2.1.0",
- "costs>=0.1.20",
- "pfix>=0.1.60",
+ "costs>=0.2.0",
+ "pfix>=0.1.79",
]
op3 = ["op3>=0.1.8"]
mcp = ["mcp>=1.0"]
@@ -82,3 +80,9 @@ max_commits = 500
# Cost thresholds for badge colors (USD)
badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 }
+
+[dependency-groups]
+automation = ["goal>=2.2.0"]
+
+[tool.uv.dependency-groups.automation]
+requires-python = ">=3.12"
diff --git a/redeploy/apply/handlers.py b/redeploy/apply/handlers.py
index e69de29..80ca4d3 100644
--- a/redeploy/apply/handlers.py
+++ b/redeploy/apply/handlers.py
@@ -0,0 +1,803 @@
+"""Step action handlers for migration execution."""
+from __future__ import annotations
+
+import base64
+import json
+import os
+import shlex
+import subprocess
+import threading
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from loguru import logger
+
+from ..models import MigrationStep, StepAction, StepStatus
+from .exceptions import StepError
+
+if TYPE_CHECKING:
+ from ..detect.remote import RemoteProbe
+ from ..models import MigrationPlan
+ from .progress import ProgressEmitter
+
+
+def _format_step_output(stdout: str, stderr: str, max_chars: int = 8000) -> str:
+ out = str(stdout or "").strip()
+ err = str(stderr or "").strip()
+ chunks: list[str] = []
+ if out:
+ chunks.append("stdout:\n" + out)
+ if err:
+ chunks.append("stderr:\n" + err)
+ if not chunks:
+ return "ok"
+ joined = "\n\n".join(chunks)
+ return joined[:max_chars]
+
+
+def run_ssh(step: MigrationStep, probe: RemoteProbe) -> None:
+ """Execute SSH command on remote host."""
+ cmd = step.command
+ if not cmd:
+ raise StepError(step, "No command specified")
+ timeout = step.timeout or 300
+ r = probe.run(cmd, timeout=timeout)
+ step.result = _format_step_output(r.out, r.stderr)
+ if not r.ok:
+ raise StepError(step, f"exit={r.exit_code}: {_format_step_output(r.out, r.stderr, 400)}")
+ step.status = StepStatus.DONE
+
+
+def run_scp(step: MigrationStep, probe: RemoteProbe, plan: MigrationPlan) -> None:
+ """Copy file via SCP."""
+ if not step.src or not step.dst:
+ raise StepError(step, "scp requires src and dst")
+ if probe.is_local and Path(step.src).resolve() == Path(step.dst).resolve():
+ step.status = StepStatus.DONE
+ step.result = "skipped (same file)"
+ return
+ if probe.is_local:
+ Path(step.dst).parent.mkdir(parents=True, exist_ok=True)
+ cmd = ["cp", step.src, step.dst]
+ else:
+ _ensure_remote_parent_dir(probe, step.dst)
+ cmd = ["scp", "-o", "StrictHostKeyChecking=no",
+ step.src, f"{plan.host}:{step.dst}"]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
+ if result.returncode != 0:
+ raise StepError(step, f"scp failed: {_format_step_output(result.stdout, result.stderr, 400)}")
+ step.status = StepStatus.DONE
+ step.result = _format_step_output(result.stdout, result.stderr)
+
+
+def run_rsync(step: MigrationStep, probe: RemoteProbe, plan: MigrationPlan) -> None:
+ """Sync files via rsync.
+
+ W trybie FROZEN (env ``REDEPLOY_FROZEN_COMMIT``, ustawiany przez
+ ``redeploy run --frozen-commit``) źródła względne trackowane w zamrożonym
+ commicie jadą z eksportu ``git archive`` zamiast żywego working tree —
+ inaczej kroki spec-a przemycają WIP mimo zamrożonego syncu projektu.
+ """
+ if not step.src or not step.dst:
+ raise StepError(step, "rsync requires src and dst")
+
+ src = step.src
+ frozen_note = ""
+ frozen_commit = os.environ.get("REDEPLOY_FROZEN_COMMIT", "").strip()
+ export_ctx = None
+ if frozen_commit:
+ import tempfile
+
+ from ..gitsync import GitSyncError, frozen_rsync_src
+
+ export_ctx = tempfile.TemporaryDirectory(prefix="redeploy-frozen-rsync-")
+ try:
+ src, frozen_note = frozen_rsync_src(
+ Path.cwd(), frozen_commit, step.src, Path(export_ctx.name)
+ )
+ except GitSyncError as exc:
+ export_ctx.cleanup()
+ raise StepError(step, f"frozen rsync src: {exc}") from exc
+ logger.info(f" [{step.id}] {frozen_note}")
+
+ try:
+ if probe.is_local:
+ dst = step.dst
+ Path(dst).mkdir(parents=True, exist_ok=True)
+ else:
+ _ensure_remote_parent_dir(probe, step.dst)
+ dst = f"{plan.host}:{step.dst}"
+ cmd = [
+ "rsync",
+ "-az",
+ "--delete",
+ "--filter",
+ ":- .gitignore",
+ "--filter",
+ ":- .redeployignore",
+ ]
+ for exc in step.excludes:
+ cmd += ["--exclude", exc]
+ cmd += [src, dst]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
+ if result.returncode != 0:
+ raise StepError(step, f"rsync failed: {_format_step_output(result.stdout, result.stderr, 400)}")
+ step.status = StepStatus.DONE
+ parts = [p for p in (frozen_note, _format_step_output(result.stdout, result.stderr)) if p]
+ step.result = "\n".join(parts)
+ finally:
+ if export_ctx is not None:
+ export_ctx.cleanup()
+
+
+def _ensure_remote_parent_dir(probe: RemoteProbe, remote_dst: str) -> None:
+ """Best-effort mkdir -p for remote transfer destinations."""
+ target = remote_dst.strip()
+ if not target:
+ return
+
+ mkdir_target = target if target.endswith("/") else os.path.dirname(target)
+ if not mkdir_target or mkdir_target == ".":
+ return
+
+ cmd = f"mkdir -p {shlex.quote(mkdir_target)}"
+ r = probe.run(cmd, timeout=30)
+ if not r.ok:
+ # Non-fatal here; scp/rsync call will still provide concrete error if needed.
+ logger.debug("mkdir preflight failed for {}: {}", remote_dst, (r.stderr or "")[:200])
+
+
+def run_docker_build(
+ step: MigrationStep,
+ probe: RemoteProbe,
+ emitter: ProgressEmitter | None,
+) -> None:
+ """Run docker compose build on remote with periodic progress polling."""
+ from .utils import run_container_build
+ run_container_build(step, probe, emitter, engine="docker")
+
+
+def run_podman_build(
+ step: MigrationStep,
+ probe: RemoteProbe,
+ emitter: ProgressEmitter | None,
+) -> None:
+ """Run podman build on remote with periodic progress polling."""
+ from .utils import run_container_build
+ run_container_build(step, probe, emitter, engine="podman")
+
+
+def run_docker_health_wait(
+ step: MigrationStep,
+ probe: RemoteProbe,
+) -> None:
+ """Wait until all containers reach 'healthy' or 'running' status."""
+ cmd = step.command # should be: "cd
&& docker compose -f ... ps --format json"
+ if not cmd:
+ raise StepError(step, "No command specified for docker_health_wait")
+
+ timeout = step.timeout or 120
+ poll_interval = 8
+ elapsed = 0
+ last_status = ""
+
+ while elapsed < timeout:
+ r = probe.run(cmd, timeout=20)
+ if r.ok and r.out.strip():
+ statuses = _parse_container_statuses(r.out)
+ status_str = ", ".join(f"{n}:{s}" for n, s in statuses)
+
+ if status_str != last_status:
+ logger.info(f" [{elapsed}s] containers: {status_str}")
+ last_status = status_str
+
+ if _all_containers_healthy(statuses):
+ step.status = StepStatus.DONE
+ step.result = f"all containers healthy after {elapsed}s: {status_str}"
+ logger.debug(f" ✓ all containers healthy ({elapsed}s)")
+ return
+ else:
+ logger.debug(f" [{elapsed}s] waiting for containers (no output yet)...")
+
+ time.sleep(poll_interval)
+ elapsed += poll_interval
+
+ # timeout — log final state and continue (don't fail hard, http_check will catch it)
+ step.status = StepStatus.DONE
+ step.result = f"timeout {timeout}s reached, last: {last_status or 'unknown'}"
+ logger.warning(f" docker_health_wait timed out after {timeout}s — proceeding to health check")
+
+
+def _parse_container_statuses(output: str) -> list[tuple[str, str]]:
+ """Parse docker compose ps output into (name, status) tuples."""
+ lines = output.strip().splitlines()
+ statuses = []
+ for line in lines:
+ if line.startswith("NAME") or not line.strip():
+ continue
+ parts = line.split(None, 1)
+ name = parts[0] if parts else "?"
+ status = parts[1].strip() if len(parts) > 1 else "?"
+ statuses.append((name, status))
+ return statuses
+
+
+def _all_containers_healthy(statuses: list[tuple[str, str]]) -> bool:
+ """Check if all containers are in a healthy/running state."""
+ if not statuses:
+ return False
+ unhealthy = [
+ n for n, s in statuses
+ if not any(kw in s.lower() for kw in ("up", "running", "healthy"))
+ ]
+ return not unhealthy
+
+
+def run_container_log_tail(step: MigrationStep, probe: RemoteProbe) -> None:
+ """Fetch and log the last N lines from each container after start."""
+ cmd = step.command
+ if not cmd:
+ raise StepError(step, "No command specified for container_log_tail")
+
+ r = probe.run(cmd, timeout=30)
+ if r.ok and r.out.strip():
+ for line in r.out.strip().splitlines():
+ logger.debug(f" log: {line}")
+ step.result = f"{len(r.out.splitlines())} log lines fetched"
+ else:
+ step.result = "no log output (containers may still be starting)"
+ step.status = StepStatus.DONE
+
+
+def run_http_check(
+ step: MigrationStep,
+ probe: RemoteProbe,
+ retries: int = 5,
+ delay: int = 8,
+) -> None:
+ """HTTP check via SSH curl on the remote host (avoids local network/firewall issues)."""
+ if not step.url:
+ raise StepError(step, "http_check requires url")
+ last_err = ""
+ for attempt in range(retries):
+ if step.expect:
+ cmd = f"curl -skf --max-time 10 '{step.url}' | grep -F '{step.expect}'"
+ else:
+ cmd = f"curl -skf --max-time 10 '{step.url}'"
+ r = probe.run(cmd, timeout=20)
+ if r.ok and (not step.expect or step.expect in r.out):
+ step.status = StepStatus.DONE
+ step.result = f"OK (expect='{step.expect}' found)" if step.expect else r.out[:200]
+ return
+ last_err = f"expected '{step.expect}' not found in: {r.out[:80]}" if r.ok else (r.stderr[:100] or f"curl exit={r.exit_code}")
+ logger.debug(f" retry {attempt + 1}/{retries}: {last_err}")
+ time.sleep(delay)
+ raise StepError(step, f"HTTP check failed after {retries} retries: {last_err}")
+
+
+# ── Query-language post-deploy tests (testql / oql / aql) ─────────────────────
+# These run LOCALLY on the controller (where the interpreters live) and target
+# the freshly deployed host via `step.url`. Each supports an inline command
+# override via `step.command` with {url}/{source}/{mode}/{locale}/{context}
+# placeholders; otherwise a sensible default argv is built per language.
+
+def _run_local_query(step: MigrationStep, label: str, default_argv: list[str]) -> subprocess.CompletedProcess:
+ """Run a query-language runner locally; command-template override wins."""
+ timeout = step.timeout or 300
+ if step.command:
+ # Podmieniamy TYLKO znane placeholdery (nie .format() — literalne {}
+ # w komendzie, np. JSON, nie mogą być traktowane jako pola formatu).
+ cmd = step.command
+ for key, val in {
+ "url": step.url or "",
+ "source": step.query_source or "",
+ "mode": step.query_mode or "",
+ "locale": step.query_locale or "en",
+ "context": step.query_context or "",
+ }.items():
+ cmd = cmd.replace("{" + key + "}", val)
+ logger.info(f"{label}: {cmd}")
+ shell, argv = True, cmd
+ else:
+ argv = [a for a in default_argv if a != ""]
+ logger.info(f"{label}: {' '.join(shlex.quote(a) for a in argv)}")
+ shell = False
+ try:
+ return subprocess.run(argv, shell=shell, capture_output=True, text=True, timeout=timeout)
+ except FileNotFoundError:
+ raise StepError(step, f"{label}: runner not found on PATH — {default_argv[0]!r} (ustaw query_runner albo command)")
+ except subprocess.TimeoutExpired:
+ raise StepError(step, f"{label}: timeout po {timeout}s")
+
+
+def _query_output(r: subprocess.CompletedProcess) -> str:
+ return ((r.stdout or "") + ("\n" + r.stderr if r.stderr else "")).strip()
+
+
+def run_testql(step: MigrationStep, probe: RemoteProbe) -> None:
+ """TestQL smoke przeciw wdrożonemu hostowi: `testql run --url `."""
+ if not step.query_source and not step.command:
+ raise StepError(step, "testql wymaga query_source (plik .testql.toon.yaml) albo command")
+ runner = step.query_runner or "testql"
+ default_argv = [runner, "run"] + (["--url", step.url] if step.url else []) \
+ + list(step.flags or []) + ([step.query_source] if step.query_source else [])
+ r = _run_local_query(step, "testql", default_argv)
+ out = _query_output(r)
+ if r.returncode == 0 and (not step.expect or step.expect in out):
+ step.status = StepStatus.DONE
+ step.result = f"testql OK: {step.query_source or step.command}" + (f" (expect '{step.expect}')" if step.expect else "")
+ return
+ raise StepError(step, f"testql FAILED (rc={r.returncode}): {out[-500:]}")
+
+
+def run_oql(step: MigrationStep, probe: RemoteProbe) -> None:
+ """OQL scenariusz przeciw wdrożonemu runtime: `oqlctl -m --json`.
+
+ Zwykle celuje w firmware (`--firmware-url `). Werdykt z pola JSON `ok`;
+ fallback: kod wyjścia 0.
+ """
+ if not step.query_source and not step.command:
+ raise StepError(step, "oql wymaga query_source (plik .oql) albo command")
+ runner = step.query_runner or "oqlctl"
+ mode = step.query_mode or "execute"
+ default_argv = [runner, step.query_source or "", "-m", mode, "--json", "-q"] \
+ + (["--firmware-url", step.url] if step.url else []) + list(step.flags or [])
+ r = _run_local_query(step, "oql", default_argv)
+ out = _query_output(r)
+ verdict, detail = _parse_oql_verdict(r.stdout, r.returncode)
+ if verdict:
+ step.status = StepStatus.DONE
+ step.result = f"oql OK: {step.query_source or step.command} ({detail})"
+ return
+ raise StepError(step, f"oql FAILED ({detail}): {out[-500:]}")
+
+
+def _parse_oql_verdict(stdout: str, returncode: int) -> tuple[bool, str]:
+ """Zwróć (ok, opis) z JSON-a oqlctl (`ok`, `errors`); fallback na exit code."""
+ text = (stdout or "").strip()
+ for candidate in (text, text.splitlines()[-1] if text else ""):
+ try:
+ data = json.loads(candidate)
+ except (json.JSONDecodeError, ValueError):
+ continue
+ ok = bool(data.get("ok"))
+ errs = data.get("errors") or []
+ return ok, ("ok=true" if ok else f"ok=false errors={errs[:2]}")
+ return returncode == 0, f"exit={returncode} (brak JSON)"
+
+
+def run_aql(step: MigrationStep, probe: RemoteProbe) -> None:
+ """AQL model decyzyjny: rozwiąż i sprawdź werdykt (wariant/plan).
+
+ Domyślnie `aql [locale]`; asercja: exit 0 i (jeśli podano) `expect`
+ w wyjściu (np. nazwa wariantu). Dla resolve z kontekstem ustaw `command`,
+ np. `node relcom/central-node/aql/resolve.mjs {source} {context}`.
+ """
+ if not step.query_source and not step.command:
+ raise StepError(step, "aql wymaga query_source (plik .aql) albo command")
+ runner = step.query_runner or "aql"
+ default_argv = [runner, step.query_source or ""] \
+ + ([step.query_context] if step.query_context else []) \
+ + ([step.query_locale] if step.query_locale else []) + list(step.flags or [])
+ r = _run_local_query(step, "aql", default_argv)
+ out = _query_output(r)
+ if r.returncode == 0 and (not step.expect or step.expect in out):
+ step.status = StepStatus.DONE
+ step.result = f"aql OK: {step.query_source or step.command}" + (f" (expect '{step.expect}')" if step.expect else "")
+ return
+ raise StepError(step, f"aql FAILED (rc={r.returncode}): {out[-500:]}")
+
+
+def run_version_check(step: MigrationStep, probe: RemoteProbe) -> None:
+ """Version check via SSH curl on the remote host."""
+ if not step.url or not step.expect:
+ raise StepError(step, "version_check requires url and expect")
+ cmd = f"curl -skf --max-time 10 '{step.url}'"
+ r = probe.run(cmd, timeout=20)
+ if not r.ok:
+ raise StepError(step, f"curl failed: {r.stderr[:100]}")
+ if step.expect not in r.out:
+ raise StepError(step, f"version '{step.expect}' not found in response: {r.out[:100]}")
+ step.status = StepStatus.DONE
+ step.result = f"version {step.expect} confirmed"
+
+
+def run_plugin(
+ step: MigrationStep,
+ probe: RemoteProbe,
+ plan: MigrationPlan,
+ emitter: ProgressEmitter | None,
+ dry_run: bool,
+) -> None:
+ """Dispatch to a registered plugin handler."""
+ from ..plugins import PluginContext, registry as _plugin_registry
+
+ plugin_type = step.plugin_type
+ if not plugin_type:
+ raise StepError(step, "plugin action requires plugin_type field")
+ handler = _plugin_registry.get(plugin_type)
+ if not handler:
+ available = ", ".join(_plugin_registry.names()) or "(none loaded)"
+ raise StepError(step, f"unknown plugin_type '{plugin_type}'. Available: {available}")
+ ctx = PluginContext(
+ step=step,
+ host=plan.host,
+ probe=probe,
+ emitter=emitter,
+ params=step.plugin_params,
+ dry_run=dry_run,
+ )
+ handler(ctx)
+
+
+def run_wait(step: MigrationStep) -> None:
+ """Wait for specified number of seconds."""
+ total = step.seconds
+ if total <= 0:
+ step.status = StepStatus.DONE
+ step.result = "waited 0s"
+ return
+ tick = min(10, max(5, total // 6)) # log every 5–10s
+ elapsed = 0
+ while elapsed < total:
+ chunk = min(tick, total - elapsed)
+ time.sleep(chunk)
+ elapsed += chunk
+ if elapsed < total:
+ logger.debug(f" waiting... {elapsed}/{total}s")
+ step.status = StepStatus.DONE
+ step.result = f"waited {total}s"
+
+
+def run_inline_script(
+ step: MigrationStep,
+ probe: RemoteProbe,
+ plan: MigrationPlan,
+) -> None:
+ """Execute multiline bash script via SSH using base64 encoding."""
+ script = step.command
+
+ # If command_ref is set, extract script from markdown file
+ if step.command_ref:
+ script = _resolve_command_ref(step.command_ref, step, plan)
+
+ if not script:
+ raise StepError(step, "inline_script requires command field or command_ref with script content")
+
+ # Base64 encode the script to safely pass it through SSH
+ encoded = base64.b64encode(script.encode()).decode()
+ timeout = step.timeout or 300
+
+ # Create temp file, decode script, run it, then clean up
+ cmd = (
+ f"tmpfile=$(mktemp) && "
+ f"echo '{encoded}' | base64 -d > \"$tmpfile\" && "
+ f"chmod +x \"$tmpfile\" && "
+ f"\"$tmpfile\"; "
+ f"rc=$?; "
+ f"rm -f \"$tmpfile\"; "
+ f"exit $rc"
+ )
+
+ r = probe.run(cmd, timeout=timeout)
+ step.result = _format_step_output(r.out, r.stderr, 500) or "script executed"
+ if not r.ok:
+ raise StepError(
+ step,
+ f"script failed with exit={r.exit_code}: {_format_step_output(r.out, r.stderr, 800)}",
+ )
+ step.status = StepStatus.DONE
+
+
+def _resolve_command_ref(command_ref: str, step: MigrationStep, plan: MigrationPlan) -> str:
+ """Resolve command_ref to script content from markdown file.
+
+ command_ref formats:
+ - "./file.md#section-id" - script from section in specific file
+ - "#section-id" - script from section in current spec file
+ - "#kiosk-browser-configuration-script" - markpact:ref block
+ """
+ from ..markpact import resolve_script_ref
+
+ # Parse command_ref
+ if "#" in command_ref:
+ file_part, section_id = command_ref.split("#", 1)
+ file_path = file_part if file_part else getattr(plan, 'spec_path', None)
+ else:
+ section_id = command_ref
+ file_path = getattr(plan, 'spec_path', None)
+
+ if not file_path:
+ raise StepError(step, f"Cannot resolve command_ref '{command_ref}': no file path available")
+
+ file_path = Path(file_path)
+ if not file_path.exists():
+ raise StepError(step, f"Command ref file not found: {file_path}")
+
+ markdown_content = file_path.read_text(encoding="utf-8")
+ result = resolve_script_ref(markdown_content, section_id, language="bash")
+
+ if result is None:
+ raise StepError(
+ step,
+ f"Could not find bash script for ref '{section_id}' in {file_path} (tried markpact:ref and section heading)"
+ )
+ script, _ = result
+ return script
+
+
+# ── hardware-specific handlers ────────────────────────────────────────────────
+
+def run_ensure_config_line(step: MigrationStep, probe: "RemoteProbe") -> None:
+ """Idempotent add/replace a line in a remote config.txt."""
+ from ..hardware.config_txt import ensure_line
+
+ if not step.config_file or not step.config_line:
+ raise StepError(step, "ensure_config_line requires config_file and config_line")
+
+ config_path = step.config_file
+ r = probe.run(f"sudo cat {config_path}", timeout=10)
+ if not r.ok:
+ raise StepError(step, f"Cannot read {config_path}: {r.stderr[:200]}")
+
+ edit = ensure_line(
+ r.out,
+ step.config_line,
+ section=step.config_section or "all",
+ replaces_pattern=step.config_replaces_pattern,
+ )
+
+ if not edit.changed:
+ step.status = StepStatus.DONE
+ step.result = f"no-op: {edit.diff_summary}"
+ return
+
+ # Write atomically: base64-encode to avoid shell quoting issues
+ encoded = base64.b64encode(edit.new_content.encode()).decode()
+ tmp = f"/tmp/redeploy-cfg-{step.id}.txt"
+ write_r = probe.run(
+ f"echo '{encoded}' | base64 -d | sudo tee {tmp} > /dev/null && sudo mv {tmp} {config_path}",
+ timeout=15,
+ )
+ if not write_r.ok:
+ raise StepError(step, f"Cannot write {config_path}: {write_r.stderr[:200]}")
+
+ step.status = StepStatus.DONE
+ step.result = edit.diff_summary
+
+
+def run_raspi_config(step: MigrationStep, probe: "RemoteProbe") -> None:
+ """Run raspi-config nonint to enable/disable an interface."""
+ from ..hardware.raspi_config import build_raspi_config_command
+
+ if not step.raspi_interface or not step.raspi_state:
+ raise StepError(step, "raspi_config requires raspi_interface and raspi_state")
+
+ try:
+ cmd = build_raspi_config_command(step.raspi_interface, step.raspi_state)
+ except ValueError as exc:
+ raise StepError(step, str(exc))
+
+ r = probe.run(cmd, timeout=30)
+ if not r.ok:
+ raise StepError(step, f"raspi-config failed: {r.stderr[:200]}")
+
+ step.status = StepStatus.DONE
+ step.result = f"applied: {cmd}"
+
+
+# ── kiosk handlers ────────────────────────────────────────────────────────────
+
+def run_ensure_kanshi_profile(step: MigrationStep, probe: "RemoteProbe") -> None:
+ """Idempotently write or replace a named kanshi output profile.
+
+ Declarative mode (preferred)::
+
+ profile_name: waveshare-only
+ outputs_on: [DSI-2]
+ outputs_off: [HDMI-A-2]
+
+ Legacy mode (pre-rendered block)::
+
+ step.command — the profile block to write (rendered kanshi syntax)
+ step.config_file — kanshi config path (default: ~/.config/kanshi/config)
+ """
+ from ..hardware.kiosk.output_profiles import OutputProfile
+
+ # Declarative mode: build profile from semantic fields
+ if step.profile_name:
+ profile = OutputProfile(
+ name=step.profile_name,
+ enabled=list(step.outputs_on),
+ disabled=list(step.outputs_off),
+ )
+ profile_block = profile.to_kanshi_config()
+ else:
+ profile_block = step.command
+
+ if not profile_block:
+ raise StepError(
+ step,
+ "ensure_kanshi_profile requires profile_name+outputs_on/outputs_off "
+ "or step.command with pre-rendered profile block",
+ )
+
+ config_path = step.config_file or "~/.config/kanshi/config"
+ mkdir_cmd = f"mkdir -p $(dirname {config_path})"
+ probe.run(mkdir_cmd, timeout=10)
+
+ r = probe.run(f"cat {config_path} 2>/dev/null || echo ''", timeout=10)
+ existing = r.out if r.ok else ""
+
+ # Extract profile name from block (first line: "profile {")
+ first_line = profile_block.strip().splitlines()[0]
+ import re
+ m = re.match(r"profile\s+(\S+)\s*\{", first_line)
+ if not m:
+ raise StepError(step, f"Cannot parse profile name from block: {first_line!r}")
+ profile_name = m.group(1)
+
+ # Replace existing profile block or append
+ pattern = re.compile(
+ rf"^profile\s+{re.escape(profile_name)}\s*\{{[^}}]*\}}", re.MULTILINE | re.DOTALL
+ )
+ if pattern.search(existing):
+ if profile_block.strip() in existing:
+ step.status = StepStatus.DONE
+ step.result = f"no-op: profile '{profile_name}' already correct"
+ return
+ new_content = pattern.sub(profile_block.strip(), existing)
+ changed = True
+ else:
+ sep = "\n" if existing and not existing.endswith("\n") else ""
+ new_content = existing + sep + profile_block.strip() + "\n"
+ changed = True
+
+ encoded = base64.b64encode(new_content.encode()).decode()
+ tmp = f"/tmp/redeploy-kanshi-{step.id}.conf"
+ write_r = probe.run(
+ f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null && mv {tmp} {config_path}",
+ timeout=15,
+ )
+ if not write_r.ok:
+ raise StepError(step, f"Cannot write {config_path}: {write_r.stderr[:200]}")
+
+ # Reload kanshi if running
+ probe.run("pkill -SIGUSR1 kanshi 2>/dev/null || true", timeout=5)
+
+ step.status = StepStatus.DONE
+ step.result = f"profile '{profile_name}' written to {config_path}"
+
+
+def run_ensure_autostart_entry(step: MigrationStep, probe: "RemoteProbe") -> None:
+ """Idempotently add or replace keyed entries in a compositor autostart file.
+
+ Declarative mode (preferred)::
+
+ compositor: labwc
+ entries:
+ - "kanshid &"
+ - "sleep 3"
+ - "bash /home/pi/c2004/scripts/kiosk-launch.sh &"
+
+ Legacy mode (single entry)::
+
+ step.config_file — path to autostart file
+ step.config_line — the line to write (the entry body)
+ step.config_section — used as the entry key for idempotent marker
+ """
+ from ..hardware.kiosk.autostart import AutostartEntry, ensure_autostart_entry
+ from ..hardware.kiosk.compositors import COMPOSITORS
+
+ # Determine autostart path
+ config_file = step.config_file
+ if not config_file and step.compositor:
+ comp = COMPOSITORS.get(step.compositor)
+ if not comp:
+ raise StepError(step, f"Unknown compositor '{step.compositor}'")
+ config_file = comp.autostart_abs()
+ if not config_file:
+ raise StepError(step, "ensure_autostart_entry requires config_file or compositor")
+
+ # Collect entries to apply
+ entries: list[AutostartEntry] = []
+ if step.entries:
+ for i, line in enumerate(step.entries):
+ key = f"redeploy-{i}-{line.split()[0] if line else 'entry'}"
+ entries.append(AutostartEntry(key=key, line=line))
+ elif step.config_line:
+ key = step.config_section or "redeploy"
+ entries.append(AutostartEntry(key=key, line=step.config_line))
+
+ if not entries:
+ raise StepError(step, "ensure_autostart_entry requires entries or config_line")
+
+ r = probe.run(f"cat {config_file} 2>/dev/null || echo ''", timeout=10)
+ existing = r.out if r.ok else ""
+
+ changed_any = False
+ new_content = existing
+ for entry in entries:
+ new_content, changed = ensure_autostart_entry(new_content, entry)
+ if changed:
+ changed_any = True
+
+ if not changed_any:
+ step.status = StepStatus.DONE
+ step.result = f"no-op: {len(entries)} autostart entry(ies) already correct"
+ return
+
+ mkdir_cmd = f"mkdir -p $(dirname {config_file})"
+ probe.run(mkdir_cmd, timeout=10)
+
+ encoded = base64.b64encode(new_content.encode()).decode()
+ tmp = f"/tmp/redeploy-autostart-{step.id}.txt"
+ write_r = probe.run(
+ f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null && mv {tmp} {config_file}",
+ timeout=15,
+ )
+ if not write_r.ok:
+ raise StepError(step, f"Cannot write {config_file}: {write_r.stderr[:200]}")
+
+ step.status = StepStatus.DONE
+ step.result = f"{len(entries)} autostart entry(ies) written to {config_file}"
+
+
+def run_ensure_browser_kiosk_script(step: MigrationStep, probe: "RemoteProbe") -> None:
+ """Write a kiosk-launch.sh script to the remote device.
+
+ Declarative mode (preferred)::
+
+ browser_profile: chromium_wayland_kiosk
+ url: "http://localhost:8100/connect-id?font=xlarge&theme=dark"
+ kiosk_script_path: /home/pi/c2004/scripts/kiosk-launch.sh
+
+ Legacy mode (pre-rendered script)::
+
+ step.command — the full script content to write
+ step.dst — destination path (default: ~/kiosk-launch.sh)
+ """
+ from ..hardware.kiosk.browsers import CHROMIUM_WAYLAND_KIOSK
+
+ # Declarative mode: build script from profile + URL
+ if step.browser_profile:
+ # Registry lookup (extend when more profiles exist)
+ if step.browser_profile == "chromium_wayland_kiosk":
+ profile = CHROMIUM_WAYLAND_KIOSK
+ else:
+ raise StepError(step, f"Unknown browser_profile '{step.browser_profile}'")
+ if not step.url:
+ raise StepError(step, "browser_profile mode requires 'url' field")
+ script_content = profile.build_launch_cmd(step.url)
+ else:
+ script_content = step.command
+
+ if not script_content:
+ raise StepError(
+ step,
+ "ensure_browser_kiosk_script requires browser_profile+url "
+ "or step.command with pre-rendered script body",
+ )
+
+ dst = step.kiosk_script_path or step.dst or "~/kiosk-launch.sh"
+
+ r = probe.run(f"cat {dst} 2>/dev/null || echo ''", timeout=10)
+ existing = r.out if r.ok else ""
+ if existing.strip() == script_content.strip():
+ step.status = StepStatus.DONE
+ step.result = f"no-op: {dst} already correct"
+ return
+
+ encoded = base64.b64encode(script_content.encode()).decode()
+ tmp = f"/tmp/redeploy-kiosk-{step.id}.sh"
+ write_r = probe.run(
+ f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null"
+ f" && chmod +x {tmp} && mv {tmp} {dst}",
+ timeout=15,
+ )
+ if not write_r.ok:
+ raise StepError(step, f"Cannot write {dst}: {write_r.stderr[:200]}")
+
+ step.status = StepStatus.DONE
+ step.result = f"kiosk script written to {dst}"
diff --git a/redeploy/tests/test_cli.py b/redeploy/tests/test_cli.py
index 25b5c24..8732877 100644
--- a/redeploy/tests/test_cli.py
+++ b/redeploy/tests/test_cli.py
@@ -5,6 +5,7 @@
from pathlib import Path
from unittest.mock import patch
import yaml
+import pytest
from click.testing import CliRunner
from redeploy.cli import cli
@@ -96,6 +97,12 @@ def _runner():
class TestRunPlanOnly:
+ @pytest.fixture(autouse=True)
+ def isolated_project(self, tmp_path, monkeypatch):
+ """Provide only the local artifacts declared by these test specs."""
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".env").write_text("TEST_MODE=1\n")
+
def test_plan_only_exit_zero(self, tmp_path):
spec = tmp_path / "migration.yaml"
spec.write_text(_migration_yaml())
diff --git a/tests/test_prompt_llm.py b/tests/test_prompt_llm.py
index 737853f..3a16c93 100644
--- a/tests/test_prompt_llm.py
+++ b/tests/test_prompt_llm.py
@@ -74,7 +74,17 @@ def call_llm(instruction: str, schema: dict | None = None) -> dict:
# Schema sanity (no LLM required)
# ---------------------------------------------------------------------------
-def test_schema_discovers_c2004_specs():
+@pytest.fixture
+def schema_workspace(tmp_path, monkeypatch):
+ """Exercise discovery without relying on a developer's sibling checkout."""
+ target = tmp_path / "redeploy" / "pi109"
+ target.mkdir(parents=True)
+ (target / "migration.yaml").write_text("name: fixture-app\nversion: 1.0.0\n")
+ monkeypatch.setattr(__name__ + ".C2004_ROOT", tmp_path)
+ return tmp_path
+
+
+def test_schema_discovers_c2004_specs(schema_workspace):
"""Schema build must find pi109 specs in c2004."""
schema = build_c2004_schema()
assert "specs" in schema
@@ -83,7 +93,7 @@ def test_schema_discovers_c2004_specs():
assert any("pi109" in p for p in paths), f"No pi109 spec found in schema: {paths}"
-def test_schema_has_command_catalogue():
+def test_schema_has_command_catalogue(schema_workspace):
schema = build_c2004_schema()
assert "commands" in schema
assert "run" in schema["commands"]
@@ -91,13 +101,13 @@ def test_schema_has_command_catalogue():
assert "import" in schema["commands"]
-def test_schema_has_version_and_cwd():
+def test_schema_has_version_and_cwd(schema_workspace):
schema = build_c2004_schema()
assert "version" in schema
- assert "cwd" in schema
+ assert Path(schema["cwd"]) == schema_workspace
-def test_schema_has_iac_metadata():
+def test_schema_has_iac_metadata(schema_workspace):
schema = build_c2004_schema()
assert "iac" in schema
assert "parsers" in schema["iac"]
diff --git a/uv.lock b/uv.lock
index 6a92d94..af014a5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -5,9 +5,12 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
- "python_full_version < '3.14' and sys_platform == 'win32'",
- "python_full_version < '3.14' and sys_platform == 'emscripten'",
- "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
+ "python_full_version < '3.12' and sys_platform == 'win32'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version < '3.12' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
@@ -502,16 +505,16 @@ wheels = [
[[package]]
name = "clickmd"
-version = "1.1.14"
+version = "1.1.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "costs" },
{ name = "goal" },
{ name = "pfix" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0e/1b/84df87d056991c2fb0789ae47b49d5afb8496f2c45541760e81553d00b43/clickmd-1.1.14.tar.gz", hash = "sha256:a3625777736ba5d3d43f7de8ff81bc01d2a4d77f6c252ce6ea7d55b860279f43", size = 41134, upload-time = "2026-04-08T09:11:28.657Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/12/82b3b80bb2751e0d6c120c1beb11116527cca9322202b5a1924811b58376/clickmd-1.1.15.tar.gz", hash = "sha256:0a2971b952e471c4726bc2f88ed397abd395283c79e4885e435921426493d01c", size = 41145, upload-time = "2026-06-29T07:35:58.128Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/89/29/625bc2004029ce7297d0709e6486447c4a0f0d832c2ddd962cada14d32c7/clickmd-1.1.14-py3-none-any.whl", hash = "sha256:005bbc7cd97d8a58ba25b4fc0018b7970e37b57ee50d53aa33df5f445b4ff46b", size = 45540, upload-time = "2026-04-08T09:11:27.228Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/5c/398fafad25e456d86fe8fe7bf9a3873f2077564d42dcdd1426b76d01e915/clickmd-1.1.15-py3-none-any.whl", hash = "sha256:b191c0c26be5ef009ea27a3e88c1c7d633d95a6da2fc737f04f6727481bfeb5e", size = 45514, upload-time = "2026-06-29T07:35:56.53Z" },
]
[[package]]
@@ -525,7 +528,7 @@ wheels = [
[[package]]
name = "costs"
-version = "0.1.50"
+version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anthropic" },
@@ -538,9 +541,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "typer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/89/25/0778be9c7c3ef2cd5b9f8c59ac2c49af885099edbc41aaeef5312c6c2fe0/costs-0.1.50.tar.gz", hash = "sha256:2a452e126ada7597a0a6134d3d185afdcad65ad08f9d4ed7e1502199c6c7320e", size = 30275, upload-time = "2026-04-20T14:11:06.415Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/6f/7f3c72d6d4b5264bd5a3447e18a3b5184bffee143f72c8d023bb759afd1a/costs-0.2.0.tar.gz", hash = "sha256:3782bae859725dae30f697b5f3e5aa5d84e06feadebc5782ae706e21d46437af", size = 44606, upload-time = "2026-09-05T15:49:55.524Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fe/83/1dca023a2fdccc2082ee22fc9848b6b453e4d673c4218c323e9f24882efa/costs-0.1.50-py3-none-any.whl", hash = "sha256:3224dab8005b9765219c0bd224731554aa67878b86e67f9afa8edecfd991d8a8", size = 35506, upload-time = "2026-04-20T14:11:04.754Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/7e/fc91d9fdc7a1ceb9116daffab27124a54f6e8887c9d59d906df43dcbfa6a/costs-0.2.0-py3-none-any.whl", hash = "sha256:4798b8535cbbcbb354e440ebce91365f17c910414bf5831a0610998ae6e679af", size = 48625, upload-time = "2026-09-05T15:49:54.054Z" },
]
[[package]]
@@ -872,7 +875,7 @@ wheels = [
[[package]]
name = "goal"
-version = "2.1.218"
+version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -882,9 +885,9 @@ dependencies = [
{ name = "tomlkit" },
{ name = "typer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d8/d3/c6d31f1087257adddaac23b243d133f49061d155b6c7f26804b9846d64a4/goal-2.1.218.tar.gz", hash = "sha256:209e403df9d44a9d533f6784d564af1e1622965ca76900ee9ee5ae08fff57d77", size = 257431, upload-time = "2026-04-26T12:00:47.589Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/9c/135e3f67a7324c3c49ef4246f5518b59ad90226e62c3d30ae20cba005c1d/goal-2.2.0.tar.gz", hash = "sha256:e9e2c1ac9493dae3b887b16d503f7c1f83d02ddcbdd3c5cf58d44a458bb2662f", size = 399786, upload-time = "2026-09-05T19:41:55.329Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/71/26e4e65d187ba5c2e1f0885ec135f2442d18f52f70c6f2d4ac5e17df2a3a/goal-2.1.218-py3-none-any.whl", hash = "sha256:752f77c5160c929bdbf2e5ab3d07b327a81d8d4dddc8691ba4d80895886f36db", size = 251843, upload-time = "2026-04-26T12:00:45.61Z" },
+ { url = "https://files.pythonhosted.org/packages/24/69/8a4cf5710683bb777f1f6e4cd44dd3907befe97b94c6ea6e97d4305b4e44/goal-2.2.0-py3-none-any.whl", hash = "sha256:0d8f93aa317ac92a66b64b797e210a5d66044ce8db2cb05b012b03d42aced95e", size = 349709, upload-time = "2026-09-05T19:41:53.615Z" },
]
[[package]]
@@ -1815,7 +1818,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ptyprocess", marker = "sys_platform != 'win32'" },
+ { name = "ptyprocess" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@@ -1824,7 +1827,7 @@ wheels = [
[[package]]
name = "pfix"
-version = "0.1.72"
+version = "0.1.79"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "litellm" },
@@ -1833,9 +1836,9 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "rich" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/af/e7/5f5ed8d95da0205422dea693f5b68077665540c3fcddab448f17f58c8304/pfix-0.1.72.tar.gz", hash = "sha256:c2dbcb29ef4f037bf5d31d33a872476dee669e092a68de9e8dd324952c0e9574", size = 2538020, upload-time = "2026-03-29T19:26:46.122Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/7e/252be5e1d55321b947013010f45b02d781de87e25b9a524c0ee72c1ad3c0/pfix-0.1.79.tar.gz", hash = "sha256:6bc4c572e22e0f0d952bc5e2df78f52f8f999cab64e22138b097b2eab4d83092", size = 2845663, upload-time = "2026-07-05T17:00:57.331Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f0/b0/4c68ab0b9e784eb9685805f46523af0875c9cb0c88072c047e5c38ed600a/pfix-0.1.72-py3-none-any.whl", hash = "sha256:608c33964aed46d0a7c02417463513e5ab2fea0f4ee6309ab6769d7f837e1d6e", size = 152417, upload-time = "2026-03-29T19:26:41.235Z" },
+ { url = "https://files.pythonhosted.org/packages/39/bf/548982294e795d79003680b2214c7f5dd00ca4b0ea856064bf9cb6869ba7/pfix-0.1.79-py3-none-any.whl", hash = "sha256:bb7ab83b99c0bfbaae5e6e95cb801a27d4fd68749485600fdfe49dad00607461", size = 151754, upload-time = "2026-07-05T17:00:52.816Z" },
]
[[package]]
@@ -2392,7 +2395,6 @@ source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "costs" },
- { name = "goal" },
{ name = "httpx" },
{ name = "jmespath" },
{ name = "loguru" },
@@ -2407,7 +2409,6 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "costs" },
- { name = "goal" },
{ name = "pfix" },
{ name = "pytest" },
{ name = "ruff" },
@@ -2419,13 +2420,16 @@ op3 = [
{ name = "op3" },
]
+[package.dev-dependencies]
+automation = [
+ { name = "goal", marker = "python_full_version >= '3.12'" },
+]
+
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.0" },
- { name = "costs", specifier = ">=0.1.20" },
- { name = "costs", marker = "extra == 'dev'", specifier = ">=0.1.20" },
- { name = "goal", specifier = ">=2.1.0" },
- { name = "goal", marker = "extra == 'dev'", specifier = ">=2.1.0" },
+ { name = "costs", specifier = ">=0.2.0" },
+ { name = "costs", marker = "extra == 'dev'", specifier = ">=0.2.0" },
{ name = "httpx", specifier = ">=0.25" },
{ name = "jmespath", specifier = ">=1.0" },
{ name = "loguru", specifier = ">=0.7" },
@@ -2433,8 +2437,8 @@ requires-dist = [
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" },
{ name = "op3", marker = "extra == 'op3'", specifier = ">=0.1.8" },
{ name = "paramiko", specifier = ">=3.0" },
- { name = "pfix", specifier = ">=0.1.60" },
- { name = "pfix", marker = "extra == 'dev'", specifier = ">=0.1.60" },
+ { name = "pfix", specifier = ">=0.1.79" },
+ { name = "pfix", marker = "extra == 'dev'", specifier = ">=0.1.79" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pyyaml", specifier = ">=6.0" },
@@ -2443,6 +2447,9 @@ requires-dist = [
]
provides-extras = ["dev", "op3", "mcp"]
+[package.metadata.requires-dev]
+automation = [{ name = "goal", marker = "python_full_version >= '3.12'", specifier = ">=2.2.0" }]
+
[[package]]
name = "referencing"
version = "0.37.0"
@@ -3006,8 +3013,8 @@ name = "uvicorn"
version = "0.46.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click", marker = "sys_platform != 'emscripten'" },
- { name = "h11", marker = "sys_platform != 'emscripten'" },
+ { name = "click" },
+ { name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" }
wheels = [