app
Adom Concur
Public Made by Adomby adom
Cross-source consensus check for chip libraries — scores whether the KiCad, EAGLE, and Altium libraries AND the adom-ds2sf datasheet extraction all AGREE on pins, pads, names, and package, across seve
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use crate::compare::{self, Verdict};
use crate::io::{err, hint, note, ok, set_json_only};
use crate::parsers::{self, Part};
use crate::result::{self, Hint, ResultDoc, Status};
#[derive(Parser)]
#[command(name = "adom-concur", version, about = "Cross-source consensus check for chip libraries")]
pub struct Cli {
#[command(subcommand)]
pub command: Cmd,
}
#[derive(Subcommand)]
pub enum Cmd {
/// Compare ds2sf-extracted symbol+footprint against chip-fetcher's libraries.
Check {
chip_dir: PathBuf,
/// Override auto-detected ds2sf symbol JSON.
#[arg(long)]
ds2sf_symbol: Option<PathBuf>,
/// Override auto-detected ds2sf footprint JSON.
#[arg(long)]
ds2sf_footprint: Option<PathBuf>,
/// Override auto-detected `<MPN>.kicad_sym`.
#[arg(long)]
kicad_sym: Option<PathBuf>,
/// Override auto-detected `<MPN>.kicad_mod`.
#[arg(long)]
kicad_mod: Option<PathBuf>,
/// Override auto-detected `<MPN>.lbr` (Fusion 360 EAGLE).
#[arg(long)]
eagle: Option<PathBuf>,
/// Override auto-detected `<MPN>.PcbLib` (Altium binary footprint).
#[arg(long)]
altium: Option<PathBuf>,
/// Override the MPN (defaults to chip-dir basename).
#[arg(long)]
mpn: Option<String>,
/// Output directory for `<MPN>-concur.result.json`. Default: chip-dir.
#[arg(long)]
out_dir: Option<PathBuf>,
/// Suppress progress; only `OK:`/`ERROR:` lines.
#[arg(long)]
json_only: bool,
/// Bypass the cache and re-run.
#[arg(long)]
force: bool,
},
/// Print a one-screen summary of an existing concur.result.json.
Inspect { chip_dir: PathBuf },
/// Probe parser dependencies.
Health,
/// Drop SKILL.md to ~/.claude/skills/adom-concur/.
Install,
}
pub fn run(cli: Cli) -> i32 {
match cli.command {
Cmd::Check {
chip_dir,
ds2sf_symbol, ds2sf_footprint,
kicad_sym, kicad_mod, eagle, altium,
mpn, out_dir,
json_only, force,
} => {
set_json_only(json_only);
run_check(
chip_dir, ds2sf_symbol, ds2sf_footprint, kicad_sym, kicad_mod, eagle, altium,
mpn, out_dir, force,
)
}
Cmd::Inspect { chip_dir } => run_inspect(&chip_dir),
Cmd::Health => run_health(),
Cmd::Install => run_install(),
}
}
#[allow(clippy::too_many_arguments)]
fn run_check(
chip_dir: PathBuf,
ds2sf_symbol: Option<PathBuf>, ds2sf_footprint: Option<PathBuf>,
kicad_sym: Option<PathBuf>, kicad_mod: Option<PathBuf>, eagle: Option<PathBuf>,
altium: Option<PathBuf>,
mpn_override: Option<String>, out_dir_override: Option<PathBuf>,
force: bool,
) -> i32 {
let chip_dir = match chip_dir.canonicalize() {
Ok(p) => p,
Err(e) => { err(format!("chip dir: {e}")); return Status::Unrecoverable.exit_code(); }
};
let basename = chip_dir
.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string();
let mpn = mpn_override.unwrap_or(basename.clone());
let out_dir = out_dir_override.unwrap_or_else(|| chip_dir.clone());
let result_path = out_dir.join(format!("{mpn}-concur.result.json"));
if result_path.exists() && !force {
// Cache-hit: return the cached exit_code.
match std::fs::read_to_string(&result_path)
.ok()
.and_then(|raw| serde_json::from_str::<ResultDoc>(&raw).ok())
{
Some(d) => {
ok(format!(
"{} — cached concur result at {} (use --force to re-run)",
d.mpn, result_path.display()
));
return d.exit_code;
}
None => { /* fall through and re-run */ }
}
}
// Auto-detect inputs.
let ds_sym = ds2sf_symbol
.or_else(|| pick(&chip_dir, &format!("{mpn}-symbol.extracted.json")));
let ds_fp = ds2sf_footprint
.or_else(|| pick(&chip_dir, &format!("{mpn}-footprint.extracted.json")));
let ki_sym = kicad_sym
.or_else(|| pick(&chip_dir, &format!("{mpn}.kicad_sym")));
let ki_mod = kicad_mod
.or_else(|| pick(&chip_dir, &format!("{mpn}.kicad_mod")));
let lbr = eagle
.or_else(|| pick(&chip_dir, &format!("{mpn}.lbr")));
let altium_path = altium
.or_else(|| pick(&chip_dir, &format!("{mpn}.PcbLib")))
.or_else(|| pick(&chip_dir, &format!("{mpn}.IntLib")));
// Altium symbol library (pins) — paired with the .PcbLib footprint above.
let altium_schlib = pick(&chip_dir, &format!("{mpn}.SchLib"))
.or_else(|| pick(&chip_dir, &format!("{mpn}.IntLib")));
note(format!("concur check {mpn}: discovered sources →"));
let mut sources_compared: Vec<String> = Vec::new();
let mut sources_missing: Vec<String> = Vec::new();
let mut hints: Vec<Hint> = Vec::new();
let mut parts: Vec<Part> = Vec::new();
// ds2sf
if let Some(sym_p) = &ds_sym {
match parsers::ds2sf::parse(sym_p, ds_fp.as_deref()) {
Ok(p) => {
note(format!(" ds2sf — {} pins, {} pads, package {:?}",
p.pins.len(), p.pads.len(),
p.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));
parts.push(p);
sources_compared.push("ds2sf".into());
}
Err(e) => {
hints.push(Hint::SourceParseFailed {
format: "ds2sf".into(), path: sym_p.clone(), error: e.to_string(),
});
sources_missing.push("ds2sf".into());
}
}
} else {
sources_missing.push("ds2sf".into());
}
// KiCad
if ki_sym.is_some() || ki_mod.is_some() {
match parsers::kicad::parse(ki_sym.as_deref(), ki_mod.as_deref()) {
Ok(p) => {
note(format!(" kicad — {} pins, {} pads, footprint {:?}",
p.pins.len(), p.pads.len(),
p.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));
parts.push(p);
sources_compared.push("kicad".into());
}
Err(e) => {
let fp = ki_sym.clone().or(ki_mod.clone()).unwrap();
hints.push(Hint::SourceParseFailed { format: "kicad".into(), path: fp, error: e.to_string() });
sources_missing.push("kicad".into());
}
}
} else {
sources_missing.push("kicad".into());
}
// EAGLE
if let Some(p) = &lbr {
match parsers::eagle::parse(p) {
Ok(part) => {
note(format!(" eagle (.lbr) — {} pins, {} pads, package {:?}",
part.pins.len(), part.pads.len(),
part.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));
parts.push(part);
sources_compared.push("eagle".into());
}
Err(e) => {
hints.push(Hint::SourceParseFailed { format: "eagle".into(), path: p.clone(), error: e.to_string() });
sources_missing.push("eagle".into());
}
}
} else {
sources_missing.push("eagle".into());
}
// Altium: .PcbLib (binary OLE2) for pad designators + .SchLib for symbol
// pins (via altium-schlib), so Altium contributes to BOTH the footprint and
// symbol axes — full parity with KiCad / EAGLE / ds2sf.
//
// Multi-variant selection: many vendor PcbLibs ship multiple variants
// (e.g. DRV8316RRGFR's RGF0040E-IPC_A 41-pad standard + RGF0040E-MFG
// 57-pad manufacturing). We compute the pad-count consensus from the
// already-parsed sources (ds2sf / kicad / eagle) and pick the altium
// variant closest to it. Without this, altium silently disagrees on
// every chip with MFG variants because altium-pcblib picks the
// longest variant by stream order.
let consensus_pad_count = {
let counts: Vec<usize> = parts
.iter()
.filter(|p| !p.pads.is_empty())
.map(|p| p.pads.len())
.collect();
if counts.is_empty() {
None
} else {
// Median = robust to one outlier (an under-reading ds2sf
// doesn't drag the consensus). For 2 sources, the index
// arithmetic still picks one of them.
let mut sorted = counts.clone();
sorted.sort();
Some(sorted[sorted.len() / 2])
}
};
if let Some(p) = &altium_path {
match parsers::altium::parse_with_consensus_hint(p, consensus_pad_count, altium_schlib.as_deref()) {
Ok(part) => {
note(format!(" altium — {} pins, {} pads, footprint {:?}{}",
part.pins.len(), part.pads.len(),
part.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?"),
consensus_pad_count.map(|c| format!(" (consensus_hint={c})")).unwrap_or_default()));
parts.push(part);
sources_compared.push("altium".into());
}
Err(e) => {
hints.push(Hint::SourceParseFailed {
format: "altium".into(), path: p.clone(), error: e.to_string(),
});
sources_missing.push("altium".into());
}
}
} else {
sources_missing.push("altium".into());
}
if parts.len() < 2 {
let summary = format!(
"{} — only {} source(s) parseable; cross-source comparison needs ≥2",
mpn, parts.len()
);
err(&summary);
let doc = ResultDoc {
schema_version: "1".into(),
status: Status::Unrecoverable,
exit_code: Status::Unrecoverable.exit_code(),
mpn: mpn.clone(),
summary,
sources_compared,
sources_missing,
axes: vec![],
variants: vec![],
hints,
extractor_version: env!("CARGO_PKG_VERSION").into(),
};
let _ = result::write(&result_path, &doc);
return Status::Unrecoverable.exit_code();
}
let report = compare::compare(&parts);
// Determine which axes are explained (covered) by a variant. Any
// disagreement on a non-covered axis bumps status to Divergent.
let covered: std::collections::HashSet<&str> = report
.variants
.iter()
.flat_map(|v| v.covers().into_iter())
.collect();
// Lift uncovered disagreements into hints; stub-name disagreements (which
// are already handled via the StubSymbolPinNames variant) get a friendlier
// ul_symbol_has_stub_names hint *in addition* to the variant entry.
for axis in &report.axes {
if axis.verdict != Verdict::Disagree { continue; }
if covered.contains(axis.axis.as_str()) {
// Already explained by a variant — surface a stub-names hint when
// applicable (downstream needs to know which source to prefer for
// pin labels) but no Refetch hint.
for c in &axis.conflicts {
if c.contains("stub-only pin names") {
if let Some(src) = c.split_whitespace().next() {
let pin_count = parts.iter().find(|p| p.source == src)
.map(|p| p.pins.len() as u32).unwrap_or(0);
hints.push(Hint::UlSymbolHasStubNames {
source: src.to_string(),
pin_count,
suggestion: "Use ds2sf-extracted symbol JSON for downstream tools that need real pin labels.".into(),
chip_fetcher_action: "Do NOT refetch the .kicad_sym — UL stub-naming is a UL-bundle property, not a chip-fetcher choice. Instead, mark this chip dir's symbol source as 'ds2sf-preferred' so downstream sym_create authors a fresh .kicad_sym from ds2sf's JSON.".into(),
});
}
}
}
} else {
// Uncovered disagreement → real divergence. Build a rich Refetch
// hint that tells chip-fetcher specifically what to do next.
let format = hint_format_for_axis(&axis.axis);
let suggested_sources = suggest_refetch_sources(&format, &mpn);
let expected_resolution = format!(
"Re-run `concur check` after refetch; axis '{}' should change from 'disagree' to 'agree' (or be covered by a benign variant).",
axis.axis
);
// If ds2sf is the LONE disagreer on this axis (every other
// parsed source agrees against it), the suspect is ds2sf's
// extraction — not the chip-fetcher libs. Fire RerunDs2sfWith-
// StrongerModel instead of asking chip-fetcher to refetch a
// lib that's already correct. Real-world catch: DMG2305UX-7
// had ds2sf-Haiku swap the GDS↔GSD pin order while kicad+eagle
// both agreed on canonical GSD; v0.4 told chip-fetcher to
// refetch the (correct) lib, which would have looped forever.
let ds2sf_is_lone_disagreer =
is_lone_ds2sf_disagreer(&axis.values)
|| is_lone_ds2sf_in_conflicts(&axis.conflicts);
for c in &axis.conflicts {
if ds2sf_is_lone_disagreer {
hints.push(Hint::RerunDs2sfWithStrongerModel {
reason: format!(
"On axis '{}', ds2sf is the lone disagreer — every other parsed source agrees against it. Most likely a Haiku misread of the datasheet (typo, swapped pins, or fine-pitch label collision). Re-running with Opus typically resolves these.",
axis.axis
),
suspect_field: axis.axis.clone(),
suggested_model: "claude-opus-4-7".into(),
});
} else if axis.axis == "body_dims" && c.to_lowercase().contains("ds2sf") {
hints.push(Hint::RerunDs2sfWithStrongerModel {
reason: "ds2sf reported a body dim that's likely the lead-tip span rather than the body D-dim — a known Haiku misread on small SOIC/VSSOP packages.".into(),
suspect_field: "bodyDimensions.x or bodyDimensions.y".into(),
suggested_model: "claude-opus-4-7".into(),
});
} else {
hints.push(Hint::RefetchLibrarySource {
format: format.clone(),
suspect: axis.axis.clone(),
notes: c.clone(),
suggested_sources: suggested_sources.clone(),
expected_resolution: expected_resolution.clone(),
});
}
}
}
}
let any_uncovered_disagreement = report.axes.iter().any(|a| {
a.verdict == Verdict::Disagree && !covered.contains(a.axis.as_str())
});
let status = if any_uncovered_disagreement {
Status::Divergent
} else if !report.variants.is_empty() {
Status::GoldenWithVariants
} else if report.axes.iter().any(|a| a.verdict == Verdict::Agree) {
Status::Golden
} else {
// No agree, no disagree → all source-missing. Treat as unrecoverable.
Status::Unrecoverable
};
let summary = build_summary(&mpn, &report, &sources_compared, &sources_missing, status);
if status.is_acceptable() { ok(&summary); } else { crate::io::warn(&summary); }
let doc = ResultDoc {
schema_version: "1".into(),
status,
exit_code: status.exit_code(),
mpn,
summary,
sources_compared,
sources_missing,
axes: report.axes,
variants: report.variants,
hints,
extractor_version: env!("CARGO_PKG_VERSION").into(),
};
if let Err(e) = result::write(&result_path, &doc) {
err(format!("writing concur.result.json: {e}"));
}
status.exit_code()
}
/// True when ds2sf is the lone disagreer in a per-pin conflict list — every
/// conflict line has ds2sf on one side, and the OTHER sources don't conflict
/// with each other. Conflict format: `pin X: SRC1=V1 but SRC2=V2`.
///
/// Real-world catch (DMG2305UX-7, P-MOSFET): ds2sf-Haiku swapped pin 2/3
/// (D↔S) while kicad+eagle both agreed on canonical GSD ordering. The
/// per-source `values` map showed all sources reporting "3 named pins"
/// (summary count), so the values-based detector couldn't tell. This
/// function parses the conflict strings directly.
fn is_lone_ds2sf_in_conflicts(conflicts: &[String]) -> bool {
use std::collections::HashSet;
if conflicts.len() < 2 { return false; }
// For each conflict, extract the two source names mentioned.
// Pattern: "pin X: SRC1=V1 but SRC2=V2" — collect (SRC1, SRC2) pairs.
let mut pairs: Vec<(String, String)> = Vec::new();
for c in conflicts {
if let Some((s1, s2)) = parse_conflict_sources(c) {
pairs.push((s1, s2));
}
}
if pairs.len() < 2 { return false; }
// ds2sf must appear in every parsed pair.
let ds2sf_in_all = pairs.iter().all(|(a, b)| a == "ds2sf" || b == "ds2sf");
if !ds2sf_in_all { return false; }
// The non-ds2sf source set must NOT include ds2sf in any other-side slot.
// i.e., kicad and eagle never disagree with each other (otherwise ds2sf
// wouldn't be the *lone* disagreer).
let other_sources: HashSet<&str> = pairs
.iter()
.flat_map(|(a, b)| vec![a.as_str(), b.as_str()].into_iter())
.filter(|s| *s != "ds2sf")
.collect();
// At least one non-ds2sf source must exist.
!other_sources.is_empty()
}
/// Parse "pin X: SRC1=V1 but SRC2=V2" → Some(("SRC1", "SRC2")). The values
/// don't matter for lone-disagreer detection; only the source names.
fn parse_conflict_sources(c: &str) -> Option<(String, String)> {
// Find the ": " that separates the pin label from the body.
let body = c.split_once(": ").map(|(_, b)| b).unwrap_or(c);
// Split on " but " to get the two halves.
let (left, right) = body.split_once(" but ")?;
let s1 = left.split_once('=')?.0.trim().to_string();
let s2 = right.split_once('=')?.0.trim().to_string();
Some((s1, s2))
}
/// True when the only source whose value differs from the others is "ds2sf".
/// Implementation: group the per-source values by stringified value; the
/// disagreer set has exactly 1 element AND that element is ds2sf.
fn is_lone_ds2sf_disagreer(values: &std::collections::BTreeMap<String, String>) -> bool {
use std::collections::HashMap;
if values.len() < 3 { return false; } // need ≥3 sources to identify a "lone" disagreer
let mut buckets: HashMap<&String, Vec<&String>> = HashMap::new();
for (src, val) in values {
// Skip placeholder values like "(no symbol loaded)" — those aren't
// real disagreements, they're missing-source markers.
if val.starts_with('(') { continue; }
buckets.entry(val).or_default().push(src);
}
// The "majority" bucket has ≥2 sources; the "minority" bucket has 1
// source which must be ds2sf.
let mut majority: Option<&Vec<&String>> = None;
let mut minority: Option<&Vec<&String>> = None;
for v in buckets.values() {
if v.len() >= 2 { majority = Some(v); }
else if v.len() == 1 { minority = Some(v); }
}
match (majority, minority) {
(Some(_maj), Some(min)) => min.iter().any(|s| s.as_str() == "ds2sf"),
_ => false,
}
}
fn hint_format_for_axis(axis: &str) -> String {
match axis {
"package_family" | "pad_count" | "pad_numbers" | "body_dims" => "kicad_mod".into(),
"symbol_pin_count" | "pin_names" | "pin_to_pad_map" => "kicad_sym".into(),
_ => "unknown".into(),
}
}
/// Concrete next-source recommendations chip-fetcher should walk through to
/// resolve a divergence. Order = priority (manufacturer-direct first).
fn suggest_refetch_sources(format: &str, mpn: &str) -> Vec<String> {
let mut out = Vec::new();
match format {
"kicad_sym" | "kicad_mod" => {
out.push(format!("Manufacturer site: {} datasheet+CAD page (search the part number on the manufacturer's website; many manufacturers offer a manufacturer-direct ZIP that bypasses UL).", mpn));
out.push(format!("Ultra Librarian: re-fetch with a different package-suffix variant; the current UL bundle may be for a sibling part in the same family with a different pinout."));
out.push(format!("SnapMagic / SnapEDA: alternate library source; usually carries a different upstream than UL."));
out.push(format!("Mouser / DigiKey: some distributors host CAD downloads from a different upstream than UL."));
out.push(format!("Component Search Engine (componentsearchengine.com): yet another upstream — useful as a tiebreaker."));
}
_ => {
out.push("Inspect the chip-fetcher fetch log to identify which upstream source was used; refetch from a different one.".into());
}
}
out
}
fn build_summary(
mpn: &str,
report: &compare::ComparisonReport,
sources_compared: &[String],
sources_missing: &[String],
status: Status,
) -> String {
let agreeing = report.axes.iter().filter(|a| a.verdict == Verdict::Agree).count();
let disagreeing = report.axes.iter().filter(|a| a.verdict == Verdict::Disagree).count();
let missing = report.axes.iter().filter(|a| a.verdict == Verdict::SourceMissing).count();
let verdict = match status {
Status::Golden => "GOLDEN",
Status::GoldenWithVariants => "GOLDEN with variants",
Status::Divergent => "DIVERGENT",
Status::Unrecoverable => "UNRECOVERABLE",
};
let variants_str = if report.variants.is_empty() {
String::new()
} else {
let kinds: Vec<&str> = report
.variants
.iter()
.map(|v| match v {
crate::result::Variant::ExposedPadVariant { .. } => "ep",
crate::result::Variant::ModuleExtraPads { .. } => "module_extras",
crate::result::Variant::LabelOnlyMismatch { .. } => "label_only",
crate::result::Variant::StubSymbolPinNames { .. } => "stub_pin_names",
crate::result::Variant::BodyDimMeasurementArtifact { .. } => "body_dim_artifact",
crate::result::Variant::NamingConventionMismatch { .. } => "naming_convention",
crate::result::Variant::KelvinSenseRepresentation { .. } => "kelvin_sense",
})
.collect();
format!(" [variants: {}]", kinds.join(","))
};
format!(
"{mpn} — {verdict}{variants_str}. Sources: [{}]{}. Axes: {} agree, {} disagree, {} source-missing.",
sources_compared.join(","),
if sources_missing.is_empty() { String::new() } else { format!(" (missing: {})", sources_missing.join(",")) },
agreeing, disagreeing, missing,
)
}
fn pick(dir: &Path, basename: &str) -> Option<PathBuf> {
let p = dir.join(basename);
if p.exists() { Some(p) } else { None }
}
fn run_inspect(chip_dir: &Path) -> i32 {
let basename = chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let p = chip_dir.join(format!("{basename}-concur.result.json"));
if !p.exists() {
err(format!("no concur result at {}", p.display()));
hint(format!("run `concur check {}` first", chip_dir.display()));
return 1;
}
let raw = match std::fs::read_to_string(&p) { Ok(r) => r, Err(e) => { err(e); return 1; } };
let doc: ResultDoc = match serde_json::from_str(&raw) { Ok(d) => d, Err(e) => { err(e); return 1; } };
note(format!("MPN: {} — status: {:?}, exit_code: {}", doc.mpn, doc.status, doc.exit_code));
note(format!("Sources compared: {:?}", doc.sources_compared));
if !doc.sources_missing.is_empty() {
note(format!("Sources missing: {:?}", doc.sources_missing));
}
note("Axes:");
for a in &doc.axes {
note(format!(" {:>16} {:?}", a.axis, a.verdict));
for c in &a.conflicts {
note(format!(" ! {c}"));
}
}
if !doc.hints.is_empty() {
note("Hints:");
for h in &doc.hints {
note(format!(" - {}", serde_json::to_string(h).unwrap_or_default()));
}
}
0
}
fn run_health() -> i32 {
// No external deps for parsing — informational pass.
ok(format!("concur {} ready (parsers: ds2sf, kicad, eagle, altium)", env!("CARGO_PKG_VERSION")));
0
}
fn run_install() -> i32 {
let home = match std::env::var("HOME") { Ok(h) => h, Err(_) => { err("HOME not set"); return 1; } };
let dest_dir = std::path::PathBuf::from(&home).join(".claude/skills/adom-concur");
if let Err(e) = std::fs::create_dir_all(&dest_dir) { err(format!("creating skill dir: {e}")); return 1; }
let dest = dest_dir.join("SKILL.md");
// SAFETY NET — do not rely on for release propagation.
const SKILL_MD: &str = include_str!("../SKILL.md");
if let Err(e) = std::fs::write(&dest, SKILL_MD) { err(format!("writing SKILL.md: {e}")); return 1; }
ok(format!("installed SKILL.md → {}", dest.display()));
0
}