From ce13a8b9e3894a84e21fe2e430fd4d1eab83b6b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 00:48:30 -0700 Subject: [PATCH 01/26] feat(rules): add static flash-rules.md and rules package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the rules package with flash-rules.md — a static markdown file teaching AI coding agents Flash-specific conventions (import placement, decorator usage, dependency declaration, CLI commands, and common mistakes). - 457 words (~594 tokens), well under the 3,000-token budget - Covers the three core patterns: queue-based, load-balanced, class-based - Includes CLI cheatsheet and common agent mistake table --- src/runpod_flash/rules/__init__.py | 1 + src/runpod_flash/rules/flash-rules.md | 106 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/runpod_flash/rules/__init__.py create mode 100644 src/runpod_flash/rules/flash-rules.md diff --git a/src/runpod_flash/rules/__init__.py b/src/runpod_flash/rules/__init__.py new file mode 100644 index 00000000..3702ea88 --- /dev/null +++ b/src/runpod_flash/rules/__init__.py @@ -0,0 +1 @@ +"""Flash rules for AI coding agents.""" diff --git a/src/runpod_flash/rules/flash-rules.md b/src/runpod_flash/rules/flash-rules.md new file mode 100644 index 00000000..10ebf798 --- /dev/null +++ b/src/runpod_flash/rules/flash-rules.md @@ -0,0 +1,106 @@ + + +# Flash Rules for AI Coding Agents + +## Identity + +Flash is a Python SDK for deploying AI workloads to Runpod GPUs. You write decorated Python functions, Flash handles infrastructure, scaling, and deployment. + +## Three Patterns + +### Pattern A: Queue-based function endpoint + +```python +from runpod_flash import Endpoint, GpuType + +@Endpoint( + name="my-gpu-worker", + gpu=GpuType.NVIDIA_GEFORCE_RTX_4090, + workers=(0, 3), + dependencies=["torch"], +) +async def process(input_data: dict) -> dict: + import torch + return {"gpu": torch.cuda.get_device_name(0)} +``` + +### Pattern B: Load-balanced routes + +```python +from runpod_flash import Endpoint + +api = Endpoint(name="my-api", cpu="cpu3c-1-2", workers=(1, 3)) + +@api.get("/health") +async def health(): + return {"status": "ok"} + +@api.post("/compute") +async def compute(numbers: list[float]) -> dict: + return {"sum": sum(numbers)} +``` + +### Pattern C: Class-based worker (stateful) + +```python +from runpod_flash import Endpoint, GpuType + +@Endpoint( + name="my-model", + gpu=GpuType.NVIDIA_GEFORCE_RTX_4090, + workers=(1, 3), + dependencies=["torch", "transformers"], +) +class MyModel: + def __init__(self): + import torch + from transformers import pipeline + self.pipe = pipeline("text-generation", device="cuda") + + async def generate(self, prompt: str) -> dict: + return {"text": self.pipe(prompt)[0]["generated_text"]} +``` + +## Rules That Break If Violated + +- `import torch` and heavy libraries INSIDE the function body, never at module level +- Declare runtime dependencies in `@Endpoint(dependencies=[...])`, not in `pyproject.toml` +- Endpoint functions can be sync (`def`) or async (`async def`). Use async when awaiting other endpoints or async I/O +- `workers=N` for fixed count, `workers=(min, max)` for auto-scaling range +- Class workers: model loading in `__init__`, request handling in instance methods +- Cross-worker calls use `await` — call `@Endpoint`-decorated functions as if local; Flash handles remote dispatch +- System-level packages (ffmpeg, libgl1) go in `system_dependencies`, not `dependencies` +- `@Endpoint` is the canonical decorator. `@remote` is the legacy alias + +## Configuration Reference + +| GPU | VRAM | Use Case | +|-----|------|----------| +| `GpuType.NVIDIA_GEFORCE_RTX_4090` | 24GB | General inference | +| `GpuType.NVIDIA_RTX_6000_ADA_GENERATION` | 48GB | Large models | +| `GpuType.NVIDIA_A100_80GB_PCIe` | 80GB | Training/large batch | +| `GpuGroup.ADA_24` | 24GB | Any Ada 24GB GPU | + +CPU types: `CpuInstanceType.CPU3C_1_2` (1 vCPU, 2GB), `CpuInstanceType.CPU3C_8_16` (8 vCPU, 16GB) + +## CLI Cheatsheet + +``` +flash init # scaffold project +flash run # local dev server at localhost:8888 +flash build # package for deployment +flash deploy # build + deploy to Runpod +flash deploy --preview # local multi-container test +flash rules # regenerate agent context files +``` + +## Common Agent Mistakes + +| Mistake | Fix | +|---------|-----| +| Writing raw FastAPI instead of `@Endpoint` | Use `@Endpoint` decorator, Flash generates FastAPI | +| `import torch` at top of file | Move inside function body | +| Adding deps to `pyproject.toml` only | Add to `@Endpoint(dependencies=[...])` | +| Forcing `async def` on all endpoints | Both sync and async are valid; use async only when awaiting | +| Creating `main.py` or `app.py` | Not needed — Flash auto-discovers decorated functions | +| Using `docker-compose` manually | Use `flash deploy --preview` for local container testing | From 681f61745ca52d2078a45b15dbf3bf562219ba7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 00:52:11 -0700 Subject: [PATCH 02/26] feat(rules): add rules engine with packaging and agent file generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rules/**/* to package-data so flash-rules.md is included in wheel - Implement read_static_rules() via importlib.resources for portable access - Implement inject_rules_section() with full marker lifecycle: replace, append, orphan-start, multiple-pairs, FLASH:DISABLE skip - Implement generate_agent_files() for CLAUDE.md, .cursorrules, AGENTS.md, .github/copilot-instructions.md, and .flash/context.md placeholder - 18 new tests across TestRulesPackaging, TestReadStaticRules, TestInjectRulesSection, TestGenerateAgentFiles — all pass - engine.py at 100% coverage; total suite 85.53% (2396 passed) --- pyproject.toml | 1 + src/runpod_flash/rules/engine.py | 115 +++++++++++++++++++++++ tests/unit/rules/__init__.py | 0 tests/unit/rules/test_engine.py | 141 +++++++++++++++++++++++++++++ tests/unit/test_rules_packaging.py | 15 +++ 5 files changed, 272 insertions(+) create mode 100644 src/runpod_flash/rules/engine.py create mode 100644 tests/unit/rules/__init__.py create mode 100644 tests/unit/rules/test_engine.py create mode 100644 tests/unit/test_rules_packaging.py diff --git a/pyproject.toml b/pyproject.toml index ba2fd845..5579cc1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ where = ["src"] runpod_flash = [ "cli/utils/skeleton_template/**/*", "cli/utils/skeleton_template/**/.*", + "rules/**/*", ] [tool.pytest.ini_options] diff --git a/src/runpod_flash/rules/engine.py b/src/runpod_flash/rules/engine.py new file mode 100644 index 00000000..d76636a2 --- /dev/null +++ b/src/runpod_flash/rules/engine.py @@ -0,0 +1,115 @@ +"""Rules engine for generating AI agent context files.""" + +from __future__ import annotations + +import logging +import re +from importlib import resources +from pathlib import Path + +logger = logging.getLogger(__name__) + +FLASH_START_MARKER = ( + "" +) +FLASH_END_MARKER = "" +FLASH_DISABLE_MARKER = "" +FLASH_REGENERATE_HINT = "" + +AGENT_FILE_TARGETS = [ + "CLAUDE.md", + ".cursorrules", + "AGENTS.md", + ".github/copilot-instructions.md", +] + +CONTEXT_PLACEHOLDER = ( + "\n" + "\n\n" + "No project context available yet. Run a flash command to generate.\n" +) + + +def read_static_rules() -> str: + """Read the bundled static rules content.""" + rules_file = resources.files("runpod_flash.rules") / "flash-rules.md" + return rules_file.read_text(encoding="utf-8") + + +def inject_rules_section( + existing_content: str, + rules_content: str, + version: str, +) -> str | None: + """Inject or replace the Flash rules section in a file. + + Returns updated content, or None if file has FLASH:DISABLE. + """ + if FLASH_DISABLE_MARKER in existing_content: + return None + + start_marker = FLASH_START_MARKER.format(version=version) + section = ( + f"{start_marker}\n" + f"{FLASH_REGENERATE_HINT}\n\n" + f"{rules_content}\n\n" + f"## Current Project State\n\n" + f"See `.flash/context.md` for live project context " + f"(regenerated on each flash command).\n\n" + f"{FLASH_END_MARKER}\n" + ) + + start_pattern = re.compile( + r"^", + re.MULTILINE, + ) + start_match = start_pattern.search(existing_content) + + if start_match: + before = existing_content[: start_match.start()] + after_start = existing_content[start_match.end() :] + end_idx = after_start.find(FLASH_END_MARKER) + if end_idx != -1: + after = after_start[end_idx + len(FLASH_END_MARKER) :] + if after.startswith("\n"): + after = after[1:] + return before + section + after + else: + return before + section + else: + if not existing_content or existing_content.isspace(): + return section + separator = "" if existing_content.endswith("\n\n") else "\n" + return existing_content.rstrip("\n") + "\n\n" + separator + section + + +def generate_agent_files(project_dir: Path, version: str) -> list[str]: + """Generate platform-specific agent files with Flash rules.""" + rules_content = read_static_rules() + written: list[str] = [] + + for target in AGENT_FILE_TARGETS: + filepath = project_dir / target + filepath.parent.mkdir(parents=True, exist_ok=True) + + existing = "" + if filepath.exists(): + existing = filepath.read_text(encoding="utf-8") + + result = inject_rules_section(existing, rules_content, version) + if result is None: + logger.info("Skipping %s (FLASH:DISABLE found)", target) + continue + + filepath.write_text(result, encoding="utf-8") + written.append(target) + + # Generate placeholder .flash/context.md + context_dir = project_dir / ".flash" + context_dir.mkdir(parents=True, exist_ok=True) + context_file = context_dir / "context.md" + if not context_file.exists(): + context_file.write_text(CONTEXT_PLACEHOLDER, encoding="utf-8") + written.append(".flash/context.md") + + return written diff --git a/tests/unit/rules/__init__.py b/tests/unit/rules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/rules/test_engine.py b/tests/unit/rules/test_engine.py new file mode 100644 index 00000000..66a163cb --- /dev/null +++ b/tests/unit/rules/test_engine.py @@ -0,0 +1,141 @@ +"""Tests for the rules engine — reading and marker injection logic.""" + +from pathlib import Path + +from runpod_flash.rules.engine import ( + AGENT_FILE_TARGETS, + generate_agent_files, + inject_rules_section, + read_static_rules, +) + + +class TestReadStaticRules: + def test_returns_string(self): + content = read_static_rules() + assert isinstance(content, str) + + def test_contains_version_comment(self): + content = read_static_rules() + assert "flash-rules-version" in content + + def test_contains_endpoint_decorator(self): + content = read_static_rules() + assert "@Endpoint" in content + + +class TestInjectRulesSection: + def test_new_file_creates_full_content(self): + result = inject_rules_section("", "rules content", "1.9.1") + assert "FLASH:START" in result + assert "FLASH:END" in result + assert "rules content" in result + + def test_existing_file_appends_section(self): + existing = "# My Project\nCustom instructions here.\n" + result = inject_rules_section(existing, "rules content", "1.9.1") + assert result.startswith("# My Project\n") + assert "Custom instructions here." in result + assert "FLASH:START" in result + assert "rules content" in result + + def test_replaces_existing_flash_section(self): + existing = ( + "# My Project\n" + "\n" + "old rules\n" + "\n" + ) + result = inject_rules_section(existing, "new rules", "1.9.1") + assert "old rules" not in result + assert "new rules" in result + assert result.count("FLASH:START") == 1 + + def test_preserves_user_content_outside_markers(self): + existing = ( + "# My Notes\nImportant stuff.\n\n" + "\n" + "old rules\n" + "\n" + "\n# More notes\n" + ) + result = inject_rules_section(existing, "new rules", "1.9.1") + assert "# My Notes\nImportant stuff." in result + assert "# More notes" in result + + def test_disabled_file_returns_none(self): + existing = "# My Project\n\n" + result = inject_rules_section(existing, "rules", "1.9.1") + assert result is None + + def test_start_without_end_replaces_to_eof(self): + existing = ( + "# My Project\n" + "\n" + "orphan content with no end marker\n" + ) + result = inject_rules_section(existing, "new rules", "1.9.1") + assert "orphan content" not in result + assert "new rules" in result + assert "FLASH:END" in result + + def test_multiple_marker_pairs_uses_first(self): + existing = ( + "\n" + "first section\n" + "\n" + "middle content\n" + "\n" + "second section\n" + "\n" + ) + result = inject_rules_section(existing, "new rules", "1.9.1") + assert "first section" not in result + assert "new rules" in result + assert "second section" in result + + def test_orphan_end_marker_appends_new_section(self): + existing = "# My Project\n\nsome content\n" + result = inject_rules_section(existing, "new rules", "1.9.1") + assert "FLASH:START" in result + assert "new rules" in result + + +class TestGenerateAgentFiles: + def test_creates_all_agent_files_in_new_project(self, tmp_path: Path): + generate_agent_files(tmp_path, "1.9.1") + for target in AGENT_FILE_TARGETS: + filepath = tmp_path / target + assert filepath.exists(), f"Missing: {target}" + content = filepath.read_text() + assert "FLASH:START" in content + assert "@Endpoint" in content + + def test_preserves_existing_user_content(self, tmp_path: Path): + claude_md = tmp_path / "CLAUDE.md" + claude_md.write_text("# My Custom Rules\nDo not delete me.\n") + generate_agent_files(tmp_path, "1.9.1") + content = claude_md.read_text() + assert "# My Custom Rules" in content + assert "Do not delete me." in content + assert "FLASH:START" in content + + def test_skips_disabled_files(self, tmp_path: Path): + claude_md = tmp_path / "CLAUDE.md" + claude_md.write_text("\nMy content.\n") + generate_agent_files(tmp_path, "1.9.1") + content = claude_md.read_text() + assert "FLASH:START" not in content + assert "My content." in content + + def test_creates_github_directory_for_copilot(self, tmp_path: Path): + generate_agent_files(tmp_path, "1.9.1") + copilot_file = tmp_path / ".github" / "copilot-instructions.md" + assert copilot_file.exists() + + def test_generates_placeholder_context_file(self, tmp_path: Path): + generate_agent_files(tmp_path, "1.9.1") + context_file = tmp_path / ".flash" / "context.md" + assert context_file.exists() + content = context_file.read_text() + assert "flash run" in content or "flash build" in content diff --git a/tests/unit/test_rules_packaging.py b/tests/unit/test_rules_packaging.py new file mode 100644 index 00000000..aec7db20 --- /dev/null +++ b/tests/unit/test_rules_packaging.py @@ -0,0 +1,15 @@ +"""Test that rules files are discoverable in the installed package.""" + +from importlib import resources + + +class TestRulesPackaging: + def test_rules_directory_exists(self): + rules_dir = resources.files("runpod_flash.rules") + assert rules_dir is not None + + def test_flash_rules_md_readable(self): + rules_file = resources.files("runpod_flash.rules") / "flash-rules.md" + content = rules_file.read_text() + assert "flash-rules-version" in content + assert "@Endpoint" in content From b4f6ecfdc5e3334c0c080c0421bc40ce38827525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 00:54:15 -0700 Subject: [PATCH 03/26] feat(rules): add flash rules CLI command --- src/runpod_flash/cli/commands/rules.py | 42 ++++++++++++ src/runpod_flash/cli/main.py | 2 + tests/unit/cli/commands/test_rules_command.py | 64 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 src/runpod_flash/cli/commands/rules.py create mode 100644 tests/unit/cli/commands/test_rules_command.py diff --git a/src/runpod_flash/cli/commands/rules.py b/src/runpod_flash/cli/commands/rules.py new file mode 100644 index 00000000..be60b43f --- /dev/null +++ b/src/runpod_flash/cli/commands/rules.py @@ -0,0 +1,42 @@ +"""Agent rules generation command.""" + +from importlib import metadata +from pathlib import Path + +import typer +from rich.console import Console + +from ...rules.engine import generate_agent_files + +console = Console() + + +def _get_version() -> str: + """Get the package version from metadata.""" + try: + return metadata.version("runpod-flash") + except metadata.PackageNotFoundError: + return "unknown" + + +def rules_command( + disable: bool = typer.Option( + False, "--disable", help="Disable agent rules generation for this project" + ), +) -> None: + """Generate or regenerate AI agent context files.""" + project_dir = Path.cwd() + + if disable: + console.print("[yellow]Agent rules generation disabled.[/yellow]") + return + + version = _get_version() + written = generate_agent_files(project_dir, version) + + if written: + console.print(f"[green]Generated {len(written)} agent file(s):[/green]") + for f in written: + console.print(f" {f}") + else: + console.print("[yellow]No agent files generated (all disabled).[/yellow]") diff --git a/src/runpod_flash/cli/main.py b/src/runpod_flash/cli/main.py index 57d51ab7..ccac8542 100644 --- a/src/runpod_flash/cli/main.py +++ b/src/runpod_flash/cli/main.py @@ -15,6 +15,7 @@ undeploy, login, update, + rules, ) from .update_checker import start_background_check @@ -45,6 +46,7 @@ def get_version() -> str: app.command("login")(login.login_command) app.command("deploy")(deploy.deploy_command) app.command("update")(update.update_command) +app.command("rules")(rules.rules_command) # app.command("report")(resource.report_command) diff --git a/tests/unit/cli/commands/test_rules_command.py b/tests/unit/cli/commands/test_rules_command.py new file mode 100644 index 00000000..6fec3247 --- /dev/null +++ b/tests/unit/cli/commands/test_rules_command.py @@ -0,0 +1,64 @@ +"""Tests for flash rules CLI command.""" + +from unittest.mock import patch + +from runpod_flash.cli.commands.rules import rules_command + + +class TestRulesCommand: + def test_generates_agent_files(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with ( + patch( + "runpod_flash.cli.commands.rules.generate_agent_files", + return_value=["CLAUDE.md", ".cursorrules"], + ) as mock_gen, + patch( + "runpod_flash.cli.commands.rules._get_version", + return_value="1.9.1", + ), + patch( + "runpod_flash.cli.commands.rules.console", + ), + ): + rules_command(disable=False) + + mock_gen.assert_called_once_with(tmp_path, "1.9.1") + + def test_disable_flag_skips_generation(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with ( + patch( + "runpod_flash.cli.commands.rules.generate_agent_files", + ) as mock_gen, + patch( + "runpod_flash.cli.commands.rules.console", + ), + ): + rules_command(disable=True) + + mock_gen.assert_not_called() + + def test_no_files_written_shows_warning(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with ( + patch( + "runpod_flash.cli.commands.rules.generate_agent_files", + return_value=[], + ), + patch( + "runpod_flash.cli.commands.rules._get_version", + return_value="1.9.1", + ), + patch( + "runpod_flash.cli.commands.rules.console", + ) as mock_console, + ): + rules_command(disable=False) + + mock_console.print.assert_called_with( + "[yellow]No agent files generated (all disabled).[/yellow]" + ) From 271f931d447a05cf7469c0a1f6e8012dec7e8468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 00:56:15 -0700 Subject: [PATCH 04/26] feat(rules): integrate agent file generation into flash init --- src/runpod_flash/cli/commands/init.py | 14 ++++++++++++++ tests/unit/cli/commands/test_init.py | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index ab7e39ab..385a7050 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -1,5 +1,6 @@ """Project initialization command.""" +from importlib import metadata as importlib_metadata from pathlib import Path from typing import Optional @@ -9,10 +10,18 @@ from rich.table import Table from ..utils.skeleton import create_project_skeleton, detect_file_conflicts +from ...rules.engine import generate_agent_files console = Console() +def _get_version() -> str: + try: + return importlib_metadata.version("runpod-flash") + except importlib_metadata.PackageNotFoundError: + return "unknown" + + def init_command( ctx: typer.Context, project_name: Optional[str] = typer.Argument( @@ -76,6 +85,9 @@ def init_command( ) with console.status(status_msg): create_project_skeleton(project_dir, should_overwrite) + # Generate AI agent context files + version = _get_version() + generate_agent_files(project_dir, version) # Success output if is_current_dir: @@ -93,6 +105,8 @@ def init_command( panel_content += " ├── pyproject.toml\n" panel_content += " ├── .env.example\n" panel_content += " ├── requirements.txt\n" + panel_content += " ├── CLAUDE.md # AI agent rules (auto-generated)\n" + panel_content += " ├── AGENTS.md # AI agent rules (auto-generated)\n" panel_content += " └── README.md\n" title = "Project Initialized" if is_current_dir else "Project Created" diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 794cb566..20013c03 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -1,5 +1,6 @@ """Tests for flash init command.""" +from pathlib import Path from unittest.mock import MagicMock, Mock, patch import pytest @@ -317,3 +318,25 @@ def test_directory_created_matches_argument( assert (tmp_path / "my_awesome_project").exists() # Verify it's a directory assert (tmp_path / "my_awesome_project").is_dir() + + +class TestInitGeneratesAgentFiles: + def test_init_calls_generate_agent_files(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with ( + patch("runpod_flash.cli.commands.init.generate_agent_files") as mock_gen, + patch("runpod_flash.cli.commands.init._get_version", return_value="1.9.1"), + patch("runpod_flash.cli.commands.init.create_project_skeleton"), + patch( + "runpod_flash.cli.commands.init.detect_file_conflicts", return_value=[] + ), + patch("runpod_flash.cli.commands.init.console"), + ): + init_command("test_project") + + # init_command uses Path(project_name) which is relative + mock_gen.assert_called_once() + call_args = mock_gen.call_args[0] + assert call_args[0] == Path("test_project") + assert call_args[1] == "1.9.1" From 24fd85b6ee50108c1a368a57223752b2196d9deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 00:59:09 -0700 Subject: [PATCH 05/26] feat(rules): add dynamic context renderer from manifest data --- src/runpod_flash/rules/context.py | 88 ++++++++++++++++++++++++++ tests/unit/rules/test_context.py | 100 ++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/runpod_flash/rules/context.py create mode 100644 tests/unit/rules/test_context.py diff --git a/src/runpod_flash/rules/context.py b/src/runpod_flash/rules/context.py new file mode 100644 index 00000000..a1f55ee4 --- /dev/null +++ b/src/runpod_flash/rules/context.py @@ -0,0 +1,88 @@ +"""Dynamic context rendering from Flash manifest data.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +MAX_DETAILED_ENDPOINTS = 20 + + +def render_dynamic_context(manifest: dict[str, Any]) -> str: + """Render .flash/context.md from manifest data.""" + resources = manifest.get("resources", {}) + timestamp = datetime.now(timezone.utc).isoformat() + + lines = [ + f"", + "", + "", + ] + + if not resources: + lines.append( + "No endpoints discovered. Add `@Endpoint` decorated functions to your project." + ) + return "\n".join(lines) + "\n" + + # Endpoints table + lines.append("## Endpoints") + lines.append("") + lines.append("| Name | Type | Resource | Workers | File |") + lines.append("|------|------|----------|---------|------|") + + for name, res in resources.items(): + ep_type = "LB" if res.get("is_load_balanced") else "QB" + res_type = res.get("resource_type", "unknown") + gpu = res.get("gpuIds", "") + workers_min = res.get("workersMin", "?") + workers_max = res.get("workersMax", "?") + workers = f"{workers_min}-{workers_max}" if workers_min != "?" else "?" + file_path = res.get("file_path", "?") + lines.append( + f"| {name} | {ep_type} | {res_type} ({gpu}) | {workers} | {file_path} |" + ) + + lines.append("") + + # Dependency graph + callers = { + name: res for name, res in resources.items() if res.get("makes_remote_calls") + } + if callers: + lines.append("## Dependency Graph") + lines.append("") + for name in callers: + lines.append(f"- {name} (LB) makes remote calls to other endpoints") + lines.append("") + + # Per-endpoint details (capped for token budget) + lines.append("## Per-Endpoint Details") + lines.append("") + + for idx, (name, res) in enumerate(resources.items()): + if idx >= MAX_DETAILED_ENDPOINTS: + remaining = len(resources) - MAX_DETAILED_ENDPOINTS + lines.append( + f"*...and {remaining} more endpoints (see flash_manifest.json for details)*" + ) + break + + lines.append(f"### {name}") + lines.append(f"- Type: {res.get('resource_type', 'unknown')}") + if res.get("gpuIds"): + lines.append(f"- GPU: {res['gpuIds']}") + workers_min = res.get("workersMin") + workers_max = res.get("workersMax") + if workers_min is not None: + lines.append(f"- Workers: {workers_min}-{workers_max}") + lines.append(f"- Makes remote calls: {res.get('makes_remote_calls', False)}") + + funcs = res.get("functions", []) + if funcs: + func_names = [f["name"] for f in funcs] + lines.append(f"- Functions: {', '.join(func_names)}") + + lines.append("") + + return "\n".join(lines) + "\n" diff --git a/tests/unit/rules/test_context.py b/tests/unit/rules/test_context.py new file mode 100644 index 00000000..a914d945 --- /dev/null +++ b/tests/unit/rules/test_context.py @@ -0,0 +1,100 @@ +"""Tests for dynamic context rendering.""" + +from runpod_flash.rules.context import render_dynamic_context + + +SAMPLE_MANIFEST = { + "version": "1.0", + "generated_at": "2026-03-16T14:00:00Z", + "project_name": "test_project", + "resources": { + "image_gen": { + "resource_type": "LiveServerless", + "file_path": "gpu_worker.py", + "functions": [ + { + "name": "generate", + "module": "gpu_worker", + "is_async": True, + "is_class": False, + } + ], + "is_load_balanced": False, + "makes_remote_calls": False, + "gpuIds": "ADA_24", + "workersMin": 0, + "workersMax": 3, + }, + "api": { + "resource_type": "CpuLiveLoadBalancer", + "file_path": "pipeline.py", + "functions": [ + { + "name": "classify", + "module": "pipeline", + "is_async": True, + "is_class": False, + "http_method": "POST", + "http_path": "/classify", + } + ], + "is_load_balanced": True, + "makes_remote_calls": True, + }, + }, + "function_registry": {"generate": "image_gen", "classify": "api"}, + "routes": {"api": {"POST /classify": "classify"}}, +} + + +class TestRenderDynamicContext: + def test_renders_endpoints_table(self): + result = render_dynamic_context(SAMPLE_MANIFEST) + assert "image_gen" in result + assert "api" in result + assert "gpu_worker.py" in result + + def test_renders_dependency_graph(self): + result = render_dynamic_context(SAMPLE_MANIFEST) + assert "Dependency Graph" in result + + def test_renders_per_endpoint_details(self): + result = render_dynamic_context(SAMPLE_MANIFEST) + assert "ADA_24" in result + assert "LiveServerless" in result + + def test_includes_timestamp(self): + result = render_dynamic_context(SAMPLE_MANIFEST) + assert "Auto-generated" in result + + def test_empty_manifest_returns_minimal_output(self): + empty = {"version": "1.0", "resources": {}, "function_registry": {}} + result = render_dynamic_context(empty) + assert "No endpoints discovered" in result + + def test_respects_token_budget(self): + large_manifest = {"version": "1.0", "resources": {}, "function_registry": {}} + for i in range(50): + large_manifest["resources"][f"endpoint_{i}"] = { + "resource_type": "LiveServerless", + "file_path": f"worker_{i}.py", + "functions": [ + { + "name": f"func_{i}", + "module": f"worker_{i}", + "is_async": True, + "is_class": False, + } + ], + "is_load_balanced": False, + "makes_remote_calls": False, + "gpuIds": "ADA_24", + "workersMin": 0, + "workersMax": 3, + } + large_manifest["function_registry"] = { + f"func_{i}": f"endpoint_{i}" for i in range(50) + } + result = render_dynamic_context(large_manifest) + word_count = len(result.split()) + assert word_count < 3000, f"Dynamic context too large: {word_count} words" From f4d9a8525e3f5b5504b4ab098f96ec7d6f590dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 01:00:05 -0700 Subject: [PATCH 06/26] feat(rules): wire dynamic context generation into flash rules command --- src/runpod_flash/cli/commands/rules.py | 3 +- src/runpod_flash/rules/engine.py | 31 ++++++++++++ tests/unit/rules/test_engine.py | 69 ++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/runpod_flash/cli/commands/rules.py b/src/runpod_flash/cli/commands/rules.py index be60b43f..7c4f00b1 100644 --- a/src/runpod_flash/cli/commands/rules.py +++ b/src/runpod_flash/cli/commands/rules.py @@ -6,7 +6,7 @@ import typer from rich.console import Console -from ...rules.engine import generate_agent_files +from ...rules.engine import generate_agent_files, update_dynamic_context console = Console() @@ -33,6 +33,7 @@ def rules_command( version = _get_version() written = generate_agent_files(project_dir, version) + update_dynamic_context(project_dir) if written: console.print(f"[green]Generated {len(written)} agent file(s):[/green]") diff --git a/src/runpod_flash/rules/engine.py b/src/runpod_flash/rules/engine.py index d76636a2..d27445fe 100644 --- a/src/runpod_flash/rules/engine.py +++ b/src/runpod_flash/rules/engine.py @@ -2,11 +2,14 @@ from __future__ import annotations +import json import logging import re from importlib import resources from pathlib import Path +from .context import render_dynamic_context + logger = logging.getLogger(__name__) FLASH_START_MARKER = ( @@ -113,3 +116,31 @@ def generate_agent_files(project_dir: Path, version: str) -> list[str]: written.append(".flash/context.md") return written + + +def _find_manifest(project_dir: Path) -> Path | None: + """Find flash_manifest.json in known locations.""" + candidates = [ + project_dir / "flash_manifest.json", + project_dir / ".flash" / "flash_manifest.json", + ] + for path in candidates: + if path.exists(): + return path + return None + + +def update_dynamic_context(project_dir: Path) -> None: + """Update .flash/context.md from the project manifest.""" + manifest_path = _find_manifest(project_dir) + context_dir = project_dir / ".flash" + context_dir.mkdir(parents=True, exist_ok=True) + context_file = context_dir / "context.md" + + if manifest_path is not None: + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + content = render_dynamic_context(manifest_data) + context_file.write_text(content, encoding="utf-8") + else: + if not context_file.exists(): + context_file.write_text(CONTEXT_PLACEHOLDER, encoding="utf-8") diff --git a/tests/unit/rules/test_engine.py b/tests/unit/rules/test_engine.py index 66a163cb..1987907b 100644 --- a/tests/unit/rules/test_engine.py +++ b/tests/unit/rules/test_engine.py @@ -1,5 +1,6 @@ """Tests for the rules engine — reading and marker injection logic.""" +import json from pathlib import Path from runpod_flash.rules.engine import ( @@ -7,6 +8,7 @@ generate_agent_files, inject_rules_section, read_static_rules, + update_dynamic_context, ) @@ -139,3 +141,70 @@ def test_generates_placeholder_context_file(self, tmp_path: Path): assert context_file.exists() content = context_file.read_text() assert "flash run" in content or "flash build" in content + + +class TestUpdateDynamicContext: + def test_writes_context_file_from_manifest(self, tmp_path: Path): + manifest_data = { + "version": "1.0", + "resources": { + "worker": { + "resource_type": "LiveServerless", + "file_path": "worker.py", + "functions": [ + { + "name": "run", + "module": "worker", + "is_async": True, + "is_class": False, + } + ], + "is_load_balanced": False, + "makes_remote_calls": False, + } + }, + "function_registry": {"run": "worker"}, + } + # Test root-level manifest location + manifest_path = tmp_path / "flash_manifest.json" + manifest_path.write_text(json.dumps(manifest_data)) + + update_dynamic_context(tmp_path) + + context_file = tmp_path / ".flash" / "context.md" + assert context_file.exists() + content = context_file.read_text() + assert "worker" in content + assert "Auto-generated" in content + + def test_finds_manifest_in_dot_flash(self, tmp_path: Path): + manifest_data = { + "version": "1.0", + "resources": { + "gpu": { + "resource_type": "LiveServerless", + "file_path": "gpu.py", + "functions": [], + "is_load_balanced": False, + "makes_remote_calls": False, + } + }, + "function_registry": {}, + } + manifest_path = tmp_path / ".flash" / "flash_manifest.json" + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest_data)) + + update_dynamic_context(tmp_path) + + context_file = tmp_path / ".flash" / "context.md" + assert context_file.exists() + assert "gpu" in context_file.read_text() + + def test_no_manifest_writes_placeholder(self, tmp_path: Path): + update_dynamic_context(tmp_path) + + context_file = tmp_path / ".flash" / "context.md" + assert context_file.exists() + content = context_file.read_text() + assert "flash run" in content or "flash build" in content From 77112607b8114cd465db39844a5b444bddea46b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 10:32:53 -0700 Subject: [PATCH 07/26] feat(rules): add --no-rules flag to flash init and .gitignore entry --- src/runpod_flash/cli/commands/init.py | 8 ++++++-- .../cli/utils/skeleton_template/.gitignore | 3 +++ tests/unit/cli/commands/test_init.py | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index 385a7050..6b9046f6 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -28,6 +28,9 @@ def init_command( None, help="Project name, or '.' to initialize in current directory" ), force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"), + no_rules: bool = typer.Option( + False, "--no-rules", help="Skip AI agent rules generation" + ), ): """Create new Flash project with Flash Server and GPU workers.""" @@ -86,8 +89,9 @@ def init_command( with console.status(status_msg): create_project_skeleton(project_dir, should_overwrite) # Generate AI agent context files - version = _get_version() - generate_agent_files(project_dir, version) + if no_rules is not True: + version = _get_version() + generate_agent_files(project_dir, version) # Success output if is_current_dir: diff --git a/src/runpod_flash/cli/utils/skeleton_template/.gitignore b/src/runpod_flash/cli/utils/skeleton_template/.gitignore index 0e3b93d7..4c785af0 100644 --- a/src/runpod_flash/cli/utils/skeleton_template/.gitignore +++ b/src/runpod_flash/cli/utils/skeleton_template/.gitignore @@ -43,3 +43,6 @@ dist/ # OS .DS_Store Thumbs.db + +# Flash dynamic context (machine-specific, regenerated on each command) +.flash/context.md diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 20013c03..085a1a1b 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -320,6 +320,24 @@ def test_directory_created_matches_argument( assert (tmp_path / "my_awesome_project").is_dir() +class TestInitNoRulesFlag: + def test_no_rules_flag_skips_agent_files(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + with ( + patch("runpod_flash.cli.commands.init.generate_agent_files") as mock_gen, + patch("runpod_flash.cli.commands.init._get_version", return_value="1.9.1"), + patch("runpod_flash.cli.commands.init.create_project_skeleton"), + patch( + "runpod_flash.cli.commands.init.detect_file_conflicts", return_value=[] + ), + patch("runpod_flash.cli.commands.init.console"), + ): + init_command("test_project", no_rules=True) + + mock_gen.assert_not_called() + + class TestInitGeneratesAgentFiles: def test_init_calls_generate_agent_files(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From 5d85bee1ef97a69fa9daf9f6eabcd935bd380071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 10:34:04 -0700 Subject: [PATCH 08/26] feat(rules): regenerate dynamic context on flash run and flash build --- src/runpod_flash/cli/commands/build.py | 2 + src/runpod_flash/cli/commands/run.py | 131 +++++++++---------------- 2 files changed, 51 insertions(+), 82 deletions(-) diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index 2146ad31..274e9d88 100644 --- a/src/runpod_flash/cli/commands/build.py +++ b/src/runpod_flash/cli/commands/build.py @@ -24,6 +24,7 @@ from runpod_flash.cli.utils.formatting import print_error, print_warning from runpod_flash.core.resources.constants import MAX_TARBALL_SIZE_MB +from ...rules.engine import update_dynamic_context from ..utils.ignore import get_file_tree, load_ignore_patterns from .build_utils.handler_generator import HandlerGenerator from .build_utils.lb_handler_generator import LBHandlerGenerator @@ -354,6 +355,7 @@ def run_build( flash_dir = project_dir / ".flash" deployment_manifest_path = flash_dir / "flash_manifest.json" shutil.copy2(manifest_path, deployment_manifest_path) + update_dynamic_context(Path.cwd()) except typer.Exit: raise diff --git a/src/runpod_flash/cli/commands/run.py b/src/runpod_flash/cli/commands/run.py index 45324e21..bfd57434 100644 --- a/src/runpod_flash/cli/commands/run.py +++ b/src/runpod_flash/cli/commands/run.py @@ -34,9 +34,9 @@ def __init__(self, **_kw): pass -from ..utils.ignore import get_file_tree, load_ignore_patterns +from ...rules.engine import update_dynamic_context from .build_utils.scanner import ( - RuntimeScanner, + RemoteDecoratorScanner, file_to_module_path, file_to_resource_name, file_to_url_prefix, @@ -96,14 +96,23 @@ class WorkerInfo: ) # fn_or_method_name -> first line of docstring -def _scan_project_workers( - project_root: Path, -) -> tuple[List[WorkerInfo], RuntimeScanner]: - """scan the project for all @remote decorated functions. +def _scan_project_workers(project_root: Path) -> List[WorkerInfo]: + """Scan the project for all @remote decorated functions. - returns (workers, scanner) so callers can inspect import_errors. + Walks all .py files (excluding .flash/, __pycache__, __init__.py) and + builds WorkerInfo for each file that contains @remote functions. + + Files with QB functions produce one WorkerInfo per file (QB type). + Files with LB functions produce one WorkerInfo per file (LB type). + A file can have both QB and LB functions (unusual but supported). + + Args: + project_root: Root directory of the Flash project + + Returns: + List of WorkerInfo, one entry per discovered source file """ - scanner = RuntimeScanner(project_root) + scanner = RemoteDecoratorScanner(project_root) remote_functions = scanner.discover_remote_functions() # Group by file path @@ -184,7 +193,7 @@ def _scan_project_workers( ) ) - return workers, scanner + return workers def _ensure_gitignore(project_root: Path) -> None: @@ -383,17 +392,6 @@ def _generate_flash_server(project_root: Path, workers: List[WorkerInfo]) -> Pat "", ] - # Purge user modules from sys.modules so uvicorn hot-reload reimports them - # with current code and config values (e.g. updated env dicts). - user_modules = sorted({w.module_path for w in workers}) - if user_modules: - module_list = ", ".join(f'"{m}"' for m in user_modules) - lines += [ - f"for _mod in [{module_list}]:", - " sys.modules.pop(_mod, None)", - "", - ] - # Collect imports — QB functions are called directly, LB config variables and # functions are passed to lb_execute for dispatch via LoadBalancerSlsStub. all_imports: List[str] = [] @@ -877,7 +875,7 @@ def _watch_and_regenerate(project_root: Path, stop_event: threading.Event) -> No if not py_changed: continue try: - workers, _ = _scan_project_workers(project_root) + workers = _scan_project_workers(project_root) _generate_flash_server(project_root, workers) logger.debug("server.py regenerated (%d changed)", len(py_changed)) except Exception as e: @@ -892,9 +890,8 @@ def _watch_and_regenerate(project_root: Path, stop_event: threading.Event) -> No def _discover_resources(project_root: Path): """Discover deployable resources in project files. - Imports each python file and inspects module-level objects for - DeployableResource instances and Endpoint facades. Endpoint facades - are unwrapped via _build_resource_config() to get the inner resource. + Uses ResourceDiscovery to find all DeployableResource instances by + parsing @remote decorators and importing the referenced config variables. Args: project_root: Root directory of the Flash project @@ -902,65 +899,35 @@ def _discover_resources(project_root: Path): Returns: List of discovered DeployableResource instances """ - from ...core.resources.base import DeployableResource - from ...endpoint import Endpoint + from ...core.discovery import ResourceDiscovery - from .build_utils.scanner import _import_module_from_file - - spec = load_ignore_patterns(project_root) - all_files = get_file_tree(project_root, spec) py_files = sorted( - f for f in all_files if f.suffix == ".py" and f.name != "__init__.py" + p + for p in project_root.rglob("*.py") + if not any( + skip in p.parts + for skip in (".flash", ".venv", "venv", "__pycache__", ".git") + ) ) + # Add project root to sys.path so cross-module imports resolve + # (e.g. api/routes.py doing "from longruns.stage1 import stage1_process"). root_str = str(project_root) added_to_path = root_str not in sys.path if added_to_path: sys.path.insert(0, root_str) resources = [] - seen_names: set[str] = set() try: for py_file in py_files: try: - module_path = file_to_module_path(py_file, project_root) - module = _import_module_from_file(py_file, module_path) - if module is None: - continue - - for name in dir(module): - try: - obj = getattr(module, name) - except Exception: - continue - - resource = None - if isinstance(obj, DeployableResource): - resource = obj - elif isinstance(obj, Endpoint) and not obj.is_client: - resource = obj._build_resource_config() - elif hasattr(obj, "__remote_config__"): - cfg = getattr(obj, "__remote_config__", {}) - rc = cfg.get("resource_config") - if isinstance(rc, Endpoint) and not rc.is_client: - resource = rc._build_resource_config() - elif isinstance(rc, DeployableResource): - resource = rc - - if resource is not None: - res_name = getattr(resource, "name", None) or name - if res_name not in seen_names: - seen_names.add(res_name) - resources.append(resource) - + discovery = ResourceDiscovery(str(py_file), max_depth=0) + resources.extend(discovery.discover()) except Exception as e: logger.debug("Discovery failed for %s: %s", py_file, e) finally: if added_to_path: - try: - sys.path.remove(root_str) - except ValueError: - pass + sys.path.remove(root_str) if resources: console.print(f"\n[dim]discovered {len(resources)} endpoint(s)[/dim]") @@ -1053,27 +1020,26 @@ def run_command( ) # Discover @remote functions - workers, scanner = _scan_project_workers(project_root) - - if scanner.import_errors: - console.print("\n[red bold]Failed to load:[/red bold]") - for filename, err in scanner.import_errors.items(): - console.print(f" [red]{filename}[/red]: {err}") - console.print() - raise typer.Exit(1) + workers = _scan_project_workers(project_root) if not workers: + console.print("[red]Error:[/red] No endpoints found.") + console.print("Decorate your functions with @Endpoint to get started.") + console.print("\nQueue-based (one function per endpoint):") console.print( - "\n[red bold]No endpoints found.[/red bold]\n" + " from runpod_flash import Endpoint, GpuGroup\n" "\n" - " [dim]Queue-based:[/dim]\n" - " @Endpoint(name='worker', gpu=GpuGroup.ANY)\n" - " async def process(input_data: dict) -> dict: ...\n" + " @Endpoint(name='my-worker', gpu=GpuGroup.ANY)\n" + " async def process(input_data: dict) -> dict:\n" + " return {'result': input_data}" + ) + console.print("\nLoad-balanced (multiple routes, shared workers):") + console.print( + " api = Endpoint(name='my-api', cpu='cpu3g-2-8', workers=(1, 3))\n" "\n" - " [dim]Load-balanced:[/dim]\n" - " api = Endpoint(name='api', cpu='cpu3g-2-8')\n" - " @api.post('/compute')\n" - " async def compute(data: dict) -> dict: ...\n" + " @api.post('/compute')\n" + " async def compute(data: dict) -> dict:\n" + " return {'result': data}" ) raise typer.Exit(1) @@ -1089,6 +1055,7 @@ def run_command( from runpod_flash.dev_console import set_name_width set_name_width([w.resource_name for w in workers]) + update_dynamic_context(Path.cwd()) _print_startup_table(workers, host, port) From a4d3bd371addb875aff4b1cc0bfe055f79dfe321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 10:40:23 -0700 Subject: [PATCH 09/26] fix(rules): wrap dynamic context in try/except, remove --disable flag, fix tests --- src/runpod_flash/cli/commands/build.py | 5 ++- src/runpod_flash/cli/commands/rules.py | 11 +---- src/runpod_flash/cli/commands/run.py | 4 ++ tests/unit/cli/commands/test_init.py | 2 +- tests/unit/cli/commands/test_rules_command.py | 40 +++++++++++-------- 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index 274e9d88..ae345b36 100644 --- a/src/runpod_flash/cli/commands/build.py +++ b/src/runpod_flash/cli/commands/build.py @@ -355,7 +355,10 @@ def run_build( flash_dir = project_dir / ".flash" deployment_manifest_path = flash_dir / "flash_manifest.json" shutil.copy2(manifest_path, deployment_manifest_path) - update_dynamic_context(Path.cwd()) + try: + update_dynamic_context(Path.cwd()) + except Exception: + logger.warning("Failed to update agent dynamic context", exc_info=True) except typer.Exit: raise diff --git a/src/runpod_flash/cli/commands/rules.py b/src/runpod_flash/cli/commands/rules.py index 7c4f00b1..7bf7b996 100644 --- a/src/runpod_flash/cli/commands/rules.py +++ b/src/runpod_flash/cli/commands/rules.py @@ -3,7 +3,6 @@ from importlib import metadata from pathlib import Path -import typer from rich.console import Console from ...rules.engine import generate_agent_files, update_dynamic_context @@ -19,18 +18,10 @@ def _get_version() -> str: return "unknown" -def rules_command( - disable: bool = typer.Option( - False, "--disable", help="Disable agent rules generation for this project" - ), -) -> None: +def rules_command() -> None: """Generate or regenerate AI agent context files.""" project_dir = Path.cwd() - if disable: - console.print("[yellow]Agent rules generation disabled.[/yellow]") - return - version = _get_version() written = generate_agent_files(project_dir, version) update_dynamic_context(project_dir) diff --git a/src/runpod_flash/cli/commands/run.py b/src/runpod_flash/cli/commands/run.py index bfd57434..9baad14d 100644 --- a/src/runpod_flash/cli/commands/run.py +++ b/src/runpod_flash/cli/commands/run.py @@ -1056,6 +1056,10 @@ def run_command( set_name_width([w.resource_name for w in workers]) update_dynamic_context(Path.cwd()) + try: + update_dynamic_context(Path.cwd()) + except Exception: + logger.warning("Failed to update agent dynamic context", exc_info=True) _print_startup_table(workers, host, port) diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 085a1a1b..4c28116f 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -351,7 +351,7 @@ def test_init_calls_generate_agent_files(self, tmp_path, monkeypatch): ), patch("runpod_flash.cli.commands.init.console"), ): - init_command("test_project") + init_command("test_project", no_rules=False) # init_command uses Path(project_name) which is relative mock_gen.assert_called_once() diff --git a/tests/unit/cli/commands/test_rules_command.py b/tests/unit/cli/commands/test_rules_command.py index 6fec3247..09c6d47a 100644 --- a/tests/unit/cli/commands/test_rules_command.py +++ b/tests/unit/cli/commands/test_rules_command.py @@ -18,47 +18,53 @@ def test_generates_agent_files(self, tmp_path, monkeypatch): "runpod_flash.cli.commands.rules._get_version", return_value="1.9.1", ), - patch( - "runpod_flash.cli.commands.rules.console", - ), + patch("runpod_flash.cli.commands.rules.update_dynamic_context"), + patch("runpod_flash.cli.commands.rules.console"), ): - rules_command(disable=False) + rules_command() mock_gen.assert_called_once_with(tmp_path, "1.9.1") - def test_disable_flag_skips_generation(self, tmp_path, monkeypatch): + def test_no_files_written_shows_warning(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) with ( patch( "runpod_flash.cli.commands.rules.generate_agent_files", - ) as mock_gen, + return_value=[], + ), patch( - "runpod_flash.cli.commands.rules.console", + "runpod_flash.cli.commands.rules._get_version", + return_value="1.9.1", ), + patch("runpod_flash.cli.commands.rules.update_dynamic_context"), + patch( + "runpod_flash.cli.commands.rules.console", + ) as mock_console, ): - rules_command(disable=True) + rules_command() - mock_gen.assert_not_called() + mock_console.print.assert_called_with( + "[yellow]No agent files generated (all disabled).[/yellow]" + ) - def test_no_files_written_shows_warning(self, tmp_path, monkeypatch): + def test_calls_update_dynamic_context(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) with ( patch( "runpod_flash.cli.commands.rules.generate_agent_files", - return_value=[], + return_value=["CLAUDE.md"], ), patch( "runpod_flash.cli.commands.rules._get_version", return_value="1.9.1", ), patch( - "runpod_flash.cli.commands.rules.console", - ) as mock_console, + "runpod_flash.cli.commands.rules.update_dynamic_context", + ) as mock_update, + patch("runpod_flash.cli.commands.rules.console"), ): - rules_command(disable=False) + rules_command() - mock_console.print.assert_called_with( - "[yellow]No agent files generated (all disabled).[/yellow]" - ) + mock_update.assert_called_once_with(tmp_path) From 5131fe7c61abe74c7712309c2b93c8cb0651dc98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Tue, 17 Mar 2026 13:08:14 -0700 Subject: [PATCH 10/26] chore: ignore entire .flash directory --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7b969615..2c47c1ab 100644 --- a/.gitignore +++ b/.gitignore @@ -175,7 +175,8 @@ cython_debug/ .pypirc .DS_Store -# Flash state +# Runpod Flash state +.runpod/ .flash/ # Code intelligence From eb7cc4b41cb9950db3c856268c8006f8d924fb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sun, 24 May 2026 20:19:15 -0700 Subject: [PATCH 11/26] fix(rules): restore main run.py shape, use CliRunner in init tests Commit 5d85bee reverted ~135 lines of main's run.py while adding the rules engine hook: RuntimeScanner renamed back to RemoteDecoratorScanner, tuple return dropped, _discover_resources reverted to the removed core.discovery.ResourceDiscovery path, and hot-reload sys.modules purge removed. This restores run.py to origin/main and re-adds only the update_dynamic_context() call. Also switches two init tests to CliRunner.invoke() so Typer resolves defaults instead of leaking ArgumentInfo sentinels. --- src/runpod_flash/cli/commands/run.py | 133 +++++++++++++++++---------- tests/unit/cli/commands/test_init.py | 17 +++- 2 files changed, 94 insertions(+), 56 deletions(-) diff --git a/src/runpod_flash/cli/commands/run.py b/src/runpod_flash/cli/commands/run.py index 9baad14d..dd318715 100644 --- a/src/runpod_flash/cli/commands/run.py +++ b/src/runpod_flash/cli/commands/run.py @@ -35,8 +35,9 @@ def __init__(self, **_kw): from ...rules.engine import update_dynamic_context +from ..utils.ignore import get_file_tree, load_ignore_patterns from .build_utils.scanner import ( - RemoteDecoratorScanner, + RuntimeScanner, file_to_module_path, file_to_resource_name, file_to_url_prefix, @@ -96,23 +97,14 @@ class WorkerInfo: ) # fn_or_method_name -> first line of docstring -def _scan_project_workers(project_root: Path) -> List[WorkerInfo]: - """Scan the project for all @remote decorated functions. +def _scan_project_workers( + project_root: Path, +) -> tuple[List[WorkerInfo], RuntimeScanner]: + """scan the project for all @remote decorated functions. - Walks all .py files (excluding .flash/, __pycache__, __init__.py) and - builds WorkerInfo for each file that contains @remote functions. - - Files with QB functions produce one WorkerInfo per file (QB type). - Files with LB functions produce one WorkerInfo per file (LB type). - A file can have both QB and LB functions (unusual but supported). - - Args: - project_root: Root directory of the Flash project - - Returns: - List of WorkerInfo, one entry per discovered source file + returns (workers, scanner) so callers can inspect import_errors. """ - scanner = RemoteDecoratorScanner(project_root) + scanner = RuntimeScanner(project_root) remote_functions = scanner.discover_remote_functions() # Group by file path @@ -193,7 +185,7 @@ def _scan_project_workers(project_root: Path) -> List[WorkerInfo]: ) ) - return workers + return workers, scanner def _ensure_gitignore(project_root: Path) -> None: @@ -392,6 +384,17 @@ def _generate_flash_server(project_root: Path, workers: List[WorkerInfo]) -> Pat "", ] + # Purge user modules from sys.modules so uvicorn hot-reload reimports them + # with current code and config values (e.g. updated env dicts). + user_modules = sorted({w.module_path for w in workers}) + if user_modules: + module_list = ", ".join(f'"{m}"' for m in user_modules) + lines += [ + f"for _mod in [{module_list}]:", + " sys.modules.pop(_mod, None)", + "", + ] + # Collect imports — QB functions are called directly, LB config variables and # functions are passed to lb_execute for dispatch via LoadBalancerSlsStub. all_imports: List[str] = [] @@ -875,7 +878,7 @@ def _watch_and_regenerate(project_root: Path, stop_event: threading.Event) -> No if not py_changed: continue try: - workers = _scan_project_workers(project_root) + workers, _ = _scan_project_workers(project_root) _generate_flash_server(project_root, workers) logger.debug("server.py regenerated (%d changed)", len(py_changed)) except Exception as e: @@ -890,8 +893,9 @@ def _watch_and_regenerate(project_root: Path, stop_event: threading.Event) -> No def _discover_resources(project_root: Path): """Discover deployable resources in project files. - Uses ResourceDiscovery to find all DeployableResource instances by - parsing @remote decorators and importing the referenced config variables. + Imports each python file and inspects module-level objects for + DeployableResource instances and Endpoint facades. Endpoint facades + are unwrapped via _build_resource_config() to get the inner resource. Args: project_root: Root directory of the Flash project @@ -899,35 +903,65 @@ def _discover_resources(project_root: Path): Returns: List of discovered DeployableResource instances """ - from ...core.discovery import ResourceDiscovery + from ...core.resources.base import DeployableResource + from ...endpoint import Endpoint + + from .build_utils.scanner import _import_module_from_file + spec = load_ignore_patterns(project_root) + all_files = get_file_tree(project_root, spec) py_files = sorted( - p - for p in project_root.rglob("*.py") - if not any( - skip in p.parts - for skip in (".flash", ".venv", "venv", "__pycache__", ".git") - ) + f for f in all_files if f.suffix == ".py" and f.name != "__init__.py" ) - # Add project root to sys.path so cross-module imports resolve - # (e.g. api/routes.py doing "from longruns.stage1 import stage1_process"). root_str = str(project_root) added_to_path = root_str not in sys.path if added_to_path: sys.path.insert(0, root_str) resources = [] + seen_names: set[str] = set() try: for py_file in py_files: try: - discovery = ResourceDiscovery(str(py_file), max_depth=0) - resources.extend(discovery.discover()) + module_path = file_to_module_path(py_file, project_root) + module = _import_module_from_file(py_file, module_path) + if module is None: + continue + + for name in dir(module): + try: + obj = getattr(module, name) + except Exception: + continue + + resource = None + if isinstance(obj, DeployableResource): + resource = obj + elif isinstance(obj, Endpoint) and not obj.is_client: + resource = obj._build_resource_config() + elif hasattr(obj, "__remote_config__"): + cfg = getattr(obj, "__remote_config__", {}) + rc = cfg.get("resource_config") + if isinstance(rc, Endpoint) and not rc.is_client: + resource = rc._build_resource_config() + elif isinstance(rc, DeployableResource): + resource = rc + + if resource is not None: + res_name = getattr(resource, "name", None) or name + if res_name not in seen_names: + seen_names.add(res_name) + resources.append(resource) + except Exception as e: logger.debug("Discovery failed for %s: %s", py_file, e) finally: if added_to_path: - sys.path.remove(root_str) + try: + sys.path.remove(root_str) + except ValueError: + pass if resources: console.print(f"\n[dim]discovered {len(resources)} endpoint(s)[/dim]") @@ -1020,26 +1054,27 @@ def run_command( ) # Discover @remote functions - workers = _scan_project_workers(project_root) + workers, scanner = _scan_project_workers(project_root) + + if scanner.import_errors: + console.print("\n[red bold]Failed to load:[/red bold]") + for filename, err in scanner.import_errors.items(): + console.print(f" [red]{filename}[/red]: {err}") + console.print() + raise typer.Exit(1) if not workers: - console.print("[red]Error:[/red] No endpoints found.") - console.print("Decorate your functions with @Endpoint to get started.") - console.print("\nQueue-based (one function per endpoint):") console.print( - " from runpod_flash import Endpoint, GpuGroup\n" + "\n[red bold]No endpoints found.[/red bold]\n" "\n" - " @Endpoint(name='my-worker', gpu=GpuGroup.ANY)\n" - " async def process(input_data: dict) -> dict:\n" - " return {'result': input_data}" - ) - console.print("\nLoad-balanced (multiple routes, shared workers):") - console.print( - " api = Endpoint(name='my-api', cpu='cpu3g-2-8', workers=(1, 3))\n" + " [dim]Queue-based:[/dim]\n" + " @Endpoint(name='worker', gpu=GpuGroup.ANY)\n" + " async def process(input_data: dict) -> dict: ...\n" "\n" - " @api.post('/compute')\n" - " async def compute(data: dict) -> dict:\n" - " return {'result': data}" + " [dim]Load-balanced:[/dim]\n" + " api = Endpoint(name='api', cpu='cpu3g-2-8')\n" + " @api.post('/compute')\n" + " async def compute(data: dict) -> dict: ...\n" ) raise typer.Exit(1) @@ -1056,10 +1091,6 @@ def run_command( set_name_width([w.resource_name for w in workers]) update_dynamic_context(Path.cwd()) - try: - update_dynamic_context(Path.cwd()) - except Exception: - logger.warning("Failed to update agent dynamic context", exc_info=True) _print_startup_table(workers, host, port) diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 4c28116f..c2b95ca0 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -322,6 +322,10 @@ def test_directory_created_matches_argument( class TestInitNoRulesFlag: def test_no_rules_flag_skips_agent_files(self, tmp_path, monkeypatch): + from typer.testing import CliRunner + + from runpod_flash.cli.main import app + monkeypatch.chdir(tmp_path) with ( @@ -331,15 +335,19 @@ def test_no_rules_flag_skips_agent_files(self, tmp_path, monkeypatch): patch( "runpod_flash.cli.commands.init.detect_file_conflicts", return_value=[] ), - patch("runpod_flash.cli.commands.init.console"), ): - init_command("test_project", no_rules=True) + result = CliRunner().invoke(app, ["init", "test_project", "--no-rules"]) + assert result.exit_code == 0, result.output mock_gen.assert_not_called() class TestInitGeneratesAgentFiles: def test_init_calls_generate_agent_files(self, tmp_path, monkeypatch): + from typer.testing import CliRunner + + from runpod_flash.cli.main import app + monkeypatch.chdir(tmp_path) with ( @@ -349,11 +357,10 @@ def test_init_calls_generate_agent_files(self, tmp_path, monkeypatch): patch( "runpod_flash.cli.commands.init.detect_file_conflicts", return_value=[] ), - patch("runpod_flash.cli.commands.init.console"), ): - init_command("test_project", no_rules=False) + result = CliRunner().invoke(app, ["init", "test_project"]) - # init_command uses Path(project_name) which is relative + assert result.exit_code == 0, result.output mock_gen.assert_called_once() call_args = mock_gen.call_args[0] assert call_args[0] == Path("test_project") From 08dd26dc92023522a10b2be94532dc7d92e1f38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sun, 24 May 2026 20:20:15 -0700 Subject: [PATCH 12/26] feat(rules): add CLI-first directive and surface generated files in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a prominent "Use the Flash CLI — Do Not Call Runpod REST or GraphQL Directly" section to flash-rules.md with an intent → command table covering init/run/build/deploy/preview/undeploy/app/env/rules. Guardrails raw runpod.Endpoint(...) use to invoking already-deployed endpoints only. Rewrites the README "Coding agent integration" section to surface the files that flash init now writes (CLAUDE.md, AGENTS.md, .cursorrules, .github/copilot-instructions.md, .flash/context.md), the FLASH:START/END markers, `flash rules` regeneration, FLASH:DISABLE per-file opt-out, and the --no-rules repo-wide opt-out. Closes AE-3155. --- README.md | 22 +++++++++++++++++++--- src/runpod_flash/rules/flash-rules.md | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index edf6ee88..3ae6a221 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,29 @@ flash login This saves your API key and allows you to use the Flash CLI and call `@Endpoint` functions. -### Coding agent integration (optional) +### Coding agent integration + +`flash init` automatically writes rules files that teach AI coding assistants (Claude Code, Cursor, Copilot, Codex, Aider, Gemini CLI, and any tool reading `AGENTS.md`) how to use Flash correctly — most importantly, to use the `flash` CLI instead of generating raw Runpod REST or GraphQL calls. + +Generated files: + +``` +CLAUDE.md # Claude Code +AGENTS.md # Codex, Aider, Cursor, Amp, Jules, ... +.cursorrules # Cursor (legacy convention) +.github/copilot-instructions.md # GitHub Copilot +.flash/context.md # Live project state, refreshed on flash run/build +``` + +Content lives between `` / `` markers — edit freely outside them. Regenerate after a Flash upgrade: ```bash -npx skills add runpod/skills +flash rules ``` -You can review the `SKILL.md` file in the [runpod/skills repository](https://github.com/runpod/skills/blob/main/flash/SKILL.md). +Opt out per-file by adding `` anywhere in it, or opt out of the whole repo with `flash init --no-rules`. + +For an additional cross-tool skill bundle, run `npx skills add runpod/skills` (see [runpod/skills](https://github.com/runpod/skills/blob/main/flash/SKILL.md)). ## Quickstart diff --git a/src/runpod_flash/rules/flash-rules.md b/src/runpod_flash/rules/flash-rules.md index 10ebf798..e2667351 100644 --- a/src/runpod_flash/rules/flash-rules.md +++ b/src/runpod_flash/rules/flash-rules.md @@ -6,6 +6,23 @@ Flash is a Python SDK for deploying AI workloads to Runpod GPUs. You write decorated Python functions, Flash handles infrastructure, scaling, and deployment. +## Use the Flash CLI — Do Not Call Runpod REST or GraphQL Directly + +For anything Flash supports, use the `flash` CLI. **Do not** generate `curl`, `httpx`, `requests`, or `gql` calls against `api.runpod.io`, `api.runpod.ai`, or `*.runpod.net` to build, deploy, list, scale, log, or invoke endpoints. The CLI handles auth, hashing, drift detection, manifest generation, and image selection. Direct API calls bypass all of that and will silently desync from Flash state. + +| Intent | Command | Do NOT | +|--------|---------|--------| +| Scaffold a project | `flash init ` | Hand-write `pyproject.toml` + manifest | +| Local dev server | `flash run` | Run `uvicorn` against generated server manually | +| Package artifact | `flash build` | Tar `src/` and POST it | +| Deploy to Runpod | `flash deploy` | Call `saveEndpoint` / REST `POST /v1/endpoints` | +| Preview locally | `flash deploy --preview` | Hand-write `docker-compose.yml` | +| Tear down | `flash undeploy` | Call `deleteEndpoint` mutation | +| List apps/envs | `flash app list` / `flash env list` | Query GraphQL `myself.endpoints` | +| Regenerate agent context | `flash rules` | Edit `CLAUDE.md` / `AGENTS.md` between FLASH markers | + +If a Flash command does not exist for what the user is asking, surface that gap (`flash --help` first), then ask before reaching for raw API calls. Raw Runpod SDK use (`runpod.Endpoint(...)`) is acceptable only for invoking already-deployed endpoints from non-Flash code — never for lifecycle operations. + ## Three Patterns ### Pattern A: Queue-based function endpoint From 62718c005c67913b937425cc693a92de002cb629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sun, 24 May 2026 23:07:11 -0700 Subject: [PATCH 13/26] feat(rules): trim flash-rules.md to AGENTS.md, CLI-first at top Renames the static rules file to AGENTS.md (the emerging cross-tool convention). Moves the CLI-first directive to the top of the file so agents read it before any other content. Cuts the GPU/CPU reference tables (out-of-date risk) and the dynamic-context placeholder section. --- .../rules/{flash-rules.md => AGENTS.md} | 34 +++---------------- src/runpod_flash/rules/engine.py | 2 +- tests/unit/rules/test_engine.py | 4 +-- tests/unit/test_rules_packaging.py | 6 ++-- 4 files changed, 11 insertions(+), 35 deletions(-) rename src/runpod_flash/rules/{flash-rules.md => AGENTS.md} (82%) diff --git a/src/runpod_flash/rules/flash-rules.md b/src/runpod_flash/rules/AGENTS.md similarity index 82% rename from src/runpod_flash/rules/flash-rules.md rename to src/runpod_flash/rules/AGENTS.md index e2667351..6eaf5923 100644 --- a/src/runpod_flash/rules/flash-rules.md +++ b/src/runpod_flash/rules/AGENTS.md @@ -1,11 +1,5 @@ - - # Flash Rules for AI Coding Agents -## Identity - -Flash is a Python SDK for deploying AI workloads to Runpod GPUs. You write decorated Python functions, Flash handles infrastructure, scaling, and deployment. - ## Use the Flash CLI — Do Not Call Runpod REST or GraphQL Directly For anything Flash supports, use the `flash` CLI. **Do not** generate `curl`, `httpx`, `requests`, or `gql` calls against `api.runpod.io`, `api.runpod.ai`, or `*.runpod.net` to build, deploy, list, scale, log, or invoke endpoints. The CLI handles auth, hashing, drift detection, manifest generation, and image selection. Direct API calls bypass all of that and will silently desync from Flash state. @@ -19,10 +13,13 @@ For anything Flash supports, use the `flash` CLI. **Do not** generate `curl`, `h | Preview locally | `flash deploy --preview` | Hand-write `docker-compose.yml` | | Tear down | `flash undeploy` | Call `deleteEndpoint` mutation | | List apps/envs | `flash app list` / `flash env list` | Query GraphQL `myself.endpoints` | -| Regenerate agent context | `flash rules` | Edit `CLAUDE.md` / `AGENTS.md` between FLASH markers | If a Flash command does not exist for what the user is asking, surface that gap (`flash --help` first), then ask before reaching for raw API calls. Raw Runpod SDK use (`runpod.Endpoint(...)`) is acceptable only for invoking already-deployed endpoints from non-Flash code — never for lifecycle operations. +## Identity + +Flash is a Python SDK for deploying AI workloads to Runpod GPUs. You write decorated Python functions, Flash handles infrastructure, scaling, and deployment. + ## Three Patterns ### Pattern A: Queue-based function endpoint @@ -89,28 +86,6 @@ class MyModel: - System-level packages (ffmpeg, libgl1) go in `system_dependencies`, not `dependencies` - `@Endpoint` is the canonical decorator. `@remote` is the legacy alias -## Configuration Reference - -| GPU | VRAM | Use Case | -|-----|------|----------| -| `GpuType.NVIDIA_GEFORCE_RTX_4090` | 24GB | General inference | -| `GpuType.NVIDIA_RTX_6000_ADA_GENERATION` | 48GB | Large models | -| `GpuType.NVIDIA_A100_80GB_PCIe` | 80GB | Training/large batch | -| `GpuGroup.ADA_24` | 24GB | Any Ada 24GB GPU | - -CPU types: `CpuInstanceType.CPU3C_1_2` (1 vCPU, 2GB), `CpuInstanceType.CPU3C_8_16` (8 vCPU, 16GB) - -## CLI Cheatsheet - -``` -flash init # scaffold project -flash run # local dev server at localhost:8888 -flash build # package for deployment -flash deploy # build + deploy to Runpod -flash deploy --preview # local multi-container test -flash rules # regenerate agent context files -``` - ## Common Agent Mistakes | Mistake | Fix | @@ -121,3 +96,4 @@ flash rules # regenerate agent context files | Forcing `async def` on all endpoints | Both sync and async are valid; use async only when awaiting | | Creating `main.py` or `app.py` | Not needed — Flash auto-discovers decorated functions | | Using `docker-compose` manually | Use `flash deploy --preview` for local container testing | +| Calling Runpod REST/GraphQL directly | Use `flash` CLI — see top of this file | diff --git a/src/runpod_flash/rules/engine.py b/src/runpod_flash/rules/engine.py index d27445fe..40d12ac3 100644 --- a/src/runpod_flash/rules/engine.py +++ b/src/runpod_flash/rules/engine.py @@ -35,7 +35,7 @@ def read_static_rules() -> str: """Read the bundled static rules content.""" - rules_file = resources.files("runpod_flash.rules") / "flash-rules.md" + rules_file = resources.files("runpod_flash.rules") / "AGENTS.md" return rules_file.read_text(encoding="utf-8") diff --git a/tests/unit/rules/test_engine.py b/tests/unit/rules/test_engine.py index 1987907b..25927176 100644 --- a/tests/unit/rules/test_engine.py +++ b/tests/unit/rules/test_engine.py @@ -17,9 +17,9 @@ def test_returns_string(self): content = read_static_rules() assert isinstance(content, str) - def test_contains_version_comment(self): + def test_contains_cli_first_directive(self): content = read_static_rules() - assert "flash-rules-version" in content + assert "Use the Flash CLI" in content def test_contains_endpoint_decorator(self): content = read_static_rules() diff --git a/tests/unit/test_rules_packaging.py b/tests/unit/test_rules_packaging.py index aec7db20..0c76b133 100644 --- a/tests/unit/test_rules_packaging.py +++ b/tests/unit/test_rules_packaging.py @@ -8,8 +8,8 @@ def test_rules_directory_exists(self): rules_dir = resources.files("runpod_flash.rules") assert rules_dir is not None - def test_flash_rules_md_readable(self): - rules_file = resources.files("runpod_flash.rules") / "flash-rules.md" + def test_agents_md_readable(self): + rules_file = resources.files("runpod_flash.rules") / "AGENTS.md" content = rules_file.read_text() - assert "flash-rules-version" in content + assert "Flash Rules for AI Coding Agents" in content assert "@Endpoint" in content From bc39e08368f7ec6c7673c16d43d8f7af29152b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sun, 24 May 2026 23:12:11 -0700 Subject: [PATCH 14/26] feat(rules): add minimal install_agent_files Single public function writes AGENTS.md if absent and creates a relative CLAUDE.md symlink. Idempotent. Symlink failure on platforms without support is logged and skipped (AGENTS.md still installed). --- src/runpod_flash/rules/__init__.py | 52 +++++++++++++++++++++- tests/unit/rules/test_install.py | 69 ++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/unit/rules/test_install.py diff --git a/src/runpod_flash/rules/__init__.py b/src/runpod_flash/rules/__init__.py index 3702ea88..e889ccea 100644 --- a/src/runpod_flash/rules/__init__.py +++ b/src/runpod_flash/rules/__init__.py @@ -1 +1,51 @@ -"""Flash rules for AI coding agents.""" +"""Flash agent rules — install AGENTS.md and (best-effort) CLAUDE.md symlink.""" + +from __future__ import annotations + +import logging +import os +from importlib import resources +from pathlib import Path + +logger = logging.getLogger(__name__) + +__all__ = ["install_agent_files"] + + +def _read_packaged_agents_md() -> str: + return (resources.files("runpod_flash.rules") / "AGENTS.md").read_text( + encoding="utf-8" + ) + + +def install_agent_files(target_dir: Path) -> list[Path]: + """Write AGENTS.md and a CLAUDE.md symlink into target_dir if absent. + + Returns the list of paths actually created. Idempotent: if both files + exist (or CLAUDE.md already exists in any form), they are left alone. + + Symlink failure (e.g. Windows without developer mode) is non-fatal — + AGENTS.md is still written and the failure is logged. + """ + target_dir = Path(target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + created: list[Path] = [] + + agents = target_dir / "AGENTS.md" + if not agents.exists(): + agents.write_text(_read_packaged_agents_md(), encoding="utf-8") + created.append(agents) + + claude = target_dir / "CLAUDE.md" + if not claude.exists() and not claude.is_symlink(): + try: + os.symlink("AGENTS.md", claude) + created.append(claude) + except OSError as exc: + logger.warning( + "Could not create CLAUDE.md symlink (%s). " + "Claude Code users can run: ln -s AGENTS.md CLAUDE.md", + exc, + ) + + return created diff --git a/tests/unit/rules/test_install.py b/tests/unit/rules/test_install.py new file mode 100644 index 00000000..b7f131a9 --- /dev/null +++ b/tests/unit/rules/test_install.py @@ -0,0 +1,69 @@ +"""Tests for runpod_flash.rules.install_agent_files.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from runpod_flash.rules import install_agent_files + + +class TestInstallAgentFiles: + def test_writes_agents_md_when_absent(self, tmp_path: Path) -> None: + written = install_agent_files(tmp_path) + + agents = tmp_path / "AGENTS.md" + assert agents in written + assert agents.is_file() + assert "Use the Flash CLI" in agents.read_text(encoding="utf-8") + + def test_does_not_overwrite_existing_agents_md(self, tmp_path: Path) -> None: + agents = tmp_path / "AGENTS.md" + agents.write_text("user's own content", encoding="utf-8") + + written = install_agent_files(tmp_path) + + assert agents not in written + assert agents.read_text(encoding="utf-8") == "user's own content" + + @pytest.mark.skipif( + sys.platform == "win32", + reason="symlinks require developer mode on Windows", + ) + def test_creates_claude_md_symlink_when_absent(self, tmp_path: Path) -> None: + written = install_agent_files(tmp_path) + + claude = tmp_path / "CLAUDE.md" + assert claude in written + assert claude.is_symlink() + assert os.readlink(claude) == "AGENTS.md" + + def test_does_not_replace_existing_claude_md(self, tmp_path: Path) -> None: + claude = tmp_path / "CLAUDE.md" + claude.write_text("user's own claude rules", encoding="utf-8") + + written = install_agent_files(tmp_path) + + assert claude not in written + assert not claude.is_symlink() + assert claude.read_text(encoding="utf-8") == "user's own claude rules" + + def test_symlink_failure_does_not_break_install(self, tmp_path: Path) -> None: + with patch("runpod_flash.rules.os.symlink", side_effect=OSError("denied")): + written = install_agent_files(tmp_path) + + agents = tmp_path / "AGENTS.md" + claude = tmp_path / "CLAUDE.md" + assert agents in written + assert agents.is_file() + assert not claude.exists() + + def test_idempotent_second_call(self, tmp_path: Path) -> None: + install_agent_files(tmp_path) + written_again = install_agent_files(tmp_path) + + assert written_again == [] From a3998baadb45397320e910cabcc3eb8630ddfaec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sun, 24 May 2026 23:16:58 -0700 Subject: [PATCH 15/26] fix(rules): warn on broken CLAUDE.md symlink, clear error on missing wheel asset Code review feedback. Broken symlink case now emits a warning instead of silently skipping. Missing AGENTS.md in package data now raises a FileNotFoundError with an actionable message instead of a bare traceback. --- src/runpod_flash/rules/__init__.py | 19 +++++++++++++++---- tests/unit/rules/test_install.py | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/runpod_flash/rules/__init__.py b/src/runpod_flash/rules/__init__.py index e889ccea..7beba5a5 100644 --- a/src/runpod_flash/rules/__init__.py +++ b/src/runpod_flash/rules/__init__.py @@ -13,9 +13,15 @@ def _read_packaged_agents_md() -> str: - return (resources.files("runpod_flash.rules") / "AGENTS.md").read_text( - encoding="utf-8" - ) + try: + return (resources.files("runpod_flash.rules") / "AGENTS.md").read_text( + encoding="utf-8" + ) + except FileNotFoundError as exc: + raise FileNotFoundError( + "AGENTS.md not found in runpod_flash.rules package data. " + "The installed wheel may be incomplete." + ) from exc def install_agent_files(target_dir: Path) -> list[Path]: @@ -37,7 +43,12 @@ def install_agent_files(target_dir: Path) -> list[Path]: created.append(agents) claude = target_dir / "CLAUDE.md" - if not claude.exists() and not claude.is_symlink(): + if claude.is_symlink() and not claude.exists(): + logger.warning( + "CLAUDE.md is a broken symlink at %s. Repair manually or remove it.", + claude, + ) + elif not claude.exists(): try: os.symlink("AGENTS.md", claude) created.append(claude) diff --git a/tests/unit/rules/test_install.py b/tests/unit/rules/test_install.py index b7f131a9..d291fa01 100644 --- a/tests/unit/rules/test_install.py +++ b/tests/unit/rules/test_install.py @@ -67,3 +67,30 @@ def test_idempotent_second_call(self, tmp_path: Path) -> None: written_again = install_agent_files(tmp_path) assert written_again == [] + + def test_broken_claude_md_symlink_logs_warning( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + import logging + + claude = tmp_path / "CLAUDE.md" + os.symlink("nonexistent.md", claude) + assert claude.is_symlink() and not claude.exists() + + with caplog.at_level(logging.WARNING, logger="runpod_flash.rules"): + written = install_agent_files(tmp_path) + + assert claude not in written + assert claude.is_symlink() + assert os.readlink(claude) == "nonexistent.md" + assert any("broken symlink" in r.message for r in caplog.records) + + def test_missing_packaged_agents_md_raises_clear_error( + self, tmp_path: Path + ) -> None: + with patch( + "runpod_flash.rules.resources.files", + side_effect=FileNotFoundError("not found"), + ): + with pytest.raises(FileNotFoundError, match="wheel may be incomplete"): + install_agent_files(tmp_path) From 8d0b1b3be0dcd49bef7eb822c0e2ae764b9b4f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sun, 24 May 2026 23:22:17 -0700 Subject: [PATCH 16/26] chore(rules): replace heavy engine with minimal install_agent_files Deletes engine.py, context.py, and the flash rules CLI command. Drops update_dynamic_context hooks from flash run and flash build. Rewires flash init to call install_agent_files, removes the --no-rules flag and the now-unused _get_version helper. Updates test_init.py to match the new behavior. Tasks 4-7 of the minimal agent-rules plan committed as one unit to keep quality-check green throughout: the engine importers in the 4 CLI files are all cleaned up before the commit lands. --- src/runpod_flash/cli/commands/build.py | 5 - src/runpod_flash/cli/commands/init.py | 20 +- src/runpod_flash/cli/commands/rules.py | 34 --- src/runpod_flash/cli/commands/run.py | 2 - src/runpod_flash/cli/main.py | 2 - src/runpod_flash/rules/context.py | 88 -------- src/runpod_flash/rules/engine.py | 146 ------------ tests/unit/cli/commands/test_init.py | 34 +-- tests/unit/cli/commands/test_rules_command.py | 70 ------ tests/unit/rules/test_context.py | 100 --------- tests/unit/rules/test_engine.py | 210 ------------------ tests/unit/test_rules_packaging.py | 15 -- 12 files changed, 8 insertions(+), 718 deletions(-) delete mode 100644 src/runpod_flash/cli/commands/rules.py delete mode 100644 src/runpod_flash/rules/context.py delete mode 100644 src/runpod_flash/rules/engine.py delete mode 100644 tests/unit/cli/commands/test_rules_command.py delete mode 100644 tests/unit/rules/test_context.py delete mode 100644 tests/unit/rules/test_engine.py delete mode 100644 tests/unit/test_rules_packaging.py diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index ae345b36..2146ad31 100644 --- a/src/runpod_flash/cli/commands/build.py +++ b/src/runpod_flash/cli/commands/build.py @@ -24,7 +24,6 @@ from runpod_flash.cli.utils.formatting import print_error, print_warning from runpod_flash.core.resources.constants import MAX_TARBALL_SIZE_MB -from ...rules.engine import update_dynamic_context from ..utils.ignore import get_file_tree, load_ignore_patterns from .build_utils.handler_generator import HandlerGenerator from .build_utils.lb_handler_generator import LBHandlerGenerator @@ -355,10 +354,6 @@ def run_build( flash_dir = project_dir / ".flash" deployment_manifest_path = flash_dir / "flash_manifest.json" shutil.copy2(manifest_path, deployment_manifest_path) - try: - update_dynamic_context(Path.cwd()) - except Exception: - logger.warning("Failed to update agent dynamic context", exc_info=True) except typer.Exit: raise diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index 6b9046f6..1ea4917c 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -1,6 +1,5 @@ """Project initialization command.""" -from importlib import metadata as importlib_metadata from pathlib import Path from typing import Optional @@ -10,27 +9,17 @@ from rich.table import Table from ..utils.skeleton import create_project_skeleton, detect_file_conflicts -from ...rules.engine import generate_agent_files +from ...rules import install_agent_files console = Console() -def _get_version() -> str: - try: - return importlib_metadata.version("runpod-flash") - except importlib_metadata.PackageNotFoundError: - return "unknown" - - def init_command( ctx: typer.Context, project_name: Optional[str] = typer.Argument( None, help="Project name, or '.' to initialize in current directory" ), force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"), - no_rules: bool = typer.Option( - False, "--no-rules", help="Skip AI agent rules generation" - ), ): """Create new Flash project with Flash Server and GPU workers.""" @@ -88,10 +77,7 @@ def init_command( ) with console.status(status_msg): create_project_skeleton(project_dir, should_overwrite) - # Generate AI agent context files - if no_rules is not True: - version = _get_version() - generate_agent_files(project_dir, version) + install_agent_files(project_dir) # Success output if is_current_dir: @@ -109,8 +95,8 @@ def init_command( panel_content += " ├── pyproject.toml\n" panel_content += " ├── .env.example\n" panel_content += " ├── requirements.txt\n" - panel_content += " ├── CLAUDE.md # AI agent rules (auto-generated)\n" panel_content += " ├── AGENTS.md # AI agent rules (auto-generated)\n" + panel_content += " ├── CLAUDE.md # symlink → AGENTS.md\n" panel_content += " └── README.md\n" title = "Project Initialized" if is_current_dir else "Project Created" diff --git a/src/runpod_flash/cli/commands/rules.py b/src/runpod_flash/cli/commands/rules.py deleted file mode 100644 index 7bf7b996..00000000 --- a/src/runpod_flash/cli/commands/rules.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Agent rules generation command.""" - -from importlib import metadata -from pathlib import Path - -from rich.console import Console - -from ...rules.engine import generate_agent_files, update_dynamic_context - -console = Console() - - -def _get_version() -> str: - """Get the package version from metadata.""" - try: - return metadata.version("runpod-flash") - except metadata.PackageNotFoundError: - return "unknown" - - -def rules_command() -> None: - """Generate or regenerate AI agent context files.""" - project_dir = Path.cwd() - - version = _get_version() - written = generate_agent_files(project_dir, version) - update_dynamic_context(project_dir) - - if written: - console.print(f"[green]Generated {len(written)} agent file(s):[/green]") - for f in written: - console.print(f" {f}") - else: - console.print("[yellow]No agent files generated (all disabled).[/yellow]") diff --git a/src/runpod_flash/cli/commands/run.py b/src/runpod_flash/cli/commands/run.py index dd318715..45324e21 100644 --- a/src/runpod_flash/cli/commands/run.py +++ b/src/runpod_flash/cli/commands/run.py @@ -34,7 +34,6 @@ def __init__(self, **_kw): pass -from ...rules.engine import update_dynamic_context from ..utils.ignore import get_file_tree, load_ignore_patterns from .build_utils.scanner import ( RuntimeScanner, @@ -1090,7 +1089,6 @@ def run_command( from runpod_flash.dev_console import set_name_width set_name_width([w.resource_name for w in workers]) - update_dynamic_context(Path.cwd()) _print_startup_table(workers, host, port) diff --git a/src/runpod_flash/cli/main.py b/src/runpod_flash/cli/main.py index ccac8542..57d51ab7 100644 --- a/src/runpod_flash/cli/main.py +++ b/src/runpod_flash/cli/main.py @@ -15,7 +15,6 @@ undeploy, login, update, - rules, ) from .update_checker import start_background_check @@ -46,7 +45,6 @@ def get_version() -> str: app.command("login")(login.login_command) app.command("deploy")(deploy.deploy_command) app.command("update")(update.update_command) -app.command("rules")(rules.rules_command) # app.command("report")(resource.report_command) diff --git a/src/runpod_flash/rules/context.py b/src/runpod_flash/rules/context.py deleted file mode 100644 index a1f55ee4..00000000 --- a/src/runpod_flash/rules/context.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Dynamic context rendering from Flash manifest data.""" - -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any - -MAX_DETAILED_ENDPOINTS = 20 - - -def render_dynamic_context(manifest: dict[str, Any]) -> str: - """Render .flash/context.md from manifest data.""" - resources = manifest.get("resources", {}) - timestamp = datetime.now(timezone.utc).isoformat() - - lines = [ - f"", - "", - "", - ] - - if not resources: - lines.append( - "No endpoints discovered. Add `@Endpoint` decorated functions to your project." - ) - return "\n".join(lines) + "\n" - - # Endpoints table - lines.append("## Endpoints") - lines.append("") - lines.append("| Name | Type | Resource | Workers | File |") - lines.append("|------|------|----------|---------|------|") - - for name, res in resources.items(): - ep_type = "LB" if res.get("is_load_balanced") else "QB" - res_type = res.get("resource_type", "unknown") - gpu = res.get("gpuIds", "") - workers_min = res.get("workersMin", "?") - workers_max = res.get("workersMax", "?") - workers = f"{workers_min}-{workers_max}" if workers_min != "?" else "?" - file_path = res.get("file_path", "?") - lines.append( - f"| {name} | {ep_type} | {res_type} ({gpu}) | {workers} | {file_path} |" - ) - - lines.append("") - - # Dependency graph - callers = { - name: res for name, res in resources.items() if res.get("makes_remote_calls") - } - if callers: - lines.append("## Dependency Graph") - lines.append("") - for name in callers: - lines.append(f"- {name} (LB) makes remote calls to other endpoints") - lines.append("") - - # Per-endpoint details (capped for token budget) - lines.append("## Per-Endpoint Details") - lines.append("") - - for idx, (name, res) in enumerate(resources.items()): - if idx >= MAX_DETAILED_ENDPOINTS: - remaining = len(resources) - MAX_DETAILED_ENDPOINTS - lines.append( - f"*...and {remaining} more endpoints (see flash_manifest.json for details)*" - ) - break - - lines.append(f"### {name}") - lines.append(f"- Type: {res.get('resource_type', 'unknown')}") - if res.get("gpuIds"): - lines.append(f"- GPU: {res['gpuIds']}") - workers_min = res.get("workersMin") - workers_max = res.get("workersMax") - if workers_min is not None: - lines.append(f"- Workers: {workers_min}-{workers_max}") - lines.append(f"- Makes remote calls: {res.get('makes_remote_calls', False)}") - - funcs = res.get("functions", []) - if funcs: - func_names = [f["name"] for f in funcs] - lines.append(f"- Functions: {', '.join(func_names)}") - - lines.append("") - - return "\n".join(lines) + "\n" diff --git a/src/runpod_flash/rules/engine.py b/src/runpod_flash/rules/engine.py deleted file mode 100644 index 40d12ac3..00000000 --- a/src/runpod_flash/rules/engine.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Rules engine for generating AI agent context files.""" - -from __future__ import annotations - -import json -import logging -import re -from importlib import resources -from pathlib import Path - -from .context import render_dynamic_context - -logger = logging.getLogger(__name__) - -FLASH_START_MARKER = ( - "" -) -FLASH_END_MARKER = "" -FLASH_DISABLE_MARKER = "" -FLASH_REGENERATE_HINT = "" - -AGENT_FILE_TARGETS = [ - "CLAUDE.md", - ".cursorrules", - "AGENTS.md", - ".github/copilot-instructions.md", -] - -CONTEXT_PLACEHOLDER = ( - "\n" - "\n\n" - "No project context available yet. Run a flash command to generate.\n" -) - - -def read_static_rules() -> str: - """Read the bundled static rules content.""" - rules_file = resources.files("runpod_flash.rules") / "AGENTS.md" - return rules_file.read_text(encoding="utf-8") - - -def inject_rules_section( - existing_content: str, - rules_content: str, - version: str, -) -> str | None: - """Inject or replace the Flash rules section in a file. - - Returns updated content, or None if file has FLASH:DISABLE. - """ - if FLASH_DISABLE_MARKER in existing_content: - return None - - start_marker = FLASH_START_MARKER.format(version=version) - section = ( - f"{start_marker}\n" - f"{FLASH_REGENERATE_HINT}\n\n" - f"{rules_content}\n\n" - f"## Current Project State\n\n" - f"See `.flash/context.md` for live project context " - f"(regenerated on each flash command).\n\n" - f"{FLASH_END_MARKER}\n" - ) - - start_pattern = re.compile( - r"^", - re.MULTILINE, - ) - start_match = start_pattern.search(existing_content) - - if start_match: - before = existing_content[: start_match.start()] - after_start = existing_content[start_match.end() :] - end_idx = after_start.find(FLASH_END_MARKER) - if end_idx != -1: - after = after_start[end_idx + len(FLASH_END_MARKER) :] - if after.startswith("\n"): - after = after[1:] - return before + section + after - else: - return before + section - else: - if not existing_content or existing_content.isspace(): - return section - separator = "" if existing_content.endswith("\n\n") else "\n" - return existing_content.rstrip("\n") + "\n\n" + separator + section - - -def generate_agent_files(project_dir: Path, version: str) -> list[str]: - """Generate platform-specific agent files with Flash rules.""" - rules_content = read_static_rules() - written: list[str] = [] - - for target in AGENT_FILE_TARGETS: - filepath = project_dir / target - filepath.parent.mkdir(parents=True, exist_ok=True) - - existing = "" - if filepath.exists(): - existing = filepath.read_text(encoding="utf-8") - - result = inject_rules_section(existing, rules_content, version) - if result is None: - logger.info("Skipping %s (FLASH:DISABLE found)", target) - continue - - filepath.write_text(result, encoding="utf-8") - written.append(target) - - # Generate placeholder .flash/context.md - context_dir = project_dir / ".flash" - context_dir.mkdir(parents=True, exist_ok=True) - context_file = context_dir / "context.md" - if not context_file.exists(): - context_file.write_text(CONTEXT_PLACEHOLDER, encoding="utf-8") - written.append(".flash/context.md") - - return written - - -def _find_manifest(project_dir: Path) -> Path | None: - """Find flash_manifest.json in known locations.""" - candidates = [ - project_dir / "flash_manifest.json", - project_dir / ".flash" / "flash_manifest.json", - ] - for path in candidates: - if path.exists(): - return path - return None - - -def update_dynamic_context(project_dir: Path) -> None: - """Update .flash/context.md from the project manifest.""" - manifest_path = _find_manifest(project_dir) - context_dir = project_dir / ".flash" - context_dir.mkdir(parents=True, exist_ok=True) - context_file = context_dir / "context.md" - - if manifest_path is not None: - manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) - content = render_dynamic_context(manifest_data) - context_file.write_text(content, encoding="utf-8") - else: - if not context_file.exists(): - context_file.write_text(CONTEXT_PLACEHOLDER, encoding="utf-8") diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index c2b95ca0..489116e5 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -320,8 +320,8 @@ def test_directory_created_matches_argument( assert (tmp_path / "my_awesome_project").is_dir() -class TestInitNoRulesFlag: - def test_no_rules_flag_skips_agent_files(self, tmp_path, monkeypatch): +class TestInitInstallsAgentFiles: + def test_init_calls_install_agent_files(self, tmp_path, monkeypatch): from typer.testing import CliRunner from runpod_flash.cli.main import app @@ -329,30 +329,7 @@ def test_no_rules_flag_skips_agent_files(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) with ( - patch("runpod_flash.cli.commands.init.generate_agent_files") as mock_gen, - patch("runpod_flash.cli.commands.init._get_version", return_value="1.9.1"), - patch("runpod_flash.cli.commands.init.create_project_skeleton"), - patch( - "runpod_flash.cli.commands.init.detect_file_conflicts", return_value=[] - ), - ): - result = CliRunner().invoke(app, ["init", "test_project", "--no-rules"]) - - assert result.exit_code == 0, result.output - mock_gen.assert_not_called() - - -class TestInitGeneratesAgentFiles: - def test_init_calls_generate_agent_files(self, tmp_path, monkeypatch): - from typer.testing import CliRunner - - from runpod_flash.cli.main import app - - monkeypatch.chdir(tmp_path) - - with ( - patch("runpod_flash.cli.commands.init.generate_agent_files") as mock_gen, - patch("runpod_flash.cli.commands.init._get_version", return_value="1.9.1"), + patch("runpod_flash.cli.commands.init.install_agent_files") as mock_install, patch("runpod_flash.cli.commands.init.create_project_skeleton"), patch( "runpod_flash.cli.commands.init.detect_file_conflicts", return_value=[] @@ -361,7 +338,6 @@ def test_init_calls_generate_agent_files(self, tmp_path, monkeypatch): result = CliRunner().invoke(app, ["init", "test_project"]) assert result.exit_code == 0, result.output - mock_gen.assert_called_once() - call_args = mock_gen.call_args[0] + mock_install.assert_called_once() + call_args = mock_install.call_args[0] assert call_args[0] == Path("test_project") - assert call_args[1] == "1.9.1" diff --git a/tests/unit/cli/commands/test_rules_command.py b/tests/unit/cli/commands/test_rules_command.py deleted file mode 100644 index 09c6d47a..00000000 --- a/tests/unit/cli/commands/test_rules_command.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for flash rules CLI command.""" - -from unittest.mock import patch - -from runpod_flash.cli.commands.rules import rules_command - - -class TestRulesCommand: - def test_generates_agent_files(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - with ( - patch( - "runpod_flash.cli.commands.rules.generate_agent_files", - return_value=["CLAUDE.md", ".cursorrules"], - ) as mock_gen, - patch( - "runpod_flash.cli.commands.rules._get_version", - return_value="1.9.1", - ), - patch("runpod_flash.cli.commands.rules.update_dynamic_context"), - patch("runpod_flash.cli.commands.rules.console"), - ): - rules_command() - - mock_gen.assert_called_once_with(tmp_path, "1.9.1") - - def test_no_files_written_shows_warning(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - with ( - patch( - "runpod_flash.cli.commands.rules.generate_agent_files", - return_value=[], - ), - patch( - "runpod_flash.cli.commands.rules._get_version", - return_value="1.9.1", - ), - patch("runpod_flash.cli.commands.rules.update_dynamic_context"), - patch( - "runpod_flash.cli.commands.rules.console", - ) as mock_console, - ): - rules_command() - - mock_console.print.assert_called_with( - "[yellow]No agent files generated (all disabled).[/yellow]" - ) - - def test_calls_update_dynamic_context(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - with ( - patch( - "runpod_flash.cli.commands.rules.generate_agent_files", - return_value=["CLAUDE.md"], - ), - patch( - "runpod_flash.cli.commands.rules._get_version", - return_value="1.9.1", - ), - patch( - "runpod_flash.cli.commands.rules.update_dynamic_context", - ) as mock_update, - patch("runpod_flash.cli.commands.rules.console"), - ): - rules_command() - - mock_update.assert_called_once_with(tmp_path) diff --git a/tests/unit/rules/test_context.py b/tests/unit/rules/test_context.py deleted file mode 100644 index a914d945..00000000 --- a/tests/unit/rules/test_context.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Tests for dynamic context rendering.""" - -from runpod_flash.rules.context import render_dynamic_context - - -SAMPLE_MANIFEST = { - "version": "1.0", - "generated_at": "2026-03-16T14:00:00Z", - "project_name": "test_project", - "resources": { - "image_gen": { - "resource_type": "LiveServerless", - "file_path": "gpu_worker.py", - "functions": [ - { - "name": "generate", - "module": "gpu_worker", - "is_async": True, - "is_class": False, - } - ], - "is_load_balanced": False, - "makes_remote_calls": False, - "gpuIds": "ADA_24", - "workersMin": 0, - "workersMax": 3, - }, - "api": { - "resource_type": "CpuLiveLoadBalancer", - "file_path": "pipeline.py", - "functions": [ - { - "name": "classify", - "module": "pipeline", - "is_async": True, - "is_class": False, - "http_method": "POST", - "http_path": "/classify", - } - ], - "is_load_balanced": True, - "makes_remote_calls": True, - }, - }, - "function_registry": {"generate": "image_gen", "classify": "api"}, - "routes": {"api": {"POST /classify": "classify"}}, -} - - -class TestRenderDynamicContext: - def test_renders_endpoints_table(self): - result = render_dynamic_context(SAMPLE_MANIFEST) - assert "image_gen" in result - assert "api" in result - assert "gpu_worker.py" in result - - def test_renders_dependency_graph(self): - result = render_dynamic_context(SAMPLE_MANIFEST) - assert "Dependency Graph" in result - - def test_renders_per_endpoint_details(self): - result = render_dynamic_context(SAMPLE_MANIFEST) - assert "ADA_24" in result - assert "LiveServerless" in result - - def test_includes_timestamp(self): - result = render_dynamic_context(SAMPLE_MANIFEST) - assert "Auto-generated" in result - - def test_empty_manifest_returns_minimal_output(self): - empty = {"version": "1.0", "resources": {}, "function_registry": {}} - result = render_dynamic_context(empty) - assert "No endpoints discovered" in result - - def test_respects_token_budget(self): - large_manifest = {"version": "1.0", "resources": {}, "function_registry": {}} - for i in range(50): - large_manifest["resources"][f"endpoint_{i}"] = { - "resource_type": "LiveServerless", - "file_path": f"worker_{i}.py", - "functions": [ - { - "name": f"func_{i}", - "module": f"worker_{i}", - "is_async": True, - "is_class": False, - } - ], - "is_load_balanced": False, - "makes_remote_calls": False, - "gpuIds": "ADA_24", - "workersMin": 0, - "workersMax": 3, - } - large_manifest["function_registry"] = { - f"func_{i}": f"endpoint_{i}" for i in range(50) - } - result = render_dynamic_context(large_manifest) - word_count = len(result.split()) - assert word_count < 3000, f"Dynamic context too large: {word_count} words" diff --git a/tests/unit/rules/test_engine.py b/tests/unit/rules/test_engine.py deleted file mode 100644 index 25927176..00000000 --- a/tests/unit/rules/test_engine.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Tests for the rules engine — reading and marker injection logic.""" - -import json -from pathlib import Path - -from runpod_flash.rules.engine import ( - AGENT_FILE_TARGETS, - generate_agent_files, - inject_rules_section, - read_static_rules, - update_dynamic_context, -) - - -class TestReadStaticRules: - def test_returns_string(self): - content = read_static_rules() - assert isinstance(content, str) - - def test_contains_cli_first_directive(self): - content = read_static_rules() - assert "Use the Flash CLI" in content - - def test_contains_endpoint_decorator(self): - content = read_static_rules() - assert "@Endpoint" in content - - -class TestInjectRulesSection: - def test_new_file_creates_full_content(self): - result = inject_rules_section("", "rules content", "1.9.1") - assert "FLASH:START" in result - assert "FLASH:END" in result - assert "rules content" in result - - def test_existing_file_appends_section(self): - existing = "# My Project\nCustom instructions here.\n" - result = inject_rules_section(existing, "rules content", "1.9.1") - assert result.startswith("# My Project\n") - assert "Custom instructions here." in result - assert "FLASH:START" in result - assert "rules content" in result - - def test_replaces_existing_flash_section(self): - existing = ( - "# My Project\n" - "\n" - "old rules\n" - "\n" - ) - result = inject_rules_section(existing, "new rules", "1.9.1") - assert "old rules" not in result - assert "new rules" in result - assert result.count("FLASH:START") == 1 - - def test_preserves_user_content_outside_markers(self): - existing = ( - "# My Notes\nImportant stuff.\n\n" - "\n" - "old rules\n" - "\n" - "\n# More notes\n" - ) - result = inject_rules_section(existing, "new rules", "1.9.1") - assert "# My Notes\nImportant stuff." in result - assert "# More notes" in result - - def test_disabled_file_returns_none(self): - existing = "# My Project\n\n" - result = inject_rules_section(existing, "rules", "1.9.1") - assert result is None - - def test_start_without_end_replaces_to_eof(self): - existing = ( - "# My Project\n" - "\n" - "orphan content with no end marker\n" - ) - result = inject_rules_section(existing, "new rules", "1.9.1") - assert "orphan content" not in result - assert "new rules" in result - assert "FLASH:END" in result - - def test_multiple_marker_pairs_uses_first(self): - existing = ( - "\n" - "first section\n" - "\n" - "middle content\n" - "\n" - "second section\n" - "\n" - ) - result = inject_rules_section(existing, "new rules", "1.9.1") - assert "first section" not in result - assert "new rules" in result - assert "second section" in result - - def test_orphan_end_marker_appends_new_section(self): - existing = "# My Project\n\nsome content\n" - result = inject_rules_section(existing, "new rules", "1.9.1") - assert "FLASH:START" in result - assert "new rules" in result - - -class TestGenerateAgentFiles: - def test_creates_all_agent_files_in_new_project(self, tmp_path: Path): - generate_agent_files(tmp_path, "1.9.1") - for target in AGENT_FILE_TARGETS: - filepath = tmp_path / target - assert filepath.exists(), f"Missing: {target}" - content = filepath.read_text() - assert "FLASH:START" in content - assert "@Endpoint" in content - - def test_preserves_existing_user_content(self, tmp_path: Path): - claude_md = tmp_path / "CLAUDE.md" - claude_md.write_text("# My Custom Rules\nDo not delete me.\n") - generate_agent_files(tmp_path, "1.9.1") - content = claude_md.read_text() - assert "# My Custom Rules" in content - assert "Do not delete me." in content - assert "FLASH:START" in content - - def test_skips_disabled_files(self, tmp_path: Path): - claude_md = tmp_path / "CLAUDE.md" - claude_md.write_text("\nMy content.\n") - generate_agent_files(tmp_path, "1.9.1") - content = claude_md.read_text() - assert "FLASH:START" not in content - assert "My content." in content - - def test_creates_github_directory_for_copilot(self, tmp_path: Path): - generate_agent_files(tmp_path, "1.9.1") - copilot_file = tmp_path / ".github" / "copilot-instructions.md" - assert copilot_file.exists() - - def test_generates_placeholder_context_file(self, tmp_path: Path): - generate_agent_files(tmp_path, "1.9.1") - context_file = tmp_path / ".flash" / "context.md" - assert context_file.exists() - content = context_file.read_text() - assert "flash run" in content or "flash build" in content - - -class TestUpdateDynamicContext: - def test_writes_context_file_from_manifest(self, tmp_path: Path): - manifest_data = { - "version": "1.0", - "resources": { - "worker": { - "resource_type": "LiveServerless", - "file_path": "worker.py", - "functions": [ - { - "name": "run", - "module": "worker", - "is_async": True, - "is_class": False, - } - ], - "is_load_balanced": False, - "makes_remote_calls": False, - } - }, - "function_registry": {"run": "worker"}, - } - # Test root-level manifest location - manifest_path = tmp_path / "flash_manifest.json" - manifest_path.write_text(json.dumps(manifest_data)) - - update_dynamic_context(tmp_path) - - context_file = tmp_path / ".flash" / "context.md" - assert context_file.exists() - content = context_file.read_text() - assert "worker" in content - assert "Auto-generated" in content - - def test_finds_manifest_in_dot_flash(self, tmp_path: Path): - manifest_data = { - "version": "1.0", - "resources": { - "gpu": { - "resource_type": "LiveServerless", - "file_path": "gpu.py", - "functions": [], - "is_load_balanced": False, - "makes_remote_calls": False, - } - }, - "function_registry": {}, - } - manifest_path = tmp_path / ".flash" / "flash_manifest.json" - manifest_path.parent.mkdir(parents=True, exist_ok=True) - manifest_path.write_text(json.dumps(manifest_data)) - - update_dynamic_context(tmp_path) - - context_file = tmp_path / ".flash" / "context.md" - assert context_file.exists() - assert "gpu" in context_file.read_text() - - def test_no_manifest_writes_placeholder(self, tmp_path: Path): - update_dynamic_context(tmp_path) - - context_file = tmp_path / ".flash" / "context.md" - assert context_file.exists() - content = context_file.read_text() - assert "flash run" in content or "flash build" in content diff --git a/tests/unit/test_rules_packaging.py b/tests/unit/test_rules_packaging.py deleted file mode 100644 index 0c76b133..00000000 --- a/tests/unit/test_rules_packaging.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Test that rules files are discoverable in the installed package.""" - -from importlib import resources - - -class TestRulesPackaging: - def test_rules_directory_exists(self): - rules_dir = resources.files("runpod_flash.rules") - assert rules_dir is not None - - def test_agents_md_readable(self): - rules_file = resources.files("runpod_flash.rules") / "AGENTS.md" - content = rules_file.read_text() - assert "Flash Rules for AI Coding Agents" in content - assert "@Endpoint" in content From 0c3ec2248319f0fe7a0b1f8d9dbd512f3d7b6c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sun, 24 May 2026 23:27:53 -0700 Subject: [PATCH 17/26] docs(rules): rewrite agent integration section for minimal design Reflects the new behavior: flash init writes AGENTS.md + CLAUDE.md symlink, never touches existing files, no flash rules command, no opt-out flag (delete the file). --- README.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3ae6a221..7045eafa 100644 --- a/README.md +++ b/README.md @@ -41,27 +41,25 @@ This saves your API key and allows you to use the Flash CLI and call `@Endpoint` ### Coding agent integration -`flash init` automatically writes rules files that teach AI coding assistants (Claude Code, Cursor, Copilot, Codex, Aider, Gemini CLI, and any tool reading `AGENTS.md`) how to use Flash correctly — most importantly, to use the `flash` CLI instead of generating raw Runpod REST or GraphQL calls. +`flash init` writes an `AGENTS.md` at your project root containing CLI-first rules for AI coding tools (Cursor, Codex, Aider, Amp, Jules, etc.). It also creates `CLAUDE.md` as a symlink to `AGENTS.md` so Claude Code picks up the same rules. -Generated files: +If `AGENTS.md` or `CLAUDE.md` already exist in your project, Flash leaves them alone — your file, your rules. -``` -CLAUDE.md # Claude Code -AGENTS.md # Codex, Aider, Cursor, Amp, Jules, ... -.cursorrules # Cursor (legacy convention) -.github/copilot-instructions.md # GitHub Copilot -.flash/context.md # Live project state, refreshed on flash run/build +**Existing projects (already past flash init):** + +```bash +# from your project root +python -c "from runpod_flash.rules import install_agent_files; from pathlib import Path; install_agent_files(Path.cwd())" ``` -Content lives between `` / `` markers — edit freely outside them. Regenerate after a Flash upgrade: +**Tools using other conventions:** GitHub Copilot reads `.github/copilot-instructions.md` and Cursor (legacy) reads `.cursorrules`. If you use those, symlink or copy `AGENTS.md`: ```bash -flash rules +ln -s ../AGENTS.md .github/copilot-instructions.md +ln -s AGENTS.md .cursorrules ``` -Opt out per-file by adding `` anywhere in it, or opt out of the whole repo with `flash init --no-rules`. - -For an additional cross-tool skill bundle, run `npx skills add runpod/skills` (see [runpod/skills](https://github.com/runpod/skills/blob/main/flash/SKILL.md)). +**Opt out:** Delete `AGENTS.md`. Flash will not re-create it. ## Quickstart From eec27bdbd88e4e1c9fe3c6925b391eef3cc8c7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sun, 24 May 2026 23:35:39 -0700 Subject: [PATCH 18/26] chore(rules): drop stale .flash/context.md skeleton gitignore entry The dynamic-context engine was removed in this branch. The .flash/ line already covers everything under that directory; the specific entry plus its 'regenerated on each command' comment are now misleading. --- src/runpod_flash/cli/utils/skeleton_template/.gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runpod_flash/cli/utils/skeleton_template/.gitignore b/src/runpod_flash/cli/utils/skeleton_template/.gitignore index 4c785af0..ddd808a7 100644 --- a/src/runpod_flash/cli/utils/skeleton_template/.gitignore +++ b/src/runpod_flash/cli/utils/skeleton_template/.gitignore @@ -44,5 +44,3 @@ dist/ .DS_Store Thumbs.db -# Flash dynamic context (machine-specific, regenerated on each command) -.flash/context.md From e67edfc2de98da66ef98a1b2bd1350e6eb25ca1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 25 May 2026 01:32:08 -0700 Subject: [PATCH 19/26] fix(rules): use 'flash dev' not 'flash run' in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI command is 'flash dev' — 'flash run' does not exist. Was a copy-paste from earlier docs that referenced the older name. --- src/runpod_flash/rules/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runpod_flash/rules/AGENTS.md b/src/runpod_flash/rules/AGENTS.md index 6eaf5923..581e7926 100644 --- a/src/runpod_flash/rules/AGENTS.md +++ b/src/runpod_flash/rules/AGENTS.md @@ -7,7 +7,7 @@ For anything Flash supports, use the `flash` CLI. **Do not** generate `curl`, `h | Intent | Command | Do NOT | |--------|---------|--------| | Scaffold a project | `flash init ` | Hand-write `pyproject.toml` + manifest | -| Local dev server | `flash run` | Run `uvicorn` against generated server manually | +| Local dev server | `flash dev` | Run `uvicorn` against generated server manually | | Package artifact | `flash build` | Tar `src/` and POST it | | Deploy to Runpod | `flash deploy` | Call `saveEndpoint` / REST `POST /v1/endpoints` | | Preview locally | `flash deploy --preview` | Hand-write `docker-compose.yml` | From b457380dd7ee852b9af8492f65353692142ef1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 25 May 2026 01:33:43 -0700 Subject: [PATCH 20/26] feat(rules): add Pattern D for pre-built container images (BYOI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents Endpoint(name=..., image=...) for workloads that already serve HTTP — vLLM, TGI, ComfyUI, etc. — and the id= attach mode for connecting to already-deployed endpoints without re-provisioning. --- src/runpod_flash/rules/AGENTS.md | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/runpod_flash/rules/AGENTS.md b/src/runpod_flash/rules/AGENTS.md index 581e7926..8d24fcd4 100644 --- a/src/runpod_flash/rules/AGENTS.md +++ b/src/runpod_flash/rules/AGENTS.md @@ -75,6 +75,38 @@ class MyModel: return {"text": self.pipe(prompt)[0]["generated_text"]} ``` +### Pattern D: Pre-built container image (no decorated function) + +For workloads that already serve HTTP — vLLM, TGI, ComfyUI, Ollama, custom images — provision the endpoint with an `image=` argument and call it as a client. No Python handler to write. Flash deploys the image and gives you HTTP + queue access to it. + +```python +from runpod_flash import Endpoint, GpuGroup + +vllm = Endpoint( + name="vllm", + image="vllm/vllm-openai:latest", + gpu=GpuGroup.ADA_24, + workers=(0, 3), + env={"MODEL": "meta-llama/Llama-3.1-8B-Instruct"}, +) + +# LB-style HTTP calls against the container's existing routes +result = await vllm.post("/v1/completions", {"prompt": "hello", "max_tokens": 64}) +models = await vllm.get("/v1/models") + +# Or QB-style if the image speaks the Runpod queue protocol +result = await vllm.runsync({"prompt": "hello"}) +``` + +When to use this pattern: the upstream project already publishes a serving image and you don't need to add any Python logic on top. If you need pre/post-processing, wrap the call inside a Pattern A or B `@Endpoint` instead. + +To attach to an already-deployed endpoint (no provisioning), pass `id=` instead of `image=`: + +```python +ep = Endpoint(id="abc123") +result = await ep.runsync({"prompt": "hello"}) +``` + ## Rules That Break If Violated - `import torch` and heavy libraries INSIDE the function body, never at module level @@ -96,4 +128,5 @@ class MyModel: | Forcing `async def` on all endpoints | Both sync and async are valid; use async only when awaiting | | Creating `main.py` or `app.py` | Not needed — Flash auto-discovers decorated functions | | Using `docker-compose` manually | Use `flash deploy --preview` for local container testing | +| Wrapping vLLM/TGI/Comfy in a custom handler for no reason | Use `Endpoint(name=..., image=...)` and call via `.post()`/`.run()` — Pattern D | | Calling Runpod REST/GraphQL directly | Use `flash` CLI — see top of this file | From f69eb4e64795b8846fb41020330edac060d09fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 25 May 2026 01:35:00 -0700 Subject: [PATCH 21/26] docs(rules): use runpod/worker-v1-vllm:v2.18.1 in Pattern D The Runpod-published vLLM worker is the canonical image for serving LLMs on Runpod Serverless. Adjusts the QB call shape to match the worker's input schema (input.prompt, not top-level prompt). --- src/runpod_flash/rules/AGENTS.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/runpod_flash/rules/AGENTS.md b/src/runpod_flash/rules/AGENTS.md index 8d24fcd4..315c4da3 100644 --- a/src/runpod_flash/rules/AGENTS.md +++ b/src/runpod_flash/rules/AGENTS.md @@ -84,18 +84,17 @@ from runpod_flash import Endpoint, GpuGroup vllm = Endpoint( name="vllm", - image="vllm/vllm-openai:latest", + image="runpod/worker-v1-vllm:v2.18.1", gpu=GpuGroup.ADA_24, workers=(0, 3), - env={"MODEL": "meta-llama/Llama-3.1-8B-Instruct"}, + env={"MODEL_NAME": "meta-llama/Llama-3.1-8B-Instruct"}, ) -# LB-style HTTP calls against the container's existing routes -result = await vllm.post("/v1/completions", {"prompt": "hello", "max_tokens": 64}) -models = await vllm.get("/v1/models") +# QB-style — the Runpod vLLM worker speaks the queue protocol +result = await vllm.runsync({"input": {"prompt": "hello", "max_tokens": 64}}) -# Or QB-style if the image speaks the Runpod queue protocol -result = await vllm.runsync({"prompt": "hello"}) +# Or LB-style HTTP if you've routed through a load-balanced front +models = await vllm.get("/v1/models") ``` When to use this pattern: the upstream project already publishes a serving image and you don't need to add any Python logic on top. If you need pre/post-processing, wrap the call inside a Pattern A or B `@Endpoint` instead. From 3562c1e39dadee0e49cdafd5bcee6540c5f4fffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 25 May 2026 01:37:51 -0700 Subject: [PATCH 22/26] chore(skeleton): drop legacy .runpod/ and duplicate dist/ from .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .runpod/ was the pre-rename state directory; nothing in current Flash writes there. The migration in resource_manager.py handles old projects that still have .runpod/resources.pkl. dist/ was duplicated — it's already in the Python section at the top of the file. --- src/runpod_flash/cli/utils/skeleton_template/.gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runpod_flash/cli/utils/skeleton_template/.gitignore b/src/runpod_flash/cli/utils/skeleton_template/.gitignore index ddd808a7..92d4fe30 100644 --- a/src/runpod_flash/cli/utils/skeleton_template/.gitignore +++ b/src/runpod_flash/cli/utils/skeleton_template/.gitignore @@ -37,8 +37,6 @@ wheels/ # Flash .flash/ -.runpod/ -dist/ # OS .DS_Store From 1492bf0f3079e1c00c9697bc477951393a5752c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 25 May 2026 01:39:09 -0700 Subject: [PATCH 23/26] docs(rules): restore runpod/skills bundle pointer in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was unintentionally dropped during the Task 8 section rewrite. The skill bundle is complementary to AGENTS.md — static rules cover the CLI-first directive; the skill bundle adds richer Claude Code behavior that goes beyond a markdown file. Both belong in the README. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 7045eafa..645a18a4 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,14 @@ ln -s AGENTS.md .cursorrules **Opt out:** Delete `AGENTS.md`. Flash will not re-create it. +**Claude Code skill bundle (optional):** For richer Claude Code integration beyond static rules, install the cross-tool skill bundle: + +```bash +npx skills add runpod/skills +``` + +See the `SKILL.md` file in the [runpod/skills repository](https://github.com/runpod/skills/blob/main/flash/SKILL.md). + ## Quickstart Create `gpu_demo.py`: From cb196b66147b2fc1a7097dec9c676bfe9ab3e16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 25 May 2026 01:48:53 -0700 Subject: [PATCH 24/26] fix(rules): address Copilot PR review feedback (code) Three findings from Copilot review on #341: - install_agent_files: AGENTS.md broken-symlink case now logs a warning instead of write_text() following the symlink and overwriting its target. Symmetric to the existing CLAUDE.md guard. - test_broken_claude_md_symlink_logs_warning: skipif on Windows (mirrors test_creates_claude_md_symlink_when_absent). - flash init success panel: only list AGENTS.md / CLAUDE.md when install_agent_files actually created them. Previously listed unconditionally even when symlink creation failed silently. Added test_broken_agents_md_symlink_logs_warning_and_skips_write for the new AGENTS.md guard. --- src/runpod_flash/cli/commands/init.py | 13 ++++++++++--- src/runpod_flash/rules/__init__.py | 7 ++++++- tests/unit/rules/test_install.py | 25 +++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/runpod_flash/cli/commands/init.py b/src/runpod_flash/cli/commands/init.py index 1ea4917c..12933e3a 100644 --- a/src/runpod_flash/cli/commands/init.py +++ b/src/runpod_flash/cli/commands/init.py @@ -77,7 +77,10 @@ def init_command( ) with console.status(status_msg): create_project_skeleton(project_dir, should_overwrite) - install_agent_files(project_dir) + installed_agent_files = install_agent_files(project_dir) + + agents_md_created = (project_dir / "AGENTS.md") in installed_agent_files + claude_md_created = (project_dir / "CLAUDE.md") in installed_agent_files # Success output if is_current_dir: @@ -95,8 +98,12 @@ def init_command( panel_content += " ├── pyproject.toml\n" panel_content += " ├── .env.example\n" panel_content += " ├── requirements.txt\n" - panel_content += " ├── AGENTS.md # AI agent rules (auto-generated)\n" - panel_content += " ├── CLAUDE.md # symlink → AGENTS.md\n" + if agents_md_created: + panel_content += ( + " ├── AGENTS.md # AI agent rules (auto-generated)\n" + ) + if claude_md_created: + panel_content += " ├── CLAUDE.md # symlink → AGENTS.md\n" panel_content += " └── README.md\n" title = "Project Initialized" if is_current_dir else "Project Created" diff --git a/src/runpod_flash/rules/__init__.py b/src/runpod_flash/rules/__init__.py index 7beba5a5..fe06b986 100644 --- a/src/runpod_flash/rules/__init__.py +++ b/src/runpod_flash/rules/__init__.py @@ -38,7 +38,12 @@ def install_agent_files(target_dir: Path) -> list[Path]: created: list[Path] = [] agents = target_dir / "AGENTS.md" - if not agents.exists(): + if agents.is_symlink() and not agents.exists(): + logger.warning( + "AGENTS.md is a broken symlink at %s. Repair manually or remove it.", + agents, + ) + elif not agents.exists(): agents.write_text(_read_packaged_agents_md(), encoding="utf-8") created.append(agents) diff --git a/tests/unit/rules/test_install.py b/tests/unit/rules/test_install.py index d291fa01..ad83ab08 100644 --- a/tests/unit/rules/test_install.py +++ b/tests/unit/rules/test_install.py @@ -68,6 +68,10 @@ def test_idempotent_second_call(self, tmp_path: Path) -> None: assert written_again == [] + @pytest.mark.skipif( + sys.platform == "win32", + reason="symlinks require developer mode on Windows", + ) def test_broken_claude_md_symlink_logs_warning( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -85,6 +89,27 @@ def test_broken_claude_md_symlink_logs_warning( assert os.readlink(claude) == "nonexistent.md" assert any("broken symlink" in r.message for r in caplog.records) + @pytest.mark.skipif( + sys.platform == "win32", + reason="symlinks require developer mode on Windows", + ) + def test_broken_agents_md_symlink_logs_warning_and_skips_write( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + import logging + + agents = tmp_path / "AGENTS.md" + os.symlink("nonexistent.md", agents) + assert agents.is_symlink() and not agents.exists() + + with caplog.at_level(logging.WARNING, logger="runpod_flash.rules"): + written = install_agent_files(tmp_path) + + assert agents not in written + assert agents.is_symlink() + assert os.readlink(agents) == "nonexistent.md" + assert any("broken symlink" in r.message for r in caplog.records) + def test_missing_packaged_agents_md_raises_clear_error( self, tmp_path: Path ) -> None: From 635f7f1f8c6bee6b04e8534f5d4cd04cbd8f0438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 25 May 2026 01:50:05 -0700 Subject: [PATCH 25/26] docs(rules): heading rename and tighter opt-out wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Copilot review on #341: - AGENTS.md: 'Three Patterns' heading was stale after Pattern D (BYOI/container image) landed. Renamed to 'Endpoint Patterns' — no count, survives future additions. - README opt-out section: previous 'Flash will not re-create it' was too absolute since a second 'flash init' would write the file again. Now scopes the guarantee to flash subcommands other than init / explicit install_agent_files calls. --- README.md | 2 +- src/runpod_flash/rules/AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 645a18a4..2aa0a7e7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ ln -s ../AGENTS.md .github/copilot-instructions.md ln -s AGENTS.md .cursorrules ``` -**Opt out:** Delete `AGENTS.md`. Flash will not re-create it. +**Opt out:** Delete `AGENTS.md`. No `flash` subcommand other than `flash init` (or an explicit call to `install_agent_files(...)`) will re-create it. **Claude Code skill bundle (optional):** For richer Claude Code integration beyond static rules, install the cross-tool skill bundle: diff --git a/src/runpod_flash/rules/AGENTS.md b/src/runpod_flash/rules/AGENTS.md index 315c4da3..0ecc1cf8 100644 --- a/src/runpod_flash/rules/AGENTS.md +++ b/src/runpod_flash/rules/AGENTS.md @@ -20,7 +20,7 @@ If a Flash command does not exist for what the user is asking, surface that gap Flash is a Python SDK for deploying AI workloads to Runpod GPUs. You write decorated Python functions, Flash handles infrastructure, scaling, and deployment. -## Three Patterns +## Endpoint Patterns ### Pattern A: Queue-based function endpoint From 7799638e61c1d79f680340e3e49133065a82b14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Wed, 27 May 2026 12:19:35 -0700 Subject: [PATCH 26/26] fix(rules): address QA feedback on agent rules layer - AGENTS.md: workers=N is shorthand for (0, N), not fixed count. Clarify fixed/warm idioms (N, N) and (1, N) to prevent agents writing endpoints that cold-start on every burst. - Flash_SDK_Reference: clarify image= provisions then acts as client; id= is pure client. Disambiguates is_client semantics. - rules installer: log info when CLAUDE.md exists as a real (non-symlink) file so users learn Claude Code is silently orphaned from Flash rules. - Add intentionality comment on relative AGENTS.md symlink target. - .gitignore: drop .runpod/ from repo root (migrated to .flash/; no new project creates .runpod/). - README: record rationale for not shipping --no-rules / flash rules. - Tests: cover new real-CLAUDE.md warning path and add end-to-end flash init test asserting AGENTS.md + CLAUDE.md land on disk. --- .gitignore | 1 - README.md | 2 ++ docs/Flash_SDK_Reference.md | 8 ++++++- src/runpod_flash/rules/AGENTS.md | 3 ++- src/runpod_flash/rules/__init__.py | 14 ++++++++++++ tests/unit/cli/commands/test_init.py | 34 ++++++++++++++++++++++++++++ tests/unit/rules/test_install.py | 17 ++++++++++++++ 7 files changed, 76 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 2c47c1ab..bd9a09db 100644 --- a/.gitignore +++ b/.gitignore @@ -176,7 +176,6 @@ cython_debug/ .DS_Store # Runpod Flash state -.runpod/ .flash/ # Code intelligence diff --git a/README.md b/README.md index 2aa0a7e7..6d5549d2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ ln -s AGENTS.md .cursorrules **Opt out:** Delete `AGENTS.md`. No `flash` subcommand other than `flash init` (or an explicit call to `install_agent_files(...)`) will re-create it. +There is no `--no-rules` flag or `flash rules` subcommand by design: the rules are small, the cost of an unwanted file is one `rm`, and an opt-out flag would advertise the existence of the file to users who would otherwise never notice. If you have a use case that needs init-time suppression (corporate policy, regulated environments), open an issue and we'll revisit. + **Claude Code skill bundle (optional):** For richer Claude Code integration beyond static rules, install the cross-tool skill bundle: ```bash diff --git a/docs/Flash_SDK_Reference.md b/docs/Flash_SDK_Reference.md index ecc26233..dd231a30 100644 --- a/docs/Flash_SDK_Reference.md +++ b/docs/Flash_SDK_Reference.md @@ -199,6 +199,12 @@ info = await ep.get("/v1/models") ### Client Methods (id= and image= modes) +`image=` mode deploys the image on first call, then behaves as a client to the +provisioned endpoint. `id=` mode is a pure client — it connects to an +already-deployed endpoint with no provisioning. `is_client` returns True for +both. + + #### run(input_data, timeout=60.0) Submit an async job to the endpoint. Returns an `EndpointJob`. @@ -251,7 +257,7 @@ HTTP PUT, DELETE, PATCH requests. Same interface as `post()`. | Property | Type | Description | | ------------------ | ---------------------- | ---------------------------------------------------------- | | `is_cpu` | `bool` | Whether this is a CPU endpoint | -| `is_client` | `bool` | Whether this is a client-only endpoint (`id=` or `image=`) | +| `is_client` | `bool` | True when `id=` or `image=` is set. `image=` provisions on first call then acts as client; `id=` is pure client (no provisioning) | | `is_load_balanced` | `bool` | Whether this endpoint has LB routes registered | | `workers_min` | `int` | Minimum worker count | | `workers_max` | `int` | Maximum worker count | diff --git a/src/runpod_flash/rules/AGENTS.md b/src/runpod_flash/rules/AGENTS.md index 0ecc1cf8..1dc1b036 100644 --- a/src/runpod_flash/rules/AGENTS.md +++ b/src/runpod_flash/rules/AGENTS.md @@ -111,7 +111,8 @@ result = await ep.runsync({"prompt": "hello"}) - `import torch` and heavy libraries INSIDE the function body, never at module level - Declare runtime dependencies in `@Endpoint(dependencies=[...])`, not in `pyproject.toml` - Endpoint functions can be sync (`def`) or async (`async def`). Use async when awaiting other endpoints or async I/O -- `workers=N` for fixed count, `workers=(min, max)` for auto-scaling range +- `workers=N` is shorthand for `(0, N)` — auto-scales from 0 (cold start on every burst). Use `workers=(N, N)` to pin a fixed count; `workers=(1, N)` to keep at least one warm worker +- `workers=(min, max)` is the explicit range form — prefer it when you care about cold-start behavior - Class workers: model loading in `__init__`, request handling in instance methods - Cross-worker calls use `await` — call `@Endpoint`-decorated functions as if local; Flash handles remote dispatch - System-level packages (ffmpeg, libgl1) go in `system_dependencies`, not `dependencies` diff --git a/src/runpod_flash/rules/__init__.py b/src/runpod_flash/rules/__init__.py index fe06b986..3e24fa49 100644 --- a/src/runpod_flash/rules/__init__.py +++ b/src/runpod_flash/rules/__init__.py @@ -53,8 +53,22 @@ def install_agent_files(target_dir: Path) -> list[Path]: "CLAUDE.md is a broken symlink at %s. Repair manually or remove it.", claude, ) + elif claude.is_file() and not claude.is_symlink(): + # Real CLAUDE.md exists — Claude Code will read it instead of AGENTS.md, + # so the user is silently orphaned from Flash rules in Claude Code while + # Cursor/Codex/Aider/Amp still pick them up via AGENTS.md. + logger.info( + "CLAUDE.md already exists at %s as a regular file (not a symlink). " + "Claude Code will use it and not see Flash rules in AGENTS.md. " + "To inherit Flash rules, append with: cat AGENTS.md >> CLAUDE.md " + "(or replace it with a symlink: ln -sf AGENTS.md CLAUDE.md).", + claude, + ) elif not claude.exists(): try: + # Relative target keeps the symlink valid when the project is moved + # or copied (with symlink-preserving tools). An absolute path would + # break on any relocation. os.symlink("AGENTS.md", claude) created.append(claude) except OSError as exc: diff --git a/tests/unit/cli/commands/test_init.py b/tests/unit/cli/commands/test_init.py index 489116e5..d36f10b0 100644 --- a/tests/unit/cli/commands/test_init.py +++ b/tests/unit/cli/commands/test_init.py @@ -341,3 +341,37 @@ def test_init_calls_install_agent_files(self, tmp_path, monkeypatch): mock_install.assert_called_once() call_args = mock_install.call_args[0] assert call_args[0] == Path("test_project") + + @pytest.mark.skipif( + __import__("sys").platform == "win32", + reason="CLAUDE.md symlink creation requires developer mode on Windows", + ) + def test_init_writes_agents_and_claude_files_end_to_end( + self, tmp_path, monkeypatch + ): + """End-to-end: `flash init ` produces AGENTS.md + CLAUDE.md on disk. + + Mocks are intentionally absent for install_agent_files and + create_project_skeleton — the goal is to catch wiring regressions + between the CLI and the rules installer. + """ + from typer.testing import CliRunner + + from runpod_flash.cli.main import app + + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(app, ["init", "demo"]) + + assert result.exit_code == 0, result.output + + project = tmp_path / "demo" + agents = project / "AGENTS.md" + claude = project / "CLAUDE.md" + + assert agents.is_file() + assert "Use the Flash CLI" in agents.read_text(encoding="utf-8") + assert claude.is_symlink() + import os + + assert os.readlink(claude) == "AGENTS.md" diff --git a/tests/unit/rules/test_install.py b/tests/unit/rules/test_install.py index ad83ab08..3da59045 100644 --- a/tests/unit/rules/test_install.py +++ b/tests/unit/rules/test_install.py @@ -110,6 +110,23 @@ def test_broken_agents_md_symlink_logs_warning_and_skips_write( assert os.readlink(agents) == "nonexistent.md" assert any("broken symlink" in r.message for r in caplog.records) + def test_existing_real_claude_md_logs_info_about_orphaned_rules( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + import logging + + claude = tmp_path / "CLAUDE.md" + claude.write_text("user's own claude rules", encoding="utf-8") + + with caplog.at_level(logging.INFO, logger="runpod_flash.rules"): + install_agent_files(tmp_path) + + assert claude.is_file() and not claude.is_symlink() + assert claude.read_text(encoding="utf-8") == "user's own claude rules" + assert any( + "not see Flash rules in AGENTS.md" in r.message for r in caplog.records + ) + def test_missing_packaged_agents_md_raises_clear_error( self, tmp_path: Path ) -> None: