skill
Publish a Hardware Component to the Adom Wiki
Public Made by Adomby adom
The one-stop-shop for publishing a hardware component (or molecule/board) to the Adom Wiki. The whole lifecycle: page anatomy, the clone→edit→preview→push loop and its hard rules, making a great hero image (Hero Component Studio — 16:10, overlay-safe, datasheet-backed), the animated 3D composite, the interactive 3D viewer, ct thumbnails, the datasheet-accuracy audit, and every wiki push/pull gotcha we've hit. Now includes the worked Path B (board) pipeline: STEP-to-GLB, refdes tagging, layer de-
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
#!/usr/bin/env python3
"""
publish_wiki_page.py — programmatic reference for publishing to the Adom Wiki.
WHY THIS FILE EXISTS
--------------------
So a new AI session (or teammate) does NOT have to re-figure-out how to publish a
wiki page on the fly. Read these functions top-to-bottom and you understand the whole
lifecycle: auth → locate → clone → edit → snapshot → (preview+approve) → push → verify,
plus publishing a brand-new page. Every non-obvious step has a comment explaining the
"why" and the gotcha that bit us the first time.
HOW IT WORKS
------------
The Adom Wiki has ONE CLI: `adom-wiki` (a Rust binary, every call prints JSON). This
module is a thin, documented wrapper around it — there is no separate REST SDK to learn;
`adom-wiki api <url>`-style access exists but the pillar verbs below are the supported
surface. Everything here shells out to `adom-wiki` and parses the JSON, so the behaviour
is identical to what you'd type by hand — this file just makes the sequence copy-pasteable
and encodes the rules from the SKILL (pull-before-edit, snapshot, real changelog, verify).
USAGE
-----
from publish_wiki_page import (
ensure_author, find_page_owner, clone_page, snapshot, push_files,
set_hero, verify_asset, publish_new_package,
)
# --- update an existing page ---
owner = find_page_owner("in-s42atr") # pages may live under aravk/, not adom/
work = clone_page(owner, "in-s42atr", "/tmp/edit-in-s42atr")
snap = snapshot(work) # revert insurance
# ... edit files under `work` ...
# ... render a PREVIEW and get EXPLICIT human approval (NOT done here) ...
push_files(owner, "in-s42atr", ["IN-S42ATR.html"],
"Fix brightness-vs-temp curve to match datasheet page 5")
verify_asset("in-s42atr", "IN-S42ATR.html") # confirm the blob is live
# --- publish a brand-new page/skill/component ---
publish_new_package("/path/to/pkg", org="adom", private=True,
changelog="Initial <slug> v0.1.0 — <what it is>")
This file is deliberately dependency-free (stdlib only) so it runs anywhere the
`adom-wiki` binary is on PATH.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
import urllib.error
TOKEN_PATH = os.path.expanduser("~/.config/adom-wiki/token") # set by `adom-wiki set-author`
WIKI_HOST = "https://wiki.adom.inc" # public path — verify assets here, not localhost
BLOB = WIKI_HOST + "/blob/component/{slug}/{file}" # the URL the page + 3D viewer fetch
# --------------------------------------------------------------------------- #
# Low-level: run the adom-wiki CLI and parse its JSON envelope.
# --------------------------------------------------------------------------- #
def wiki(*args: str, check: bool = True) -> dict | list | str:
"""Run `adom-wiki <args>` and return parsed JSON (or raw text if not JSON).
Every adom-wiki verb prints a JSON envelope to stdout. We parse it. On a
non-zero exit we raise with the stderr so failures are loud, not silent.
"""
proc = subprocess.run(["adom-wiki", *args], capture_output=True, text=True)
if check and proc.returncode != 0:
raise RuntimeError(f"adom-wiki {' '.join(args)} failed:\n{proc.stderr or proc.stdout}")
out = proc.stdout.strip()
try:
return json.loads(out)
except json.JSONDecodeError:
return out
# --------------------------------------------------------------------------- #
# Auth — WIKI_STRICT_AUTH is ON: real author identity + real changelog required.
# --------------------------------------------------------------------------- #
def whoami() -> dict:
"""Who will publishes be attributed to? (server-controlled, from the bearer token)."""
return wiki("whoami")
def ensure_author() -> None:
"""Make sure we publish under a real Adom identity, not the shared 'Developer' token.
The CLI hard-blocks publishing as the shared identity. `set-author` SSOs the
container's Adom identity into the wiki (writes a token to ~/.config/adom-wiki/).
Idempotent — safe to call every run.
"""
me = whoami()
user = (me or {}).get("user", {}) if isinstance(me, dict) else {}
if not user.get("email"):
wiki("set-author")
# --------------------------------------------------------------------------- #
# Locate — a page's OWNER is not always `adom`. Basic-parts live under `aravk/`.
# --------------------------------------------------------------------------- #
def page_exists(owner: str, slug: str) -> bool:
"""True if <owner>/<slug> resolves. A 404 here does NOT mean the page doesn't
exist — only that it doesn't exist UNDER THAT OWNER (see find_page_owner)."""
res = wiki("page", "stats", f"{owner}/{slug}", check=False)
if isinstance(res, dict):
return res.get("http_status") not in (404, "404") and "PAGE_NOT_FOUND" not in json.dumps(res)
return False
def find_page_owner(slug: str, candidates: list[str] | None = None) -> str | None:
"""Find which owner a component page lives under. GOTCHA: the basic-parts pages
are owned by `aravk`, not `adom` — a naive `adom/<slug>` check 404s. We also
fall back to full-text discovery to catch any other owner."""
for owner in (candidates or ["aravk", "adom"]):
if page_exists(owner, slug):
return owner
# last resort: search and read the owner off the first exact-slug hit
res = wiki("discover", "search", "--query", slug, "--limit", "10", check=False)
for hit in (res.get("results", []) if isinstance(res, dict) else []):
if hit.get("slug") == slug:
return hit.get("owner")
return None
# --------------------------------------------------------------------------- #
# Clone / snapshot — ALWAYS pull fresh before editing; ALWAYS snapshot before push.
# --------------------------------------------------------------------------- #
def clone_page(owner: str, slug: str, dest: str) -> str:
"""Pull the page's repo fresh into `dest`. RULE: do this immediately before you
edit — someone else may have pushed since your last pull, and a stale local copy
will clobber their work on push."""
if os.path.exists(dest):
shutil.rmtree(dest)
wiki("repo", "clone", f"{owner}/{slug}", "--dir", dest)
return dest
def snapshot(work_dir: str, stamp: str | None = None) -> str:
"""Copy the pristine pulled state to a timestamped folder so revert is a one-liner.
Pass `stamp` explicitly (Date.now-style calls are unavailable in some sandboxes)."""
stamp = stamp or str(int(time.time()))
snap = f"{work_dir.rstrip('/')}-snapshot-{stamp}"
shutil.copytree(work_dir, snap)
print(f"snapshot: {snap} (revert with: cp -r {snap}/* {work_dir}/ && push)")
return snap
# --------------------------------------------------------------------------- #
# Push — only the files you changed, only this page, with a REAL changelog.
# --------------------------------------------------------------------------- #
def push_files(owner: str, slug: str, files: list[str], changelog: str) -> dict:
"""Commit + push `files` (paths RELATIVE to the page repo root) to <owner>/<slug>.
RULES enforced by the server (WIKI_STRICT_AUTH):
- changelog must be >=10 chars, >=2 words, and NOT a placeholder ('update',
'release v1', ...). Make it describe the actual change (cite the datasheet
page for characterization edits).
- only the files you list are pushed. Never push files you didn't change and
never touch another page.
Run this from inside the page's working dir, or pass repo-relative paths.
"""
if len(changelog.split()) < 2 or len(changelog) < 10:
raise ValueError(f"changelog too weak (needs >=2 words, >=10 chars): {changelog!r}")
return wiki("repo", "push", "--files", *files, "-m", changelog, f"{owner}/{slug}")
def repo_log(owner: str, slug: str, limit: int = 5) -> dict:
"""Like `git log` for the page — confirm ONLY your commit landed after a push."""
return wiki("repo", "log", f"{owner}/{slug}", "--limit", str(limit))
# --------------------------------------------------------------------------- #
# Hero — the page card image. GOTCHA: this verb takes NO --changelog.
# --------------------------------------------------------------------------- #
def set_hero(owner: str, slug: str, image_rel_path: str) -> dict:
"""Set the page hero/billboard image (path relative to the page repo)."""
return wiki("page", "hero", f"{owner}/{slug}", "--image", image_rel_path)
# --------------------------------------------------------------------------- #
# Verify — fetch the served blob (the path the page + viewer actually fetch),
# NOT localhost. GOTCHA: private pages return 403 to UNAUTHENTICATED requests, so
# we send the same bearer token adom-wiki uses. Comparing the returned byte_count
# to the file you pushed is the real proof (a stable URL means a 200 alone doesn't
# tell you the *new* bytes are live — an in-place overwrite keeps the URL).
# --------------------------------------------------------------------------- #
def verify_asset(slug: str, filename: str, timeout: int = 15) -> tuple[int, int]:
"""GET the served blob (authenticated) and return (http_status, byte_count).
Cross-check byte_count against the local file's size. If this still 403s, the
push still landed — confirm with `repo_log(...)` that your commit is at HEAD."""
req = urllib.request.Request(BLOB.format(slug=slug, file=filename))
if os.path.exists(TOKEN_PATH):
with open(TOKEN_PATH) as fh:
req.add_header("Authorization", "Bearer " + fh.read().strip())
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, len(r.read())
except urllib.error.HTTPError as e:
return e.code, 0
# --------------------------------------------------------------------------- #
# New page — scaffold + publish a fresh page/skill/component/app.
# --------------------------------------------------------------------------- #
def init_package(slug: str, ptype: str = "component", title: str | None = None) -> dict:
"""Scaffold a new package dir (package.json + README + SKILL/skeleton) in ./<slug>/.
ptype ∈ {app, skill, component, bootstrap}."""
args = ["pkg", "init", slug, "--type", ptype]
if title:
args += ["--title", title]
return wiki(*args)
def lint_package(pkg_dir: str) -> dict:
"""Pre-publish linter. errors[] must be empty to publish (hero image is a common
blocker); warnings[] (e.g. no README screenshots) do not block."""
cwd = os.getcwd()
try:
os.chdir(pkg_dir)
return wiki("pkg", "lint")
finally:
os.chdir(cwd)
def publish_new_package(pkg_dir: str, org: str | None = None, private: bool = False,
changelog: str = "") -> dict:
"""Publish the package in `pkg_dir` (contains package.json). For an org-owned
private page: org='adom', private=True. Lints first and refuses on hard errors."""
lint = lint_package(pkg_dir)
if isinstance(lint, dict) and lint.get("errors"):
raise RuntimeError(f"lint errors block publish: {lint['errors']}")
args = ["pkg", "publish"]
if org:
args += ["--org", org]
args += ["--private"] if private else ["--public"]
if changelog:
args += ["-m", changelog]
cwd = os.getcwd()
try:
os.chdir(pkg_dir)
return wiki(*args)
finally:
os.chdir(cwd)
# --------------------------------------------------------------------------- #
# Worked flows — read these to see the full sequence. Guarded so importing or
# running with no args does nothing destructive.
# --------------------------------------------------------------------------- #
def _demo_update_existing(slug: str, changed_files: list[str], changelog: str) -> None:
"""The canonical 'edit an existing component page' flow. This function does the
SAFE steps automatically (auth, locate, clone, snapshot) and STOPS before push so
a human can preview + approve — publishing is never silent."""
ensure_author()
owner = find_page_owner(slug)
if not owner:
raise SystemExit(f"no page found for slug {slug!r}")
work = clone_page(owner, slug, f"/tmp/edit-{slug}")
snapshot(work)
print(f"\nPulled {owner}/{slug} into {work}.")
print("NEXT (do by hand): edit the files, render a PREVIEW, get EXPLICIT approval, then:")
print(f' push_files("{owner}", "{slug}", {changed_files!r}, "{changelog}")')
print(f' verify_asset("{slug}", "{changed_files[0]}")')
if __name__ == "__main__":
if len(sys.argv) >= 2 and sys.argv[1] == "whoami":
print(json.dumps(whoami(), indent=2))
elif len(sys.argv) >= 3 and sys.argv[1] == "owner":
print(find_page_owner(sys.argv[2]))
else:
print(__doc__)
print("Quick checks: python3 publish_wiki_page.py whoami")
print(" python3 publish_wiki_page.py owner <slug>")