rapt0r-cli 1.0.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.
rapt0r/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
rapt0r/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from rapt0r.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
File without changes
rapt0r/core/builder.py ADDED
@@ -0,0 +1,20 @@
1
+ import logging
2
+ from jinja2 import Environment, BaseLoader, DebugUndefined
3
+
4
+ from rapt0r.core.utils import DEFAULT_BODY
5
+ from rapt0r.core.context_vars import extract_context_vars, resolve_context_vars
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ _env = Environment(loader=BaseLoader(), undefined=DebugUndefined, keep_trailing_newline=True)
10
+
11
+
12
+ def build_prompt(bodies, answers, body_key=None):
13
+ key = body_key if body_key and body_key in bodies else DEFAULT_BODY
14
+ if key not in bodies:
15
+ logger.warning(f"Body section {key!r} not found, using first available")
16
+ key = next(iter(bodies))
17
+ vars_ = {k: v for k, v in answers.items() if not k.startswith("__")}
18
+ body, pending = extract_context_vars(bodies[key])
19
+ result = _env.from_string(body).render(**vars_)
20
+ return resolve_context_vars(result, pending)
@@ -0,0 +1,97 @@
1
+ """Dynamic context variables: {{git_diff}}, {{tree}}, {{tree:path}}, {{file:path}}.
2
+
3
+ The colon forms are not valid Jinja2 syntax, and injected content may itself
4
+ contain ``{{``, so these tokens are swapped for opaque sentinels before the
5
+ Jinja2 render and resolved afterwards (see builder.build_prompt).
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import subprocess
11
+
12
+ MAX_BYTES = 30000
13
+ MAX_TREE_ENTRIES = 500
14
+ SKIP_DIRS = {"__pycache__", "node_modules", "venv", ".venv"}
15
+
16
+ CONTEXT_VAR_NAMES = {"git_diff", "tree", "file"}
17
+
18
+ _PATTERN = re.compile(r"\{\{\s*(git_diff|tree|file)(?::([^}]+))?\s*\}\}")
19
+
20
+
21
+ def _truncate(text):
22
+ if len(text) > MAX_BYTES:
23
+ return text[:MAX_BYTES] + "\n... [truncated]"
24
+ return text
25
+
26
+
27
+ def _git_diff():
28
+ try:
29
+ out = subprocess.run(
30
+ ["git", "diff", "HEAD"], capture_output=True, text=True, timeout=10
31
+ )
32
+ except (OSError, subprocess.TimeoutExpired):
33
+ return "[git diff unavailable]"
34
+ if out.returncode != 0:
35
+ return "[git diff unavailable: not a git repository?]"
36
+ return _truncate(out.stdout.strip()) or "[no uncommitted changes]"
37
+
38
+
39
+ def _tree(root):
40
+ root = (root or ".").strip() or "."
41
+ if not os.path.isdir(root):
42
+ return f"[directory not found: {root}]"
43
+ lines = []
44
+ for dirpath, dirnames, filenames in os.walk(root):
45
+ dirnames[:] = sorted(
46
+ d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS
47
+ )
48
+ for f in sorted(filenames):
49
+ lines.append(os.path.relpath(os.path.join(dirpath, f), root))
50
+ if len(lines) >= MAX_TREE_ENTRIES:
51
+ lines.append("... [truncated]")
52
+ return "\n".join(lines)
53
+ return "\n".join(lines) or "[empty directory]"
54
+
55
+
56
+ def _file(path):
57
+ path = (path or "").strip()
58
+ try:
59
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
60
+ return _truncate(f.read())
61
+ except OSError as e:
62
+ return f"[could not read {path}: {e}]"
63
+
64
+
65
+ def _resolve(kind, arg):
66
+ if kind == "git_diff":
67
+ return _git_diff()
68
+ if kind == "tree":
69
+ return _tree(arg)
70
+ if kind == "file":
71
+ return _file(arg)
72
+ return ""
73
+
74
+
75
+ def extract_context_vars(text):
76
+ """Replace context tokens with sentinels safe to pass through Jinja2."""
77
+ pending = {}
78
+
79
+ def repl(m):
80
+ token = f"\x00CTX{len(pending)}\x00"
81
+ pending[token] = (m.group(1), m.group(2))
82
+ return token
83
+
84
+ return _PATTERN.sub(repl, text), pending
85
+
86
+
87
+ def resolve_context_vars(text, pending):
88
+ """Substitute sentinels with their resolved content after the render."""
89
+ for token, (kind, arg) in pending.items():
90
+ text = text.replace(token, _resolve(kind, arg))
91
+ return text
92
+
93
+
94
+ def expand_context_vars(text):
95
+ """One-shot expansion (extract + resolve), used directly in tests."""
96
+ extracted, pending = extract_context_vars(text)
97
+ return resolve_context_vars(extracted, pending)
rapt0r/core/llm.py ADDED
@@ -0,0 +1,123 @@
1
+ """Stream a built prompt to an LLM over raw HTTP (no SDK dependencies).
2
+
3
+ Providers are detected from environment variables:
4
+ ANTHROPIC_API_KEY -> Anthropic Messages API
5
+ OPENAI_API_KEY -> OpenAI Chat Completions API
6
+
7
+ Model overrides: RAPT0R_ANTHROPIC_MODEL, RAPT0R_OPENAI_MODEL.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import urllib.error
13
+ import urllib.request
14
+
15
+ ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
16
+ OPENAI_URL = "https://api.openai.com/v1/chat/completions"
17
+
18
+ DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-8"
19
+ DEFAULT_OPENAI_MODEL = "gpt-5-codex"
20
+
21
+ TIMEOUT = 600
22
+
23
+
24
+ class LLMError(Exception):
25
+ pass
26
+
27
+
28
+ def available_providers():
29
+ providers = []
30
+ if os.environ.get("ANTHROPIC_API_KEY"):
31
+ providers.append("anthropic")
32
+ if os.environ.get("OPENAI_API_KEY"):
33
+ providers.append("openai")
34
+ return providers
35
+
36
+
37
+ def iter_sse_data(lines):
38
+ """Yield parsed JSON payloads from an iterable of raw SSE byte lines."""
39
+ for raw in lines:
40
+ line = raw.decode("utf-8", errors="replace").strip()
41
+ if not line.startswith("data:"):
42
+ continue
43
+ data = line[len("data:"):].strip()
44
+ if data == "[DONE]":
45
+ return
46
+ try:
47
+ yield json.loads(data)
48
+ except json.JSONDecodeError:
49
+ continue
50
+
51
+
52
+ def _post_stream(url, headers, body):
53
+ req = urllib.request.Request(
54
+ url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST"
55
+ )
56
+ try:
57
+ return urllib.request.urlopen(req, timeout=TIMEOUT)
58
+ except urllib.error.HTTPError as e:
59
+ detail = e.read().decode("utf-8", errors="replace")[:500]
60
+ raise LLMError(f"HTTP {e.code}: {detail}") from e
61
+ except urllib.error.URLError as e:
62
+ raise LLMError(f"Network error: {e.reason}") from e
63
+
64
+
65
+ def _stream_anthropic(prompt):
66
+ model = os.environ.get("RAPT0R_ANTHROPIC_MODEL", DEFAULT_ANTHROPIC_MODEL)
67
+ resp = _post_stream(
68
+ ANTHROPIC_URL,
69
+ {
70
+ "x-api-key": os.environ["ANTHROPIC_API_KEY"],
71
+ "anthropic-version": "2023-06-01",
72
+ "content-type": "application/json",
73
+ },
74
+ {
75
+ "model": model,
76
+ "max_tokens": 64000,
77
+ "stream": True,
78
+ "thinking": {"type": "adaptive"},
79
+ "messages": [{"role": "user", "content": prompt}],
80
+ },
81
+ )
82
+ with resp:
83
+ for event in iter_sse_data(resp):
84
+ etype = event.get("type")
85
+ if etype == "content_block_delta":
86
+ delta = event.get("delta", {})
87
+ if delta.get("type") == "text_delta":
88
+ yield delta.get("text", "")
89
+ elif etype == "error":
90
+ raise LLMError(event.get("error", {}).get("message", "stream error"))
91
+
92
+
93
+ def _stream_openai(prompt):
94
+ model = os.environ.get("RAPT0R_OPENAI_MODEL", DEFAULT_OPENAI_MODEL)
95
+ resp = _post_stream(
96
+ OPENAI_URL,
97
+ {
98
+ "Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
99
+ "content-type": "application/json",
100
+ },
101
+ {
102
+ "model": model,
103
+ "stream": True,
104
+ "messages": [{"role": "user", "content": prompt}],
105
+ },
106
+ )
107
+ with resp:
108
+ for event in iter_sse_data(resp):
109
+ choices = event.get("choices") or []
110
+ if choices:
111
+ text = choices[0].get("delta", {}).get("content")
112
+ if text:
113
+ yield text
114
+
115
+
116
+ def stream_completion(prompt, provider):
117
+ """Yield response text chunks from the given provider."""
118
+ if provider == "anthropic":
119
+ yield from _stream_anthropic(prompt)
120
+ elif provider == "openai":
121
+ yield from _stream_openai(prompt)
122
+ else:
123
+ raise LLMError(f"Unknown provider: {provider}")
rapt0r/core/loader.py ADDED
@@ -0,0 +1,137 @@
1
+ import os
2
+ import re
3
+ import shutil
4
+ import yaml
5
+ import logging
6
+
7
+ from rapt0r.core.context_vars import CONTEXT_VAR_NAMES
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ BUNDLED_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "..", "templates")
12
+ USER_TEMPLATES_DIR = os.path.expanduser(os.path.join("~", ".rapt0r", "templates"))
13
+
14
+
15
+ def _resolve_templates_dir():
16
+ env = os.environ.get("RAPT0R_TEMPLATES_DIR")
17
+ if env:
18
+ return env
19
+ # Running from a writable checkout (dev mode) — use bundled dir directly.
20
+ if os.access(BUNDLED_TEMPLATES_DIR, os.W_OK):
21
+ return BUNDLED_TEMPLATES_DIR
22
+ # Installed package: seed user dir from bundled templates on first run.
23
+ if not os.path.isdir(USER_TEMPLATES_DIR):
24
+ try:
25
+ shutil.copytree(BUNDLED_TEMPLATES_DIR, USER_TEMPLATES_DIR)
26
+ except OSError as e:
27
+ logger.warning(f"Could not create {USER_TEMPLATES_DIR}: {e}")
28
+ return BUNDLED_TEMPLATES_DIR
29
+ return USER_TEMPLATES_DIR
30
+
31
+
32
+ TEMPLATES_DIR = _resolve_templates_dir()
33
+
34
+
35
+ def load_templates(templates_dir=None):
36
+ templates_dir = templates_dir or TEMPLATES_DIR
37
+ templates = []
38
+ seen_names = {}
39
+
40
+ def _load_dir(dirpath, category):
41
+ for filename in sorted(os.listdir(dirpath)):
42
+ filepath = os.path.join(dirpath, filename)
43
+ if not os.path.isfile(filepath) or not filename.endswith(".md"):
44
+ continue
45
+ try:
46
+ with open(filepath, "r", encoding="utf-8") as f:
47
+ raw = f.read()
48
+ except OSError as e:
49
+ logger.warning(f"Could not read {filename}: {e}")
50
+ continue
51
+
52
+ meta, bodies = parse_template(raw, filename)
53
+ if not meta:
54
+ continue
55
+
56
+ _validate_template(meta, bodies, filename)
57
+
58
+ name = meta.get("name", filename)
59
+ if name in seen_names:
60
+ logger.warning(
61
+ f"Duplicate name '{name}' in {filename} and {seen_names[name]}"
62
+ )
63
+ seen_names[name] = filename
64
+
65
+ templates.append({
66
+ "filename": filename,
67
+ "filepath": filepath,
68
+ "category": category,
69
+ "meta": meta,
70
+ "body": bodies,
71
+ })
72
+
73
+ # Root templates first (e.g. generate-template.md)
74
+ _load_dir(templates_dir, None)
75
+
76
+ # Category subdirectories
77
+ for dirname in sorted(os.listdir(templates_dir)):
78
+ dirpath = os.path.join(templates_dir, dirname)
79
+ if os.path.isdir(dirpath):
80
+ _load_dir(dirpath, dirname)
81
+
82
+ return templates
83
+
84
+
85
+ def parse_template(content, filename="?"):
86
+ match = re.match(r"^---\n(.*?)\n---\n(.*)$", content, re.DOTALL)
87
+ if not match:
88
+ return None, content
89
+
90
+ try:
91
+ meta = yaml.safe_load(match.group(1))
92
+ except yaml.YAMLError as e:
93
+ logger.warning(f"YAML error in {filename}: {e}")
94
+ return None, content
95
+
96
+ if not isinstance(meta, dict):
97
+ logger.warning(f"Invalid frontmatter in {filename}")
98
+ return None, content
99
+
100
+ body_raw = match.group(2).strip()
101
+ bodies = {}
102
+ parts = re.split(r"---body:(\w+)---", body_raw)
103
+
104
+ if len(parts) == 1:
105
+ bodies["default"] = parts[0].strip()
106
+ else:
107
+ for i in range(1, len(parts), 2):
108
+ if i + 1 < len(parts):
109
+ bodies[parts[i]] = parts[i + 1].strip()
110
+ else:
111
+ logger.warning(f"Empty body section body:{parts[i]} in {filename}")
112
+
113
+ return meta, bodies
114
+
115
+
116
+ def _validate_template(meta, bodies, filename):
117
+ branch = meta.get("branch")
118
+ if branch:
119
+ for side in ("y", "n"):
120
+ if side not in branch:
121
+ logger.warning(f"Branch '{side}' missing in {filename}")
122
+
123
+ # Collect all declared question keys
124
+ question_keys = set(meta.get("questions", {}).keys())
125
+ if branch:
126
+ for side in ("y", "n"):
127
+ branch_data = branch.get(side, {})
128
+ question_keys.update(branch_data.get("questions", {}).keys())
129
+
130
+ # Check for {{key}} in body that have no matching question
131
+ for body_name, body_text in bodies.items():
132
+ clean = re.sub(r"```.*?```", "", body_text, flags=re.DOTALL)
133
+ clean = re.sub(r"`[^`]+`", "", clean)
134
+ used_vars = set(re.findall(r"\{\{(\w+)\}\}", clean))
135
+ missing = used_vars - question_keys - CONTEXT_VAR_NAMES
136
+ if missing:
137
+ logger.warning(f"{filename}: variables without a question: {missing}")
rapt0r/core/output.py ADDED
@@ -0,0 +1,62 @@
1
+ import os
2
+ import pyperclip
3
+ from datetime import datetime
4
+ from rich.console import Console
5
+ from rich.markdown import Markdown
6
+ from rapt0r.core.utils import clear
7
+
8
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
+ _REPO_OUTPUT = os.path.join(_REPO_ROOT, "prompts", "temp")
10
+ _USER_OUTPUT = os.path.expanduser(os.path.join("~", ".rapt0r", "prompts"))
11
+
12
+
13
+ def _resolve_output_dir():
14
+ env = os.environ.get("RAPT0R_OUTPUT_DIR")
15
+ if env:
16
+ return env
17
+ # Dev checkout keeps its prompt history next to the code.
18
+ if os.path.isdir(os.path.join(_REPO_ROOT, "prompts")):
19
+ return _REPO_OUTPUT
20
+ return _USER_OUTPUT
21
+
22
+
23
+ OUTPUT_DIR = _resolve_output_dir()
24
+ console = Console()
25
+ SEP = "[bold #d8b4e2]" + "─" * 50 + "[/]"
26
+
27
+
28
+ def show_preview(prompt, context_file=None):
29
+ clear()
30
+ console.print("[bold #FFFDD0]PROMPT READY:[/]")
31
+ if context_file:
32
+ console.print(f"[bold #d8b4e2]project context attached: {context_file}[/]")
33
+ console.print(SEP)
34
+ console.print(Markdown(prompt))
35
+ console.print(SEP)
36
+
37
+
38
+ def save_prompt(prompt, name):
39
+ try:
40
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
41
+ timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
42
+ slug = name.lower().replace(" ", "-").replace("/", "-").strip("-")
43
+ base = f"{timestamp}-{slug}"
44
+ filepath = os.path.join(OUTPUT_DIR, f"{base}.md")
45
+ counter = 2
46
+ while os.path.exists(filepath):
47
+ filepath = os.path.join(OUTPUT_DIR, f"{base}-{counter}.md")
48
+ counter += 1
49
+ with open(filepath, "w", encoding="utf-8") as f:
50
+ f.write(prompt)
51
+ return filepath
52
+ except OSError as e:
53
+ console.print(f"[bold red]Save error:[/] {e}")
54
+ return None
55
+
56
+
57
+ def copy_to_clipboard(text):
58
+ try:
59
+ pyperclip.copy(text)
60
+ return True, None
61
+ except Exception as e:
62
+ return False, str(e)
rapt0r/core/utils.py ADDED
@@ -0,0 +1,54 @@
1
+ import os
2
+ import sys
3
+ import subprocess
4
+
5
+ BRANCH_KEY = "__branch__"
6
+ DEFAULT_BODY = "default"
7
+ PROJECT_CONTEXT_FILE = ".rapt0r"
8
+
9
+
10
+ def clear():
11
+ os.system("cls" if os.name == "nt" else "clear")
12
+
13
+
14
+ def pick(value, max_val):
15
+ if value.isdigit() and 0 <= int(value) <= max_val:
16
+ return int(value)
17
+ return None
18
+
19
+
20
+ def open_file(path):
21
+ if sys.platform == "darwin":
22
+ subprocess.run(["open", path])
23
+ elif sys.platform == "win32":
24
+ os.startfile(path)
25
+ else:
26
+ subprocess.run(["xdg-open", path])
27
+
28
+
29
+ def find_project_context(start_dir=None):
30
+ """Walk up from start_dir looking for a .rapt0r file.
31
+
32
+ Returns (filepath, content) or (None, None)."""
33
+ current = os.path.abspath(start_dir or os.getcwd())
34
+ while True:
35
+ candidate = os.path.join(current, PROJECT_CONTEXT_FILE)
36
+ if os.path.isfile(candidate):
37
+ try:
38
+ with open(candidate, "r", encoding="utf-8") as f:
39
+ content = f.read().strip()
40
+ if content:
41
+ return candidate, content
42
+ return None, None
43
+ except OSError:
44
+ return None, None
45
+ parent = os.path.dirname(current)
46
+ if parent == current:
47
+ return None, None
48
+ current = parent
49
+
50
+
51
+ def apply_project_context(prompt, context):
52
+ if not context:
53
+ return prompt
54
+ return f"{prompt}\n\n## Project context\n\n{context}"