shoplazza-common · git:20260917.8ccd841 · 2026-09-17 · sha256 b45311dd879ec87d

shoplazza-common git:20260917.8ccd841A

Immutable. This exact content never changes and is served at /api/v1/blob/b45311dd879ec87d.

---
name: shoplazza-common
description: >-
  Shared foundation for the shoplazza CLI: authentication (auth login — account-level and
  store-level), auth status/scopes/logout, store switching (auth store use), multi-store
  profiles (add/list/use/rename/remove/info), the three access tiers (+shortcut / spec leaf /
  api rest), the output envelope ({ok,data} / .data), --dry-run / --jq / --fields / --format,
  schema introspection, api rest fallback, update notice, the safety protocol, and how to trust
  a filtered list read (pagination / has_more / cursor, silently-dropped filters, the exact-match
  precision ladder). Read this FIRST on first use of the shoplazza CLI, when logging in /
  authorizing / switching store or profile, on permission errors, when a search or filter looks
  like it returned too much, and before using any domain skill.
---

# shoplazza CLI — shared foundation (shoplazza-common)

This skill is the **foundation** for every domain skill (`shoplazza-discounts`,
`shoplazza-products`, …): it owns all cross-cutting mechanics so domain skills never repeat
them, and every domain skill opens by requiring this file be read first. The authoritative
source for commands/params is `shoplazza <svc> <cmd> --help` and `shoplazza schema` — this
file teaches the mechanics, it does not memorize flags.

**Reply in the user's language.** Skill prose is English, but that never sets your reply
language — mirror the user (中文提问 → 中文回答), including questions you ask back,
write-confirmation restatements, and error explanations. Keep CLI commands, flags, and field
names verbatim (untranslated).

## Access tiers

Start every operation at the **highest tier that fits** and stop as soon as one expresses it:

| Tier | Form | When |
|---|---|---|
| 1. Shortcut | `shoplazza <svc> +<verb> [named flags]` | First choice. Named flags, smart defaults, structured errors. |
| 2. Spec leaf | `shoplazza <svc> <cmd> --params '{…}' [--data '{…}']` | Endpoints with no shortcut; one command per endpoint. |
| 3. api rest | `shoplazza api rest <METHOD> <PATH>` | Raw fallback, full coverage. Only when the first two can't express it. |

In `shoplazza <svc> --help`, entries prefixed with `+` are shortcuts; the rest are spec leaves.

## Output envelope (.data wrapper)

The structured-output contract, uniform across commands:

- Success → stdout: `{"ok":true,"data":<body>}`
- Failure → stderr: `{"ok":false,"error":{"type":…,"message":…}}`, non-zero exit.

**Response bodies nest under `.data`**, so every `jq` path starts at `.data` (e.g.
`.data.products[].id`). Missing the `.data` prefix is the most common extraction mistake.

**Exception — local commands have no envelope.** `auth status` / `auth scopes` /
`profile list` print their object at the top level (no `{ok,data}`, no `--jq` support);
read fields directly (e.g. `logged_in`).

## Common flags

| Flag | Notes |
|---|---|
| `--dry-run` | Print the request that would be sent, **without sending it**. **Always `--dry-run` first** for destructive / batch / money-spending writes. Read commands and shortcuts support it too. |
| `-q, --jq <expr>` | Filter JSON output with a jq expression. **Outputs raw scalars by default** (no surrounding quotes, just a trailing newline) — **do not add `-r`**: cobra parses `-r` as a separate flag and rejects the command. It is a single-string flag. |
| `--fields <f1,f2,…>` | Response field projection on **a few shortcuts only** — verified: `products +search` (comma-separated: `products +search --fields id,title`). **Not universal** — most commands have no `--fields`; check `<shortcut> --help` before using it. The server may still return extra base fields. |
| `--page-limit <n>` | Page size, 1–250, on list shortcuts. **The API default is 10** — see "Reading a filtered list" below. |
| `--format json\|pretty\|table` | Output format, default `json`. Use `json` for scripts/jq, `pretty`/`table` for humans. Global flag. |
| `--profile <name>` | Profile to use for this invocation (see "Profiles"). Global flag. |

Field projection: a few search shortcuts accept `--fields` (verify with `--help`); everywhere else, project/filter with `--jq`.

## Reading a filtered list

A read that returns `{"ok":true,…}` is **not** evidence that the filter or the page you asked
for is the one you got. Two failure modes are silent, and both produce a confident wrong
answer rather than an error:

**1. Truncation — the page default is 10.** List endpoints return 10 records unless
`--page-limit` says otherwise (max 250), with `has_more` / `cursor` marking the rest.
`has_more` may come back `null` rather than `false`, so treat "not `true`" as the end.
Whenever the answer depends on *completeness* — "how many", "which ones", "all of X", any
total you compute yourself — pass `--page-limit 250` and follow the cursor. For a pure count,
prefer the domain's `+count` shortcut over counting rows.

**2. Silently-dropped filters — the API ignores query params it does not recognise.** It
does not 400. A flag whose param name has drifted comes back 200 with the **unfiltered** set,
which reads exactly like "this customer really has that many orders".

So before a filtered read becomes an answer or feeds a write, confirm the filter landed:

```bash
shoplazza <svc> +search --<filter> <value> --dry-run   # 1. which param actually goes on the wire
shoplazza <svc> +count                                  # 2. unfiltered total, for comparison
shoplazza <svc> +count --<filter> <value>               # 3. filtered total — must be < the unfiltered one
```

**A filtered result equal to the unfiltered total is the tell.** Confirm it by spot-checking
that the returned records actually satisfy the filter (`--jq` over the field you filtered on).
If the filter is being dropped, `schema <svc>.<cmd> --view request` lists the params the
endpoint really accepts — reach for the spec leaf or `api rest` with the documented param.

**Precision ladder** — when the endpoint offers more than one way to filter on the same thing,
prefer the narrowest: a dedicated **exact-match** param (often an array, e.g. `customer_emails`,
`skus`, `order_tags`) beats a general **keyword** search, which beats hand-filtering with `--jq`
over a full page. Exact params say what they match; keyword search may match a field you did
not intend.

**Which tier — read the user's verb.** The phrasing tells you whether they mean a whole value
or a fragment, and that decides exact vs keyword:

| User's signal | Meaning | Tier |
|---|---|---|
| 「是 / 为 / 等于 / =」, 「编号 / 号码 / 值 is X」, or a self-identifying whole string (a full email, a full SKU like `ABC-001`, a full phone) | whole value | exact-match array param |
| 「包含 / 含 / 像 / 类似 / 以…开头 / 前缀 / 片段 / 部分」, `X*`, contains / starts-with / like | fragment | keyword search |

「是 / equals」is the authoritative exact signal even when it sits right next to a field name —
match the whole value, do not fuzzy-search it.

**Report the basis, not just the number.** When you answer from a list read, say which filter
was applied and what the completeness basis is ("3 of 3, unfiltered total 18"). It makes a
dropped filter visible to the user instead of invisible.

**Locate before you act.** A write targets a record by its id, but users name it by something
else (an order number, a code, an email, a title). Resolve the target with a read first, then
branch on the count:

- **0 matches** → stop and say so; do not fall back to a broader write or invent an id.
- **exactly 1** → proceed (still `--dry-run` + restate for a destructive / money op).
- **2+ matches** → list the candidates and ask which one. Never guess, and never act on "the
  first" silently.

A number is not an id (e.g. an order number vs the internal order_id) — feeding one where the
other is expected hits the wrong record or errors. Resolve it through the read, don't assume.

## Schema introspection

Before calling any spec leaf or `api rest`, check the schema to confirm params / body / response
shape — do not guess fields:

```bash
shoplazza schema                              # list all modules
shoplazza schema discounts                    # list a module's commands
shoplazza schema discounts.get                # a command's params / body / response
shoplazza schema orders.create --view request # request side only (--view all|request|response)
```

## api rest fallback

Only when the first two tiers can't express it. Write the **resolved real URL** (not a `{…}`
template):

```bash
shoplazza api rest GET  /openapi/2026-01/products
shoplazza api rest GET  /openapi/2026-01/products/gid_123 --params '{"page_size":10}'
shoplazza api rest POST /openapi/2026-01/discounts/automatic --data @body.json
```

Supports `--params` / `--data` (incl. `-` for stdin, `@file` for a file), `--dry-run`, `-q/--jq`.

**Large integer ids → quote them in JSON.** In `--params` / `--data`, pass a big numeric id as a
**string** (`{"id":"669923000979557666"}`), not a bare number — bare large ints get float-coerced
(→ `6.69…e+17`) and the request is rejected. Applies anywhere an id exceeds ~15 digits
(e.g. `shop redirects`).

## Authentication (auth)

### Login-state preflight

**Before the first store operation in a session, run `shoplazza auth status`** (local, instant,
no envelope). Two independent things must both be true:

- `logged_in: true` — account credentials exist. If `false` → run the login flow below.
- `profiles` non-empty — a store is bound. `logged_in: true` with `profiles: []` still cannot
  reach any store API; ask which store and run `auth login -s <store> --domain <modules>`.

If you skip the preflight, an unauthenticated business command fails fast with a
`validation` error — `"no profile configured"` plus a `hint` naming the fix. Treat that the
same way: don't retry, guide the user through login first.

### Login

Authorization has two layers: **account-level** (`auth login` obtains account credentials) and
**store-level** (exchange a store token for a specific store, stored in a profile).

```bash
# Account-level login (no store bound)
shoplazza auth login --domain products,orders        # or --scope read_product,write_product
# Store-level login (select a store; an interactive login with -s must also pass --scope or --domain)
shoplazza auth login -s my-store.myshoplazza.com --domain discounts
```

- `--domain <module,…>`: authorize by **top-level command name**; the CLI expands each into the
  OAuth scopes that module needs. Options: `billing, checkout, customers, discounts, orders,
  products, shop, themes, webhook, all`. **Preferred** — you don't memorize scope literals.
- `--scope <literal,…>`: fine-grained OAuth scopes (e.g. `read_product,write_product`),
  least-privilege.
- **Re-login REPLACES the grant** (verified live: a 22-scope grant re-authorized with one
  scope came back with one). Topping up permissions? Pass **`--merge-scopes`** to carry the
  prior grant along (older CLIs lack the flag — build the union yourself via `auth scopes`;
  `--domain` and `--scope` combine in one login). Only omit it when the user explicitly
  wants to narrow permissions.

### Agent-driven login flow

When acting as an AI agent to log the user in: run `auth login` in the **background** (it
**blocks and polls** until the user completes authorization or it times out — see
`--poll-interval` / `--timeout`, defaults 2s / 300s), **extract the authorization URL from its
output and hand it to the user**; the command returns on its own after the user authorizes.

**Missing flags become on-screen questions when a terminal is present.** `auth login` (and
`app init`) asks for what you left out only when stdin **and** stderr are both real terminals and
neither `SHOPLAZZA_CLI_NO_INTERACTIVE` nor `CI` is set (present and non-empty = set); otherwise a
missing flag is an error pointing at that flag. **A harness that hands the child process a pty
passes that check** and the command waits on a keypress that never comes — export
`SHOPLAZZA_CLI_NO_INTERACTIVE=1` once for the session if yours does.

It only switches prompting **off**. There is no `--interactive` flag and no env var to force it
on — deliberately, since forcing it would hang for good wherever no terminal exists.

**Re-login for a missing scope? Pass `--merge-scopes`.** Scopes are replaced, not merged
(see Login above); requesting only the missing scope drops everything else mid-task. If the
flag is unknown (older CLI), fall back to building the union: `auth scopes` → granted, then
request granted + the new scopes in one login.

### Non-interactive fast path (UAT)

If you already ran `auth login` on another machine and have an account UAT, skip the browser:

```bash
shoplazza auth login --uat <UAT>     # or set env SHOPLAZZA_UAT=<UAT>
```

`--uat` is promptless whatever the terminal looks like — a second, whole-command off-switch on
top of the gate above. Browser logins driven from a pty-allocating harness still need
`SHOPLAZZA_CLI_NO_INTERACTIVE=1`.

### Store switching and status

```bash
shoplazza auth status                                                  # auth state / identity / current store
shoplazza auth scopes                                                  # supported scopes + scopes granted to the account
shoplazza auth store use -s another-store.myshoplazza.com [--scope …]  # exchange a token for that store, set as current profile
shoplazza auth logout                                                  # log out of the current store
```

### Handling insufficient permissions

On 401/403 for a write, the error envelope's `hint` says what's missing. Grant it as directed:
`shoplazza auth login --domain <that module>` (or `--scope <missing scope>`), then retry. Run
`auth scopes` first to see what's already granted.

## Profiles (multi-tenant)

A profile = one store execution context (its store token + scopes). Manage multiple stores by
switching profiles:

```bash
shoplazza profile add --name prod-us -s my-store.myshoplazza.com [--scope …] [--use]
shoplazza profile list
shoplazza profile use --name prod-us          # or --previous to switch back to the last one
shoplazza profile info [--name prod-us]       # defaults to the current profile
shoplazza profile rename --from old --to new
shoplazza profile remove --name prod-us
shoplazza profile update --name prod-us --scope …   # adjust a profile's scopes
```

Per-invocation override: the `--profile <name>` global flag beats the `SHOPLAZZA_CLI_PROFILE`
env var and the current profile.

## Update notice

When the CLI detects a new version after running, it prints a one-line notice **to stderr**
(`⚡ shoplazza-cli X is available …`). It never appears in the JSON on stdout. **Do not
silently ignore it**: after finishing the user's current request, tell them the current /
latest version and offer to update:

```bash
shoplazza update            # update via npm (--check only reports versions, no install)
# or: npm install -g shoplazza-cli@latest
```

(Set `SHOPLAZZA_CLI_NO_UPDATE_CHECK` to disable the check.)

`update` does three things and reports each in its JSON body:

| Field | Means |
|---|---|
| `updated` | the binary was reinstalled. `false` = already current, **not** a failure |
| `meta_updated` / `meta_revision` | the API metadata cache was rewritten, and at which revision |
| `skill_installed` | the Agent Skills **are installed** — nothing more |

`skill_installed` answers "are they there", not "were they just refreshed": `shoplazza update`
does refresh them when it is `true`, but `update --check` reports the same `true` while
refreshing nothing, and a refresh that failed also still reports `true` (with a warning on
stderr). **Never tell the user their skills were updated on the strength of this field alone** —
read the stderr lines (`✓ Refreshed N skill(s)`) for that.

The skills are **opt-in**: `false` means the user never installed them and `update` left them
alone rather than installing them. To install:

```bash
npx skills add Shoplazza/shoplazza-cli -g
```

(Set `SHOPLAZZA_CLI_SKIP_SKILLS=1` to suppress the skill step; the field then disappears from the
body entirely — an absent field means "suppressed", `false` means "not installed".)

## Safety protocol

shoplazza-cli has **no** lark-style exit-10 high-risk-write gate and no `--yes` confirmation
flag. **Do not invent a confirmation protocol.** The safety model is **`--dry-run` → restate →
wait for the user's go-ahead**:

1. **`--dry-run` first** for destructive (cancel / delete), batch, or fund-moving (billing
   charges) operations: print the request that would be sent, restate it, **then STOP and end
   your turn**. For entity/campaign **creation via a `+shortcut`** (e.g. creating a discount),
   follow the domain skill — `+shortcuts` are structured and safe to run directly once the
   required, money-affecting values are confirmed (never fabricated); `--dry-run` only if the
   user asks.
2. **Consent must be a user message, not your own judgment.** Showing the dry-run is not
   consent; your own review of it is not consent. The real command may only run in a turn
   that comes **after** the user explicitly agrees (e.g. "确认" / "yes / go ahead"). Running
   dry-run and the real write in the **same turn** is a protocol violation even if the
   dry-run output was perfect.
3. **Restate intent** alongside the dry-run: plain language — which store, what action, which
   key params — so the user knows exactly what they are agreeing to.
4. **Do not fabricate money-spending values** (discount amounts / caps / prices) — omit for the
   default or ask; see each domain skill's required-vs-ask.
5. Rehearse writes against **dev / stg** stores only; never practice on prod.

## Command discovery

```bash
shoplazza --help                 # top-level modules
shoplazza <svc> --help           # a module's shortcuts + spec leaves
shoplazza <svc> <cmd> --help     # a command's flags
shoplazza schema <svc>.<cmd>     # params / body / response shape
```