← Commit history
.gitignoreadded+3
@@ -0,0 +1,3 @@+/target+/.env+*.log
BUILD-SKILL.mdadded+75
@@ -0,0 +1,75 @@+---+name: adom-digikey-build+description: Build and release a new version of adom-digikey. Use when the user says "release adom-digikey", "publish adom-digikey", "cut a new adom-digikey version", "bump adom-digikey", "ship adom-digikey", or "push adom-digikey to the wiki".+---++# Release adom-digikey++A release syncs five places to the same version X.Y.Z:+`VERSION`, `Cargo.toml`, `adom-digikey --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-digikey --version                    # must print "adom-digikey X.Y.Z"+   adom-digikey health                       # backend reachable+   adom-digikey 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+   DIGIKEY_API=<backend> adom-digikey app --no-tab &+   adom-desktop browser_open_window '{"sessionId":"digikey","profile":"digikey","url":"http://127.0.0.1:8891"}'+   # 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://digikey-<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-digikey --version                     # → "adom-digikey X.Y.Z"+   ```++## Pre-release checklist++- [ ] `VERSION`, `Cargo.toml`, `adom-digikey --version`, wiki `metadata.version`, `/health` all return X.Y.Z+- [ ] Wiki page at `apps/adom-digikey` 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 (`.digikey-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-digikey"+version = "0.2.0"+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-digikey"+version = "0.2.0"+edition = "2021"+description = "DigiKey Electronics component search — CLI, backend HTTP service, and Hydrogen webview app"+license = "MIT"++[[bin]]+name = "adom-digikey"+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-digikey+# DigiKey Electronics Search -Search DigiKey Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:+**DigiKey's catalog — millions of parts, real-time stock and quantity pricing — one command away.** -- **CLI verbs** — `adom-digikey search|part|health` return normalized JSON on stdout, ready for `| jq`.-- **Hydrogen webview app** — `adom-digikey app` opens a first-class "DigiKey Search" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and DigiKey PN, and a backend-health indicator.-- **Backend HTTP service** — `adom-digikey serve` runs an HTTP proxy (port 8777) with a 10-minute LRU cache. Keeps the DigiKey API key server-side; consumer containers point `DIGIKEY_API` at it.+![DigiKey's catalog one command away — real-time stock, quantity pricing and datasheets in your Adom workspace](/blob/app/adom-digikey/screenshots/hero.png) -![DigiKey search webview showing STM32F103 results with peripherals and specs](https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-digikey/digikey-search-results.png)+`adom-digikey` is the single-vendor DigiKey tool for the Adom ecosystem — one Rust binary, three+faces: -## Install+- **CLI** — `adom-digikey search | part | health` for quick JSON-returning shell use and BOMs.+- **Hydrogen app** — `adom-digikey app` opens a searchable UI as a first-class Hydrogen tab.+- **Backend service** — `adom-digikey serve` runs the shared proxy (port 8777) that powers the+  `service-digikey` container and feeds [`adom-parts-search`](/app/adom-parts-search).++It replaces the old `gallia/services/digikey` + MCP setup: no MCP server, no Adom Viewer.  ```bash-curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-digikey/adom-digikey \-  -o /tmp/adom-digikey \-  && chmod +x /tmp/adom-digikey \-  && sudo install -m 0755 /tmp/adom-digikey /usr/local/bin/adom-digikey \-  && adom-digikey install+adompkg install adom-digikey+adom-digikey search "BME280" ``` -Installs the binary to `/usr/local/bin/adom-digikey`, deploys `~/.claude/skills/adom-digikey/SKILL.md` and `~/.claude/skills/adom-digikey-build/SKILL.md`, and registers bash completions.+## Real-time stock, straight to the bench++DigiKey shines when you need it *today*. Every card shows live stock, qty-1 price and the full+break ladder, lead time, RoHS and lifecycle — and DigiKey's deep catalog means the obscure sensor+or alt part is usually right there.++![adom-digikey results for a BME280 humidity sensor — Bosch, alternatives from Sensirion, with stock, pricing and interface attributes](/blob/app/adom-digikey/screenshots/results-sensors.png)++## Find the exact part, and its alternates++Search a function (`buck regulator 1A SOT-23`) or a precise MPN. DigiKey's parametric data lets the+cards surface the specs that actually drive a decision — Iq, Iout, Vin range, package — so the+substitute is one glance away when the first choice is out. -For gallia containers, this is done automatically by `gallia/install.mjs` — adom-digikey is a **Tier A app** (every Adom user needs component search day-one).+![adom-digikey results for a TPS62840 buck regulator with alternates from TI and Monolithic Power, showing current, input range and price breaks](/blob/app/adom-digikey/screenshots/results-buck.png) -## Use+## Scriptable from the first keystroke++`search` returns JSON; `part` fetches one MPN; `health` checks the backend's OAuth token. Pipe it+into `jq`, a BOM, or another Claude step.++![Terminal showing adom-digikey search piped to jq for the first component, then a health check reporting the backend OAuth is fresh](/blob/app/adom-digikey/screenshots/cli.png)  ```bash-# CLI-adom-digikey search "STM32F103RBT6" --limit 5-adom-digikey part 296-STM32F103RBT6-ND | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+adom-digikey search "TPS62840DLCR" | jq '.components[0]'+adom-digikey part "296-TPS62840DLCRCT-ND" adom-digikey health+``` -# UI-adom-digikey app        # opens a Hydrogen webview tab+## Install -# Backend-DIGIKEY_CLIENT_ID=<key> adom-digikey serve    # runs on service-digikey container+```bash+adompkg install adom-digikey ``` -## Architecture+## How it works  ```-  any user container             service-digikey (default-light)-  ──────────────────             ──────────────────────────────-  adom-digikey search       ─┐-  adom-digikey app          ─┼──▶ adom-digikey serve (port 8777)-  adom-digikey health       ─┘    + DIGIKEY_CLIENT_ID-                                 + 10-min LRU cache-                                 + self-heal cron watchdog-                                          │-                                          ▼-                                api.digikey.com / v2+  any container                          service-digikey+  $ adom-digikey search "BME280" ──POST──▶  adom-digikey serve  (:8777)+  $ adom-digikey app                        DIGIKEY_CLIENT_ID + OAuth+    (Hydrogen webview tab)                  token-refresh + cache+                                                 │+                                                 ▼+                                          api.digikey.com (v4) ``` -The service container clones only `adom-inc/adom-digikey`; 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 backend manages the DigiKey OAuth handshake and token refresh for you; the CLI auto-detects it+(or a local `:8777`). DigiKey is the go-to alternate when Mouser is out of stock or long-lead —+see how [`adom-parts-search`](/app/adom-parts-search) flips the recommendation automatically.++## 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-digikey](https://github.com/adom-inc/adom-digikey) (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-digikey-description: >-  Search DigiKey components, pricing, and stock. Three faces: CLI verbs (search/part/health), a Hydrogen webview app, and a shared backend HTTP service.+description: Search DigiKey Electronics for components, pricing, stock, and part numbers. Use when the user asks to find components on DigiKey, check DigiKey pricing, look up a DigiKey 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 digikey", "digikey app", "digikey search UI". ---+ # DigiKey Electronics Search-Search DigiKey components, pricing, and stock. Three faces: CLI verbs (search/part/health), a Hydrogen webview app, and a shared backend HTTP service.++Search DigiKey'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-digikey` — with three faces:++1. **One-shot search from the shell**, for when the AI just needs the data:+   ```bash+   adom-digikey search "STM32F103RBT6"+   adom-digikey part 296-STM32F103RBT6-ND+   adom-digikey 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-digikey app        # opens a "DigiKey Search" tab in Hydrogen+   ```+   The app has a search bar, in-stock filter, per-result cards with price breaks, MPN/DigiKey-PN copy buttons, datasheet + product-page links, and hover tooltips on every control.++3. **A backend HTTP service** (port 8777), which is what the shared `service-digikey` container runs:+   ```bash+   adom-digikey serve      # DIGIKEY_CLIENT_ID 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-digikey search <keyword>`++Search by keyword, MPN, or DigiKey PN.++| Flag | Default | Description |+|---|---|---|+| `--limit N` | 10 | Max results (1–50) |+| `--in-stock-only` | false | Only parts currently in stock |+| `--api URL` / `$DIGIKEY_API` | auto | Backend URL. If unset, the CLI tries `http://127.0.0.1:8777`, and if that's also down it falls back to calling DigiKey directly (requires `DIGIKEY_CLIENT_ID`) |++Examples:+```bash+adom-digikey search "STM32F103RBT6" --limit 5+adom-digikey search "USB-C connector" --in-stock-only+adom-digikey search "LDO 3.3V 500mA SOT-23"+adom-digikey search "ESP32 WiFi Bluetooth" | jq '.components[].mpn'+```++### `adom-digikey part <part-number>`++Full detail lookup for a specific part. Accepts DigiKey PN (`296-STM32F103RBT6-ND`) or MPN (`STM32F103RBT6`). Returns a single-component result with full price-break table and attributes.++```bash+adom-digikey part 296-STM32F103RBT6-ND+adom-digikey part STM32F103RBT6 | jq '.components[0].price_tiers'+```++### `adom-digikey app`++Open the search UI in a Hydrogen webview tab.++| Flag | Default | Description |+|---|---|---|+| `--port N` | 8891 | Local app HTTP server port |+| `--api URL` | auto | Backend to use (same resolution as `search`) |+| `--tab-name S` | "DigiKey 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-digikey app                         # normal use+adom-digikey app --dev                   # enable /eval for UI debugging+adom-digikey app --no-tab --port 8891 &  # local-browser mode+```++When `adom-digikey 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:8891/search \+  -H 'Content-Type: application/json' \+  -d '{"keyword":"ESP32","limit":5,"inStockOnly":false}'++# state + cached last-search+curl http://127.0.0.1:8891/state | jq++# UI-side console logs (what the frontend JS printed)+curl http://127.0.0.1:8891/console | jq '.messages[-10:]'++# backend-health proxy (so the UI doesn't need CORS to 8777)+curl http://127.0.0.1:8891/backend-health | jq++# shutdown cleanly (removes the Hydrogen tab and exits)+curl -X POST http://127.0.0.1:8891/shutdown+```++### `adom-digikey serve`++Run the backend HTTP proxy. This is what the `service-digikey` container runs.++| Flag | Default | Description |+|---|---|---|+| `--port N` / `$DIGIKEY_PORT` | 8777 | Port |++Requires `DIGIKEY_CLIENT_ID`. 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-digikey health`++Check reachability.++```bash+adom-digikey health                 # checks backend (or falls back to DigiKey direct)+adom-digikey health --api http://127.0.0.1:8777+```++## What's in a result++Each component in `.components[]` has:++- `mouser_pn`    — DigiKey part number, use this for BOM+- `mpn`          — manufacturer part number, use this for schematic/footprint+- `manufacturer` — manufacturer name+- `description`, `category`+- `stock`        — integer count in DigiKey's warehouse (live, 10-min cache on backend)+- `stock_text`   — raw DigiKey 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-digikey search "32-bit ARM Cortex-M3 microcontroller LQFP-48"+# pick one, then:+adom-digikey part <digikey-pn>+```++### Confirming price for a BOM entry+```bash+adom-digikey part STM32F103RBT6 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+```++### Browsing interactively+```bash+adom-digikey app+# then type in the search box, click Product page / Datasheet, etc.+```++## Running the backend++There are two common setups.++### Dedicated `service-digikey` container (shared, has the API key)++The backend proxy runs on a dedicated `default-light` container with `DIGIKEY_CLIENT_ID` 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 `DIGIKEY_API` at its public URL:++```+https://digikey-gjuu8l5t69uh.adom.cloud+```++`curl /health` should return `{"ok":true, "api_reachable":true, "version":"X.Y.Z", ...}`. Recover the current URL from `~/service-watcher/services.json` (`digikey` entry, `"primary": true`) if the slug has rotated. To point the CLI at it:++```bash+export DIGIKEY_API=https://digikey-gjuu8l5t69uh.adom.cloud+adom-digikey search "LM317"+adom-digikey app        # UI reads $DIGIKEY_API automatically+```++Gallia users don't need to set `DIGIKEY_API` explicitly — `adom-digikey` 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-digikey serve` on 127.0.0.1:8777 and falls back to calling DigiKey directly if `DIGIKEY_CLIENT_ID` is set locally.++### Local-only (quick testing)++On any container, if `DIGIKEY_CLIENT_ID` is set and you run `adom-digikey serve &`, the CLI and the app will autodetect `http://127.0.0.1:8777` and use it. This is the dev-loop path.++## Installation++The binary is distributed as a GitHub release from `adom-inc/adom-digikey`. After downloading to `/usr/local/bin/adom-digikey` and making it executable, run:++```bash+adom-digikey install+```++This deploys the skill to `~/.claude/skills/digikey/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 DigiKey's internal relevance algorithm, not strictly by stock.+- `DIGIKEY_CLIENT_ID` is only ever needed on the backend (`serve`) side. Consumer containers just need `DIGIKEY_API` pointing at a running backend.
VERSIONadded+1
@@ -0,0 +1 @@+0.2.0
build.shadded+27
@@ -0,0 +1,27 @@+#!/usr/bin/env bash+# Local build + install for adom-digikey.+set -euo pipefail++cd "$(dirname "$0")"++echo "==> cargo build --release"+cargo build --release++BIN=target/release/adom-digikey+if [ ! -x "$BIN" ]; then+  echo "ERROR: build did not produce $BIN" >&2+  exit 1+fi++DEST=/usr/local/bin/adom-digikey+echo "==> installing $DEST (sudo)"+sudo install -m 0755 "$BIN" "$DEST"++echo "==> adom-digikey install (skill + completions)"+adom-digikey install++echo+echo "Done. Try:"+echo "  adom-digikey --help"+echo "  adom-digikey search 'STM32F103'"+echo "  adom-digikey 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-digikey",   "version": "1.0.0",   "type": "app",-  "description": "Search DigiKey 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": "DigiKey component search in your workspace — millions of parts with real-time stock, quantity pricing and datasheets. CLI verbs, a Hydrogen app, and a shared backend service.",+  "dependencies": {} }
page.json+41−27
@@ -3,42 +3,56 @@   "type": "app",   "slug": "adom-digikey",   "title": "DigiKey Electronics Search",-  "brief": "Search DigiKey components, pricing, and stock. Three faces: CLI verbs (search/part/health), a Hydrogen webview app, and a shared backend HTTP service.",-  "version": "0.2.0",-  "tags": [],+  "brief": "DigiKey component search in your workspace — millions of parts with real-time stock, quantity pricing and datasheets. CLI verbs, a Hydrogen app, and a shared backend service.",+  "version": "1.0.0",+  "install": {+    "binary_name": "adom-digikey",+    "install_dir": "",+    "install_hint": "adompkg install adom-digikey",+    "version_cmd": "adom-digikey --version"+  },+  "author": {+    "id": "6738b00a4dc87b8adbb0a5a3",+    "name": "John Lauer",+    "email": "[email protected]"+  },+  "tags": [+    "digikey",+    "parts",+    "pricing",+    "stock",+    "datasheet",+    "electronics",+    "distributor"+  ],   "license": "MIT",+  "visibility": {+    "public": true+  },+  "hero": {+    "path": "screenshots/hero.png",+    "type": "image"+  },+  "sample_prompts": [+    "price a TPS62840 buck on DigiKey",+    "is the BME280 in stock at DigiKey",+    "find a 1A buck regulator in SOT-23 on DigiKey"+  ],   "discovery_triggers": [     "digikey",+    "digi-key",     "digikey parts",     "digikey pricing",     "digikey search",     "digikey part number",     "component search",     "part lookup",-    "price breaks",+    "real-time stock",     "datasheet digikey",     "in stock digikey"   ],-  "discovery_pitch": "Search DigiKey Electronics for components, pricing, stock, and datasheets — CLI + webview app + shared backend service.",-  "install": {-    "binary_name": "adom-digikey",-    "install_dir": "",-    "install_hint": "",-    "version_cmd": ""-  },-  "readme": "# adom-digikey\n\nSearch DigiKey Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:\n\n- **CLI verbs** — `adom-digikey search|part|health` return normalized JSON on stdout, ready for `| jq`.\n- **Hydrogen webview app** — `adom-digikey app` opens a first-class \"DigiKey Search\" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and DigiKey PN, and a backend-health indicator.\n- **Backend HTTP service** — `adom-digikey serve` runs an HTTP proxy (port 8777) with a 10-minute LRU cache. Keeps the DigiKey API key server-side; consumer containers point `DIGIKEY_API` at it.\n\n![DigiKey search webview showing STM32F103 results with peripherals and specs](https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-digikey/digikey-search-results.png)\n\n## Install\n\n```bash\ncurl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-digikey/adom-digikey \\\n  -o /tmp/adom-digikey \\\n  && chmod +x /tmp/adom-digikey \\\n  && sudo install -m 0755 /tmp/adom-digikey /usr/local/bin/adom-digikey \\\n  && adom-digikey install\n```\n\nInstalls the binary to `/usr/local/bin/adom-digikey`, deploys `~/.claude/skills/adom-digikey/SKILL.md` and `~/.claude/skills/adom-digikey-build/SKILL.md`, and registers bash completions.\n\nFor gallia containers, this is done automatically by `gallia/install.mjs` — adom-digikey is a **Tier A app** (every Adom user needs component search day-one).\n\n## Use\n\n```bash\n# CLI\nadom-digikey search \"STM32F103RBT6\" --limit 5\nadom-digikey part 296-STM32F103RBT6-ND | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'\nadom-digikey health\n\n# UI\nadom-digikey app        # opens a Hydrogen webview tab\n\n# Backend\nDIGIKEY_CLIENT_ID=<key> adom-digikey serve    # runs on service-digikey container\n```\n\n## Architecture\n\n```\n  any user container             service-digikey (default-light)\n  ──────────────────             ──────────────────────────────\n  adom-digikey search       ─┐\n  adom-digikey app          ─┼──▶ adom-digikey serve (port 8777)\n  adom-digikey health       ─┘    + DIGIKEY_CLIENT_ID\n                                 + 10-min LRU cache\n                                 + self-heal cron watchdog\n                                          │\n                                          ▼\n                                api.digikey.com / v2\n```\n\nThe service container clones only `adom-inc/adom-digikey`; 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-digikey](https://github.com/adom-inc/adom-digikey) (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:38.495Z",-  "updated_at": "2026-05-28T05:28:38.495Z",-  "skills": []-}\ No newline at end of file+  "discovery_pitch": "Search DigiKey for millions of components with real-time stock, quantity pricing, and datasheets — CLI + Hydrogen webview app + shared backend service.",+  "metadata": {+    "platform": "linux"+  }+}
publish.shadded+118
@@ -0,0 +1,118 @@+#!/usr/bin/env bash+# adom-digikey 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-digikey+BIN_VER=$("$BIN" --version | awk '{print $NF}')+if [ "$BIN_VER" != "$VERSION" ]; then+  fail "binary --version ($BIN_VER) != VERSION ($VERSION)"+fi+ok "binary prints adom-digikey $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-digikey"+TITLE=$(jq -r '.title' wiki/page.json)+BRIEF=$(jq -r '.brief' wiki/page.json)+adom-wiki page publish apps/adom-digikey \+  --title "$TITLE" \+  --brief "$BRIEF" \+  --body-md wiki/body.md \+  --version "$VERSION" \+  --sample-prompt "Search DigiKey|find me a USB-C connector on DigiKey with stock > 1000" \+  --sample-prompt "Part lookup|look up DigiKey PN 296-STM32F103RBT6-ND" \+  --sample-prompt "Open the app|open the DigiKey search app in Hydrogen" \+  --sample-prompt "Compare alternatives|find cheaper alternatives to the AMS1117-3.3 on DigiKey" \+  --sample-prompt "Check stock|is the ESP32-S3-WROOM-1U-N4R2 in stock at DigiKey?"+ok "wiki page published at apps/adom-digikey v$VERSION"++# ── 5. Wiki asset upload (canonical install surface) ───────────────+step "5. adom-wiki asset upload (docker-binary)"+adom-wiki asset upload apps/adom-digikey --asset-type docker-binary --file "$BIN"+ok "uploaded $BIN to wiki at apps/adom-digikey"++# ── 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-digikey --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-digikey $VERSION"++echo+echo "───────────────────────────────────────────────"+echo "  adom-digikey v$VERSION published."+echo "  Wiki: $BINARY_URL"+echo "  The service-digikey container will auto-update within 2 minutes."+echo "───────────────────────────────────────────────"
screenshots/cli.pngadded
⋯ 1 unchanged line ⋯
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
screenshots/results-buck.pngadded
⋯ 1 unchanged line ⋯
screenshots/results-sensors.pngadded
⋯ 1 unchanged line ⋯
service/deploy.shadded+96
@@ -0,0 +1,96 @@+#!/usr/bin/env bash+# adom-digikey service container bootstrap.+# Run ONCE on a freshly created default-light container.+# Usage: DIGIKEY_CLIENT_ID=<id> DIGIKEY_CLIENT_SECRET=<secret> bash service/deploy.sh+#+# What it does:+#   1. Installs rustup if missing (default-light has no Rust)+#   2. Clones adom-inc/adom-digikey if not already present+#   3. Builds the binary and installs to /usr/local/bin+#   4. Persists DIGIKEY_CLIENT_ID + DIGIKEY_CLIENT_SECRET to /etc/environment (so cron inherits them)+#   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_DIGIKEY_REPO_URL:-https://github.com/adom-inc/adom-digikey.git}"+REPO_DIR="${ADOM_DIGIKEY_REPO:-$HOME/adom-digikey}"+PORT="${DIGIKEY_PORT:-8777}"++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-digikey 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-digikey /usr/local/bin/adom-digikey+ok "installed /usr/local/bin/adom-digikey ($(adom-digikey --version))"++# ── 4. API credentials → /etc/environment (OAuth2 needs BOTH id+secret) ──+for var in DIGIKEY_CLIENT_ID DIGIKEY_CLIENT_SECRET; do+  val="${!var:-}"+  if [ -n "$val" ]; then+    sudo sed -i "/^${var}=/d" /etc/environment 2>/dev/null || true+    echo "${var}=${val}" | sudo tee -a /etc/environment >/dev/null+    ok "${var} persisted to /etc/environment"+  else+    info "${var} not set — service will start but /health will show ok:false"+  fi+done++# ── 5. Crons — watchdog + hourly smoke ─────────────────────────────+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-digikey-watchdog.log 2>&1"+SMOKE_CRON="25 * * * * $REPO_DIR/tests/cron-smoke.sh >> /tmp/adom-digikey-smoke.log 2>&1"++(crontab -l 2>/dev/null | grep -v -E 'adom-digikey' || true; \+ echo "$WATCHDOG_CRON"; echo "$SMOKE_CRON") | crontab -+ok "crons installed (watchdog 2m · smoke hourly :25)"++chmod +x "$REPO_DIR/service/watchdog.sh" "$REPO_DIR/tests/cron-smoke.sh" "$REPO_DIR/tests/smoke.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-digikey.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 digikey"+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-digikey",+  "port": 8777,+  "health": "http",+  "health_path": "/health",+  "start": "/usr/local/bin/adom-digikey serve",+  "env_required": ["DIGIKEY_CLIENT_ID"],+  "log": "/tmp/adom-digikey.log",+  "description": "DigiKey Electronics API proxy with 10-min LRU cache"+}
service/watchdog.shadded+62
@@ -0,0 +1,62 @@+#!/usr/bin/env bash+# adom-digikey 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="${DIGIKEY_PORT:-8777}"+LOG=/tmp/adom-digikey.log+REPO_DIR="${ADOM_DIGIKEY_REPO:-$HOME/adom-digikey}"+BIN=/usr/local/bin/adom-digikey++# ── 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-digikey" "$BIN"+        pkill -f 'adom-digikey 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 DIGIKEY_CLIENT_ID 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-digikey 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-digikey app` — opens a Hydrogen webview tab with a searchable+//! DigiKey UI. The server here is a thin per-session wrapper that proxies+//! the real work to the shared backend (`adom-digikey serve` on 8777, or+//! a remote DIGIKEY_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::digikey::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:8777".into()+}++pub fn run(args: AppArgs) -> Result<(), String> {+    let api = resolve_backend(args.api.clone());+    ok(format!("DigiKey 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 "DigiKey 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-digikey app --no-tab` + `$ open http://127.0.0.1:8891`.+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>DigiKey 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;+}+.digikey-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>DigiKey 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-digikey backend HTTP proxy the UI talks to. Green means the backend is reachable and its DIGIKEY_CLIENT_ID 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 DigiKey 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 DigiKey. 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 DigiKey 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 DigiKey. ` ++    `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 DigiKey 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="digikey-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 DigiKey product page in your default browser. Use this to view the full spec sheet, order, or check real-time stock at all DigiKey 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 DigiKey part number to your clipboard. Use this for BOM ordering.">Copy DigiKey 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 DigiKey for "' + keyword + '"…');+  document.getElementById('status').innerHTML = '<span class="loading"></span> searching DigiKey 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 DigiKey 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+134
@@ -0,0 +1,134 @@+//! One-shot CLI verbs: `adom-digikey search|part|health`.+//!+//! Resolution order for the data source:+//!   1. If `--api` / `DIGIKEY_API` is set → talk to that backend via POST /+//!   2. If http://127.0.0.1:8777 is reachable → use it as the backend+//!   3. Else fall back to a direct DigiKey API call (requires DIGIKEY_CLIENT_ID)++use crate::digikey::{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:8777".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:8777 and no --api / DIGIKEY_API set; calling DigiKey API directly.");+        let client_id = std::env::var("DIGIKEY_CLIENT_ID").unwrap_or_default();+        let client_secret = std::env::var("DIGIKEY_CLIENT_SECRET").unwrap_or_default();+        digikey::search(&client_id, &client_secret, &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:8777 and no --api / DIGIKEY_API set; calling DigiKey API directly.");+        let client_id = std::env::var("DIGIKEY_CLIENT_ID").unwrap_or_default();+        let client_secret = std::env::var("DIGIKEY_CLIENT_SECRET").unwrap_or_default();+        digikey::part(&client_id, &client_secret, &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:8777 and no --api / DIGIKEY_API set; pinging DigiKey directly.");+        let client_id = std::env::var("DIGIKEY_CLIENT_ID").unwrap_or_default();+        let client_secret = std::env::var("DIGIKEY_CLIENT_SECRET").unwrap_or_default();+        let n = digikey::ping(&client_id, &client_secret)?;+        ok(format!("DigiKey API reachable (test query returned {} results)", n));+        Ok(())+    }+}
src/digikey.rsadded+356
@@ -0,0 +1,356 @@+//! DigiKey Products v4 API client + response normalization.+//!+//! Two ways to resolve a query:+//!   1. Direct — requires DIGIKEY_CLIENT_ID + DIGIKEY_CLIENT_SECRET. OAuth2+//!      client-credentials flow, bearer token cached in-process with ~80%+//!      of TTL as safety margin.+//!   2. Via a backend — the CLI hits `POST <api>/` and lets the backend hold+//!      the credentials + cache.++use serde::{Deserialize, Deserializer, Serialize};+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::{Duration, Instant};++const DIGIKEY_API_BASE: &str = "https://api.digikey.com/products/v4";+const TOKEN_URL: &str = "https://api.digikey.com/v1/oauth2/token";+const FETCH_TIMEOUT_SECS: u64 = 30;++// ── OAuth2 token cache ─────────────────────────────────────────────+struct TokenCache {+    token: Option<String>,+    expires_at: Option<Instant>,+}+static TOKEN_CACHE: Mutex<TokenCache> = Mutex::new(TokenCache { token: None, expires_at: None });++fn get_access_token(client_id: &str, client_secret: &str) -> Result<String, String> {+    if client_id.is_empty() || client_secret.is_empty() {+        return Err("DIGIKEY_CLIENT_ID and DIGIKEY_CLIENT_SECRET must be set".into());+    }+    if let Ok(cache) = TOKEN_CACHE.lock() {+        if let (Some(tok), Some(exp)) = (cache.token.as_ref(), cache.expires_at) {+            if Instant::now() < exp {+                return Ok(tok.clone());+            }+        }+    }+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))+        .build();+    let res = match agent+        .post(TOKEN_URL)+        .set("Content-Type", "application/x-www-form-urlencoded")+        .send_form(&[+            ("client_id", client_id),+            ("client_secret", client_secret),+            ("grant_type", "client_credentials"),+        ]) {+        Ok(r) => r,+        Err(ureq::Error::Status(code, r)) => {+            let body = r.into_string().unwrap_or_default();+            return Err(format!("OAuth token request failed: HTTP {}: {}", code, body));+        }+        Err(e) => return Err(format!("OAuth token request failed: {}", e)),+    };+    let data: Value = res.into_json().map_err(|e| format!("OAuth response parse: {}", e))?;+    let token = data.get("access_token").and_then(|v| v.as_str())+        .ok_or_else(|| format!("OAuth response missing access_token: {}", data))?.to_string();+    let ttl = data.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(600);+    let expires_at = Instant::now() + Duration::from_secs(ttl * 4 / 5);+    if let Ok(mut cache) = TOKEN_CACHE.lock() {+        cache.token = Some(token.clone());+        cache.expires_at = Some(expires_at);+    }+    Ok(token)+}++// ── Normalized types (same shape as adom-mouser — UI is compatible) ─+#[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 digikey_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>,+    /// Direct URL (usually via SamacSys / SnapEDA / Ultra Librarian+    /// redirect) to a STEP, IGES, or ECAD model for this part. Populated+    /// from `MediaLinks[]` in the DigiKey v4 response. `None` when the+    /// part has no CAD model on DigiKey's side.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub cad_3d_url: Option<String>,+    /// Best-effort file format guess from the URL or MediaType ("step",+    /// "iges", "zip" if bundled, or the raw MediaType when unclear).+    #[serde(skip_serializing_if = "Option::is_none")]+    pub cad_3d_format: 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 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, 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, got {:?}", other))),+    }+}++fn digikey_post(client_id: &str, client_secret: &str, endpoint: &str, body: Value) -> Result<Value, String> {+    let token = get_access_token(client_id, client_secret)?;+    let url = format!("{}/{}", DIGIKEY_API_BASE, endpoint);+    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)).build();+    let req = agent.post(&url)+        .set("Content-Type", "application/json")+        .set("Accept", "application/json")+        .set("Authorization", &format!("Bearer {}", token))+        .set("X-DIGIKEY-Client-Id", client_id)+        .set("X-DIGIKEY-Locale-Site", "US")+        .set("X-DIGIKEY-Locale-Language", "en")+        .set("X-DIGIKEY-Locale-Currency", "USD");+    let res = match req.send_json(body) {+        Ok(r) => r,+        Err(ureq::Error::Status(code, r)) => {+            let body = r.into_string().unwrap_or_default();+            return Err(format!("DigiKey API {}: {}", code, body));+        }+        Err(e) => return Err(format!("DigiKey API request failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("DigiKey API JSON parse failed: {}", e))+}++fn digikey_get(client_id: &str, client_secret: &str, endpoint: &str) -> Result<Value, String> {+    let token = get_access_token(client_id, client_secret)?;+    let url = format!("{}/{}", DIGIKEY_API_BASE, endpoint);+    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)).build();+    let req = agent.get(&url)+        .set("Content-Type", "application/json")+        .set("Accept", "application/json")+        .set("Authorization", &format!("Bearer {}", token))+        .set("X-DIGIKEY-Client-Id", client_id)+        .set("X-DIGIKEY-Locale-Site", "US")+        .set("X-DIGIKEY-Locale-Language", "en")+        .set("X-DIGIKEY-Locale-Currency", "USD");+    let res = match req.call() {+        Ok(r) => r,+        Err(ureq::Error::Status(code, r)) => {+            let body = r.into_string().unwrap_or_default();+            return Err(format!("DigiKey API {}: {}", code, body));+        }+        Err(e) => return Err(format!("DigiKey API request failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("DigiKey API JSON parse failed: {}", e))+}++fn normalize_part(p: &Value) -> Component {+    let stock = p.get("QuantityAvailable").and_then(|v| v.as_u64()).unwrap_or(0);+    let popularity = if stock < 1000 { "low" } else if stock < 10_000 { "medium" } else { "high" };++    let variation = p.pointer("/ProductVariations/0");+    let price_tiers: Vec<PriceTier> = variation+        .and_then(|v| v.get("StandardPricing"))+        .and_then(|v| v.as_array())+        .map(|arr| arr.iter().filter_map(|t| {+            let qty = t.get("BreakQuantity")?.as_u64()? as u32;+            let unit = t.get("UnitPrice")?.as_f64()?;+            Some(PriceTier { qty, price: format!("${:.4}", unit) })+        }).collect())+        .unwrap_or_default();++    let attributes: Vec<Attribute> = p.get("Parameters").and_then(|v| v.as_array())+        .map(|arr| arr.iter().filter_map(|param| {+            let name = param.get("ParameterText").or_else(|| param.get("Parameter"))+                .and_then(|v| v.as_str())?.to_string();+            let value = param.get("ValueText").or_else(|| param.get("Value"))+                .and_then(|v| v.as_str())?.to_string();+            Some(Attribute { name, value })+        }).collect())+        .unwrap_or_default();++    // MediaLinks[] on the v4 response carries datasheet + photo + 3D+    // model links. We scan for the first entry whose MediaType is a+    // known CAD-model label. DigiKey routes most CAD links through+    // SamacSys — the resolved URL usually requires the user to click+    // through a vendor-library auth flow, so we pass the URL straight+    // to the UI rather than trying to download it.+    let mut cad_3d_url: Option<String> = None;+    let mut cad_3d_format: Option<String> = None;+    if let Some(media) = p.get("MediaLinks").and_then(|v| v.as_array()) {+        for m in media {+            let mt = m.get("MediaType").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();+            let url = m.get("Url").and_then(|v| v.as_str()).unwrap_or("");+            if url.is_empty() { continue; }+            // Accept the common DigiKey MediaType values. "Design Files"+            // occasionally bundles schematic/footprint/3D together.+            if mt.contains("3d model") || mt == "3d" || mt.contains("cad") || mt == "design files" {+                // Guess format from URL suffix; fall back to label.+                let lower = url.to_lowercase();+                let fmt = if lower.contains(".step") || lower.contains(".stp") { "step" }+                    else if lower.contains(".iges") || lower.contains(".igs") { "iges" }+                    else if lower.contains(".stl") { "stl" }+                    else if lower.contains(".zip") { "zip" }+                    else { "step" }; // SamacSys almost always serves STEP+                cad_3d_url = Some(url.to_string());+                cad_3d_format = Some(fmt.to_string());+                break;+            }+        }+    }++    let s = |k: &str| p.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();+    let os = |ptr: &str| p.pointer(ptr).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());++    let digikey_pn = variation.and_then(|v| v.get("DigiKeyProductNumber"))+        .and_then(|v| v.as_str()).unwrap_or("").to_string();+    let min_order = variation.and_then(|v| v.get("MinimumOrderQuantity"))+        .and_then(|v| v.as_u64()).unwrap_or(1) as u32;+    let stock_text = if stock > 0 { format!("{} In Stock", format_with_commas(stock)) } else { "Out of Stock".to_string() };+    let lead_time = p.get("ManufacturerLeadWeeks").and_then(|v| v.as_u64())+        .map(|w| format!("{} weeks", w)).unwrap_or_default();+    let unit_price = p.get("UnitPrice").and_then(|v| v.as_f64()).map(|u| format!("${:.4}", u));++    Component {+        digikey_pn,+        mpn: s("ManufacturerProductNumber"),+        manufacturer: p.pointer("/Manufacturer/Name").and_then(|v| v.as_str()).unwrap_or("").to_string(),+        description: p.pointer("/Description/ProductDescription").and_then(|v| v.as_str()).unwrap_or("").to_string(),+        category: p.pointer("/Category/Name").and_then(|v| v.as_str()).unwrap_or("").to_string(),+        stock,+        stock_text,+        popularity: popularity.to_string(),+        lead_time,+        unit_price,+        price_tiers,+        min_order,+        mult: 1,+        rohs: p.pointer("/Classifications/RohsStatus").and_then(|v| v.as_str()).unwrap_or("").to_string(),+        lifecycle: p.pointer("/ProductStatus/Status").and_then(|v| v.as_str()).unwrap_or("").to_string(),+        datasheet_url: os("/DatasheetUrl"),+        product_url: os("/ProductUrl"),+        image_url: os("/PhotoUrl"),+        cad_3d_url,+        cad_3d_format,+        attributes,+    }+}++fn format_with_commas(n: u64) -> String {+    let s = n.to_string();+    let mut out = String::new();+    for (i, c) in s.chars().rev().enumerate() {+        if i > 0 && i % 3 == 0 { out.insert(0, ','); }+        out.insert(0, c);+    }+    out+}++pub fn search(client_id: &str, client_secret: &str, keyword: &str, limit: u32, in_stock_only: bool) -> Result<SearchResult, String> {+    if keyword.trim().is_empty() {+        return Err("keyword is required".into());+    }+    let mut body = json!({+        "Keywords": keyword.trim(),+        "Limit": limit.clamp(1, 50),+        "Offset": 0,+    });+    if in_stock_only {+        body["FilterOptionsRequest"] = json!({"SearchOptions": ["InStock"]});+    }+    let data = digikey_post(client_id, client_secret, "search/keyword", body)?;+    let products = data.get("Products").and_then(|v| v.as_array()).cloned().unwrap_or_default();+    let count = data.get("ProductsCount").and_then(|v| v.as_u64()).unwrap_or(0);+    Ok(SearchResult {+        components: products.iter().map(normalize_part).collect(),+        count,+        query: keyword.trim().to_string(),+        exact_match: None,+    })+}++pub fn part(client_id: &str, client_secret: &str, part_number: &str) -> Result<SearchResult, String> {+    if part_number.trim().is_empty() {+        return Err("partNumber is required".into());+    }+    let encoded = urlencoding_simple(part_number.trim());+    let endpoint = format!("search/{}/productdetails", encoded);+    match digikey_get(client_id, client_secret, &endpoint) {+        Ok(data) => {+            let product = data.get("Product");+            match product {+                Some(p) if !p.is_null() => Ok(SearchResult {+                    components: vec![normalize_part(p)],+                    count: 1,+                    query: part_number.trim().into(),+                    exact_match: Some(true),+                }),+                _ => Ok(SearchResult { components: vec![], count: 0, query: part_number.trim().into(), exact_match: Some(false) }),+            }+        }+        Err(e) if e.contains(" 404") => Ok(SearchResult {+            components: vec![], count: 0, query: part_number.trim().into(), exact_match: Some(false),+        }),+        Err(e) => Err(e),+    }+}++pub fn ping(client_id: &str, client_secret: &str) -> Result<u64, String> {+    let data = digikey_post(client_id, client_secret, "search/keyword",+        json!({"Keywords": "resistor", "Limit": 1, "Offset": 0}))?;+    Ok(data.get("ProductsCount").and_then(|v| v.as_u64()).unwrap_or(0))+}++fn urlencoding_simple(s: &str) -> String {+    let mut out = String::new();+    for c in s.chars() {+        match c {+            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),+            _ => {+                let mut buf = [0u8; 4];+                for b in c.encode_utf8(&mut buf).bytes() {+                    out.push_str(&format!("%{:02X}", b));+                }+            }+        }+    }+    out+}
src/install.rsadded+61
@@ -0,0 +1,61 @@+//! `adom-digikey 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-digikey");+    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-digikey-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-digikey");+    let mut cmd = crate::Cli::command();+    let mut buf: Vec<u8> = Vec::new();+    clap_complete::generate(Shell::Bash, &mut cmd, "adom-digikey", &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-digikey completions") {+        match fs::OpenOptions::new().append(true).open(&bashrc) {+            Ok(mut f) => {+                let _ = f.write_all(b"\n# adom-digikey completions\neval \"$(adom-digikey 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+205
@@ -0,0 +1,205 @@+//! adom-digikey: DigiKey Electronics component search.+//!+//! One binary, three modes:+//!   - `adom-digikey serve`   — backend HTTP proxy (port 8777). Replaces+//!                             the old gallia/services/digikey/server.js.+//!   - `adom-digikey search|part|health` — one-shot CLI verbs.+//!   - `adom-digikey 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 digikey;+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-digikey", version, about = "DigiKey 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 8777 (replacement for the old+    /// gallia/services/digikey/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 DigiKey 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 = "DIGIKEY_PORT", default_value_t = 8777)]+    pub port: u16,+}++#[derive(clap::Args)]+pub struct SearchArgs {+    /// Keyword, manufacturer part number, or DigiKey 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 $DIGIKEY_API or http://127.0.0.1:8777).+    #[arg(long, env = "DIGIKEY_API")]+    pub api: Option<String>,+}++#[derive(clap::Args)]+pub struct PartArgs {+    /// DigiKey PN (296-STM32F103RBT6-ND) or MPN (STM32F103RBT6).+    pub part_number: String,+    #[arg(long, env = "DIGIKEY_API")]+    pub api: Option<String>,+}++#[derive(clap::Args)]+pub struct HealthArgs {+    #[arg(long, env = "DIGIKEY_API")]+    pub api: Option<String>,+}++#[derive(clap::Args)]+pub struct AppArgs {+    /// Port the local app server listens on.+    #[arg(long, default_value_t = 8891)]+    pub port: u16,+    /// Backend URL for DigiKey API calls (defaults to $DIGIKEY_API or http://127.0.0.1:8777).+    #[arg(long, env = "DIGIKEY_API")]+    pub api: Option<String>,+    /// Hydrogen tab name.+    #[arg(long, default_value = "DigiKey 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-digikey", &mut std::io::stdout());+            Ok(())+        }+    };+    match result {+        Ok(()) => ExitCode::SUCCESS,+        Err(e) => {+            err(&e);+            ExitCode::FAILURE+        }+    }+}
src/service.rsadded+324
@@ -0,0 +1,324 @@+//! Backend HTTP proxy for the DigiKey API. Replaces+//! gallia/services/digikey/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: DIGIKEY_CLIENT_ID is REQUIRED.+//! Port: 8777 by default (configurable via --port / DIGIKEY_PORT).++use crate::digikey::{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>,+}++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(+    client_id: &str,+    client_secret: &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) {+        return serde_json::from_value(cached).map_err(|e| e.to_string());+    }+    let result = digikey::search(client_id, client_secret, keyword, limit, in_stock_only)?;+    let v = serde_json::to_value(&result).map_err(|e| e.to_string())?;+    cache_set(cache, cache_key, v);+    Ok(result)+}++fn do_part(+    client_id: &str,+    client_secret: &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 = digikey::part(client_id, client_secret, 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(+    client_id: &str,+    client_secret: &str,+    cache: &Arc<Mutex<HashMap<String, CacheEntry>>>,+    live: bool,+) -> (u16, Value) {+    let has_creds = !client_id.is_empty() && !client_secret.is_empty();+    let cache_size = cache.lock().map(|m| m.len()).unwrap_or(0);+    let mut hr = HealthResponse {+        ok: has_creds,+        version: env!("CARGO_PKG_VERSION"),+        cache_size,+        cache_max: CACHE_MAX,+        cache_ttl_ms: CACHE_TTL.as_millis() as u64,+        error: if has_creds { None } else { Some("DIGIKEY_CLIENT_ID / DIGIKEY_CLIENT_SECRET not set") },+        api_reachable: None,+        api_error: None,+        test_results: None,+    };+    if has_creds && live {+        match digikey::ping(client_id, client_secret) {+            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 client_id = std::env::var("DIGIKEY_CLIENT_ID").unwrap_or_default();+    let client_secret = std::env::var("DIGIKEY_CLIENT_SECRET").unwrap_or_default();+    if client_id.is_empty() || client_secret.is_empty() {+        warn("DIGIKEY_CLIENT_ID / DIGIKEY_CLIENT_SECRET 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-digikey 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 = !client_id.is_empty() && !client_secret.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 — DIGIKEY_CLIENT_ID / DIGIKEY_CLIENT_SECRET not set"+                        },+                    );+                let _ = request.respond(html_resp(html));+                continue;+            }+        }++        let result: (u16, Value) = match (&method, path) {+            (Method::Get, "/health") => health(&client_id, &client_secret, &cache, true),+            (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(&client_id, &client_secret, &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(&client_id, &client_secret, &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(&client_id, &client_secret, &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(&client_id, &client_secret, &cache, &pn) {+                            Ok(r) => (200, serde_json::to_value(&r).unwrap()),+                            Err(e) => (400, json!({"error": e})),+                        }+                    }+                    "health" => health(&client_id, &client_secret, &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>DigiKey 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>DigiKey Component Search Service</h1>+<p class="subtitle">Shared HTTP proxy for the DigiKey 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 DigiKey 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-digikey</code> CLI, then:</p>+<pre>adom-digikey search "USB-C connector"+adom-digikey part 296-STM32F103RBT6-ND+adom-digikey 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+63
@@ -0,0 +1,63 @@+#!/usr/bin/env bash+# adom-digikey cron smoke runner.+#+# Purpose: every ~1 h, run the full smoke suite against the live backend+# (the one this container IS serving), and alert via Kel on state+# transitions — not on every run.+#+# Alerting policy (mirrors adom-mouser's quota alert):+#   - PASS → PASS : silent+#   - PASS → FAIL : ALERT ("jlcpcb backend smoke test failing")+#   - FAIL → FAIL : silent (already alerted; don't spam)+#   - FAIL → PASS : ALERT ("jlcpcb backend recovered")+#+# State persisted to /tmp/adom-digikey-smoke-state. Resets on reboot+# (which is fine — first run after reboot assumes PASS-state and only+# alerts on transition).+#+# Install (on service-jlcpcb):+#   (crontab -l 2>/dev/null; echo '15 * * * * /home/adom/adom-digikey/tests/cron-smoke.sh >> /tmp/adom-digikey-smoke.log 2>&1') | crontab -++set -u+set -a; [ -f /etc/environment ] && . /etc/environment; set +a++REPO_DIR="${ADOM_DIGIKEY_REPO:-$HOME/adom-digikey}"+STATE_FILE="${ADOM_DIGIKEY_SMOKE_STATE:-/tmp/adom-digikey-smoke-state}"+BACKEND="${DIGIKEY_API:-http://127.0.0.1:${DIGIKEY_PORT:-8777}}"+WEBHOOK="${KEL_ALERT_WEBHOOK:-}"+HOSTNAME_SHORT="$(hostname -s 2>/dev/null || echo digikey-host)"++prev_status="pass"+[ -f "$STATE_FILE" ] && prev_status="$(cat "$STATE_FILE" 2>/dev/null || echo pass)"++log_out="$(bash "$REPO_DIR/tests/smoke.sh" "$BACKEND" 2>&1)"+status=$?+printf '[%s] status=%s\n%s\n' "$(date -Iseconds)" "$status" "$log_out"++new_status="pass"+[ "$status" -ne 0 ] && new_status="fail"++post_kel() {+  local text="$1"+  [ -z "$WEBHOOK" ] && return 0+  local body+  body="$(jq -n --arg t "$text" '{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+  # Extract summary + first 3 failing cases for the alert body.+  summary="$(printf '%s' "$log_out" | tail -25 | grep -E '^(FAIL|OK|FAIL.*passed)' | head -4)"+  post_kel "⚠️ *adom-digikey backend smoke FAILING on $HOSTNAME_SHORT.*+Backend: $BACKEND+$(printf '%s' "$summary")+Check /tmp/adom-digikey-smoke.log for the full run. This alert fires once per incident; no spam."+elif [ "$prev_status" = "fail" ] && [ "$new_status" = "pass" ]; then+  post_kel "✅ *adom-digikey backend smoke RECOVERED on $HOSTNAME_SHORT.*+Backend: $BACKEND+All cases passing again."+fi++echo "$new_status" > "$STATE_FILE"+exit "$status"
tests/smoke.shadded+115
@@ -0,0 +1,115 @@+#!/usr/bin/env bash+# adom-digikey smoke test — real-backend search assertions.+#+# Runs a fixed set of real-world keyword queries against the Node + SQLite+# backend and asserts each returns at least one component whose `mfr` field+# contains the expected substring. Catches regressions like the 2026-04 bug+# where handleSearch destructured `query` but every caller sent `keyword`,+# silently routing all searches to "top stocked parts" (capacitors).+#+# Usage:+#   tests/smoke.sh                                                    # hits deployed backend+#   tests/smoke.sh http://127.0.0.1:8777                              # local backend+#   DIGIKEY_API=http://127.0.0.1:8777 tests/smoke.sh                   # same via env+#+# Exit code: 0 iff all cases pass. Non-zero + red FAIL line otherwise.++set -u++BACKEND="${1:-${DIGIKEY_API:-https://digikey-gjuu8l5t69uh.adom.cloud}}"+BACKEND="${BACKEND%/}"++# ANSI only on a real terminal (adom-cli-design rule: no color escapes in+# captured stdout).+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++pass=0; fail=0; failures=()++run_case() {+  local label="$1" keyword="$2" expect_substr="$3" expect_min="${4:-1}"+  local body resp count match+  body=$(jq -n --arg k "$keyword" '{action:"search", keyword:$k, limit:5}')+  resp=$(curl -sS --max-time 10 -X POST "$BACKEND/" \+    -H 'content-type: application/json' -d "$body" 2>/dev/null)+  if [ -z "$resp" ]; then+    fail=$((fail+1)); failures+=("$label — backend unreachable or empty response")+    printf '%sFAIL%s  %-36s  %s\n' "$C_FAIL" "$C_RESET" "$label" "(no response)"+    return+  fi+  count=$(printf '%s' "$resp" | jq '.count // 0' 2>/dev/null)+  if [ -z "$expect_substr" ]; then+    # Just assert >= expect_min results, no substring check.+    if [ "$count" -ge "$expect_min" ]; then+      pass=$((pass+1))+      printf '%sPASS%s  %-36s  count=%s\n' "$C_OK" "$C_RESET" "$label" "$count"+    else+      fail=$((fail+1)); failures+=("$label — count=$count expected >=$expect_min")+      printf '%sFAIL%s  %-36s  count=%s (expected >=%s)\n' "$C_FAIL" "$C_RESET" "$label" "$count" "$expect_min"+    fi+    return+  fi+  match=$(printf '%s' "$resp" | jq --arg s "$expect_substr" \+    '[.components[]? | (.mpn // .mfr // "") + " " + (.description // "") | ascii_downcase | contains($s | ascii_downcase)] | any' 2>/dev/null)+  if [ "$match" = "true" ] && [ "$count" -ge "$expect_min" ]; then+    pass=$((pass+1))+    printf '%sPASS%s  %-36s  count=%s  mfr contains %s\n' "$C_OK" "$C_RESET" "$label" "$count" "$expect_substr"+  else+    fail=$((fail+1)); failures+=("$label — count=$count, mfr-contains-'$expect_substr'=$match")+    printf '%sFAIL%s  %-36s  count=%s  mfr contains %s? %s\n' \+      "$C_FAIL" "$C_RESET" "$label" "$count" "$expect_substr" "${match:-null}"+  fi+}++run_negative() {+  local label="$1" keyword="$2"+  local body resp count+  body=$(jq -n --arg k "$keyword" '{action:"search", keyword:$k, limit:5}')+  resp=$(curl -sS --max-time 10 -X POST "$BACKEND/" \+    -H 'content-type: application/json' -d "$body" 2>/dev/null)+  count=$(printf '%s' "$resp" | jq '.count // 0' 2>/dev/null)+  if [ "$count" = "0" ]; then+    pass=$((pass+1))+    printf '%sPASS%s  %-36s  count=0 (expected)\n' "$C_OK" "$C_RESET" "$label"+  else+    fail=$((fail+1)); failures+=("$label — nonsense keyword returned $count results (should be 0)")+    printf '%sFAIL%s  %-36s  count=%s (expected 0)\n' "$C_FAIL" "$C_RESET" "$label" "$count"+  fi+}++echo "${C_BOLD}adom-digikey smoke test${C_RESET}  ${C_DIM}backend: $BACKEND${C_RESET}"+echo "${C_DIM}────────────────────────────────────────────────────────${C_RESET}"++# Canonical MPNs that should always match.+run_case "esp32 (generic)"         "ESP32"              "esp32"       1+run_case "esp32-s3 (variant)"      "ESP32-S3"           "esp32-s3"    1+run_case "stm32f103 (arm mcu)"     "STM32F103"          "stm32f103"   1+run_case "lm358 (op-amp)"          "LM358"              "lm358"       1+run_case "ne555 (timer)"           "NE555"              "ne555"       1+run_case "tl072 (audio op-amp)"    "TL072"              "tl072"       1+run_case "atmega328 (arduino mcu)" "ATmega328"          "atmega328"   1+run_case "ams1117 (ldo)"           "AMS1117"            "ams1117"     1+run_case "ch340 (usb-uart)"        "CH340"              "ch340"       1+run_case "w25q (spi flash)"        "W25Q"               "w25q"        1++# DigiKey-specific MPN (LM1117-3.3 LDO).+run_case "lm1117-3.3 (ldo)"        "LM1117-3.3"         "lm1117"      1++# Broad keyword — at least one match.+run_case "100nF 0603 (keyword)"    "100nF 0603"         ""            1++# Nonsense query should return 0, not default fallback.+run_negative "bogus keyword"       "XYZZZZ999NOTAPART"++echo "${C_DIM}────────────────────────────────────────────────────────${C_RESET}"+if [ "$fail" -eq 0 ]; then+  echo "${C_OK}${C_BOLD}OK${C_RESET}  ${pass} passed, 0 failed"+  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
wiki/body.mdadded+59
@@ -0,0 +1,59 @@+# adom-digikey++Search DigiKey Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:++- **CLI verbs** — `adom-digikey search|part|health` return normalized JSON on stdout, ready for `| jq`.+- **Hydrogen webview app** — `adom-digikey app` opens a first-class "DigiKey Search" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and DigiKey PN, and a backend-health indicator.+- **Backend HTTP service** — `adom-digikey serve` runs an HTTP proxy (port 8777) with a 10-minute LRU cache. Keeps the DigiKey API key server-side; consumer containers point `DIGIKEY_API` at it.++## Install++```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-digikey/adom-digikey \+  -o /tmp/adom-digikey \+  && chmod +x /tmp/adom-digikey \+  && sudo install -m 0755 /tmp/adom-digikey /usr/local/bin/adom-digikey \+  && adom-digikey install+```++Installs the binary to `/usr/local/bin/adom-digikey`, deploys `~/.claude/skills/adom-digikey/SKILL.md` and `~/.claude/skills/adom-digikey-build/SKILL.md`, and registers bash completions.++For gallia containers, this is done automatically by `gallia/install.mjs` — adom-digikey is a **Tier A app** (every Adom user needs component search day-one).++## Use++```bash+# CLI+adom-digikey search "STM32F103RBT6" --limit 5+adom-digikey part 296-STM32F103RBT6-ND | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+adom-digikey health++# UI+adom-digikey app        # opens a Hydrogen webview tab++# Backend+DIGIKEY_CLIENT_ID=<key> adom-digikey serve    # runs on service-digikey container+```++## Architecture++```+  any user container             service-digikey (default-light)+  ──────────────────             ──────────────────────────────+  adom-digikey search       ─┐+  adom-digikey app          ─┼──▶ adom-digikey serve (port 8777)+  adom-digikey health       ─┘    + DIGIKEY_CLIENT_ID+                                 + 10-min LRU cache+                                 + self-heal cron watchdog+                                          │+                                          ▼+                                api.digikey.com / v2+```++The service container clones only `adom-inc/adom-digikey`; 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-digikey](https://github.com/adom-inc/adom-digikey) (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-digikey",+  "pitch": "Search DigiKey Electronics for components, pricing, stock, and datasheets — CLI + webview app + shared backend service.",+  "triggers": [+    "digikey",+    "digikey parts",+    "digikey pricing",+    "digikey search",+    "digikey part number",+    "component search",+    "part lookup",+    "price breaks",+    "datasheet digikey",+    "in stock digikey"+  ]+}
wiki/page.jsonadded+30
@@ -0,0 +1,30 @@+{+  "type": "app",+  "slug": "adom-digikey",+  "title": "DigiKey Electronics Search",+  "brief": "Search DigiKey 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-digikey",+    "version": "0.1.0",+    "releases": {+      "adom_docker": {+        "asset_name": "adom-digikey",+        "binary_url": "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-digikey/adom-digikey",+        "install_hint": "curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-digikey/adom-digikey -o /tmp/adom-digikey && chmod +x /tmp/adom-digikey && sudo install -m 0755 /tmp/adom-digikey /usr/local/bin/adom-digikey && adom-digikey install"+      }+    },+    "discovery_triggers": [+      "digikey",+      "digikey parts",+      "digikey pricing",+      "digikey search",+      "digikey part number",+      "component search",+      "part lookup",+      "price breaks",+      "datasheet digikey",+      "in stock digikey"+    ],+    "discovery_pitch": "Search DigiKey Electronics for components, pricing, stock, and datasheets — CLI + webview app + shared backend service."+  }+}