Files
claude-kicad-helper/kicad-query.mjs
mikael-lovqvists-claude-agent ec46df5d14 Refactor: separate data functions from rendering
Query functions now return plain data objects. Two renderers
(render_human, render_json) handle output formatting. A single
run_query dispatcher routes to the right data function by file
type and query name, then main() calls the appropriate renderer
based on --format. Eliminates duplicated *_json variants and
the OUTPUT_FORMAT global.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:29:29 +00:00

866 lines
28 KiB
JavaScript

#!/usr/bin/env node
// kicad-query.mjs — query KiCad files for Claude-readable summaries
// Usage: node kicad-query.mjs <file> <query> [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<node|string> } 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 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: data functions ────────────────────────────────────────────────
// 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 => !(first_str(find_child(s, 'lib_id')) ?? '').startsWith('power:'));
const power_syms = all_placed.filter(s => (first_str(find_child(s, 'lib_id')) ?? '').startsWith('power:'));
const libs = {};
for (const s of real) {
const lib = (first_str(find_child(s, 'lib_id')) ?? 'unknown').split(':')[0];
libs[lib] = (libs[lib] ?? 0) + 1;
}
return {
components : real.length,
power_symbols : power_syms.length,
net_labels : collect(root, 'net_label').length,
global_labels : collect(root, 'global_label').length,
wires : collect(root, 'wire').length,
libraries : Object.fromEntries(Object.entries(libs).sort()),
};
}
function sch_components(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));
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));
comps.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
return { components: comps };
}
function sch_component(root, ref) {
const placed = get_placed_symbols(root);
const all_units = (root?.items ?? []).filter(item =>
item?.tag === 'symbol' && find_child(item, 'lib_id') && get_prop(item, 'Reference') === ref
);
const s = placed.find(s => get_prop(s, 'Reference') === ref);
if (!s && all_units.length === 0) return null;
const target = s ?? all_units[0];
const at = find_child(target, 'at');
const props = {};
for (const unit of all_units) {
for (const item of (unit?.items ?? [])) {
if (item?.tag === 'property') {
const k = item.items?.[0], v = item.items?.[1];
if (typeof k === 'string' && typeof v === 'string' && !(k in props)) props[k] = v;
}
}
}
const pins = all_units.flatMap(u => find_children(u, 'pin')).map(pin => {
const net = find_child(pin, 'net');
const net_name = net ? (first_str(find_child(net, 'name')) ?? first_str(net)) : null;
return { num: first_str(pin), net: net_name };
});
return {
ref,
lib_id : first_str(find_child(target, 'lib_id')) ?? '',
position: { x: Number(at?.items?.[0] ?? 0), y: Number(at?.items?.[1] ?? 0) },
properties: props,
pins,
};
}
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 =>
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]);
return {
nets: [...all].sort().map(name => ({
name,
global: globals.has(name),
power : power.has(name),
})),
};
}
// ── PCB: data functions ──────────────────────────────────────────────────────
function pcb_summary(root) {
const real_nets = find_children(root, 'net').filter(n => first_str(n) !== '0');
return {
nets : real_nets.length,
footprints: collect(root, 'footprint').length,
segments : collect(root, 'segment').length,
vias : collect(root, 'via').length,
zones : collect(root, 'zone').length,
};
}
function pcb_nets(root) {
return {
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),
};
}
function pcb_net(root, name) {
const net_node = find_children(root, 'net').find(n => n.items?.[1] === name);
if (!net_node) return null;
const net_id = first_str(net_node);
const connected_pads = [];
for (const fp of collect(root, 'footprint')) {
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] ?? '?' });
}
}
}
connected_pads.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
return { name, id: Number(net_id), connected_pads };
}
function pcb_footprints(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 }));
return { footprints: fps };
}
function pcb_footprint(root, ref) {
const fps = collect(root, 'footprint').filter(fp => get_prop(fp, 'Reference') === ref);
if (fps.length === 0) return null;
const fp = fps[0];
const at = find_child(fp, 'at');
const pads = find_children(fp, 'pad').map(p => ({
num : p.items?.[0] ?? '?',
type : p.items?.[1] ?? '?',
net_name: find_child(p, 'net')?.items?.[1] ?? '',
}));
return {
ref,
lib : first_str(fp) ?? '',
layer : first_str(find_child(fp, 'layer')) ?? '',
position: { x: Number(at?.items?.[0] ?? 0), y: Number(at?.items?.[1] ?? 0) },
value : get_prop(fp, 'Value') ?? '?',
pads,
};
}
// ── 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_traces(root, ref, pad_num) {
const fps = collect(root, 'footprint').filter(fp => get_prop(fp, 'Reference') === ref);
if (fps.length === 0) return { error: `Footprint '${ref}' not found` };
const fp = fps[0];
const pads = find_children(fp, 'pad');
const pad = pad_num ? pads.find(p => p.items?.[0] === String(pad_num)) : pads[0];
if (!pad) return {
error : `Pad '${pad_num}' not found on '${ref}'`,
available_pads : pads.map(p => p.items?.[0]),
};
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] ?? null;
const pad_layers = (find_child(pad, 'layers')?.items ?? [])
.filter(i => typeof i === 'string' && i.endsWith('.Cu'));
if (!pad_layers.length) pad_layers.push('F.Cu');
if (!net_id || net_id === '0') return {
ref, pad: pad.items?.[0], position: { x: abs_x, y: abs_y }, connected: false,
};
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);
return {
ref,
pad : pad.items?.[0],
net : { id: Number(net_id), name: net_name },
position : { x: abs_x, y: abs_y },
pad_layers,
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 })),
};
}
// ── Generic: data functions ──────────────────────────────────────────────────
function generic_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);
return { tags: Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([tag, count]) => ({ tag, count })) };
}
function generic_raw(root, tag) {
return {
tag,
nodes: collect(root, tag).map(n => ({
tag : n.tag,
items: (n.items ?? []).map(item =>
typeof item === 'string' ? item : item?.items ? `(${item.tag} ...)` : String(item)
),
})),
};
}
function pro_summary(text) {
const proj = JSON.parse(text);
const meta = proj.metadata ?? {};
const board = proj.board ?? {};
return {
filename : meta.filename ?? null,
version : meta.version ?? null,
min_clearance: board.design_settings?.rules?.min_clearance ?? null,
sections : Object.keys(proj),
};
}
// ── Query dispatcher (returns data) ─────────────────────────────────────────
function run_query(ext, query, root_or_text, args) {
const filters = parse_filters(args);
if (ext === '.kicad_pro') {
switch (query) {
case 'summary': return pro_summary(root_or_text);
default: return { error: `Unknown query '${query}' for .kicad_pro`, available: ['summary'] };
}
}
const root = root_or_text; // parsed S-expression tree
switch (query) {
case 'tags': return generic_tags(root);
case 'raw' : return generic_raw(root, args[0]);
}
if (ext === '.kicad_sch') {
switch (query) {
case 'summary' : return sch_summary(root);
case 'components': return sch_components(root, filters);
case 'component' : return sch_component(root, args[0]) ?? { error: `Component '${args[0]}' not found` };
case 'nets' : return sch_nets(root);
default: return { error: `Unknown query '${query}' for .kicad_sch`, available: ['summary', 'components', 'component', 'nets', 'tags', 'raw'] };
}
}
if (ext === '.kicad_pcb') {
switch (query) {
case 'summary' : return pcb_summary(root);
case 'nets' : return pcb_nets(root);
case 'net' : return pcb_net(root, args[0]) ?? { error: `Net '${args[0]}' not found` };
case 'footprints': return pcb_footprints(root, filters);
case 'footprint' : return pcb_footprint(root, args[0]) ?? { error: `Footprint '${args[0]}' not found` };
case 'traces' : return pcb_traces(root, args[0], args[1]);
default: return { error: `Unknown query '${query}' for .kicad_pcb`, available: ['summary', 'nets', 'net', 'footprints', 'footprint', 'traces', 'tags', 'raw'] };
}
}
return { error: `Unsupported file type '${ext}'` };
}
// ── Human renderer ───────────────────────────────────────────────────────────
function render_human(ext, query, data) {
if (data?.error) { console.error(`Error: ${data.error}`); return; }
// generic
if (query === 'tags') {
console.log('=== All tags (by frequency) ===');
pad_table([['Tag', 'Count'], ['---', '-----'], ...data.tags.map(t => [t.tag, t.count])]);
return;
}
if (query === 'raw') {
console.log(`=== Raw nodes: ${data.tag} (${data.nodes.length}) ===`);
for (const n of data.nodes) console.log(` (${n.tag} ${n.items.join(' ')})`);
return;
}
// .kicad_pro
if (ext === '.kicad_pro') {
console.log('=== KiCad Project ===');
if (data.filename) console.log(`File : ${data.filename}`);
if (data.version) console.log(`Version : ${data.version}`);
if (data.min_clearance) console.log(`Min clearance: ${data.min_clearance}`);
console.log(`Sections : ${data.sections.join(', ')}`);
return;
}
// .kicad_sch
if (ext === '.kicad_sch') {
if (query === 'summary') {
console.log('=== Schematic Summary ===');
console.log(`Components : ${data.components}`);
console.log(`Power symbols: ${data.power_symbols}`);
console.log(`Net labels : ${data.net_labels}`);
console.log(`Global labels: ${data.global_labels}`);
console.log(`Wires : ${data.wires}`);
if (Object.keys(data.libraries).length) {
console.log('\nLibraries:');
for (const [lib, count] of Object.entries(data.libraries)) console.log(` ${lib}: ${count}`);
}
return;
}
if (query === 'components') {
const comps = data.components;
if (comps.length === 0) { console.log('No components match.'); return; }
const header = ['Reference', 'Value', 'Library:Symbol', 'Footprint'];
pad_table([header, header.map(h => '-'.repeat(h.length)),
...comps.map(c => [c.dnp ? `${c.ref}*` : c.ref, c.value, c.lib_id, c.footprint])]);
console.log(`\n${comps.length} component(s)${comps.some(c => c.dnp) ? ' (* = DNP)' : ''}`);
return;
}
if (query === 'component') {
console.log(`=== Component: ${data.ref} ===`);
console.log(`Library/Symbol : ${data.lib_id}`);
console.log(`Position : (${data.position.x}, ${data.position.y})`);
console.log('\nProperties:');
for (const [k, v] of Object.entries(data.properties)) if (v && v !== '~') console.log(` ${k}: ${v}`);
if (data.pins.length) {
console.log(`\nPins (${data.pins.length}):`);
for (const p of data.pins) console.log(` pin ${p.num}${p.net ? `${p.net}` : ''}`);
}
return;
}
if (query === 'nets') {
console.log(`=== Net Names (${data.nets.length} total) ===`);
for (const n of data.nets) {
const tags = [...(n.global ? ['global'] : []), ...(n.power ? ['power'] : [])];
console.log(` ${n.name}${tags.length ? ' [' + tags.join(', ') + ']' : ''}`);
}
return;
}
}
// .kicad_pcb
if (ext === '.kicad_pcb') {
if (query === 'summary') {
console.log('=== PCB Summary ===');
console.log(`Nets : ${data.nets}`);
console.log(`Footprints : ${data.footprints}`);
console.log(`Segments : ${data.segments}`);
console.log(`Vias : ${data.vias}`);
console.log(`Zones : ${data.zones}`);
return;
}
if (query === 'nets') {
if (data.nets.length === 0) { console.log('No nets found.'); return; }
console.log(`=== PCB Nets (${data.nets.length}) ===`);
pad_table([['ID', 'Net Name'], ['--', '--------'], ...data.nets.map(n => [n.id, n.name])]);
return;
}
if (query === 'net') {
console.log(`=== Net: ${data.name} (id=${data.id}) ===`);
if (data.connected_pads.length) {
console.log(`\nConnected pads (${data.connected_pads.length}):`);
for (const { ref, pad } of data.connected_pads) console.log(` ${ref} pad ${pad}`);
} else {
console.log('No pads connected.');
}
return;
}
if (query === 'footprints') {
const fps = data.footprints;
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)`);
return;
}
if (query === 'footprint') {
console.log(`=== Footprint: ${data.ref} ===`);
console.log(`Library : ${data.lib}`);
console.log(`Layer : ${data.layer}`);
console.log(`Position : (${data.position.x}, ${data.position.y})`);
console.log(`Value : ${data.value}`);
if (data.pads.length) {
console.log(`\nPads (${data.pads.length}):`);
pad_table([['Pad', 'Type', 'Net'], ['---', '----', '---'], ...data.pads.map(p => [p.num, p.type, p.net_name])]);
}
return;
}
if (query === 'traces') {
if (data.connected === false) { console.log(`${data.ref} pad ${data.pad} is unconnected.`); return; }
console.log(`=== Traces from ${data.ref} pad ${data.pad} ===`);
console.log(`Net : ${data.net.name} (id=${data.net.id})`);
console.log(`Position : (${data.position.x.toFixed(3)}, ${data.position.y.toFixed(3)})`);
console.log(`Pad layers : ${data.pad_layers.join(', ')}`);
if (data.traces.length === 0 && data.vias.length === 0) {
console.log('\nNo traces found connected to this pad.');
return;
}
const by_layer = {};
for (const seg of data.traces) (by_layer[seg.layer] ??= []).push(seg);
for (const [layer, segs] of Object.entries(by_layer).sort()) {
console.log(`\nLayer ${layer}${segs.length} segment(s):`);
pad_table([
[' From (x, y)', '', 'To (x, y)', '', 'Width'],
[' -----------', '', '---------', '', '-----'],
...segs.map(s => [
` (${s.start.x.toFixed(3)},`, `${s.start.y.toFixed(3)})`,
`→ (${s.end.x.toFixed(3)},`, `${s.end.y.toFixed(3)})`,
`${s.width}mm`,
]),
]);
}
if (data.vias.length) {
console.log(`\nVias (${data.vias.length}):`);
for (const v of data.vias) console.log(` (${v.position.x.toFixed(3)}, ${v.position.y.toFixed(3)}) ${v.layers.join(' ↔ ')}`);
}
return;
}
}
}
// ── JSON renderer ────────────────────────────────────────────────────────────
function render_json(data) {
console.log(JSON.stringify(data, null, 2));
}
// ── Entry point ──────────────────────────────────────────────────────────────
function usage() {
console.log(`Usage: node kicad-query.mjs [--format=json] <file> <query> [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 <ref> Details for one component
nets All net/label names
Queries for .kicad_pcb:
summary High-level statistics
nets List all nets with IDs
net <name> Show which pads connect to a net
footprints [filters] List footprints (filters: ref=R* value=10k)
footprint <ref> Details for one footprint
traces <ref> [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 <tag> Dump all nodes with a given tag`);
}
function main() {
let argv = process.argv.slice(2);
let format = 'human';
for (let i = argv.length - 1; i >= 0; i--) {
const m = argv[i].match(/^--format=(\w+)$/);
if (m) { 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();
const query = query_arg ?? 'summary';
// .kicad_pro stays as text (JSON); everything else gets parsed as S-expression
let root_or_text;
if (ext === '.kicad_pro') {
root_or_text = text;
} else {
try {
root_or_text = parse_sexp(text);
} catch (e) {
console.error(`Parse error: ${e.message}`);
process.exit(1);
}
}
const data = run_query(ext, query, root_or_text, rest_args);
if (format === 'json') {
render_json(data);
} else {
render_human(ext, query, data);
}
}
main();