app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 166 file(s)
18 files changed
+185−43
BRIDGE_VERSION+1−1bridge.json+1−1contracts/describe.json+2dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+1−1install.sh+1−1package.json+1−1page.json+10−26rust/Cargo.toml+1−1rust/bridge/src/janitor.rs+8−2rust/bridge/src/seat.rs+24−2rust/bridge/src/signin.rs+122−1skills/fusion-license-seat/SKILL.md+11−4tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.shBRIDGE_VERSION+1−1@@ -1 +1 @@-1.11.0+1.11.1
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.11.0",+ "version": "1.11.1", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
contracts/describe.json+2@@ -2979,7 +2979,9 @@ { "example": {}, "input": {+ "account": "optional str email: which account to pick in a provider chooser (e.g. the Google account list); remembered with the method", "auto": "optional bool (do click+capture+reopen in one on-box call - recommended)",+ "method": "optional str google|apple|microsoft|facebook|password (1.11.1): HOW this Autodesk account signs in. With it the bridge drives the sign-in page itself (clicks the provider, picks the account, clicks Open Product) and REMEMBERS it, so later calls need nothing. Without one and none remembered, the reply is stage method_required with methods[]: ASK THE USER, never guess", "profile": "optional str (chrome:<email>/edge:<email> - the profile your Autodesk account is in; remembered)", "waitSec": "optional int (server-side poll budget for the fresh URL, default 32)" },
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.11.0"+version = "1.11.1" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.11.0"+version = "1.11.1" edition = "2021" [dependencies]
describe.py+1−1@@ -62,7 +62,7 @@ _T = [ {"title": "Fusion needs you", "body": "Please complete the Autodesk 2FA prompt on winvm - everything else is done."}), ("fusion_signin", "SELF-DRIVING as of 1.9.32: launches Fusion if needed, clicks Sign In, opens the OAuth in the right profile, TYPES THE EMAIL and advances to the exact remaining wall (password/2FA/handoff), toasting the user at that step only - see signinWall + _hint in the response, and the fusion-autodesk-signin skill. Sign Fusion in through the CORRECT browser profile - the fix for Fusion's dumb OAuth that fires at the OS-DEFAULT browser and lands in the wrong Autodesk account. Fusion opens its sign-in in whatever browser is default (e.g. a personal Chrome), but a user's Autodesk seat often lives in a different profile (work). This verb reads the FULL authorize URL (client_id/PKCE/state/request_id) that Fusion wrote into that wrong browser's HISTORY and re-opens it in the RIGHT profile IN THE BACKGROUND; the URL completes back to the running Fusion via the idmgr/callback + autodesk:// handoff, so the browser no longer has to be the OS default. Pass {profile:'chrome:[email protected]'} to fix + remember your Autodesk profile; else it probes each profile for a live Autodesk session (or asks). Staged: if Fusion hasn't opened the browser yet it returns stage:'click_signin' so you click Fusion's Sign In first. BEST: pass {auto:true} to run the WHOLE chain in ONE server-side call - it clicks Fusion's Sign In, polls the browser history on-box for the fresh authorize URL, and re-opens it in the target profile, all without extra remote round-trips, so it fits inside Fusion's ~2-min OAuth request expiry even on a slow/flaky relay (the manual staged path is the fallback). Fusion's request EXPIRES in ~2 min, so a stale captured URL returns stage:'stale' - restart Fusion (fusion_stop/start) for a fresh request and retry auto PROMPTLY. See the 'fusion-multiprofile-signin' skill.",- {"profile": "optional str (chrome:<email>/edge:<email> - the profile your Autodesk account is in; remembered)", "auto": "optional bool (do click+capture+reopen in one on-box call - recommended)", "waitSec": "optional int (server-side poll budget for the fresh URL, default 32)"},+ {"profile": "optional str (chrome:<email>/edge:<email> - the profile your Autodesk account is in; remembered)", "auto": "optional bool (do click+capture+reopen in one on-box call - recommended)", "method": "optional str google|apple|microsoft|facebook|password (1.11.1): HOW this Autodesk account signs in. With it the bridge drives the sign-in page itself (clicks the provider, picks the account, clicks Open Product) and REMEMBERS it, so later calls need nothing. Without one and none remembered, the reply is stage method_required with methods[]: ASK THE USER, never guess", "account": "optional str email: which account to pick in a provider chooser (e.g. the Google account list); remembered with the method", "waitSec": "optional int (server-side poll budget for the fresh URL, default 32)"}, {"stage": "str", "done": "bool", "signinWhere": "str", "signinProfile": "str", "signinProfileReason": "str", "signinWrongBrowser": "str", "signinAuthUrl": "str", "signinOpenedVia": "str (extension|extension_free)", "signinWall": "str|null (twofactor_email_code|credentials|protocol_handoff)", "notifyDelivered": "bool", "signinWallHint": "str|null", "narrate": "str"}, 120, "fusion_signin", True, {}), ("fusion_mcp_status", "Is Autodesk's Fusion MCP server up, and what does it expose? Autodesk + Anthropic ship a LOCAL MCP server at http://127.0.0.1:27182/mcp exposing Fusion's own text-to-CAD surface (read geometry, execute/update features, read Electronics design data). It binds LOOPBACK on the user's machine and was built for Claude Desktop running there, so a CLOUD AI cannot reach it at all - this bridge runs on that machine and proxies it for you. Returns enabled, serverInfo and the live tool list. OFF by default: if it reports enabled:false, call fusion_mcp_enable and the bridge turns it on for the user.",
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.11.0"+DASH_VERSION="1.11.1" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.11.0",+ "version": "1.11.1", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+10−26@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.11.0",+ "version": "1.11.1", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -76,6 +80,9 @@ "add a 3d model to a component", "make a fusion 360 library with 3d", "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page", "aps not configured", "share aps client id across my org", "set up fusion cloud search for my company",@@ -108,28 +115,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.11.0"+version = "1.11.1" edition = "2021" license = "MIT"
rust/bridge/src/janitor.rs+8−2@@ -37,9 +37,15 @@ fn tick(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value) { if running && pref_on(&sd, "autoTakeOverSeat") && facts.licensing_dialog.is_some() { let r = crate::readiness_exec::resolve_seat_dialog(ctx, 3); if r["clicks"].as_u64().unwrap_or(0) > 0 {+ // Toast only a SURPRISE takeover. When an AI moved the seat on purpose (fusion_claim_seat+ // or fusion_release_seat here or on a peer within the last 10 minutes) the user already+ // knows, and two boxes trading the seat used to toast them repeatedly (John, 2026-09-23).+ let intentional = crate::seat::recent_intentional_move(&sd, 600.0); let mut last = LAST_SEAT_NOTICE.lock().unwrap();- if now() - *last > 120.0 { *last = now(); let _ = crate::small_verbs::notify_owner(&sd, &json!({"title": "Fusion seat taken over on this machine", "body": "Autodesk flagged your account's session limit, so I clicked through the 'Active Sessions Exceeded' dialogs in the background and took the seat here. Fusion on your other machine is SUSPENDED (resumable, nothing lost) - using Fusion there will simply grab the seat back the same way.", "level": "info"}), &ctx.caller); }- eprintln!("[fusion-bridge] seat sweep: took over the session (clicks={}, verified={})", r["clicks"], r["verified"]);+ if !intentional && now() - *last > 3600.0 {+ *last = now();+ let _ = crate::small_verbs::notify_owner(&sd, &json!({"title": "Fusion seat taken over on this machine", "body": "Autodesk flagged your account's session limit, so I clicked through the 'Active Sessions Exceeded' dialogs in the background and took the seat here. Fusion on your other machine is SUSPENDED (resumable).", "level": "info"}), &ctx.caller);+ } eprintln!("[fusion-bridge] seat sweep: took over the session (clicks={}, verified={})", r["clicks"], r["verified"]); crate::fusion_prefs::action_ledger(&sd, "seat_takeover", json!({"clicks": r["clicks"], "verified": r["verified"]})); } }
rust/bridge/src/seat.rs+24−2@@ -7,8 +7,23 @@ //! `fusion_account` says who is signed in and whether this box holds the seat, `fusion_claim_seat` //! moves it here, `fusion_release_seat` gives it up so another box can take it without a dialog. use serde_json::{json, Value};+use std::path::Path; use std::time::{Duration, Instant}; +fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++/// A seat move the AI asked for, so the passive sweep does not toast the user about a surprise.+fn note_intentional_move(state_dir: &Path, what: &str) {+ let _ = std::fs::create_dir_all(state_dir);+ let _ = std::fs::write(state_dir.join("seat-move.json"), json!({"at": now_f(), "what": what}).to_string());+}+/// True when this box claimed or released the seat within `secs`.+pub fn recent_intentional_move(state_dir: &Path, secs: f64) -> bool {+ std::fs::read_to_string(state_dir.join("seat-move.json")).ok()+ .and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v["at"].as_f64()).map(|at| now_f() - at < secs).unwrap_or(false)+}+ /// Autodesk's licence blob, read through Fusion's own `Application.LicenseInformation`. fn license_info(pass: &dyn Fn(&str, Value) -> Value) -> Value { let r = pass("execute_text_command", json!({"command": "Application.LicenseInformation"}));@@ -38,11 +53,15 @@ pub fn account(ctx: &crate::addin::Ctx, pass: &dyn Fn(&str, Value) -> Value) -> let who = pass("run_modeling_script", json!({"script": "import adsk.core\napp = adsk.core.Application.get()\nu = app.currentUser\nresult = {\"email\": u.email if u else None, \"name\": u.displayName if u else None, \"userId\": u.userId if u else None, \"hub\": app.data.activeHub.name if app.data.activeHub else None, \"offline\": app.isOffLine}"})); let r = if who["data"]["result"].is_object() { who["data"]["result"].clone() } else { who["result"].clone() }; let lic = license_info(pass);- // the seat is HELD here when Fusion is signed in, authorized and not showing the seat modal+ // "Holds the seat" is only ever a LOCAL reading: Autodesk hands each machine a licence token+ // that stays valid until its own reauthorize time, so two boxes can both answer authorized+ // for a while after a move (seen live, ConfRoomROG and arav-rog, 2026-09-23). Signed in ++ // authorized + no seat modal is the strongest signal one box has; the box that LOST the seat+ // shows the modal (seatDialogOpen) or stops being authorized at its next reauthorize. let holds = r["email"].is_string() && lic["authorized"].as_bool() != Some(false) && facts.licensing_dialog.is_none(); json!({"success": true, "running": true, "signedIn": r["email"].is_string(), "account": r, "license": lic, "holdsSeat": holds, "seatDialogOpen": facts.licensing_dialog.is_some(), "windowTitle": facts.main_title,- "_hint": if holds { "This box holds the Autodesk seat. Another box's fusion_claim_seat takes it from here and suspends this Fusion resumably." }+ "_hint": if holds { "This box is signed in and licensed right now (the strongest local reading of 'holds the seat'; Autodesk lets a just-moved box stay authorized until its next reauthorize, so ask both boxes before concluding). Another box's fusion_claim_seat takes the seat from here." } else { "Signed in, but this box does not hold the seat right now (another machine has it, or the seat modal is up). fusion_claim_seat takes it." }}) } @@ -50,6 +69,7 @@ pub fn account(ctx: &crate::addin::Ctx, pass: &dyn Fn(&str, Value) -> Value) -> pub fn claim_seat(ctx: &crate::addin::Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let t0 = Instant::now(); let mut steps = Vec::new();+ note_intentional_move(&ctx.state_dir, "claim"); if ctx.host.fusion_processes().is_empty() { let r = crate::launch::start(ctx, &json!({})); steps.push(json!({"step": "start Fusion", "ok": r["success"].as_bool() != Some(false)}));@@ -75,11 +95,13 @@ pub fn claim_seat(ctx: &crate::addin::Ctx, args: &Value, pass: &dyn Fn(&str, Val } json!({"success": acct["holdsSeat"].as_bool() == Some(true), "steps": steps, "seatDialogAnswered": resolved["clicks"], "account": acct["account"], "license": acct["license"], "holdsSeat": acct["holdsSeat"], "durationMs": t0.elapsed().as_millis() as u64,+ "seatSweepWarning": "The box you took it FROM keeps its own passive takeover sweep: if its Fusion is still open it can grab the seat straight back. fusion_release_seat there (or fusion_set_seat_takeover {enabled:false}) before a long test run.", "_hint": "This box now holds the Autodesk seat; any other machine signed into the same account suspended RESUMABLY (its documents are kept, it resumes when it takes the seat back). fusion_account on the other box confirms it lost the seat."}) } /// `fusion_release_seat`: give up the seat from THIS box so another can take it cleanly. pub fn release_seat(ctx: &crate::addin::Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ note_intentional_move(&ctx.state_dir, "release"); if ctx.host.fusion_processes().is_empty() { return json!({"success": true, "released": true, "wasRunning": false, "_hint": "Fusion was not running here, so this box held no seat. Claim it on the box you want with fusion_claim_seat."}); }
rust/bridge/src/signin.rs+122−1@@ -66,6 +66,102 @@ pub fn click_background_first(ctx: &Ctx, hwnd: i64, name: Option<&str>, shot: Op } json!({"clicked": false, "via": null, "announced": false, "why": "no UIA node and no shot coords given"}) }+/// The sign-in METHODS Autodesk offers. John signs into Fusion with Google; other people use a+/// password or another provider, so the method is an input, remembered per user, never a guess.+pub const METHODS: &[&str] = &["google", "apple", "microsoft", "facebook", "password"];++fn method_pref_path(state_dir: &std::path::Path) -> std::path::PathBuf { state_dir.join("signin-method.json") }+pub fn remembered_method(state_dir: &std::path::Path) -> Option<String> {+ std::fs::read_to_string(method_pref_path(state_dir)).ok()+ .and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v["method"].as_str().map(str::to_string))+}+fn remember_method(state_dir: &std::path::Path, method: &str, account: Option<&str>) {+ let _ = std::fs::create_dir_all(state_dir);+ let _ = std::fs::write(method_pref_path(state_dir), json!({"method": method, "account": account}).to_string());+}++/// Read the sign-in page through the browser extension (a window the BRIDGE opened, so it is+/// drivable): what stage it is at, which methods it offers, which accounts a chooser lists.+fn probe_page(ctx: &Ctx, session: &str) -> Value {+ let js = r#"(() => { const t = e => (e.innerText || e.textContent || '').trim();+ const els = [...document.querySelectorAll('button,a,[role=button]')];+ const labels = els.map(t).filter(Boolean);+ const methods = labels.filter(x => /^continue with /i.test(x)).map(x => x.replace(/^continue with /i, '').toLowerCase());+ const accounts = [...document.querySelectorAll('[data-identifier],li,div')].map(t).filter(x => /@/.test(x) && x.length < 120).slice(0, 10);+ return { url: location.href, title: document.title, methods, accounts,+ hasPassword: !!document.querySelector('input[type=password]'),+ hasEmail: !!document.querySelector('input[type=email],input[name=username],input[name=UserName]'),+ openProduct: labels.some(x => /open product/i.test(x)),+ signedIn: /you're signed in|youre signed in/i.test(document.body.innerText || '') };+ })()"#;+ let r = ab_call(ctx, "nbrowser_eval", json!({"sessionId": session, "expression": js}), 30);+ let d = if r["data"].is_object() { r["data"].clone() } else { r.clone() };+ d["result"].clone().take_if_object().unwrap_or(d["value"].clone())+}++trait TakeIfObject { fn take_if_object(self) -> Option<Value>; }+impl TakeIfObject for Value { fn take_if_object(self) -> Option<Value> { if self.is_object() { Some(self) } else { None } } }++/// Click an element by its visible TEXT in the extension-driven window (no coordinates, no OCR).+fn click_text(ctx: &Ctx, session: &str, needle: &str) -> bool {+ let js = format!(r#"(() => {{ const n = {needle:?}.toLowerCase();+ const els = [...document.querySelectorAll('button,a,[role=button],[data-identifier],li,div')];+ const hit = els.find(e => {{ const t = (e.innerText || e.textContent || '').trim().toLowerCase(); return t && t.includes(n) && t.length < 200 && e.offsetParent !== null; }});+ if (!hit) return false; hit.click(); return true; }})()"#);+ let r = ab_call(ctx, "nbrowser_eval", json!({"sessionId": session, "expression": js}), 30);+ let d = if r["data"].is_object() { r["data"].clone() } else { r.clone() };+ d["result"].as_bool().or(d["value"].as_bool()).unwrap_or(false)+}++/// Drive the Autodesk sign-in page with the METHOD the user signs in with. Returns a typed+/// outcome; it never types a password and never guesses which method or which account.+pub fn drive_with_method(ctx: &Ctx, session: &str, method: Option<&str>, account: Option<&str>) -> Value {+ let mut steps: Vec<String> = Vec::new();+ for _ in 0..10 {+ let p = probe_page(ctx, session);+ if !p.is_object() { return json!({"stage": "unreadable", "steps": steps, "_hint": "The sign-in page could not be read through the browser extension. Fall back to the window on screen."}); }+ let methods: Vec<String> = p["methods"].as_array().cloned().unwrap_or_default().iter().filter_map(|m| m.as_str().map(str::to_string)).collect();+ if p["signedIn"].as_bool() == Some(true) || p["openProduct"].as_bool() == Some(true) {+ let clicked = click_text(ctx, session, "open product");+ steps.push(format!("signed in; clicked Open Product: {clicked}"));+ return json!({"stage": "signed_in", "steps": steps, "openProductClicked": clicked,+ "_hint": "Autodesk says signed in and the handoff to Fusion is running. Chrome may ask 'Open Autodesk Identity Manager?': tick Always allow and click Open (desktop_ui_click, or desktop_click on the dialog). Then poll fusion_account until it reports the account."});+ }+ if p["hasPassword"].as_bool() == Some(true) {+ return json!({"stage": "password_required", "steps": steps, "method": "password",+ "_hint": "The page is on the PASSWORD step. The bridge never types a password. Either the user types it in the window, or a stored credential fills it (pup_login / nbrowser_autologin on a profile that has it). If the user signs in with Google instead, call fusion_signin {method:\"google\"} and the bridge takes the Google route."});+ }+ // a Google/Apple/Microsoft account chooser+ if p["url"].as_str().map(|u| u.contains("accounts.google.com") || u.contains("login.microsoftonline") || u.contains("appleid.apple.com")).unwrap_or(false) {+ let accounts: Vec<String> = p["accounts"].as_array().cloned().unwrap_or_default().iter().filter_map(|a| a.as_str().map(str::to_string)).collect();+ let Some(want) = account else {+ return json!({"stage": "account_choice_required", "accounts": accounts, "steps": steps,+ "_hint": "The provider is asking WHICH account. Ask the user, then call fusion_signin {method, account:\"<email>\"}; the bridge remembers both."});+ };+ if click_text(ctx, session, want) { steps.push(format!("picked account {want}")); std::thread::sleep(std::time::Duration::from_secs(5)); continue; }+ return json!({"stage": "account_not_listed", "accounts": accounts, "wanted": want, "steps": steps,+ "_hint": "That account is not signed into this browser profile, so the provider cannot offer it. Either the user signs into it in that browser once, or pick one of accounts[]."});+ }+ // the Autodesk method page+ let Some(m) = method else {+ return json!({"stage": "method_required", "methods": if methods.is_empty() { json!(METHODS) } else { json!(methods) }, "steps": steps,+ "_hint": "ASK THE USER which sign-in method this Autodesk account uses (Google, Apple, Microsoft, Facebook or a password), then call fusion_signin {method:\"<it>\", account:\"<email>\"}. The bridge remembers it, so every later box just works."});+ };+ if m == "password" {+ if let Some(a) = account { let _ = click_text(ctx, session, "email"); steps.push(format!("password route for {a}")); }+ let _ = click_text(ctx, session, "next");+ std::thread::sleep(std::time::Duration::from_secs(4));+ continue;+ }+ let label = format!("continue with {m}");+ if click_text(ctx, session, &label) { steps.push(format!("clicked '{label}'")); std::thread::sleep(std::time::Duration::from_secs(5)); continue; }+ return json!({"stage": "method_not_on_page", "methods": methods, "wanted": m, "steps": steps,+ "_hint": "That method is not offered on this page. methods[] is what Autodesk shows; ask the user which one to use."});+ }+ json!({"stage": "gave_up", "steps": steps, "_hint": "The sign-in page did not reach a signed-in state within the bridge's attempts. Read fusion_state and the window on screen."})+}+ /// `_advance_credentials_page`: drive the Autodesk credentials page as far as code can go /// WITHOUT secrets: set the email, invoke Next, classify what the page became. Never a password. fn advance_credentials_page(ctx: &Ctx, hwnd: i64, email: Option<&str>) -> Value {@@ -250,6 +346,12 @@ fn drive_sso_signin(ctx: &Ctx, profile: &str, tab: &Value, email: Option<&str>, /// `fusion_signin`. pub fn signin(ctx: &Ctx, args: &Value, env: &Env) -> Value {+ // The sign-in METHOD is an input, remembered per user (John signs in with Google). With it,+ // the bridge drives the page itself; without it, the page answers method_required and the AI+ // asks the user once. No toast until a HUMAN step is actually reached (John, 2026-09-23).+ let method: Option<String> = args["method"].as_str().map(|m| m.trim().to_lowercase()).filter(|m| METHODS.contains(&m.as_str())).or_else(|| remembered_method(&ctx.state_dir));+ let account_arg: Option<String> = args["account"].as_str().map(|a| a.trim().to_string()).filter(|a| a.contains('@'));+ if let Some(m) = args["method"].as_str().map(|m| m.trim().to_lowercase()).filter(|m| METHODS.contains(&m.as_str())) { remember_method(&ctx.state_dir, &m, account_arg.as_deref()); } let mut steps: Vec<String> = Vec::new(); let auto = args["auto"].as_bool().unwrap_or(true); let wait_sec = args["waitSec"].as_u64().filter(|w| *w > 0).unwrap_or(32);@@ -319,7 +421,7 @@ pub fn signin(ctx: &Ctx, args: &Value, env: &Env) -> Value { let sess = format!("fusion-signin-{}", now() as i64); let target_dir = chosen.as_ref().and_then(|c| c["profileDir"].as_str().or(c["dir"].as_str()).map(str::to_string)); let email = args["email"].as_str().map(str::to_string).or_else(|| chosen.as_ref().and_then(|c| c["email"].as_str().filter(|e| !e.is_empty()).map(str::to_string)));- if let Some(td) = &target_dir { if !src_dir.is_empty() && td.trim().to_lowercase() == src_dir.trim().to_lowercase() {+ if let Some(td) = &target_dir { if method.is_none() && !src_dir.is_empty() && td.trim().to_lowercase() == src_dir.trim().to_lowercase() { steps.push(format!("Fusion already opened the sign-in in {} (profile dir {}) - no re-open needed", target, fusion_lbr::py_repr_str(td))); let mut wall0 = detect_signin_wall(ctx, &[]); if wall0["wall"].is_string() { wall0 = resolve_and_advance_wall(ctx, wall0, email.as_deref()); }@@ -341,6 +443,25 @@ pub fn signin(ctx: &Ctx, args: &Value, env: &Env) -> Value { } steps.push(if opened { format!("re-opened it in {}{}", tdesc, if open_via == Some("extension_free") { " (extension-free)" } else { "" }) } else { format!("could not open {} (raw: {})", tdesc, r.to_string().chars().take(160).collect::<String>()) }); if opened { track_signin_window(&sess, &target, "fusion"); }+ if opened && open_via == Some("extension") {+ std::thread::sleep(std::time::Duration::from_secs(3));+ let acct = account_arg.clone().or_else(|| email.clone());+ let out = drive_with_method(ctx, &sess, method.as_deref(), acct.as_deref());+ let stage = out["stage"].as_str().unwrap_or("").to_string();+ // AI-answerable stages come back WITHOUT a toast; only a real human step toasts+ let human = matches!(stage.as_str(), "password_required" | "gave_up" | "unreadable");+ let notified = if human { notify_signin_wall(ctx, if stage == "password_required" { "password" } else { "credentials" }) } else { false };+ if stage != "unreadable" {+ let mut r = out.clone();+ r["success"] = json!(stage == "signed_in" || !human);+ r["done"] = json!(stage == "signed_in");+ r["method"] = json!(method); r["account"] = json!(acct);+ r["signinProfile"] = json!(target); r["signinSession"] = json!(sess); r["notifyDelivered"] = json!(notified);+ r["stepsBefore"] = json!(steps);+ return r;+ }+ steps.push("the extension could not read the page; falling back to the window on screen".into());+ } if opened && open_via == Some("extension") && args["driveSso"].as_bool().unwrap_or(true) { let tabs = unwrap(&ab_call(ctx, "nbrowser_list_tabs", json!({"profile": target}), 45))["tabs"].as_array().cloned().unwrap_or_default(); let my_tab = tabs.iter().filter(|t| t["url"].as_str().unwrap_or("").to_lowercase().contains("autodesk") && t["owner"].as_str() == Some("agent-opened")).last().map(|t| if t["tabId"].is_null() { t["id"].clone() } else { t["tabId"].clone() });
skills/fusion-license-seat/SKILL.md+11−4@@ -47,10 +47,17 @@ automatable controls for the account menu, and **the bridge never types a creden 1. `fusion_account` tells you who it is. 2. To change it, the person signs out in Fusion (the avatar, top right, then Sign out) or signs in on the sign-in screen.-3. `fusion_signin {auto:true, profile:"<browser>:<email>"}` drives everything up to the wall:- it opens the OAuth in the right browser profile and types the email. A password or 2FA step- comes back as `signinWall` for the person, and `fusion_signin_2fa {code}` finishes a 2FA if you- can read the code (adom-google can, from their Gmail).+3. `fusion_signin {auto:true, method:"google", account:"<email>"}` signs Fusion in. The METHOD is+ how that Autodesk account signs in (google, apple, microsoft, facebook or password). The bridge+ opens the sign-in in a window it can drive, clicks the provider, picks the account in the+ provider's chooser, clicks Open Product, and remembers the method and account for next time.+ - No method given and none remembered: the reply is `stage: method_required` with `methods[]`.+ **Ask the user once** which method they use; do not guess.+ - `account_choice_required` / `account_not_listed`: the provider needs to know which account,+ or that account is not signed into this browser. Ask; no toast is sent for these.+ - `password_required`: the only step that toasts the person. The bridge never types a password.+ - After Open Product, Chrome may ask "Open Autodesk Identity Manager?": tick Always allow once+ per browser profile and click Open. Then `fusion_account` shows the account. 4. A box whose Fusion sits on the sign-in screen answers `needsSignin` from `fusion_account` and from `fusion_claim_seat`, never a made-up failure.
tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.sh