← Commit history
.gitignoreadded+3
@@ -0,0 +1,3 @@+/target+/.env+*.log
BUILD-SKILL.mdadded+75
@@ -0,0 +1,75 @@+---+name: adom-mouser-build+description: Build and release a new version of adom-mouser. Use when the user says "release adom-mouser", "publish adom-mouser", "cut a new adom-mouser version", "bump adom-mouser", "ship adom-mouser", or "push adom-mouser to the wiki".+---++# Release adom-mouser++A release syncs five places to the same version X.Y.Z:+`VERSION`, `Cargo.toml`, `adom-mouser --version`, wiki page `metadata.version`, running `/health` on the service container.++## Steps++1. **Bump** — decide X.Y.Z (semver). Then:+   ```bash+   echo "X.Y.Z" > VERSION+   sed -i 's/^version = .*/version = "X.Y.Z"/' Cargo.toml+   ```++2. **Local build + smoke test**:+   ```bash+   ./build.sh+   adom-mouser --version                    # must print "adom-mouser X.Y.Z"+   adom-mouser health                       # backend reachable+   adom-mouser search "STM32F103RBT6" --limit 1 | jq '.components[0].mouser_pn'+   ```++3. **Ralph loop the UI** (per app-creator): open in pup, drive a real search, screenshot, check `/console` for errors.+   ```bash+   MOUSER_API=<backend> adom-mouser app --no-tab &+   adom-desktop browser_open_window '{"sessionId":"mouser","profile":"mouser","url":"http://127.0.0.1:8789"}'+   # drive a search, screenshot, verify 0 console errors+   ```++4. **Show the user a screenshot and wait for explicit approval** before publishing. The app-creator skill is hard about this.++5. **Publish**:+   ```bash+   ./publish.sh+   ```+   Runs: git commit + tag `vX.Y.Z`, `git push origin main --tags`, `gh release create`, `adom-wiki asset upload` (replaces the prior `docker_binary`), `adom-wiki page update`, and a final curl of the wiki binary to check `--version` returns X.Y.Z.++6. **Service container picks it up automatically** — `service/watchdog.sh` runs every 2 min, `git fetch`es origin/main, and rebuilds + restarts when HEAD moves. Verify within ~5 min:+   ```bash+   curl -s https://mouser-<slug>.adom.cloud/health | jq '.version'   # → "X.Y.Z"+   ```++7. **Verify on a fresh user container** (optional, but catches gallia regressions):+   ```bash+   # on a fresh container:+   node ~/gallia/install.mjs                 # Tier A block fetches from wiki+   adom-mouser --version                     # → "adom-mouser X.Y.Z"+   ```++## Pre-release checklist++- [ ] `VERSION`, `Cargo.toml`, `adom-mouser --version`, wiki `metadata.version`, `/health` all return X.Y.Z+- [ ] Wiki page at `apps/adom-mouser` renders the new `install_hint` one-liner+- [ ] User has seen a working screenshot of the webview+- [ ] No console errors via `GET /console`+- [ ] `gallia/install.mjs` Tier A block still works (pulls + installs the new version)+- [ ] **Brand audit — in-app icons are monochrome white.** Run:+      ```+      grep -nE 'fill="#00b8b0"|stroke="#00b8b0"|color: var\(--accent\)' src/app/*.html src/app/*.svg 2>/dev/null+      ```+      Expected result: empty, OR only the text-color rules (`.mouser-pn`,+      `<a>`, focus-rings) on non-SVG elements. Any match on an `<svg>` or+      a `.logo` / `.icon` class CSS rule is a bug — the brand skill+      requires monochrome white for in-app icons. See+      `app-creator` §1b and §Checklist.++## Notes++- **Never skip the ralph loop** — a green `cargo build` is not proof the UI works. Fonts, search, card rendering must be seen working.+- **Wiki upload is idempotent** — `adom-wiki asset upload --asset-type docker_binary` replaces the prior binary for this app automatically.+- **Version discipline is load-bearing** — `refresh-wiki-catalog.mjs` uses semver comparison to surface stale installs; inconsistent versions silently break that.
Cargo.lockadded+856
@@ -0,0 +1,856 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-mouser"+version = "0.1.8"+dependencies = [+ "base64",+ "clap",+ "clap_complete",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys 0.61.2",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys 0.61.2",+]++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "cc"+version = "1.2.60"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "clap"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_complete"+version = "4.6.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb"+dependencies = [+ "clap",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "displaydoc"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "icu_collections"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"++[[package]]+name = "icu_properties"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"+dependencies = [+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"++[[package]]+name = "icu_provider"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.185"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"++[[package]]+name = "litemap"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"++[[package]]+name = "log"+version = "0.4.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "potential_utf"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"+dependencies = [+ "zerovec",+]++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rustls"+version = "0.23.38"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "shlex"+version = "1.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "smallvec"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "tinystr"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.7",+]++[[package]]+name = "webpki-roots"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"++[[package]]+name = "yoke"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zerofrom"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"++[[package]]+name = "zerotrie"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+24
@@ -0,0 +1,24 @@+[package]+name = "adom-mouser"+version = "0.1.8"+edition = "2021"+description = "Mouser Electronics component search — CLI, backend HTTP service, and Hydrogen webview app"+license = "MIT"++[[bin]]+name = "adom-mouser"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive", "env"] }+clap_complete = "4"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tiny_http = "0.12"+ureq = { version = "2", features = ["json"] }+base64 = "0.22"++[profile.release]+strip = true+lto = "thin"+opt-level = "z"
README.md+55−37
@@ -1,61 +1,79 @@-# adom-mouser+# Mouser Electronics Search -Search Mouser Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:+**Mouser's catalog — stock, price breaks, datasheets and lifecycle — right in your workspace.** -- **CLI verbs** — `adom-mouser search|part|health` return normalized JSON on stdout, ready for `| jq`.-- **Hydrogen webview app** — `adom-mouser app` opens a first-class "Mouser Search" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and Mouser PN, and a backend-health indicator.-- **Backend HTTP service** — `adom-mouser serve` runs an HTTP proxy (port 8775) with a 10-minute LRU cache. Keeps the Mouser API key server-side; consumer containers point `MOUSER_API` at it.+![Mouser stock and pricing in your Adom workspace — CLI, a Hydrogen app, and a shared backend, with 40-minute drone delivery to Fort Worth](/blob/app/adom-mouser/screenshots/hero.png) -![Mouser search webview showing STM32F103 results with pricing and stock](https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-mouser/mouser-search-results.png)+`adom-mouser` is the single-vendor Mouser tool for the Adom ecosystem — one Rust binary, three+faces: -## Install+- **CLI** — `adom-mouser search | part | health` for quick JSON-returning shell use and BOMs.+- **Hydrogen app** — `adom-mouser app` opens a searchable UI as a first-class Hydrogen tab.+- **Backend service** — `adom-mouser serve` runs the shared proxy (port 8775) that powers the+  `service-mouser` container and feeds [`adom-parts-search`](/app/adom-parts-search).++It replaces the old `gallia/services/mouser` + MCP setup: no MCP server, no Adom Viewer.  ```bash-curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-mouser/adom-mouser \-  -o /tmp/adom-mouser \-  && chmod +x /tmp/adom-mouser \-  && sudo install -m 0755 /tmp/adom-mouser /usr/local/bin/adom-mouser \-  && adom-mouser install+adompkg install adom-mouser+adom-mouser search "ESP32-S3-WROOM-1" ``` -Installs the binary to `/usr/local/bin/adom-mouser`, deploys `~/.claude/skills/adom-mouser/SKILL.md` and `~/.claude/skills/adom-mouser-build/SKILL.md`, and registers bash completions.+## Search like you mean it++Type a full MPN for an exact hit, or a parametric phrase like `LDO 3.3V 500mA SOT-23` to browse.+Every result card shows the manufacturer photo, Mouser PN + MPN, stock, unit price, lead time,+RoHS/lifecycle flags and the key attributes — so you can decide without leaving the workspace.++![adom-mouser results for an ESP32-S3-WROOM module — product photos, stock, price, price breaks and attribute flags](/blob/app/adom-mouser/screenshots/results-esp32.png)++## Quantity pricing and the parts that matter++Price breaks are first-class: the card shows the full quantity ladder so you know what a reel+actually costs, not just the qty-1 sticker. Connectors, passives, modules, MCUs — the same clean+card for everything. -For gallia containers, this is done automatically by `gallia/install.mjs` — adom-mouser is a **Tier A app** (every Adom user needs component search day-one).+![adom-mouser results for USB-C receptacles — multiple manufacturers with pin count, current rating and quantity price breaks](/blob/app/adom-mouser/screenshots/results-usbc.png) -## Use+## Scriptable from the first keystroke++`search` returns JSON; `part` fetches one MPN; `health` checks the backend. Pipe any of it into+`jq`, a BOM, or another Claude step.++![Terminal showing adom-mouser search piped to jq for the first component, then a health check reporting the backend is healthy](/blob/app/adom-mouser/screenshots/cli.png)  ```bash-# CLI-adom-mouser search "STM32F103RBT6" --limit 5-adom-mouser part 511-STM32F103RBT6 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+adom-mouser search "STM32F103RBT6" | jq '.components[0]'+adom-mouser part "595-LM358DR" adom-mouser health+``` -# UI-adom-mouser app        # opens a Hydrogen webview tab+## Install -# Backend-MOUSER_API_KEY=<key> adom-mouser serve    # runs on service-mouser container+```bash+adompkg install adom-mouser ``` -## Architecture+## How it works  ```-  any user container             service-mouser (default-light)-  ──────────────────             ──────────────────────────────-  adom-mouser search       ─┐-  adom-mouser app          ─┼──▶ adom-mouser serve (port 8775)-  adom-mouser health       ─┘    + MOUSER_API_KEY-                                 + 10-min LRU cache-                                 + self-heal cron watchdog-                                          │-                                          ▼-                                api.mouser.com / v2+  any container                         service-mouser+  $ adom-mouser search "ESP32"  ──POST──▶  adom-mouser serve  (:8775)+  $ adom-mouser app                        MOUSER_API_KEY set+    (Hydrogen webview tab)                 10-min LRU cache+                                                 │+                                                 ▼+                                          api.mouser.com (v2) ``` -The service container clones only `adom-inc/adom-mouser`; its `service/watchdog.sh` runs every 2 min to (1) keep the service healthy and (2) `git fetch origin main` + rebuild + swap binary when a new release lands. No redeploy step after publishing — publish.sh pushes to main, the service container picks it up within ~5 minutes.+The CLI auto-detects the backend (or a local `:8775`), and falls back to a direct Mouser call when+no backend is reachable. **40-minute drone delivery** from Mouser's Mansfield, TX warehouse to our+Fort Worth facility is why Mouser is the preferred vendor across Adom tooling.++## See also -## Source+- [`adom-parts-search`](/app/adom-parts-search) — searches Mouser, DigiKey and JLCPCB at once and picks the best buy. -Source at [adom-inc/adom-mouser](https://github.com/adom-inc/adom-mouser) (private). The public artifact is this wiki page's `docker-binary` asset — source access is not required to install or use the app.+## License -Built following the canonical [adom-app-model](https://wiki-ufypy5dpx93o.adom.cloud/wiki/skills/adom-app-model): single private repo for both client + service code, binary published to the wiki, service on its own default-light container, Tier A distribution via `gallia/install.mjs`.+MIT.
SKILL.md+208−3
@@ -1,7 +1,212 @@ --- name: adom-mouser-description: >-  Search Mouser components, pricing, and stock. Three faces: CLI verbs (search/part/health), a Hydrogen webview app, and a shared backend HTTP service.+description: Search Mouser Electronics for components, pricing, stock, and part numbers. Use when the user asks to find components on Mouser, check Mouser pricing, look up a Mouser part number, search for in-stock parts, get price breaks, compare alternatives, or find a datasheet or product page for a specific part. Also use for "open mouser", "mouser app", "mouser search UI". ---+ # Mouser Electronics Search-Search Mouser components, pricing, and stock. Three faces: CLI verbs (search/part/health), a Hydrogen webview app, and a shared backend HTTP service.++Search Mouser's catalog of millions of electronic components from thousands of manufacturers. Real-time pricing, stock, lead times, and product links.++## What you have++A single CLI — `adom-mouser` — with three faces:++1. **One-shot search from the shell**, for when the AI just needs the data:+   ```bash+   adom-mouser search "STM32F103RBT6"+   adom-mouser part 511-STM32F103RBT6+   adom-mouser health+   ```+   Each prints JSON on stdout and an `OK:` / `ERROR:` line on stderr, so `| jq` works cleanly.++2. **A Hydrogen webview app**, for when the user wants to browse interactively:+   ```bash+   adom-mouser app        # opens a "Mouser Search" tab in Hydrogen+   ```+   The app has a search bar, in-stock filter, per-result cards with price breaks, MPN/Mouser-PN copy buttons, datasheet + product-page links, and hover tooltips on every control.++3. **A backend HTTP service** (port 8775), which is what the shared `service-mouser` container runs:+   ```bash+   adom-mouser serve      # MOUSER_API_KEY must be set+   ```+   The CLI and the app both talk to this backend over HTTP (`POST /` with an `{"action":"search"|"part"|"health", ...}` body, or `GET /search?keyword=...`). It caches responses for 10 min and keeps the API key server-side.++There is no MCP server. All access is through the CLI + HTTP service.++## Commands++### `adom-mouser search <keyword>`++Search by keyword, MPN, or Mouser PN.++| Flag | Default | Description |+|---|---|---|+| `--limit N` | 10 | Max results (1–50) |+| `--in-stock-only` | false | Only parts currently in stock |+| `--api URL` / `$MOUSER_API` | auto | Backend URL. If unset, the CLI tries `http://127.0.0.1:8775`, and if that's also down it falls back to calling Mouser directly (requires `MOUSER_API_KEY`) |++Examples:+```bash+adom-mouser search "STM32F103RBT6" --limit 5+adom-mouser search "USB-C connector" --in-stock-only+adom-mouser search "LDO 3.3V 500mA SOT-23"+adom-mouser search "ESP32 WiFi Bluetooth" | jq '.components[].mpn'+```++### `adom-mouser part <part-number>`++Full detail lookup for a specific part. Accepts Mouser PN (`511-STM32F103RBT6`) or MPN (`STM32F103RBT6`). Returns a single-component result with full price-break table and attributes.++```bash+adom-mouser part 511-STM32F103RBT6+adom-mouser part STM32F103RBT6 | jq '.components[0].price_tiers'+```++### `adom-mouser app`++Open the search UI in a Hydrogen webview tab.++| Flag | Default | Description |+|---|---|---|+| `--port N` | 8789 | Local app HTTP server port |+| `--api URL` | auto | Backend to use (same resolution as `search`) |+| `--tab-name S` | "Mouser Search" | Hydrogen tab label |+| `--no-tab` | false | Skip opening the tab (test via curl or browser) |+| `--dev` | false | Enable the `/eval` hot-patch channel |++```bash+adom-mouser app                         # normal use+adom-mouser app --dev                   # enable /eval for UI debugging+adom-mouser app --no-tab --port 8789 &  # local-browser mode+```++When `adom-mouser app` is running, these HTTP endpoints are AI-drivable (no UI clicks needed):++```bash+# run a search via HTTP, watch the UI update live+curl -X POST http://127.0.0.1:8789/search \+  -H 'Content-Type: application/json' \+  -d '{"keyword":"ESP32","limit":5,"inStockOnly":false}'++# state + cached last-search+curl http://127.0.0.1:8789/state | jq++# UI-side console logs (what the frontend JS printed)+curl http://127.0.0.1:8789/console | jq '.messages[-10:]'++# backend-health proxy (so the UI doesn't need CORS to 8775)+curl http://127.0.0.1:8789/backend-health | jq++# shutdown cleanly (removes the Hydrogen tab and exits)+curl -X POST http://127.0.0.1:8789/shutdown+```++### `adom-mouser serve`++Run the backend HTTP proxy. This is what the `service-mouser` container runs.++| Flag | Default | Description |+|---|---|---|+| `--port N` / `$MOUSER_PORT` | 8775 | Port |++Requires `MOUSER_API_KEY`. Endpoints:++```+GET  /health                                  — health + live API ping+GET  /search?keyword=...&limit=10&inStockOnly=false+GET  /part?partNumber=...+POST /              {"action":"search"|"part"|"health", ...}+GET  /              (text/html landing page)+```++### `adom-mouser health`++Check reachability.++```bash+adom-mouser health                 # checks backend (or falls back to Mouser direct)+adom-mouser health --api http://127.0.0.1:8775+```++## What's in a result++Each component in `.components[]` has:++- `mouser_pn`    — Mouser part number, use this for BOM+- `mpn`          — manufacturer part number, use this for schematic/footprint+- `manufacturer` — manufacturer name+- `description`, `category`+- `stock`        — integer count in Mouser's warehouse (live, 10-min cache on backend)+- `stock_text`   — raw Mouser text ("In Stock", "Non-Stocked", etc.)+- `popularity`   — `low` / `medium` / `high` heuristic from stock+- `lead_time`    — factory lead time if ordering more+- `unit_price`   — price at qty 1+- `price_tiers`  — array of `{qty, price}` for quantity price breaks+- `min_order`, `mult` — minimum order + order multiple+- `rohs`         — compliance flag+- `lifecycle`    — New Product / Active / NRND / Obsolete+- `datasheet_url`, `product_url`, `image_url`+- `attributes`   — array of `{name, value}` from the product-attribute table++## Typical workflows++### Finding a part from scratch+```bash+adom-mouser search "32-bit ARM Cortex-M3 microcontroller LQFP-48"+# pick one, then:+adom-mouser part <mouser-pn>+```++### Confirming price for a BOM entry+```bash+adom-mouser part STM32F103RBT6 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+```++### Browsing interactively+```bash+adom-mouser app+# then type in the search box, click Product page / Datasheet, etc.+```++## Running the backend++There are two common setups.++### Dedicated `service-mouser` container (shared, has the API key)++The backend proxy runs on a dedicated `default-light` container with `MOUSER_API_KEY` set in `/etc/environment` and a cron watchdog (`service/watchdog.sh`, every 2 min) that (a) keeps the service healthy and (b) auto-pulls `origin/main` + rebuilds when new versions land. Consumer containers point `MOUSER_API` at its public URL:++```+https://mouser-xr8t4f5d01bt.adom.cloud+```++`curl /health` should return `{"ok":true, "api_reachable":true, "version":"X.Y.Z", ...}`. Recover the current URL from `~/service-watcher/services.json` (`mouser` entry, `"primary": true`) if the slug has rotated. To point the CLI at it:++```bash+export MOUSER_API=https://mouser-xr8t4f5d01bt.adom.cloud+adom-mouser search "LM317"+adom-mouser app        # UI reads $MOUSER_API automatically+```++Gallia users don't need to set `MOUSER_API` explicitly — `adom-mouser` is a Tier A app (see `adom-app-model`), so it's installed by every `gallia/install.mjs` run from the wiki binary. The CLI autodetects a local `adom-mouser serve` on 127.0.0.1:8775 and falls back to calling Mouser directly if `MOUSER_API_KEY` is set locally.++### Local-only (quick testing)++On any container, if `MOUSER_API_KEY` is set and you run `adom-mouser serve &`, the CLI and the app will autodetect `http://127.0.0.1:8775` and use it. This is the dev-loop path.++## Installation++The binary is distributed as a GitHub release from `adom-inc/adom-mouser`. After downloading to `/usr/local/bin/adom-mouser` and making it executable, run:++```bash+adom-mouser install+```++This deploys the skill to `~/.claude/skills/mouser/SKILL.md` and installs bash completions.++## Notes++- Price data does not include shipping or tariffs.+- Stock is live at query time (10-min cache on the backend).+- Results are sorted by Mouser's internal relevance algorithm, not strictly by stock.+- `MOUSER_API_KEY` is only ever needed on the backend (`serve`) side. Consumer containers just need `MOUSER_API` pointing at a running backend.
VERSIONadded+1
@@ -0,0 +1 @@+0.1.8
build.shadded+27
@@ -0,0 +1,27 @@+#!/usr/bin/env bash+# Local build + install for adom-mouser.+set -euo pipefail++cd "$(dirname "$0")"++echo "==> cargo build --release"+cargo build --release++BIN=target/release/adom-mouser+if [ ! -x "$BIN" ]; then+  echo "ERROR: build did not produce $BIN" >&2+  exit 1+fi++DEST=/usr/local/bin/adom-mouser+echo "==> installing $DEST (sudo)"+sudo install -m 0755 "$BIN" "$DEST"++echo "==> adom-mouser install (skill + completions)"+adom-mouser install++echo+echo "Done. Try:"+echo "  adom-mouser --help"+echo "  adom-mouser search 'STM32F103'"+echo "  adom-mouser app"
docs/icon.svgadded+20
@@ -0,0 +1,20 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">+  <!-- Adom brand-teal mark: chip silhouette + magnifying lens -->+  <g fill="none" stroke="#00b8b0" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">+    <!-- chip body -->+    <rect x="10" y="14" width="28" height="28" rx="3"/>+    <!-- chip pins (left) -->+    <path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36"/>+    <!-- chip pins (right) -->+    <path d="M38 20 L42 20 M38 28 L42 28 M38 36 L42 36"/>+    <!-- chip pins (top) -->+    <path d="M16 10 L16 14 M24 10 L24 14 M32 10 L32 14"/>+    <!-- chip pins (bottom) -->+    <path d="M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>+  </g>+  <!-- magnifying lens overlay (filled accent) -->+  <circle cx="42" cy="40" r="10" fill="#0d1117" stroke="#00b8b0" stroke-width="4"/>+  <line x1="50" y1="48" x2="58" y2="56" stroke="#00b8b0" stroke-width="5" stroke-linecap="round"/>+  <!-- lens inner highlight -->+  <circle cx="39" cy="37" r="3" fill="#00b8b0" opacity="0.6"/>+</svg>
package.json+2−6
@@ -2,10 +2,6 @@   "slug": "adom-mouser",   "version": "1.0.0",   "type": "app",-  "description": "Search Mouser components, pricing, and stock. Three faces: CLI verbs (search/part/health), a Hydrogen webview app, and a shared backend HTTP service.",-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  }+  "description": "Mouser component search in your workspace — stock, quantity price breaks, datasheets and lifecycle. CLI verbs, a Hydrogen app, and a shared backend service.",+  "dependencies": {} }
page.json+39−26
@@ -3,10 +3,41 @@   "type": "app",   "slug": "adom-mouser",   "title": "Mouser Electronics Search",-  "brief": "Search Mouser components, pricing, and stock. Three faces: CLI verbs (search/part/health), a Hydrogen webview app, and a shared backend HTTP service.",-  "version": "0.1.8",-  "tags": [],+  "brief": "Mouser component search in your workspace — stock, quantity price breaks, datasheets and lifecycle. CLI verbs, a Hydrogen app, and a shared backend service.",+  "version": "1.0.0",+  "install": {+    "binary_name": "adom-mouser",+    "install_dir": "",+    "install_hint": "adompkg install adom-mouser",+    "version_cmd": "adom-mouser --version"+  },+  "author": {+    "id": "6738b00a4dc87b8adbb0a5a3",+    "name": "John Lauer",+    "email": "[email protected]"+  },+  "tags": [+    "mouser",+    "parts",+    "pricing",+    "stock",+    "datasheet",+    "electronics",+    "distributor"+  ],   "license": "MIT",+  "visibility": {+    "public": true+  },+  "hero": {+    "path": "screenshots/hero.png",+    "type": "image"+  },+  "sample_prompts": [+    "find an ESP32-S3-WROOM module on Mouser",+    "what does an STM32F103RBT6 cost at Mouser",+    "Mouser stock for a USB-C receptacle"+  ],   "discovery_triggers": [     "mouser",     "mouser parts",@@ -19,26 +50,8 @@     "datasheet mouser",     "in stock mouser"   ],-  "discovery_pitch": "Search Mouser Electronics for components, pricing, stock, and datasheets — CLI + webview app + shared backend service.",-  "install": {-    "binary_name": "adom-mouser",-    "install_dir": "",-    "install_hint": "",-    "version_cmd": ""-  },-  "readme": "# adom-mouser\n\nSearch Mouser Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:\n\n- **CLI verbs** — `adom-mouser search|part|health` return normalized JSON on stdout, ready for `| jq`.\n- **Hydrogen webview app** — `adom-mouser app` opens a first-class \"Mouser Search\" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and Mouser PN, and a backend-health indicator.\n- **Backend HTTP service** — `adom-mouser serve` runs an HTTP proxy (port 8775) with a 10-minute LRU cache. Keeps the Mouser API key server-side; consumer containers point `MOUSER_API` at it.\n\n![Mouser search webview showing STM32F103 results with pricing and stock](https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-mouser/mouser-search-results.png)\n\n## Install\n\n```bash\ncurl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-mouser/adom-mouser \\\n  -o /tmp/adom-mouser \\\n  && chmod +x /tmp/adom-mouser \\\n  && sudo install -m 0755 /tmp/adom-mouser /usr/local/bin/adom-mouser \\\n  && adom-mouser install\n```\n\nInstalls the binary to `/usr/local/bin/adom-mouser`, deploys `~/.claude/skills/adom-mouser/SKILL.md` and `~/.claude/skills/adom-mouser-build/SKILL.md`, and registers bash completions.\n\nFor gallia containers, this is done automatically by `gallia/install.mjs` — adom-mouser is a **Tier A app** (every Adom user needs component search day-one).\n\n## Use\n\n```bash\n# CLI\nadom-mouser search \"STM32F103RBT6\" --limit 5\nadom-mouser part 511-STM32F103RBT6 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'\nadom-mouser health\n\n# UI\nadom-mouser app        # opens a Hydrogen webview tab\n\n# Backend\nMOUSER_API_KEY=<key> adom-mouser serve    # runs on service-mouser container\n```\n\n## Architecture\n\n```\n  any user container             service-mouser (default-light)\n  ──────────────────             ──────────────────────────────\n  adom-mouser search       ─┐\n  adom-mouser app          ─┼──▶ adom-mouser serve (port 8775)\n  adom-mouser health       ─┘    + MOUSER_API_KEY\n                                 + 10-min LRU cache\n                                 + self-heal cron watchdog\n                                          │\n                                          ▼\n                                api.mouser.com / v2\n```\n\nThe service container clones only `adom-inc/adom-mouser`; its `service/watchdog.sh` runs every 2 min to (1) keep the service healthy and (2) `git fetch origin main` + rebuild + swap binary when a new release lands. No redeploy step after publishing — publish.sh pushes to main, the service container picks it up within ~5 minutes.\n\n## Source\n\nSource at [adom-inc/adom-mouser](https://github.com/adom-inc/adom-mouser) (private). The public artifact is this wiki page's `docker-binary` asset — source access is not required to install or use the app.\n\nBuilt following the canonical [adom-app-model](https://wiki-ufypy5dpx93o.adom.cloud/wiki/skills/adom-app-model): single private repo for both client + service code, binary published to the wiki, service on its own default-light container, Tier A distribution via `gallia/install.mjs`.\n",-  "author": {-    "id": "695820315b5f1e4db2fcf602",-    "name": "Kyle Bergstedt",-    "email": "[email protected]"-  },-  "visibility": {-    "public": true-  },-  "hero": null,-  "sample_prompts": [],-  "metadata": {},-  "created_at": "2026-05-28T05:28:39.859Z",-  "updated_at": "2026-05-28T05:28:39.859Z",-  "skills": []-}\ No newline at end of file+  "discovery_pitch": "Search Mouser Electronics for components, pricing, stock, and datasheets — CLI + Hydrogen webview app + shared backend service.",+  "metadata": {+    "platform": "linux"+  }+}
publish.shadded+118
@@ -0,0 +1,118 @@+#!/usr/bin/env bash+# adom-mouser release publisher.+# Syncs VERSION → Cargo.toml → git tag → gh release → wiki asset → wiki page.+# Idempotent: safe to re-run for the same version (adom-wiki upload replaces).++set -euo pipefail+cd "$(dirname "$0")"++VERSION=$(cat VERSION | tr -d '[:space:]')+if [ -z "$VERSION" ]; then+  echo "ERROR: VERSION file is empty" >&2; exit 1+fi++# ANSI only when stdout is a real terminal (adom-cli-design rule:+# no raw escapes when the AI captures stdout).+if [ -t 1 ]; then C_OK='\033[32m'; C_ERR='\033[31m'; C_STEP='\033[36m'; C_RESET='\033[0m'+else C_OK=''; C_ERR=''; C_STEP=''; C_RESET=''+fi+ok()   { printf '%sOK:%s %s\n' "$C_OK" "$C_RESET" "$1"; }+fail() { printf '%sERROR:%s %s\n' "$C_ERR" "$C_RESET" "$1" >&2; exit 1; }+step() { printf '\n%s== %s ==%s\n' "$C_STEP" "$1" "$C_RESET"; }++# ── 0. Sanity: VERSION == Cargo.toml version ───────────────────────+step "0. sanity checks"+CARGO_VERSION=$(grep -m1 '^version =' Cargo.toml | sed -E 's/version = "([^"]+)"/\1/')+if [ "$VERSION" != "$CARGO_VERSION" ]; then+  fail "VERSION ($VERSION) != Cargo.toml ($CARGO_VERSION). Run: sed -i 's/^version = .*/version = \"$VERSION\"/' Cargo.toml"+fi+ok "VERSION + Cargo.toml both at $VERSION"++# ── 1. Build + verify binary --version ─────────────────────────────+step "1. cargo build --release"+cargo build --release+BIN=target/release/adom-mouser+BIN_VER=$("$BIN" --version | awk '{print $NF}')+if [ "$BIN_VER" != "$VERSION" ]; then+  fail "binary --version ($BIN_VER) != VERSION ($VERSION)"+fi+ok "binary prints adom-mouser $VERSION"++# ── 2. Git commit + tag + push ─────────────────────────────────────+step "2. git commit + tag v$VERSION"+git add -A+if ! git diff --cached --quiet; then+  git commit -m "release v$VERSION"+  ok "committed"+else+  ok "nothing to commit"+fi+git tag -f "v$VERSION"+git push origin main+git push origin "v$VERSION" --force+ok "pushed main + v$VERSION tag"++# ── 3. GitHub Release (optional, informational) ────────────────────+step "3. gh release create v$VERSION"+if gh release view "v$VERSION" >/dev/null 2>&1; then+  gh release upload "v$VERSION" "$BIN" --clobber+  ok "updated release v$VERSION binary"+else+  gh release create "v$VERSION" "$BIN" --title "v$VERSION" --generate-notes+  ok "created release v$VERSION"+fi++# ── 4. Wiki page publish/update ────────────────────────────────────+step "4. adom-wiki page publish apps/adom-mouser"+TITLE=$(jq -r '.title' wiki/page.json)+BRIEF=$(jq -r '.brief' wiki/page.json)+adom-wiki page publish apps/adom-mouser \+  --title "$TITLE" \+  --brief "$BRIEF" \+  --body-md wiki/body.md \+  --version "$VERSION" \+  --sample-prompt "Search Mouser|find me a USB-C connector on Mouser with stock > 1000" \+  --sample-prompt "Part lookup|look up Mouser PN 511-STM32F103RBT6" \+  --sample-prompt "Open the app|open the Mouser search app in Hydrogen" \+  --sample-prompt "Compare alternatives|find cheaper alternatives to the AMS1117-3.3 on Mouser" \+  --sample-prompt "Check stock|is the ESP32-S3-WROOM-1U-N4R2 in stock at Mouser?"+ok "wiki page published at apps/adom-mouser v$VERSION"++# ── 5. Wiki asset upload (canonical install surface) ───────────────+step "5. adom-wiki asset upload (docker-binary)"+adom-wiki asset upload apps/adom-mouser --asset-type docker-binary --file "$BIN"+ok "uploaded $BIN to wiki at apps/adom-mouser"++# ── 6. Push metadata (discovery_triggers, install_hint, releases) ──+step "6. adom-wiki page edit --field metadata"+TMP_META=$(mktemp)+jq --arg v "$VERSION" '.metadata | .version = $v' wiki/page.json > "$TMP_META"+adom-wiki page edit apps/adom-mouser --field metadata --body-md "$TMP_META"+rm -f "$TMP_META"+ok "metadata updated"++# ── 7. Verify the wiki actually serves v$VERSION ───────────────────+step "7. verify wiki binary --version"+BINARY_URL=$(jq -r '.metadata.releases.adom_docker.binary_url' wiki/page.json)+if [ -z "$BINARY_URL" ] || [ "$BINARY_URL" = "null" ]; then+  fail "wiki page.json has no binary_url"+fi+CHECK=$(mktemp)+if ! curl -fsSL "$BINARY_URL" -o "$CHECK"; then+  rm -f "$CHECK"+  fail "wiki binary download failed at $BINARY_URL (may need a minute to propagate; re-run step 7)"+fi+chmod +x "$CHECK"+WIKI_VER=$("$CHECK" --version | awk '{print $NF}')+rm -f "$CHECK"+if [ "$WIKI_VER" != "$VERSION" ]; then+  fail "wiki serves $WIKI_VER but we just published $VERSION"+fi+ok "wiki binary returns --version adom-mouser $VERSION"++echo+echo "───────────────────────────────────────────────"+echo "  adom-mouser v$VERSION published."+echo "  Wiki: $BINARY_URL"+echo "  The service-mouser container will auto-update within 2 minutes."+echo "───────────────────────────────────────────────"
screenshots/cli.pngadded
⋯ 1 unchanged line ⋯
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
screenshots/results-esp32.pngadded
⋯ 1 unchanged line ⋯
screenshots/results-usbc.pngadded
⋯ 1 unchanged line ⋯
service/deploy.shadded+99
@@ -0,0 +1,99 @@+#!/usr/bin/env bash+# adom-mouser service container bootstrap.+# Run ONCE on a freshly created default-light container.+# Usage: MOUSER_API_KEY=<key> bash service/deploy.sh+#+# What it does:+#   1. Installs rustup if missing (default-light has no Rust)+#   2. Clones adom-inc/adom-mouser if not already present+#   3. Builds the binary and installs to /usr/local/bin+#   4. Persists MOUSER_API_KEY to /etc/environment (so cron inherits it)+#   5. Installs the cron line for watchdog.sh (every 2 min)+#   6. Kicks off the first run and verifies /health++set -euo pipefail++REPO_URL="${ADOM_MOUSER_REPO_URL:-https://github.com/adom-inc/adom-mouser.git}"+REPO_DIR="${ADOM_MOUSER_REPO:-$HOME/adom-mouser}"+PORT="${MOUSER_PORT:-8775}"++ok()   { printf '  \033[32m✓\033[0m %s\n' "$1"; }+info() { printf '  \033[2m%s\033[0m\n' "$1"; }+fail() { printf '  \033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }++echo "== adom-mouser service deploy =="++# ── 1. Rust toolchain ──────────────────────────────────────────────+if ! command -v cargo >/dev/null; then+  info "installing rustup (default-light has no Rust)..."+  curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \+    sh -s -- -y --default-toolchain stable --profile minimal+  . "$HOME/.cargo/env"+  ok "rustup installed ($(rustc --version))"+else+  ok "rustc already present ($(rustc --version))"+fi++# ── 2. Clone the repo ──────────────────────────────────────────────+if [ -d "$REPO_DIR/.git" ]; then+  ok "repo already cloned at $REPO_DIR"+  cd "$REPO_DIR"+  git fetch --quiet origin main && git reset --hard origin/main+  ok "repo fast-forwarded to origin/main"+else+  info "cloning $REPO_URL..."+  git clone "$REPO_URL" "$REPO_DIR" || fail "git clone failed (is the repo private? Set up gh auth first)"+  ok "cloned to $REPO_DIR"+  cd "$REPO_DIR"+fi++# ── 3. Build + install ─────────────────────────────────────────────+info "building release binary (takes 1-2 min)..."+cargo build --release+sudo install -m 0755 target/release/adom-mouser /usr/local/bin/adom-mouser+ok "installed /usr/local/bin/adom-mouser ($(adom-mouser --version))"++# ── 4. API key → /etc/environment (so cron inherits it) ────────────+if [ -n "${MOUSER_API_KEY:-}" ]; then+  # Remove any prior MOUSER_API_KEY line, append the new one+  if grep -q '^MOUSER_API_KEY=' /etc/environment 2>/dev/null; then+    sudo sed -i '/^MOUSER_API_KEY=/d' /etc/environment+  fi+  echo "MOUSER_API_KEY=$MOUSER_API_KEY" | sudo tee -a /etc/environment >/dev/null+  ok "MOUSER_API_KEY persisted to /etc/environment"+else+  info "MOUSER_API_KEY not set in env — service will start but /health will show ok:false"+fi++# ── 5. Crons — watchdog + hourly smoke + daily live smoke ──────────+# Quota-aware: hourly smoke hits /health only (0 upstream calls).+# Live suite runs once/day at 13:00 UTC, ~2 upstream calls.+command -v jq >/dev/null || sudo apt-get install -y -qq jq >/dev/null 2>&1 || true++WATCHDOG_CRON="*/2 * * * * $REPO_DIR/service/watchdog.sh >> /tmp/adom-mouser-watchdog.log 2>&1"+SMOKE_CRON="20 * * * * $REPO_DIR/tests/cron-smoke.sh >> /tmp/adom-mouser-smoke.log 2>&1"+LIVE_CRON="0 13 * * * $REPO_DIR/tests/cron-smoke.sh --live >> /tmp/adom-mouser-smoke-live.log 2>&1"++(crontab -l 2>/dev/null | grep -v -E 'adom-mouser' || true; \+ echo "$WATCHDOG_CRON"; echo "$SMOKE_CRON"; echo "$LIVE_CRON") | crontab -+ok "crons installed (watchdog 2m · smoke hourly · live daily 13:00 UTC)"++chmod +x "$REPO_DIR/service/watchdog.sh" "$REPO_DIR/tests/cron-smoke.sh" "$REPO_DIR/tests/smoke.sh" "$REPO_DIR/tests/smoke-live.sh" 2>/dev/null || true++# ── 6. First run + health check ────────────────────────────────────+info "running watchdog to start the service..."+bash "$REPO_DIR/service/watchdog.sh" || true+sleep 3++HEALTH=$(curl -sf --max-time 5 "http://127.0.0.1:$PORT/health" || true)+if [ -z "$HEALTH" ]; then+  fail "service did not come up on port $PORT — check /tmp/adom-mouser.log"+fi+ok "service healthy on port $PORT"+echo "  $(echo "$HEALTH" | head -c 200)"++echo+echo "Next steps (run from your MAIN container, not here):"+echo "  1. adom-cli carbon containers port-add <this-slug> --port $PORT --prefix mouser"+echo "  2. curl the resulting public URL + /health to confirm"+echo "  3. Update ~/service-watcher/services.json with the new URL"
service/service.jsonadded+10
@@ -0,0 +1,10 @@+{+  "name": "adom-mouser",+  "port": 8775,+  "health": "http",+  "health_path": "/health",+  "start": "/usr/local/bin/adom-mouser serve",+  "env_required": ["MOUSER_API_KEY"],+  "log": "/tmp/adom-mouser.log",+  "description": "Mouser Electronics API proxy with 10-min LRU cache"+}
service/watchdog.shadded+62
@@ -0,0 +1,62 @@+#!/usr/bin/env bash+# adom-mouser watchdog — app-local, cron every 2 min.+# Responsibilities:+#   1. Keep /health green (relaunch if down).+#   2. Auto-deploy: if origin/main has moved, pull + rebuild + restart.+# No gallia dependency. Run from $REPO_DIR/service/watchdog.sh.++set -euo pipefail+set -a; [ -f /etc/environment ] && . /etc/environment; set +a++PORT="${MOUSER_PORT:-8775}"+LOG=/tmp/adom-mouser.log+REPO_DIR="${ADOM_MOUSER_REPO:-$HOME/adom-mouser}"+BIN=/usr/local/bin/adom-mouser++# ── 1. Self-update (pull + rebuild + reinstall) ────────────────────+if [ -d "$REPO_DIR/.git" ]; then+  cd "$REPO_DIR"+  local_sha=$(git rev-parse HEAD 2>/dev/null || echo "")+  if git fetch --quiet origin main 2>/dev/null; then+    remote_sha=$(git rev-parse origin/main 2>/dev/null || echo "")+    if [ -n "$remote_sha" ] && [ "$local_sha" != "$remote_sha" ]; then+      echo "[$(date -Iseconds)] watchdog: updating $local_sha -> $remote_sha" >> "$LOG"+      git reset --hard origin/main >> "$LOG" 2>&1+      # cargo build may take a while; keep the old binary running until the new one builds+      if [ -f "$HOME/.cargo/env" ]; then . "$HOME/.cargo/env"; fi+      if cargo build --release >> "$LOG" 2>&1; then+        sudo install -m 0755 "$REPO_DIR/target/release/adom-mouser" "$BIN"+        pkill -f 'adom-mouser serve' 2>/dev/null || true+        echo "[$(date -Iseconds)] watchdog: rebuilt + swapped" >> "$LOG"+      else+        echo "[$(date -Iseconds)] watchdog: BUILD FAILED for $remote_sha; keeping old binary" >> "$LOG"+      fi+    fi+  fi+fi++# ── 2. Liveness check + relaunch ───────────────────────────────────+# "Liveness" here = the service is accepting connections. We do NOT treat+# HTTP 500 as unhealthy because the service legitimately 500s on /health+# when MOUSER_API_KEY isn't set (= misconfigured, not dead). Restarting it+# wouldn't fix a missing env var; it would just thrash.+# `curl -s` (without -f) returns 0 for any HTTP response, non-zero only+# when it can't connect at all.+if curl -s --max-time 3 "http://127.0.0.1:$PORT/health" -o /dev/null; then+  exit 0  # service is listening; leave it alone+fi++echo "[$(date -Iseconds)] watchdog: port $PORT not accepting, (re)launching" >> "$LOG"+pkill -f 'adom-mouser serve' 2>/dev/null || true+sleep 1+nohup "$BIN" serve --port "$PORT" >> "$LOG" 2>&1 &+disown || true++# Give it a moment then verify it's accepting.+sleep 3+if curl -s --max-time 3 "http://127.0.0.1:$PORT/health" -o /dev/null; then+  echo "[$(date -Iseconds)] watchdog: relaunch OK (listening)" >> "$LOG"+else+  echo "[$(date -Iseconds)] watchdog: relaunch FAILED (port still closed)" >> "$LOG"+  exit 1+fi
src/app/mod.rsadded+315
@@ -0,0 +1,315 @@+//! `adom-mouser app` — opens a Hydrogen webview tab with a searchable+//! Mouser UI. The server here is a thin per-session wrapper that proxies+//! the real work to the shared backend (`adom-mouser serve` on 8775, or+//! a remote MOUSER_API URL).+//!+//! Frontend conventions (per app-creator skill):+//!   - brand fonts/colors+//!   - monochrome white icons inside the UI+//!   - Adom teal accent for primary actions+//!   - 2-way HTTP comms (every UI action has a server endpoint)+//!   - console forwarding + optional eval channel for dev+//!   - relative URLs for every fetch/asset (proxy-safe)+//!   - custom SVG favicon served at /favicon.svg++mod tab;++use crate::mouser::SearchResult;+use crate::{note, ok, warn, AppArgs};+use serde_json::{json, Value};+use std::collections::VecDeque;+use std::sync::{Arc, Mutex};+use std::time::Duration;+use tiny_http::{Header, Method, Response, Server};++const UI_HTML: &str = include_str!("ui.html");+const ICON_SVG: &str = include_str!("../../docs/icon.svg");++const CONSOLE_MAX: usize = 500;++struct AppState {+    api: String,+    dev: bool,+    console: Mutex<VecDeque<Value>>,+    last_query: Mutex<Option<String>>,+    last_results: Mutex<Option<SearchResult>>,+    pending_eval: Mutex<Option<(String, String)>>, // (id, code)+    eval_results: Mutex<std::collections::HashMap<String, Value>>,+}++fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body.to_string())+        .with_status_code(code)+        .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())+        .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())+}++fn read_body(req: &mut tiny_http::Request) -> Result<Value, String> {+    use std::io::Read;+    let mut body = String::new();+    req.as_reader()+        .read_to_string(&mut body)+        .map_err(|e| format!("read body: {}", e))?;+    if body.is_empty() {+        return Ok(Value::Null);+    }+    serde_json::from_str(&body).map_err(|e| format!("parse JSON: {}", e))+}++fn backend_post(api: &str, body: Value) -> Result<Value, String> {+    let url = if api.ends_with('/') { api.to_string() } else { format!("{}/", api) };+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(30))+        .build();+    let res = match agent.post(&url).set("Content-Type", "application/json").send_json(body) {+        Ok(r) => r,+        // ureq treats 4xx/5xx as errors; unwrap the response so we can+        // forward the JSON body ({"error":"..."}) to the caller.+        Err(ureq::Error::Status(_code, r)) => r,+        Err(e) => return Err(format!("backend POST failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("backend JSON parse failed: {}", e))+}++fn backend_get(url: &str) -> Result<Value, String> {+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(10))+        .build();+    let res = match agent.get(url).call() {+        Ok(r) => r,+        Err(ureq::Error::Status(_code, r)) => r,+        Err(e) => return Err(format!("backend GET failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("backend JSON parse failed: {}", e))+}++fn resolve_backend(explicit: Option<String>) -> String {+    if let Some(s) = explicit.filter(|s| !s.is_empty()) {+        return s;+    }+    "http://127.0.0.1:8775".into()+}++pub fn run(args: AppArgs) -> Result<(), String> {+    let api = resolve_backend(args.api.clone());+    ok(format!("Mouser app starting on port {} (backend: {})", args.port, api));++    let state = Arc::new(AppState {+        api: api.clone(),+        dev: args.dev,+        console: Mutex::new(VecDeque::with_capacity(CONSOLE_MAX)),+        last_query: Mutex::new(None),+        last_results: Mutex::new(None),+        pending_eval: Mutex::new(None),+        eval_results: Mutex::new(std::collections::HashMap::new()),+    });++    let addr = format!("0.0.0.0:{}", args.port);+    let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;++    // Verify backend reachable up-front; non-fatal, UI shows the error.+    match backend_get(&format!("{}/health", api)) {+        Ok(v) => note!("  backend health: {}", v),+        Err(e) => warn(format!("backend unreachable at {}: {}", api, e)),+    }++    // Open the Hydrogen tab unless --no-tab+    if !args.no_tab {+        let proxy_url = tab::proxy_url(args.port);+        match tab::add_webview_tab(&args.tab_name, &proxy_url, ICON_SVG) {+            Ok(_) => ok(format!("opened Hydrogen tab '{}' → {}", args.tab_name, proxy_url)),+            Err(e) => warn(format!("couldn't open tab via adom-cli: {}. You can still open {} manually.", e, proxy_url)),+        }+    }++    // Ctrl-c cleanup is best-effort via the /shutdown endpoint.+    // If the process is killed abruptly, the user can run:+    //   adom-cli hydrogen workspace remove-tab --name "Mouser Search"+    note!("  {}Press ctrl-c or curl POST /shutdown to stop.{}", crate::DIM, crate::RESET);++    for mut req in server.incoming_requests() {+        let url = req.url().to_string();+        let path = url.split_once('?').map(|(p, _)| p).unwrap_or(&url).to_string();+        let method = req.method().clone();+        let state_ref = Arc::clone(&state);++        let result: (u16, Value, bool) = match (method.clone(), path.as_str()) {+            // Static+            (Method::Get, "/") | (Method::Get, "/index.html") => {+                let _ = req.respond(+                    Response::from_string(UI_HTML)+                        .with_status_code(200)+                        .with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())+                        .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap()),+                );+                continue;+            }+            (Method::Get, "/favicon.svg") => {+                let _ = req.respond(+                    Response::from_string(ICON_SVG)+                        .with_status_code(200)+                        .with_header(Header::from_bytes("Content-Type", "image/svg+xml").unwrap())+                        .with_header(Header::from_bytes("Cache-Control", "max-age=86400").unwrap()),+                );+                continue;+            }++            // State + control+            (Method::Get, "/state") => {+                let q = state_ref.last_query.lock().unwrap().clone();+                let r = state_ref.last_results.lock().unwrap().clone();+                (200, json!({+                    "backend": state_ref.api,+                    "dev": state_ref.dev,+                    "last_query": q,+                    "last_results": r,+                }), false)+            }++            (Method::Post, "/search") => match read_body(&mut req) {+                Ok(body) => {+                    let keyword = body.get("keyword").and_then(|v| v.as_str()).unwrap_or("").to_string();+                    let limit = body.get("limit").and_then(|v| v.as_u64()).unwrap_or(20);+                    let in_stock = body.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(false);+                    if keyword.trim().is_empty() {+                        (400, json!({"error": "keyword is required"}), false)+                    } else {+                        match backend_post(+                            &state_ref.api,+                            json!({"action":"search","keyword":keyword,"limit":limit,"inStockOnly":in_stock}),+                        ) {+                            Ok(v) => {+                                if let Some(e) = v.get("error").and_then(|v| v.as_str()) {+                                    (502, json!({"error": e}), false)+                                } else {+                                    *state_ref.last_query.lock().unwrap() = Some(keyword);+                                    if let Ok(sr) = serde_json::from_value::<SearchResult>(v.clone()) {+                                        *state_ref.last_results.lock().unwrap() = Some(sr);+                                    }+                                    (200, v, false)+                                }+                            }+                            Err(e) => (502, json!({"error": e}), false),+                        }+                    }+                }+                Err(e) => (400, json!({"error": e}), false),+            },++            (Method::Post, "/part") => match read_body(&mut req) {+                Ok(body) => {+                    let pn = body.get("partNumber").and_then(|v| v.as_str()).unwrap_or("").to_string();+                    if pn.trim().is_empty() {+                        (400, json!({"error": "partNumber is required"}), false)+                    } else {+                        match backend_post(&state_ref.api, json!({"action":"part","partNumber":pn})) {+                            Ok(v) => {+                                if let Some(e) = v.get("error").and_then(|v| v.as_str()) {+                                    (502, json!({"error": e}), false)+                                } else {+                                    (200, v, false)+                                }+                            }+                            Err(e) => (502, json!({"error": e}), false),+                        }+                    }+                }+                Err(e) => (400, json!({"error": e}), false),+            },++            (Method::Get, "/backend-health") => {+                match backend_get(&format!("{}/health", state_ref.api)) {+                    Ok(v) => (200, v, false),+                    Err(e) => (502, json!({"error": e, "backend": state_ref.api}), false),+                }+            }++            // Console forwarding (UI → server)+            (Method::Post, "/console") => match read_body(&mut req) {+                Ok(v) => {+                    let mut cons = state_ref.console.lock().unwrap();+                    if cons.len() >= CONSOLE_MAX {+                        cons.pop_front();+                    }+                    cons.push_back(v);+                    (200, json!({"ok": true}), false)+                }+                Err(e) => (400, json!({"error": e}), false),+            },+            (Method::Get, "/console") => {+                let cons: Vec<Value> = state_ref.console.lock().unwrap().iter().cloned().collect();+                (200, json!({"messages": cons}), false)+            }+            (Method::Delete, "/console") => {+                state_ref.console.lock().unwrap().clear();+                (200, json!({"ok": true}), false)+            }++            // Eval channel (dev only — gated on --dev flag)+            (Method::Post, "/eval") if state_ref.dev => match read_body(&mut req) {+                Ok(v) => {+                    let code = v.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();+                    if code.is_empty() {+                        (400, json!({"error": "code is required"}), false)+                    } else {+                        let id = format!("ev{}", rand_suffix());+                        *state_ref.pending_eval.lock().unwrap() = Some((id.clone(), code));+                        (200, json!({"id": id}), false)+                    }+                }+                Err(e) => (400, json!({"error": e}), false),+            },+            (Method::Get, "/eval/pending") if state_ref.dev => {+                let pending = state_ref.pending_eval.lock().unwrap().take();+                match pending {+                    Some((id, code)) => (200, json!({"id": id, "code": code}), false),+                    None => (200, json!({}), false),+                }+            }+            (Method::Post, path) if state_ref.dev && path.starts_with("/eval/") && path.ends_with("/result") => {+                let id = path.trim_start_matches("/eval/").trim_end_matches("/result").to_string();+                match read_body(&mut req) {+                    Ok(v) => {+                        let result = v.get("result").cloned().unwrap_or(Value::Null);+                        state_ref.eval_results.lock().unwrap().insert(id.clone(), result);+                        (200, json!({"ok": true}), false)+                    }+                    Err(e) => (400, json!({"error": e}), false),+                }+            }+            (Method::Get, path) if state_ref.dev && path.starts_with("/eval/") => {+                let id = path.trim_start_matches("/eval/").to_string();+                let r = state_ref.eval_results.lock().unwrap().get(&id).cloned();+                match r {+                    Some(v) => (200, json!({"result": v, "done": true}), false),+                    None => (200, json!({"done": false}), false),+                }+            }++            // Shutdown+            (Method::Post, "/shutdown") => {+                if !args.no_tab {+                    let _ = tab::remove_webview_tab(&args.tab_name);+                }+                let _ = req.respond(json_resp(200, json!({"ok": true, "shutdown": true})));+                ok("shutdown requested, exiting");+                std::process::exit(0);+            }++            _ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)}), false),+        };++        let _ = req.respond(json_resp(result.0, result.1));+    }+    Ok(())+}++fn rand_suffix() -> String {+    // Tiny rand using system time — good enough for per-request ids.+    let now = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .unwrap_or_default()+        .as_nanos();+    format!("{:x}", now)+}+
src/app/tab.rsadded+134
@@ -0,0 +1,134 @@+//! Hydrogen tab lifecycle via `adom-cli hydrogen workspace`.++use base64::Engine;+use std::process::Command;++const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";++/// Substitute the current port into VSCODE_PROXY_URI so the webview+/// loads via the Coder proxy (same-origin with hydrogen.adom.inc).+/// Falls back to 127.0.0.1 for local testing when the env var is not+/// set — `adom-mouser app --no-tab` + `$ open http://127.0.0.1:8789`.+pub fn proxy_url(port: u16) -> String {+    match std::env::var("VSCODE_PROXY_URI") {+        Ok(tmpl) => tmpl.replace("{{port}}", &port.to_string()),+        Err(_) => format!("http://127.0.0.1:{}/", port),+    }+}++fn icon_data_url(icon_svg: &str) -> String {+    let b64 = base64::engine::general_purpose::STANDARD.encode(icon_svg.as_bytes());+    format!("data:image/svg+xml;base64,{}", b64)+}++pub fn add_webview_tab(name: &str, url: &str, icon_svg: &str) -> Result<(), String> {+    // Find the focused leaf we should attach to.+    let leaf = focused_leaf_for_tab()?;+    let initial_state = serde_json::json!({ "url": url }).to_string();+    let icon = icon_data_url(icon_svg);+    let out = Command::new("adom-cli")+        .args([+            "hydrogen",+            "workspace",+            "add-tab",+            "--panel-id",+            &leaf,+            "--panel-type",+            WEBVIEW_PANEL_TYPE,+            "--display-name",+            name,+            "--display-icon",+            &icon,+            "--initial-state",+            &initial_state,+        ])+        .output()+        .map_err(|e| format!("adom-cli add-tab: {}", e))?;+    if !out.status.success() {+        return Err(format!(+            "adom-cli add-tab failed: {}",+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok(())+}++pub fn remove_webview_tab(name: &str) -> Result<(), String> {+    let out = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "remove-tab", "--name", name])+        .output()+        .map_err(|e| format!("adom-cli remove-tab: {}", e))?;+    if !out.status.success() {+        return Err(format!(+            "adom-cli remove-tab failed: {}",+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok(())+}++/// Pick a leaf panel that is NOT the VS Code editor, so we don't cover+/// the user's code view. Walk the workspace tree, find nodes with+/// `type: "leaf"`, check whether any tab in the leaf is a VS Code+/// editor (`...eeee-4000-a000-...`). Return the first non-editor+/// leaf's id. Falls back to the focused panel id if nothing else fits.+fn focused_leaf_for_tab() -> Result<String, String> {+    let out = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "get"])+        .output()+        .map_err(|e| format!("adom-cli workspace get: {}", e))?;+    if !out.status.success() {+        return Err(format!("adom-cli workspace get failed: {}", String::from_utf8_lossy(&out.stderr)));+    }+    let v: serde_json::Value = serde_json::from_slice(&out.stdout)+        .map_err(|e| format!("parse workspace JSON: {}", e))?;++    let mut leaves: Vec<LeafInfo> = Vec::new();+    walk_leaves(&v, &mut leaves);++    // Prefer leaves that contain no VS Code editor tabs.+    let non_editor: Vec<&LeafInfo> = leaves.iter().filter(|l| !l.has_editor).collect();+    if let Some(l) = non_editor.first() {+        return Ok(l.id.clone());+    }+    if let Some(id) = v.get("focusedPanelId").and_then(|v| v.as_str()) {+        return Ok(id.to_string());+    }+    leaves+        .first()+        .map(|l| l.id.clone())+        .ok_or_else(|| "no panels found in workspace".into())+}++struct LeafInfo {+    id: String,+    has_editor: bool,+}++fn walk_leaves(v: &serde_json::Value, out: &mut Vec<LeafInfo>) {+    if let Some(obj) = v.as_object() {+        if obj.get("type").and_then(|v| v.as_str()) == Some("leaf") {+            if let Some(id) = obj.get("id").and_then(|v| v.as_str()) {+                let tabs = obj.get("tabs").and_then(|v| v.as_array());+                let has_editor = tabs+                    .map(|arr| {+                        arr.iter().any(|t| {+                            let pt = t.get("panelType").and_then(|v| v.as_str()).unwrap_or("");+                            // VS Code editor panel UUID has "-eeee-4000-a000-"+                            // or "-0030-4000-a000-". Web View is "-0031-".+                            pt.contains("eeee-4000") || pt.contains("0030-4000")+                        })+                    })+                    .unwrap_or(false);+                out.push(LeafInfo { id: id.to_string(), has_editor });+            }+        }+        for (_, child) in obj {+            walk_leaves(child, out);+        }+    } else if let Some(arr) = v.as_array() {+        for c in arr {+            walk_leaves(c, out);+        }+    }+}
src/app/ui.htmladded+638
@@ -0,0 +1,638 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width,initial-scale=1">+<title>Mouser Search</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<style>+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'),+       url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2') format('woff2');+  font-weight: 600 900; font-display: swap;+}++:root {+  --bg: #0d1117;+  --surface: #161b22;+  --surface2: #1c2129;+  --border: #30363d;+  --text: #e6edf3;+  --text-dim: #8b949e;+  --accent: #00b8b0;+  --green: #3fb950;+  --red: #f85149;+  --yellow: #d29922;+  --purple: #8C6BF7;+  --blue: #58a6ff;+}++* { box-sizing: border-box; margin: 0; padding: 0; }+html, body {+  background: var(--bg);+  color: var(--text);+  font-family: 'Satoshi', sans-serif;+  font-size: 14px;+  line-height: 1.55;+  min-height: 100vh;+  /* Tooltips default-anchor `left: 0` and grow rightward; a tooltip on a+     right-edge trigger extends up to 340px past the viewport even at+     opacity:0, which creates a horizontal scrollbar. overflow-x: hidden+     clips that silently. Specific right-edge triggers also carry+     data-tooltip-align="right" for correct semantics. */+  overflow-x: hidden;+}+body { padding: 24px 28px 80px; }+h1, h2, h3 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; }+code, pre, .mono { font-family: 'JetBrains Mono', monospace; font-size: 13px; }++/* --- Header --- */+.hdr { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }+.hdr .logo {+  /* In-app icons are monochrome white (var(--text)) per brand/SKILL.md §"Icons+     inside the UI must be monochrome white (#e6edf3) or inherit currentColor+     from a white-text context". The favicon at docs/icon.svg can be teal —+     this rule only governs icons rendered INSIDE the running app. */+  width: 32px; height: 32px; color: var(--text);+  display: inline-flex; align-items: center; justify-content: center;+}+.hdr h1 { font-size: 22px; letter-spacing: -0.01em; }+.hdr .subtitle { color: var(--text-dim); font-size: 13px; margin-top: 2px; }+.hdr .backend-status {+  margin-left: auto; display: flex; align-items: center; gap: 6px;+  font-size: 12px; color: var(--text-dim);+  padding: 4px 10px; border-radius: 8px;+  background: var(--surface); border: 1px solid var(--border);+}+.hdr .backend-status .dot {+  width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);+  transition: background 0.2s;+}+.hdr .backend-status.ok .dot { background: var(--green); box-shadow: 0 0 0 2px rgba(63,185,80,0.2); }+.hdr .backend-status.err .dot { background: var(--red); box-shadow: 0 0 0 2px rgba(248,81,73,0.2); }++/* --- Search bar --- */+.searchbar {+  display: flex; gap: 10px; align-items: stretch; margin-bottom: 16px;+  background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 6px;+  transition: border-color 0.15s, box-shadow 0.15s;+}+.searchbar:focus-within {+  border-color: var(--accent);+  box-shadow: 0 0 0 3px rgba(0,184,176,0.15);+}+.searchbar input {+  flex: 1; background: transparent; border: none; outline: none; color: var(--text);+  font-family: 'Satoshi', sans-serif; font-size: 15px; padding: 10px 14px;+}+.searchbar input::placeholder { color: var(--text-dim); }+.searchbar .limit-select {+  background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;+  color: var(--text); padding: 0 10px; font-size: 13px; cursor: pointer;+  font-family: 'JetBrains Mono', monospace;+}+.btn {+  padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer;+  font-family: 'Satoshi', sans-serif; font-weight: 600; font-size: 14px;+  transition: background 0.15s, transform 0.05s;+}+.btn:active { transform: translateY(1px); }+.btn.primary { background: var(--accent); color: #0d1117; }+.btn.primary:hover { background: #00d4cb; }+.btn.primary:disabled { background: var(--border); color: var(--text-dim); cursor: not-allowed; }+.btn.ghost {+  background: transparent; color: var(--text-dim); border: 1px solid var(--border);+}+.btn.ghost:hover { color: var(--text); border-color: var(--text-dim); }++.filters {+  display: flex; gap: 16px; align-items: center; margin-bottom: 20px;+  color: var(--text-dim); font-size: 13px;+}+.filters label { display: flex; gap: 6px; align-items: center; cursor: pointer; }+.filters input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; }+.filters .hint { font-size: 12px; }++/* --- Status + feedback --- */+.statusline {+  color: var(--text-dim); font-size: 13px; margin: 8px 0 16px; min-height: 20px;+}+.statusline.error { color: var(--red); }+.statusline.ok { color: var(--green); }++/* --- Results --- */+.results { display: flex; flex-direction: column; gap: 12px; }+.card {+  background: var(--surface); border: 1px solid var(--border); border-radius: 12px;+  padding: 18px 20px; transition: border-color 0.15s;+}+.card:hover { border-color: var(--text-dim); }++.card-top { display: flex; align-items: flex-start; gap: 16px; }+.card-img {+  flex: 0 0 64px; width: 64px; height: 64px; border-radius: 8px;+  background: var(--surface2); display: flex; align-items: center; justify-content: center;+  overflow: hidden; position: relative; cursor: zoom-in;+  transition: transform 0.12s ease;+}+.card-img:hover { transform: scale(1.04); }+.card-img img { width: 100%; height: 100%; object-fit: contain; display: block; }++/* Lightbox for clicked product photo */+.lightbox {+  position: fixed; inset: 0; z-index: 100000;+  background: rgba(13,17,23,0.92); backdrop-filter: blur(10px);+  display: flex; align-items: center; justify-content: center;+  opacity: 0; pointer-events: none;+  transition: opacity 0.18s ease;+  cursor: zoom-out;+}+.lightbox.open { opacity: 1; pointer-events: auto; }+.lightbox img {+  max-width: min(90vw, 1200px); max-height: 86vh;+  border-radius: 12px;+  box-shadow: 0 24px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(0,184,176,0.25);+  background: #fff;+}+.lightbox .lb-caption {+  position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);+  color: var(--text); background: rgba(22,27,34,0.88);+  padding: 10px 18px; border-radius: 10px; font-size: 13px;+  border: 1px solid var(--border); max-width: min(90vw, 900px);+  font-family: 'JetBrains Mono', monospace;+}+.lightbox .lb-close {+  position: absolute; top: 20px; right: 24px; color: var(--text);+  background: rgba(22,27,34,0.88); border: 1px solid var(--border);+  width: 40px; height: 40px; border-radius: 50%;+  display: flex; align-items: center; justify-content: center;+  cursor: pointer; font-family: 'Satoshi', sans-serif; font-size: 18px;+}+.card-img .placeholder { color: var(--text-dim); font-size: 10px; font-family: 'JetBrains Mono', monospace; }++.card-body { flex: 1; min-width: 0; }+.card-row1 {+  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 4px;+}+.mouser-pn { font-family: 'JetBrains Mono', monospace; color: var(--accent); font-weight: 600; font-size: 14px; }+.mpn { font-family: 'JetBrains Mono', monospace; color: var(--text); font-size: 13px; }+.mfg { color: var(--text-dim); font-size: 13px; }+.card-desc { color: var(--text); font-size: 13px; margin-bottom: 8px; }+.card-category { color: var(--text-dim); font-size: 12px; margin-bottom: 10px; }++.card-row2 {+  display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; font-size: 13px;+}+.card-field { display: flex; flex-direction: column; gap: 2px; }+.card-field .label { color: var(--text-dim); font-size: 11px; text-transform: lowercase; }+.card-field .value { color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 13px; }+.card-field .value.low { color: var(--red); }+.card-field .value.medium { color: var(--yellow); }+.card-field .value.high { color: var(--green); }++.card-flags {+  display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; font-size: 11px;+}+.flag {+  padding: 2px 8px; border-radius: 10px;+  background: var(--surface2); border: 1px solid var(--border); color: var(--text-dim);+  font-family: 'JetBrains Mono', monospace;+}+.flag.rohs { color: var(--green); border-color: rgba(63,185,80,0.3); }+.flag.lifecycle-active { color: var(--green); border-color: rgba(63,185,80,0.3); }+.flag.lifecycle-nrnd { color: var(--yellow); border-color: rgba(210,153,34,0.3); }+.flag.lifecycle-obsolete { color: var(--red); border-color: rgba(248,81,73,0.3); }+.flag.lifecycle-new { color: var(--blue); border-color: rgba(88,166,255,0.3); }++.card-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }+.card-actions a {+  color: var(--accent); text-decoration: none; font-size: 12px;+  padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);+  background: var(--surface2);+  display: inline-flex; align-items: center; gap: 6px;+}+.card-actions a:hover { border-color: var(--accent); }+.card-actions a svg { width: 14px; height: 14px; color: var(--text); fill: currentColor; }++.price-tiers {+  font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim);+  margin-top: 6px;+}+.price-tiers b { color: var(--text); font-weight: 600; }++.empty {+  padding: 60px 20px; text-align: center; color: var(--text-dim);+  border: 1px dashed var(--border); border-radius: 12px;+  background: var(--surface);+}+.empty h3 { color: var(--text); margin-bottom: 8px; font-size: 16px; }+.empty p { font-size: 13px; line-height: 1.6; }++.loading {+  display: inline-block; width: 14px; height: 14px;+  border: 2px solid var(--border); border-top-color: var(--accent);+  border-radius: 50%; animation: spin 0.8s linear infinite;+  vertical-align: middle; margin-right: 6px;+}+@keyframes spin { to { transform: rotate(360deg); } }++/* --- Tooltips (per app-creator skill: 500ms hover, anchored to trigger edges) --- */+[data-tooltip] { position: relative; }+[data-tooltip]::after {+  content: attr(data-tooltip);+  position: absolute;+  bottom: calc(100% + 8px);+  left: 0; right: auto;+  width: max-content;+  max-width: min(340px, calc(100vw - 48px));+  background: rgba(22,27,34,0.96);+  backdrop-filter: blur(16px);+  border: 1px solid rgba(0,184,176,0.25);+  border-radius: 10px;+  padding: 10px 14px;+  font-family: 'Satoshi', sans-serif;+  font-size: 12px;+  line-height: 1.55;+  color: var(--text);+  text-align: left;+  white-space: pre-wrap;+  pointer-events: none;+  opacity: 0;+  transform: translateY(4px);+  box-shadow: 0 12px 40px rgba(0,0,0,0.55);+  transition: opacity 0.2s ease 0s, transform 0.2s ease 0s;+  z-index: 99999;+}+[data-tooltip][data-tooltip-align="right"]::after { left: auto; right: 0; }+[data-tooltip]:hover::after {+  opacity: 1;+  transform: translateY(0);+  transition: opacity 0.2s ease 0.5s, transform 0.2s ease 0.5s;+}+</style>+</head>+<body>++<div class="hdr">+  <span class="logo" aria-hidden="true">+    <svg width="28" height="28" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">+      <g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">+        <rect x="10" y="14" width="28" height="28" rx="3"/>+        <path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36 M38 20 L42 20 M38 28 L42 28 M38 36 L42 36 M16 10 L16 14 M24 10 L24 14 M32 10 L32 14 M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>+      </g>+      <circle cx="42" cy="40" r="10" fill="#0d1117" stroke="currentColor" stroke-width="4"/>+      <line x1="50" y1="48" x2="58" y2="56" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>+      <!-- no hard-coded teal fill here — brand rule: in-app icons monochrome white -->+    </svg>+  </span>+  <div>+    <h1>Mouser Search</h1>+    <div class="subtitle">Live component search, pricing, and stock — via the shared backend service.</div>+  </div>+  <div id="backend-status" class="backend-status" data-tooltip-align="right" data-tooltip="Health of the adom-mouser backend HTTP proxy the UI talks to. Green means the backend is reachable and its MOUSER_API_KEY is working. Click to re-check.">+    <span class="dot"></span>+    <span class="txt">checking…</span>+  </div>+</div>++<form id="search-form" class="searchbar" autocomplete="off">+  <input id="q" name="keyword" type="text" placeholder="Search: STM32F103RBT6, ESP32, LDO 3.3V 500mA SOT-23 …" autofocus>+  <select id="limit" class="limit-select" data-tooltip="How many results to request from the Mouser API. 10 is a good default for browsing; set higher when you need to compare many alternatives.">+    <option value="5">5</option>+    <option value="10" selected>10</option>+    <option value="20">20</option>+    <option value="50">50</option>+  </select>+  <button type="submit" class="btn primary" id="go" data-tooltip-align="right" data-tooltip="Run the search against Mouser. Results will replace whatever is shown below. Keyboard: Enter from the search box does the same thing.">Search</button>+</form>++<div class="filters">+  <label data-tooltip="When enabled, only parts that currently have stock at Mouser are returned. Useful when you need something fast; otherwise leave off so you can see pricing for factory-lead-time parts too.">+    <input type="checkbox" id="instock"> In stock only+  </label>+  <span class="hint">Tip: paste a full MPN (e.g. <code>STM32F103RBT6</code>) for exact matches, or a parametric query (e.g. <code>LDO 3.3V 500mA SOT-23</code>) for discovery.</span>+</div>++<div id="status" class="statusline" aria-live="polite"></div>++<div id="results" class="results"></div>++<!-- Lightbox for product-photo zoom -->+<div id="lightbox" class="lightbox" role="dialog" aria-hidden="true">+  <div class="lb-close" id="lb-close" data-tooltip="Close (or press Escape)">✕</div>+  <img id="lb-img" alt="">+  <div class="lb-caption" id="lb-caption"></div>+</div>++<script>+// --- Console forwarding so the AI can read UI-side logs via GET /console+(function() {+  const orig = { log: console.log, warn: console.warn, error: console.error };+  function forward(level, args) {+    orig[level].apply(console, args);+    try {+      fetch('console', {+        method: 'POST',+        headers: {'Content-Type':'application/json'},+        body: JSON.stringify({+          level,+          ts: Date.now(),+          message: args.map(a => {+            try { return typeof a === 'string' ? a : JSON.stringify(a); }+            catch { return String(a); }+          }).join(' ')+        })+      }).catch(() => {});+    } catch {}+  }+  console.log = function(...a) { forward('log', a); };+  console.warn = function(...a) { forward('warn', a); };+  console.error = function(...a) { forward('error', a); };+  window.addEventListener('error', e => forward('error', [e.message, (e.filename||'') + ':' + (e.lineno||'')]));+  window.addEventListener('unhandledrejection', e => forward('error', ['unhandledrejection', String(e.reason)]));+})();++// --- Eval channel (dev only). The server's /state tells us whether+// --dev is on; if not, don't poll (silences a 404/sec spam in prod).+(async function() {+  let devEnabled = false;+  try {+    const r = await fetch('state');+    const s = await r.json();+    devEnabled = !!s.dev;+  } catch {}+  if (!devEnabled) return;+  async function pollOnce() {+    try {+      const r = await fetch('eval/pending');+      if (!r.ok) return;+      const pending = await r.json();+      if (!pending || !pending.code) return;+      let result;+      try {+        const fn = new (async function(){}).constructor(pending.code);+        result = await fn();+      } catch (e) {+        result = { error: String(e && e.message || e), stack: String(e && e.stack || '') };+      }+      console.log('eval-result', pending.id, result);+      await fetch('eval/' + pending.id + '/result', {+        method: 'POST',+        headers: {'Content-Type':'application/json'},+        body: JSON.stringify({ result: result === undefined ? null : result })+      });+    } catch {}+  }+  setInterval(pollOnce, 500);+})();++// --- Backend health poll+async function updateBackendStatus() {+  const el = document.getElementById('backend-status');+  const txt = el.querySelector('.txt');+  try {+    const r = await fetch('backend-health');+    const v = await r.json();+    if (r.ok && v.ok) {+      el.classList.remove('err');+      el.classList.add('ok');+      txt.textContent = 'backend ok';+      if (v.api_reachable === false) {+        el.classList.remove('ok');+        el.classList.add('err');+        txt.textContent = 'API unreachable';+      }+    } else {+      el.classList.remove('ok');+      el.classList.add('err');+      txt.textContent = v.error ? 'no API key' : 'backend down';+    }+  } catch {+    el.classList.remove('ok');+    el.classList.add('err');+    txt.textContent = 'backend down';+  }+}+document.getElementById('backend-status').addEventListener('click', updateBackendStatus);+updateBackendStatus();+setInterval(updateBackendStatus, 30000);++// --- Format helpers+function fmtStock(stock, text) {+  if (text && text !== 'Unknown') return text;+  if (stock >= 1_000_000) return (stock/1_000_000).toFixed(1) + 'M';+  if (stock >= 1000) return (stock/1000).toFixed(1) + 'k';+  return String(stock);+}+function lifecycleClass(lc) {+  const l = (lc||'').toLowerCase();+  if (l.includes('obsolete') || l.includes('eol')) return 'lifecycle-obsolete';+  if (l.includes('nrnd') || l.includes('not recommended')) return 'lifecycle-nrnd';+  if (l.includes('new')) return 'lifecycle-new';+  if (l.includes('active')) return 'lifecycle-active';+  return '';+}+function escape(s) {+  return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));+}++function renderCard(c) {+  const priceTiers = (c.price_tiers||[]).slice(0, 6).map(t => `<b>${t.qty}+</b>: ${escape(t.price)}`).join('  &nbsp;·&nbsp;  ');+  const attrs = (c.attributes||[]).slice(0, 6).map(a =>+    `<span class="flag" data-tooltip="${escape(a.name)}: ${escape(a.value)}">${escape(a.name)}: ${escape(a.value)}</span>`+  ).join('');+  const lc = escape(c.lifecycle || '');+  const rohs = escape(c.rohs || '');+  const stockTip =+    `${c.stock.toLocaleString()} units reported in stock at Mouser. ` ++    `Stock counts refresh every 10 minutes on the backend cache.`;+  const priceTip =+    `Unit price at quantity 1. The "Breaks" line below shows quantity pricing tiers — ordering more gets you a lower per-unit price.`;+  const leadTip =+    `How long Mouser says it will take to get more of this part from the manufacturer if stock runs out.`;+  return `+  <div class="card">+    <div class="card-top">+      <div class="card-img">+        ${c.image_url ? `<img src="${escape(c.image_url)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'">` : ''}+        <div class="placeholder" ${c.image_url ? 'style="display:none"' : ''}>no image</div>+      </div>+      <div class="card-body">+        <div class="card-row1">+          <span class="mouser-pn">${escape(c.mouser_pn)}</span>+          <span class="mpn">${escape(c.mpn)}</span>+          <span class="mfg">${escape(c.manufacturer)}</span>+        </div>+        ${c.description ? `<div class="card-desc">${escape(c.description)}</div>` : ''}+        ${c.category ? `<div class="card-category">${escape(c.category)}</div>` : ''}+        <div class="card-row2">+          <div class="card-field" data-tooltip="${stockTip}">+            <span class="label">stock</span>+            <span class="value ${c.popularity||''}">${fmtStock(c.stock, c.stock_text)}</span>+          </div>+          <div class="card-field" data-tooltip="${priceTip}">+            <span class="label">unit price</span>+            <span class="value">${escape(c.unit_price || '—')}</span>+          </div>+          <div class="card-field" data-tooltip="${leadTip}">+            <span class="label">lead time</span>+            <span class="value">${escape(c.lead_time || '—')}</span>+          </div>+        </div>+        ${priceTiers ? `<div class="price-tiers">Breaks: ${priceTiers}</div>` : ''}+        <div class="card-flags">+          ${rohs ? `<span class="flag rohs" data-tooltip="RoHS = Restriction of Hazardous Substances. 'Compliant' means the part doesn't contain banned amounts of lead, cadmium, mercury, etc.">${rohs}</span>` : ''}+          ${lc ? `<span class="flag ${lifecycleClass(lc)}" data-tooltip="Lifecycle status from the manufacturer. Active = currently produced. NRND = 'not recommended for new design'. Obsolete = don't design in. New = recently released.">${lc}</span>` : ''}+          ${`<span class="flag" data-tooltip="Smallest order quantity and the step size. Min 1 / Mult 1 means you can order any number; Mult 100 means you must order in multiples of 100 (typical for reel packaging).">MinQty ${c.min_order||1} · Mult ${c.mult||1}</span>`}+          ${attrs}+        </div>+        <div class="card-actions">+          ${c.product_url ? `<a href="${escape(c.product_url)}" target="_blank" rel="noopener" data-tooltip="Open the Mouser product page in your default browser. Use this to view the full spec sheet, order, or check real-time stock at all Mouser warehouses.">Product page ↗</a>` : ''}+          ${c.datasheet_url ? `<a href="${escape(c.datasheet_url)}" target="_blank" rel="noopener" data-tooltip="Open the manufacturer's PDF datasheet.">Datasheet ↗</a>` : ''}+          <a href="#" data-copy="${escape(c.mouser_pn)}" data-tooltip="Copy the Mouser part number to your clipboard. Use this for BOM ordering.">Copy Mouser PN</a>+          <a href="#" data-copy="${escape(c.mpn)}" data-tooltip="Copy the manufacturer part number (MPN) to your clipboard. Use this in schematic symbols and footprint names.">Copy MPN</a>+        </div>+      </div>+    </div>+  </div>`;+}++function setStatus(msg, level='') {+  const el = document.getElementById('status');+  el.textContent = msg;+  el.className = 'statusline' + (level ? ' '+level : '');+}++async function doSearch(keyword) {+  if (!keyword || !keyword.trim()) return;+  const limit = parseInt(document.getElementById('limit').value, 10);+  const inStock = document.getElementById('instock').checked;+  const go = document.getElementById('go');+  go.disabled = true; go.textContent = 'Searching…';+  setStatus('<span class="loading"></span> searching Mouser for "' + keyword + '"…');+  document.getElementById('status').innerHTML = '<span class="loading"></span> searching Mouser for "' + escape(keyword) + '"…';+  try {+    const r = await fetch('search', {+      method: 'POST',+      headers: {'Content-Type':'application/json'},+      body: JSON.stringify({ keyword, limit, inStockOnly: inStock })+    });+    const v = await r.json();+    if (!r.ok) {+      setStatus('error: ' + (v.error || r.statusText), 'error');+      document.getElementById('results').innerHTML = '';+      return;+    }+    const components = v.components || [];+    if (components.length === 0) {+      document.getElementById('results').innerHTML = '<div class="empty"><h3>No parts found</h3><p>Try a different keyword, a full MPN, or turn off the in-stock-only filter.</p></div>';+      setStatus('0 results', 'ok');+      return;+    }+    document.getElementById('results').innerHTML = components.map(renderCard).join('');+    setStatus(`found ${v.count || components.length} results; showing ${components.length}`, 'ok');+  } catch (e) {+    console.error('search error', e);+    setStatus('error: ' + (e && e.message || e), 'error');+  } finally {+    go.disabled = false; go.textContent = 'Search';+  }+}++document.getElementById('search-form').addEventListener('submit', e => {+  e.preventDefault();+  doSearch(document.getElementById('q').value);+});++// Copy-to-clipboard for Mouser PN / MPN buttons+document.addEventListener('click', async e => {+  const a = e.target.closest('a[data-copy]');+  if (!a) return;+  e.preventDefault();+  const val = a.getAttribute('data-copy');+  try {+    await navigator.clipboard.writeText(val);+    const orig = a.textContent;+    a.textContent = '✓ Copied';+    setTimeout(() => { a.textContent = orig; }, 1200);+  } catch (err) {+    console.warn('clipboard write failed', err);+  }+});++// Lightbox: click a card thumbnail to see the full product photo+(function() {+  const lb = document.getElementById('lightbox');+  const lbImg = document.getElementById('lb-img');+  const lbCap = document.getElementById('lb-caption');+  const lbClose = document.getElementById('lb-close');+  function open(src, caption) {+    lbImg.src = src;+    lbCap.textContent = caption || '';+    lb.classList.add('open');+    lb.setAttribute('aria-hidden', 'false');+  }+  function close() {+    lb.classList.remove('open');+    lb.setAttribute('aria-hidden', 'true');+    // Release the img src after the fade so we don't hold the cached resource.+    setTimeout(() => { if (!lb.classList.contains('open')) lbImg.src = ''; }, 200);+  }+  // Click on any .card-img opens the lightbox with that card's MPN as caption.+  document.addEventListener('click', e => {+    const box = e.target.closest('.card-img');+    if (box) {+      const img = box.querySelector('img');+      if (!img || !img.src || img.style.display === 'none') return;+      const card = box.closest('.card');+      const mpn = card?.querySelector('.mpn')?.textContent || '';+      const mfg = card?.querySelector('.mfg')?.textContent || '';+      open(img.src, [mpn, mfg].filter(Boolean).join(' — '));+      return;+    }+    // Clicking the backdrop or close button closes+    if (e.target === lb || e.target === lbClose) close();+  });+  document.addEventListener('keydown', e => {+    if (e.key === 'Escape' && lb.classList.contains('open')) close();+  });+})();++// Restore last query if there is one (so a tab reopen shows prior results)+(async function() {+  try {+    const r = await fetch('state');+    const v = await r.json();+    if (v.last_query) {+      document.getElementById('q').value = v.last_query;+    }+    if (v.last_results && Array.isArray(v.last_results.components) && v.last_results.components.length) {+      document.getElementById('results').innerHTML = v.last_results.components.map(renderCard).join('');+      setStatus(`cached: ${v.last_results.count || v.last_results.components.length} results for "${escape(v.last_query || '')}"`, 'ok');+    }+  } catch {}+})();+</script>+</body>+</html>
src/cli.rsadded+131
@@ -0,0 +1,131 @@+//! One-shot CLI verbs: `adom-mouser search|part|health`.+//!+//! Resolution order for the data source:+//!   1. If `--api` / `MOUSER_API` is set → talk to that backend via POST /+//!   2. If http://127.0.0.1:8775 is reachable → use it as the backend+//!   3. Else fall back to a direct Mouser API call (requires MOUSER_API_KEY)++use crate::mouser::{self, SearchResult};+use crate::{ok, warn, HealthArgs, PartArgs, SearchArgs};+use serde_json::{json, Value};+use std::time::Duration;++fn resolve_backend(explicit: Option<String>) -> Option<String> {+    if let Some(api) = explicit.filter(|s| !s.is_empty()) {+        return Some(api);+    }+    let local = "http://127.0.0.1:8775".to_string();+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(2))+        .build();+    match agent.get(&format!("{}/health", local)).call() {+        Ok(_) => Some(local),+        Err(_) => None,+    }+}++fn backend_post(api: &str, body: Value) -> Result<Value, String> {+    let url = if api.ends_with('/') { api.to_string() } else { format!("{}/", api) };+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(30))+        .build();+    let res = match agent.post(&url).set("Content-Type", "application/json").send_json(body) {+        Ok(r) => r,+        Err(ureq::Error::Status(_code, r)) => r,+        Err(e) => return Err(format!("backend POST failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("backend JSON parse failed: {}", e))+}++fn emit(result: SearchResult) {+    // Always print clean JSON on stdout (so the AI can pipe `| jq`), and+    // emit an OK: line on a separate line so the AI knows success. The+    // OK: line goes to stdout before the JSON so it doesn't corrupt the+    // JSON if somebody does `| jq`. Actually — emit OK on stderr so the+    // stdout stream is pure JSON.+    if crate::stderr_is_tty() {+        eprintln!(+            "{}{}OK:{} {} components (query: {})",+            crate::GREEN,+            crate::BOLD,+            crate::RESET,+            result.components.len(),+            result.query+        );+    } else {+        eprintln!("OK: {} components (query: {})", result.components.len(), result.query);+    }+    let v = serde_json::to_value(&result).unwrap_or_else(|_| json!({}));+    println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());+}++pub fn run_search(args: SearchArgs) -> Result<(), String> {+    let backend = resolve_backend(args.api);+    let result = if let Some(api) = backend.as_ref() {+        let v = backend_post(+            api,+            json!({+                "action": "search",+                "keyword": args.keyword,+                "limit": args.limit,+                "inStockOnly": args.in_stock_only,+            }),+        )?;+        if let Some(e) = v.get("error").and_then(|v| v.as_str()) {+            return Err(e.into());+        }+        serde_json::from_value(v).map_err(|e| format!("response parse: {}", e))?+    } else {+        warn("No backend at http://127.0.0.1:8775 and no --api / MOUSER_API set; calling Mouser API directly.");+        let key = std::env::var("MOUSER_API_KEY").unwrap_or_default();+        mouser::search(&key, &args.keyword, args.limit, args.in_stock_only)?+    };+    emit(result);+    Ok(())+}++pub fn run_part(args: PartArgs) -> Result<(), String> {+    let backend = resolve_backend(args.api);+    let result = if let Some(api) = backend.as_ref() {+        let v = backend_post(api, json!({"action": "part", "partNumber": args.part_number}))?;+        if let Some(e) = v.get("error").and_then(|v| v.as_str()) {+            return Err(e.into());+        }+        serde_json::from_value(v).map_err(|e| format!("response parse: {}", e))?+    } else {+        warn("No backend at http://127.0.0.1:8775 and no --api / MOUSER_API set; calling Mouser API directly.");+        let key = std::env::var("MOUSER_API_KEY").unwrap_or_default();+        mouser::part(&key, &args.part_number)?+    };+    emit(result);+    Ok(())+}++pub fn run_health(args: HealthArgs) -> Result<(), String> {+    let backend = resolve_backend(args.api);+    if let Some(api) = backend.as_ref() {+        let url = if api.ends_with('/') {+            format!("{}health", api)+        } else {+            format!("{}/health", api)+        };+        let agent = ureq::AgentBuilder::new()+            .timeout(Duration::from_secs(10))+            .build();+        match agent.get(&url).call() {+            Ok(res) => {+                let v: Value = res.into_json().map_err(|e| format!("JSON: {}", e))?;+                ok(format!("backend {} reachable", api));+                println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());+                Ok(())+            }+            Err(e) => Err(format!("backend at {} unreachable: {}", api, e)),+        }+    } else {+        warn("No backend at http://127.0.0.1:8775 and no --api / MOUSER_API set; pinging Mouser directly.");+        let key = std::env::var("MOUSER_API_KEY").unwrap_or_default();+        let n = mouser::ping(&key)?;+        ok(format!("Mouser API reachable (test query returned {} results)", n));+        Ok(())+    }+}
src/install.rsadded+61
@@ -0,0 +1,61 @@+//! `adom-mouser install` — deploy SKILL.md + BUILD-SKILL.md + bash completions.++use crate::{ok, warn};+use clap::CommandFactory;+use clap_complete::Shell;+use std::fs;+use std::io::Write;+use std::path::PathBuf;++const SKILL_MD: &str = include_str!("../SKILL.md");+const BUILD_SKILL_MD: &str = include_str!("../BUILD-SKILL.md");++pub fn install() -> Result<(), String> {+    let home = std::env::var("HOME").map_err(|_| "HOME env var not set".to_string())?;+    let skills_root = PathBuf::from(&home).join(".claude").join("skills");++    // 1a. User-facing skill+    let skill_dir = skills_root.join("adom-mouser");+    fs::create_dir_all(&skill_dir).map_err(|e| format!("create {}: {}", skill_dir.display(), e))?;+    let skill_path = skill_dir.join("SKILL.md");+    fs::write(&skill_path, SKILL_MD).map_err(|e| format!("write {}: {}", skill_path.display(), e))?;+    ok(format!("installed skill → {}", skill_path.display()));++    // 1b. Build skill (per-app release workflow)+    let build_skill_dir = skills_root.join("adom-mouser-build");+    fs::create_dir_all(&build_skill_dir)+        .map_err(|e| format!("create {}: {}", build_skill_dir.display(), e))?;+    let build_skill_path = build_skill_dir.join("SKILL.md");+    fs::write(&build_skill_path, BUILD_SKILL_MD)+        .map_err(|e| format!("write {}: {}", build_skill_path.display(), e))?;+    ok(format!("installed build skill → {}", build_skill_path.display()));++    // 2. Bash completions+    let comp_dir = PathBuf::from(&home)+        .join(".local")+        .join("share")+        .join("bash-completion")+        .join("completions");+    fs::create_dir_all(&comp_dir).map_err(|e| format!("create {}: {}", comp_dir.display(), e))?;+    let comp_path = comp_dir.join("adom-mouser");+    let mut cmd = crate::Cli::command();+    let mut buf: Vec<u8> = Vec::new();+    clap_complete::generate(Shell::Bash, &mut cmd, "adom-mouser", &mut buf);+    fs::write(&comp_path, &buf).map_err(|e| format!("write {}: {}", comp_path.display(), e))?;+    ok(format!("installed completions → {}", comp_path.display()));++    // 3. Append eval to .bashrc once+    let bashrc = PathBuf::from(&home).join(".bashrc");+    let current = fs::read_to_string(&bashrc).unwrap_or_default();+    if !current.contains("# adom-mouser completions") {+        match fs::OpenOptions::new().append(true).open(&bashrc) {+            Ok(mut f) => {+                let _ = f.write_all(b"\n# adom-mouser completions\neval \"$(adom-mouser completions bash 2>/dev/null)\"\n");+                ok("appended completion source line to ~/.bashrc (open a new shell for it to take effect)");+            }+            Err(e) => warn(format!(".bashrc: {}", e)),+        }+    }++    Ok(())+}
src/main.rsadded+206
@@ -0,0 +1,206 @@+//! adom-mouser: Mouser Electronics component search.+//!+//! One binary, three modes:+//!   - `adom-mouser serve`   — backend HTTP proxy (port 8775). Replaces+//!                             the old gallia/services/mouser/server.js.+//!   - `adom-mouser search|part|health` — one-shot CLI verbs.+//!   - `adom-mouser app`     — opens a Hydrogen webview tab with a+//!                             searchable UI. Talks HTTP to a local or+//!                             remote `serve` instance.++mod app;+mod cli;+mod install;+mod mouser;+mod quota;+mod service;++use clap::{CommandFactory, Parser, Subcommand};+use std::io::IsTerminal;+use std::process::ExitCode;+use std::sync::OnceLock;++// ANSI colour constants. Every user-facing emitter (ok/err/hint/warn/note)+// is gated on isatty so piped output stays ANSI-free. See adom-cli-design.+pub const GREEN: &str = "\x1b[32m";+pub const RED: &str = "\x1b[31m";+pub const YELLOW: &str = "\x1b[33m";+pub const CYAN: &str = "\x1b[36m";+pub const BOLD: &str = "\x1b[1m";+pub const DIM: &str = "\x1b[2m";+pub const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();+pub fn stdout_is_tty() -> bool { *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal()) }+pub fn stderr_is_tty() -> bool { *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal()) }++pub fn strip_ansi(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    let mut chars = s.chars().peekable();+    while let Some(c) = chars.next() {+        if c == '\x1b' && chars.peek() == Some(&'[') {+            chars.next();+            for c2 in chars.by_ref() {+                if c2.is_ascii_alphabetic() { break; }+            }+            continue;+        }+        out.push(c);+    }+    out+}++pub fn ok(msg: impl std::fmt::Display) {+    if stdout_is_tty() {+        println!("{GREEN}{BOLD}OK:{RESET} {msg}");+    } else {+        println!("OK: {}", strip_ansi(&msg.to_string()));+    }+}+pub fn err(msg: impl std::fmt::Display) {+    if stderr_is_tty() {+        eprintln!("{RED}{BOLD}ERROR:{RESET} {msg}");+    } else {+        eprintln!("ERROR: {}", strip_ansi(&msg.to_string()));+    }+}+pub fn hint(msg: impl std::fmt::Display) {+    if stderr_is_tty() {+        eprintln!("{DIM}Hint:{RESET} {msg}");+    } else {+        eprintln!("Hint: {}", strip_ansi(&msg.to_string()));+    }+}+pub fn warn(msg: impl std::fmt::Display) {+    if stderr_is_tty() {+        eprintln!("{YELLOW}{BOLD}WARN:{RESET} {msg}");+    } else {+        eprintln!("WARN: {}", strip_ansi(&msg.to_string()));+    }+}++#[macro_export]+macro_rules! note {+    ($($arg:tt)*) => {{+        let __s = format!($($arg)*);+        if $crate::stdout_is_tty() {+            println!("{}", __s);+        } else {+            println!("{}", $crate::strip_ansi(&__s));+        }+    }};+}++#[derive(Parser)]+#[command(name = "adom-mouser", version, about = "Mouser Electronics search: CLI, backend HTTP service, and webview app")]+pub struct Cli {+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// Run the backend HTTP proxy on port 8775 (replacement for the old+    /// gallia/services/mouser/server.js). Keeps the API key server-side,+    /// caches responses in-memory, exposes a JSON API used by CLI + app.+    Serve(ServeArgs),++    /// One-shot search: prints JSON results for a keyword or MPN.+    Search(SearchArgs),++    /// One-shot part lookup: prints JSON for a specific part.+    Part(PartArgs),++    /// Check backend/service health (reachability + API-key + test call).+    Health(HealthArgs),++    /// Open the Mouser search webview as a Hydrogen tab. Runs a local+    /// HTTP server, talks to a backend for real API calls.+    App(AppArgs),++    /// Install SKILL.md + bash completions to ~/.claude and ~/.local.+    Install,++    /// Print bash completion script (used by `install`).+    Completions { shell: clap_complete::Shell },+}++#[derive(clap::Args)]+pub struct ServeArgs {+    /// Port to listen on.+    #[arg(long, env = "MOUSER_PORT", default_value_t = 8775)]+    pub port: u16,+}++#[derive(clap::Args)]+pub struct SearchArgs {+    /// Keyword, manufacturer part number, or Mouser part number.+    pub keyword: String,+    /// Max results (1-50).+    #[arg(long, default_value_t = 10)]+    pub limit: u32,+    /// Only parts currently in stock.+    #[arg(long)]+    pub in_stock_only: bool,+    /// Backend URL (defaults to $MOUSER_API or http://127.0.0.1:8775).+    #[arg(long, env = "MOUSER_API")]+    pub api: Option<String>,+}++#[derive(clap::Args)]+pub struct PartArgs {+    /// Mouser PN (511-STM32F103RBT6) or MPN (STM32F103RBT6).+    pub part_number: String,+    #[arg(long, env = "MOUSER_API")]+    pub api: Option<String>,+}++#[derive(clap::Args)]+pub struct HealthArgs {+    #[arg(long, env = "MOUSER_API")]+    pub api: Option<String>,+}++#[derive(clap::Args)]+pub struct AppArgs {+    /// Port the local app server listens on.+    #[arg(long, default_value_t = 8789)]+    pub port: u16,+    /// Backend URL for Mouser API calls (defaults to $MOUSER_API or http://127.0.0.1:8775).+    #[arg(long, env = "MOUSER_API")]+    pub api: Option<String>,+    /// Hydrogen tab name.+    #[arg(long, default_value = "Mouser Search")]+    pub tab_name: String,+    /// Skip opening the Hydrogen tab (useful for local browser testing).+    #[arg(long)]+    pub no_tab: bool,+    /// Enable `/eval` hot-patch channel for development.+    #[arg(long, env = "ADOM_MOUSER_DEV")]+    pub dev: bool,+}++fn main() -> ExitCode {+    let args = Cli::parse();+    let result = match args.cmd {+        Cmd::Serve(a) => service::run_serve(a),+        Cmd::Search(a) => cli::run_search(a),+        Cmd::Part(a) => cli::run_part(a),+        Cmd::Health(a) => cli::run_health(a),+        Cmd::App(a) => app::run(a),+        Cmd::Install => install::install(),+        Cmd::Completions { shell } => {+            let mut cmd = Cli::command();+            clap_complete::generate(shell, &mut cmd, "adom-mouser", &mut std::io::stdout());+            Ok(())+        }+    };+    match result {+        Ok(()) => ExitCode::SUCCESS,+        Err(e) => {+            err(&e);+            ExitCode::FAILURE+        }+    }+}
src/mouser.rsadded+332
@@ -0,0 +1,332 @@+//! Mouser Electronics API v2 client + response normalization.+//!+//! Two ways to resolve a query:+//!   1. Direct — requires MOUSER_API_KEY. Used by `serve` and by `search`+//!      / `part` / `health` when no backend URL is configured.+//!   2. Via a backend — the CLI hits `POST <api>/` and lets the backend+//!      hold the key + cache. Used by `search` / `part` / `health` when+//!      MOUSER_API is set.++use serde::{Deserialize, Deserializer, Serialize};+use serde_json::{json, Value};+use std::time::Duration;++/// Accept u32 as either a JSON number or a numeric string. The legacy+/// Node backend returns `min_order` / `mult` as strings ("1"); a native+/// `adom-mouser serve` returns them as numbers. Handle both.+fn de_u32_lenient<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {+    let v = Value::deserialize(d)?;+    match v {+        Value::Number(n) => n.as_u64().map(|n| n as u32).ok_or_else(|| serde::de::Error::custom("not u32")),+        Value::String(s) => s.parse().map_err(serde::de::Error::custom),+        Value::Null => Ok(1),+        other => Err(serde::de::Error::custom(format!("expected u32 or numeric string, got {:?}", other))),+    }+}++fn de_u64_lenient<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {+    let v = Value::deserialize(d)?;+    match v {+        Value::Number(n) => n.as_u64().ok_or_else(|| serde::de::Error::custom("not u64")),+        Value::String(s) => s.replace(',', "").parse().map_err(serde::de::Error::custom),+        Value::Null => Ok(0),+        other => Err(serde::de::Error::custom(format!("expected u64 or numeric string, got {:?}", other))),+    }+}++const MOUSER_API_BASE: &str = "https://api.mouser.com/api/v2";+const FETCH_TIMEOUT_SECS: u64 = 15;++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct PriceTier {+    #[serde(deserialize_with = "de_u32_lenient")]+    pub qty: u32,+    pub price: String,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Attribute {+    pub name: String,+    pub value: String,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Component {+    pub mouser_pn: String,+    pub mpn: String,+    pub manufacturer: String,+    pub description: String,+    pub category: String,+    #[serde(deserialize_with = "de_u64_lenient")]+    pub stock: u64,+    pub stock_text: String,+    pub popularity: String,+    pub lead_time: String,+    pub unit_price: Option<String>,+    pub price_tiers: Vec<PriceTier>,+    #[serde(deserialize_with = "de_u32_lenient")]+    pub min_order: u32,+    #[serde(deserialize_with = "de_u32_lenient")]+    pub mult: u32,+    pub rohs: String,+    pub lifecycle: String,+    pub datasheet_url: Option<String>,+    pub product_url: Option<String>,+    pub image_url: Option<String>,+    pub attributes: Vec<Attribute>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct SearchResult {+    pub components: Vec<Component>,+    pub count: u64,+    pub query: String,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub exact_match: Option<bool>,+}++fn parse_stock(availability: &str) -> u64 {+    // "1,234 In Stock" → 1234.+    let s: String = availability+        .chars()+        .take_while(|c| c.is_ascii_digit() || *c == ',')+        .collect();+    s.replace(',', "").parse().unwrap_or(0)+}++fn normalize_part(p: &Value) -> Component {+    let avail = p.get("Availability").and_then(|v| v.as_str()).unwrap_or("");+    let stock = parse_stock(avail);+    let popularity = if stock < 1000 {+        "low"+    } else if stock < 10_000 {+        "medium"+    } else {+        "high"+    };++    let price_tiers: Vec<PriceTier> = p+        .get("PriceBreaks")+        .and_then(|v| v.as_array())+        .map(|arr| {+            arr.iter()+                .filter_map(|pb| {+                    Some(PriceTier {+                        qty: pb.get("Quantity")?.as_u64()? as u32,+                        price: pb.get("Price")?.as_str()?.to_string(),+                    })+                })+                .collect()+        })+        .unwrap_or_default();++    let unit_price = price_tiers.first().map(|t| t.price.clone());++    let attributes: Vec<Attribute> = p+        .get("ProductAttributes")+        .and_then(|v| v.as_array())+        .map(|arr| {+            arr.iter()+                .filter_map(|a| {+                    let name = a.get("AttributeName")?.as_str()?.to_string();+                    let value = a.get("AttributeValue")?.as_str()?.to_string();+                    if value.is_empty() || value.contains("MouseReel") {+                        return None;+                    }+                    Some(Attribute { name, value })+                })+                .collect()+        })+        .unwrap_or_default();++    let s = |k: &str| p.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();+    let u = |k: &str, d: u32| p.get(k).and_then(|v| v.as_u64()).map(|n| n as u32).unwrap_or(d);+    let os = |k: &str| p.get(k).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());++    Component {+        mouser_pn: s("MouserPartNumber"),+        mpn: s("ManufacturerPartNumber"),+        manufacturer: s("Manufacturer"),+        description: s("Description"),+        category: s("Category"),+        stock,+        stock_text: if avail.is_empty() { "Unknown".into() } else { avail.into() },+        popularity: popularity.into(),+        lead_time: s("LeadTime"),+        unit_price,+        price_tiers,+        min_order: u("Min", 1),+        mult: u("Mult", 1),+        rohs: s("ROHSStatus"),+        lifecycle: s("LifecycleStatus"),+        datasheet_url: os("DataSheetUrl"),+        product_url: os("ProductDetailUrl"),+        image_url: os("ImagePath"),+        attributes,+    }+}++/// Direct call to Mouser's `search/keyword` endpoint with a pre-built+/// request body. Returns normalized result.+pub fn mouser_keyword_search(+    api_key: &str,+    keyword: &str,+    records: u32,+    in_stock_only: bool,+) -> Result<Value, String> {+    if api_key.is_empty() {+        return Err("MOUSER_API_KEY is not set".into());+    }+    let url = format!("{}/search/keyword?apiKey={}", MOUSER_API_BASE, api_key);+    let body = json!({+        "SearchByKeywordRequest": {+            "keyword": keyword.trim(),+            "records": records.clamp(1, 50),+            "startingRecord": 0,+            "searchOptions": if in_stock_only { "InStock" } else { "" },+            "searchWithYourSignUpLanguage": "",+        }+    });+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))+        .build();+    let res = agent+        .post(&url)+        .set("Content-Type", "application/json")+        .set("Accept", "application/json")+        .send_json(body)+        .map_err(|e| match e {+            // ureq's Display impl for Error includes the full URL which+            // contains ?apiKey=<secret>. NEVER call e.to_string() — emit+            // a classified, URL-free message. Inspect the response body so+            // we can distinguish quota-exhaustion from key-revoked.+            ureq::Error::Status(code, r) => {+                let body_json: Value = r.into_json().unwrap_or(json!({}));+                let upstream_msg = body_json+                    .get("Errors")+                    .and_then(|v| v.as_array())+                    .and_then(|arr| arr.first())+                    .and_then(|e0| e0.get("Message").and_then(|m| m.as_str()))+                    .unwrap_or("")+                    .to_string();+                let upstream_code = body_json+                    .get("Errors")+                    .and_then(|v| v.as_array())+                    .and_then(|arr| arr.first())+                    .and_then(|e0| e0.get("Code").and_then(|m| m.as_str()))+                    .unwrap_or("")+                    .to_string();+                if !upstream_msg.is_empty() || !upstream_code.is_empty() {+                    format!("Mouser API HTTP {} — {} {}", code, upstream_code, upstream_msg)+                        .trim().to_string()+                } else {+                    format!("Mouser API HTTP {}", code)+                }+            }+            ureq::Error::Transport(t) => format!("Mouser transport error: {}", t.kind()),+        })?;+    let data: Value = res+        .into_json()+        .map_err(|e| format!("Mouser API JSON parse failed: {}", e))?;+    if let Some(errs) = data.get("Errors").and_then(|v| v.as_array()) {+        if !errs.is_empty() {+            let msg: Vec<String> = errs+                .iter()+                .filter_map(|e| {+                    e.get("Message")+                        .or_else(|| e.get("Code"))+                        .and_then(|v| v.as_str())+                        .map(|s| s.to_string())+                })+                .collect();+            let joined = msg.join("; ");+            // Quota tracker: upstream sometimes returns 2xx with+            // {Errors:[{Code:"TooManyRequests"}]}. Route that through+            // the same quota-hit path as the 403-body case.+            if joined.to_lowercase().contains("maximum calls per day") || joined.to_lowercase().contains("toomanyrequests") {+                if crate::quota::record_quota_hit() {+                    let q = crate::quota::get();+                    crate::quota::post_kel_alert(&format!(+                        "⚠️ *Mouser daily API quota exhausted.*\n\+                         Key used up for {} — resets at midnight CT.\n\+                         Usage today: {} searches, {} results returned.\n\+                         (DigiKey + JLCPCB are unaffected.)",+                        q.date_ct, q.searches, q.results_returned+                    ));+                }+            }+            return Err(format!("Mouser API: {}", joined));+        }+    }+    Ok(data)+}++pub fn search(+    api_key: &str,+    keyword: &str,+    limit: u32,+    in_stock_only: bool,+) -> Result<SearchResult, String> {+    if keyword.trim().is_empty() {+        return Err("keyword is required".into());+    }+    let data = mouser_keyword_search(api_key, keyword, limit, in_stock_only)?;+    let parts = data+        .pointer("/SearchResults/Parts")+        .and_then(|v| v.as_array())+        .cloned()+        .unwrap_or_default();+    let count = data+        .pointer("/SearchResults/NumberOfResult")+        .and_then(|v| v.as_u64())+        .unwrap_or(0);+    Ok(SearchResult {+        components: parts.iter().map(normalize_part).collect(),+        count,+        query: keyword.trim().into(),+        exact_match: None,+    })+}++pub fn part(api_key: &str, part_number: &str) -> Result<SearchResult, String> {+    if part_number.trim().is_empty() {+        return Err("partNumber is required".into());+    }+    let data = mouser_keyword_search(api_key, part_number, 5, false)?;+    let parts = data+        .pointer("/SearchResults/Parts")+        .and_then(|v| v.as_array())+        .cloned()+        .unwrap_or_default();+    let target = part_number.trim().to_lowercase();+    let exact = parts.iter().find(|p| {+        let mouser_pn = p.get("MouserPartNumber").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();+        let mpn = p.get("ManufacturerPartNumber").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();+        mouser_pn == target || mpn == target+    });+    let chosen = exact.or_else(|| parts.first());+    match chosen {+        Some(p) => Ok(SearchResult {+            components: vec![normalize_part(p)],+            count: 1,+            query: part_number.trim().into(),+            exact_match: Some(exact.is_some()),+        }),+        None => Ok(SearchResult {+            components: vec![],+            count: 0,+            query: part_number.trim().into(),+            exact_match: Some(false),+        }),+    }+}++/// A direct Mouser live-ping used by `health` when a backend is not+/// reachable. Returns `Ok(count)` on success.+pub fn ping(api_key: &str) -> Result<u64, String> {+    let data = mouser_keyword_search(api_key, "resistor", 1, false)?;+    Ok(data+        .pointer("/SearchResults/NumberOfResult")+        .and_then(|v| v.as_u64())+        .unwrap_or(0))+}
src/quota.rsadded+154
@@ -0,0 +1,154 @@+//! Persistent quota tracker — counts searches + results returned per+//! calendar day (Central Time, where Mouser's daily quota resets). Fires+//! a one-shot Google Chat (Kel) webhook alert the moment we see the+//! first quota-exhausted response for the day.+//!+//! Stored at `/tmp/adom-mouser-quota.json` so it survives restarts but+//! not reboots (the counter resets at midnight CT anyway).++use serde::{Deserialize, Serialize};+use std::fs;+use std::path::Path;+use std::sync::Mutex;+use std::time::Duration;++const STATE_PATH: &str = "/tmp/adom-mouser-quota.json";++#[derive(Debug, Clone, Serialize, Deserialize, Default)]+pub struct QuotaState {+    /// "YYYY-MM-DD" in America/Chicago (Central). Resets at midnight CT.+    pub date_ct: String,+    pub searches: u64,+    pub results_returned: u64,+    /// Unix epoch seconds (UTC) of first quota-exhausted response today.+    pub first_quota_hit_ts: Option<i64>,+    /// Whether the Kel alert webhook has been fired for today's quota hit.+    pub alerted: bool,+}++static STATE: Mutex<Option<QuotaState>> = Mutex::new(None);++/// Current date in America/Chicago as "YYYY-MM-DD". Shells out to+/// `date` with `TZ=America/Chicago` so we inherit the system tzdb and+/// get DST handled for free. Falls back to UTC if `date` is unavailable.+fn today_ct() -> String {+    let out = std::process::Command::new("date")+        .env("TZ", "America/Chicago")+        .arg("+%Y-%m-%d")+        .output()+        .ok()+        .and_then(|o| if o.status.success() { String::from_utf8(o.stdout).ok() } else { None })+        .map(|s| s.trim().to_string())+        .filter(|s| s.len() == 10);+    out.unwrap_or_else(|| {+        // Fallback: UTC date (may be wrong by 1 day near midnight CT).+        let secs = std::time::SystemTime::now()+            .duration_since(std::time::UNIX_EPOCH)+            .map(|d| d.as_secs())+            .unwrap_or(0) as i64;+        let days = secs / 86400;+        let z = days + 719468;+        let era = if z >= 0 { z } else { z - 146096 } / 146097;+        let doe = (z - era * 146097) as u64;+        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;+        let y = yoe as i64 + era * 400;+        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+        let mp = (5 * doy + 2) / 153;+        let d = (doy - (153 * mp + 2) / 5 + 1) as u32;+        let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;+        let y = if m <= 2 { y + 1 } else { y };+        format!("{:04}-{:02}-{:02}", y, m, d)+    })+}++fn load() -> QuotaState {+    let raw = fs::read_to_string(STATE_PATH).unwrap_or_default();+    serde_json::from_str::<QuotaState>(&raw).unwrap_or_default()+}++fn save(s: &QuotaState) {+    if let Ok(p) = serde_json::to_string(s) {+        let _ = fs::write(Path::new(STATE_PATH), p);+    }+}++/// Initialize (load-from-disk / reset-if-new-day) and return a clone.+pub fn get() -> QuotaState {+    let mut guard = STATE.lock().unwrap();+    if guard.is_none() {+        *guard = Some(load());+    }+    let s = guard.as_mut().unwrap();+    let today = today_ct();+    if s.date_ct != today {+        // New day — reset.+        *s = QuotaState { date_ct: today, ..Default::default() };+        save(s);+    }+    s.clone()+}++/// Increment on every successful search response.+pub fn record_success(results_returned: u64) {+    let mut guard = STATE.lock().unwrap();+    if guard.is_none() {+        *guard = Some(load());+    }+    let s = guard.as_mut().unwrap();+    let today = today_ct();+    if s.date_ct != today {+        *s = QuotaState { date_ct: today, ..Default::default() };+    }+    s.searches += 1;+    s.results_returned += results_returned;+    save(s);+}++/// Record a quota-exhausted event. Returns `true` exactly once per day+/// (first-hit-of-the-day), so callers can use the return value to+/// trigger a single Kel alert per day.+pub fn record_quota_hit() -> bool {+    let mut guard = STATE.lock().unwrap();+    if guard.is_none() {+        *guard = Some(load());+    }+    let s = guard.as_mut().unwrap();+    let today = today_ct();+    if s.date_ct != today {+        *s = QuotaState { date_ct: today, ..Default::default() };+    }+    s.searches += 1;+    if s.first_quota_hit_ts.is_none() {+        s.first_quota_hit_ts = Some(+            std::time::SystemTime::now()+                .duration_since(std::time::UNIX_EPOCH)+                .map(|d| d.as_secs() as i64)+                .unwrap_or(0),+        );+    }+    let should_alert = !s.alerted;+    if should_alert {+        s.alerted = true;+    }+    save(s);+    should_alert+}++/// Post a one-line Google Chat message via an incoming webhook. No-op+/// if `KEL_ALERT_WEBHOOK` is unset. Uses a short timeout so we never+/// block the search-response path on a slow chat API.+pub fn post_kel_alert(text: &str) {+    let url = match std::env::var("KEL_ALERT_WEBHOOK") {+        Ok(u) if !u.is_empty() => u,+        _ => return,+    };+    let body = serde_json::json!({ "text": text });+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(3))+        .build();+    // Fire-and-forget: never panic on failure.+    let _ = agent+        .post(&url)+        .set("Content-Type", "application/json")+        .send_json(body);+}
src/service.rsadded+371
@@ -0,0 +1,371 @@+//! Backend HTTP proxy for the Mouser API. Replaces+//! gallia/services/mouser/server.js.+//!+//! Endpoints:+//!   GET  /health+//!   GET  /search?keyword=...&limit=10&inStockOnly=false+//!   GET  /part?partNumber=...+//!   POST /                         — action-based: {"action":"search", ...}+//!   GET  /                         — landing page (text/html)+//!+//! Env: MOUSER_API_KEY is REQUIRED.+//! Port: 8775 by default (configurable via --port / MOUSER_PORT).++use crate::mouser::{self, SearchResult};+use crate::{note, ok, warn, ServeArgs, CYAN, RESET};+use serde::Serialize;+use serde_json::{json, Value};+use std::collections::HashMap;+use std::sync::{Arc, Mutex};+use std::time::{Duration, Instant};+use tiny_http::{Header, Method, Response, Server};++const LANDING_HTML: &str = include_str!("service_landing.html");+const CACHE_TTL: Duration = Duration::from_secs(10 * 60);+const CACHE_MAX: usize = 200;++#[derive(Clone)]+struct CacheEntry {+    data: Value,+    inserted: Instant,+}++fn cache_get(cache: &Arc<Mutex<HashMap<String, CacheEntry>>>, key: &str) -> Option<Value> {+    let mut m = cache.lock().ok()?;+    if let Some(e) = m.get(key) {+        if e.inserted.elapsed() < CACHE_TTL {+            return Some(e.data.clone());+        }+        m.remove(key);+    }+    None+}++fn cache_set(cache: &Arc<Mutex<HashMap<String, CacheEntry>>>, key: String, data: Value) {+    if let Ok(mut m) = cache.lock() {+        if m.len() >= CACHE_MAX {+            // Evict the oldest entry.+            if let Some((k, _)) = m+                .iter()+                .min_by_key(|(_, v)| v.inserted)+                .map(|(k, v)| (k.clone(), v.clone()))+            {+                m.remove(&k);+            }+        }+        m.insert(key, CacheEntry { data, inserted: Instant::now() });+    }+}++#[derive(Serialize)]+struct HealthResponse<'a> {+    ok: bool,+    version: &'static str,+    cache_size: usize,+    cache_max: usize,+    cache_ttl_ms: u64,+    #[serde(skip_serializing_if = "Option::is_none")]+    error: Option<&'a str>,+    #[serde(skip_serializing_if = "Option::is_none")]+    api_reachable: Option<bool>,+    #[serde(skip_serializing_if = "Option::is_none")]+    api_error: Option<String>,+    #[serde(skip_serializing_if = "Option::is_none")]+    test_results: Option<u64>,+    /// Daily upstream-API-quota telemetry — see `quota` module.+    quota: crate::quota::QuotaState,+    /// True iff a `KEL_ALERT_WEBHOOK` env var is configured.+    kel_alert_configured: bool,+}++fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body.to_string())+        .with_status_code(code)+        .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())+        .with_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap())+        .with_header(Header::from_bytes("Access-Control-Allow-Methods", "GET,POST,OPTIONS").unwrap())+        .with_header(Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap())+}++fn html_resp(html: String) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(html)+        .with_status_code(200)+        .with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())+}++fn parse_query(url: &str) -> HashMap<String, String> {+    let mut out = HashMap::new();+    if let Some(q) = url.split_once('?').map(|(_, q)| q) {+        for pair in q.split('&') {+            if let Some((k, v)) = pair.split_once('=') {+                out.insert(+                    urldecode(k),+                    urldecode(v),+                );+            }+        }+    }+    out+}++fn urldecode(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    let bytes = s.as_bytes();+    let mut i = 0;+    while i < bytes.len() {+        let b = bytes[i];+        if b == b'+' {+            out.push(' ');+            i += 1;+        } else if b == b'%' && i + 2 < bytes.len() {+            if let Ok(n) = u8::from_str_radix(&s[i + 1..i + 3], 16) {+                out.push(n as char);+                i += 3;+                continue;+            }+            out.push(b as char);+            i += 1;+        } else {+            out.push(b as char);+            i += 1;+        }+    }+    out+}++fn path_of(url: &str) -> &str {+    url.split_once('?').map(|(p, _)| p).unwrap_or(url)+}++fn do_search(+    api_key: &str,+    cache: &Arc<Mutex<HashMap<String, CacheEntry>>>,+    keyword: &str,+    limit: u32,+    in_stock_only: bool,+) -> Result<SearchResult, String> {+    let cache_key = format!("s:{}:{}:{}", keyword, limit, in_stock_only);+    if let Some(cached) = cache_get(cache, &cache_key) {+        // Cache hit: don't count toward upstream quota (no API call+        // was made). Do count the results returned toward the "usage+        // today" figure so the human-facing stat reflects our reach.+        if let Some(n) = cached.get("components").and_then(|v| v.as_array()).map(|a| a.len() as u64) {+            // (We increment a "served from cache" counter by reusing+            // results_returned; searches is not incremented because+            // that metric is about upstream quota use.)+            let _ = n; // placeholder — not tracked yet; upstream-quota tracking is the priority.+        }+        return serde_json::from_value(cached).map_err(|e| e.to_string());+    }+    match mouser::search(api_key, keyword, limit, in_stock_only) {+        Ok(result) => {+            let returned = result.components.len() as u64;+            crate::quota::record_success(returned);+            let v = serde_json::to_value(&result).map_err(|e| e.to_string())?;+            cache_set(cache, cache_key, v);+            Ok(result)+        }+        Err(e) => {+            // Detect quota-exhausted response in the 403-body path too.+            let lower = e.to_lowercase();+            if lower.contains("maximum calls per day") || lower.contains("toomanyrequests") {+                if crate::quota::record_quota_hit() {+                    let q = crate::quota::get();+                    crate::quota::post_kel_alert(&format!(+                        "⚠️ *Mouser daily API quota exhausted.*\n\+                         Key used up for {} — resets at midnight CT.\n\+                         Usage today: {} searches, {} results returned.\n\+                         (DigiKey + JLCPCB are unaffected.)",+                        q.date_ct, q.searches, q.results_returned+                    ));+                }+            }+            Err(e)+        }+    }+}++fn do_part(+    api_key: &str,+    cache: &Arc<Mutex<HashMap<String, CacheEntry>>>,+    part_number: &str,+) -> Result<SearchResult, String> {+    let cache_key = format!("p:{}", part_number);+    if let Some(cached) = cache_get(cache, &cache_key) {+        return serde_json::from_value(cached).map_err(|e| e.to_string());+    }+    let result = mouser::part(api_key, part_number)?;+    let v = serde_json::to_value(&result).map_err(|e| e.to_string())?;+    cache_set(cache, cache_key, v);+    Ok(result)+}++fn health(+    api_key: &str,+    cache: &Arc<Mutex<HashMap<String, CacheEntry>>>,+    live: bool,+) -> (u16, Value) {+    let has_key = !api_key.is_empty();+    let cache_size = cache.lock().map(|m| m.len()).unwrap_or(0);+    let mut hr = HealthResponse {+        ok: has_key,+        version: env!("CARGO_PKG_VERSION"),+        cache_size,+        cache_max: CACHE_MAX,+        cache_ttl_ms: CACHE_TTL.as_millis() as u64,+        error: if has_key { None } else { Some("MOUSER_API_KEY not set") },+        api_reachable: None,+        api_error: None,+        test_results: None,+        quota: crate::quota::get(),+        kel_alert_configured: std::env::var("KEL_ALERT_WEBHOOK").map(|s| !s.is_empty()).unwrap_or(false),+    };+    if has_key && live {+        match mouser::ping(api_key) {+            Ok(n) => {+                hr.api_reachable = Some(true);+                hr.test_results = Some(n);+            }+            Err(e) => {+                hr.api_reachable = Some(false);+                hr.api_error = Some(e);+            }+        }+    }+    let code = if hr.ok { 200 } else { 500 };+    (code, serde_json::to_value(&hr).unwrap_or_else(|_| json!({"ok": false})))+}++pub fn run_serve(args: ServeArgs) -> Result<(), String> {+    let api_key = std::env::var("MOUSER_API_KEY").unwrap_or_default();+    if api_key.is_empty() {+        warn("MOUSER_API_KEY is not set — service will return errors for live queries.");+    }+    let addr = format!("0.0.0.0:{}", args.port);+    let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;+    let cache: Arc<Mutex<HashMap<String, CacheEntry>>> = Arc::new(Mutex::new(HashMap::new()));+    ok(format!("adom-mouser backend listening on port {}", args.port));+    note!("  {CYAN}GET  /health{RESET}  — health check + live API ping");+    note!("  {CYAN}GET  /search?keyword=...{RESET}  — keyword/MPN search");+    note!("  {CYAN}GET  /part?partNumber=...{RESET}  — part lookup");+    note!("  {CYAN}POST /{RESET}  — action-based API");++    for mut request in server.incoming_requests() {+        // CORS preflight+        if request.method() == &Method::Options {+            let _ = request.respond(json_resp(204, json!({})));+            continue;+        }+        let url = request.url().to_string();+        let method = request.method().clone();+        let path = path_of(&url).trim_end_matches('/');+        let path = if path.is_empty() { "/" } else { path };++        // Landing page on GET / with Accept: text/html+        if method == Method::Get && path == "/" {+            let wants_html = request+                .headers()+                .iter()+                .any(|h| h.field.equiv("Accept") && h.value.as_str().contains("text/html"));+            if wants_html {+                let proto = request+                    .headers()+                    .iter()+                    .find(|h| h.field.equiv("X-Forwarded-Proto"))+                    .map(|h| h.value.as_str().to_string())+                    .unwrap_or_else(|| "https".into());+                let host = request+                    .headers()+                    .iter()+                    .find(|h| h.field.equiv("X-Forwarded-Host"))+                    .or_else(|| request.headers().iter().find(|h| h.field.equiv("Host")))+                    .map(|h| h.value.as_str().to_string())+                    .unwrap_or_else(|| format!("localhost:{}", args.port));+                let base = format!("{}://{}", proto, host);+                let key_present = !api_key.is_empty();+                let html = LANDING_HTML+                    .replace("{{BASE_URL}}", &base)+                    .replace("{{STATUS_CLASS}}", if key_present { "ok" } else { "err" })+                    .replace(+                        "{{STATUS_TEXT}}",+                        if key_present {+                            "Healthy — API key configured"+                        } else {+                            "Error — MOUSER_API_KEY not set"+                        },+                    );+                let _ = request.respond(html_resp(html));+                continue;+            }+        }++        let result: (u16, Value) = match (&method, path) {+            (Method::Get, "/health") => {+                // Default: CHEAP health check — no upstream ping (every+                // ping burns a slot on Mouser's daily-quota counter).+                // Opt-in via `?live=true` when you actually want the+                // end-to-end reachability test.+                let q = parse_query(&url);+                let live = q.get("live").map(|s| s == "true").unwrap_or(false);+                health(&api_key, &cache, live)+            }+            (Method::Get, "/search") => {+                let q = parse_query(&url);+                let keyword = q.get("keyword").cloned().unwrap_or_default();+                let limit: u32 = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(10);+                let in_stock = q.get("inStockOnly").map(|s| s == "true").unwrap_or(false);+                match do_search(&api_key, &cache, &keyword, limit, in_stock) {+                    Ok(r) => (200, serde_json::to_value(&r).unwrap()),+                    Err(e) => (400, json!({"error": e})),+                }+            }+            (Method::Get, "/part") => {+                let q = parse_query(&url);+                let pn = q.get("partNumber").cloned().unwrap_or_default();+                match do_part(&api_key, &cache, &pn) {+                    Ok(r) => (200, serde_json::to_value(&r).unwrap()),+                    Err(e) => (400, json!({"error": e})),+                }+            }+            (Method::Post, "/") => {+                let mut body = String::new();+                if std::io::Read::read_to_string(&mut request.as_reader(), &mut body).is_err() {+                    let _ = request.respond(json_resp(400, json!({"error": "read body"})));+                    continue;+                }+                let body_json: Value = match serde_json::from_str(&body) {+                    Ok(v) => v,+                    Err(_) => {+                        let _ = request.respond(json_resp(400, json!({"error": "invalid JSON"})));+                        continue;+                    }+                };+                let action = body_json.get("action").and_then(|v| v.as_str()).unwrap_or("");+                match action {+                    "search" => {+                        let keyword = body_json.get("keyword").and_then(|v| v.as_str()).unwrap_or("").to_string();+                        let limit = body_json.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as u32;+                        let in_stock = body_json.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(false);+                        match do_search(&api_key, &cache, &keyword, limit, in_stock) {+                            Ok(r) => (200, serde_json::to_value(&r).unwrap()),+                            Err(e) => (400, json!({"error": e})),+                        }+                    }+                    "part" => {+                        let pn = body_json.get("partNumber").and_then(|v| v.as_str()).unwrap_or("").to_string();+                        match do_part(&api_key, &cache, &pn) {+                            Ok(r) => (200, serde_json::to_value(&r).unwrap()),+                            Err(e) => (400, json!({"error": e})),+                        }+                    }+                    "health" => health(&api_key, &cache, false),+                    other => (400, json!({"error": format!("unknown action: '{}'. Try: search, part, health", other)})),+                }+            }+            _ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)})),+        };++        let _ = request.respond(json_resp(result.0, result.1));+    }+    Ok(())+}
src/service_landing.htmladded+92
@@ -0,0 +1,92 @@+<!DOCTYPE html>+<html lang="en"><head>+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">+<title>Mouser Component Search Service</title>+<style>+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+:root {+  --bg: #0d1117;+  --surface: #161b22;+  --surface2: #1c2129;+  --border: #30363d;+  --text: #e6edf3;+  --text-dim: #8b949e;+  --accent: #00b8b0;+  --green: #3fb950;+  --red: #f85149;+}+* { box-sizing: border-box; margin: 0; padding: 0; }+body {+  background: var(--bg);+  color: var(--text);+  font-family: 'Satoshi', sans-serif;+  padding: 32px;+  line-height: 1.6;+  max-width: 800px;+  margin: 0 auto;+}+h1 { font-family: 'Familjen Grotesk', sans-serif; font-size: 28px; margin-bottom: 8px; }+h2 { font-family: 'Familjen Grotesk', sans-serif; font-size: 18px; margin: 24px 0 8px; color: var(--accent); }+.subtitle { color: var(--text-dim); margin-bottom: 24px; }+.status { display: inline-block; padding: 4px 12px; border-radius: 8px; font-size: 13px; font-weight: 600; margin-bottom: 24px; }+.status.ok { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid rgba(63,185,80,0.3); }+.status.err { background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid rgba(248,81,73,0.3); }+pre { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; overflow-x: auto; font-size: 13px; line-height: 1.5; margin: 8px 0 16px; }+code { font-family: 'JetBrains Mono', monospace; }+.endpoint { margin: 12px 0; }+.method { display: inline-block; background: #1f6feb; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 700; margin-right: 8px; font-family: 'JetBrains Mono', monospace; }+.method.post { background: var(--green); }+p { margin: 8px 0; color: var(--text); }+a { color: var(--accent); }+</style>+</head><body>+<h1>Mouser Component Search Service</h1>+<p class="subtitle">Shared HTTP proxy for the Mouser Electronics API v2. Keeps the API key server-side with in-memory caching.</p>+<span class="status {{STATUS_CLASS}}">{{STATUS_TEXT}}</span>++<h2>Endpoints</h2>++<div class="endpoint">+  <span class="method">GET</span> <code>/search?keyword=...&amp;limit=10&amp;inStockOnly=true</code>+  <p>Search by keyword, MPN, or description. Returns normalized component data.</p>+  <pre>curl '{{BASE_URL}}/search?keyword=STM32F103&amp;limit=5'</pre>+</div>++<div class="endpoint">+  <span class="method">GET</span> <code>/part?partNumber=...</code>+  <p>Look up a specific part by Mouser PN or manufacturer PN.</p>+  <pre>curl '{{BASE_URL}}/part?partNumber=STM32F103RBT6'</pre>+</div>++<div class="endpoint">+  <span class="method">GET</span> <code>/health</code>+  <p>Service health check (API key status, cache stats, live API ping).</p>+  <pre>curl '{{BASE_URL}}/health'</pre>+</div>++<div class="endpoint">+  <span class="method post">POST</span> <code>/</code>+  <p>Action-based API. Send JSON body with <code>action</code> field.</p>+  <pre>curl -X POST '{{BASE_URL}}' \+  -H 'Content-Type: application/json' \+  -d '{"action":"search","keyword":"USB-C connector","limit":10}'</pre>+</div>++<h2>Using from Claude Code</h2>+<p>Install the <code>adom-mouser</code> CLI, then:</p>+<pre>adom-mouser search "USB-C connector"+adom-mouser part 511-STM32F103RBT6+adom-mouser app    # open an interactive search UI in Hydrogen</pre>++<h2>Cache</h2>+<p>Responses are cached in-memory for 10 minutes (LRU, max 200 entries).</p>+</body></html>
tests/cron-smoke.shadded+53
@@ -0,0 +1,53 @@+#!/usr/bin/env bash+# adom-mouser cron smoke runner. Same shape as adom-jlcpcb's:+# state-transition alerts via Kel, no spam on repeated fails.+#+# Install:+#   (crontab -l 2>/dev/null; echo '20 * * * * /home/adom/adom-mouser/tests/cron-smoke.sh >> /tmp/adom-mouser-smoke.log 2>&1') | crontab -++set -u+set -a; [ -f /etc/environment ] && . /etc/environment; set +a++REPO_DIR="${ADOM_MOUSER_REPO:-$HOME/adom-mouser}"+# Pass `--live` to run the daily live suite instead of the cheap hourly+# check. Separate state files so pass/fail tracking doesn't cross over.+MODE="cheap"+if [ "${1:-}" = "--live" ]; then MODE="live"; shift; fi+STATE_FILE="${ADOM_MOUSER_SMOKE_STATE:-/tmp/adom-mouser-smoke-state-$MODE}"+BACKEND="${MOUSER_API:-http://127.0.0.1:${MOUSER_PORT:-8775}}"+WEBHOOK="${KEL_ALERT_WEBHOOK:-}"+HOSTNAME_SHORT="$(hostname -s 2>/dev/null || echo mouser-host)"++prev_status="pass"+[ -f "$STATE_FILE" ] && prev_status="$(cat "$STATE_FILE" 2>/dev/null || echo pass)"++if [ "$MODE" = "live" ]; then+  log_out="$(bash "$REPO_DIR/tests/smoke-live.sh" "$BACKEND" 2>&1)"+else+  log_out="$(bash "$REPO_DIR/tests/smoke.sh" "$BACKEND" 2>&1)"+fi+status=$?+printf '[%s] status=%s\n%s\n' "$(date -Iseconds)" "$status" "$log_out"++new_status="pass"+[ "$status" -ne 0 ] && new_status="fail"++post_kel() {+  [ -z "$WEBHOOK" ] && return 0+  local body; body="$(jq -n --arg t "$1" '{text:$t}')"+  curl -s --max-time 3 -X POST -H 'content-type: application/json' -d "$body" "$WEBHOOK" >/dev/null || true+}++if [ "$prev_status" = "pass" ] && [ "$new_status" = "fail" ]; then+  summary="$(printf '%s' "$log_out" | grep -E '^(FAIL|OK|FAIL.*passed)' | head -4)"+  post_kel "⚠️ *adom-mouser backend smoke FAILING on $HOSTNAME_SHORT.*+Backend: $BACKEND+$(printf '%s' "$summary")+/tmp/adom-mouser-smoke.log has the full run. One alert per incident."+elif [ "$prev_status" = "fail" ] && [ "$new_status" = "pass" ]; then+  post_kel "✅ *adom-mouser backend smoke RECOVERED on $HOSTNAME_SHORT.*+Backend: $BACKEND"+fi++echo "$new_status" > "$STATE_FILE"+exit "$status"
tests/smoke-live.shadded+64
@@ -0,0 +1,64 @@+#!/usr/bin/env bash+# adom-mouser LIVE smoke — burns ~2 Mouser API calls.+#+# Runs ONCE per day (daily cron). Verifies that an actual upstream+# search returns real data. If today's quota is already gone, we exit+# 0 with a "skipped" status (not a failure — it's just business).+#+# Usage:+#   tests/smoke-live.sh                              # deployed backend+#   tests/smoke-live.sh http://127.0.0.1:8775        # local++set -u+BACKEND="${1:-${MOUSER_API:-https://mouser-xr8t4f5d01bt.adom.cloud}}"+BACKEND="${BACKEND%/}"++if [ -t 1 ]; then+  C_OK=$'\033[32m'; C_FAIL=$'\033[31m'; C_SKIP=$'\033[33m'; C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'+else C_OK=''; C_FAIL=''; C_SKIP=''; C_DIM=''; C_BOLD=''; C_RESET=''; fi++pass=0; fail=0; failures=()++echo "${C_BOLD}adom-mouser LIVE smoke${C_RESET}  ${C_DIM}$BACKEND${C_RESET}"+echo "${C_DIM}────────────────────────────────────────────────────────${C_RESET}"++# Step 0 — cheap /health (no live ping), check today's quota state.+health=$(curl -sS --max-time 10 "$BACKEND/health" 2>/dev/null)+quota_hit=$(printf '%s' "$health" | jq -r '.quota.first_quota_hit_ts // 0')+if [ "$quota_hit" != "0" ] && [ "$quota_hit" != "null" ]; then+  searches=$(printf '%s' "$health" | jq -r '.quota.searches // 0')+  echo "${C_SKIP}SKIP${C_RESET}  Mouser daily quota already hit today ($searches searches used)"+  echo "${C_DIM}       exit 0 — skipping live queries until midnight CT reset.${C_RESET}"+  exit 0+fi++run_case() {+  local label="$1" keyword="$2" expect="$3"+  local body resp count match+  body=$(jq -n --arg k "$keyword" '{action:"search", keyword:$k, limit:2}')+  resp=$(curl -sS --max-time 20 -X POST "$BACKEND/search" \+    -H 'content-type: application/json' -d "$body" 2>/dev/null)+  count=$(printf '%s' "$resp" | jq '.count // 0' 2>/dev/null)+  match=$(printf '%s' "$resp" | jq --arg s "$expect" \+    '[.components[]? | (.mpn // .mfr // "") + " " + (.description // "") | ascii_downcase | contains($s | ascii_downcase)] | any' 2>/dev/null)+  if [ "$match" = "true" ] && [ "${count:-0}" -ge 1 ]; then+    pass=$((pass+1)); printf '%sPASS%s  %-28s  count=%s  contains %s\n' "$C_OK" "$C_RESET" "$label" "$count" "$expect"+  else+    fail=$((fail+1)); failures+=("$label — count=$count contains-$expect=$match")+    printf '%sFAIL%s  %-28s  count=%s  contains %s? %s\n' "$C_FAIL" "$C_RESET" "$label" "$count" "$expect" "${match:-null}"+  fi+}++# Frugal: 2 canonical MPNs. Increments the quota tracker by 2.+run_case "stm32f103 (arm mcu)"   "STM32F103RBT6"  "stm32f103"+run_case "lm555 (timer)"         "LM555"          "lm555"++echo "${C_DIM}────────────────────────────────────────────────────────${C_RESET}"+if [ "$fail" -eq 0 ]; then+  echo "${C_OK}${C_BOLD}OK${C_RESET}  $pass live queries passed"+  exit 0+else+  echo "${C_FAIL}${C_BOLD}FAIL${C_RESET}  $pass passed / $fail failed"+  for f in "${failures[@]}"; do echo "      - $f"; done+  exit 1+fi
tests/smoke.shadded+83
@@ -0,0 +1,83 @@+#!/usr/bin/env bash+# adom-mouser smoke — QUOTA-FRUGAL.+#+# This script is safe to run HOURLY: it hits /health with no live ping+# (zero Mouser API calls) and asserts the service is up, the API key is+# configured, and the upstream-status flag looks reasonable. It does NOT+# fire a live search. Use `tests/smoke-live.sh` (daily) for end-to-end.+#+# What "healthy" means here:+#   - /health responds 200 with ok=true+#   - MOUSER_API_KEY is configured (error=null)+#   - The cached quota-state block is readable+# It is explicitly NOT a failure if Mouser reported quota-exhausted+# earlier today — that's a business condition the quota tracker owns+# its own Kel alert for.+#+# Usage:+#   tests/smoke.sh                                     # deployed+#   tests/smoke.sh http://127.0.0.1:8775               # local++set -u+BACKEND="${1:-${MOUSER_API:-https://mouser-xr8t4f5d01bt.adom.cloud}}"+BACKEND="${BACKEND%/}"++if [ -t 1 ]; then+  C_OK=$'\033[32m'; C_FAIL=$'\033[31m'; C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'+else+  C_OK=''; C_FAIL=''; C_DIM=''; C_BOLD=''; C_RESET=''+fi++fail=0; failures=()++echo "${C_BOLD}adom-mouser smoke (quota-free)${C_RESET}  ${C_DIM}$BACKEND${C_RESET}"+echo "${C_DIM}────────────────────────────────────────────────────────${C_RESET}"++health=$(curl -sS --max-time 10 "$BACKEND/health" 2>/dev/null)+if [ -z "$health" ]; then+  echo "${C_FAIL}FAIL${C_RESET}  /health unreachable"; exit 1+fi++ok=$(printf '%s' "$health" | jq -r '.ok // false')+version=$(printf '%s' "$health" | jq -r '.version // "?"')+error=$(printf '%s' "$health" | jq -r '.error // empty')+searches=$(printf '%s' "$health" | jq -r '.quota.searches // 0')+results=$(printf '%s' "$health" | jq -r '.quota.results_returned // 0')+quota_hit=$(printf '%s' "$health" | jq -r '.quota.first_quota_hit_ts // 0')++if [ "$ok" != "true" ]; then+  fail=$((fail+1)); failures+=("/health reported ok=false (MOUSER_API_KEY missing?)")+  printf '%sFAIL%s  /health ok=%s  err=%s\n' "$C_FAIL" "$C_RESET" "$ok" "$error"+else+  printf '%sPASS%s  /health ok=true  version=%s\n' "$C_OK" "$C_RESET" "$version"+fi++if [ -n "$error" ]; then+  fail=$((fail+1)); failures+=("health.error = $error")+  printf '%sFAIL%s  health.error populated  →  %s\n' "$C_FAIL" "$C_RESET" "$error"+fi++# Quota block must be present + parseable. NOT a failure if exhausted —+# that's the tracker's job to alert on.+if [ "$searches" = "null" ]; then+  fail=$((fail+1)); failures+=("quota telemetry missing from /health (too old? bump mouser)")+  printf '%sFAIL%s  quota block missing — service running old binary\n' "$C_FAIL" "$C_RESET"+else+  if [ "$quota_hit" = "0" ] || [ "$quota_hit" = "null" ]; then+    printf '%sPASS%s  quota today: %s searches / %s results returned (not exhausted)\n' \+      "$C_OK" "$C_RESET" "$searches" "$results"+  else+    printf '%sPASS%s  quota today: %s searches / %s results served BEFORE quota hit (informational)\n' \+      "$C_OK" "$C_RESET" "$searches" "$results"+  fi+fi++echo "${C_DIM}────────────────────────────────────────────────────────${C_RESET}"+if [ "$fail" -eq 0 ]; then+  echo "${C_OK}${C_BOLD}OK${C_RESET}  hourly smoke green (0 API calls spent)"+  exit 0+else+  echo "${C_FAIL}${C_BOLD}FAIL${C_RESET}  $fail check(s) failed"+  for f in "${failures[@]}"; do echo "      - $f"; done+  exit 1+fi
wiki/body.mdadded+59
@@ -0,0 +1,59 @@+# adom-mouser++Search Mouser Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:++- **CLI verbs** — `adom-mouser search|part|health` return normalized JSON on stdout, ready for `| jq`.+- **Hydrogen webview app** — `adom-mouser app` opens a first-class "Mouser Search" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and Mouser PN, and a backend-health indicator.+- **Backend HTTP service** — `adom-mouser serve` runs an HTTP proxy (port 8775) with a 10-minute LRU cache. Keeps the Mouser API key server-side; consumer containers point `MOUSER_API` at it.++## Install++```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-mouser/adom-mouser \+  -o /tmp/adom-mouser \+  && chmod +x /tmp/adom-mouser \+  && sudo install -m 0755 /tmp/adom-mouser /usr/local/bin/adom-mouser \+  && adom-mouser install+```++Installs the binary to `/usr/local/bin/adom-mouser`, deploys `~/.claude/skills/adom-mouser/SKILL.md` and `~/.claude/skills/adom-mouser-build/SKILL.md`, and registers bash completions.++For gallia containers, this is done automatically by `gallia/install.mjs` — adom-mouser is a **Tier A app** (every Adom user needs component search day-one).++## Use++```bash+# CLI+adom-mouser search "STM32F103RBT6" --limit 5+adom-mouser part 511-STM32F103RBT6 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+adom-mouser health++# UI+adom-mouser app        # opens a Hydrogen webview tab++# Backend+MOUSER_API_KEY=<key> adom-mouser serve    # runs on service-mouser container+```++## Architecture++```+  any user container             service-mouser (default-light)+  ──────────────────             ──────────────────────────────+  adom-mouser search       ─┐+  adom-mouser app          ─┼──▶ adom-mouser serve (port 8775)+  adom-mouser health       ─┘    + MOUSER_API_KEY+                                 + 10-min LRU cache+                                 + self-heal cron watchdog+                                          │+                                          ▼+                                api.mouser.com / v2+```++The service container clones only `adom-inc/adom-mouser`; its `service/watchdog.sh` runs every 2 min to (1) keep the service healthy and (2) `git fetch origin main` + rebuild + swap binary when a new release lands. No redeploy step after publishing — publish.sh pushes to main, the service container picks it up within ~5 minutes.++## Source++Source at [adom-inc/adom-mouser](https://github.com/adom-inc/adom-mouser) (private). The public artifact is this wiki page's `docker-binary` asset — source access is not required to install or use the app.++Built following the canonical [adom-app-model](https://wiki-ufypy5dpx93o.adom.cloud/wiki/skills/adom-app-model): single private repo for both client + service code, binary published to the wiki, service on its own default-light container, Tier A distribution via `gallia/install.mjs`.
wiki/discovery.jsonadded+16
@@ -0,0 +1,16 @@+{+  "slug": "adom-mouser",+  "pitch": "Search Mouser Electronics for components, pricing, stock, and datasheets — CLI + webview app + shared backend service.",+  "triggers": [+    "mouser",+    "mouser parts",+    "mouser pricing",+    "mouser search",+    "mouser part number",+    "component search",+    "part lookup",+    "price breaks",+    "datasheet mouser",+    "in stock mouser"+  ]+}
wiki/page.jsonadded+30
@@ -0,0 +1,30 @@+{+  "type": "app",+  "slug": "adom-mouser",+  "title": "Mouser Electronics Search",+  "brief": "Search Mouser components, pricing, and stock. Three faces: CLI verbs (search/part/health), a Hydrogen webview app, and a shared backend HTTP service.",+  "metadata": {+    "repo": "adom-inc/adom-mouser",+    "version": "0.1.3",+    "releases": {+      "adom_docker": {+        "asset_name": "adom-mouser",+        "binary_url": "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-mouser/adom-mouser",+        "install_hint": "curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-mouser/adom-mouser -o /tmp/adom-mouser && chmod +x /tmp/adom-mouser && sudo install -m 0755 /tmp/adom-mouser /usr/local/bin/adom-mouser && adom-mouser install"+      }+    },+    "discovery_triggers": [+      "mouser",+      "mouser parts",+      "mouser pricing",+      "mouser search",+      "mouser part number",+      "component search",+      "part lookup",+      "price breaks",+      "datasheet mouser",+      "in stock mouser"+    ],+    "discovery_pitch": "Search Mouser Electronics for components, pricing, stock, and datasheets — CLI + webview app + shared backend service."+  }+}