promptpump 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
promptpump/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """PromptPump: compile shorthand prompts into dense, structured master prompts."""
2
+
3
+ from .context import WorkspaceContext, scan_workspace
4
+ from .engine import CompileResult, OllamaClient, PromptCompiler, PromptPumpError
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "CompileResult",
10
+ "OllamaClient",
11
+ "PromptCompiler",
12
+ "PromptPumpError",
13
+ "WorkspaceContext",
14
+ "scan_workspace",
15
+ "__version__",
16
+ ]
promptpump/cli.py ADDED
@@ -0,0 +1,188 @@
1
+ """Typer CLI entry point: ``promptpump run "shorthand" --stack python --copy``."""
2
+
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Annotated, Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+ from rich.table import Table
12
+
13
+ from . import __version__
14
+ from .context import scan_workspace
15
+ from .engine import (
16
+ DEFAULT_HOST,
17
+ DEFAULT_MODEL,
18
+ OllamaClient,
19
+ PromptCompiler,
20
+ PromptPumpError,
21
+ )
22
+ from .templates import UnknownStackError, get_stack, list_stacks
23
+
24
+ app = typer.Typer(
25
+ name="promptpump",
26
+ help="Compile a short shorthand prompt into a dense, production-ready master prompt "
27
+ "using local codebase context and a local Ollama model.",
28
+ no_args_is_help=True,
29
+ add_completion=False,
30
+ )
31
+
32
+ # Progress and diagnostics go to stderr so stdout stays clean for piping the prompt.
33
+ err = Console(stderr=True)
34
+
35
+
36
+ def copy_to_clipboard(text: str) -> bool:
37
+ """Copy ``text`` to the system clipboard; return False if unavailable."""
38
+ try:
39
+ import pyperclip
40
+
41
+ pyperclip.copy(text)
42
+ return True
43
+ except Exception: # pyperclip raises PyperclipException when no backend exists
44
+ return False
45
+
46
+
47
+ def _force_utf8() -> None:
48
+ """Avoid UnicodeEncodeError on Windows when output is redirected (cp1252 default)."""
49
+ for stream in (sys.stdout, sys.stderr):
50
+ reconfigure = getattr(stream, "reconfigure", None)
51
+ if reconfigure is not None:
52
+ try:
53
+ reconfigure(encoding="utf-8", errors="replace")
54
+ except (OSError, ValueError):
55
+ pass
56
+
57
+
58
+ def _version_callback(value: bool) -> None:
59
+ if value:
60
+ typer.echo(f"promptpump {__version__}")
61
+ raise typer.Exit()
62
+
63
+
64
+ @app.callback()
65
+ def main(
66
+ version: Annotated[
67
+ Optional[bool],
68
+ typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."),
69
+ ] = None,
70
+ ) -> None:
71
+ """PromptPump: deterministic context engineering for coding prompts."""
72
+ _force_utf8()
73
+
74
+
75
+ @app.command()
76
+ def run(
77
+ prompt: Annotated[str, typer.Argument(help="Shorthand prompt, e.g. 'add jwt auth'.")],
78
+ stack: Annotated[
79
+ Optional[list[str]],
80
+ typer.Option("--stack", "-s", help="Stack rules to inject (repeatable). See `stacks`."),
81
+ ] = None,
82
+ copy: Annotated[
83
+ bool, typer.Option("--copy/--no-copy", help="Copy the result to the clipboard.")
84
+ ] = True,
85
+ path: Annotated[
86
+ Path,
87
+ typer.Option("--path", "-p", exists=True, file_okay=False, resolve_path=True,
88
+ help="Project directory to scan."),
89
+ ] = Path("."),
90
+ model: Annotated[
91
+ str, typer.Option("--model", "-m", envvar="PROMPTPUMP_MODEL", help="Ollama model name.")
92
+ ] = DEFAULT_MODEL,
93
+ host: Annotated[
94
+ str, typer.Option("--host", envvar="PROMPTPUMP_HOST", help="Ollama base URL.")
95
+ ] = DEFAULT_HOST,
96
+ max_context_tokens: Annotated[
97
+ int, typer.Option("--max-context-tokens", min=500, help="Workspace context budget.")
98
+ ] = 3000,
99
+ ground: Annotated[
100
+ bool,
101
+ typer.Option("--ground/--no-ground",
102
+ help="Overwrite <codebase_context> with deterministic scan facts."),
103
+ ] = True,
104
+ timeout: Annotated[float, typer.Option(help="Seconds to wait for the model.")] = 300.0,
105
+ dry_run: Annotated[
106
+ bool, typer.Option("--dry-run", help="Print the payload; do not call the model.")
107
+ ] = False,
108
+ ) -> None:
109
+ """Compile PROMPT into a structured master prompt."""
110
+ try:
111
+ profiles = [get_stack(s) for s in stack or []]
112
+ except UnknownStackError as exc:
113
+ err.print(f"[red]error:[/red] {escape(str(exc))}")
114
+ raise typer.Exit(2) from exc
115
+
116
+ with err.status("Scanning workspace..."):
117
+ context = scan_workspace(path, max_tokens=max_context_tokens)
118
+ err.print(
119
+ f"[dim]Scanned {context.root.name}/: {len(context.tree_lines)} tree entries, "
120
+ f"{len(context.key_files)} key files, {len(context.outlines)} outlined modules[/dim]"
121
+ )
122
+
123
+ compiler = PromptCompiler(
124
+ OllamaClient(host=host, model=model, timeout=timeout), ground_context=ground
125
+ )
126
+ stack_names = [p.name for p in profiles]
127
+
128
+ if dry_run:
129
+ for message in compiler.build_messages(prompt, context, stack_names):
130
+ typer.echo(f"===== {message['role'].upper()} =====\n{message['content']}\n")
131
+ raise typer.Exit()
132
+
133
+ start = time.perf_counter()
134
+ try:
135
+ with err.status(f"Compiling with {escape(model)} (local)..."):
136
+ result = compiler.compile(prompt, context, stack_names)
137
+ except PromptPumpError as exc:
138
+ err.print(f"[red]error:[/red] {escape(str(exc))}")
139
+ raise typer.Exit(1) from exc
140
+ elapsed = time.perf_counter() - start
141
+
142
+ if result.missing_tags:
143
+ err.print(
144
+ f"[yellow]warning:[/yellow] model output is missing tags: "
145
+ f"{', '.join(result.missing_tags)}. Try a larger model or rephrase."
146
+ )
147
+ err.print(f"[green]Compiled[/green] in {elapsed:.1f}s ({result.attempts} attempt(s))")
148
+
149
+ typer.echo(result.prompt)
150
+
151
+ if copy:
152
+ if copy_to_clipboard(result.prompt):
153
+ err.print("[green]Copied to clipboard.[/green]")
154
+ else:
155
+ err.print("[yellow]Clipboard unavailable (install xclip/xsel on Linux).[/yellow]")
156
+
157
+
158
+ @app.command()
159
+ def stacks() -> None:
160
+ """List available --stack profiles."""
161
+ table = Table("Name", "Label", "Rules")
162
+ for profile in list_stacks():
163
+ table.add_row(profile.name, profile.label, str(len(profile.rules)))
164
+ Console().print(table)
165
+
166
+
167
+ @app.command()
168
+ def doctor(
169
+ model: Annotated[str, typer.Option("--model", "-m", envvar="PROMPTPUMP_MODEL")] = DEFAULT_MODEL,
170
+ host: Annotated[str, typer.Option("--host", envvar="PROMPTPUMP_HOST")] = DEFAULT_HOST,
171
+ ) -> None:
172
+ """Check that Ollama is reachable and the model is installed."""
173
+ client = OllamaClient(host=host, model=model)
174
+ try:
175
+ installed = client.list_models()
176
+ except PromptPumpError as exc:
177
+ err.print(f"[red]x[/red] {escape(str(exc))} Start it with `ollama serve`.")
178
+ raise typer.Exit(1) from exc
179
+ err.print(f"[green]ok[/green] Ollama reachable at {host}")
180
+ if any(name == model or name.split(":")[0] == model for name in installed):
181
+ err.print(f"[green]ok[/green] Model '{model}' is installed")
182
+ else:
183
+ err.print(f"[red]x[/red] Model '{model}' missing. Run: ollama pull {model}")
184
+ raise typer.Exit(1)
185
+
186
+
187
+ if __name__ == "__main__":
188
+ app()
promptpump/context.py ADDED
@@ -0,0 +1,322 @@
1
+ """Local workspace scanner: file tree, key files, and Python AST outlines.
2
+
3
+ The scanner is deliberately conservative: it never follows symlinks, skips heavy
4
+ directories and secret files, honours the root ``.gitignore``, and keeps the total
5
+ output under a token budget so it fits a small local model's context window.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Iterable
14
+
15
+ import pathspec
16
+
17
+ # Heavy / generated directories that are never worth scanning.
18
+ DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
19
+ {
20
+ ".git", ".hg", ".svn", "node_modules", "venv", ".venv", "env", "__pycache__",
21
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".idea", ".vscode",
22
+ "dist", "build", ".next", ".nuxt", "target", "coverage", ".eggs", ".cache",
23
+ "site-packages",
24
+ }
25
+ )
26
+
27
+ # Non-text files that would only waste tokens.
28
+ BINARY_SUFFIXES: frozenset[str] = frozenset(
29
+ {
30
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg", ".pdf", ".zip", ".tar",
31
+ ".gz", ".7z", ".rar", ".exe", ".dll", ".so", ".dylib", ".pyc", ".pyo", ".class",
32
+ ".jar", ".bin", ".woff", ".woff2", ".ttf", ".mp3", ".mp4", ".mov", ".sqlite",
33
+ ".db", ".lock", ".pt", ".pth", ".onnx", ".parquet", ".npy", ".npz",
34
+ }
35
+ )
36
+
37
+ SECRET_SUFFIXES: frozenset[str] = frozenset({".pem", ".key", ".p12", ".pfx"})
38
+ ENV_TEMPLATE_NAMES: frozenset[str] = frozenset({".env.example", ".env.sample", ".env.template"})
39
+
40
+ # Priority-ordered file names treated as "key files" (first match wins the budget).
41
+ KEY_FILE_NAMES: tuple[str, ...] = (
42
+ "README.md", "README.rst", "README.txt", "README",
43
+ "pyproject.toml", "package.json", "requirements.txt", "setup.py", "setup.cfg", "Pipfile",
44
+ "tsconfig.json", "vite.config.ts", "vite.config.js", "next.config.js",
45
+ "Dockerfile", "docker-compose.yml", "Makefile", ".env.example",
46
+ "main.py", "app.py", "manage.py", "__main__.py", "cli.py",
47
+ "index.js", "index.ts", "server.js", "main.tsx", "App.tsx", "App.jsx",
48
+ )
49
+
50
+ MAX_FILE_BYTES = 1_000_000
51
+ MAX_OUTLINE_FILES = 15
52
+ MAX_OUTLINE_LINES_PER_FILE = 40
53
+
54
+
55
+ def estimate_tokens(text: str) -> int:
56
+ """Cheap token estimate (~4 characters per token); good enough for budgeting."""
57
+ return max(1, len(text) // 4) if text else 0
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class KeyFile:
62
+ """A key project file and its (possibly truncated) content."""
63
+
64
+ path: str
65
+ content: str
66
+ truncated: bool = False
67
+
68
+
69
+ @dataclass
70
+ class WorkspaceContext:
71
+ """Snapshot of the local workspace handed to the compiler."""
72
+
73
+ root: Path
74
+ tree_lines: list[str] = field(default_factory=list)
75
+ key_files: list[KeyFile] = field(default_factory=list)
76
+ outlines: dict[str, list[str]] = field(default_factory=dict)
77
+
78
+ @property
79
+ def tree(self) -> str:
80
+ """The file tree as printable text, rooted at the project directory name."""
81
+ return "\n".join([f"{self.root.name}/", *self.tree_lines])
82
+
83
+ def render(self) -> str:
84
+ """Full context block sent to the model."""
85
+ parts = [f"<file_tree>\n{self.tree}\n</file_tree>"]
86
+ if self.key_files:
87
+ files = "\n".join(
88
+ f'<file path="{kf.path}"{" truncated" if kf.truncated else ""}>\n'
89
+ f"{kf.content}\n</file>"
90
+ for kf in self.key_files
91
+ )
92
+ parts.append(f"<key_files>\n{files}\n</key_files>")
93
+ if self.outlines:
94
+ mods = "\n".join(
95
+ f'<module path="{path}">\n' + "\n".join(lines) + "\n</module>"
96
+ for path, lines in self.outlines.items()
97
+ )
98
+ parts.append(f"<code_outline>\n{mods}\n</code_outline>")
99
+ return "\n\n".join(parts)
100
+
101
+ def render_summary(self, max_tree_lines: int = 60) -> str:
102
+ """Compact, deterministic summary used to ground ``<codebase_context>``."""
103
+ tree = self.tree.splitlines()
104
+ if len(tree) > max_tree_lines:
105
+ hidden = len(tree) - max_tree_lines
106
+ tree = [*tree[:max_tree_lines], f"... ({hidden} more entries)"]
107
+ lines = ["File tree:", "```text", *tree, "```"]
108
+ if self.key_files:
109
+ lines += ["", "Key files: " + ", ".join(kf.path for kf in self.key_files)]
110
+ if self.outlines:
111
+ lines += ["", "Python modules (public API):"]
112
+ for path, outline in self.outlines.items():
113
+ lines.append(f"- {path}")
114
+ lines.extend(f" {entry}" for entry in outline[:12])
115
+ return "\n".join(lines)
116
+
117
+
118
+ def load_gitignore(root: Path) -> pathspec.GitIgnoreSpec | None:
119
+ """Parse the root ``.gitignore`` if present (nested ignore files are not read)."""
120
+ gitignore = root / ".gitignore"
121
+ if not gitignore.is_file():
122
+ return None
123
+ try:
124
+ lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
125
+ except OSError:
126
+ return None
127
+ return pathspec.GitIgnoreSpec.from_lines(lines)
128
+
129
+
130
+ def _is_secret(name: str) -> bool:
131
+ """Heuristic for files that may contain credentials; never read or list them."""
132
+ if name == ".env" or (name.startswith(".env.") and name not in ENV_TEMPLATE_NAMES):
133
+ return True
134
+ return (
135
+ Path(name).suffix.lower() in SECRET_SUFFIXES
136
+ or name.startswith("id_rsa")
137
+ or name == "credentials.json"
138
+ )
139
+
140
+
141
+ def _read_text(path: Path, max_chars: int) -> tuple[str, bool] | None:
142
+ """Read up to ``max_chars`` of a text file; return ``None`` for binary/unreadable."""
143
+ try:
144
+ size = path.stat().st_size
145
+ if size > MAX_FILE_BYTES:
146
+ return None
147
+ with path.open("rb") as fh:
148
+ data = fh.read(max_chars * 4 + 1)
149
+ except OSError:
150
+ return None
151
+ if b"\x00" in data[:1024]:
152
+ return None
153
+ text = data.decode("utf-8", errors="replace")
154
+ truncated = len(text) > max_chars or size > len(data)
155
+ return text[:max_chars], truncated
156
+
157
+
158
+ def outline_python(source: str, max_lines: int = MAX_OUTLINE_LINES_PER_FILE) -> list[str]:
159
+ """Extract classes, functions, and public methods from Python source via ``ast``."""
160
+ try:
161
+ tree = ast.parse(source)
162
+ except (SyntaxError, ValueError):
163
+ return []
164
+
165
+ def sig(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
166
+ prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
167
+ ret = f" -> {ast.unparse(node.returns)}" if node.returns else ""
168
+ return f"{prefix} {node.name}({ast.unparse(node.args)}){ret}"
169
+
170
+ def doc(node: ast.AST) -> str:
171
+ text = ast.get_docstring(node) # type: ignore[arg-type]
172
+ return f" # {text.splitlines()[0][:80]}" if text else ""
173
+
174
+ out: list[str] = []
175
+ for node in tree.body:
176
+ if isinstance(node, ast.ClassDef):
177
+ bases = f"({', '.join(ast.unparse(b) for b in node.bases)})" if node.bases else ""
178
+ out.append(f"class {node.name}{bases}:{doc(node)}")
179
+ for sub in node.body:
180
+ if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
181
+ if not sub.name.startswith("_") or sub.name == "__init__":
182
+ out.append(f" {sig(sub)}")
183
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
184
+ if not node.name.startswith("_"):
185
+ out.append(f"{sig(node)}{doc(node)}")
186
+ return out[:max_lines]
187
+
188
+
189
+ def _fit_lines(lines: list[str], token_budget: int) -> list[str]:
190
+ """Trim ``lines`` so their estimated token total fits the budget."""
191
+ kept: list[str] = []
192
+ used = 0
193
+ for i, line in enumerate(lines):
194
+ cost = estimate_tokens(line) + 1
195
+ if used + cost > token_budget:
196
+ kept.append(f"... ({len(lines) - i} more entries truncated)")
197
+ break
198
+ kept.append(line)
199
+ used += cost
200
+ return kept
201
+
202
+
203
+ class WorkspaceScanner:
204
+ """Crawls a directory and builds a :class:`WorkspaceContext`."""
205
+
206
+ def __init__(
207
+ self,
208
+ root: Path,
209
+ *,
210
+ max_tokens: int = 3000,
211
+ max_depth: int = 4,
212
+ max_entries: int = 400,
213
+ extra_ignore: Iterable[str] = (),
214
+ ) -> None:
215
+ self.root = root.resolve()
216
+ self.max_tokens = max_tokens
217
+ self.max_depth = max_depth
218
+ self.max_entries = max_entries
219
+ self.ignore_dirs = DEFAULT_IGNORE_DIRS | frozenset(extra_ignore)
220
+ self.gitignore = load_gitignore(self.root)
221
+ self._lines: list[str] = []
222
+ self._files: list[Path] = []
223
+ self._count = 0
224
+
225
+ # -- filtering ---------------------------------------------------------------------
226
+
227
+ def _skip(self, entry: Path, is_dir: bool) -> bool:
228
+ if entry.is_symlink(): # avoids loops and escaping the root
229
+ return True
230
+ name = entry.name
231
+ if is_dir:
232
+ if name in self.ignore_dirs or name.endswith(".egg-info"):
233
+ return True
234
+ elif _is_secret(name) or entry.suffix.lower() in BINARY_SUFFIXES:
235
+ return True
236
+ if self.gitignore is not None:
237
+ rel = entry.relative_to(self.root).as_posix()
238
+ return self.gitignore.match_file(rel + "/" if is_dir else rel)
239
+ return False
240
+
241
+ # -- tree --------------------------------------------------------------------------
242
+
243
+ def _walk(self, directory: Path, depth: int, prefix: str) -> None:
244
+ try:
245
+ entries = sorted(directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
246
+ except OSError:
247
+ return
248
+ visible = [(e, e.is_dir()) for e in entries]
249
+ visible = [(e, d) for e, d in visible if not self._skip(e, d)]
250
+ for i, (entry, is_dir) in enumerate(visible):
251
+ if self._count >= self.max_entries:
252
+ return
253
+ last = i == len(visible) - 1
254
+ self._lines.append(f"{prefix}{'└── ' if last else '├── '}{entry.name}{'/' if is_dir else ''}")
255
+ self._count += 1
256
+ if is_dir:
257
+ if depth < self.max_depth:
258
+ self._walk(entry, depth + 1, prefix + (" " if last else "│ "))
259
+ else:
260
+ self._files.append(entry.relative_to(self.root))
261
+
262
+ # -- key files & outlines ----------------------------------------------------------
263
+
264
+ def _select_key_files(self, token_budget: int) -> list[KeyFile]:
265
+ ranked: list[tuple[int, int, str, Path]] = []
266
+ for rel in self._files:
267
+ if len(rel.parts) <= 3 and rel.name in KEY_FILE_NAMES:
268
+ ranked.append((KEY_FILE_NAMES.index(rel.name), len(rel.parts), rel.as_posix(), rel))
269
+ ranked.sort()
270
+
271
+ selected: list[KeyFile] = []
272
+ remaining = token_budget
273
+ per_file_cap = max(200, token_budget // 2)
274
+ for *_, rel in ranked:
275
+ if remaining < 100:
276
+ break
277
+ budget = min(remaining, per_file_cap)
278
+ result = _read_text(self.root / rel, budget * 4)
279
+ if result is None:
280
+ continue
281
+ text, truncated = result
282
+ if not text.strip():
283
+ continue
284
+ selected.append(KeyFile(rel.as_posix(), text.rstrip(), truncated))
285
+ remaining -= estimate_tokens(text)
286
+ return selected
287
+
288
+ def _build_outlines(self, token_budget: int) -> dict[str, list[str]]:
289
+ candidates = sorted(
290
+ (p for p in self._files if p.suffix == ".py"), key=lambda p: (len(p.parts), p.as_posix())
291
+ )
292
+ outlines: dict[str, list[str]] = {}
293
+ remaining = token_budget
294
+ for rel in candidates:
295
+ if len(outlines) >= MAX_OUTLINE_FILES or remaining < 50:
296
+ break
297
+ result = _read_text(self.root / rel, 200_000)
298
+ if result is None:
299
+ continue
300
+ outline = outline_python(result[0])
301
+ if not outline:
302
+ continue
303
+ outlines[rel.as_posix()] = outline
304
+ remaining -= sum(estimate_tokens(line) + 1 for line in outline)
305
+ return outlines
306
+
307
+ # -- public ------------------------------------------------------------------------
308
+
309
+ def scan(self) -> WorkspaceContext:
310
+ """Run the scan and return a budgeted :class:`WorkspaceContext`."""
311
+ self._walk(self.root, 1, "")
312
+ tree_lines = _fit_lines(self._lines, int(self.max_tokens * 0.3))
313
+ key_files = self._select_key_files(int(self.max_tokens * 0.5))
314
+ used = sum(estimate_tokens(kf.content) for kf in key_files)
315
+ outline_budget = max(0, self.max_tokens - int(self.max_tokens * 0.3) - used)
316
+ outlines = self._build_outlines(outline_budget)
317
+ return WorkspaceContext(self.root, tree_lines, key_files, outlines)
318
+
319
+
320
+ def scan_workspace(root: Path | str | None = None, **kwargs: object) -> WorkspaceContext:
321
+ """Convenience wrapper: scan ``root`` (default: current directory)."""
322
+ return WorkspaceScanner(Path(root or Path.cwd()), **kwargs).scan() # type: ignore[arg-type]
promptpump/engine.py ADDED
@@ -0,0 +1,217 @@
1
+ """Core compiler: builds the payload, talks to Ollama, and post-processes the result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Protocol, Sequence
8
+
9
+ import requests
10
+
11
+ from .context import WorkspaceContext
12
+ from .templates import (
13
+ REQUIRED_TAGS,
14
+ build_system_prompt,
15
+ build_user_message,
16
+ get_stack,
17
+ )
18
+
19
+ DEFAULT_MODEL = "qwen2.5-coder:7b"
20
+ DEFAULT_HOST = "http://localhost:11434"
21
+
22
+ Message = dict[str, str]
23
+
24
+
25
+ class PromptPumpError(Exception):
26
+ """Base class for all PromptPump errors."""
27
+
28
+
29
+ class OllamaError(PromptPumpError):
30
+ """Raised when the local Ollama server cannot satisfy a request."""
31
+
32
+
33
+ class ChatClient(Protocol):
34
+ """Anything that can turn chat messages into a text reply (easy to fake in tests)."""
35
+
36
+ def chat(self, messages: list[Message]) -> str: ...
37
+
38
+
39
+ class OllamaClient:
40
+ """Minimal client for Ollama's ``/api/chat`` endpoint (non-streaming)."""
41
+
42
+ def __init__(
43
+ self,
44
+ host: str = DEFAULT_HOST,
45
+ model: str = DEFAULT_MODEL,
46
+ *,
47
+ timeout: float = 300.0,
48
+ temperature: float = 0.2,
49
+ seed: int = 42,
50
+ num_ctx: int = 8192,
51
+ ) -> None:
52
+ self.host = host.rstrip("/")
53
+ self.model = model
54
+ self.timeout = timeout
55
+ # Low temperature + fixed seed keeps runs as reproducible as a local LLM allows.
56
+ self.options = {"temperature": temperature, "seed": seed, "num_ctx": num_ctx}
57
+
58
+ def chat(self, messages: list[Message]) -> str:
59
+ """Send messages to the model and return the assistant text."""
60
+ payload = {
61
+ "model": self.model,
62
+ "messages": messages,
63
+ "stream": False,
64
+ "options": self.options,
65
+ }
66
+ try:
67
+ resp = requests.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
68
+ except requests.ConnectionError as exc:
69
+ raise OllamaError(
70
+ f"Cannot reach Ollama at {self.host}. Is it running? Start it with `ollama serve`."
71
+ ) from exc
72
+ except requests.Timeout as exc:
73
+ raise OllamaError(f"Ollama timed out after {self.timeout:.0f}s.") from exc
74
+
75
+ if resp.status_code == 404:
76
+ raise OllamaError(f"Model '{self.model}' not found. Run `ollama pull {self.model}`.")
77
+ if not resp.ok:
78
+ raise OllamaError(f"Ollama returned HTTP {resp.status_code}: {resp.text[:200]}")
79
+ try:
80
+ return str(resp.json()["message"]["content"])
81
+ except (ValueError, KeyError, TypeError) as exc:
82
+ raise OllamaError("Unexpected response format from Ollama.") from exc
83
+
84
+ def list_models(self) -> list[str]:
85
+ """Return the names of locally installed models."""
86
+ try:
87
+ resp = requests.get(f"{self.host}/api/tags", timeout=10)
88
+ resp.raise_for_status()
89
+ return [m["name"] for m in resp.json().get("models", [])]
90
+ except (requests.RequestException, ValueError, KeyError) as exc:
91
+ raise OllamaError(f"Cannot reach Ollama at {self.host}.") from exc
92
+
93
+
94
+ # ---------------------------------------------------------------------------------------
95
+ # Post-processing
96
+ # ---------------------------------------------------------------------------------------
97
+
98
+ _THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
99
+ _FENCE_RE = re.compile(r"^```[\w-]*\s*\n(.*?)\n?```\s*$", re.DOTALL)
100
+ _CONTEXT_RE = re.compile(r"<codebase_context>.*?</codebase_context>", re.DOTALL)
101
+
102
+
103
+ def clean_response(raw: str) -> str:
104
+ """Strip reasoning blocks, conversational filler, and wrapping code fences."""
105
+ text = _THINK_RE.sub("", raw).strip()
106
+
107
+ first = text.find(f"<{REQUIRED_TAGS[0]}>")
108
+ if first > 0: # drop any preamble such as "Sure! Here's your prompt:"
109
+ text = text[first:]
110
+ closing = f"</{REQUIRED_TAGS[-1]}>"
111
+ last = text.rfind(closing)
112
+ if last != -1: # drop trailing remarks such as "Let me know if..."
113
+ text = text[: last + len(closing)]
114
+
115
+ fenced = _FENCE_RE.match(text.strip())
116
+ if fenced:
117
+ text = fenced.group(1)
118
+ return text.strip()
119
+
120
+
121
+ def find_missing_tags(text: str) -> list[str]:
122
+ """Return required tags whose opening or closing marker is absent."""
123
+ return [t for t in REQUIRED_TAGS if f"<{t}>" not in text or f"</{t}>" not in text]
124
+
125
+
126
+ def ground_codebase_context(prompt: str, summary: str) -> str:
127
+ """Replace the model-written ``<codebase_context>`` with deterministic facts."""
128
+ block = f"<codebase_context>\n{summary}\n</codebase_context>"
129
+ return _CONTEXT_RE.sub(lambda _m: block, prompt, count=1)
130
+
131
+
132
+ # ---------------------------------------------------------------------------------------
133
+ # Compiler
134
+ # ---------------------------------------------------------------------------------------
135
+
136
+
137
+ @dataclass
138
+ class CompileResult:
139
+ """Outcome of a compilation run."""
140
+
141
+ prompt: str
142
+ raw: str
143
+ model: str
144
+ attempts: int
145
+ grounded: bool
146
+ missing_tags: list[str] = field(default_factory=list)
147
+
148
+ @property
149
+ def is_valid(self) -> bool:
150
+ return not self.missing_tags
151
+
152
+
153
+ class PromptCompiler:
154
+ """Combines shorthand + workspace + template rules and runs the local model."""
155
+
156
+ def __init__(
157
+ self, client: ChatClient, *, ground_context: bool = True, max_repairs: int = 1
158
+ ) -> None:
159
+ self.client = client
160
+ self.ground_context = ground_context
161
+ self.max_repairs = max_repairs
162
+
163
+ def build_messages(
164
+ self, shorthand: str, context: WorkspaceContext, stacks: Sequence[str] = ()
165
+ ) -> list[Message]:
166
+ """Assemble the system + user messages (also used by ``--dry-run``)."""
167
+ profiles = list(dict.fromkeys(get_stack(s) for s in stacks))
168
+ return [
169
+ {"role": "system", "content": build_system_prompt(profiles)},
170
+ {"role": "user", "content": build_user_message(shorthand, context.render())},
171
+ ]
172
+
173
+ def compile(
174
+ self, shorthand: str, context: WorkspaceContext, stacks: Sequence[str] = ()
175
+ ) -> CompileResult:
176
+ """Compile ``shorthand`` into a validated master prompt."""
177
+ if not shorthand.strip():
178
+ raise PromptPumpError("Shorthand prompt must not be empty.")
179
+
180
+ messages = self.build_messages(shorthand, context, stacks)
181
+ raw = self.client.chat(messages)
182
+ prompt = clean_response(raw)
183
+ missing = find_missing_tags(prompt)
184
+ attempts = 1
185
+
186
+ # One-shot repair loop: show the model what it missed and ask for a full redo.
187
+ while missing and attempts <= self.max_repairs:
188
+ messages = [
189
+ *messages,
190
+ {"role": "assistant", "content": raw},
191
+ {
192
+ "role": "user",
193
+ "content": (
194
+ f"Your output is missing or has unclosed tags: {', '.join(missing)}. "
195
+ "Re-output the COMPLETE master prompt with all seven tags in order. "
196
+ "Output only the prompt."
197
+ ),
198
+ },
199
+ ]
200
+ raw = self.client.chat(messages)
201
+ prompt = clean_response(raw)
202
+ missing = find_missing_tags(prompt)
203
+ attempts += 1
204
+
205
+ grounded = False
206
+ if self.ground_context and "<codebase_context>" in prompt:
207
+ prompt = ground_codebase_context(prompt, context.render_summary())
208
+ grounded = True
209
+
210
+ return CompileResult(
211
+ prompt=prompt,
212
+ raw=raw,
213
+ model=str(getattr(self.client, "model", "unknown")),
214
+ attempts=attempts,
215
+ grounded=grounded,
216
+ missing_tags=missing,
217
+ )
@@ -0,0 +1,232 @@
1
+ """Structural schema, system prompt, and stack-specific rules.
2
+
3
+ Everything in this module is static data plus pure functions, so the same inputs
4
+ always produce the same system prompt (the deterministic half of PromptPump).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Sequence
11
+
12
+ # The seven tags every compiled master prompt must contain, in this exact order.
13
+ REQUIRED_TAGS: tuple[str, ...] = (
14
+ "role_definition",
15
+ "task_objective",
16
+ "codebase_context",
17
+ "architectural_specifications",
18
+ "implementation_steps",
19
+ "constraints_and_guardrails",
20
+ "expected_output_format",
21
+ )
22
+
23
+
24
+ class UnknownStackError(ValueError):
25
+ """Raised when a --stack value does not match a known profile."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class StackProfile:
30
+ """Domain-specific rules injected into the system prompt."""
31
+
32
+ name: str
33
+ label: str
34
+ rules: tuple[str, ...]
35
+
36
+
37
+ STACKS: dict[str, StackProfile] = {
38
+ p.name: p
39
+ for p in (
40
+ StackProfile(
41
+ "python",
42
+ "Python",
43
+ (
44
+ "Target Python 3.10+; use type hints on every public function and `pathlib` for paths.",
45
+ "Prefer the standard library; justify any new third-party dependency.",
46
+ "Follow PEP 8, use dataclasses or typed models for structured data, and raise specific exceptions.",
47
+ "Require pytest tests for new behaviour, including at least one failure-path test.",
48
+ ),
49
+ ),
50
+ StackProfile(
51
+ "fastapi",
52
+ "FastAPI",
53
+ (
54
+ "Define request/response bodies as Pydantic models; never return raw dicts from routes.",
55
+ "Split code into routers, schemas, services, and dependencies; keep route handlers thin.",
56
+ "Use dependency injection (`Depends`) for DB sessions, auth, and settings.",
57
+ "Use async handlers only when the underlying I/O is async; declare explicit status codes and error responses.",
58
+ "Add tests with `TestClient` or `httpx.AsyncClient` covering success and validation errors.",
59
+ ),
60
+ ),
61
+ StackProfile(
62
+ "django",
63
+ "Django",
64
+ (
65
+ "Keep business logic in models/services, not views; use class-based or function views consistently with the project.",
66
+ "Every model change requires a migration; never edit applied migrations.",
67
+ "Use the ORM with `select_related`/`prefetch_related` to avoid N+1 queries.",
68
+ "Read configuration from settings/environment; never hard-code secrets.",
69
+ ),
70
+ ),
71
+ StackProfile(
72
+ "react",
73
+ "React",
74
+ (
75
+ "Use function components and hooks only; keep components small and single-purpose.",
76
+ "Colocate state with the lowest common parent; lift or use context only when needed.",
77
+ "Type props explicitly (TypeScript if the project uses it) and handle loading, error, and empty states.",
78
+ "Avoid unnecessary re-renders: stable keys, `useMemo`/`useCallback` only where measured or obviously needed.",
79
+ "Cover behaviour with React Testing Library tests that query by role/label, not implementation details.",
80
+ ),
81
+ ),
82
+ StackProfile(
83
+ "nextjs",
84
+ "Next.js",
85
+ (
86
+ "Respect the project's router (App Router vs Pages Router) and do not mix them.",
87
+ "Default to Server Components; add `use client` only where interactivity requires it.",
88
+ "Fetch data on the server where possible; validate all inputs in route handlers.",
89
+ ),
90
+ ),
91
+ StackProfile(
92
+ "node",
93
+ "Node.js",
94
+ (
95
+ "Match the project's module system (ESM or CommonJS) and package manager.",
96
+ "Use async/await with centralized error handling; never swallow rejected promises.",
97
+ "Validate external input at the boundary and read config from environment variables.",
98
+ ),
99
+ ),
100
+ StackProfile(
101
+ "pygame",
102
+ "Pygame",
103
+ (
104
+ "Structure the game around a single main loop: handle events, update state, then render, in that order.",
105
+ "Use delta time (`clock.tick(fps) / 1000`) for movement; never tie speed to frame rate.",
106
+ "Separate game state/logic from rendering; model entities as classes (or `pygame.sprite.Sprite` subclasses).",
107
+ "Load assets once at startup, never inside the loop; call `pygame.quit()` on exit.",
108
+ "Keep constants (screen size, FPS, colours) in one config location.",
109
+ ),
110
+ ),
111
+ StackProfile(
112
+ "pytorch",
113
+ "PyTorch",
114
+ (
115
+ "Separate dataset, model, training loop, and evaluation into distinct modules.",
116
+ "Seed all RNGs, set `model.train()`/`model.eval()` explicitly, and wrap inference in `torch.no_grad()`.",
117
+ "Make device placement explicit and configurable; log metrics per epoch and checkpoint the best model.",
118
+ ),
119
+ ),
120
+ )
121
+ }
122
+
123
+ # Friendly aliases accepted on the command line.
124
+ STACK_ALIASES: dict[str, str] = {
125
+ "py": "python",
126
+ "fast-api": "fastapi",
127
+ "reactjs": "react",
128
+ "next": "nextjs",
129
+ "next.js": "nextjs",
130
+ "nodejs": "node",
131
+ "torch": "pytorch",
132
+ }
133
+
134
+
135
+ def get_stack(name: str) -> StackProfile:
136
+ """Resolve a stack name or alias (case-insensitive) to its profile."""
137
+ key = name.strip().lower()
138
+ key = STACK_ALIASES.get(key, key)
139
+ try:
140
+ return STACKS[key]
141
+ except KeyError:
142
+ available = ", ".join(sorted(STACKS))
143
+ raise UnknownStackError(f"Unknown stack '{name}'. Available stacks: {available}") from None
144
+
145
+
146
+ def list_stacks() -> list[StackProfile]:
147
+ """Return all known stack profiles sorted by name."""
148
+ return [STACKS[k] for k in sorted(STACKS)]
149
+
150
+
151
+ SYSTEM_PROMPT_TEMPLATE = """\
152
+ You are PromptPump, a deterministic prompt compiler for software engineers.
153
+
154
+ Your job: convert a short shorthand request plus workspace context into ONE high-density \
155
+ "master prompt" that another coding LLM will execute. You do NOT write the implementation \
156
+ and you do NOT answer the request yourself.
157
+
158
+ OUTPUT CONTRACT (strict):
159
+ 1. Output ONLY the master prompt. No greeting, no explanation, no code fences around the \
160
+ whole output, no closing remarks.
161
+ 2. The output MUST contain exactly these seven XML tags, once each, in this order:
162
+ {{TAG_LIST}}
163
+ 3. Inside each tag use terse, imperative Markdown (bullets or numbered lists). No filler.
164
+ 4. Ground everything in the <workspace> block. Reference only files, modules, and \
165
+ dependencies that appear there. If something is unknown, record it as an explicit \
166
+ assumption inside <constraints_and_guardrails>. Never invent file paths or APIs.
167
+ 5. Do not copy the shorthand verbatim. Expand it into concrete, testable requirements.
168
+ 6. REUSE BEFORE CREATE: if the workspace outline already contains a function or class that \
169
+ does part of the job, name it and extend or call it. Never propose a duplicate module or helper.
170
+ 7. NO IMPLEMENTATION CODE: name files, functions, and signatures, but do not write function \
171
+ bodies or test bodies inside the prompt.
172
+ 8. TARGET THE MECHANISM: state HOW the behaviour is achieved (for example: change the \
173
+ generation prompt vs post-process the answer), not just what the end result should look like.
174
+ 9. UNKNOWNS: the workspace snapshot may be truncated. Any file, test, model, or API you cannot \
175
+ see must be listed under "Assumptions (verify before coding)" in <constraints_and_guardrails>.
176
+ 10. STAY IN SCOPE: add no features, flags, files, or dependencies beyond the request. If the \
177
+ request contains contradictory or ambiguous parts, resolve them explicitly in the assumptions \
178
+ instead of copying the contradiction.
179
+
180
+ SECTION RULES:
181
+ <role_definition>: One short paragraph assigning the executing LLM a precise senior-engineer \
182
+ persona matched to the languages and frameworks visible in the workspace.
183
+ <task_objective>: One unambiguous, measurable objective, followed by success criteria and \
184
+ explicit out-of-scope items.
185
+ <codebase_context>: The facts from the workspace that matter: language, frameworks, entry \
186
+ points, and the files most likely to change.
187
+ <architectural_specifications>: Design decisions: files/modules to create or modify, data \
188
+ flow, key interfaces and signatures, naming and style conventions that match existing code.
189
+ <implementation_steps>: Ordered, numbered, small, verifiable steps; each names its target file.
190
+ <constraints_and_guardrails>: Hard rules: dependencies, compatibility, error handling, \
191
+ security, performance, testing, files not to touch, then an "Assumptions (verify before \
192
+ coding)" list.
193
+ <expected_output_format>: Exactly how the executing LLM must reply (for example: full file \
194
+ contents per path in fenced blocks, then a brief test plan). Nothing else.
195
+
196
+ {{STACK_SECTION}}"""
197
+
198
+
199
+ def render_tag_list() -> str:
200
+ """Render the required tags as a numbered list for the system prompt."""
201
+ return "\n".join(f" {i}. <{tag}>...</{tag}>" for i, tag in enumerate(REQUIRED_TAGS, 1))
202
+
203
+
204
+ def render_stack_section(stacks: Sequence[StackProfile]) -> str:
205
+ """Render stack-specific rules, or an explicit 'none' marker."""
206
+ if not stacks:
207
+ return "STACK RULES: none specified. Infer conventions from the workspace only."
208
+ lines = [
209
+ "STACK RULES (defaults: apply them in <architectural_specifications> and "
210
+ "<constraints_and_guardrails>, but skip any that conflict with conventions "
211
+ "visible in the workspace):"
212
+ ]
213
+ for profile in stacks:
214
+ lines.append(f"[{profile.label}]")
215
+ lines.extend(f"- {rule}" for rule in profile.rules)
216
+ return "\n".join(lines)
217
+
218
+
219
+ def build_system_prompt(stacks: Sequence[StackProfile] = ()) -> str:
220
+ """Assemble the full system prompt for the local model."""
221
+ return SYSTEM_PROMPT_TEMPLATE.replace("{{TAG_LIST}}", render_tag_list()).replace(
222
+ "{{STACK_SECTION}}", render_stack_section(stacks)
223
+ )
224
+
225
+
226
+ def build_user_message(shorthand: str, workspace_text: str) -> str:
227
+ """Wrap the shorthand request and workspace snapshot into the user turn."""
228
+ return (
229
+ f"<shorthand_request>\n{shorthand.strip()}\n</shorthand_request>\n\n"
230
+ f"<workspace>\n{workspace_text.strip()}\n</workspace>\n\n"
231
+ "Compile the master prompt now. Output only the seven XML-tagged sections."
232
+ )
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: promptpump
3
+ Version: 0.1.0
4
+ Summary: Deterministic context-engineering engine and prompt compiler powered by a local Ollama model.
5
+ Author: Ritesh Nipane
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/RiteshNipane/PromptPump
8
+ Project-URL: Issues, https://github.com/RiteshNipane/PromptPump/issues
9
+ Keywords: prompt-engineering,ollama,llm,cli,context-engineering
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: typer>=0.12
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: requests>=2.31
26
+ Requires-Dist: pyperclip>=1.8
27
+ Requires-Dist: pathspec>=0.11
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.4; extra == "dev"
30
+ Requires-Dist: ruff>=0.4; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # PromptPump
34
+
35
+ A local CLI tool and Python library that turns a short shorthand prompt into a
36
+ high-density, production-ready **master prompt**. It scans your project (file tree,
37
+ README/config snippets, Python AST outlines), merges that with a strict structural
38
+ schema, and compiles it with a local model (Ollama + Qwen2.5-Coder). Nothing leaves
39
+ your machine.
40
+
41
+ ## Install
42
+
43
+ Requires Python 3.10+ and [Ollama](https://ollama.com) (Windows, macOS, Linux).
44
+
45
+ ```bash
46
+ ollama pull qwen2.5-coder:7b # or qwen2.5-coder:14b for better quality
47
+ pip install pipx && pipx ensurepath # once; then reopen your terminal
48
+ pipx install promptpump # works in PowerShell, cmd, bash, zsh
49
+ promptpump doctor # verify Ollama + model
50
+ ```
51
+
52
+ Plain `pip install promptpump` also works. On Linux, clipboard support needs `xclip` or `xsel`.
53
+
54
+ Develop from source: `pip install -e ".[dev]"`.
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ promptpump doctor # verify Ollama + model
60
+ promptpump stacks # list stack profiles
61
+ promptpump run "add jwt auth to the api" --stack fastapi --copy
62
+ promptpump run "add a pause menu" --stack pygame --stack python --path ../mygame
63
+ promptpump run "refactor db layer" --dry-run # inspect the payload, no model call
64
+ promptpump run "add caching" --no-copy > prompt.md # progress goes to stderr, prompt to stdout
65
+ ```
66
+
67
+ | Option | Description |
68
+ | --- | --- |
69
+ | `--stack/-s` | Inject stack rules (repeatable): python, fastapi, django, react, nextjs, node, pygame, pytorch |
70
+ | `--copy/--no-copy` | Copy to clipboard (default: on) |
71
+ | `--path/-p` | Project directory to scan (default: `.`) |
72
+ | `--model/-m` | Ollama model (env `PROMPTPUMP_MODEL`) |
73
+ | `--host` | Ollama URL (env `PROMPTPUMP_HOST`) |
74
+ | `--max-context-tokens` | Workspace context budget (default 3000) |
75
+ | `--ground/--no-ground` | Replace the model's `<codebase_context>` with deterministic scan facts (default: on) |
76
+ | `--dry-run` | Print the system/user payload and exit |
77
+
78
+ ## Library use
79
+
80
+ ```python
81
+ from promptpump import OllamaClient, PromptCompiler, scan_workspace
82
+
83
+ ctx = scan_workspace(".")
84
+ result = PromptCompiler(OllamaClient()).compile("add rate limiting", ctx, ["fastapi"])
85
+ print(result.prompt)
86
+ ```
87
+
88
+ ## How it stays deterministic
89
+
90
+ - Fixed system prompt and stack rules (`templates.py`); temperature 0.2 and fixed seed.
91
+ - Output validated against the seven required tags, with one automatic repair retry.
92
+ - Conversational filler and code fences stripped; `<codebase_context>` overwritten with real scan data so the model cannot hallucinate files.
93
+ - Secrets (`.env`, keys), binaries, and `.gitignore`d paths are never read.
94
+
95
+ ## Tests
96
+
97
+ ```bash
98
+ pytest
99
+ ```
100
+
101
+ ## Limitations
102
+
103
+ Only the root `.gitignore` is parsed; nested ignore files are not.
@@ -0,0 +1,11 @@
1
+ promptpump/__init__.py,sha256=TCWmbRwkpwV4-qOKh0dEp93PZAFcRbcP2ZPgUn2EQnQ,408
2
+ promptpump/cli.py,sha256=3XSJoc1e4owDyC6tP2jmFmSMIyLvtVI9LjTKJeEmYTE,6526
3
+ promptpump/context.py,sha256=aXDYNTgxOyhAagSzLWqWFSGLpTn3gS6agUE97AbBwNk,12924
4
+ promptpump/engine.py,sha256=d8YoaX7jVTv5WdhWBbCqfDT2DHQ0Bq4a9FzR1G6oGJM,7671
5
+ promptpump/templates.py,sha256=GGRcNQvqo6M39NUuG09zxbQNMCFkKiQVkA0JIJ4tzXY,10609
6
+ promptpump-0.1.0.dist-info/licenses/LICENSE,sha256=RTV018ZVG9hNOJQD_Hk-r3GrMgpuJcZ7Ka451K00SgA,1070
7
+ promptpump-0.1.0.dist-info/METADATA,sha256=I7QYtInQ-sLM_8nbVS0UVu9PK6FNd73D91kA8Q15Nhc,4106
8
+ promptpump-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ promptpump-0.1.0.dist-info/entry_points.txt,sha256=AVHnv6655-sjTVBdtpdGd2fOKcyOiHrWx3X4BgXjpmE,50
10
+ promptpump-0.1.0.dist-info/top_level.txt,sha256=WvQM6aRNXWYOyOlfJ1ApgLQ3Xy0nphpyFPFgC3euEAM,11
11
+ promptpump-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ promptpump = promptpump.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritesh Nipane
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ promptpump