Skip to content
Back to skills

Store Health Check

ASecurity

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.

  • 2 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 19, 2026
ai-agentsrustgoshellbashnodeapi

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Pro scans all 6 files and shows the line behind each finding

Scanned September 19, 2026

npx -y skills add storehand/storehand --skill store-health-check --agent claude-code

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.

Security grade badge for Store Health Check
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/storehand-store-health-check/badge)](https://www.skillsdirectory.com/skills/storehand-store-health-check)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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.

Files in this skill

  • SKILL.md15.4 KB
  • queries/active-products-inventory.graphql1.4 KB
  • queries/collections-health.graphql884 B
  • queries/discounts.graphql1.3 KB
  • queries/menus-and-domain.graphql640 B
  • scripts/check-urls.mjs7.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.