research-product
Research inverters or batteries from their makers' datasheets and add or update them in the solar-dimension product library through the token-protected API after the user confirms. Use for "/research-
SKILL.md.claude/skills/research-product/SKILL.md
GitHubResearch products into the library
Arguments: <what> [--target local|prod] [--replace] (default target local).
<what> is one of:
- Named models, e.g.
EG4 6000XP, Pytes V5: research exactly these. - A need, e.g.
48 V inverter, ≥ 6 kW, split-phase: search for current models that meet it, pick up to 4 whose maker publishes a datasheet, show the list, then research each. --from <file.json>: a saved search run. Take its products from the JSON'sinvertersandbatteries; in a spike--outfile they are in theoutputitem of typemessage, in itsoutput_textcontent, whosetextis a JSON string to parse. Use onlybrand,model,productUrlanddatasheetUrlas leads; never copy a run's specs or other keys across (itsproductUrlis not necessarily the maker's page). Research each product again from its datasheet.
Research with Claude Code's own web search and fetch. Never run scripts/product-search/spike.ts or any other paid AI API.
Setup
- Never
cat,Read,rgor otherwise print any.env*file:.env.localholds the token and the production database URL. Shell variables and functions don't survive between commands, so start every command that calls the API with this line. It reads the token, stops if the token or the address is missing, and definesapi: acurlthat sends the token and always ends with the HTTP status on a line of its own.
ForTOKEN=$(sed -n 's/^CATALOG_API_TOKEN=//p' .env.local | tr -d '"') && BASE=http://localhost:3000 && : "${TOKEN:?CATALOG_API_TOKEN is not in .env.local}" "${BASE:?CATALOG_API_URL is not in .env.local}" && api() { curl -sS -m 30 -w '\n%{http_code}\n' -H "Authorization: Bearer $TOKEN" "$@"; }--target prod, putBASE=$(sed -n 's/^CATALOG_API_URL=//p' .env.local | tr -d '"')in place ofBASE=http://localhost:3000. - Read the status before the body, every time:
000: no server answered on that target. Say so and stop. After the write in step 7 it means you don't know whether the write landed: say that, and run the dry run again to see.- No status at all, and the shell names a key that is not in
.env.local: tell the user which key and stop. Do not open the file to look. - A status this skill does not list is not a success. Before the write, stop and tell the user. After the write in step 7, treat it like
000.
- Never print, echo or log the token or any database URL. Pass the token only through
api. - Before the first API call, follow "Which database a target writes to" in
.claude/skills/research-appliance/SKILL.md. It applies to this skill as written: you ask the target itself which database it writes to, and it tells you what to say to the user, what every POST must carry and when to stop. Never look for the database in an env file or in the server's process.
Steps
- List the products to research (see
<what>). At most 50 per request, each product once. - Find each one's datasheet on the maker's site and fetch it. A PDF fetch saves the file and reports its path:
Readthat path withpagesand read the spec table itself. Take no number from a summary, a reseller page when the datasheet states it, or a similar model. No datasheet found, or it won't load or has no readable table: drop the product and say why. - Fill one item per product (shape below). Every spec is
{ value, source, asWritten }:value: a number in W, V, A, s, Ah, kWh, %, years, cycles or units;splitPhaseistrueorfalse;chemistryislifepo4,agmorother.source: the https URL you read it on.asWritten: the datasheet's own words for it, copied (at most 200 characters).- Not stated: all three
null. Never estimate, convert from a similar model or fill a typical value. surges: every surge or overload level, shortest first, each{ w, seconds, asWritten, source }. "12,000W for ≈3.5 s | 11,000W for ≈5 s" is two levels. "2× Pn, 5 s" getsw= 2 × the stated rated power. A level in VA only getsw: null; a level with no duration getsseconds: null(never 0). No level stated:surges: [].- Battery
nominalVas written (51.2), not the system class (48). bmsComms: the protocols the datasheet names (can,rs485). Compatible brands (at most 30): only those the maker lists.extraSpecs: up to 8 more lines an installer would check, as written (name≤ 60,value≤ 200 characters), each name once whatever its case ("Weight" and "weight" are one line; two are refused): transfer time, IP rating, operating temperature, dimensions, weight, the maker's depth of discharge.price:null, unless you saw it on a seller's page; then{ "amount": 1199, "currency": "USD", "seller": "…", "url": "https://…", "seenOn": "YYYY-MM-DD" }with today's date. Never search for prices.brand≤ 60 andmodel≤ 80 characters.
- Check for duplicates:
api -o <file> "$BASE/api/catalog/products"saves the answer to a file in the session scratchpad and prints only the status; on200, list them withjq '.items[] | {key, brand, model, retired}' <file>. If the product is there under another spelling, use the storedbrandandmodelexactly. Aretiredproduct stays retired after a write; say so. - Dry run: write the JSON array of items to a file in the session scratchpad, then
api -X POST -H "X-Expect-Database: <endpoint>" -H 'Content-Type: application/json' --data @<file> "$BASE/api/catalog/products?dryRun=true"(add&replace=truewith--replace).<endpoint>is the id from "Which database a target writes to". The only query parameters aredryRunandreplace, each exactlytrueorfalse(absent isfalse). It answers{ changes, warnings }. Each change haskey,status(new,updated,conflicts,unchanged) and:fills: fields that were null and get a value;replaced:{ field, stored, found }for each value overwritten. With--replaceit is any stated value; without it, onlyprice: a different price always replaces the stored one (a first price is afillsentry);cleared: fields whose stored conflicts this write settles;conflicts:{ field, stored, found }for each differing value kept aside;dropped:extraSpecs.<name>for each new extra line not stored because the product already holds 8. The status does not change.warningslists{ key, messages }for each product worth a second look: kWh more than 10% off volts × Ah, a surge or peak below continuous, nominal volts far from 12, 24, 36 or 48 V, efficiency under 50%, continuous under 100 W, a value whose digits are not in itsasWritten, a source on a host that is neither the maker page's nor the datasheet's, and "Possible duplicate of …" naming a stored product or another one in this request by the same maker with the same datasheet, the same maker page or a model starting with the same 6 characters.
- Show the user one table per product: its status and key; every stated spec with value,
asWrittenand the source's host; then fills, cleared, conflicts (stored → found), everyreplacedentry as stored → found (a changed price shows here even without--replace) and everydroppedline (not stored: the product already has 8 extra lines). Print every warning beside its product. A warning never blocks the write: recheck that value against the datasheet and fix it, or say why it stands. For a possible duplicate, compare it with the product named; if it is the same one, reuse the storedbrandandmodeland repeat from step 5. Flag: a VA-only or duration-less surge, no parallel limit, any stated value missingasWritten. Say where the write goes, in the table and in the question, in the words "Which database a target writes to" gives for the server's answer. - Wait for an explicit yes. Then send the same file with the same headers to
"$BASE/api/catalog/products"(or?replace=truewith--replace) and reportcreated,updated,unchangedandconflicts. An answer withchangesinstead means the dry run was sent again: nothing was written. A product can be inupdatedand inconflicts: it was filled or changed and also read a differing value. Conflicts are kept on the product for review.--replaceoverwrites them; re-sending the stored value with--replacekeeps it and clears that field's conflicts (cleared). Every product sent again, changed or not, has itslast_seen_atmoved. - On errors:
- 400: a query parameter other than
dryRunorreplace, one not exactlytrueorfalse, or one given twice (errornames it). Nothing was written; fix the URL and repeat from step 5. - 422: show the per-item issues (index and dotted path). Re-read the datasheet and fix the item; never change a value or its words just to pass a limit. Repeat from step 5. Two items with one key are refused as "the same product twice".
- 409: a stored product with that key no longer passes the schema (
keyslists them). Nothing was written; report it, the stored row needs fixing first. - 412: the server is not on the database you named. It did nothing, and the dry run and the yes are void: start again from "Which database a target writes to", then repeat steps 4 to 7.
- 428: the POST named no database. Send it again with the header, using the id in the answer you showed the user.
- 401: the token is not the one that server was started with (this skill reads
CATALOG_API_TOKENfrom.env.local). 404 on any route but/api/catalog/database: the API is disabled on that target (CATALOG_API_TOKENunset there). 429: too many requests with a wrong token from this address; wait ten minutes. 500: database error; report it and ask the user to retry.
- 400: a query parameter other than
Item shape
Inverter:
{ "kind": "inverter", "brand": "EG4 Electronics", "model": "6000XP",
"makerUrl": "https://…", "datasheetUrl": "https://….pdf", "price": null,
"extraSpecs": [{ "name": "Transfer time", "value": "<15ms @ Single / <30ms @ Parallel", "source": "https://….pdf" }],
"specs": {
"continuousW": { "value": 6000, "source": "https://….pdf", "asWritten": "NOMINAL POWER OUTPUT 6000W" },
"surges": [{ "w": 12000, "seconds": 3.5, "asWritten": "12,000W for ≈3.5 seconds", "source": "https://….pdf" }],
"batteryVolts": {…}, "splitPhase": {…}, "chargerA": {…}, "pvInputW": {…}, "pvVocMax": {…},
"maxParallelUnits": {…}, "idleW": {…}, "peakEfficiencyPct": {…}, "warrantyYears": {…},
"bmsComms": ["can", "rs485"], "compatibleBatteryBrands": [] } }
Battery: the same top level with "kind": "battery" and
"specs": { "chemistry": {…}, "nominalV": {…}, "ah": {…}, "kWh": {…}, "maxContinuousA": {…}, "peakA": {…},
"peakSeconds": {…}, "maxParallel": {…}, "cycleLife": {…}, "warrantyYears": {…},
"bmsComms": [], "compatibleInverterBrands": [] }
Every {…} is { "value", "source", "asWritten" }. All keys are required; unknown keys are refused.