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 +1 -0
- rapt0r/__main__.py +4 -0
- rapt0r/core/__init__.py +0 -0
- rapt0r/core/builder.py +20 -0
- rapt0r/core/context_vars.py +97 -0
- rapt0r/core/llm.py +123 -0
- rapt0r/core/loader.py +137 -0
- rapt0r/core/output.py +62 -0
- rapt0r/core/utils.py +54 -0
- rapt0r/main.py +471 -0
- rapt0r/templates/coding/api-design.md +42 -0
- rapt0r/templates/coding/code-review.md +41 -0
- rapt0r/templates/coding/commit.md +59 -0
- rapt0r/templates/coding/debug.md +35 -0
- rapt0r/templates/coding/explain.md +36 -0
- rapt0r/templates/coding/new-feature.md +33 -0
- rapt0r/templates/coding/performance.md +35 -0
- rapt0r/templates/coding/refactor.md +36 -0
- rapt0r/templates/coding/security.md +39 -0
- rapt0r/templates/coding/tests.md +40 -0
- rapt0r/templates/content/brainstorm.md +39 -0
- rapt0r/templates/content/documentation.md +23 -0
- rapt0r/ui.py +115 -0
- rapt0r_cli-1.0.0.dist-info/METADATA +172 -0
- rapt0r_cli-1.0.0.dist-info/RECORD +29 -0
- rapt0r_cli-1.0.0.dist-info/WHEEL +5 -0
- rapt0r_cli-1.0.0.dist-info/entry_points.txt +2 -0
- rapt0r_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- rapt0r_cli-1.0.0.dist-info/top_level.txt +1 -0
rapt0r/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
rapt0r/__main__.py
ADDED
rapt0r/core/__init__.py
ADDED
|
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}"
|