commit f3fddfac4bce6b29565c3b340f1bb03fcb042c6e Author: mikael-lovqvists-claude-agent Date: Tue Mar 24 23:09:14 2026 +0000 Initial implementation of kicad-query helper Generic S-expression parser feeding into KiCad-specific queries. Supports .kicad_sch (summary, components, component, nets), .kicad_pcb (summary, nets, net, footprints, footprint, traces), and .kicad_pro (summary). The 'traces' command does BFS through segments and vias to show the physical copper path from a specific pad. All queries support --format=json for structured LLM output. Co-Authored-By: Claude Sonnet 4.6 diff --git a/kicad-query.mjs b/kicad-query.mjs new file mode 100644 index 0000000..83fd14a --- /dev/null +++ b/kicad-query.mjs @@ -0,0 +1,970 @@ +#!/usr/bin/env node +// kicad-query.mjs — query KiCad files for Claude-readable summaries +// Usage: node kicad-query.mjs [args...] + +import { readFileSync } from 'fs'; +import { extname } from 'path'; + +// ── S-expression tokenizer ────────────────────────────────────────────────── + +function tokenize(text) { + const tokens = []; + let i = 0; + const n = text.length; + + while (i < n) { + const ch = text[i]; + + // whitespace + if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') { + i++; + continue; + } + + // line comment + if (ch === ';') { + while (i < n && text[i] !== '\n') i++; + continue; + } + + if (ch === '(') { tokens.push('('); i++; continue; } + if (ch === ')') { tokens.push(')'); i++; continue; } + + // quoted string + if (ch === '"') { + let s = ''; + i++; // skip opening quote + while (i < n && text[i] !== '"') { + if (text[i] === '\\') { + i++; + const esc = text[i] ?? ''; + s += esc === 'n' ? '\n' : esc === 't' ? '\t' : esc; + } else { + s += text[i]; + } + i++; + } + i++; // skip closing quote + tokens.push({ s }); + continue; + } + + // atom (number, identifier, etc.) + let atom = ''; + while (i < n && text[i] !== '(' && text[i] !== ')' && text[i] !== '"' + && text[i] !== ' ' && text[i] !== '\t' && text[i] !== '\r' && text[i] !== '\n') { + atom += text[i++]; + } + if (atom) tokens.push(atom); + } + + return tokens; +} + +// ── S-expression parser ───────────────────────────────────────────────────── +// Returns { tag: string, items: Array } for lists, +// or a plain string for atoms/quoted strings. + +function parse_sexp(text) { + const tokens = tokenize(text); + let pos = 0; + + function next() { return tokens[pos++]; } + function peek() { return tokens[pos]; } + + function parse_expr() { + const tok = next(); + + if (tok === '(') { + // first item is the tag + const tag_tok = next(); + const tag = typeof tag_tok === 'string' ? tag_tok : (tag_tok?.s ?? ''); + const items = []; + while (pos < tokens.length && peek() !== ')') { + items.push(parse_expr()); + } + next(); // consume ')' + return { tag, items }; + } + + // atom or quoted string + return typeof tok === 'string' ? tok : (tok?.s ?? ''); + } + + return parse_expr(); +} + +// ── Tree helpers ──────────────────────────────────────────────────────────── + +function find_child(node, tag) { + if (!node?.items) return null; + return node.items.find(n => n?.tag === tag) ?? null; +} + +function find_children(node, tag) { + if (!node?.items) return []; + return node.items.filter(n => n?.tag === tag); +} + +// Get value of first atom/string child of a node +function first_str(node) { + if (!node?.items) return null; + for (const item of node.items) { + if (typeof item === 'string') return item; + } + return null; +} + +// Get a (property "Key" "Value") property from a symbol node +function get_prop(node, key) { + for (const item of (node?.items ?? [])) { + if (item?.tag === 'property') { + const [k, v] = item.items ?? []; + if (k === key) return typeof v === 'string' ? v : (v?.s ?? null); + } + } + return null; +} + +// Collect all nodes recursively matching tag +function collect(node, tag, acc = []) { + if (!node?.items) return acc; + if (node.tag === tag) acc.push(node); + for (const item of node.items) { + if (item?.items) collect(item, tag, acc); + } + return acc; +} + +// ── Output format ─────────────────────────────────────────────────────────── +// Set by --format=json flag; queries emit via output_*() helpers. + +let OUTPUT_FORMAT = 'human'; + +function output_json(data) { + console.log(JSON.stringify(data, null, 2)); +} + +// ── Output helpers ────────────────────────────────────────────────────────── + +function pad_table(rows, sep = ' ') { + if (rows.length === 0) return; + const widths = rows[0].map((_, ci) => + Math.max(...rows.map(r => String(r[ci] ?? '').length)) + ); + for (const row of rows) { + console.log(row.map((cell, ci) => + ci === row.length - 1 ? cell : String(cell ?? '').padEnd(widths[ci]) + ).join(sep)); + } +} + +function glob_match(pattern, str) { + if (!pattern) return true; + const re = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); + return re.test(str); +} + +// Parse "key=value" filter args +function parse_filters(args) { + const filters = {}; + for (const arg of args) { + const m = arg.match(/^(\w+)=(.*)$/); + if (m) filters[m[1].toLowerCase()] = m[2]; + } + return filters; +} + +// ── Schematic queries ─────────────────────────────────────────────────────── + +// Placed symbols (not lib_symbols definitions, not power-only, not unit>1 duplicates) +function get_placed_symbols(root) { + const lib_syms = find_child(root, 'lib_symbols'); + const lib_sym_set = new Set(lib_syms?.items?.map(n => n?.tag === 'symbol' ? first_str(n) : null).filter(Boolean)); + + // Top-level symbols that are not inside lib_symbols + const placed = []; + for (const item of (root?.items ?? [])) { + if (item?.tag !== 'symbol') continue; + // lib_id distinguishes placed symbols from sub-unit definitions + const lib_id_node = find_child(item, 'lib_id'); + if (!lib_id_node) continue; + placed.push(item); + } + + // Filter to unit 1 only (avoid duplicates for multi-unit ICs) + return placed.filter(s => { + const unit = find_child(s, 'unit'); + return !unit || first_str(unit) === '1'; + }); +} + +function sch_summary(root) { + const all_placed = get_placed_symbols(root); + const real = all_placed.filter(s => { + const lib_id = first_str(find_child(s, 'lib_id')) ?? ''; + return !lib_id.startsWith('power:'); + }); + const power_syms = all_placed.filter(s => { + const lib_id = first_str(find_child(s, 'lib_id')) ?? ''; + return lib_id.startsWith('power:'); + }); + + const net_labels = collect(root, 'net_label'); + const global_labels = collect(root, 'global_label'); + const wires = collect(root, 'wire'); + + console.log('=== Schematic Summary ==='); + console.log(`Components : ${real.length}`); + console.log(`Power nets : ${power_syms.length} power symbols`); + console.log(`Net labels : ${net_labels.length}`); + console.log(`Global labels: ${global_labels.length}`); + console.log(`Wires : ${wires.length}`); + + // Library breakdown + const libs = {}; + for (const s of real) { + const lib_id = first_str(find_child(s, 'lib_id')) ?? 'unknown'; + const lib = lib_id.split(':')[0]; + libs[lib] = (libs[lib] ?? 0) + 1; + } + if (Object.keys(libs).length) { + console.log('\nLibraries:'); + for (const [lib, count] of Object.entries(libs).sort()) { + console.log(` ${lib}: ${count}`); + } + } +} + +function sch_components(root, filters = {}) { + const placed = get_placed_symbols(root); + let comps = placed + .filter(s => { + const lib_id = first_str(find_child(s, 'lib_id')) ?? ''; + return !lib_id.startsWith('power:'); + }) + .map(s => ({ + ref : get_prop(s, 'Reference') ?? '?', + value : get_prop(s, 'Value') ?? '?', + footprint : get_prop(s, 'Footprint') ?? '', + lib_id : first_str(find_child(s, 'lib_id')) ?? '', + datasheet : get_prop(s, 'Datasheet') ?? '', + dnp : first_str(find_child(s, 'dnp')) === 'yes', + })); + + // Apply filters + if (filters.ref) comps = comps.filter(c => glob_match(filters.ref, c.ref)); + if (filters.value) comps = comps.filter(c => glob_match(filters.value, c.value)); + if (filters.lib) comps = comps.filter(c => glob_match(filters.lib, c.lib_id.split(':')[0])); + if (filters.footprint) comps = comps.filter(c => glob_match(filters.footprint, c.footprint)); + + // Sort by reference (alphanumeric) + comps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true })); + + if (comps.length === 0) { + console.log('No components match.'); + return; + } + + const header = ['Reference', 'Value', 'Library:Symbol', 'Footprint']; + const rows = [header, header.map(h => '-'.repeat(h.length))]; + for (const c of comps) { + rows.push([ + c.dnp ? `${c.ref}*` : c.ref, + c.value, + c.lib_id, + c.footprint, + ]); + } + pad_table(rows); + console.log(`\n${comps.length} component(s)${comps.some(c => c.dnp) ? ' (* = DNP)' : ''}`); +} + +function sch_component(root, ref) { + const placed = get_placed_symbols(root); + // Include all units for this ref + const all_placed_all_units = (root?.items ?? []).filter(item => { + if (item?.tag !== 'symbol') return false; + if (!find_child(item, 'lib_id')) return false; + return get_prop(item, 'Reference') === ref; + }); + + const s = placed.find(s => get_prop(s, 'Reference') === ref); + if (!s && all_placed_all_units.length === 0) { + console.log(`Component '${ref}' not found.`); + return; + } + + const target = s ?? all_placed_all_units[0]; + const lib_id = first_str(find_child(target, 'lib_id')) ?? ''; + const at = find_child(target, 'at'); + const pos = at?.items?.slice(0, 2).join(', ') ?? '?'; + + console.log(`=== Component: ${ref} ===`); + console.log(`Library/Symbol : ${lib_id}`); + console.log(`Position : (${pos})`); + + // Collect all properties from all units + const props = {}; + for (const unit_sym of all_placed_all_units) { + for (const item of (unit_sym?.items ?? [])) { + if (item?.tag === 'property') { + const k = item.items?.[0]; + const v = item.items?.[1]; + if (typeof k === 'string' && typeof v === 'string') { + if (!(k in props)) props[k] = v; + } + } + } + } + + console.log('\nProperties:'); + for (const [k, v] of Object.entries(props)) { + if (v && v !== '~') console.log(` ${k}: ${v}`); + } + + // Pins + const pins = all_placed_all_units.flatMap(u => find_children(u, 'pin')); + if (pins.length) { + console.log(`\nPins (${pins.length}):`); + for (const pin of pins) { + const num = first_str(pin); + const net = find_child(pin, 'net'); + const net_name = net ? first_str(find_child(net, 'name')) ?? first_str(net) : null; + console.log(` pin ${num}${net_name ? ` → ${net_name}` : ''}`); + } + } +} + +function sch_nets(root) { + const net_labels = collect(root, 'net_label'); + const global_labels = collect(root, 'global_label'); + const power_syms = (root?.items ?? []).filter(item => { + if (item?.tag !== 'symbol') return false; + const lib_id = first_str(find_child(item, 'lib_id')) ?? ''; + return lib_id.startsWith('power:'); + }); + + const locals = new Set(net_labels.map(n => first_str(n)).filter(Boolean)); + const globals = new Set(global_labels.map(n => first_str(n)).filter(Boolean)); + const power = new Set(power_syms.map(s => get_prop(s, 'Value')).filter(Boolean)); + + const all_nets = new Set([...locals, ...globals, ...power]); + const sorted = [...all_nets].sort((a, b) => a.localeCompare(b)); + + console.log(`=== Net Names (${sorted.length} total) ===`); + for (const name of sorted) { + const tags = []; + if (globals.has(name)) tags.push('global'); + if (power.has(name)) tags.push('power'); + console.log(` ${name}${tags.length ? ' [' + tags.join(', ') + ']' : ''}`); + } +} + +// ── PCB queries ───────────────────────────────────────────────────────────── + +function pcb_summary(root) { + const nets = find_children(root, 'net'); + const footprints = collect(root, 'footprint'); + const segments = collect(root, 'segment'); + const vias = collect(root, 'via'); + const zones = collect(root, 'zone'); + + // Exclude net 0 (unconnected sentinel) + const real_nets = nets.filter(n => first_str(n) !== '0'); + + console.log('=== PCB Summary ==='); + console.log(`Nets : ${real_nets.length}`); + console.log(`Footprints : ${footprints.length}`); + console.log(`Segments : ${segments.length}`); + console.log(`Vias : ${vias.length}`); + console.log(`Zones : ${zones.length}`); +} + +function pcb_nets(root) { + const nets = find_children(root, 'net') + .filter(n => first_str(n) !== '0') + .map(n => ({ id: first_str(n), name: n.items?.[1] ?? '' })) + .sort((a, b) => Number(a.id) - Number(b.id)); + + if (nets.length === 0) { console.log('No nets found.'); return; } + + console.log(`=== PCB Nets (${nets.length}) ===`); + pad_table([ + ['ID', 'Net Name'], + ['--', '--------'], + ...nets.map(n => [n.id, n.name]), + ]); +} + +function pcb_footprints(root, filters = {}) { + let fps = collect(root, 'footprint').map(fp => { + const ref = get_prop(fp, 'Reference') ?? '?'; + const value = get_prop(fp, 'Value') ?? '?'; + const lib = first_str(fp) ?? ''; + const layer = first_str(find_child(fp, 'layer')) ?? ''; + return { ref, value, lib, layer }; + }); + + if (filters.ref) fps = fps.filter(f => glob_match(filters.ref, f.ref)); + if (filters.value) fps = fps.filter(f => glob_match(filters.value, f.value)); + + fps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true })); + + if (fps.length === 0) { console.log('No footprints match.'); return; } + + pad_table([ + ['Reference', 'Value', 'Layer', 'Footprint'], + ['---------', '-----', '-----', '---------'], + ...fps.map(f => [f.ref, f.value, f.layer, f.lib]), + ]); + console.log(`\n${fps.length} footprint(s)`); +} + +function pcb_footprint(root, ref) { + const fps = collect(root, 'footprint').filter(fp => get_prop(fp, 'Reference') === ref); + if (fps.length === 0) { console.log(`Footprint '${ref}' not found.`); return; } + + const fp = fps[0]; + const lib = first_str(fp) ?? ''; + const layer = first_str(find_child(fp, 'layer')) ?? ''; + const at = find_child(fp, 'at'); + const pos = at?.items?.slice(0, 2).join(', ') ?? '?'; + + console.log(`=== Footprint: ${ref} ===`); + console.log(`Library : ${lib}`); + console.log(`Layer : ${layer}`); + console.log(`Position : (${pos})`); + console.log(`Value : ${get_prop(fp, 'Value') ?? '?'}`); + + // Pads and their nets + const pads = find_children(fp, 'pad'); + if (pads.length) { + console.log(`\nPads (${pads.length}):`); + pad_table([ + ['Pad', 'Type', 'Net'], + ['---', '----', '---'], + ...pads.map(p => { + const num = p.items?.[0] ?? '?'; + const type = p.items?.[1] ?? '?'; + const net = find_child(p, 'net'); + const net_name = net?.items?.[1] ?? ''; + return [num, type, net_name]; + }), + ]); + } +} + +// ── PCB net detail ────────────────────────────────────────────────────────── + +function pcb_net(root, name) { + // Find net id + const net_node = find_children(root, 'net').find(n => n.items?.[1] === name); + if (!net_node) { console.log(`Net '${name}' not found.`); return; } + + const net_id = first_str(net_node); + console.log(`=== Net: ${name} (id=${net_id}) ===`); + + // Pads on this net + const fps = collect(root, 'footprint'); + const connected_pads = []; + for (const fp of fps) { + const ref = get_prop(fp, 'Reference') ?? '?'; + for (const pad of find_children(fp, 'pad')) { + const pad_net = find_child(pad, 'net'); + if (pad_net && first_str(pad_net) === net_id) { + connected_pads.push({ ref, pad: pad.items?.[0] ?? '?' }); + } + } + } + + if (connected_pads.length) { + console.log(`\nConnected pads (${connected_pads.length}):`); + for (const { ref, pad } of connected_pads.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }))) { + console.log(` ${ref} pad ${pad}`); + } + } else { + console.log('No pads connected.'); + } +} + +// ── PCB geometry helpers ───────────────────────────────────────────────────── + +const COORD_TOL = 0.005; // mm tolerance for point equality + +function pt_eq(ax, ay, bx, by) { + return Math.abs(ax - bx) <= COORD_TOL && Math.abs(ay - by) <= COORD_TOL; +} + +// Rotate point (px, py) by angle degrees and translate by (ox, oy) +function rotate_translate(px, py, angle_deg, ox, oy) { + const rad = (angle_deg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return [ox + px * cos - py * sin, oy + px * sin + py * cos]; +} + +// Get absolute pad position given footprint node +function pad_abs_pos(fp_node, pad_node) { + const fp_at = find_child(fp_node, 'at'); + const fx = Number(fp_at?.items?.[0] ?? 0); + const fy = Number(fp_at?.items?.[1] ?? 0); + const fr = Number(fp_at?.items?.[2] ?? 0); // footprint rotation + + const pad_at = find_child(pad_node, 'at'); + const px = Number(pad_at?.items?.[0] ?? 0); + const py = Number(pad_at?.items?.[1] ?? 0); + + return rotate_translate(px, py, fr, fx, fy); +} + +// Collect all segments keyed by net id +function build_segment_index(root) { + const segs = collect(root, 'segment').map(seg => { + const s = find_child(seg, 'start'); + const e = find_child(seg, 'end'); + const net = find_child(seg, 'net'); + const lay = find_child(seg, 'layer'); + const w = find_child(seg, 'width'); + return { + sx : Number(s?.items?.[0] ?? 0), + sy : Number(s?.items?.[1] ?? 0), + ex : Number(e?.items?.[0] ?? 0), + ey : Number(e?.items?.[1] ?? 0), + net : first_str(net) ?? '', + layer: first_str(lay) ?? '', + width: Number(first_str(w) ?? 0), + }; + }); + + const vias = collect(root, 'via').map(v => { + const at = find_child(v, 'at'); + const net = find_child(v, 'net'); + const layers_node = find_child(v, 'layers'); + return { + x : Number(at?.items?.[0] ?? 0), + y : Number(at?.items?.[1] ?? 0), + net : first_str(net) ?? '', + layers: layers_node?.items?.filter(i => typeof i === 'string') ?? [], + }; + }); + + return { segs, vias }; +} + +// BFS from a set of seed {x, y, layer} points through segments and vias on net_id +function trace_from(seed_points, net_id, segs, vias) { + // frontier: Set of "x,y,layer" strings + const visited_points = new Set(); + const visited_segs = new Set(); + const visited_vias = new Set(); + const queue = [...seed_points]; + + for (const p of queue) { + visited_points.add(`${p.x.toFixed(4)},${p.y.toFixed(4)},${p.layer}`); + } + + const result_segs = []; + const result_vias = []; + + while (queue.length) { + const { x, y, layer } = queue.shift(); + + // Find segments starting or ending here on same layer + net + for (let i = 0; i < segs.length; i++) { + const seg = segs[i]; + if (seg.net !== net_id || seg.layer !== layer) continue; + if (visited_segs.has(i)) continue; + + let other_x, other_y; + if (pt_eq(seg.sx, seg.sy, x, y)) { + other_x = seg.ex; other_y = seg.ey; + } else if (pt_eq(seg.ex, seg.ey, x, y)) { + other_x = seg.sx; other_y = seg.sy; + } else { + continue; + } + + visited_segs.add(i); + result_segs.push(seg); + + const key = `${other_x.toFixed(4)},${other_y.toFixed(4)},${layer}`; + if (!visited_points.has(key)) { + visited_points.add(key); + queue.push({ x: other_x, y: other_y, layer }); + } + } + + // Find vias at this point that carry the same net + for (let i = 0; i < vias.length; i++) { + const via = vias[i]; + if (via.net !== net_id) continue; + if (!pt_eq(via.x, via.y, x, y)) continue; + if (visited_vias.has(i)) continue; + + visited_vias.add(i); + result_vias.push(via); + + // Expand to all layers the via connects + for (const vl of via.layers) { + const key = `${via.x.toFixed(4)},${via.y.toFixed(4)},${vl}`; + if (!visited_points.has(key)) { + visited_points.add(key); + queue.push({ x: via.x, y: via.y, layer: vl }); + } + } + } + } + + return { segs: result_segs, vias: result_vias }; +} + +function pcb_pin_traces(root, ref, pad_num) { + // Find the footprint + const fps = collect(root, 'footprint').filter(fp => get_prop(fp, 'Reference') === ref); + if (fps.length === 0) { console.log(`Footprint '${ref}' not found.`); return; } + const fp = fps[0]; + + // Find the pad + const pads = find_children(fp, 'pad'); + const pad = pad_num + ? pads.find(p => p.items?.[0] === String(pad_num)) + : pads[0]; + + if (!pad) { + console.log(`Pad '${pad_num}' not found on '${ref}'.`); + if (pads.length) console.log(`Available pads: ${pads.map(p => p.items?.[0]).join(', ')}`); + return; + } + + const [abs_x, abs_y] = pad_abs_pos(fp, pad); + const net_node = find_child(pad, 'net'); + const net_id = first_str(net_node) ?? ''; + const net_name = net_node?.items?.[1] ?? '(unconnected)'; + + // Pad layer(s) + const layers_node = find_child(pad, 'layers'); + const pad_layers = layers_node?.items?.filter(i => typeof i === 'string').filter(l => l.endsWith('.Cu')) ?? ['F.Cu']; + + console.log(`=== Traces from ${ref} pad ${pad.items?.[0]} ===`); + console.log(`Net : ${net_name} (id=${net_id})`); + console.log(`Position : (${abs_x.toFixed(3)}, ${abs_y.toFixed(3)})`); + console.log(`Pad layers : ${pad_layers.join(', ')}`); + + if (!net_id || net_id === '0') { console.log('Pad is unconnected.'); return; } + + const { segs, vias } = build_segment_index(root); + const seeds = pad_layers.map(l => ({ x: abs_x, y: abs_y, layer: l })); + const { segs: found_segs, vias: found_vias } = trace_from(seeds, net_id, segs, vias); + + // Group segments by layer + const by_layer = {}; + for (const seg of found_segs) { + (by_layer[seg.layer] ??= []).push(seg); + } + + if (found_segs.length === 0 && found_vias.length === 0) { + console.log('\nNo traces found connected to this pad.'); + return; + } + + for (const [layer, layer_segs] of Object.entries(by_layer).sort()) { + console.log(`\nLayer ${layer} — ${layer_segs.length} segment(s):`); + pad_table([ + [' From (x, y)', '', 'To (x, y)', '', 'Width'], + [' -----------', '', '---------', '', '-----'], + ...layer_segs.map(s => [ + ` (${s.sx.toFixed(3)},`, `${s.sy.toFixed(3)})`, + `→ (${s.ex.toFixed(3)},`, `${s.ey.toFixed(3)})`, + `${s.width}mm`, + ]), + ]); + } + + if (found_vias.length) { + console.log(`\nVias (${found_vias.length}):`); + for (const v of found_vias) { + console.log(` (${v.x.toFixed(3)}, ${v.y.toFixed(3)}) ${v.layers.join(' ↔ ')}`); + } + } + + if (OUTPUT_FORMAT === 'json') { + output_json({ + ref, + pad: pad.items?.[0], + net: { id: net_id, name: net_name }, + position: { x: abs_x, y: abs_y }, + traces: found_segs.map(s => ({ + layer: s.layer, + start: { x: s.sx, y: s.sy }, + end : { x: s.ex, y: s.ey }, + width: s.width, + })), + vias: found_vias.map(v => ({ + position: { x: v.x, y: v.y }, + layers : v.layers, + })), + }); + } +} + +// ── JSON output variants ───────────────────────────────────────────────────── + +function sch_components_json(root, filters = {}) { + const placed = get_placed_symbols(root); + let comps = placed + .filter(s => !(first_str(find_child(s, 'lib_id')) ?? '').startsWith('power:')) + .map(s => ({ + ref : get_prop(s, 'Reference') ?? '?', + value : get_prop(s, 'Value') ?? '?', + footprint: get_prop(s, 'Footprint') ?? '', + lib_id : first_str(find_child(s, 'lib_id')) ?? '', + datasheet: get_prop(s, 'Datasheet') ?? '', + dnp : first_str(find_child(s, 'dnp')) === 'yes', + })); + + if (filters.ref) comps = comps.filter(c => glob_match(filters.ref, c.ref)); + if (filters.value) comps = comps.filter(c => glob_match(filters.value, c.value)); + comps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true })); + output_json({ components: comps }); +} + +function sch_nets_json(root) { + const net_labels = collect(root, 'net_label'); + const global_labels = collect(root, 'global_label'); + const power_syms = (root?.items ?? []).filter(item => + item?.tag === 'symbol' && (first_str(find_child(item, 'lib_id')) ?? '').startsWith('power:') + ); + + const locals = new Set(net_labels.map(n => first_str(n)).filter(Boolean)); + const globals = new Set(global_labels.map(n => first_str(n)).filter(Boolean)); + const power = new Set(power_syms.map(s => get_prop(s, 'Value')).filter(Boolean)); + const all = new Set([...locals, ...globals, ...power]); + + output_json({ + nets: [...all].sort().map(name => ({ + name, + global: globals.has(name), + power : power.has(name), + })), + }); +} + +function pcb_nets_json(root) { + const nets = find_children(root, 'net') + .filter(n => first_str(n) !== '0') + .map(n => ({ id: Number(first_str(n)), name: n.items?.[1] ?? '' })) + .sort((a, b) => a.id - b.id); + output_json({ nets }); +} + +function pcb_footprints_json(root, filters = {}) { + let fps = collect(root, 'footprint').map(fp => ({ + ref : get_prop(fp, 'Reference') ?? '?', + value : get_prop(fp, 'Value') ?? '?', + lib : first_str(fp) ?? '', + layer : first_str(find_child(fp, 'layer')) ?? '', + })); + if (filters.ref) fps = fps.filter(f => glob_match(filters.ref, f.ref)); + if (filters.value) fps = fps.filter(f => glob_match(filters.value, f.value)); + fps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true })); + output_json({ footprints: fps }); +} + +// ── Generic / debug queries ───────────────────────────────────────────────── + +function query_tags(root) { + const counts = {}; + function walk(node) { + if (!node?.items) return; + counts[node.tag] = (counts[node.tag] ?? 0) + 1; + for (const item of node.items) walk(item); + } + walk(root); + const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]); + console.log('=== All tags (by frequency) ==='); + pad_table([ + ['Tag', 'Count'], + ['---', '-----'], + ...sorted.map(([tag, count]) => [tag, count]), + ]); +} + +function query_raw(root, tag) { + const nodes = collect(root, tag); + if (nodes.length === 0) { console.log(`No nodes with tag '${tag}'.`); return; } + console.log(`=== Raw nodes: ${tag} (${nodes.length}) ===`); + for (const n of nodes) { + // Print a compact one-line representation + const parts = (n.items ?? []).map(item => + typeof item === 'string' + ? item + : item?.items + ? `(${item.tag} ...)` + : String(item) + ); + console.log(` (${n.tag} ${parts.join(' ')})`); + } +} + +// ── Project file (.kicad_pro is JSON) ─────────────────────────────────────── + +function query_pro(text, query, args) { + const proj = JSON.parse(text); + + switch (query) { + case 'summary': + case undefined: { + const meta = proj.metadata ?? {}; + const board = proj.board ?? {}; + const sch = proj.schematic ?? {}; + console.log('=== KiCad Project ==='); + if (meta.filename) console.log(`File : ${meta.filename}`); + if (meta.version) console.log(`Version : ${meta.version}`); + const design_settings = board.design_settings ?? {}; + if (design_settings.rules?.min_clearance) { + console.log(`Min clearance: ${design_settings.rules.min_clearance}`); + } + console.log('\nRaw sections:', Object.keys(proj).join(', ')); + break; + } + default: + console.log('Project file is JSON. Available queries: summary'); + console.log('Raw JSON keys:', Object.keys(proj).join(', ')); + } +} + +// ── Dispatch ──────────────────────────────────────────────────────────────── + +function usage() { + console.log(`Usage: node kicad-query.mjs [--format=json] [args...] + +Options: + --format=json Emit structured JSON (for LLM consumption) + +Queries for .kicad_sch: + summary High-level statistics + components [filters] List placed components (filters: ref=U* value=10k lib=Device) + component Details for one component + nets All net/label names + +Queries for .kicad_pcb: + summary High-level statistics + nets List all nets with IDs + net Show which pads connect to a net + footprints [filters] List footprints (filters: ref=R* value=10k) + footprint Details for one footprint + traces [pad] Physical trace path from a pad (BFS through segments/vias) + +Queries for .kicad_pro: + summary Project overview + +Generic queries (any file): + tags All S-expression tags and their counts + raw Dump all nodes with a given tag`); +} + +function main() { + let argv = process.argv.slice(2); + + // Strip --format= option + for (let i = argv.length - 1; i >= 0; i--) { + const m = argv[i].match(/^--format=(\w+)$/); + if (m) { + OUTPUT_FORMAT = m[1]; + argv.splice(i, 1); + } + } + + const [file_arg, query_arg, ...rest_args] = argv; + + if (!file_arg || file_arg === '--help' || file_arg === '-h') { + usage(); + process.exit(0); + } + + let text; + try { + text = readFileSync(file_arg, 'utf8'); + } catch (e) { + console.error(`Error reading file: ${e.message}`); + process.exit(1); + } + + const ext = extname(file_arg).toLowerCase(); + + // .kicad_pro is JSON + if (ext === '.kicad_pro') { + query_pro(text, query_arg ?? 'summary', rest_args); + return; + } + + // All others are S-expressions + let root; + try { + root = parse_sexp(text); + } catch (e) { + console.error(`Parse error: ${e.message}`); + process.exit(1); + } + + const query = query_arg ?? 'summary'; + + // Generic queries work on any file + if (query === 'tags') { query_tags(root); return; } + if (query === 'raw') { query_raw(root, rest_args[0]); return; } + + // File-type specific + if (ext === '.kicad_sch') { + const filters = parse_filters(rest_args); + switch (query) { + case 'summary': sch_summary(root); break; + case 'components': + OUTPUT_FORMAT === 'json' + ? sch_components_json(root, filters) + : sch_components(root, filters); + break; + case 'component': sch_component(root, rest_args[0]); break; + case 'nets': + OUTPUT_FORMAT === 'json' + ? sch_nets_json(root) + : sch_nets(root); + break; + default: + console.error(`Unknown query '${query}' for .kicad_sch`); + usage(); + process.exit(1); + } + return; + } + + if (ext === '.kicad_pcb') { + const filters = parse_filters(rest_args); + switch (query) { + case 'summary': pcb_summary(root); break; + case 'nets': + OUTPUT_FORMAT === 'json' + ? pcb_nets_json(root) + : pcb_nets(root); + break; + case 'net': pcb_net(root, rest_args[0]); break; + case 'footprints': + OUTPUT_FORMAT === 'json' + ? pcb_footprints_json(root, filters) + : pcb_footprints(root, filters); + break; + case 'footprint': pcb_footprint(root, rest_args[0]); break; + case 'traces': pcb_pin_traces(root, rest_args[0], rest_args[1]); break; + default: + console.error(`Unknown query '${query}' for .kicad_pcb`); + usage(); + process.exit(1); + } + return; + } + + console.error(`Unknown file type '${ext}'. Supported: .kicad_sch, .kicad_pcb, .kicad_pro`); + process.exit(1); +} + +main();