From 2b41c91cfe412f8de05993bfaa7a1e5c43fec36e Mon Sep 17 00:00:00 2001 From: David Rice Date: Tue, 31 Mar 2026 15:38:56 +0100 Subject: [PATCH] first commit --- .claude/settings.local.json | 7 + README.md | 2 + nexarxclaude.py | 248 ++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 README.md create mode 100644 nexarxclaude.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0a37a01 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebSearch" + ] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bc3384 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Introduction +Nexar & Claude Operations - Python diff --git a/nexarxclaude.py b/nexarxclaude.py new file mode 100644 index 0000000..1f8b004 --- /dev/null +++ b/nexarxclaude.py @@ -0,0 +1,248 @@ +""" +List all projects in Altium 365 workspaces via the Nexar GraphQL API. + +Requirements: + pip install requests + +Environment variables: + NEXAR_CLIENT_ID - Your Nexar application client ID + NEXAR_CLIENT_SECRET - Your Nexar application client secret + +Your Nexar application must have the 'design' scope enabled. +Authentication opens a browser window for Altium 365 login. +""" + +import base64 +import hashlib +import http.server +import os +import re +import sys +import webbrowser +from urllib.parse import parse_qs, urlparse + +import requests + +TOKEN_URL = "https://identity.nexar.com/connect/token" +AUTH_URL = "https://identity.nexar.com/connect/authorize" +NEXAR_API_URL = "https://api.nexar.com/graphql" +REDIRECT_URI = "http://localhost:3000/login" +SCOPES = "openid profile email design.domain user.access" + +WORKSPACE_NAME = "Flowbird SAS" + +QUERY_WORKSPACES_AND_PROJECTS = """ +query GetWorkspacesAndProjects($workspace: String!) { + desWorkspaces(where: {name: {eq: $workspace}}) { + url + name + description + projects { + id + name + description + } + } +} +""" + +QUERY_VARIANTS = """ +query GetVariants($projectId: ID!) { + desProjectById(id: $projectId) { + name + design { + workInProgress { + variants { + name + } + } + } + } +} +""" + +_CALLBACK_HTML = """ +Nexar Login + +

Login successful

You can close this tab and return to the terminal.

+ +""" + + +def _make_callback_handler(code_bucket): + """Return an HTTPRequestHandler that captures the OAuth2 auth code.""" + class _Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass # silence request logs + + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path != "/login": + self.send_response(404) + self.end_headers() + return + params = parse_qs(parsed.query) + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(_CALLBACK_HTML.encode()) + code_bucket.append(params.get("code", [""])[0]) + + return _Handler + + +def get_token(client_id: str, client_secret: str) -> str: + """Obtain a Nexar access token via Authorization Code + PKCE flow.""" + # Generate PKCE verifier and challenge + code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode() + code_verifier = re.sub(r"[^a-zA-Z0-9]+", "", code_verifier) + + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") + + # Build authorization URL + auth_params = ( + f"?response_type=code" + f"&client_id={client_id}" + f"&redirect_uri={REDIRECT_URI}" + f"&scope={SCOPES.replace(' ', '%20')}" + f"&code_challenge={code_challenge}" + f"&code_challenge_method=S256" + ) + auth_url = AUTH_URL + auth_params + + # Start local callback server and open browser + code_bucket = [] + server = http.server.HTTPServer(("localhost", 3000), _make_callback_handler(code_bucket)) + + print("Opening browser for Nexar login...") + webbrowser.open(auth_url) + + while not code_bucket: + server.handle_request() + server.server_close() + + auth_code = code_bucket[0] + if not auth_code: + print("Error: no authorization code received.", file=sys.stderr) + sys.exit(1) + + # Exchange auth code for token + response = requests.post( + TOKEN_URL, + data={ + "grant_type": "authorization_code", + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": REDIRECT_URI, + "code": auth_code, + "code_verifier": code_verifier, + }, + timeout=30, + ) + response.raise_for_status() + return response.json()["access_token"] + + +def graphql(token: str, url: str, query: str, variables: dict = None) -> dict: + headers = {"Authorization": f"Bearer {token}"} + payload = {"query": query} + if variables: + payload["variables"] = variables + + response = requests.post(url, json=payload, headers=headers, timeout=30) + if not response.ok: + print(f"HTTP {response.status_code}: {response.text}", file=sys.stderr) + response.raise_for_status() + + result = response.json() + if "errors" in result: + for err in result["errors"]: + print(f"GraphQL error: {err.get('message')}", file=sys.stderr) + sys.exit(1) + + return result["data"] + + +def main(): + client_id = os.environ.get("NEXAR_CLIENT_ID") + client_secret = os.environ.get("NEXAR_CLIENT_SECRET") + + if not client_id or not client_secret: + print( + "Error: NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET must be set.", + file=sys.stderr, + ) + sys.exit(1) + + token = get_token(client_id, client_secret) + print("Authenticated.\n") + + print(f"Fetching projects from workspace '{WORKSPACE_NAME}'...") + data = graphql(token, NEXAR_API_URL, QUERY_WORKSPACES_AND_PROJECTS, {"workspace": WORKSPACE_NAME}) + workspaces = data["desWorkspaces"] + + if not workspaces: + print( + "No workspaces found. Ensure the 'design' scope is enabled on your " + "Nexar application and you are a member of an Altium 365 workspace." + ) + return + + projects = workspaces[0].get("projects") or [] + + if not projects: + print("No projects found in this workspace.") + return + + print(f"\nProjects in '{WORKSPACE_NAME}':") + print("-" * 60) + for i, project in enumerate(projects, start=1): + description = project.get("description") or "" + desc_str = f" — {description}" if description else "" + print(f" [{i}] {project['name']}{desc_str}") + + print() + while True: + try: + choice = int(input(f"Select a project (1-{len(projects)}): ")) + if 1 <= choice <= len(projects): + break + print(f" Please enter a number between 1 and {len(projects)}.") + except ValueError: + print(" Please enter a valid number.") + + selected_project = projects[choice - 1] + print(f"\nSelected project: {selected_project['name']}") + + # Fetch variants for the selected project + print("\nFetching variants...") + data = graphql(token, NEXAR_API_URL, QUERY_VARIANTS, {"projectId": selected_project["id"]}) + wip = data["desProjectById"]["design"]["workInProgress"] + variants = wip.get("variants") or [] + + if not variants: + print("No variants found for this project.") + return + + print(f"\nVariants in '{selected_project['name']}':") + print("-" * 60) + for i, variant in enumerate(variants, start=1): + print(f" [{i}] {variant['name']}") + + print() + while True: + try: + choice = int(input(f"Select a variant (1-{len(variants)}): ")) + if 1 <= choice <= len(variants): + break + print(f" Please enter a number between 1 and {len(variants)}.") + except ValueError: + print(" Please enter a valid number.") + + selected_variant = variants[choice - 1] + print(f"\nSelected variant: {selected_variant['name']}") + + +if __name__ == "__main__": + main() \ No newline at end of file