app
service-kicad
Public Made by Adomby adom
Shared headless KiCad 10: DRC, ERC, SVG/Gerber/STEP export, Altium library conversion, symbol/footprint/3D-model lookup. Every Adom tool shells to this CLI instead of calling the HTTP API directly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
//! `service-kicad` CLI — every Adom tool shells to this binary instead of
//! calling the HTTP API directly. Base URL is baked at build time from
//! `$SERVICE_KICAD_URL` or `../service.json::url`; at runtime
//! `$SERVICE_KICAD_URL` (or the legacy `$KICAD_SERVICE_API`) overrides it,
//! which is how a self-hosted instance is addressed.
//!
//! See PLAN Part 29.4 for the canonical command surface.
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use reqwest::blocking::Client;
use std::{io::Write, path::PathBuf, time::Duration};
const PROD_URL: &str = env!("SERVICE_KICAD_URL");
#[derive(Parser)]
#[command(name = "service-kicad", version, about = "Shared headless KiCad CLI — see PLAN Part 29")]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// GET /health.
Health,
/// GET /version.
Version,
/// Print the effective service URL (verify env override).
Config,
/// PCB operations (body = `.kicad_pcb` file on disk).
#[command(subcommand)]
Pcb(PcbCmd),
/// Schematic operations (body = `.kicad_sch` file on disk).
#[command(subcommand)]
Sch(SchCmd),
/// Symbol library lookup.
#[command(subcommand)]
Sym(SymCmd),
/// Footprint library lookup.
#[command(subcommand)]
Fp(FpCmd),
/// Download a static model file from kicad-packages3d.
Model {
#[command(subcommand)]
cmd: ModelCmd,
},
/// Altium library upgrades (body = `.SchLib` / `.PcbLib` on disk).
#[command(subcommand)]
Altium(AltiumCmd),
/// Drop SKILL.md + bash/zsh completions into ~/.claude/skills/ +
/// ~/.bash_completion.d/ (tool-publisher pattern).
Install {
#[arg(long)] skill_only: bool,
#[arg(long)] completions_only: bool,
},
}
#[derive(Subcommand)]
enum PcbCmd {
/// POST /kicad/pcb/drc.
Drc {
file: PathBuf,
#[arg(long, default_value = "text")] format: String,
#[arg(long)] out: Option<PathBuf>,
},
/// POST /kicad/pcb/export/{svg,gerbers,step}.
#[command(subcommand)]
Export(PcbExportCmd),
}
#[derive(Subcommand)]
enum PcbExportCmd {
Svg {
file: PathBuf,
#[arg(long, default_value = "top")] side: String,
/// Comma-separated KiCad layer names (e.g. F.Cu,F.Mask,Edge.Cuts).
/// Overrides the side's default set; drops the drawing sheet.
#[arg(long)] layers: Option<String>,
#[arg(long)] out: Option<PathBuf>,
},
Gerbers { file: PathBuf, #[arg(long)] out: Option<PathBuf> },
Step { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String> },
// Gated per page issue #106: KiCad-native GLB bypasses the molecule
// pipeline (no anchoring, gold pins, normalization, footprint emit).
// Hidden from help and refused without the acknowledgment flag, so
// agents that reach for the old verb get routed to STEP + step2glb.
// Output GLBs are provenance-stamped server-side (asset.extras.adom).
#[command(hide = true)]
Glb { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String>, #[arg(long)] i_know_this_skips_the_pipeline: bool },
#[command(hide = true)]
GlbZup { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String>, #[arg(long)] i_know_this_skips_the_pipeline: bool },
}
#[derive(Subcommand)]
enum SchCmd {
/// POST /kicad/sch/erc.
Erc {
file: PathBuf,
#[arg(long, default_value = "text")] format: String,
#[arg(long)] out: Option<PathBuf>,
},
#[command(subcommand)]
Export(SchExportCmd),
}
#[derive(Subcommand)]
enum SchExportCmd {
Svg { file: PathBuf, #[arg(long)] out: Option<PathBuf> },
/// Hierarchical project → one SVG per sheet. DIR is the project
/// directory (.kicad_pro + all .kicad_sch); output is a directory.
SvgProject { dir: PathBuf, #[arg(long)] out: Option<PathBuf> },
Pdf { file: PathBuf, #[arg(long)] out: Option<PathBuf> },
Bom { file: PathBuf, #[arg(long)] out: Option<PathBuf> },
}
#[derive(Subcommand)]
enum SymCmd {
/// GET /sym/export/svg/:library/:name.
Svg { library: String, name: String, #[arg(long)] out: Option<PathBuf> },
/// GET /symbols/<library>.kicad_sym (raw fetch).
Fetch { library: String, #[arg(long)] out: Option<PathBuf> },
}
#[derive(Subcommand)]
enum FpCmd {
/// GET /fp/export/svg/:library/:name.
Svg { library: String, name: String, #[arg(long)] out: Option<PathBuf> },
/// GET /footprints/<library>.pretty/<name>.kicad_mod.
Fetch { library: String, name: String, #[arg(long)] out: Option<PathBuf> },
}
#[derive(Subcommand)]
enum ModelCmd {
/// GET /models/<path>.
Fetch { path: String, #[arg(long)] out: Option<PathBuf> },
}
#[derive(Subcommand)]
enum AltiumCmd {
/// POST /kicad/altium/sym — Altium `.SchLib` → KiCad `.kicad_sym`.
Sym { file: PathBuf, #[arg(long)] out: Option<PathBuf> },
/// POST /kicad/altium/fp — Altium `.PcbLib` → KiCad `.kicad_mod`
/// (single-part libraries; multi-part returns the first footprint).
Fp { file: PathBuf, #[arg(long)] out: Option<PathBuf> },
}
// ─── Transport ────────────────────────────────────────────────────────
/// Resolve the service URL. Order: $SERVICE_KICAD_URL env →
/// $KICAD_SERVICE_API env (legacy alias) → baked-in production URL.
/// Trailing slash trimmed. The env override is how a self-hosted
/// instance is addressed — same commands, your container.
fn base_url() -> String {
std::env::var("SERVICE_KICAD_URL")
.or_else(|_| std::env::var("KICAD_SERVICE_API"))
.unwrap_or_else(|_| PROD_URL.to_string())
.trim_end_matches('/')
.to_string()
}
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("reqwest client")
}
/// Exit 2 on unreachable service, 3 on auth failure, 4 on any other
/// service-side error. 1 is reserved for input validation.
fn http_error_exit_code(status: u16) -> i32 {
match status {
401 | 403 => 3,
_ => 4,
}
}
fn get_bytes(path: &str) -> Result<Vec<u8>> {
let url = format!("{}{}", base_url(), path);
let resp = http_client().get(&url).send().unwrap_or_else(|e| {
eprintln!("service-kicad: unreachable ({e})");
std::process::exit(2);
});
let status = resp.status();
if !status.is_success() {
eprintln!("service-kicad: {} — {}", status, resp.text().unwrap_or_default());
std::process::exit(http_error_exit_code(status.as_u16()));
}
Ok(resp.bytes().map(|b| b.to_vec()).unwrap_or_default())
}
fn flags_to_query_string(flags: &[String]) -> String {
if flags.is_empty() { return String::new(); }
let params: Vec<String> = flags.iter().map(|f| {
let encoded = f.replace('%', "%25").replace('&', "%26").replace('=', "%3D").replace(' ', "%20");
format!("F={encoded}")
}).collect();
format!("?{}", params.join("&"))
}
fn post_file_bytes(path: &str, file: &PathBuf) -> Result<Vec<u8>> {
let body = std::fs::read(file)
.with_context(|| format!("read {}", file.display()))?;
let url = format!("{}{}", base_url(), path);
let resp = http_client().post(&url).body(body).send().unwrap_or_else(|e| {
eprintln!("service-kicad: unreachable ({e})");
std::process::exit(2);
});
let status = resp.status();
if !status.is_success() {
eprintln!("service-kicad: {} — {}", status, resp.text().unwrap_or_default());
std::process::exit(http_error_exit_code(status.as_u16()));
}
Ok(resp.bytes().map(|b| b.to_vec()).unwrap_or_default())
}
#[allow(dead_code)]
fn _unused() -> Result<()> {
Ok(())
}
fn write_or_stdout(bytes: &[u8], out: Option<&PathBuf>) -> Result<()> {
match out {
Some(p) => {
std::fs::write(p, bytes).with_context(|| format!("write {}", p.display()))?;
eprintln!("→ {} ({} bytes)", p.display(), bytes.len());
}
None => {
std::io::stdout().write_all(bytes).context("stdout")?;
}
}
Ok(())
}
fn extract_tarball(bytes: &[u8], dest: &PathBuf) -> Result<()> {
std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
let tmp = tempfile::Builder::new().suffix(".tar").tempfile()?;
std::fs::write(tmp.path(), bytes)?;
let status = std::process::Command::new("tar")
.args(["-xf", tmp.path().to_str().unwrap_or_default(),
"-C", dest.to_str().unwrap_or_default()])
.status()
.context("tar")?;
if !status.success() { anyhow::bail!("tar exit {}", status.code().unwrap_or(-1)); }
Ok(())
}
fn print_json_pretty_or_write(bytes: &[u8], format: &str, out: Option<&PathBuf>) -> Result<()> {
if format == "json" || out.is_some() {
return write_or_stdout(bytes, out);
}
// Pretty text for humans. Fall back to raw JSON if it doesn't parse.
let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return write_or_stdout(bytes, None);
};
let errs = v.get("errors").and_then(|x| x.as_u64()).unwrap_or(0);
let warns = v.get("warnings").and_then(|x| x.as_u64())
.or_else(|| v.get("warning").and_then(|x| x.as_u64()))
.unwrap_or(0);
let total = v.get("count").and_then(|x| x.as_u64()).unwrap_or(errs + warns);
println!("errors: {errs} warnings: {warns} total: {total}");
if let Some(violations) = v.get("violations").and_then(|x| x.as_array()) {
for violation in violations.iter().take(20) {
let sev = violation.get("severity").and_then(|s| s.as_str()).unwrap_or("?");
let typ = violation.get("type").and_then(|s| s.as_str()).unwrap_or("?");
let msg = violation.get("description").or_else(|| violation.get("message")).and_then(|s| s.as_str()).unwrap_or("");
println!(" [{sev}] {typ}: {msg}");
}
if violations.len() > 20 { println!(" … {} more", violations.len() - 20); }
}
Ok(())
}
// ─── Commands ─────────────────────────────────────────────────────────
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Cmd::Health => {
let bytes = get_bytes("/health")?;
std::io::stdout().write_all(&bytes)?;
println!();
Ok(())
}
Cmd::Version => {
let bytes = get_bytes("/version")?;
std::io::stdout().write_all(&bytes)?;
println!();
Ok(())
}
Cmd::Config => {
let effective = base_url();
let prod = PROD_URL;
let source = if std::env::var("SERVICE_KICAD_URL").is_ok() {
"SERVICE_KICAD_URL env"
} else if std::env::var("KICAD_SERVICE_API").is_ok() {
"KICAD_SERVICE_API env (legacy alias)"
} else {
"compile-time baked-in URL"
};
println!("url : {effective}");
println!("source : {source}");
println!("prod bake : {prod}");
if source.starts_with("compile") {
println!("hint : export SERVICE_KICAD_URL=<url> to target a self-hosted instance");
}
Ok(())
}
Cmd::Pcb(PcbCmd::Drc { file, format, out }) => {
let bytes = post_file_bytes("/kicad/pcb/drc", &file)?;
print_json_pretty_or_write(&bytes, &format, out.as_ref())
}
Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Svg { file, side, layers, out })) => {
let mut path = format!("/kicad/pcb/export/svg?side={}", urlencode(&side));
if let Some(l) = layers {
path.push_str(&format!("&layers={}", urlencode(&l)));
}
let bytes = post_file_bytes(&path, &file)?;
write_or_stdout(&bytes, out.as_ref())
}
Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Gerbers { file, out })) => {
let bytes = post_file_bytes("/kicad/pcb/export/gerbers", &file)?;
match out {
Some(ref d) => extract_tarball(&bytes, d),
None => {
let d = PathBuf::from("gerbers");
extract_tarball(&bytes, &d)
}
}
}
Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Step { file, out, flags })) => {
let qs = flags_to_query_string(&flags);
let path = format!("/kicad/pcb/export/step{qs}");
write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())
}
Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Glb { file, out, flags, i_know_this_skips_the_pipeline })) => {
glb_gate(i_know_this_skips_the_pipeline);
let qs = flags_to_query_string(&flags);
let path = format!("/kicad/pcb/export/glb{qs}");
write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())
}
Cmd::Pcb(PcbCmd::Export(PcbExportCmd::GlbZup { file, out, flags, i_know_this_skips_the_pipeline })) => {
glb_gate(i_know_this_skips_the_pipeline);
let qs = flags_to_query_string(&flags);
let path = format!("/kicad/pcb/export/glb-zup{qs}");
write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())
}
Cmd::Sch(SchCmd::Erc { file, format, out }) => {
let bytes = post_file_bytes("/kicad/sch/erc", &file)?;
print_json_pretty_or_write(&bytes, &format, out.as_ref())
}
Cmd::Sch(SchCmd::Export(SchExportCmd::Svg { file, out })) =>
write_or_stdout(&post_file_bytes("/kicad/sch/export/svg", &file)?, out.as_ref()),
Cmd::Sch(SchCmd::Export(SchExportCmd::SvgProject { dir, out })) => {
if !dir.is_dir() {
anyhow::bail!("{} is not a directory — pass the project dir (.kicad_pro + sheets)", dir.display());
}
// Tar the project dir client-side; the service picks the root
// sheet (stem matching the .kicad_pro) and returns a tar of SVGs.
let tmp = tempfile::Builder::new().suffix(".tar").tempfile()?;
let status = std::process::Command::new("tar")
.args(["-cf", tmp.path().to_str().unwrap_or_default(),
"-C", dir.to_str().unwrap_or_default(), "."])
.status().context("tar")?;
if !status.success() { anyhow::bail!("tar exit {}", status.code().unwrap_or(-1)); }
let bytes = post_file_bytes("/kicad/sch/export/svg-project", &tmp.path().to_path_buf())?;
let dest = out.unwrap_or_else(|| PathBuf::from("sch-svg"));
extract_tarball(&bytes, &dest)?;
eprintln!("→ {}/ (per-sheet SVGs)", dest.display());
Ok(())
}
Cmd::Sch(SchCmd::Export(SchExportCmd::Pdf { file, out })) =>
write_or_stdout(&post_file_bytes("/kicad/sch/export/pdf", &file)?, out.as_ref()),
Cmd::Sch(SchCmd::Export(SchExportCmd::Bom { file, out })) =>
write_or_stdout(&post_file_bytes("/kicad/sch/export/bom", &file)?, out.as_ref()),
Cmd::Sym(SymCmd::Svg { library, name, out }) => {
let bytes = get_bytes(&format!("/sym/export/svg/{}/{}", urlencode(&library), urlencode(&name)))?;
write_or_stdout(&bytes, out.as_ref())
}
Cmd::Sym(SymCmd::Fetch { library, out }) => {
let bytes = get_bytes(&format!("/symbols/{}.kicad_sym", urlencode(&library)))?;
write_or_stdout(&bytes, out.as_ref())
}
Cmd::Fp(FpCmd::Svg { library, name, out }) => {
let bytes = get_bytes(&format!("/fp/export/svg/{}/{}", urlencode(&library), urlencode(&name)))?;
write_or_stdout(&bytes, out.as_ref())
}
Cmd::Fp(FpCmd::Fetch { library, name, out }) => {
let bytes = get_bytes(&format!("/footprints/{}.pretty/{}.kicad_mod", urlencode(&library), urlencode(&name)))?;
write_or_stdout(&bytes, out.as_ref())
}
Cmd::Model { cmd: ModelCmd::Fetch { path, out } } => {
let bytes = get_bytes(&format!("/models/{}", path.trim_start_matches('/')))?;
write_or_stdout(&bytes, out.as_ref())
}
Cmd::Altium(AltiumCmd::Sym { file, out }) => {
let bytes = post_file_bytes("/kicad/altium/sym", &file)?;
let dest = out.unwrap_or_else(|| file.with_extension("kicad_sym"));
write_or_stdout(&bytes, Some(&dest))
}
Cmd::Altium(AltiumCmd::Fp { file, out }) => {
let bytes = post_file_bytes("/kicad/altium/fp", &file)?;
let dest = out.unwrap_or_else(|| file.with_extension("kicad_mod"));
write_or_stdout(&bytes, Some(&dest))
}
Cmd::Install { skill_only, completions_only } => do_install(skill_only, completions_only),
}
}
/// Refuse KiCad-native GLB export unless explicitly acknowledged (page
/// issue #106). Acknowledged exports still come back provenance-stamped
/// by the service (`asset.extras.adom.provenance = "kicad-native-glb"`).
fn glb_gate(acknowledged: bool) {
if acknowledged {
eprintln!("WARNING: KiCad-native GLB skips the molecule pipeline; output is");
eprintln!("provenance-stamped (asset.extras.adom.provenance = \"kicad-native-glb\").");
return;
}
eprintln!("ERROR: `pcb export glb` is gated (page issue #106).");
eprintln!("KiCad-native GLB bypasses the molecule pipeline — no MP-marker");
eprintln!("anchoring, gold machine-pin material, meter normalization, or");
eprintln!("footprint emit. Export STEP here, then convert with step2glb:");
eprintln!("Hint: service-kicad pcb export step board.kicad_pcb --out board.step");
eprintln!("Hint: adom-step2glb board.step # molecule mode when the board has machine pins");
eprintln!("Hint: for a quick non-molecule preview only, re-run with --i-know-this-skips-the-pipeline");
std::process::exit(1);
}
/// Absolute-minimum URL encoder — just the three characters we actually
/// see in library/name path segments.
fn urlencode(s: &str) -> String {
s.replace('%', "%25")
.replace(' ', "%20")
.replace('/', "%2F")
}
const WIKI_BASE: &str = "https://wiki.adom.inc";
/// Fetch the canonical SKILL.md from the wiki page repo's raw-file
/// endpoint. No fallback bundled via include_str! — we never bake skill
/// prose into the binary because prose is edited far more often than the
/// binary ships (per adom-cli-design rule).
fn fetch_skill_from_wiki() -> Result<String> {
let url = format!("{}/api/pages/adom/service-kicad/files/SKILL.md", WIKI_BASE);
let text = http_client()
.get(&url)
.send()?
.error_for_status()?
.text()?;
if text.trim().is_empty() || !text.starts_with("---") {
return Err(anyhow!("wiki returned unexpected SKILL.md content from {url}"));
}
Ok(text)
}
fn do_install(skill_only: bool, completions_only: bool) -> Result<()> {
let home = std::env::var("HOME").map(PathBuf::from)
.map_err(|_| anyhow!("no $HOME"))?;
if !completions_only {
let skill_dir = home.join(".claude/skills/service-kicad");
std::fs::create_dir_all(&skill_dir)?;
let dst = skill_dir.join("SKILL.md");
match fetch_skill_from_wiki() {
Ok(skill) => {
std::fs::write(&dst, skill)?;
eprintln!("✓ skill fetched from wiki → {}", dst.display());
}
Err(e) if dst.exists() => {
// Gallia (Tier A path) or a prior install already wrote
// SKILL.md. A transient wiki outage isn't a reason to
// overwrite or blow up.
eprintln!("! wiki fetch failed ({e}) — keeping existing {}", dst.display());
}
Err(e) => {
return Err(anyhow!("could not fetch SKILL.md from wiki and no existing skill to keep: {e}"));
}
}
}
if !skill_only {
let comp_dir = home.join(".bash_completion.d");
std::fs::create_dir_all(&comp_dir)?;
let bash_comp = comp_dir.join("service-kicad");
std::fs::write(&bash_comp,
"# service-kicad bash completions\n\
_service_kicad() {\n\
\tlocal cur=\"${COMP_WORDS[COMP_CWORD]}\"\n\
\tCOMPREPLY=($(compgen -W \"health version config pcb sch sym fp model altium install\" -- \"$cur\"))\n\
}\n\
complete -F _service_kicad service-kicad\n")?;
eprintln!("✓ completions at {}", bash_comp.display());
}
Ok(())
}