Weekly read-only health check of a Shopify store — active products that are out of stock, discounts that expired or never end, storefront links that 404, and missing SEO metadata. Use when the user asks for a health check, a site check, broken link check, a weekly store checkup, or whether the store is healthy.
Installs into .claude/skills of the current project.
Are you the author of Store Health Check?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/storehand-store-health-check)
---
name: store-health-check
description: Weekly read-only health check of a Shopify store — active products that are out of stock, discounts that expired or never end, storefront links that 404, and missing SEO metadata. Use when the user asks for a health check, a site check, broken link check, a weekly store checkup, or whether the store is healthy.
---
# Store health check
One weekly, read-only sweep. Four checks, one report, and a small memory so the
report can say "new" and "open since". Writes nothing to the store, ever.
Network: your own storefront (route 3 in shared/safety.md)
**Two kinds of path, do not mix them up.** Files belonging to this plugin —
`shared/*.md`, the `queries/` and `scripts/` directories — live under the
plugin's install directory. The store profile (`.storehand/`) lives in the
user's working directory.
## Step 0 — Find the plugin
`$CLAUDE_PLUGIN_ROOT` is empty in the environment of a Bash tool call. That is
measured, not assumed; the working routes and the reasoning are in
`shared/plugin-root.md`. Run this once, before anything else:
```bash
ROOT=$( {
printf '%s\n' "${CLAUDE_PLUGIN_ROOT:-}"
printf '%s' "$PATH" | tr ':' '\n' | sed -n 's|/bin$||p' | grep -E '/storehand/[^/]+$'
node -e 'const fs=require("fs"),os=require("os"),p=require("path");try{const j=JSON.parse(fs.readFileSync(p.join(os.homedir(),".claude","plugins","installed_plugins.json"),"utf8"));for(const[k,v]of Object.entries(j.plugins||{}))if(k.split("@")[0]==="storehand"&&v[0]&&v[0].installPath){console.log(v[0].installPath);break}}catch{}' 2>/dev/null
} | while IFS= read -r c; do
[ -n "$c" ] && [ -f "$c/shared/api-version.md" ] && { printf '%s' "$c"; break; }
done )
[ -n "$ROOT" ] && echo "StoreHand plugin root: $ROOT" || echo "StoreHand plugin root NOT FOUND"
```
Shell state does not survive between tool calls, so there is nothing to export
into: take the path it printed and **substitute it literally** wherever these
instructions write `$CLAUDE_PLUGIN_ROOT`. Never guess it.
Printed `NOT FOUND`? Stop, and tell the user to reinstall the plugin with
`/plugin marketplace add storehand/storehand`.
Read `$CLAUDE_PLUGIN_ROOT/shared/safety.md` and
`$CLAUDE_PLUGIN_ROOT/shared/store-profile.md` before you start.
## Step 1 — Load the profile and the memory
Read `.storehand/store.yaml`: you need `store`. No `.storehand/`? Point the
user at the `storehand-setup` skill and stop.
Read `.storehand/state.json` and keep the **whole** object in hand — other
skills store their keys in this file and they must survive your write in
Step 6. Under `healthCheck` you may find `lastRunAt` and `findings`
(a list of `{ "id", "firstSeenAt" }`).
- **File absent** → this is the first health check: say so in the report and
skip the "new"/"open since" labels.
- **File present but unparseable** → say so explicitly in the report, run the
checks anyway, but do **not** write `state.json` at the end — overwriting a
file you could not parse would destroy another skill's marker along with
your own.
- **File present but no `healthCheck` key** → first health check for this
skill: say so and skip the "new"/"open since" labels.
## Step 2 — Run the queries
Check `$CLAUDE_PLUGIN_ROOT/shared/api-version.md`; if it names a pinned
version, pass `--version <handle>` on every call. All calls are read-only —
**never add `--allow-mutations`**. Write variables to files, never inline
(quoting damage is silent):
```bash
V="$(mktemp -d)"
```
Shell state does not survive between tool calls. Either set `V` again in every
call below, or pick one fixed scratch path and reuse it — an unset `V` turns
`"$V/products.json"` into `/products.json`, which fails or writes to the wrong
place without ever mentioning `V`.
```bash
printf '%s' '{"query":"status:active","first":100}' > "$V/products.json"
shopify store execute --store <store> --json \
--query-file "$CLAUDE_PLUGIN_ROOT/skills/store-health-check/queries/active-products-inventory.graphql" \
--variable-file "$V/products.json"
```
```bash
printf '%s' '{"first":100}' > "$V/collections.json"
shopify store execute --store <store> --json \
--query-file "$CLAUDE_PLUGIN_ROOT/skills/store-health-check/queries/collections-health.graphql" \
--variable-file "$V/collections.json"
```
```bash
printf '%s' '{"first":50}' > "$V/discounts.json"
shopify store execute --store <store> --json \
--query-file "$CLAUDE_PLUGIN_ROOT/skills/store-health-check/queries/discounts.graphql" \
--variable-file "$V/discounts.json"
```
```bash
printf '%s' '{"first":10}' > "$V/menus.json"
shopify store execute --store <store> --json \
--query-file "$CLAUDE_PLUGIN_ROOT/skills/store-health-check/queries/menus-and-domain.graphql" \
--variable-file "$V/menus.json"
```
Read each command's output before moving on.
Progress lines with terminal escape codes go to stderr — and so does the
CLI's error box when a call fails, while stdout stays empty. `2>/dev/null`
therefore hides errors, not just noise: only silence stderr once a command
has proven to work. Check the exit code, and treat empty stdout as a failed
call, never as a quiet result.
The last two need scopes a store
connected before health checks existed will not have (`read_discounts`,
`read_online_store_navigation`). An ACCESS_DENIED there does **not** kill the
run: mark that check "not measured", keep going, and put the exact re-auth
line from the Errors table in the report.
If any `pageInfo.hasNextPage` is true, say the list was truncated and that the
real numbers are higher. Do not paginate.
## Step 3 — Derive the findings
Re-check everything; filters narrow, they do not guarantee.
- **Sold out but active** — products where `status` is `ACTIVE`,
`tracksInventory` is true and `totalInventory <= 0`. Exclude products where
every returned variant has `inventoryPolicy: CONTINUE` — they sell on
backorder by design, not a stockout. When some but not all variants
continue selling, keep the finding but phrase it as a question in the
report ("intentional backorder, or should it be hidden?"). When
`variants.pageInfo.hasNextPage` is true, keep the finding rather than
excluding it — a hidden `DENY` variant may be the stockout.
Finding id: `oos:<handle>`.
- **Discounts** (skip entirely if not measured) — the clock decides whether to
look, `status` only decides what to call it. A discount counts as *pending*
when its status is `ACTIVE` or `SCHEDULED`:
- pending and `endsAt` null → `promo:<title>:no-end` (informational). This
covers `SCHEDULED` too: a discount with no end date is the same open-ended
promo whether it is running already or starts next week;
- `endsAt` within the past 7 days → `promo:<title>:just-expired`
("was that intentional?"), whatever the status says;
- pending and `endsAt` within the next 7 days → `promo:<title>:ends-soon`.
For a `SCHEDULED` one, name both dates: it opens and closes inside the
window between two weekly runs, so this is the only run that will mention it;
- `status` and the dates contradict each other → `promo:<title>:status-mismatch`.
Three shapes count: `EXPIRED` while `endsAt` is still in the future,
`SCHEDULED` while `startsAt` is already past, and `ACTIVE` while `startsAt`
is in the future. Report the contradiction itself rather than picking a
side — from outside, there is no way to tell which half is wrong, and both
readings are worth a look. Quote both values: "Shopify says EXPIRED, the
end date is 3 days away." A plausible cause for the first shape is a
discount that hit its usage limit early, but **that is untested** — say
what you see, not what you suspect.
On a store with more discounts than the query fetches, the just-expired
finding is a lower bound — an old untouched discount can fall outside the
window; say so when `hasNextPage` was true. A discount naturally moves
`ends-soon` → `just-expired` between runs; when the old id disappears
exactly as the new one appears, say "expired as announced" rather than
calling it new.
- **Metadata gaps** — counts plus at most three example handles each:
products with `seo.description` null or empty
(`meta:products-no-seo-description`), collections where **both** the body
`description` and `seo.description` are empty
(`meta:collections-no-description`). For images: products with
`featuredMedia` null are a separate count and a worse problem — no image at
all (`meta:images-missing`); only products with a non-null `featuredMedia`
whose `alt` is empty or null count toward `meta:images-no-alt`. Presence
only — judging and fixing the texts is the SEO audit skill's job, say so in
the report.
## Step 4 — Check the storefront links
Build one URL list from what the store says exists, in this order — menu
items first, then collections, then products:
- every menu item `url` (all levels, when menus were measured), **skipping**
items whose URL is empty or points at another domain — count those external
links and mention the count, do not fetch them. Menu-item URLs arrive
absolute (`https://store.example/pages/x`); normalise each to its path
before it goes in the list;
- `/collections/<handle>` for every collection from Step 2;
- `/products/<handle>` for every **ACTIVE** product.
The script checks at most 200 URLs (its own cap). Put the menu links first so
they are never the ones dropped — they are the only hand-typed URLs and the
likeliest to be broken. When the cap is hit the script says `truncated: true`;
name the cap in the report.
Write the input file yourself from the query output (no extra tooling needed):
```bash
cat > "$V/urls.json" <<'JSON'
{ "base": "<shop.primaryDomain.url from the collections query, Step 2>",
"urls": ["/collections/…", "/products/…", "/pages/contact"] }
JSON
node "$CLAUDE_PLUGIN_ROOT/skills/store-health-check/scripts/check-urls.mjs" "$V/urls.json"
```
The run can take several minutes on a large store (200 URLs, 10s timeout
each, 4 at a time) — give the command a generous shell timeout rather than
letting a default kill it silently.
`base` is `shop.primaryDomain.url` from the collections query (it rides along
there precisely because that query needs no extra scope). If even that is
missing, do **not** run the link check — report it "not measured". Never
substitute the `*.myshopify.com` domain: its canonical redirect makes every
URL come back `offDomain` and the check silently measures nothing.
Read the JSON it prints. **Read `probe` first — it decides whether the rest
means anything.** Before checking the list, the script asks the storefront for
a page that cannot exist (one per URL family, at most three) and reports what
came back:
- `probe.soft404: false` → the storefront returns real 404s; the link results
below are trustworthy.
- `probe.soft404: true` → this theme answers **200 for missing pages**. Every
URL in the affected families would "pass" no matter what. Report those
families as **not measured** — naming `probe.soft404Families` and the fact
that a soft 404 is itself worth fixing — and never write "no broken links"
for them. Families that probed clean are still reportable.
- `probe.soft404: null` → the probe could not complete (network error, timeout,
password page, or no path segments to probe). Report the link check "not
measured"; an unreachable probe is not a clean bill of health.
Then the list itself:
- any entry with `passwordPage: true` → **the storefront is behind its
password page.** Report that as one finding (`lock:password-page`), skip all
other link findings — behind the lock every URL redirects there and none of
it means anything.
- entries with `status >= 400` → finding `link:<path>` (no status in the id —
a 404 that becomes a 503 is the same broken link; report the current status
as detail). For a collection or product URL, name the admin object it came
from: "exists in admin but returns 404 — not published to the Online Store,
or broken".
- entries with `offDomain: true` → informational: name the URL and where it
went.
- entries with `skipped: true` → not fetched (a `mailto:`, `tel:` or similar
non-http link); count them with the external links, never as broken.
- entries with `error` → the check did not run for that URL; report the
literal error, never count it as healthy or broken.
- `truncated: true` → say the list was cut off at the cap and link findings
are a lower bound.
## Step 5 — Compare with last time
For every finding id from Steps 3–4:
- id present in the previous `findings` → label it **open since
<firstSeenAt>** and carry the old `firstSeenAt` forward;
- id not present before → label it **new**, `firstSeenAt` = now;
- previous id that no longer occurs → drop it silently — resolved is
resolved — but **only if the check that produces that id prefix actually
ran**. For a check reported "not measured", and for all `link:*` ids when
the password page was hit, carry the previous entries forward unchanged; a
finding you could not look at is not resolved.
## Step 6 — Report, then remember
Fixed shape:
1. One headline line, counts only:
> 2 sold out but active · 1 broken link (**new**) · 1 discount without an
> end date · 12 metadata gaps
Every check that ran gets a number here, **including a zero**; every check
that did not gets the words "not measured". A measured zero is a result and
the owner should see it. A check that is simply missing from the headline
reads like a zero without being one.
2. Only the non-empty categories, new findings first, with the "open since"
labels. Name products and collections by handle or title, never by GraphQL
id. Above ~15 items in one category, give the shape (how many, how bad)
and offer the full list on request.
3. A few concrete suggested actions. **Never execute them** — this skill
proposes, the human decides.
**Only after a successful report** — every check either measured or explicitly
reported "not measured" — write `.storehand/state.json`: take the object from
Step 1, replace only the `healthCheck` key with
`{ "lastRunAt": "<now, ISO 8601 UTC>", "findings": [ … ] }` — all ids you
reported this run **plus every id carried forward under Step 5** (a finding
you could not look at keeps its place and its `firstSeenAt`) — and write the
whole object back. If any measured check failed outright, leave the file
untouched — a moved marker after a partial run hides whatever the failed part
never saw. And as in Step 1: if `state.json` was unparseable, do not write at
all.
## Errors — never report a number you did not measure
| Situation | What to do |
|---|---|
| `shopify` not found or older than 4.5 | Show the install or `shopify upgrade` step, stop |
| Not authenticated / token expired | Show `shopify store auth --store <store> --scopes read_orders,read_products,read_inventory,read_discounts,read_online_store_navigation`, stop |
| ACCESS_DENIED on discounts or menus only | Report that check "not measured", show the auth line above as the fix, continue with the rest |
| Storefront behind its password page | One finding, no link list — see Step 4 |
| `check-urls.mjs` fails or times out | Show the literal error; never report "no broken links" |
| A field does not exist (API version drift) | Show the error, name the query file, point at `$CLAUDE_PLUGIN_ROOT/shared/api-version.md` |
| Step 0 printed `NOT FOUND` | Stop and tell the user to reinstall; never guess where plugin files are |
| Every query fails | Report the failure. Never "the store is healthy" |
A healthy store and a broken integration look identical in a report that hides
errors. Never let them.