app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
AI Flow 0.2.65: unquoted wiki symbols, current verified list, macOS desktop stand-in (#196 #192 #195)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
14 files changed
+183−40
Cargo.lock+10−10Cargo.toml+1−1bin/adom-aiflowcrates/adom-aiflow/src/main.rs+17−4crates/adom-aiflow/src/verified.rs+42−2docs/release-0.2.65.md+19package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+1−1skills/aiflow-project-page/SKILL.md+2tools/kicad-cli-remote+33−10tools/project-page.py+18−5tools/test-kicad-cli-remote.py+23−3tools/test_project_page.py+15−2Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.64"+version = "0.2.65" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.64"+version = "0.2.65" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.64"+version = "0.2.65" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.64"+version = "0.2.65" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.64"+version = "0.2.65" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.64"+version = "0.2.65" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.64"+version = "0.2.65" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.64"+version = "0.2.65" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.64"+version = "0.2.65" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.64"+version = "0.2.65" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.64"+version = "0.2.65" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+17−4@@ -3088,6 +3088,18 @@ fn unacked_orientation(r: &Run, warnings: &[Value]) -> Vec<String> { } /// A wiki page's page.json through `adom-wiki repo show` (None when it cannot be read).+/// Issue #192: record the verified list as `by` (export) checked it, as `summary.verifiedAt<By>` and as+/// `summary.verified`, which always holds the newest check a page should show. Saves the run.+fn record_verified(r: &mut Run, by: &str) {+ let board_path = r.current_board();+ let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(r), &spec_soft(r)));+ let snap = verified::snapshot(&verified::run_items(r, facts.as_ref()), by, &now());+ let key = format!("verifiedAt{}{}", by[..1].to_uppercase(), &by[1..]);+ r.data["summary"][key.as_str()] = snap.clone();+ r.data["summary"]["verified"] = snap;+ r.save().unwrap();+}+ /// Issue #193: the BOM references of an MPN, from the parts record. fn part_refs(r: &Run, mpn: &str) -> Vec<String> { r.data["outcomes"]["parts"]["parts"].as_array().into_iter().flatten().filter(|p| p["mpn"].as_str().map(|m| m.eq_ignore_ascii_case(mpn)).unwrap_or(false))@@ -3560,7 +3572,7 @@ fn main() { report["orderFiles"] = order; write_json(&out_dir.join("export-check.json"), &report); r.outcome("export", report.clone());- r.save().unwrap();+ record_verified(&mut r, "export"); let mut hints = order_hints; hints.extend(report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}"))); hints.push(format!("file: {}", out_dir.join(export::ORDER_NOTE).display()));@@ -3587,7 +3599,7 @@ fn main() { if report.get("files").is_some() { r.outcome("export", report.clone()); r.log("export-findings", json!({"board": board_path, "dir": out_dir, "problems": problems}));- r.save().unwrap();+ record_verified(&mut r, "export"); let mut hints = vec![fill_hint, "Fix the board or its footprint attributes (DNP, exclude from BOM/position files) and run `export` again. Until then, tell the human which files do not match the board and why before anything is ordered.".into()]; hints.extend(report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}"))); findings(&format!("manufacturing files written to {}, but they do not check out against the board: {}", out_dir.display(), problems.join("; ")), &hints);@@ -5576,7 +5588,7 @@ fn main() { let mut reviewed: Vec<String> = acked_peaks.iter().map(|c| format!("acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} ({}): \"{}\" by {}", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))).collect(); reviewed.extend(sink_assumptions.as_array().into_iter().flatten().filter_map(|a| a.as_str()).map(|a| format!("board-sink assumption: {a}"))); let checked = verified::run_items(&r, facts.as_ref());- let summary = json!({"verified": verified::to_json(&checked), "mechanical": facts.as_ref().map(|f| f.mech.to_json()), "poursInSavedBoard": facts.as_ref().map(|f| f.fills.clone()), "engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true, "acknowledgedPeaks": acked_peaks, "boardSinkAssumptions": sink_assumptions});+ let summary = json!({"verified": verified::snapshot(&checked, "finish", &now()), "mechanical": facts.as_ref().map(|f| f.mech.to_json()), "poursInSavedBoard": facts.as_ref().map(|f| f.fills.clone()), "engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true, "acknowledgedPeaks": acked_peaks, "boardSinkAssumptions": sink_assumptions}); r.data["summary"] = summary; if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); } r.data["summary"]["projectLibraryParts"] = json!(lib_parts);@@ -7295,7 +7307,8 @@ fn main() { if let Some(a) = agy_report(&r) { r.data["summary"]["antigravity"] = a; } r.data["delivery"] = json!({"at": t, "video": std::fs::canonicalize(vp).map(|p| p.display().to_string()).unwrap_or(video.clone()), "videoBytes": bytes, "message": message}); r.data["summary"]["promptToDeliveredMinutes"] = json!(r.delivered_minutes());- r.data["summary"]["verifiedAtDelivery"] = verified::to_json(&checked);+ r.data["summary"]["verifiedAtDelivery"] = verified::snapshot(&checked, "deliver", &t);+ r.data["summary"]["verified"] = r.data["summary"]["verifiedAtDelivery"].clone(); r.data["summary"]["steps"] = Value::Object(r.step_report()); r.data["summary"]["turns"] = json!(r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0)); r.data["summary"]["clips"] = json!(r.data["captures"].as_array().map(|a| a.iter().filter(|c| c.get("file").is_some()).map(|c| json!({"step": c["step"], "visit": c["visit"], "back": c["back"], "why": c["why"], "start": c["start"], "stop": c["stop"], "file": c["file"], "camera": c["camera"], "died": c["died"], "resumedAfterGap": c["resumedAfterGap"], "truncated": c["truncated"]})).collect::<Vec<_>>()).unwrap_or_default());
crates/adom-aiflow/src/verified.rs+42−2@@ -164,10 +164,31 @@ pub fn verdict(items: &[Item]) -> String { "Every listed check passed. This is not a fabrication sign-off: a human review of the board, the BOM and the fab files is still advised before ordering.".into() } else { let open: Vec<&str> = items.iter().filter(|i| !matches!(i.state, State::Yes | State::NotApplicable(_))).map(|i| i.name).collect();- format!("Not cleared for fabrication: {} not verified. Do not tell the human the board is ready to fabricate.", open.join(", "))+ format!("Not cleared for fabrication: {} not verified yet.", open.join(", ")) } } +/// Each item as a reader sees it: "fills in Gerbers not known (not exported yet (run export))" (issue #192:+/// a page printed the raw state object).+pub fn lines(items: &[Item]) -> Vec<String> {+ line(items).trim_start_matches("verified: ").split(", ").map(str::to_string).collect::<Vec<_>>()+ .into_iter().fold(Vec::new(), |mut acc, part| {+ // a reason may itself hold ", ": glue it back onto its item+ if items.iter().any(|i| part.starts_with(i.name)) || acc.is_empty() { acc.push(part) } else { let last = acc.len() - 1; acc[last].push_str(&format!(", {part}")); }+ acc+ })+}++/// The list as checked by `by` (finish, export or deliver) at `at`. `summary.verified` always holds the+/// newest one (issue #192: a page read finish's pre-export snapshot after export and deliver had passed).+pub fn snapshot(items: &[Item], by: &str, at: &str) -> Value {+ let mut v = to_json(items);+ v["checkedBy"] = json!(by);+ v["at"] = json!(at);+ v["lines"] = json!(lines(items));+ v+}+ pub fn to_json(items: &[Item]) -> Value { let mut m = Map::new(); for i in items {@@ -219,7 +240,10 @@ pub fn with_tool_proofs(outcomes: &Map<String, Value>, dir: &std::path::Path) -> } /// "verified: ..." and the verdict, as one message tail.-pub fn message(items: &[Item]) -> String { format!("{}\n{}", line(items), verdict(items)) }+/// What the AI is told: the line, the verdict, and (when something is open) never to call the board ready.+pub fn message(items: &[Item]) -> String {+ format!("{}\n{}{}", line(items), verdict(items), if all_passed(items) { "" } else { " Do not tell the human the board is ready to fabricate." })+} #[cfg(test)] mod tests {@@ -366,4 +390,20 @@ mod tests { assert!(v.contains("not a fabrication sign-off") && v.contains("human review"), "{v}"); assert!(!v.to_lowercase().contains("ready to fabricate")); }++ #[test]+ fn the_snapshot_says_who_checked_it_and_reads_without_ai_wording() {+ // issue #192: the page showed {'state': 'not known', ...} and an instruction meant for the AI+ let items = vec![+ Item { name: "DRC", state: State::Yes },+ Item { name: "fills in Gerbers", state: State::Unknown("not exported yet, run export".into()) },+ ];+ let v = snapshot(&items, "export", "2026-11-11T00:00:00Z");+ assert_eq!(v["checkedBy"], "export");+ assert_eq!(v["lines"], json!(["DRC yes", "fills in Gerbers not known (not exported yet, run export)"]));+ assert_eq!(v["verdict"], "Not cleared for fabrication: fills in Gerbers not verified yet.");+ assert!(message(&items).ends_with("Do not tell the human the board is ready to fabricate."), "the AI is still told");+ let all = vec![Item { name: "DRC", state: State::Yes }];+ assert!(!message(&all).contains("Do not tell"));+ } }
docs/release-0.2.65.mdadded+19@@ -0,0 +1,19 @@+# AI Flow 0.2.65++- **#196: wiki symbols written without quotes are packaged.** `project-page` read a symbol's name, its+ properties and `(extends parent)` only in the quoted form, so a page written `(property Footprint lib:name ...)`+ (adom/cl05b104kb54pnc, adom/cl10a105kb8nnnc) read as having no Footprint and stopped the board's page. The+ library text now gets its quotes when it is read (wiki export and project library), so the footprint+ resolves and the `PCM_<lib>` nickname rewrite reaches it too.+- **#192: the verified list a page shows is the newest one.** `summary.verified` was finish's snapshot,+ taken before `export`, so a page built from it said "Not cleared for fabrication" after export and deliver+ had passed. `export` and `deliver` now write it too (and `summary.verifiedAtExport`, `verifiedAtDelivery`),+ each with `checkedBy`, `at` and `lines` (each item as people read it, never a raw state object). The+ verdict in the run is written for readers; "Do not tell the human..." is only in what the AI is told.+ The aiflow-project-page skill says to build the page from it after export.+- **#195: the desktop KiCad stand-in runs on a Mac.** `adom-aiflow-kicad-cli-remote` detects macOS (the+ bridge's platform, the target's OS, `KICAD_REMOTE_OS=mac`, or a kicad-cli under /Applications), runs bash+ through `shell_execute` there (Adom Bridge's macOS `run_script` ignores a scriptB64, adom/adom-bridge#262),+ and moves files `send_files` left in ~/Downloads into the job folder (it ignores `dest` on macOS,+ adom/adom-bridge#263). Checksums fall back to `shasum`. The skill says to keep a Mac project out of+ ~/Documents, ~/Desktop and ~/Downloads, which macOS privacy settings keep the bridge from reading.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.64",+ "version": "0.2.65", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.64",+ "version": "0.2.65", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+1−1@@ -551,7 +551,7 @@ Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification. A machine contact or pin hole (`MC<n>`, `MP<n>`) carries an inserted conductor (a battery lead, a needle) the solver does not model, so its barrel is not failed on plating alone: the screen lists it as covered; declare the conductor with `spec.contacts.<REF>.insertMm2` (14 AWG is 2.08) to screen it on plating plus the conductor. A single-pad part (a contact) is always a load terminal, and a terminal in `loads.<net>.terminals` may name the reference alone. -Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs `pcb drc`, `sch erc`, `sch export netlist` and `pcb export gerbers|drill|pos` on a desktop's KiCad through Adom Bridge (`KICAD_REMOTE_TARGET` names the desktop; Windows or Linux, detected from the bridge; on Linux the job folder is `~/.adom-aiflow-kicad`, removed after each call; relative output paths are fine); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking.+Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs `pcb drc`, `sch erc`, `sch export netlist` and `pcb export gerbers|drill|pos` on a desktop's KiCad through Adom Bridge (`KICAD_REMOTE_TARGET` names the desktop; Windows, Linux or macOS, detected from the bridge, or set `KICAD_REMOTE_OS`; on Linux and macOS the job folder is `~/.adom-aiflow-kicad`, removed after each call; relative output paths are fine; on a Mac keep the KiCad project out of ~/Documents, ~/Desktop and ~/Downloads, which macOS privacy settings stop Adom Bridge from reading back, and use a folder like ~/AdomProjects); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking. Component library footage: keep the full `library-tour` separate. A reviewed five-second `library-overview` artifact may enter compose, with `--review` binding the exact selected STEP/GLB variants, current board, clip and native readback/screenshot. See [the review contract](https://wiki.adom.inc/adom/adom-aiflow/files/docs/library-overview-review.md). Never claim native model matching from a tour alone.
skills/aiflow-project-page/SKILL.md+2@@ -36,4 +36,6 @@ A board project must showcase its board assembly, not one of its component asset 6. Push only the reviewed files. Fetch `/api/pages/<owner>/<slug>` into a local JSON and rerun with `--live-json <file>`; require exact model/datasheet binding equality and the intended rendered README. Verify uploaded bytes or hashes. Use one existing Pup window or Hydrogen tab according to the user's preference; never open duplicates. 7. Open the actual page, load the 3D hero, orbit it, inspect all screenshots and read the first and final datasheet pages. Verify the datasheet follows the README in live section order. Record screenshots/evidence and a short action clip where recording was requested. A successful push or HTTP200 is not rendered acceptance. Fix wrong assets before calling the project page delivered. +What a page says about fabrication readiness comes from the run's `summary.verified` (issue #192): it always holds the newest check (`checkedBy` is finish, export or deliver, with `at`), `lines` reads each item as people should see it ("fills in Gerbers not known (not exported yet)"), and `verdict` is written for readers. Never publish an older snapshot after a later check, never print a raw state object, and never put an instruction meant for the AI on a page. Build or refresh the page after `export` (and after `deliver`), so it shows that check.+ Call this preflight and review before final delivery whenever the run publishes a project page. It supplements, never replaces, electrical, thermal, mechanical, fabrication or simulation gates. A draft may be published with explicit limitations; do not claim final delivery while page acceptance remains open.
tools/kicad-cli-remote+33−10@@ -18,6 +18,7 @@ # Local outputs may be relative: they are resolved against the current folder before anything is pulled. # Windows desktops run cmd through shell_execute in %TEMP%. Linux desktops (kicad-bridge's Linux build, a # Flatpak KiCad too) run bash through run_script in ~/.adom-aiflow-kicad (home, not /tmp: a Flatpak has its own /tmp).+# macOS desktops run the same bash, through shell_execute (issue #195), in ~/.adom-aiflow-kicad. # Env: KICAD_REMOTE_TARGET (adom-aiflow sets the run's desktop; unset: this machine's own desktop), # KICAD_REMOTE_CLI (native kicad-cli path on it), KICAD_REMOTE_THREAD (ai-thread name for the bridge; # default kicad-cli-remote), KICAD_REMOTE_OS (windows|linux; default: detected),@@ -52,8 +53,8 @@ d=json.load(sys.stdin);print((d.get("install") or {}).get("kicadCli",""));print( # The desktop's OS: KICAD_REMOTE_OS, else kicad_status platform.os (kicad-bridge 1.0.56+), else # `adom-bridge targets` os, else the shape of the kicad-cli path. find_os() {- [[ -n "$ROS" ]] && return 0- local o="${KSTATUS_OS,,}"+ case "$ROS" in linux|mac|windows) return 0;; esac+ local o="${ROS:-${KSTATUS_OS,,}}" if [[ -z "$o" && -n "$T" ]]; then o=$(adom-bridge targets --ai-thread "$TH" 2>/dev/null | python3 -c 'import sys,json t=sys.argv[1].lower()@@ -64,12 +65,21 @@ for x in d.get("targets") or []: fi case "$o" in *linux*) ROS=linux;;+ *darwin*|*macos*|*mac\ os*|osx|mac) ROS=mac;; *windows*|win*) ROS=windows;;- *) if [[ "$CLI" == /* ]]; then ROS=linux; else ROS=windows; fi;;+ *) if [[ "$CLI" == /Applications/* ]]; then ROS=mac; elif [[ "$CLI" == /* ]]; then ROS=linux; else ROS=windows; fi;; esac }+# Linux and macOS desktops take bash; Windows takes cmd lines.+posix() { [[ "$ROS" != windows ]]; } # Linux: run a bash script on the desktop (run_script; no shell-string quoting), print the bridge reply.+# macOS (issue #195): Adom Bridge's run_script there answers "No command specified" to a scriptB64, so the+# script goes through shell_execute as base64 piped into bash (still no shell-string quoting of the script). lx_script() {+ if [[ "$ROS" == mac ]]; then+ ab shell_execute "$(python3 -c 'import json,sys,base64;print(json.dumps({"command":"echo "+base64.b64encode(sys.argv[1].encode()).decode()+" | base64 -d | bash","timeoutSeconds":int(sys.argv[2]),"reason":sys.argv[3]}))' "$1" "$2" "$3")"+ return+ fi ab run_script "$(python3 -c 'import json,sys,base64;print(json.dumps({"interpreter":"bash","scriptB64":base64.b64encode(sys.argv[1].encode()).decode(),"timeoutSeconds":int(sys.argv[2]),"reason":sys.argv[3]}))' "$1" "$2" "$3")" } # Linux: one command line, every argument quoted for bash.@@ -78,7 +88,7 @@ find_tmp() { # The job folder parent: KICAD_REMOTE_TMP, else %TEMP% (Windows) or ~/.adom-aiflow-kicad (Linux). RTMP="${KICAD_REMOTE_TMP:-}" [[ -n "$RTMP" ]] && return 0- if [[ "$ROS" == linux ]]; then+ if posix; then RTMP_OURS=1 RTMP=$(lx_script 'mkdir -p "$HOME/.adom-aiflow-kicad" && printf %s "$HOME/.adom-aiflow-kicad"' 30 "job folder for an adom-aiflow kicad-cli job" | jstdout | tr -d '\r\n') [[ "$RTMP" == /* ]] || { echo "kicad-cli-remote: could not make a job folder in the home folder on ${T:-this machine}" >&2; exit 2; }@@ -88,7 +98,7 @@ find_tmp() { fi } cleanup_remote() {- if [[ "$ROS" == linux ]]; then+ if posix; then # only a job folder, and only inside the job parent [[ "$1" == "$RTMP"/aiflow-* ]] && lx_script "rm -rf -- $(printf '%q' "$1")${RTMP_OURS:+; rmdir -- $(printf '%q' "$RTMP") 2>/dev/null}; true" 60 "clean up the kicad-cli job folder" >/dev/null || true return 0@@ -98,7 +108,20 @@ cleanup_remote() { # Send local files to one remote folder: <dest> <files...> send_to() { local d="$1"; shift- ab send_files "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"files for a kicad-cli job for adom-aiflow (native KiCad on the desktop)"}))' "$d" "$@")" >/dev/null+ local reply moves+ reply=$(ab send_files "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"files for a kicad-cli job for adom-aiflow (native KiCad on the desktop)"}))' "$d" "$@")")+ posix || return 0+ # issue #195: Adom Bridge on macOS saves into ~/Downloads whatever dest says, and its destinationPaths+ # say where; move each file that landed elsewhere into the job folder+ moves=$(python3 -c 'import json,os,shlex,sys+d=sys.argv[1].rstrip("/")+try: r=json.loads(sys.argv[2])+except Exception: r={}+for p in r.get("destinationPaths") or []:+ p=str(p)+ if os.path.dirname(p.rstrip("/")) != d: print("mv -f -- %s %s" % (shlex.quote(p), shlex.quote(d + "/" + os.path.basename(p.rstrip("/")))))' "$d" "$reply")+ [[ -n "$moves" ]] || return 0+ lx_script "mkdir -p $(printf '%q' "$d") && $(tr '\n' ';' <<<"$moves")" 120 "move the sent files into the kicad-cli job folder" >/dev/null } # The project files a kicad-cli job needs, grouped by folder relative to the job folder, one # "<sub>\t<json list>" line per folder. Modes:@@ -199,7 +222,7 @@ send_schematic() { RENV=() # NAME=value pairs set on the desktop for the next run_cli (issue #177: the 3rd-party model folder) run_cli() { local to="$1" why="$2" mk="$3"; shift 3- if [[ "$ROS" == linux ]]; then+ if posix; then local envp=""; [[ ${#RENV[@]} -gt 0 ]] && envp="env $(lx_cmd "${RENV[@]}")" lx_script "${mk:+mkdir -p $(printf '%q' "$mk") && }${envp}$(lx_cmd "$CLI" "$@")" "$to" "$why" else@@ -225,7 +248,7 @@ PY } # The names in a remote folder, one per line. list_remote() {- if [[ "$ROS" == linux ]]; then+ if posix; then lx_script "cd $(printf '%q' "$1") && ls -1A" 60 "list the kicad-cli outputs" | jstdout else ab shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"dir /b \""+sys.argv[1].replace("/","\\")+"\"","reason":"list the kicad-cli outputs"}))' "$1")" | jstdout@@ -238,11 +261,11 @@ pull_to() { local save; save="$(absp "$1")"; shift mkdir -p "$save" ab pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"saveTo":sys.argv[1],"reason":"bring the kicad-cli outputs back"}))' "$save" "$@")" >/dev/null 2>&1 || true- [[ "$ROS" == linux ]] || return 0+ posix || return 0 local miss=() p sums for p in "$@"; do [[ -f "$save/$(basename "$p")" ]] || miss+=("$p"); done [[ ${#miss[@]} -eq 0 ]] && return 0- sums=$(lx_script "sha256sum -- $(printf '%q ' "${miss[@]}")" 60 "checksums of the kicad-cli outputs" | jstdout || true)+ sums=$(lx_script "{ sha256sum -- $(printf '%q ' "${miss[@]}") 2>/dev/null || shasum -a 256 -- $(printf '%q ' "${miss[@]}"); }" 60 "checksums of the kicad-cli outputs" | jstdout || true) python3 - "$T" "$TH" "$save" "$sums" "${miss[@]}" <<'PY' import base64, hashlib, json, os, subprocess, sys target, thread, save, sums, paths = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5:]
tools/project-page.py+18−5@@ -377,6 +377,19 @@ def unit_names_follow_parent(text): return text +_ATOM = r'[^\s()"]+'+_QSTR = r'"(?:[^"\\]|\\.)*"'+++def quote_atoms(text):+ """KiCad reads a symbol's name, its property names and values, and (extends parent) with or without quotes+ (issue #196: wiki symbols written `(property Footprint lib:name ...)`). The rest of this script reads and+ rewrites the quoted form KiCad itself writes, so put the quotes in once, where a library is read."""+ text = re.sub(r'\((symbol|extends)\s+(' + _ATOM + r')(?=[\s)])', lambda m: f'({m.group(1)} "{m.group(2)}"', text)+ text = re.sub(r'\(property\s+(' + _ATOM + r')(?=\s)', lambda m: f'(property "{m.group(1)}"', text)+ return re.sub(r'\(property\s+(' + _QSTR + r')\s+(' + _ATOM + r')(?=[\s)])', lambda m: f'(property {m.group(1)} "{m.group(2)}"', text)++ def top_symbols(text): """{name: (start, end)} of the library's top-level symbols.""" root = sx_parse(text)@@ -487,7 +500,7 @@ def merge_project_parts(parts, sym_text, fp_dir, m_dir, lib, pkgid): blocks, have = [], set(top_symbols(sym_text)) if sym_text else set() for p in parts: pl = p["projectLib"]- src = open(pl["symbolFile"]).read()+ src = quote_atoms(open(pl["symbolFile"]).read()) spans = top_symbols(src) want = [pl["symbol"]] root = sx_parse(src)@@ -531,10 +544,10 @@ def sym_property(sym_text, syms, name, prop, seen=()): """A symbol's property value, following (extends "parent") when the symbol itself has none.""" a, b = syms[name] block = sym_text[a:b]- m = re.search(r'\(property\s+"' + re.escape(prop) + r'"\s+"([^"]*)"', block)+ m = re.search(r'\(property\s+"?' + re.escape(prop) + r'"?\s+(?:"([^"]*)"|([^\s()"]+))', block) if m:- return m.group(1)- e = re.search(r'\(extends\s+"([^"]*)"', block)+ return m.group(1) if m.group(1) is not None else m.group(2)+ e = re.search(r'\(extends\s+"?([^"\s()]+)"?', block) if e and e.group(1) in syms and e.group(1) not in seen: return sym_property(sym_text, syms, e.group(1), prop, seen + (name,)) return ""@@ -872,7 +885,7 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts t = re.sub(r'\(model\s+"?3dmodels/([^")\s]+)"?', lambda mm: f'(model "${{KICAD10_3RD_PARTY}}/3dmodels/{pkgid.replace(".", "_")}/{lib}.3dshapes/{mm.group(1)}"', t) open(os.path.join(fp_dir, m), "w").write(t) sp = os.path.join(sym_dir, f"{lib}.kicad_sym")- t = open(sp).read() if os.path.isfile(sp) else ""+ t = quote_atoms(open(sp).read()) if os.path.isfile(sp) else "" # one entry per MPN: the parts record lists a part once per reference group, so a value on several # references (four 1 uF caps) arrives more than once and must be merged only once proj = list({p["mpn"]: p for p in reversed([p for p in parts if p.get("projectLib") and not p.get("wiki")])}.values())[::-1]
tools/test-kicad-cli-remote.py+23−3@@ -29,9 +29,14 @@ with open(os.environ["MOCK_LOG"], "a") as f: f.write(json.dumps([verb, args]) + def out(d): print(json.dumps(d)); sys.exit(0) def p(x): return x.replace("\\", "/") if verb == "targets":- out({"targets": [{"name": "box", "os": "Linux" if OS == "linux" else "Windows"}]})+ out({"targets": [{"name": "box", "os": {"linux": "Linux", "mac": "macOS", "windows": "Windows"}[OS]}]}) if verb == "kicad_status":- out({"install": {"kicadCli": CLI if OS == "linux" else "C:" + CLI.replace("/", "\\")}, "platform": {"os": OS}})+ out({"install": {"kicadCli": "C:" + CLI.replace("/", "\\") if OS == "windows" else CLI}, "platform": {"os": "darwin" if OS == "mac" else OS}})+if verb == "send_files" and OS == "mac":+ # Adom Bridge on macOS (issue #195): dest is ignored, the files land in ~/Downloads+ d = os.path.join(HOME, "Downloads"); os.makedirs(d, exist_ok=True)+ for f in args["filePaths"]: shutil.copy(f, d)+ out({"success": True, "destinationPaths": [os.path.join(d, os.path.basename(f)) for f in args["filePaths"]]}) if verb == "send_files": d = p(args["dest"]); os.makedirs(d, exist_ok=True) for f in args["filePaths"]: shutil.copy(f, d)@@ -43,6 +48,15 @@ if verb == "pull_file": for f in args["filePaths"]: if os.path.isfile(p(f)): shutil.copy(p(f), save) out({"success": True})+if verb == "run_script" and OS == "mac":+ out({"success": False, "error": "No command specified"})+if verb == "shell_execute" and OS == "mac":+ m = re.match(r'echo ([A-Za-z0-9+/=]+) \| base64 -d \| bash$', args["command"])+ assert m, args["command"]+ s = base64.b64decode(m.group(1)).decode()+ # macOS has shasum, not sha256sum+ r = subprocess.run(["bash", "-c", s], capture_output=True, text=True, env=dict(os.environ, HOME=HOME))+ out({"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr, "exitCode": r.returncode}) if verb == "run_script": assert OS == "linux" s = base64.b64decode(args["scriptB64"]).decode()@@ -164,6 +178,12 @@ def run(osname): assert all(d.startswith(home + "/.adom-aiflow-kicad/aiflow-") for d in dests), dests assert not os.path.exists(os.path.join(home, ".adom-aiflow-kicad")), "the Linux job folder was left behind" assert not any(c[0] == "shell_execute" for c in calls)+ elif osname == "mac":+ # issue #195: bash goes through shell_execute, and the files sent to ~/Downloads were moved into the job+ assert all(d.startswith(home + "/.adom-aiflow-kicad/aiflow-") for d in dests), dests+ assert not os.path.exists(os.path.join(home, ".adom-aiflow-kicad")), "the macOS job folder was left behind"+ assert os.listdir(os.path.join(home, "Downloads")) == [], os.listdir(os.path.join(home, "Downloads"))+ assert not any(c[0] == "run_script" for c in calls) else: assert all(d.startswith(home + "/Temp/aiflow-") for d in dests), dests assert os.listdir(os.path.join(home, "Temp")) == [], os.listdir(os.path.join(home, "Temp"))@@ -178,5 +198,5 @@ def run(osname): if __name__ == "__main__":- for o in sys.argv[1:] or ["linux", "windows"]:+ for o in sys.argv[1:] or ["linux", "mac", "windows"]: run(o)
tools/test_project_page.py+15−2@@ -12,6 +12,10 @@ GOOD = '(symbol "GOOD1" (property "Reference" "R" (at 0 0 0)) (property "Footpri CHILD = '(symbol "CHILD1" (extends "GOOD1"))' # noah's two pages: written without quotes BAD = '(symbol bsc016n06ns (property Reference Q (at 0 0 0)))'+# a symbol KiCad cannot load whatever the quoting+BROKEN = '(symbol "bsc016n06ns" (property "Reference" "Q" (at 0 0 0)) (property "Footprint" "x:GOOD1" (at 0 0 0)) (unknown_token))'+# issue #196: a page written with unquoted atoms, which KiCad reads; project-page puts the quotes in+UNQUOTED = '(symbol cl05b104kb54pnc (property Reference C (at 0 0 0)) (property Footprint cl05b104kb54pnc:GOOD1 (at 0 0 0)))' # refuses a library holding an unquoted symbol name, the way KiCad 10 refuses it KICAD_CLI = '''#!/usr/bin/env python3@@ -19,7 +23,7 @@ import re, sys args = sys.argv[1:] out = args[args.index('-o') + 1] text = open(args[-1]).read()-if re.search(r'\\(symbol [^"(]', text):+if re.search(r'\\(symbol [^"(]', text) or '(unknown_token)' in text: print('Unable to load library'); sys.exit(1) if '(extends' in text and '(symbol "GOOD1"' not in text: print('Unable to load library'); sys.exit(1)@@ -155,7 +159,7 @@ class Create(Base): return any(c[:2] == ['repo', 'push'] and 'README.md' in c for c in self.read_state()['calls']) def test_a_failed_library_check_leaves_no_page_and_names_the_part(self):- self.write_state(symbols=HEADER + GOOD + '\n' + BAD + '\n)\n')+ self.write_state(symbols=HEADER + GOOD + '\n' + BROKEN + '\n)\n') r = self.run_main(parts=self.PARTS + [{'mpn': 'BSC016N06NS', 'wiki': 'adom/bsc016n06ns', 'refs': ['Q1']}]) self.assertFalse(r['ok']) self.assertIn('BSC016N06NS (adom/bsc016n06ns): KiCad cannot load this symbol', r['error'])@@ -166,6 +170,15 @@ class Create(Base): r = self.run_main() self.assertTrue(r['ok'], r); self.assertTrue(r['created']); self.assertTrue(self.pushed_readme()) + def test_a_page_with_unquoted_atoms_is_packaged(self):+ # issue #196: before, its Footprint read as missing and the PCM rewrite skipped it+ self.write_state(symbols=HEADER + GOOD + '\n' + UNQUOTED + '\n)\n')+ r = self.run_main(parts=self.PARTS + [{'mpn': 'CL05B104KB54PNC', 'wiki': 'adom/cl05b104kb54pnc', 'refs': ['C1'], 'noModelExpected': True}])+ self.assertTrue(r['ok'], r)+ sym = next(Path(self.d).rglob('*.kicad_sym')).read_text()+ self.assertIn('(symbol "cl05b104kb54pnc"', sym)+ self.assertRegex(sym, r'\(property "Footprint" "PCM_[^":]+:GOOD1"')+ def test_a_failed_push_can_be_rerun_with_create_and_finishes_the_page(self): self.write_state(pushFails=True) self.assertFalse(self.run_main()['ok'])