app
Chip Fetcher (cf)
Public Made by Adomby adom
Crawls the web on your behalf to gather everything about the chips you care about: datasheets, symbols, footprints, 3D models, stock and pricing, straight from the manufacturer first, into your EDA tool. Say "cf" to your AI.
master
John Lauer
1.4.4: close the mid-update race that left a stale build serving (serve hands off when it is not the installed build), retire by port ownership rather than name, strip the (deleted) suffix before any path use; reproduced via /proc/pid/exe and verified fixed
d0c7483
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
//! The Hydrogen Dock Bar contract for chip-fetcher.
//!
//! Reference: the Dock Bar guide on adom/hydrogen (docs/dockbar.md) and
//! adom/kicad-bridge's dashboard, which was verified green end to end on
//! 2026-08-23. Everything here exists because one of the first four bridges
//! shipped it wrong:
//!
//! * `serve --print-url` must print the DIRECT `http://127.0.0.1:<port>/`
//! URL (never the `/proxy/<port>/` form, never the name `localhost`), then
//! SELF-BACKGROUND and exit 0, or the dock's synchronous exec kills the
//! server at its timeout and the readiness probe finds nothing listening.
//! * serve must be IDEMPOTENT and VERSION-AWARE: an instance of ours already
//! on the port makes relaunch instant; an instance of a DIFFERENT version
//! is the thing to replace, matched by exact argv (a substring match once
//! killed the shell that invoked it).
//! * every `/api/*` response sends `Access-Control-Allow-Origin: *` and
//! answers `OPTIONS`, or the dock's cross-origin health probe is blocked
//! by the browser and the card LED reads "not answering" while curl says
//! 200.
//! * the dock sets `ADOM_DOCK_WEBVIEW` to the tab it opened for us; we take
//! that tab over with `adom-cli hydrogen webview open-or-refresh`.
//! * the `say` phrases in dockbar.json are how users learn to ASK for the
//! app; the dashboard carries the same list at the very top.
use serde_json::{json, Value};
use std::collections::VecDeque;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const APP: &str = "adom-chip-fetcher";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DOCKBAR_JSON: &str = include_str!("../dockbar.json");
pub const APP_TAG: &str = "app:adom/adom-chip-fetcher";
const HYDROGEN_CONTROL_PORT: u16 = 47084;
/// The sister CLIs `adom-wiki pkg install adom/adom-chip-fetcher` pulls in
/// (page.json `dependencies`). Each is a wiki package whose binary carries the
/// same name. chip-fetcher orchestrates them: symbol/footprint/3D viewers,
/// the datasheet and STEP converters, pin-1 sign-off, thumbnails, and the
/// distributor backends.
pub const SISTER_TOOLS: &[(&str, &str)] = &[
("adom-symbol", "symbol viewer + .kicad_sym export"),
("adom-footprint", "footprint viewer + .kicad_mod export"),
("adom-step", "3D STEP viewer"),
("adom-lbr", "Fusion 360 / EAGLE .lbr export"),
("adom-chipsmith", "pin-1 alignment + sign-off"),
("adom-chip-thumbnailer", "card thumbnails (3D iso, symbol, footprint)"),
("adom-concur", "symbol/footprint cross-check"),
("adom-ds2sf", "datasheet to symbol + footprint"),
("adom-sfconvert", "EDA format conversion"),
("adom-parts-search", "unified Mouser + DigiKey + JLCPCB search"),
("adom-mouser", "Mouser stock + pricing"),
("adom-digikey", "DigiKey stock + CAD links"),
("adom-jlcpcb", "JLCPCB stock"),
];
pub fn direct_url(port: u16) -> String {
format!("http://127.0.0.1:{port}/")
}
// ---------------------------------------------------------------- loopback http
/// Minimal HTTP/1.0 request over loopback with a hard timeout. Returns the
/// body (headers stripped). std only: shelling to curl costs 20-50ms per
/// probe, which matters when the dock polls every 15s and budgets 2s.
fn http(port: u16, method: &str, path: &str, body: Option<&str>, timeout: Duration) -> Option<String> {
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().ok()?;
let mut s = TcpStream::connect_timeout(&addr, timeout).ok()?;
s.set_read_timeout(Some(timeout)).ok()?;
s.set_write_timeout(Some(timeout)).ok()?;
let body = body.unwrap_or("");
let req = format!(
"{method} {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nUser-Agent: {APP}/{VERSION}\r\n\
Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
s.write_all(req.as_bytes()).ok()?;
let mut raw = Vec::new();
let _ = s.read_to_end(&mut raw);
let text = String::from_utf8_lossy(&raw).to_string();
let idx = text.find("\r\n\r\n")?;
Some(text[idx + 4..].to_string())
}
pub fn http_get(port: u16, path: &str, timeout: Duration) -> Option<String> {
http(port, "GET", path, None, timeout)
}
pub fn http_post(port: u16, path: &str, body: &str, timeout: Duration) -> Option<String> {
http(port, "POST", path, Some(body), timeout)
}
// ------------------------------------------------------- idempotent serve
/// Version of OUR dashboard on `port`, or None when nothing (or something
/// that is not us) answers. Another service on the port is not a reason to
/// claim success.
pub fn running_version(port: u16) -> Option<String> {
let body = http_get(port, "/health", Duration::from_millis(1500))?;
let v: Value = serde_json::from_str(&body).ok()?;
if v.get("ok").and_then(|b| b.as_bool()) == Some(true) && v.get("app").and_then(|a| a.as_str()) == Some(APP) {
Some(v.get("version").and_then(|s| s.as_str()).unwrap_or("?").to_string())
} else {
None
}
}
/// The PID listening on `port`, found through the kernel rather than through
/// process names: parse /proc/net/tcp{,6} for the LISTEN socket on that port,
/// then find the process holding that socket inode. Names and argv can lie or
/// drift (a rename, an alias, a wrapper); the socket cannot.
pub fn pid_owning_port(port: u16) -> Option<u32> {
let mut inodes: Vec<String> = Vec::new();
for f in ["/proc/net/tcp", "/proc/net/tcp6"] {
let Ok(text) = std::fs::read_to_string(f) else { continue };
for line in text.lines().skip(1) {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() < 10 {
continue;
}
// local_address is "HEXADDR:HEXPORT"; state 0A is LISTEN.
let Some((_, p)) = cols[1].split_once(':') else { continue };
if u16::from_str_radix(p, 16).ok() != Some(port) || cols[3] != "0A" {
continue;
}
inodes.push(cols[9].to_string());
}
}
if inodes.is_empty() {
return None;
}
let rd = std::fs::read_dir("/proc").ok()?;
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
let Ok(pid) = name.parse::<u32>() else { continue };
let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { continue };
for fd in fds.flatten() {
if let Ok(target) = std::fs::read_link(fd.path()) {
let t = target.to_string_lossy();
if let Some(ino) = t.strip_prefix("socket:[").and_then(|x| x.strip_suffix(']')) {
if inodes.iter().any(|i| i == ino) {
return Some(pid);
}
}
}
}
}
None
}
/// Is this PID one of OUR builds? Checks the executable behind the process,
/// stripping Linux's "(deleted)" suffix, which appears the instant a package
/// update unlinks the old binary and writes a new one at the same path. Any
/// path comparison that skips this stops matching exactly when it matters.
fn pid_is_ours(pid: u32) -> bool {
let exe = std::fs::read_link(format!("/proc/{pid}/exe"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let exe = exe.strip_suffix(" (deleted)").unwrap_or(&exe).to_string();
let base = exe.rsplit('/').next().unwrap_or("").to_string();
if base == "adom-chip-fetcher" || base == "chip-fetcher" {
return true;
}
// Fall back to argv, which is frozen at exec and never gains "(deleted)".
let cmd = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
let argv: Vec<&[u8]> = cmd.split(|b| *b == 0).collect();
if argv.len() < 2 || argv[1] != b"serve" {
return false;
}
let bin = argv[0].rsplit(|b| *b == b'/').next().unwrap_or(argv[0]);
bin == b"adom-chip-fetcher" || bin == b"chip-fetcher"
}
/// Retire a stale instance of a different version. Kills only processes
/// whose argv is EXACTLY `[<...>/adom-chip-fetcher, serve, ...]` (or the
/// `chip-fetcher` alias), never a shell whose command text merely mentions
/// us. Returns true once the port is free.
pub fn retire_stale(port: u16, their: &str) -> bool {
eprintln!("{APP} {their} is serving on {port} but this is {VERSION}; retiring the stale instance");
let me = std::process::id();
let parent = std::os::unix::process::parent_id();
let mut victims: Vec<u32> = Vec::new();
// Preferred: whoever actually owns the port, confirmed to be one of ours.
// Never ourselves, never our parent.
if let Some(owner) = pid_owning_port(port) {
if owner != me && owner != parent && pid_is_ours(owner) {
victims.push(owner);
}
}
if let Ok(rd) = std::fs::read_dir("/proc") {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
let pid: u32 = match name.parse() {
Ok(p) => p,
Err(_) => continue,
};
if pid == me || pid == parent || victims.contains(&pid) {
continue;
}
let cmd = match std::fs::read(format!("/proc/{pid}/cmdline")) {
Ok(c) => c,
Err(_) => continue,
};
let argv: Vec<&[u8]> = cmd.split(|b| *b == 0).collect();
if argv.len() < 2 {
continue;
}
let bin = argv[0].rsplit(|b| *b == b'/').next().unwrap_or(argv[0]);
let is_us = bin == b"adom-chip-fetcher" || bin == b"chip-fetcher";
if is_us && argv[1] == b"serve" {
victims.push(pid);
}
}
}
for pid in &victims {
let _ = std::process::Command::new("kill").args(["-TERM", &pid.to_string()]).output();
}
for _ in 0..40 {
if running_version(port).is_none() {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
for pid in &victims {
let _ = std::process::Command::new("kill").args(["-KILL", &pid.to_string()]).output();
}
std::thread::sleep(Duration::from_millis(500));
running_version(port).is_none()
}
/// Take ownership of the dock's pre-opened webview tab. The dock names the
/// tab in `ADOM_DOCK_WEBVIEW`; `open-or-refresh` with that name is idempotent
/// so re-running it after a restart just renavigates. Unset env (manual
/// serve) is a silent no-op; the dock's own interstitial navigation remains
/// the fallback.
pub fn claim_dock_webview(port: u16) {
let name = match std::env::var("ADOM_DOCK_WEBVIEW") {
Ok(n) if !n.trim().is_empty() => n,
_ => return,
};
let url = direct_url(port);
let _ = std::process::Command::new("timeout")
.args(["20", "adom-cli", "hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
console_push("info", &format!("claimed dock webview tab '{name}' -> {url}"), "serve");
}
/// Spawn a detached copy of ourselves to serve, wait until it answers
/// `/health`, and return its URL. This is how `serve --print-url`
/// self-backgrounds without `fork()` (tiny_http owns threads; forking a
/// threaded process is undefined behaviour).
pub fn spawn_detached_server(port: u16) -> anyhow::Result<String> {
use std::os::unix::process::CommandExt;
let exe = std::env::current_exe()?;
let mut cmd = std::process::Command::new(exe);
cmd.args(["serve", "--port", &port.to_string(), "--detached-child"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// New session: the dock's exec pipe must not stay open on us, and the
// exec's process-tree kill at timeout must not take the server with it.
unsafe {
cmd.pre_exec(|| {
libc_setsid();
Ok(())
});
}
cmd.spawn()?;
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
if running_version(port).is_some() {
return Ok(direct_url(port));
}
std::thread::sleep(Duration::from_millis(100));
}
anyhow::bail!("the detached server never answered /health on port {port} within 15s")
}
/// `setsid(2)` without the libc crate: the syscall number is stable on
/// x86_64 (112) and aarch64 (112) Linux. Failure is harmless (we keep the
/// parent's session), so the return value is ignored.
fn libc_setsid() {
extern "C" {
fn setsid() -> i32;
}
unsafe {
setsid();
}
}
// ------------------------------------------------------------------ say / try
/// The `say` phrases from dockbar.json, the single source both the dock's
/// interstitial and the dashboard's "Say it" strip read.
pub fn say_phrases() -> Vec<String> {
serde_json::from_str::<Value>(DOCKBAR_JSON)
.ok()
.and_then(|v| v.get("say").cloned())
.and_then(|s| s.as_array().cloned())
.map(|a| a.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default()
}
/// Stage a phrase into this app's own AI thread via the Hydrogen control API.
/// Measured order (dock thread, 2026-08-23): the census FIRST (open threads),
/// history SECOND (closed ones; it deliberately excludes anything open, so
/// history-first returns empty exactly when the user has the thread open),
/// copy fallback third. The control API is reachable from WSL2 under mirrored
/// networking; in a cloud container it is not, and copying is the right
/// answer there.
pub fn try_phrase(phrase: &str) -> Value {
// TRY_IT_PARKED (ship night 2026-08-23, dock-bar thread ruling, matching
// adom-parts-search): every /ai-threads call from an app server can wake
// ah's parked AI Threads backend, and run-prompt's typing path has an
// unverified crash fix that has killed the whole ah process. Copy-only
// until the all-clear; flip this to false to bring Try it back.
const TRY_IT_PARKED: bool = true;
if TRY_IT_PARKED {
let _ = phrase;
return json!({"staged": false, "reason": "Try it is parked tonight (ship-night posture); copy the phrase and paste it to your AI"});
}
let t = Duration::from_millis(1500);
let get = |path: &str| -> Option<Value> {
http_get(HYDROGEN_CONTROL_PORT, path, t).and_then(|b| serde_json::from_str(&b).ok())
};
let post = |path: &str, body: Value| -> Option<Value> {
http_post(HYDROGEN_CONTROL_PORT, path, &body.to_string(), t).and_then(|b| serde_json::from_str(&b).ok())
};
// 1. census
if let Some(c) = get("/ai-threads") {
let mut hit: Option<Value> = None;
for g in c.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default() {
for th in g.get("threads").and_then(|t| t.as_array()).cloned().unwrap_or_default() {
let tagged = th
.get("tags")
.and_then(|t| t.as_array())
.map(|a| a.iter().any(|x| x.as_str() == Some(APP_TAG)))
.unwrap_or(false);
if tagged {
hit = Some(th);
break;
}
}
if hit.is_some() {
break;
}
}
if let Some(th) = hit {
let mut body = json!({"prompt": phrase, "submit": false});
match th.get("sid").and_then(|s| s.as_str()) {
Some(sid) if !sid.is_empty() => body["sid"] = json!(sid),
_ => body["tid"] = th.get("tid").cloned().unwrap_or(Value::Null),
}
return match post("/ai-threads/run-prompt", body) {
Some(r) if r.get("ok").and_then(|b| b.as_bool()).unwrap_or(true) => {
json!({"staged": true, "where": "open thread"})
}
_ => json!({"staged": false, "reason": "run-prompt failed on the open thread"}),
};
}
// 2. history
if let Some(h) = get(&format!("/ai-threads/history?tag={APP_TAG}")) {
if let Some(it) = h.get("items").and_then(|i| i.as_array()).and_then(|a| a.first()).cloned() {
let id = it.get("id").cloned().unwrap_or(Value::Null);
let ai = it.get("ai").cloned().unwrap_or(json!("claude"));
let resumed = post("/ai-threads/resume", json!({"ai": ai, "id": id}));
if resumed.is_some() {
let r = post("/ai-threads/run-prompt", json!({"sid": id, "prompt": phrase, "submit": false}));
if r.is_some() {
return json!({"staged": true, "where": "resumed thread"});
}
}
return json!({"staged": false, "reason": "resume/run-prompt failed on the closed thread"});
}
}
return json!({"staged": false, "reason": "no chip-fetcher AI thread yet; start one by pasting the phrase"});
}
json!({"staged": false, "reason": "the Hydrogen control API is not reachable from this container"})
}
// ------------------------------------------------------- console + toasts
static CONSOLE: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
static TOASTS: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
static UI_CMDS: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
const CONSOLE_CAP: usize = 500;
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Ring buffer the dashboard's console.* override and the server both write
/// to, read back by `GET /console`. This is how the AI sees what the UI is
/// doing without DevTools.
pub fn console_push(level: &str, msg: &str, source: &str) {
if let Ok(mut q) = CONSOLE.lock() {
q.push_back(json!({"t": now_ms(), "level": level, "msg": msg, "source": source}));
while q.len() > CONSOLE_CAP {
q.pop_front();
}
}
}
pub fn console_json(limit: usize) -> String {
let q = CONSOLE.lock().map(|q| q.iter().rev().take(limit).cloned().collect::<Vec<_>>()).unwrap_or_default();
let mut items: Vec<Value> = q;
items.reverse();
json!({"ok": true, "count": items.len(), "items": items}).to_string()
}
/// Queue a toast for the dashboard. The dashboard drains the queue on its
/// 1s activity poll, so `adom-chip-fetcher ui toast "..."` lands over the
/// app within a second. Kinds: ok | err | info.
pub fn toast_push(message: &str, kind: &str) {
if let Ok(mut q) = TOASTS.lock() {
q.push_back(json!({"t": now_ms(), "message": message, "kind": kind}));
while q.len() > 20 {
q.pop_front();
}
}
}
pub fn drain_toasts() -> Vec<Value> {
TOASTS.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
}
/// Queue a front-end driving command (`{"cmd":"click","id":"btn-settings"}`,
/// `{"cmd":"open_settings"}`, `{"cmd":"select","mpn":"..."}`). Same drain.
pub fn ui_command_push(cmd: Value) {
if let Ok(mut q) = UI_CMDS.lock() {
q.push_back(cmd);
while q.len() > 50 {
q.pop_front();
}
}
}
pub fn drain_ui_commands() -> Vec<Value> {
UI_CMDS.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
}
// ------------------------------------------------------------- status LEDs
fn on_path(bin: &str) -> bool {
let extra = std::env::var("HOME").map(|h| format!("{h}/.local/bin")).unwrap_or_default();
let path = std::env::var("PATH").unwrap_or_default();
std::iter::once(extra.as_str())
.chain(path.split(':'))
.filter(|d| !d.is_empty())
.any(|d| std::path::Path::new(d).join(bin).is_file())
}
fn led(label: &str, state: &str, detail: String, hint: &str) -> Value {
json!({"label": label, "state": state, "detail": detail, "_hint": hint})
}
fn rank(state: &str) -> u8 {
match state {
"fail" | "err" => 3,
"warn" => 2,
"ok" => 1,
_ => 0,
}
}
/// Whether a desktop is connected through Adom Bridge. `adom-bridge targets`
/// is the authoritative probe; it answers in well under a second when the
/// relay is up and the 2s timeout bounds the dead case.
fn bridge_targets(force: bool) -> Option<usize> {
// The one non-trivial probe (spawns a process, talks to the relay).
// Cached 5 minutes: a desktop appearing or leaving is a slow event, and
// the dock/dashboard polling must not turn into a background process
// treadmill (John, 2026-08-23). `?force=1` (the settings panel, doctor)
// bypasses the cache.
static CACHE: Mutex<Option<(Instant, Option<usize>)>> = Mutex::new(None);
if !force {
if let Ok(c) = CACHE.lock() {
if let Some((t, v)) = c.as_ref() {
if t.elapsed() < Duration::from_secs(300) {
return *v;
}
}
}
}
let v = bridge_targets_probe();
if let Ok(mut c) = CACHE.lock() {
*c = Some((Instant::now(), v));
}
v
}
fn bridge_targets_probe() -> Option<usize> {
let bin = if on_path("adom-bridge") { "adom-bridge" } else { "adom-bridge-cli" };
let out = std::process::Command::new("timeout").args(["2", bin, "targets"]).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
// The CLI prefixes a "note:" line for the retired alias; find the JSON.
let start = text.find('{')?;
let v: Value = serde_json::from_str(&text[start..]).ok()?;
v.get("count").and_then(|c| c.as_u64()).map(|c| c as usize)
}
pub fn build_facets() -> Facets {
build_facets_forced(true)
}
pub struct Facets {
pub leds: serde_json::Map<String, Value>,
pub state: String,
pub missing_tools: Vec<String>,
}
/// Build every facet. Cheap checks only (PATH stats, one bounded bridge
/// probe, the library listing), cached by `status_json`.
pub fn build_facets_forced(force: bool) -> Facets {
let mut leds = serde_json::Map::new();
// 1. the dashboard itself: answering means ok.
leds.insert(
"dashboard".into(),
match installed_version() {
Some(d) if d != VERSION => led(
"Dashboard",
"warn",
format!("v{VERSION} serving, v{d} installed"),
"A newer build is on disk. This server hands the port over within a minute by itself; `adom-chip-fetcher serve --print-url` does it now.",
),
_ => led("Dashboard", "ok", format!("v{VERSION} serving"), "The dashboard answers /api/status; the dock polls it every 15s."),
},
);
// 2. the AI skills on this container: the main skill (+playbooks), the
// dock skill, and the community-extensible per-vendor site pack.
let home = std::env::var("HOME").unwrap_or_default();
let sk = |n: &str| std::path::Path::new(&home).join(".claude/skills").join(n).join("SKILL.md").is_file();
let sites_dir = std::path::Path::new(&home).join(".claude/skills/chip-fetcher-sites/sites");
let site_count = std::fs::read_dir(&sites_dir).map(|rd| rd.flatten().filter(|e| e.path().is_dir()).count()).unwrap_or(0);
let have = [sk("adom-chip-fetcher"), sk("adom-chip-fetcher-dock"), sk("chip-fetcher-sites")];
let n_have = have.iter().filter(|b| **b).count();
leds.insert(
"skill".into(),
if n_have == 3 {
led("AI skills", "ok", format!("3 skills, {site_count} vendor site guides"), "adom-chip-fetcher (the sourcing ladder + 14 playbooks), adom-chip-fetcher-dock (the Dock Bar), and chip-fetcher-sites (one crawl guide per vendor website; anyone can contribute a new site).")
} else {
led("AI skills", "warn", format!("{n_have}/3 skills installed"), "Run `adom-chip-fetcher install` (or `adom-wiki pkg install adom/adom-chip-fetcher`) so your AI has the sourcing ladder, the dock guide and the per-site crawl playbooks.")
},
);
// 3. sister tools.
let missing: Vec<String> = SISTER_TOOLS.iter().filter(|(b, _)| !on_path(b)).map(|(b, _)| b.to_string()).collect();
let total = SISTER_TOOLS.len();
leds.insert(
"tools".into(),
if missing.is_empty() {
led("Sister tools", "ok", format!("{total}/{total} installed automatically"), "The wiki installs these as dependencies of chip fetcher; nothing to manage. This light only exists to catch a broken install.")
} else {
led(
"Sister tools",
"warn",
format!("{}/{} landed, missing {}", total - missing.len(), total, missing.join(", ")),
"These install automatically with chip fetcher; a missing one means its own package misfired. `adom-chip-fetcher doctor --fix` re-installs each missing tool and self-heals from the downloaded payload.",
)
},
);
// 4. the desktop bridge (pup lives there; no desktop means no scraping).
leds.insert(
"bridge".into(),
match bridge_targets(force) {
Some(n) if n > 0 => led("Desktop", "ok", format!("{n} box(es) on Adom Bridge"), "pup drives the chip-fetcher Chrome profile on a connected desktop."),
Some(_) => led("Desktop", "warn", "no desktop connected".into(), "Open Adom Hydrogen on your desktop; chip-fetcher scrapes vendor sites through pup there. The library view works without it."),
None => led("Desktop", "warn", "adom-bridge not answering".into(), "Install the bridge CLI (`adom-wiki pkg install adom/adom-bridge`) and open Adom Hydrogen on your desktop."),
},
);
// 5. the library.
let (parts, complete) = library_counts();
leds.insert(
"library".into(),
if parts == 0 {
led("Library", "ok", "no parts yet".into(), "Ask your AI: \"fetch STM32F103C8T6 with chip fetcher\". The first card appears within seconds.")
} else if complete == parts {
led("Library", "ok", format!("{parts} parts, all complete"), "Every card has datasheet + STEP + symbol + footprint.")
} else {
led("Library", "ok", format!("{parts} parts, {} still incomplete", parts - complete), "Incomplete cards are work in progress, not a fault. `adom-chip-fetcher gate` lists exactly which artifact each one is missing.")
},
);
// rollup: worst facet; "off" facets never count.
let worst = leds.values().map(|v| v.get("state").and_then(|s| s.as_str()).unwrap_or("off")).max_by_key(|s| rank(s)).unwrap_or("ok");
let state = match worst {
"fail" | "err" => "fail",
"warn" => "warn",
_ => "ok",
}
.to_string();
Facets { leds, state, missing_tools: missing }
}
fn library_counts() -> (usize, usize) {
let root = crate::library::root();
let mut parts = 0;
let mut complete = 0;
if let Ok(rd) = std::fs::read_dir(&root) {
for e in rd.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let mpn = e.file_name().to_string_lossy().to_string();
if mpn.starts_with('.') {
continue;
}
parts += 1;
let has = |ext: &str| p.join(format!("{mpn}.{ext}")).is_file();
if has("pdf") && has("step") && has("kicad_sym") && has("kicad_mod") {
complete += 1;
}
}
}
(parts, complete)
}
static STATUS_CACHE: Mutex<Option<(Instant, String)>> = Mutex::new(None);
/// `/api/status`: per-facet LEDs plus a top-level `state` rollup the dock
/// honours. Cached 10s so the dock's 15s poll always answers in
/// milliseconds (the bridge probe is the slow part).
pub fn status_json(force: bool) -> String {
if !force {
if let Ok(c) = STATUS_CACHE.lock() {
if let Some((t, s)) = c.as_ref() {
if t.elapsed() < Duration::from_secs(10) {
return s.clone();
}
}
}
}
let f = build_facets_forced(force);
let body = json!({
"ok": true,
"app": APP,
"version": VERSION,
"state": f.state,
"leds": f.leds,
"say": say_phrases(),
"target": crate::bridge::target(),
"missing_tools": f.missing_tools,
"_hint": "GET /api/library for the cards, GET /api/activity for the live heartbeat, POST /ui/toast {message,kind} to raise a toast, POST /ui/command {cmd,...} to drive the front-end.",
})
.to_string();
if let Ok(mut c) = STATUS_CACHE.lock() {
*c = Some((Instant::now(), body.clone()));
}
body
}
/// The version of the binary currently AT our own path on disk. After
/// `adom-wiki pkg update` replaces the file, this differs from the running
/// VERSION, and that gap is how a stale build kept serving after a dock Hard
/// Refresh (the dock reopens the tab against the running server and never
/// re-runs the launch steps, so nothing asks the old process to stand down).
/// Our own executable path, with Linux's "(deleted)" suffix stripped. A
/// package update unlinks the old file and writes a new one at the same path,
/// so a running process resolves /proc/self/exe to "<path> (deleted)"; reading
/// the SUFFIXED path fails and the upgrade watch goes blind. That is the exact
/// trap that let a stale build keep serving.
pub fn exe_path() -> Option<PathBuf> {
let p = std::fs::read_link("/proc/self/exe").ok().or_else(|| std::env::current_exe().ok())?;
let s = p.to_string_lossy().to_string();
Some(PathBuf::from(s.strip_suffix(" (deleted)").unwrap_or(&s)))
}
pub fn installed_version() -> Option<String> {
let exe = exe_path()?;
if !exe.is_file() {
return None;
}
let out = std::process::Command::new("timeout").arg("5").arg(&exe).arg("--version").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let v = text.split_whitespace().last()?.trim().to_string();
// Only trust a clean semver; never act on a parse accident.
if v.split('.').count() == 3 && v.chars().all(|c| c.is_ascii_digit() || c == '.') {
Some(v)
} else {
None
}
}
/// Watch our own binary and hand off when a package update replaces it.
/// Verified handoff: the replacement must answer `--version` cleanly before we
/// give up the port, and the successor is spawned detached with a short gap so
/// it can bind. Without this, `pkg update` + a tab reopen leaves the OLD build
/// serving, which is exactly how a parked-crash build stayed live.
pub fn watch_for_upgrade(port: u16) {
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(if std::env::var("CF_UPGRADE_WATCH_FAST").is_ok() { 3 } else { 60 }));
let Some(disk) = installed_version() else { continue };
if disk == VERSION {
continue;
}
let Some(exe) = exe_path() else { continue };
console_push("info", &format!("binary on disk is v{disk} but this process is v{VERSION}; handing the port over"), "serve");
eprintln!("{APP}: v{disk} is installed, this process is v{VERSION}; handing over port {port}");
let cmd = format!("sleep 1; exec {:?} serve --port {port} --detached-child", exe);
let spawned = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
if spawned.is_ok() {
std::thread::sleep(Duration::from_millis(200));
std::process::exit(0);
}
eprintln!("{APP}: could not spawn the successor; staying up on v{VERSION}");
});
}
pub fn health_json() -> String {
let disk = installed_version();
let stale = disk.as_deref().map(|d| d != VERSION).unwrap_or(false);
json!({"ok": true, "app": APP, "version": VERSION, "installed_version": disk, "stale": stale}).to_string()
}