pgate 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write # required for Trusted Publishing (OIDC)
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ name: Build distribution
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ - name: Install hatchling
23
+ run: pip install hatchling
24
+
25
+ - name: Build wheel and sdist
26
+ run: python -m hatchling build
27
+
28
+ - name: Upload dist artifacts
29
+ uses: actions/upload-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+
34
+ publish:
35
+ name: Publish to PyPI
36
+ needs: build
37
+ runs-on: ubuntu-latest
38
+ environment:
39
+ name: pypi
40
+ url: https://pypi.org/p/pgate
41
+ steps:
42
+ - name: Download dist artifacts
43
+ uses: actions/download-artifact@v4
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+
48
+ - name: Publish to PyPI
49
+ uses: pypa/gh-action-pypi-publish@release/v1
pgate-0.1.1/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ *.whl
9
+ logs/
10
+ .env
pgate-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: pgate
3
+ Version: 0.1.1
4
+ Summary: PGate — Prompt ORM for LLM agents. Store, compile, validate and cache prompt contracts locally.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: jinja2>=3.1
9
+ Requires-Dist: jsonschema>=4.0
10
+ Requires-Dist: loguru>=0.7
11
+ Requires-Dist: mcp>=1.0
12
+ Requires-Dist: pydantic>=2.0
13
+ Requires-Dist: pyyaml>=6.0
14
+ Provides-Extra: crypto
15
+ Requires-Dist: cryptography>=41.0; extra == 'crypto'
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
18
+ Requires-Dist: pytest>=7.0; extra == 'dev'
19
+ Provides-Extra: litellm
20
+ Requires-Dist: litellm>=1.0; extra == 'litellm'
21
+ Provides-Extra: postgres
22
+ Requires-Dist: psycopg2-binary>=2.9; extra == 'postgres'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # PGate
26
+
27
+ **Prompt ORM for LLM agents.** Store, find, compile, validate, and cache prompt contracts — locally, without cloud, without telemetry.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install pgate
33
+ ```
34
+
35
+ ## Quickstart
36
+
37
+ ```bash
38
+ pgate init
39
+ pgate add --file examples/report_sales.yaml
40
+ pgate compile --interactive
41
+ ```
42
+
43
+ ## MCP (Claude Desktop / Cursor)
44
+
45
+ Add to `claude_desktop_config.json`:
46
+
47
+ ```json
48
+ {
49
+ "pgate": {
50
+ "command": "pgate",
51
+ "args": ["mcp", "--stdio"]
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## Status
57
+
58
+ v0.1 — in development.
pgate-0.1.1/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # PGate
2
+
3
+ **Prompt ORM for LLM agents.** Store, find, compile, validate, and cache prompt contracts — locally, without cloud, without telemetry.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install pgate
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```bash
14
+ pgate init
15
+ pgate add --file examples/report_sales.yaml
16
+ pgate compile --interactive
17
+ ```
18
+
19
+ ## MCP (Claude Desktop / Cursor)
20
+
21
+ Add to `claude_desktop_config.json`:
22
+
23
+ ```json
24
+ {
25
+ "pgate": {
26
+ "command": "pgate",
27
+ "args": ["mcp", "--stdio"]
28
+ }
29
+ }
30
+ ```
31
+
32
+ ## Status
33
+
34
+ v0.1 — in development.
@@ -0,0 +1,70 @@
1
+ """Basic usage example for PromptGate v0.1."""
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ from promptgate.models import PromptConfig
6
+ from promptgate.storage import SQLiteBackend
7
+ from promptgate.router import search
8
+ from promptgate.compiler import compile_prompt
9
+ from promptgate.validator import extract_json, validate_output
10
+
11
+ # Use temp DB for this demo
12
+ with tempfile.TemporaryDirectory() as tmpdir:
13
+ db_path = Path(tmpdir) / "demo.db"
14
+
15
+ # 1. Initialize storage
16
+ backend = SQLiteBackend(db_path)
17
+ backend.init()
18
+
19
+ # 2. Define and store a prompt
20
+ prompt = PromptConfig(
21
+ id="sales_report_v1",
22
+ name="Sales Report Generator",
23
+ tags=["sales", "reporting", "finance"],
24
+ **{"schema": {
25
+ "type": "object",
26
+ "properties": {
27
+ "period": {"type": "string", "pattern": r"^\d{4}-\d{2}$"},
28
+ "region": {"type": "string"},
29
+ },
30
+ "required": ["period"],
31
+ }},
32
+ template=(
33
+ "You are a sales analyst. Generate a concise report for period {{ period }}"
34
+ "{% if region %} in region {{ region }}{% endif %}.\n"
35
+ "Payload: {{ payload_json }}\n"
36
+ "Return JSON matching: {{ output_schema }}"
37
+ ),
38
+ description="Monthly sales report with optional region filter",
39
+ )
40
+ backend.upsert(prompt)
41
+ print(f"Stored prompt: {prompt.id}")
42
+
43
+ # 3. Search
44
+ results = search("sales report", db_path=db_path)
45
+ print(f"Search 'sales report': {results}")
46
+
47
+ # 4. Compile
48
+ contract = compile_prompt(prompt, {"period": "2024-01", "region": "EMEA"})
49
+ print(f"\nCompiled contract:")
50
+ print(f" schema_hash: {contract.schema_hash}")
51
+ print(f" missing_fields: {contract.missing_fields}")
52
+ print(f" system_prompt:\n{contract.system_prompt[:200]}...")
53
+
54
+ # 5. Validate LLM output (simulated)
55
+ simulated_llm_output = """
56
+ Sure, here is the report:
57
+ {"summary": "Q1 EMEA sales up 12%", "period": "2024-01", "region": "EMEA"}
58
+ """
59
+ result = validate_output(
60
+ simulated_llm_output,
61
+ contract,
62
+ schema={"type": "object", "properties": {"summary": {"type": "string"}}},
63
+ )
64
+ print(f"\nValidation: ok={result.ok}, data={result.data}")
65
+
66
+ # 6. Demonstrate missing fields handling
67
+ contract_missing = compile_prompt(prompt, {})
68
+ print(f"\nMissing fields test: {contract_missing.missing_fields}")
69
+
70
+ print("\nDone.")
@@ -0,0 +1,20 @@
1
+ id: "report_sales_v2"
2
+ name: "Генерация отчёта по продажам"
3
+ tags: ["report", "finance", "stable"]
4
+ schema:
5
+ type: object
6
+ required: ["period", "metrics"]
7
+ properties:
8
+ period:
9
+ type: string
10
+ pattern: "^\\d{4}-\\d{2}$"
11
+ metrics:
12
+ type: array
13
+ items:
14
+ type: string
15
+ enum: ["revenue", "orders", "aov"]
16
+ template: |
17
+ Ты — аналитик данных. Входные данные (валидированы):
18
+ {{ payload_json }}
19
+ Верни строго по схеме: {{ output_schema }}
20
+ Если данных нет — используй поле "missing_info".
@@ -0,0 +1,6 @@
1
+ from .models import CompiledContract, PromptConfig, ValidationResult
2
+ from .file_api import get_or_compile
3
+ from .router import search
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["PromptConfig", "CompiledContract", "ValidationResult", "get_or_compile", "search"]
@@ -0,0 +1,3 @@
1
+ from .base import StorageBackend
2
+
3
+ __all__ = ["StorageBackend"]
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from promptgate.models import PromptConfig
6
+
7
+
8
+ @runtime_checkable
9
+ class StorageBackend(Protocol):
10
+ def init(self) -> None: ...
11
+ def upsert(self, prompt: PromptConfig) -> None: ...
12
+ def get(self, prompt_id: str) -> PromptConfig | None: ...
13
+ def delete(self, prompt_id: str) -> bool: ...
14
+ def list_all(self) -> list[PromptConfig]: ...
15
+ def search_fts(self, query: str, limit: int = 5) -> list[tuple[str, float]]: ...
@@ -0,0 +1,365 @@
1
+ """Click CLI: init, add, search, compile, mcp."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import yaml
10
+ from loguru import logger
11
+
12
+ from promptgate.file_api import get_or_compile, purge_stale
13
+ from promptgate.models import PromptConfig
14
+ from promptgate.router import search as pg_search
15
+ from promptgate.storage import SQLiteBackend, _DEFAULT_DB_PATH
16
+
17
+
18
+ def _configure_logging(verbose: bool) -> None:
19
+ logger.remove()
20
+ level = "DEBUG" if verbose else "INFO"
21
+ logger.add(sys.stderr, level=level, format="<level>{level}</level> {message}")
22
+ log_dir = Path("logs")
23
+ log_dir.mkdir(exist_ok=True)
24
+ logger.add(
25
+ log_dir / "app.log",
26
+ level="DEBUG",
27
+ rotation="10 MB",
28
+ retention="7 days",
29
+ encoding="utf-8",
30
+ )
31
+
32
+
33
+ @click.group()
34
+ @click.option("--verbose", "-v", is_flag=True, default=False, help="Enable debug logging.")
35
+ @click.option(
36
+ "--db",
37
+ default=str(_DEFAULT_DB_PATH),
38
+ show_default=True,
39
+ help="Path to SQLite database.",
40
+ )
41
+ @click.pass_context
42
+ def main(ctx: click.Context, verbose: bool, db: str) -> None:
43
+ """PromptGate - Prompt ORM for LLM agents."""
44
+ ctx.ensure_object(dict)
45
+ ctx.obj["db"] = db
46
+ _configure_logging(verbose)
47
+
48
+
49
+ @main.command()
50
+ @click.pass_context
51
+ def init(ctx: click.Context) -> None:
52
+ """Initialize the PromptGate database.
53
+
54
+ Creates ~/.promptgate/db.sqlite and FTS5 tables if they don't exist.
55
+ """
56
+ backend = SQLiteBackend(ctx.obj["db"])
57
+ backend.init()
58
+ click.echo(f"Initialized: {ctx.obj['db']}")
59
+
60
+
61
+ @main.command("add")
62
+ @click.option("--file", "-f", "file_path", type=click.Path(exists=True), help="Path to YAML prompt file.")
63
+ @click.option("--url", "-u", help="URL to fetch YAML prompt from.")
64
+ @click.pass_context
65
+ def add_prompt(ctx: click.Context, file_path: str | None, url: str | None) -> None:
66
+ """Add or update a prompt from a YAML file or URL.
67
+
68
+ Examples:
69
+
70
+ promptgate add --file examples/report_sales.yaml
71
+
72
+ promptgate add --url https://example.com/my_prompt.yaml
73
+ """
74
+ if not file_path and not url:
75
+ raise click.UsageError("Provide --file or --url.")
76
+
77
+ raw: str
78
+ if url:
79
+ import urllib.request
80
+ with urllib.request.urlopen(url) as resp: # noqa: S310
81
+ raw = resp.read().decode("utf-8")
82
+ else:
83
+ raw = Path(file_path).read_text(encoding="utf-8")
84
+
85
+ data = yaml.safe_load(raw)
86
+ prompt = PromptConfig.model_validate(data)
87
+
88
+ backend = SQLiteBackend(ctx.obj["db"])
89
+ backend.init()
90
+ backend.upsert(prompt)
91
+ click.echo(f"Added prompt: {prompt.id} ({prompt.name})")
92
+
93
+
94
+ @main.command("search")
95
+ @click.argument("query")
96
+ @click.option("--limit", "-n", default=5, show_default=True, help="Max results.")
97
+ @click.pass_context
98
+ def search_cmd(ctx: click.Context, query: str, limit: int) -> None:
99
+ """Search prompts by full-text relevance.
100
+
101
+ Examples:
102
+
103
+ promptgate search "sales report"
104
+ """
105
+ results = pg_search(query, db_path=ctx.obj["db"], limit=limit)
106
+ if not results:
107
+ click.echo("No results.")
108
+ return
109
+ for prompt_id, score in results:
110
+ click.echo(f" {score:.4f} {prompt_id}")
111
+
112
+
113
+ @main.command("compile")
114
+ @click.argument("prompt_id")
115
+ @click.option("--payload", "-p", default="{}", help="JSON payload string.")
116
+ @click.option("--model", "-m", default=None, help="LLM model ID for model-aware compilation.")
117
+ @click.option("--interactive", "-i", is_flag=True, help="Prompt for each required field interactively.")
118
+ @click.pass_context
119
+ def compile_cmd(ctx: click.Context, prompt_id: str, payload: str, model: str | None, interactive: bool) -> None:
120
+ """Compile a prompt and print the system_prompt.
121
+
122
+ Examples:
123
+
124
+ promptgate compile sales_v1 --payload '{"period": "2024-01"}'
125
+
126
+ promptgate compile sales_v1 --model gpt-4o --payload '{"period": "2024-01"}'
127
+
128
+ promptgate compile sales_v1 --interactive
129
+ """
130
+ backend = SQLiteBackend(ctx.obj["db"])
131
+ backend.init()
132
+ prompt = backend.get(prompt_id)
133
+ if prompt is None:
134
+ raise click.ClickException(f"Prompt '{prompt_id}' not found.")
135
+
136
+ data: dict = json.loads(payload)
137
+
138
+ if interactive:
139
+ required = prompt.schema_.get("required", [])
140
+ props = prompt.schema_.get("properties", {})
141
+ for field in required:
142
+ if field not in data:
143
+ desc = props.get(field, {})
144
+ hint = desc.get("type", "string")
145
+ value = click.prompt(f" {field} ({hint})")
146
+ data[field] = value
147
+
148
+ contract = get_or_compile(prompt_id, data, db_path=ctx.obj["db"], model_id=model)
149
+ click.echo(contract.system_prompt)
150
+ if contract.missing_fields:
151
+ click.echo(f"\n[missing: {', '.join(contract.missing_fields)}]", err=True)
152
+ if contract.model_hints:
153
+ h = contract.model_hints
154
+ rm_tag = {"none": "", "chain_of_thought": f" [CoT:{h.cot_strategy}]", "native": " [think]"}.get(
155
+ h.reasoning_mode, ""
156
+ )
157
+ sys_tag = "" if h.supports_system_prompt else " [no-sys]"
158
+ logger.debug("Model: {} ({}) ctx={}k{}{}", h.model_id, h.family, h.context_window // 1000, rm_tag, sys_tag)
159
+
160
+
161
+ @main.group("models", invoke_without_command=True)
162
+ @click.pass_context
163
+ def models_group(ctx: click.Context) -> None:
164
+ """Manage LLM model registry.
165
+
166
+ Without a subcommand, lists all registered models.
167
+
168
+ Examples:
169
+
170
+ promptgate models
171
+
172
+ promptgate models list --family anthropic
173
+
174
+ promptgate models add --id gpt-5 --family openai --ctx 400000 --reasoning native
175
+
176
+ promptgate models rm gpt-5
177
+
178
+ promptgate models show gpt-4o
179
+ """
180
+ if ctx.invoked_subcommand is None:
181
+ ctx.invoke(models_list)
182
+
183
+
184
+ def _print_models_table(rows: list[tuple[str, object]], custom_ids: set[str]) -> None:
185
+ """Print a formatted table row for each (model_id, ModelCapabilities) pair."""
186
+ from promptgate.models_registry import ModelCapabilities as MC
187
+ rm_labels = {"none": " ", "chain_of_thought": " CoT ", "native": "think"}
188
+ for name, caps in rows:
189
+ rm = rm_labels.get(caps.reasoning_mode, " ")
190
+ cot = f"/{caps.cot_strategy}" if caps.reasoning_mode == "chain_of_thought" else ""
191
+ sys_tag = " " if caps.supports_system_prompt else "NS"
192
+ json_tag = "J" if caps.json_native else " "
193
+ ctx_k = caps.context_window // 1000
194
+ custom_tag = "*" if name in custom_ids else " "
195
+ click.echo(f" {custom_tag}[{rm}{cot:<12}] [{sys_tag}][{json_tag}] ctx={ctx_k:>5}k {name}")
196
+
197
+
198
+ @models_group.command("list")
199
+ @click.option("--family", "-f", default=None, help="Filter by family (openai, anthropic, deepseek...)")
200
+ @click.option("--reasoning", "-r", is_flag=True, help="Show only models with reasoning support.")
201
+ @click.option("--custom", "-c", is_flag=True, help="Show only user-defined models.")
202
+ def models_list(family: str | None, reasoning: bool, custom: bool) -> None:
203
+ """List registered LLM models and their capabilities.
204
+
205
+ A leading ``*`` marks user-defined models.
206
+
207
+ Examples:
208
+
209
+ promptgate models list
210
+
211
+ promptgate models list --family anthropic
212
+
213
+ promptgate models list --reasoning
214
+
215
+ promptgate models list --custom
216
+ """
217
+ from promptgate.models_registry import _REGISTRY, _USER_REGISTRY
218
+
219
+ all_rows: dict = {**_REGISTRY, **_USER_REGISTRY} # user overrides win
220
+ rows = sorted(all_rows.items())
221
+ if family:
222
+ rows = [(k, v) for k, v in rows if v.family == family]
223
+ if reasoning:
224
+ rows = [(k, v) for k, v in rows if v.reasoning_mode != "none"]
225
+ if custom:
226
+ rows = [(k, v) for k, v in rows if k in _USER_REGISTRY]
227
+
228
+ if not rows:
229
+ click.echo("No models match the filter.")
230
+ return
231
+ _print_models_table(rows, set(_USER_REGISTRY.keys()))
232
+
233
+
234
+ @models_group.command("add")
235
+ @click.option("--id", "model_id", required=True, help="Unique model identifier.")
236
+ @click.option("--family", "-f", required=True, help="Provider family (openai, anthropic, deepseek...)")
237
+ @click.option("--ctx", "context_window", required=True, type=int, help="Context window in tokens.")
238
+ @click.option(
239
+ "--reasoning", "-r", default="none",
240
+ type=click.Choice(["none", "chain_of_thought", "native"]),
241
+ help="Reasoning mode.",
242
+ )
243
+ @click.option(
244
+ "--cot-strategy", default="zero_shot",
245
+ type=click.Choice(["zero_shot", "structured", "react"]),
246
+ help="CoT algorithm for chain_of_thought models.",
247
+ )
248
+ @click.option("--json-native", is_flag=True, help="Model supports native JSON output mode.")
249
+ @click.option("--no-system-prompt", is_flag=True, help="Model does not accept a system prompt.")
250
+ @click.option("--reasoning-budget", type=int, default=None, help="Token budget for native reasoning.")
251
+ @click.option("--max-output", type=int, default=4096, help="Max output tokens.")
252
+ def models_add(
253
+ model_id: str,
254
+ family: str,
255
+ context_window: int,
256
+ reasoning: str,
257
+ cot_strategy: str,
258
+ json_native: bool,
259
+ no_system_prompt: bool,
260
+ reasoning_budget: int | None,
261
+ max_output: int,
262
+ ) -> None:
263
+ """Add or override a model in the user registry.
264
+
265
+ Writes to ``~/.promptgate/models.yaml``.
266
+
267
+ Examples:
268
+
269
+ promptgate models add --id gpt-5 --family openai --ctx 400000 --reasoning native
270
+
271
+ promptgate models add --id my-llm --family custom --ctx 8000 --reasoning chain_of_thought --cot-strategy structured
272
+ """
273
+ from promptgate.models_registry import ModelCapabilities, register_model
274
+
275
+ caps = ModelCapabilities(
276
+ family=family,
277
+ context_window=context_window,
278
+ reasoning_mode=reasoning,
279
+ cot_strategy=cot_strategy,
280
+ json_native=json_native,
281
+ supports_system_prompt=not no_system_prompt,
282
+ reasoning_budget_tokens=reasoning_budget,
283
+ max_output_tokens=max_output,
284
+ )
285
+ register_model(model_id, caps)
286
+ click.echo(f"Registered '{model_id}' ({family}, ctx={context_window:,}) → ~/.promptgate/models.yaml")
287
+
288
+
289
+ @models_group.command("rm")
290
+ @click.argument("model_id")
291
+ def models_rm(model_id: str) -> None:
292
+ """Remove a custom model from the user registry.
293
+
294
+ Only user-defined models can be removed; built-in entries are protected.
295
+
296
+ Examples:
297
+
298
+ promptgate models rm my-custom-llm
299
+ """
300
+ from promptgate.models_registry import unregister_model
301
+
302
+ if not unregister_model(model_id):
303
+ raise click.ClickException(
304
+ f"'{model_id}' not found in user registry. Built-in models cannot be removed."
305
+ )
306
+ click.echo(f"Removed '{model_id}' from ~/.promptgate/models.yaml")
307
+
308
+
309
+ @models_group.command("show")
310
+ @click.argument("model_id")
311
+ def models_show(model_id: str) -> None:
312
+ """Show full capabilities for a specific model.
313
+
314
+ Examples:
315
+
316
+ promptgate models show gpt-4o
317
+
318
+ promptgate models show my-custom-llm
319
+ """
320
+ from promptgate.models_registry import _USER_REGISTRY, resolve_model
321
+
322
+ caps = resolve_model(model_id)
323
+ if caps is None:
324
+ raise click.ClickException(f"Unknown model: '{model_id}'")
325
+
326
+ is_custom = model_id in _USER_REGISTRY
327
+ source = "user-defined" if is_custom else "built-in"
328
+ click.echo(f" model_id: {model_id} [{source}]")
329
+ click.echo(f" family: {caps.family}")
330
+ click.echo(f" context_window: {caps.context_window:,}")
331
+ click.echo(f" max_output_tokens: {caps.max_output_tokens:,}")
332
+ click.echo(f" reasoning_mode: {caps.reasoning_mode}")
333
+ click.echo(f" cot_strategy: {caps.cot_strategy}")
334
+ click.echo(f" json_native: {caps.json_native}")
335
+ click.echo(f" supports_system_prompt: {caps.supports_system_prompt}")
336
+ if caps.reasoning_budget_tokens is not None:
337
+ click.echo(f" reasoning_budget_tokens: {caps.reasoning_budget_tokens:,}")
338
+
339
+
340
+ @main.command("mcp")
341
+ @click.option("--stdio", "transport", flag_value="stdio", default=True, help="Run MCP over stdio.")
342
+ @click.pass_context
343
+ def mcp_cmd(ctx: click.Context, transport: str) -> None:
344
+ """Start the PromptGate MCP server.
345
+
346
+ Add to Claude Desktop config:
347
+
348
+ {\"command\": \"promptgate\", \"args\": [\"mcp\", \"--stdio\"]}
349
+ """
350
+ from promptgate.mcp_adapter import run_stdio
351
+ run_stdio(db_path=ctx.obj["db"])
352
+
353
+
354
+ @main.command("purge")
355
+ @click.option("--days", default=7, show_default=True, help="Delete contracts older than N days.")
356
+ @click.pass_context
357
+ def purge_cmd(ctx: click.Context, days: int) -> None:
358
+ """Delete stale compiled contract files.
359
+
360
+ Examples:
361
+
362
+ promptgate purge --days 3
363
+ """
364
+ deleted = purge_stale(max_age_seconds=days * 86400)
365
+ click.echo(f"Deleted {deleted} stale contract file(s).")