From dcc70a72dc58aa3a087b54a38365bfdab1f803ff Mon Sep 17 00:00:00 2001 From: digitone Date: Wed, 27 May 2026 16:20:48 +0530 Subject: [PATCH 1/3] feat(scripts): Add paperclip-hermes-bridge with preview URL support Adds a new bridge script that connects Hermes to Paperclip AI agents. Key features: - All existing bridge commands (list-agents, assign, run, poll, etc.) - Project type auto-detection (Vite, Next.js, Tauri, React, static) - Dev server auto-start with port conflict handling - preview command: detects project in issue workspace and starts server - serve command: start dev server for any local project path - Automatic preview URL posted as issue comment Closes #46 --- scripts/paperclip-hermes-bridge.py | 553 +++++++++++++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100755 scripts/paperclip-hermes-bridge.py diff --git a/scripts/paperclip-hermes-bridge.py b/scripts/paperclip-hermes-bridge.py new file mode 100755 index 00000000000..c7754761146 --- /dev/null +++ b/scripts/paperclip-hermes-bridge.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python3 +""" +Hermes <-> Paperclip Seamless Bridge +Usage: python3 paperclip-hermes-bridge.py [args] + +Commands: + list-agents List all agents and their status + agent-status Get detailed status of a specific agent + assign Create and assign an issue to an agent + issues [--status STATUS] List issues (optionally filter by status) + issue-status Get detailed issue status + update-issue Update issue status (todo, in_progress, done, etc.) + message Send a message/comment to an agent's current issue + poll Poll an issue until it's done (blocks) + run Full cycle: create issue, assign, poll until done, return result + broadcast Create an unassigned issue for the whole team + team-status Quick health check of all agents + preview Detect project type in workspace and start dev server, return URL + serve Start a dev server for a local project and return the URL +""" +import sys, json, urllib.request, urllib.error, time, argparse, os, subprocess, socket, re + +BASE = "http://localhost:3100/api" +COMPANY = "39909fad-4bf8-4302-8093-d3bf8235a881" + +# Project type detection patterns +PROJECT_TYPES = { + "vite": { + "files": ["vite.config.ts", "vite.config.js", "vite.config.mjs"], + "commands": ["npm run dev", "pnpm dev", "yarn dev"], + "default_port": 5173, + "url_path": "/", + }, + "nextjs": { + "files": ["next.config.ts", "next.config.js", "next.config.mjs"], + "commands": ["npm run dev", "pnpm dev", "yarn dev"], + "default_port": 3000, + "url_path": "/", + }, + "tauri": { + "files": ["src-tauri/tauri.conf.json", "tauri.conf.json"], + "commands": ["npm run tauri dev", "pnpm tauri dev", "yarn tauri dev"], + "default_port": 1420, + "url_path": "/", + }, + "react-scripts": { + "files": [], # Detected by package.json scripts + "commands": ["npm start", "pnpm start", "yarn start"], + "default_port": 3000, + "url_path": "/", + }, + "static": { + "files": ["index.html"], + "commands": ["python3 -m http.server", "npx serve .", "python -m http.server"], + "default_port": 8000, + "url_path": "/", + }, +} + +def api(method, path, data=None): + url = f"{BASE}{path}" + req = urllib.request.Request(url, method=method) + if data: + req.add_header("Content-Type", "application/json") + req.data = json.dumps(data).encode() + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + return {"error": e.read().decode(), "status": e.code} + except Exception as e: + return {"error": str(e)} + +def get_agents(): + result = api("GET", f"/companies/{COMPANY}/agents") + if isinstance(result, list): + return result + if isinstance(result, dict) and "error" in result: + print(f"Error fetching agents: {result['error']}") + return [] + return result if isinstance(result, list) else [] + +def get_agent_by_name(name): + agents = get_agents() + name_lower = name.lower() + for a in agents: + if isinstance(a, dict) and (a.get('name', '').lower() == name_lower or a.get('role', '').lower() == name_lower): + return a + # Fuzzy match + for a in agents: + if isinstance(a, dict) and (name_lower in a.get('name', '').lower() or name_lower in a.get('role', '').lower()): + return a + return None + +def list_agents(): + agents = get_agents() + print(f"{'Agent Name':<30} {'Role':<15} {'Status':<10} {'Adapter':<15} {'Last Heartbeat'}") + print("-" * 90) + for a in agents: + if not isinstance(a, dict): + continue + hb = a.get('lastHeartbeatAt', 'never')[:19] if a.get('lastHeartbeatAt') else 'never' + print(f"{a.get('name','?'):<30} {a.get('role','?'):<15} {a.get('status','?'):<10} {a.get('adapterType','?'):<15} {hb}") + +def agent_status(name): + agent = get_agent_by_name(name) + if not agent: + print(f"Agent '{name}' not found") + return + print(json.dumps(agent, indent=2)) + +def assign(agent_name, task, title=None): + agent = get_agent_by_name(agent_name) + if not agent: + print(f"Agent '{agent_name}' not found. Available agents:") + list_agents() + return None + issue_title = title or task[:80] + issue = api("POST", f"/companies/{COMPANY}/issues", { + "title": issue_title, + "description": task, + "status": "todo", + "assigneeAgentId": agent['id'] + }) + if 'error' in issue: + print(f"Error creating issue: {issue['error']}") + return None + print(f"CREATED | Issue: {issue['id']} | Assigned to: {agent['name']} | Status: {issue['status']}") + return issue + +def list_issues(status=None): + path = f"/companies/{COMPANY}/issues" + if status: + path += f"?status={status}" + items = api("GET", path) + if not isinstance(items, list): + print(f"Error: {items}") + return + print(f"{'Issue ID':<36} {'Status':<12} {'Assignee':<20} {'Title'}") + print("-" * 110) + for i in items: + if not isinstance(i, dict): + continue + assignee = i.get('assigneeAgentId', 'unassigned')[:18] if i.get('assigneeAgentId') else 'unassigned' + print(f"{i.get('id','?'):<36} {i.get('status','?'):<12} {assignee:<20} {i.get('title','')[:50]}") + +def issue_status(issue_id): + # Try /api/issues/{id} first (Paperclip shortcut route) + issue = api("GET", f"/issues/{issue_id}") + if 'error' in issue: + # Fallback to company-scoped route + issue = api("GET", f"/companies/{COMPANY}/issues/{issue_id}") + if 'error' in issue: + print(f"Error: {issue['error']}") + return + print(json.dumps(issue, indent=2)) + +def update_issue(issue_id, status): + valid = ['backlog', 'todo', 'in_progress', 'in_review', 'done', 'blocked', 'cancelled'] + if status not in valid: + print(f"Invalid status. Use: {', '.join(valid)}") + return + result = api("PUT", f"/issues/{issue_id}", {"status": status}) + if 'error' in result: + print(f"Error: {result['error']}") + else: + print(f"Updated {issue_id} -> {status}") + +def message_agent(agent_name, msg): + agent = get_agent_by_name(agent_name) + if not agent: + print(f"Agent '{agent_name}' not found") + return + # Add comment to agent's most recent issue + issues = api("GET", f"/companies/{COMPANY}/issues") + if not isinstance(issues, list): + print(f"Error fetching issues: {issues}") + return + agent_issues = [i for i in issues if isinstance(i, dict) and i.get('assigneeAgentId') == agent['id']] + if not agent_issues: + print(f"No active issues for {agent_name}. Creating one...") + issue = assign(agent_name, msg) + return + latest = sorted(agent_issues, key=lambda x: x.get('updatedAt', ''), reverse=True)[0] + comment = api("POST", f"/companies/{COMPANY}/issues/{latest['id']}/comments", { + "content": f"[Hermes] {msg}" + }) + print(f"Messaged {agent_name} on issue {latest['id']}") + +def poll_issue(issue_id, interval=10, timeout=3600): + start = time.time() + print(f"Polling issue {issue_id}...") + while time.time() - start < timeout: + issue = api("GET", f"/issues/{issue_id}") + if 'error' in issue: + issue = api("GET", f"/companies/{COMPANY}/issues/{issue_id}") + if 'error' in issue: + print(f"Error: {issue['error']}") + return None + status = issue.get('status', 'unknown') + print(f" [{time.strftime('%H:%M:%S')}] Status: {status}") + if status in ['done', 'cancelled', 'blocked']: + print(f"\nFinal result:\n{issue.get('description', 'No description')}") + return issue + time.sleep(interval) + print("Timeout reached") + return None + +def run_task(agent_name, task, title=None): + issue = assign(agent_name, task, title) + if not issue: + return + return poll_issue(issue['id']) + +def broadcast(task): + issue = api("POST", f"/companies/{COMPANY}/issues", { + "title": task[:80], + "description": task, + "status": "todo" + }) + if 'error' in issue: + print(f"Error: {issue['error']}") + else: + print(f"Broadcast issue {issue['id']} created. Team can self-assign.") + +def team_status(): + agents = get_agents() + issues = api("GET", f"/companies/{COMPANY}/issues") + if not isinstance(issues, list): + print(f"Error fetching issues: {issues}") + return + active_issues = [i for i in issues if isinstance(i, dict) and i.get('status') not in ['done', 'cancelled']] + print(f"Team: OpenScanAi") + print(f"Agents: {len(agents)} | Active Issues: {len(active_issues)}") + print(f"\nAgent Health:") + for a in agents: + if not isinstance(a, dict): + continue + status_icon = "āœ…" if a.get('status') == 'idle' else "🟔" if a.get('status') == 'active' else "šŸ”“" + agent_issues = [i for i in active_issues if isinstance(i, dict) and i.get('assigneeAgentId') == a.get('id')] + print(f" {status_icon} {a.get('name','?'):<25} {a.get('status','?'):<10} ({len(agent_issues)} active issues)") + +# --------------------------------------------------------------------------- +# Preview / Dev Server functionality +# --------------------------------------------------------------------------- + +def find_free_port(start_port=8000, max_port=9000): + """Find a free port starting from start_port.""" + for port in range(start_port, max_port + 1): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(('127.0.0.1', port)) + return port + except OSError: + continue + return None + +def detect_project_type(project_path): + """Detect the type of project based on files present.""" + if not os.path.isdir(project_path): + return None + + # Check for package.json first + package_json_path = os.path.join(project_path, "package.json") + has_package_json = os.path.exists(package_json_path) + + # Check each project type + for proj_type, config in PROJECT_TYPES.items(): + # Check specific config files + for file in config["files"]: + if os.path.exists(os.path.join(project_path, file)): + return proj_type + + # For react-scripts, check package.json scripts + if proj_type == "react-scripts" and has_package_json: + try: + with open(package_json_path, 'r') as f: + pkg = json.load(f) + scripts = pkg.get("scripts", {}) + deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + if "react-scripts" in deps or "start" in scripts: + # Distinguish from Next.js/Vite which also have react + if "next" not in deps and "vite" not in deps: + return "react-scripts" + except: + pass + + # Check for static HTML + if os.path.exists(os.path.join(project_path, "index.html")): + return "static" + + # Check for package.json with dev script as fallback + if has_package_json: + try: + with open(package_json_path, 'r') as f: + pkg = json.load(f) + scripts = pkg.get("scripts", {}) + if "dev" in scripts: + # Try to infer from dependencies + deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + if "vite" in deps: + return "vite" + if "next" in deps: + return "nextjs" + return "vite" # Generic fallback + if "start" in scripts: + return "react-scripts" + except: + pass + + return None + +def get_dev_command(project_type, project_path, preferred_port=None): + """Get the appropriate dev command for the project type.""" + config = PROJECT_TYPES.get(project_type) + if not config: + return None, None + + # Check which package manager is available + package_json_path = os.path.join(project_path, "package.json") + has_npm = os.path.exists(os.path.join(project_path, "package-lock.json")) + has_pnpm = os.path.exists(os.path.join(project_path, "pnpm-lock.yaml")) + has_yarn = os.path.exists(os.path.join(project_path, "yarn.lock")) + + # Determine package manager + if has_pnpm: + pm = "pnpm" + elif has_yarn: + pm = "yarn" + else: + pm = "npm" + + # Build command + if project_type == "vite": + port = preferred_port or config["default_port"] + if pm == "npm": + return f"npm run dev -- --port {port}", port + else: + return f"{pm} dev --port {port}", port + elif project_type == "nextjs": + port = preferred_port or config["default_port"] + if pm == "npm": + return f"npm run dev -- --port {port}", port + else: + return f"{pm} dev --port {port}", port + elif project_type == "tauri": + port = preferred_port or config["default_port"] + # Tauri uses its own port config, but we can try + return f"{pm} tauri dev", port + elif project_type == "react-scripts": + port = preferred_port or config["default_port"] + if pm == "npm": + return f"PORT={port} npm start", port + else: + return f"PORT={port} {pm} start", port + elif project_type == "static": + port = preferred_port or config["default_port"] + return f"python3 -m http.server {port}", port + + return None, None + +def start_dev_server(project_path, project_type=None, preferred_port=None): + """Start a dev server for the project and return the URL.""" + if not project_type: + project_type = detect_project_type(project_path) + + if not project_type: + print(f"Could not detect project type in {project_path}") + return None + + print(f"Detected project type: {project_type}") + + # Find a free port + port = preferred_port or find_free_port(PROJECT_TYPES[project_type]["default_port"]) + if not port: + print("Could not find a free port") + return None + + # Check if port is already in use + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(('127.0.0.1', port)) == 0: + print(f"Port {port} is already in use. Trying to find another...") + port = find_free_port(port + 1) + if not port: + print("Could not find a free port") + return None + + command, actual_port = get_dev_command(project_type, project_path, port) + if not command: + print(f"Could not determine dev command for {project_type}") + return None + + print(f"Starting dev server: {command}") + print(f"Working directory: {project_path}") + + # Start the server in background + try: + process = subprocess.Popen( + command, + shell=True, + cwd=project_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # Wait a bit for server to start + time.sleep(3) + + # Check if process is still running + if process.poll() is not None: + stdout, stderr = process.communicate() + print(f"Server failed to start:") + if stderr: + print(stderr) + if stdout: + print(stdout) + return None + + # Try to connect to verify it's running + max_retries = 10 + for i in range(max_retries): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(2) + s.connect(('127.0.0.1', actual_port)) + url = f"http://localhost:{actual_port}" + print(f"āœ… Server is running at {url}") + return { + "url": url, + "port": actual_port, + "process": process, + "project_type": project_type, + "project_path": project_path + } + except: + time.sleep(1) + + print("Server started but could not verify it's responding") + url = f"http://localhost:{actual_port}" + return { + "url": url, + "port": actual_port, + "process": process, + "project_type": project_type, + "project_path": project_path + } + + except Exception as e: + print(f"Error starting server: {e}") + return None + +def preview_issue(issue_id): + """Get the workspace for an issue, detect project type, and start dev server.""" + # Get issue details + issue = api("GET", f"/issues/{issue_id}") + if 'error' in issue: + issue = api("GET", f"/companies/{COMPANY}/issues/{issue_id}") + if 'error' in issue: + print(f"Error fetching issue: {issue['error']}") + return None + + # Get execution workspace + workspace_id = issue.get('executionWorkspaceId') + if not workspace_id: + print(f"Issue {issue_id} has no execution workspace") + return None + + workspace = api("GET", f"/execution-workspaces/{workspace_id}") + if 'error' in workspace: + print(f"Error fetching workspace: {workspace['error']}") + return None + + cwd = workspace.get('cwd') + if not cwd or not os.path.isdir(cwd): + print(f"Workspace CWD not found: {cwd}") + return None + + print(f"Workspace path: {cwd}") + + # Detect and start + result = start_dev_server(cwd) + if result: + # Add a comment to the issue with the preview URL + comment_text = f"šŸš€ **Preview URL**: {result['url']}\n\nProject type: {result['project_type']}\nStarted at: {time.strftime('%Y-%m-%d %H:%M:%S')}" + comment = api("POST", f"/companies/{COMPANY}/issues/{issue_id}/comments", { + "content": comment_text + }) + if 'error' not in comment: + print(f"Added preview URL to issue {issue_id}") + + return result + +def serve_project(project_path): + """Start a dev server for a local project path.""" + if not os.path.isdir(project_path): + print(f"Path not found: {project_path}") + return None + + result = start_dev_server(project_path) + if result: + print(f"\nPreview URL: {result['url']}") + print(f"Project: {result['project_path']}") + print(f"Type: {result['project_type']}") + print(f"PID: {result['process'].pid}") + print("\nPress Ctrl+C to stop the server") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\nStopping server...") + result['process'].terminate() + result['process'].wait(timeout=5) + print("Server stopped") + return result + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == "list-agents": + list_agents() + elif cmd == "agent-status" and len(sys.argv) >= 3: + agent_status(sys.argv[2]) + elif cmd == "assign" and len(sys.argv) >= 4: + assign(sys.argv[2], " ".join(sys.argv[3:])) + elif cmd == "issues": + status = sys.argv[3] if len(sys.argv) > 3 and sys.argv[2] == "--status" else None + list_issues(status) + elif cmd == "issue-status" and len(sys.argv) >= 3: + issue_status(sys.argv[2]) + elif cmd == "update-issue" and len(sys.argv) >= 4: + update_issue(sys.argv[2], sys.argv[3]) + elif cmd == "message" and len(sys.argv) >= 4: + message_agent(sys.argv[2], " ".join(sys.argv[3:])) + elif cmd == "poll" and len(sys.argv) >= 3: + poll_issue(sys.argv[2]) + elif cmd == "run" and len(sys.argv) >= 4: + run_task(sys.argv[2], " ".join(sys.argv[3:])) + elif cmd == "broadcast" and len(sys.argv) >= 3: + broadcast(" ".join(sys.argv[2:])) + elif cmd == "team-status": + team_status() + elif cmd == "preview" and len(sys.argv) >= 3: + preview_issue(sys.argv[2]) + elif cmd == "serve" and len(sys.argv) >= 3: + serve_project(sys.argv[2]) + else: + print(f"Unknown command: {cmd}") + print(__doc__) From 3ef2587da6fefdfb831d55c0256399da70a1d107 Mon Sep 17 00:00:00 2001 From: digitone Date: Wed, 27 May 2026 16:30:12 +0530 Subject: [PATCH 2/3] feat(scripts): Add shareable preview URL generation to bridge Adds new command to paperclip-hermes-bridge.py: - Generates both local and shareable preview URLs for issue workspaces - Auto-detects project type and finds free port - Posts shareable link as issue comment - Reads PAPERCLIP_PUBLIC_URL env var or instance settings for public URL - Prints formatted preview link to terminal/chat Updates PR #47 --- scripts/paperclip-hermes-bridge.py | 88 ++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/scripts/paperclip-hermes-bridge.py b/scripts/paperclip-hermes-bridge.py index c7754761146..e98744ee1a0 100755 --- a/scripts/paperclip-hermes-bridge.py +++ b/scripts/paperclip-hermes-bridge.py @@ -17,6 +17,7 @@ team-status Quick health check of all agents preview Detect project type in workspace and start dev server, return URL serve Start a dev server for a local project and return the URL + share Generate a shareable preview URL for an issue's workspace """ import sys, json, urllib.request, urllib.error, time, argparse, os, subprocess, socket, re @@ -514,6 +515,91 @@ def serve_project(project_path): print("Server stopped") return result +def generate_shareable_url(issue_id): + """Generate a shareable preview URL for an issue's workspace.""" + # Get issue details + issue = api("GET", f"/issues/{issue_id}") + if 'error' in issue: + issue = api("GET", f"/companies/{COMPANY}/issues/{issue_id}") + if 'error' in issue: + print(f"Error fetching issue: {issue['error']}") + return None + + # Get execution workspace + workspace_id = issue.get('executionWorkspaceId') + if not workspace_id: + print(f"Issue {issue_id} has no execution workspace") + return None + + workspace = api("GET", f"/execution-workspaces/{workspace_id}") + if 'error' in workspace: + print(f"Error fetching workspace: {workspace['error']}") + return None + + cwd = workspace.get('cwd') + if not cwd or not os.path.isdir(cwd): + print(f"Workspace CWD not found: {cwd}") + return None + + # Detect project type + project_type = detect_project_type(cwd) + if not project_type: + print(f"Could not detect project type in {cwd}") + return None + + # Find a free port + port = find_free_port(PROJECT_TYPES[project_type]["default_port"]) + if not port: + print("Could not find a free port") + return None + + # Generate URLs + local_url = f"http://localhost:{port}" + + # Try to get the public URL from environment or config + public_url = os.environ.get('PAPERCLIP_PUBLIC_URL', '') + if not public_url: + # Try to get from instance settings + settings = api("GET", "/instance-settings") + if isinstance(settings, dict): + public_url = settings.get('publicBaseUrl', '') + + # Generate shareable link + share_url = f"{public_url}/preview/{issue_id}" if public_url else local_url + + # Build result + result = { + "issue_id": issue_id, + "project_type": project_type, + "workspace_path": cwd, + "local_url": local_url, + "share_url": share_url, + "port": port, + "agent_name": issue.get('assigneeAgentId', 'unassigned'), + "issue_title": issue.get('title', 'Untitled'), + } + + # Print to chat/terminal + print("\n" + "="*60) + print("šŸ”— SHAREABLE PREVIEW LINK") + print("="*60) + print(f"Issue: {result['issue_title']}") + print(f"Agent: {result['agent_name']}") + print(f"Project: {result['project_type']}") + print(f"\nšŸ“Ž Shareable URL: {result['share_url']}") + print(f"šŸ–„ļø Local URL: {result['local_url']}") + print("="*60) + + # Also post as comment to the issue + comment_text = f"šŸ”— **Shareable Preview Link**\n\n- **URL**: {result['share_url']}\n- **Local**: {result['local_url']}\n- **Project Type**: {result['project_type']}\n\n_Generated by Hermes Bridge_" + comment = api("POST", f"/companies/{COMPANY}/issues/{issue_id}/comments", { + "content": comment_text + }) + if 'error' not in comment: + print(f"\nāœ… Posted preview link to issue {issue_id}") + + return result + if __name__ == "__main__": if len(sys.argv) < 2: print(__doc__) @@ -548,6 +634,8 @@ def serve_project(project_path): preview_issue(sys.argv[2]) elif cmd == "serve" and len(sys.argv) >= 3: serve_project(sys.argv[2]) + elif cmd == "share" and len(sys.argv) >= 3: + generate_shareable_url(sys.argv[2]) else: print(f"Unknown command: {cmd}") print(__doc__) From 892947bfd651b180b353523c5628a886e323e4d5 Mon Sep 17 00:00:00 2001 From: digitone Date: Wed, 27 May 2026 16:30:12 +0530 Subject: [PATCH 3/3] feat(scripts): Add shareable preview URL generation to bridge Adds new command to paperclip-hermes-bridge.py: - Generates both local and shareable preview URLs for issue workspaces - Auto-detects project type and finds free port - Posts shareable link as issue comment - Reads PAPERCLIP_PUBLIC_URL env var or instance settings for public URL - Prints formatted preview link to terminal/chat Updates PR #47 --- scripts/tests/.gitignore | 1 + ...hermes_bridge.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 55066 bytes ...st_paperclip_hermes_bridge.cpython-314.pyc | Bin 0 -> 22734 bytes scripts/tests/test_paperclip_hermes_bridge.py | 377 ++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 scripts/tests/.gitignore create mode 100644 scripts/tests/__pycache__/test_paperclip_hermes_bridge.cpython-314-pytest-9.0.3.pyc create mode 100644 scripts/tests/__pycache__/test_paperclip_hermes_bridge.cpython-314.pyc create mode 100644 scripts/tests/test_paperclip_hermes_bridge.py diff --git a/scripts/tests/.gitignore b/scripts/tests/.gitignore new file mode 100644 index 00000000000..c18dd8d83ce --- /dev/null +++ b/scripts/tests/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/tests/__pycache__/test_paperclip_hermes_bridge.cpython-314-pytest-9.0.3.pyc b/scripts/tests/__pycache__/test_paperclip_hermes_bridge.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c9b7a2c054b841b495ad0726353511224a73cd6 GIT binary patch literal 55066 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 GOZoqa$CyR{ literal 0 HcmV?d00001 diff --git a/scripts/tests/__pycache__/test_paperclip_hermes_bridge.cpython-314.pyc b/scripts/tests/__pycache__/test_paperclip_hermes_bridge.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ec99eaac4d2c467c13d387fb96b2821c3662b59 GIT binary patch literal 22734 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") + assert bridge.detect_project_type(str(tmp_path)) == "static" + + def test_detects_react_scripts(self, tmp_path): + """Should detect Create React App from package.json with react-scripts.""" + pkg = { + "dependencies": {"react-scripts": "5.0.1"}, + "scripts": {"start": "react-scripts start"} + } + (tmp_path / "package.json").write_text(json.dumps(pkg)) + assert bridge.detect_project_type(str(tmp_path)) == "react-scripts" + + def test_detects_vite_from_deps(self, tmp_path): + """Should detect Vite from package.json devDependencies.""" + pkg = { + "devDependencies": {"vite": "^4.0.0"}, + "scripts": {"dev": "vite"} + } + (tmp_path / "package.json").write_text(json.dumps(pkg)) + assert bridge.detect_project_type(str(tmp_path)) == "vite" + + def test_detects_nextjs_from_deps(self, tmp_path): + """Should detect Next.js from package.json dependencies.""" + pkg = { + "dependencies": {"next": "^13.0.0"}, + "scripts": {"dev": "next dev"} + } + (tmp_path / "package.json").write_text(json.dumps(pkg)) + assert bridge.detect_project_type(str(tmp_path)) == "nextjs" + + def test_returns_none_for_unknown(self, tmp_path): + """Should return None for unknown project types.""" + (tmp_path / "random.txt").write_text("hello") + assert bridge.detect_project_type(str(tmp_path)) is None + + def test_returns_none_for_nonexistent_path(self): + """Should return None for non-existent path.""" + assert bridge.detect_project_type("/nonexistent/path/12345") is None + + +class TestGetDevCommand: + """Tests for get_dev_command function.""" + + def test_vite_with_npm(self, tmp_path): + """Should generate correct Vite command with npm.""" + (tmp_path / "package-lock.json").write_text("{}") + cmd, port = bridge.get_dev_command("vite", str(tmp_path)) + assert "npm run dev" in cmd + assert "--port" in cmd + assert port == 5173 + + def test_vite_with_pnpm(self, tmp_path): + """Should generate correct Vite command with pnpm.""" + (tmp_path / "pnpm-lock.yaml").write_text("") + cmd, port = bridge.get_dev_command("vite", str(tmp_path)) + assert "pnpm dev" in cmd + assert port == 5173 + + def test_nextjs_with_yarn(self, tmp_path): + """Should generate correct Next.js command with yarn.""" + (tmp_path / "yarn.lock").write_text("") + cmd, port = bridge.get_dev_command("nextjs", str(tmp_path)) + assert "yarn dev" in cmd + assert port == 3000 + + def test_react_scripts_port_override(self, tmp_path): + """Should use custom port for React scripts.""" + (tmp_path / "package-lock.json").write_text("{}") + cmd, port = bridge.get_dev_command("react-scripts", str(tmp_path), preferred_port=4000) + assert "PORT=4000" in cmd + assert port == 4000 + + def test_static_server(self, tmp_path): + """Should generate Python http.server command for static.""" + cmd, port = bridge.get_dev_command("static", str(tmp_path)) + assert "python3 -m http.server" in cmd + assert port == 8000 + + def test_tauri_command(self, tmp_path): + """Should generate Tauri dev command.""" + (tmp_path / "package-lock.json").write_text("{}") + cmd, port = bridge.get_dev_command("tauri", str(tmp_path)) + assert "tauri dev" in cmd + + +class TestApi: + """Tests for api function.""" + + @patch('urllib.request.urlopen') + def test_get_request(self, mock_urlopen): + """Should make GET request and parse JSON response.""" + mock_response = MagicMock() + mock_response.read.return_value = b'{"id": "123", "name": "test"}' + mock_urlopen.return_value.__enter__.return_value = mock_response + + result = bridge.api("GET", "/test") + assert result == {"id": "123", "name": "test"} + + @patch('urllib.request.urlopen') + def test_post_request(self, mock_urlopen): + """Should make POST request with JSON body.""" + mock_response = MagicMock() + mock_response.read.return_value = b'{"success": true}' + mock_urlopen.return_value.__enter__.return_value = mock_response + + result = bridge.api("POST", "/test", {"key": "value"}) + assert result == {"success": True} + + @patch('urllib.request.urlopen') + def test_http_error(self, mock_urlopen): + """Should handle HTTP errors gracefully.""" + from urllib.error import HTTPError + mock_urlopen.side_effect = HTTPError( + "http://test", 404, "Not Found", {}, None + ) + + result = bridge.api("GET", "/test") + assert "error" in result + assert result["status"] == 404 + + +class TestGetAgents: + """Tests for get_agents function.""" + + @patch.object(bridge, 'api') + def test_returns_list(self, mock_api): + """Should return list of agents.""" + mock_api.return_value = [ + {"id": "1", "name": "Agent1", "status": "idle"}, + {"id": "2", "name": "Agent2", "status": "active"} + ] + + result = bridge.get_agents() + assert len(result) == 2 + assert result[0]["name"] == "Agent1" + + @patch.object(bridge, 'api') + def test_handles_error(self, mock_api): + """Should return empty list on error.""" + mock_api.return_value = {"error": "Connection failed"} + + result = bridge.get_agents() + assert result == [] + + @patch.object(bridge, 'api') + def test_handles_non_list(self, mock_api): + """Should return empty list for non-list response.""" + mock_api.return_value = {"count": 5} + + result = bridge.get_agents() + assert result == [] + + +class TestGetAgentByName: + """Tests for get_agent_by_name function.""" + + @patch.object(bridge, 'get_agents') + def test_exact_match(self, mock_get_agents): + """Should find agent by exact name match.""" + mock_get_agents.return_value = [ + {"id": "1", "name": "Frontend Developer", "role": "dev"}, + {"id": "2", "name": "Backend Architect", "role": "arch"} + ] + + result = bridge.get_agent_by_name("Frontend Developer") + assert result["id"] == "1" + + @patch.object(bridge, 'get_agents') + def test_fuzzy_match(self, mock_get_agents): + """Should find agent by fuzzy match.""" + mock_get_agents.return_value = [ + {"id": "1", "name": "Frontend Developer", "role": "dev"} + ] + + result = bridge.get_agent_by_name("frontend") + assert result["id"] == "1" + + @patch.object(bridge, 'get_agents') + def test_no_match(self, mock_get_agents): + """Should return None when no match found.""" + mock_get_agents.return_value = [ + {"id": "1", "name": "Agent1", "role": "dev"} + ] + + result = bridge.get_agent_by_name("nonexistent") + assert result is None + + +class TestAssign: + """Tests for assign function.""" + + @patch.object(bridge, 'get_agent_by_name') + @patch.object(bridge, 'api') + def test_creates_issue(self, mock_api, mock_get_agent): + """Should create issue and assign to agent.""" + mock_get_agent.return_value = {"id": "agent1", "name": "Test Agent"} + mock_api.return_value = {"id": "issue1", "status": "todo"} + + result = bridge.assign("Test Agent", "Build landing page") + assert result["id"] == "issue1" + + @patch.object(bridge, 'get_agent_by_name') + def test_agent_not_found(self, mock_get_agent): + """Should return None when agent not found.""" + mock_get_agent.return_value = None + + result = bridge.assign("Nonexistent", "Task") + assert result is None + + +class TestGenerateShareableUrl: + """Tests for generate_shareable_url function.""" + + @patch.object(bridge, 'api') + @patch.object(bridge, 'detect_project_type') + @patch.object(bridge, 'find_free_port') + def test_generates_urls(self, mock_find_port, mock_detect, mock_api, tmp_path): + """Should generate both local and shareable URLs.""" + test_cwd = str(tmp_path) + mock_api.side_effect = [ + {"id": "issue1", "executionWorkspaceId": "ws1", "title": "Test Issue", "assigneeAgentId": "agent1"}, + {"cwd": test_cwd, "id": "ws1"}, + {"id": "comment1"} + ] + mock_detect.return_value = "vite" + mock_find_port.return_value = 5173 + + with patch.dict(os.environ, {"PAPERCLIP_PUBLIC_URL": "https://test.com"}): + result = bridge.generate_shareable_url("issue1") + + assert result is not None + assert result["local_url"] == "http://localhost:5173" + assert result["share_url"] == "https://test.com/preview/issue1" + assert result["project_type"] == "vite" + + @patch.object(bridge, 'api') + def test_no_workspace(self, mock_api): + """Should return None when issue has no workspace.""" + mock_api.return_value = {"id": "issue1", "executionWorkspaceId": None} + + result = bridge.generate_shareable_url("issue1") + assert result is None + + @patch.object(bridge, 'api') + def test_issue_not_found(self, mock_api): + """Should return None when issue not found.""" + mock_api.return_value = {"error": "Not found"} + + result = bridge.generate_shareable_url("nonexistent") + assert result is None + + +class TestProjectTypeConfig: + """Tests for PROJECT_TYPES configuration.""" + + def test_all_types_have_required_keys(self): + """All project types should have required configuration keys.""" + required_keys = ["files", "commands", "default_port", "url_path"] + for proj_type, config in bridge.PROJECT_TYPES.items(): + for key in required_keys: + assert key in config, f"{proj_type} missing {key}" + + def test_ports_are_valid(self): + """All default ports should be in valid range.""" + for proj_type, config in bridge.PROJECT_TYPES.items(): + port = config["default_port"] + assert 1 <= port <= 65535, f"{proj_type} has invalid port {port}" + + +class TestIntegration: + """Integration-style tests.""" + + def test_full_workflow_mocked(self, tmp_path): + """Test complete workflow with mocked API.""" + # Create a fake Vite project + (tmp_path / "vite.config.ts").write_text("export default {}") + (tmp_path / "package.json").write_text(json.dumps({ + "scripts": {"dev": "vite"} + })) + + # Detect project type + proj_type = bridge.detect_project_type(str(tmp_path)) + assert proj_type == "vite" + + # Get dev command + cmd, port = bridge.get_dev_command(proj_type, str(tmp_path)) + assert cmd is not None + assert port == 5173 + + +if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v"])