conversession 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: conversession
3
+ Version: 2.0.0
4
+ Author-email: Jordi Carrera Ventura <jordi.carrera.ventura@gmail.com>
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Operating System :: OS Independent
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: build
11
+ Requires-Dist: twine
12
+ Requires-Dist: openai
13
+ Requires-Dist: python-dotenv
14
+ Requires-Dist: playwright
15
+ Requires-Dist: beautifulsoup4
16
+ Dynamic: license-file
17
+
18
+ # Conversession
19
+
20
+ Prompt management CLI application for Linux and MacOS.
21
+
22
+
23
+ # Build and publish
24
+
25
+ 1. Building the package before uploading: `python -m build # from "conversession"`.
26
+ 2. Upload the package to pypi: `python -m twine upload --repository {pypi|testpypi} dist/*`
27
+
28
+ If any dependencies are required, edit the `pyproject.toml` file, "\[project\]" field, and add a `dependencies` key with a `List\[str\]` value, where each string is a `pip`-readable dependency.
@@ -0,0 +1,11 @@
1
+ # Conversession
2
+
3
+ Prompt management CLI application for Linux and MacOS.
4
+
5
+
6
+ # Build and publish
7
+
8
+ 1. Building the package before uploading: `python -m build # from "conversession"`.
9
+ 2. Upload the package to pypi: `python -m twine upload --repository {pypi|testpypi} dist/*`
10
+
11
+ If any dependencies are required, edit the `pyproject.toml` file, "\[project\]" field, and add a `dependencies` key with a `List\[str\]` value, where each string is a `pip`-readable dependency.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=75.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "conversession"
7
+ version = "2.0.0"
8
+ authors = [
9
+ { name="Jordi Carrera Ventura", email="jordi.carrera.ventura@gmail.com" },
10
+ ]
11
+ description = ""
12
+ readme = "README.md"
13
+ requires-python = ">=3.11"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ dependencies = [
19
+ "build",
20
+ "twine",
21
+ "openai",
22
+ "python-dotenv",
23
+ "playwright",
24
+ "beautifulsoup4"
25
+
26
+ ] # as pip-installable strings
27
+
28
+ [project.scripts]
29
+ conversession = "conversession.__main__:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,257 @@
1
+ from pathlib import Path
2
+ import subprocess
3
+ import os
4
+ import sys
5
+ import re
6
+ from typing import Optional
7
+ from urllib.parse import urlparse
8
+
9
+ import dotenv
10
+ from openai import OpenAI
11
+ from bs4 import BeautifulSoup
12
+ from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
13
+
14
+ from .models import LLMS
15
+
16
+ BUILT_IN_COMMANDS = [
17
+ "all",
18
+ "new",
19
+ "model",
20
+ "models",
21
+ "help",
22
+ "quit",
23
+ ]
24
+
25
+ SKILLS_DIR = Path(__file__).resolve().parent.parent.parent / "skills"
26
+
27
+
28
+ def _get_skills_dir() -> Path:
29
+ SKILLS_DIR.mkdir(parents=True, exist_ok=True)
30
+ return SKILLS_DIR
31
+
32
+
33
+ def load_skills() -> list[dict]:
34
+ skills_dir = _get_skills_dir()
35
+ skills = []
36
+ for f in sorted(skills_dir.glob("*.md")):
37
+ content = f.read_text().strip()
38
+ if not content:
39
+ continue
40
+ name = f.stem
41
+ summary = content[:90].replace("\n", " ")
42
+ if len(content) > 90:
43
+ summary += "..."
44
+ skills.append({"name": name, "path": f, "summary": summary})
45
+ return skills
46
+
47
+
48
+ def list_models(active=None) -> None:
49
+ for m in LLMS:
50
+ marker = "* " if m is active else " "
51
+ print(f"{marker}{m.name} ({m.shorthand}): {m.url}")
52
+
53
+
54
+ def list_skills() -> None:
55
+ skills = load_skills()
56
+ if not skills:
57
+ print("No skills found. Use new to create one.")
58
+ return
59
+ for s in skills:
60
+ print(f" {s['name']}: {s['summary']}")
61
+
62
+
63
+ def _open_in_editor(filepath: Path) -> None:
64
+ editor = os.environ.get("EDITOR")
65
+ if editor:
66
+ subprocess.run([editor, str(filepath)])
67
+ elif sys.platform == "darwin":
68
+ subprocess.run(["open", "-t", str(filepath)])
69
+ elif sys.platform.startswith("linux"):
70
+ subprocess.run(["xdg-open", str(filepath)])
71
+ else:
72
+ subprocess.run(["vi", str(filepath)])
73
+
74
+
75
+ def run_skill_creation_wizard() -> None:
76
+ skills_dir = _get_skills_dir()
77
+ name = input("Skill name: ").strip()
78
+ if not name:
79
+ print("Cancelled.")
80
+ return
81
+ sanitized = "".join(c for c in name if c.isalnum() or c in "-_").strip("-_")
82
+ if not sanitized:
83
+ print("Invalid name. Use letters, numbers, hyphens, and underscores.")
84
+ return
85
+ if sanitized in BUILT_IN_COMMANDS:
86
+ raise ValueError(
87
+ f"Can't create a skill whose name {sanitized}"
88
+ f"matches that of a built-in command: {str(BUILT_IN_COMMANDS)}")
89
+ filepath = skills_dir / f"{sanitized}.md"
90
+ if filepath.exists():
91
+ overwrite = input(f"Skill '{sanitized}' already exists. Edit it? [Y/n] ").strip().lower()
92
+ if overwrite not in ("", "y", "yes"):
93
+ print("Cancelled.")
94
+ return
95
+ else:
96
+ filepath.write_text("")
97
+ print(f"Created empty skill '{sanitized}'.")
98
+ _open_in_editor(filepath)
99
+ if filepath.exists() and filepath.read_text().strip():
100
+ print(f"Skill '{sanitized}' saved.")
101
+ else:
102
+ print(f"Skill '{sanitized}' is empty.")
103
+
104
+
105
+ def _resolve_skill(name: str) -> Optional[Path]:
106
+ skills_dir = _get_skills_dir()
107
+ filepath = skills_dir / f"{name}.md"
108
+ return filepath if filepath.exists() else None
109
+
110
+
111
+ def edit_skill(name: str) -> None:
112
+ filepath = _resolve_skill(name)
113
+ if not filepath:
114
+ print(f"Skill '{name}' not found.")
115
+ return
116
+ _open_in_editor(filepath)
117
+
118
+
119
+ def _set_clipboard(text: str) -> None:
120
+ try:
121
+ if sys.platform == "darwin":
122
+ subprocess.run(["pbcopy"], input=text, text=True)
123
+ elif sys.platform.startswith("linux"):
124
+ subprocess.run(["xclip", "-selection", "clipboard"], input=text, text=True)
125
+ else:
126
+ pass # silently skip
127
+ except Exception:
128
+ pass
129
+
130
+
131
+ def _is_url(value: str) -> bool:
132
+ if not value:
133
+ return False
134
+ parsed = urlparse(value)
135
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
136
+
137
+
138
+ def _dismiss_common_popups(page) -> None:
139
+ try:
140
+ page.evaluate(
141
+ """
142
+ () => {
143
+ const selectors = [
144
+ '[role="dialog"]',
145
+ '[aria-modal="true"]',
146
+ '[class*="cookie" i]',
147
+ '[class*="consent" i]',
148
+ '[class*="popup" i]',
149
+ '[class*="modal" i]',
150
+ '[class*="overlay" i]'
151
+ ];
152
+ for (const sel of selectors) {
153
+ document.querySelectorAll(sel).forEach(el => el.remove());
154
+ }
155
+ }
156
+ """
157
+ )
158
+ except Exception:
159
+ return
160
+
161
+ button_texts = [
162
+ "Accept all",
163
+ "Accept",
164
+ "Agree",
165
+ "I agree",
166
+ "Allow all",
167
+ "OK",
168
+ "Got it",
169
+ "Close",
170
+ ]
171
+ for text in button_texts:
172
+ try:
173
+ locator = page.locator(f"button:has-text('{text}')").first
174
+ if locator.count() > 0 and locator.is_visible():
175
+ locator.click(timeout=1000)
176
+ except Exception:
177
+ continue
178
+
179
+
180
+ def _extract_main_text(html: str) -> str:
181
+ soup = BeautifulSoup(html, "html.parser")
182
+ for tag in soup(["script", "style", "noscript", "svg", "canvas"]):
183
+ tag.decompose()
184
+
185
+ candidate = (
186
+ soup.find("article")
187
+ or soup.find("main")
188
+ or soup.find(attrs={"role": "main"})
189
+ or soup.body
190
+ or soup
191
+ )
192
+
193
+ text = " ".join(candidate.stripped_strings)
194
+ text = re.sub(r"\s+", " ", text).strip()
195
+ return text
196
+
197
+
198
+ def fetch_webpage_text(url: str, timeout_ms: int = 20000) -> str:
199
+ with sync_playwright() as p:
200
+ browser = p.chromium.launch(headless=True)
201
+ context = browser.new_context(
202
+ user_agent=(
203
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
204
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
205
+ "Chrome/124.0 Safari/537.36"
206
+ )
207
+ )
208
+
209
+ def _route_filter(route, request):
210
+ if request.resource_type in {"image", "media", "font"}:
211
+ route.abort()
212
+ else:
213
+ route.continue_()
214
+
215
+ context.route("**/*", _route_filter)
216
+ page = context.new_page()
217
+ page.on("dialog", lambda dialog: dialog.dismiss())
218
+
219
+ try:
220
+ page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
221
+ try:
222
+ page.wait_for_load_state("networkidle", timeout=timeout_ms)
223
+ except PlaywrightTimeoutError:
224
+ pass
225
+ _dismiss_common_popups(page)
226
+ try:
227
+ page.wait_for_selector("article, main, [role='main']", timeout=5000)
228
+ except PlaywrightTimeoutError:
229
+ pass
230
+
231
+ html = page.content()
232
+ text = _extract_main_text(html)
233
+ if text:
234
+ return text
235
+
236
+ try:
237
+ body_text = page.locator("body").inner_text(timeout=3000)
238
+ except Exception:
239
+ body_text = ""
240
+ body_text = re.sub(r"\s+", " ", body_text).strip()
241
+ return body_text
242
+ finally:
243
+ context.close()
244
+ browser.close()
245
+
246
+ def compose_prompt(name: str, target: str) -> None:
247
+ filepath = _resolve_skill(name)
248
+ if not filepath:
249
+ print(f"Skill '{name}' not found.")
250
+ return
251
+
252
+ template = filepath.read_text().strip()
253
+
254
+ parts = [template, target]
255
+ prompt = "\n".join(parts)
256
+
257
+ return prompt
@@ -0,0 +1,147 @@
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ from . import (
6
+ compose_prompt,
7
+ edit_skill,
8
+ list_models,
9
+ list_skills,
10
+ load_skills,
11
+ run_skill_creation_wizard,
12
+ _open_in_editor,
13
+ _resolve_skill,
14
+ _set_clipboard,
15
+ _is_url,
16
+ fetch_webpage_text,
17
+ )
18
+ from .models import LLMS
19
+
20
+ HELP = """
21
+ Built-in commands:
22
+ new Create a new skill
23
+ all List all skills
24
+ model Show active model
25
+ model <arg> Switch model (by name or shorthand)
26
+ models List all available models
27
+ help Show this message
28
+ quit Quit the application
29
+
30
+ Type <skill> to edit then open the LLM webpage.
31
+ Type <skill> <target> to execute a skill with context.
32
+ Type edit <skill> to edit a skill only (no webpage).
33
+ """
34
+
35
+
36
+ ACTIVE_MODEL = LLMS[0]
37
+
38
+
39
+ def _dispatch(command: str) -> bool:
40
+ """Handle one user input. Returns False if the app should quit."""
41
+ global ACTIVE_MODEL
42
+ raw = command.strip()
43
+ if not raw:
44
+ return True
45
+
46
+ # ... (Keep existing command logic: quit, help, new, all, models, model) ...
47
+ if raw == "quit": return False
48
+ if raw == "help": print(HELP); return True
49
+ if raw == "new": run_skill_creation_wizard(); return True
50
+ if raw == "all": list_skills(); return True
51
+ if raw == "models": list_models(ACTIVE_MODEL); return True
52
+
53
+ if raw == "model":
54
+ print(f"Current model: {ACTIVE_MODEL.name} ({ACTIVE_MODEL.shorthand})")
55
+ return True
56
+ if raw.startswith("model "):
57
+ arg = raw[6:].strip()
58
+ for m in LLMS:
59
+ if arg.lower() in (m.name.lower(), m.shorthand.lower()):
60
+ ACTIVE_MODEL = m
61
+ print(f"Switched to {ACTIVE_MODEL.name}.")
62
+ return True
63
+ print(f"Unknown model '{arg}'.")
64
+ return True
65
+
66
+ # Parse <edit> <skill>, <skill> <target>, or <skill>
67
+ rest = raw
68
+ first_space = rest.find(" ")
69
+ if first_space == -1:
70
+ name = rest
71
+ target = ""
72
+ else:
73
+ name = rest[:first_space]
74
+ target = rest[first_space + 1:].strip()
75
+
76
+ if name == "edit":
77
+ edit_skill(target)
78
+ return True
79
+
80
+ skills = {s["name"] for s in load_skills()}
81
+ if name not in skills:
82
+ print(f"Unknown skill '{name}'. Type `help` or `all` to see available commands.")
83
+ return True
84
+
85
+
86
+ # Determine initial prompt content
87
+ if target and _is_url(target):
88
+ print("Fetching webpage content...")
89
+ prompt = compose_prompt(name, target)
90
+ _set_clipboard(prompt)
91
+ os.system(f"open '{ACTIVE_MODEL.url}'")
92
+ return True
93
+ filepath = _resolve_skill(name)
94
+ if not filepath:
95
+ print(f"Skill '{name}' not found.")
96
+ return True
97
+
98
+ prompt = compose_prompt(name, target)
99
+
100
+ # Create temporary scratchpad and edit
101
+ with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as tf:
102
+ tf.write(prompt)
103
+ temp_path = Path(tf.name)
104
+
105
+ if not target:
106
+ try:
107
+ _open_in_editor(temp_path)
108
+
109
+ # --- ADDED: Manual wait to ensure file is saved ---
110
+ input("\nFile opened in editor. Press Enter here once you have saved and closed the editor to proceed...")
111
+
112
+ # Read the final content
113
+ final_prompt = temp_path.read_text()
114
+ _set_clipboard(final_prompt)
115
+ print("Prompt copied to clipboard.") # Feedback for clarity
116
+ os.system(f"open '{ACTIVE_MODEL.url}'")
117
+ finally:
118
+ # Clean up
119
+ if temp_path.exists():
120
+ os.remove(temp_path)
121
+ else:
122
+ prompt = compose_prompt(name, target)
123
+ _set_clipboard(prompt)
124
+ os.system(f"open '{ACTIVE_MODEL.url}'")
125
+
126
+ return True
127
+
128
+
129
+
130
+ def main() -> None:
131
+ try:
132
+ while True:
133
+ try:
134
+ command = input("\nAction: ")
135
+ except (EOFError, KeyboardInterrupt):
136
+ print()
137
+ break
138
+ if not _dispatch(command):
139
+ break
140
+ except KeyboardInterrupt:
141
+ print()
142
+ finally:
143
+ print("Done.")
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
@@ -0,0 +1,9 @@
1
+ from collections import namedtuple
2
+
3
+ LLM = namedtuple("LLM", "name shorthand url")
4
+
5
+ LLMS = [
6
+ LLM("ChatGPT", "gpt", "https://chatgpt.com"),
7
+ LLM("DeepSeek", "deep", "https://chat.deepseek.com/a/chat"),
8
+ LLM("Gemini", "gem", "https://gemini.google.com/app"),
9
+ ]
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: conversession
3
+ Version: 2.0.0
4
+ Author-email: Jordi Carrera Ventura <jordi.carrera.ventura@gmail.com>
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Operating System :: OS Independent
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: build
11
+ Requires-Dist: twine
12
+ Requires-Dist: openai
13
+ Requires-Dist: python-dotenv
14
+ Requires-Dist: playwright
15
+ Requires-Dist: beautifulsoup4
16
+ Dynamic: license-file
17
+
18
+ # Conversession
19
+
20
+ Prompt management CLI application for Linux and MacOS.
21
+
22
+
23
+ # Build and publish
24
+
25
+ 1. Building the package before uploading: `python -m build # from "conversession"`.
26
+ 2. Upload the package to pypi: `python -m twine upload --repository {pypi|testpypi} dist/*`
27
+
28
+ If any dependencies are required, edit the `pyproject.toml` file, "\[project\]" field, and add a `dependencies` key with a `List\[str\]` value, where each string is a `pip`-readable dependency.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/conversession/__init__.py
5
+ src/conversession/__main__.py
6
+ src/conversession/models.py
7
+ src/conversession.egg-info/PKG-INFO
8
+ src/conversession.egg-info/SOURCES.txt
9
+ src/conversession.egg-info/dependency_links.txt
10
+ src/conversession.egg-info/entry_points.txt
11
+ src/conversession.egg-info/requires.txt
12
+ src/conversession.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ conversession = conversession.__main__:main
@@ -0,0 +1,6 @@
1
+ build
2
+ twine
3
+ openai
4
+ python-dotenv
5
+ playwright
6
+ beautifulsoup4
@@ -0,0 +1 @@
1
+ conversession