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 +1 -0
- claudforge/browser/__init__.py +0 -0
- claudforge/browser/launcher.py +131 -0
- claudforge/cli.py +257 -0
- claudforge/dashboard/app.py +135 -0
- claudforge/uploader/__init__.py +0 -0
- claudforge/uploader/batch.py +324 -0
- claudforge/uploader/single.py +133 -0
- claudforge/utils/__init__.py +0 -0
- claudforge/utils/archive.py +41 -0
- claudforge/utils/history.py +32 -0
- claudforge/utils/yaml_parser.py +70 -0
- claudforge/utils/zipper.py +36 -0
- claudforge-2.0.0.dist-info/METADATA +214 -0
- claudforge-2.0.0.dist-info/RECORD +18 -0
- claudforge-2.0.0.dist-info/WHEEL +4 -0
- claudforge-2.0.0.dist-info/entry_points.txt +2 -0
- claudforge-2.0.0.dist-info/licenses/LICENSE +21 -0
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
|