claudforge 2.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.
claudforge/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "2.0.0"
File without changes
@@ -0,0 +1,131 @@
1
+ from playwright.sync_api import sync_playwright, Browser, Page
2
+ from rich.console import Console
3
+
4
+ console = Console()
5
+
6
+ def launch_browser(p, headless: bool = False, connect_port: int = None, profile_path: str = None) -> tuple[any, Page]:
7
+ """Launch a fresh browser, a persistent session, or connect to an existing one."""
8
+ import random
9
+
10
+ # Randomize viewport slightly to avoid "bot signatures"
11
+ width = random.randint(1280, 1920)
12
+ height = random.randint(720, 1080)
13
+
14
+ stealth_args = [
15
+ "--disable-blink-features=AutomationControlled",
16
+ "--no-sandbox",
17
+ "--disable-infobars",
18
+ f"--window-size={width},{height}",
19
+ ]
20
+
21
+ if connect_port:
22
+ console.print(f"[bold cyan]🔗 Connecting to existing Chrome on port {connect_port}...[/bold cyan]")
23
+ try:
24
+ browser = p.chromium.connect_over_cdp(f"http://127.0.0.1:{connect_port}")
25
+ # Existing sessions are already "stealthy" because they are real processes
26
+ page = None
27
+ for context in browser.contexts:
28
+ for p_ in context.pages:
29
+ if "claude.ai" in p_.url:
30
+ page = p_
31
+ console.print(f"[bold green]✅ Found existing Claude tab: '{page.title()}'[/bold green]")
32
+ break
33
+ if page: break
34
+
35
+ if not page:
36
+ page = browser.contexts[0].new_page()
37
+ return browser, page
38
+ except Exception as e:
39
+ if "Browser.setDownloadBehavior" in str(e) or "context management" in str(e):
40
+ raise RuntimeError(
41
+ "Protocol Error: Your Chrome version is incompatible with direct CDP connection.\n"
42
+ "FIX: Close Chrome and run with the --profile flag instead:\n"
43
+ "claudforge upload --batch ... --profile \"/tmp/claudforge_debug\""
44
+ )
45
+ raise RuntimeError(f"Failed to connect to Chrome: {e}")
46
+ elif profile_path:
47
+ console.print(f"[bold cyan]🚀 Launching persistent Chrome session: {profile_path}[/bold cyan]")
48
+ context = p.chromium.launch_persistent_context(
49
+ profile_path,
50
+ headless=headless,
51
+ channel="chrome",
52
+ args=stealth_args,
53
+ no_viewport=True,
54
+ viewport={"width": width, "height": height}
55
+ )
56
+ page = context.pages[0] if context.pages else context.new_page()
57
+ return context, page
58
+ else:
59
+ console.print("[bold cyan]🚀 Launching fresh Chrome...[/bold cyan]")
60
+ browser = p.chromium.launch(headless=headless, channel="chrome", args=stealth_args)
61
+ context = browser.new_context(viewport={"width": width, "height": height})
62
+ page = context.new_page()
63
+ return browser, page
64
+
65
+ def navigate_to_skills(page, console: Console):
66
+ """Navigate to the skills page and handle auth/Cloudflare."""
67
+ TARGET = "https://claude.ai/customize/skills"
68
+
69
+ try:
70
+ page.goto(TARGET, wait_until="domcontentloaded", timeout=30000)
71
+ except Exception:
72
+ pass
73
+
74
+ # Handle Login
75
+ if "login" in page.url or "signin" in page.url:
76
+ console.print("\n[bold yellow]⚠️ Please log in manually in the browser window, then press Enter.[/bold yellow]")
77
+ input(" [Press Enter once logged in] ")
78
+ if TARGET not in page.url:
79
+ page.goto(TARGET, wait_until="networkidle")
80
+
81
+ # Handle Cloudflare
82
+ while "api/challenge_redirect" in page.url or "cloudflare" in page.content().lower() or "Just a moment" in page.title():
83
+ console.print("\n[bold red]🛡️ Cloudflare challenge detected![/bold red]")
84
+ console.print(" Please solve the challenge in the browser window.")
85
+ console.print(" The script will detect when you are through. (Or press Enter if page is ready)")
86
+ try:
87
+ # Wait for content or user to press enter
88
+ page.wait_for_selector("button:has-text('Add skill')", timeout=15000)
89
+ break
90
+ except Exception:
91
+ input(" [Press Enter once the Skills page has fully loaded] ")
92
+ if TARGET not in page.url:
93
+ page.goto(TARGET, wait_until="domcontentloaded")
94
+ break
95
+
96
+ def get_existing_skills(page: Page) -> list[str]:
97
+ """Extract names of already uploaded skills from the settings page."""
98
+ try:
99
+ # Claude lists skills in a list-like structure.
100
+ skills = []
101
+ # Wait a bit for the list to render
102
+ page.wait_for_timeout(2000)
103
+
104
+ # 1. Primary: Look for elements in the flex list
105
+ elements = page.query_selector_all("div.flex.flex-col > div.flex.items-center.justify-between")
106
+ for el in elements:
107
+ text = el.inner_text().split('\n')[0]
108
+ if text and text not in ["Add skill", "Settings", "Skills"]:
109
+ skills.append(text.strip())
110
+
111
+ # 2. Fallback: Search all H3s (often used for skill titles)
112
+ if not skills:
113
+ elements = page.query_selector_all("h3")
114
+ for el in elements:
115
+ t = el.inner_text().strip()
116
+ if t and t not in ["Add skill", "Settings", "Skills", "Customize Clause"]:
117
+ skills.append(t)
118
+
119
+ # 3. Last Resort: Any text near an 'Edit' button
120
+ if not skills:
121
+ elements = page.query_selector_all("button:has-text('Edit')")
122
+ for el in elements:
123
+ # Find the text in the same row/container
124
+ parent = el.evaluate_handle("node => node.closest('div.flex')")
125
+ if parent:
126
+ t = parent.as_element().inner_text().split('\n')[0]
127
+ if t: skills.append(t.strip())
128
+
129
+ return list(set([s for s in skills if s])) # Cleanup and deduplicate
130
+ except Exception:
131
+ return []
claudforge/cli.py ADDED
@@ -0,0 +1,257 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from playwright.sync_api import sync_playwright
7
+
8
+ from claudforge.browser.launcher import launch_browser, navigate_to_skills
9
+ from claudforge.utils.zipper import zip_folder, cleanup_zips
10
+ from claudforge.utils.yaml_parser import validate_skill_metadata, get_skill_md_path
11
+ from claudforge.utils.history import load_history
12
+ from claudforge.uploader.single import upload_skill
13
+ from claudforge.uploader.batch import run_batch_upload, export_web_data
14
+
15
+ app = typer.Typer(
16
+ help="ClaudForge ⚒️ - The missing CLI for Claude.ai Skills.",
17
+ add_completion=False,
18
+ )
19
+ console = Console()
20
+
21
+ @app.command()
22
+ def upload(
23
+ path: Path = typer.Argument(..., help="Path to a skill folder or batch directory", metavar="PATH"),
24
+ limit: Optional[int] = typer.Option(None, "--limit", help="Max skills to upload in batch mode"),
25
+ headless: bool = typer.Option(False, "--headless", help="Run browser in headless mode"),
26
+ connect: Optional[int] = typer.Option(None, "--connect", help="Connect to existing Chrome on port", show_default=False),
27
+ profile: Optional[str] = typer.Option(None, "--profile", help="Path to a persistent Chrome profile/data directory"),
28
+ keep_zips: bool = typer.Option(False, "--keep-zips", help="Keep generated zip files"),
29
+ force: bool = typer.Option(False, "--force", "-f", help="Ignore local history and force re-check/re-upload"),
30
+ ):
31
+ """Deploy a skill or a batch of skills to Claude.ai."""
32
+ target = path.expanduser().resolve()
33
+ if not target.exists():
34
+ console.print(f"[red]Error: Path '{target}' does not exist.[/red]")
35
+ raise typer.Exit(1)
36
+
37
+ # AUTO-DETECT MODE
38
+ is_single = (target / "SKILL.md").exists() or (target / "skill.md").exists()
39
+
40
+ with sync_playwright() as p:
41
+ try:
42
+ browser, page = launch_browser(p, headless=headless, connect_port=connect, profile_path=profile)
43
+ navigate_to_skills(page, console)
44
+
45
+ if is_single:
46
+ ok, err = validate_skill_metadata(target)
47
+ if not ok:
48
+ console.print(f"[red]Validation Error: {err}[/red]")
49
+ raise typer.Exit(1)
50
+
51
+ zip_dir = target.parent / "_zips"
52
+ zip_dir.mkdir(exist_ok=True)
53
+ zp = zip_folder(target, zip_dir)
54
+
55
+ console.print(f"⬆️ Uploading [cyan]{target.name}[/cyan] ...", end="")
56
+ if upload_skill(page, zp, console, auto_replace=force):
57
+ console.print(" [bold green]✅ Success[/bold green]")
58
+ else:
59
+ console.print(" [bold red]❌ Failed[/bold red]")
60
+
61
+ if not keep_zips:
62
+ cleanup_zips(zip_dir)
63
+ else:
64
+ # BATCH MODE
65
+ zip_dir = target / "_zips"
66
+ zip_dir.mkdir(exist_ok=True)
67
+ run_batch_upload(page, target, zip_dir, limit, keep_zips, console, force=force)
68
+ if not keep_zips:
69
+ cleanup_zips(zip_dir)
70
+
71
+ browser.close()
72
+ except Exception as e:
73
+ console.print(f"[bold red]Fatal Error:[/bold red] {e}")
74
+ raise typer.Exit(1)
75
+
76
+ @app.command()
77
+ def status(
78
+ path: Path = typer.Argument(..., help="Path to the directory of skill folders", metavar="PATH")
79
+ ):
80
+ """Check the upload progress/status of a batch without launching a browser."""
81
+ batch_dir = path.expanduser().resolve()
82
+ if not batch_dir.is_dir():
83
+ console.print(f"[red]Error: '{batch_dir}' is not a directory.[/red]")
84
+ return
85
+
86
+ history = load_history(batch_dir)
87
+ # Only count folders that actually contain a SKILL.md
88
+ skill_folders = [d for d in batch_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()]
89
+
90
+ total = len(skill_folders)
91
+ done = len([f for f in skill_folders if f.name in history])
92
+ pending = total - done
93
+
94
+ console.print(f"\n⚒️ [bold cyan]Batch Project:[/bold cyan] {batch_dir.name}")
95
+ console.print(f"📁 Total SkillFolders: [bold]{total}[/bold]")
96
+ console.print(f"✅ Local History: [bold green]{done}[/bold green]")
97
+ console.print(f"⏳ Pending Upload: [bold yellow]{pending}[/bold yellow]")
98
+
99
+ if total > 0:
100
+ percent = (done / total) * 100
101
+ console.print(f"📊 Completion: [bold]{percent:.1f}%[/bold]\n")
102
+
103
+ # Export data for the True UI website
104
+ export_web_data(batch_dir, history)
105
+
106
+ @app.command()
107
+ def validate(
108
+ path: Path = typer.Argument(..., help="Path to the skill folder", metavar="PATH")
109
+ ):
110
+ """Validate SKILL.md structure without deploying."""
111
+ folder = path.expanduser().resolve()
112
+ ok, err = validate_skill_metadata(folder)
113
+ if ok:
114
+ console.print(f"[bold green]✅ '{folder.name}' is valid and ready for upload.[/bold green]")
115
+ else:
116
+ console.print(f"[bold red]❌ Validation failed for '{folder.name}': {err}[/bold red]")
117
+
118
+ @app.command()
119
+ def init(name: str = typer.Option(..., "--name", help="Name of the new skill")):
120
+ """Scaffold a new Claude skill folder."""
121
+ folder = Path.cwd() / name
122
+ if folder.exists():
123
+ console.print(f"[red]Error: Folder '{name}' already exists.[/red]")
124
+ return
125
+
126
+ folder.mkdir()
127
+ skill_md = folder / "SKILL.md"
128
+ skill_md.write_text(f"""---
129
+ name: {name}
130
+ description: A short description of what {name} does.
131
+ ---
132
+
133
+ # {name}
134
+
135
+ Describe your skill here.
136
+ """)
137
+ console.print(f"[bold green]✅ Created skill scaffold in ./{name}/[/bold green]")
138
+ console.print(f"🚀 Edit {name}/SKILL.md, then run: [cyan]claudforge upload ./{name}[/cyan]")
139
+
140
+ @app.command()
141
+ def doctor():
142
+ """Check environment health (Chrome, Playwright, Python)."""
143
+ import sys
144
+ console.print(f"Python Version: [cyan]{sys.version.split()[0]}[/cyan]")
145
+
146
+ try:
147
+ from playwright.sync_api import sync_playwright
148
+ console.print("Playwright: [green]Installed[/green]")
149
+ except ImportError:
150
+ console.print("Playwright: [red]Not Found[/red]")
151
+
152
+ console.print("\n[dim]To fix environment issues, run:[/dim]")
153
+ console.print("[cyan]pip install -r requirements.txt && playwright install chrome[/cyan]")
154
+
155
+ @app.command(name="list")
156
+ def list_skills(
157
+ path: Path = typer.Argument(..., help="Path to the batch directory", metavar="PATH")
158
+ ):
159
+ """List all skills recorded in the local history."""
160
+ batch_dir = path.expanduser().resolve()
161
+ if not (batch_dir / ".claudforge_history").exists():
162
+ console.print(f"[yellow]No history found for '{batch_dir.name}'.[/yellow]")
163
+ return
164
+
165
+ history = load_history(batch_dir)
166
+
167
+ table = Table(title=f"Synced Skills: {batch_dir.name}", box=None)
168
+ table.add_column("#", justify="right", style="dim")
169
+ table.add_column("Skill Name", style="cyan")
170
+
171
+ for i, name in enumerate(sorted(list(history)), 1):
172
+ table.add_row(str(i), name)
173
+
174
+ console.print("\n", table)
175
+ console.print(f"\n[dim]Total: {len(history)} skills recorded.[/dim]")
176
+
177
+ @app.command()
178
+ def dashboard(path: Path = typer.Argument(..., help="Path to the batch directory", metavar="PATH")):
179
+ """Launch the real-time web dashboard to monitor progress."""
180
+ import subprocess
181
+ import sys
182
+
183
+ batch_dir = path.expanduser().resolve()
184
+ if not batch_dir.is_dir():
185
+ console.print(f"[red]Error: '{batch_dir}' is not a directory.[/red]")
186
+ raise typer.Exit(1)
187
+
188
+ dashboard_path = Path(__file__).parent / "dashboard" / "app.py"
189
+
190
+ if not dashboard_path.exists():
191
+ console.print("[red]Error: Dashboard component not found.[/red]")
192
+ raise typer.Exit(1)
193
+
194
+ console.print(f"\n[bold cyan]🚀 Launching ClaudForge Live Monitor...[/bold cyan]")
195
+ console.print(f"[dim]Tracking directory: {batch_dir}[/dim]")
196
+ console.print("[dim]Opening browser at http://localhost:8501[/dim]\n")
197
+
198
+ try:
199
+ # Run streamlit as a module
200
+ subprocess.run([sys.executable, "-m", "streamlit", "run", str(dashboard_path), "--", str(batch_dir)])
201
+ except KeyboardInterrupt:
202
+ console.print("\n[yellow]Dashboard stopped.[/yellow]")
203
+
204
+ @app.command()
205
+ def rollback(
206
+ path: Path = typer.Argument(..., help="Path to the batch directory", metavar="PATH"),
207
+ skill: str = typer.Argument(..., help="Name of the skill folder to rollback"),
208
+ profile: Optional[str] = typer.Option(None, "--profile", help="Chrome profile to use"),
209
+ ):
210
+ """Revert a skill to a previous version from the archive."""
211
+ from rich.prompt import IntPrompt
212
+ from claudforge.utils.archive import list_snapshots, get_snapshot_zip
213
+
214
+ batch_dir = path.expanduser().resolve()
215
+ snapshots = list_snapshots(batch_dir, skill)
216
+
217
+ if not snapshots:
218
+ console.print(f"[yellow]No archives found for '{skill}' in {batch_dir.name}.[/yellow]")
219
+ return
220
+
221
+ table = Table(title=f"📜 Archive History: {skill}", box=None)
222
+ table.add_column("#", justify="right", style="dim")
223
+ table.add_column("Uploaded At", style="cyan")
224
+ table.add_column("Filename", style="dim")
225
+
226
+ for i, (ts, filename) in enumerate(snapshots, 1):
227
+ table.add_row(str(i), ts, filename)
228
+
229
+ console.print("\n", table)
230
+
231
+ choice = IntPrompt.ask("\n[bold green]Select version to restore[/bold green]", choices=[str(i) for i in range(1, len(snapshots)+1)])
232
+ selected_ts, selected_file = snapshots[choice - 1]
233
+
234
+ zip_path = get_snapshot_zip(batch_dir, skill, selected_file)
235
+
236
+ console.print(f"\n[bold yellow]🕒 Preparing rollback to version {selected_ts}...[/bold yellow]")
237
+
238
+ with sync_playwright() as p:
239
+ try:
240
+ # We use headless=False by default for rollback to ensure safety
241
+ browser, page = launch_browser(p, profile_path=profile)
242
+ navigate_to_skills(page, console)
243
+
244
+ if upload_skill(page, zip_path, console, auto_replace=True):
245
+ console.print(f"\n[bold green]✅ Successfully rolled back '{skill}' to {selected_ts}![/bold green]")
246
+ else:
247
+ console.print(f"\n[bold red]❌ Rollback upload failed.[/bold red]")
248
+
249
+ browser.close()
250
+ except Exception as e:
251
+ console.print(f"[bold red]Fatal Error during rollback:[/bold red] {e}")
252
+
253
+ def main():
254
+ app()
255
+
256
+ if __name__ == "__main__":
257
+ main()
@@ -0,0 +1,135 @@
1
+ import streamlit as st
2
+ import json
3
+ import time
4
+ from pathlib import Path
5
+ import pandas as pd
6
+ from datetime import datetime
7
+
8
+ # --- CONFIG ---
9
+ st.set_page_config(
10
+ page_title="ClaudForge Live ⚒️",
11
+ page_icon="⚒️",
12
+ layout="wide",
13
+ initial_sidebar_state="expanded"
14
+ )
15
+
16
+ # --- THEME (Glassmorphism) ---
17
+ st.markdown("""
18
+ <style>
19
+ .main {
20
+ background-color: #0E1117;
21
+ }
22
+ .stMetric {
23
+ background: rgba(255, 255, 255, 0.05);
24
+ padding: 20px;
25
+ border-radius: 15px;
26
+ border: 1px solid rgba(255, 255, 255, 0.1);
27
+ backdrop-filter: blur(10px);
28
+ }
29
+ .status-card {
30
+ background: linear-gradient(135deg, #1e1e2f 0%, #11111d 100%);
31
+ padding: 30px;
32
+ border-radius: 20px;
33
+ border-left: 5px solid #ff4b4b;
34
+ margin-bottom: 25px;
35
+ }
36
+ .highlight {
37
+ color: #ff4b4b;
38
+ font-weight: bold;
39
+ }
40
+ header {visibility: hidden;}
41
+ footer {visibility: hidden;}
42
+ .stDeployButton {display:none;}
43
+ /* Hide the running spinner */
44
+ [data-testid="stStatusWidget"] {
45
+ display: none;
46
+ }
47
+ </style>
48
+ """, unsafe_allow_html=True)
49
+
50
+ # --- DATA LOADING ---
51
+ def load_session(path: Path):
52
+ if not path.exists():
53
+ return None
54
+ try:
55
+ with open(path, 'r') as f:
56
+ return json.load(f)
57
+ except Exception:
58
+ return None
59
+
60
+ # --- UI LOGIC ---
61
+ def run_app():
62
+ # Get path from CLI args passed to streamlit
63
+ import sys
64
+ # Extract path if passed after --
65
+ try:
66
+ idx = sys.argv.index("--")
67
+ batch_path_str = sys.argv[idx + 1]
68
+ except (ValueError, IndexError):
69
+ batch_path_str = "."
70
+
71
+ batch_path = Path(batch_path_str).resolve()
72
+ session_file = batch_path / ".claudforge_session.json"
73
+
74
+ st.title("ClaudForge Live Monitor ⚒️")
75
+
76
+ placeholder = st.empty()
77
+
78
+ while True:
79
+ data = load_session(session_file)
80
+
81
+ with placeholder.container():
82
+ if not data:
83
+ st.warning(f"Waiting for session data at {batch_path.name}...")
84
+ st.info("Start an upload in the terminal to see live progress.")
85
+ else:
86
+ # 1. METRICS ROW
87
+ col1, col2, col3, col4 = st.columns(4)
88
+
89
+ total = data["total_folders"]
90
+ history = data["history_count"]
91
+ current_session_done = len(data["results"])
92
+ limit = data["limit"] or total
93
+
94
+ global_progress = ((history + current_session_done) / total) * 100 if total > 0 else 0
95
+ session_progress = (current_session_done / limit) * 100 if limit > 0 else 0
96
+
97
+ col1.metric("Global Progress", f"{global_progress:.1f}%", f"{history + current_session_done}/{total}")
98
+ col2.metric("Current Session", f"{session_progress:.1f}%", f"{current_session_done}/{limit}")
99
+
100
+ # ETR Calculation
101
+ elapsed = time.time() - data["session_start"]
102
+ if current_session_done > 0:
103
+ avg_time = elapsed / current_session_done
104
+ remaining = (limit - current_session_done) * avg_time
105
+ etr_min = int(remaining // 60)
106
+ etr_sec = int(remaining % 60)
107
+ col3.metric("ETR", f"{etr_min}m {etr_sec}s", f"Avg: {avg_time:.1f}s/skill")
108
+ else:
109
+ col3.metric("ETR", "Calculating...")
110
+
111
+ col4.metric("Status", data["status"])
112
+
113
+ # 2. ACTIVE UPLOAD
114
+ if data["status"] == "RUNNING" and data["current_skill"]:
115
+ st.markdown(f"""
116
+ <div class="status-card">
117
+ <h3 style='margin:0'>🚀 Now Uploading</h3>
118
+ <p style='font-size: 24px; color: #00d4ff; margin-top:10px;'>{data["current_skill"]}</p>
119
+ <p style='font-size: 14px; opacity: 0.7;'>Processing skill {data["current_index"]} of session limit {limit}</p>
120
+ </div>
121
+ """, unsafe_allow_html=True)
122
+ elif data["status"] == "FINISHED":
123
+ st.success("🎉 Batch Complete! All skills successfully processed.")
124
+
125
+ # 3. RESULTS TABLE
126
+ if data["results"]:
127
+ st.subheader("Recent Activity")
128
+ df = pd.DataFrame(data["results"], columns=["Skill", "Status", "Details"])
129
+ # Reverse to show newest at top
130
+ st.dataframe(df.iloc[::-1], width="stretch", hide_index=True)
131
+
132
+ time.sleep(1)
133
+
134
+ if __name__ == "__main__":
135
+ run_app()
File without changes