@cerkel/cli 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +502 -2
  2. package/dist/api-url.js +42 -0
  3. package/dist/api.js +48 -0
  4. package/dist/bin.js +157 -0
  5. package/dist/chunk.js +31 -0
  6. package/dist/cli.js +497 -0
  7. package/dist/client.js +125 -0
  8. package/dist/command.js +2 -0
  9. package/dist/commands/args.js +107 -0
  10. package/dist/commands/auth.js +255 -0
  11. package/dist/commands/call.js +67 -0
  12. package/dist/commands/collection.js +121 -0
  13. package/dist/commands/content.js +281 -0
  14. package/dist/commands/customer.js +126 -0
  15. package/dist/commands/dev.js +261 -0
  16. package/dist/commands/draft.js +101 -0
  17. package/dist/commands/file.js +53 -0
  18. package/dist/commands/index.js +43 -0
  19. package/dist/commands/inventory.js +103 -0
  20. package/dist/commands/offer.js +92 -0
  21. package/dist/commands/order-args.js +37 -0
  22. package/dist/commands/order.js +219 -0
  23. package/dist/commands/price.js +55 -0
  24. package/dist/commands/product.js +265 -0
  25. package/dist/commands/promo.js +140 -0
  26. package/dist/commands/redirect.js +94 -0
  27. package/dist/commands/report.js +93 -0
  28. package/dist/commands/section.js +233 -0
  29. package/dist/commands/store.js +23 -0
  30. package/dist/commands/theme-tools.js +432 -0
  31. package/dist/commands/theme.js +388 -0
  32. package/dist/commands/whatsapp.js +39 -0
  33. package/dist/commands/zone.js +90 -0
  34. package/dist/commerce.js +138 -0
  35. package/dist/content-files.js +246 -0
  36. package/dist/context.js +5 -0
  37. package/dist/credentials.js +201 -0
  38. package/dist/diff.js +146 -0
  39. package/dist/env.js +24 -0
  40. package/dist/errors.js +73 -0
  41. package/dist/format.js +13 -0
  42. package/dist/layout.js +122 -0
  43. package/dist/logins.js +49 -0
  44. package/dist/raizn-alias.js +6 -0
  45. package/dist/redact.js +27 -0
  46. package/dist/session.js +266 -0
  47. package/dist/state.js +153 -0
  48. package/dist/sync.js +121 -0
  49. package/dist/zip.js +88 -0
  50. package/package.json +37 -3
package/README.md CHANGED
@@ -1,3 +1,503 @@
1
- # Temporary Holding Version
1
+ # @cerkel/cli — the `cerkel` CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run a Cerkel store from the terminal, by hand or with Claude Code: its **theme** as local
4
+ files (preview it on the store's real domain and send it for approval), and its **catalogue** —
5
+ products, prices, bundles, stock and collections (see [Store commands](#store-commands-catalogue-prices-stock-collections)).
6
+ It works like Shopify CLI, with one difference: there is no theme publish, unpublish or rollback
7
+ command. **`submit` sends the draft for approval; for
8
+ the store owner's login it publishes immediately** (a rollback link is printed) — a login whose owner
9
+ holds `themes.publish` in that store (owner, admin), and Cerkel team logins. Everyone else's request
10
+ waits for the owner, who approves and publishes it in the Cerkel admin.
11
+
12
+ Requires Node.js 20 or newer.
13
+
14
+ ## 1.0.1: published by GitHub, no npm token
15
+
16
+ Same commands as 1.0.0. This is the first release that GitHub Actions publishes through npm trusted publishing
17
+ (`publish-cli.yml`), so the package carries a provenance statement and no npm token exists anywhere.
18
+
19
+ ## 1.0.0: the CLI is now `cerkel`
20
+
21
+ The package is **@cerkel/cli** and the command is **`cerkel`** (it was @raizn/cli and `raizn`).
22
+
23
+ - **Install:** `npm rm -g @raizn/cli`, then `npm i -g @cerkel/cli`. Both packages install a `raizn` command,
24
+ so remove the old one first, or npm refuses the second.
25
+ - **`raizn` still works for now.** It prints one line on stderr (`raizn is now cerkel …`) and then does exactly
26
+ what `cerkel` does, so old scripts keep running. It goes away in a later release.
27
+ - **Variables:** `CERKEL_TOKEN` and `CERKEL_API_URL`. The old `RAIZN_TOKEN` and `RAIZN_API_URL` still work; when
28
+ both are set, the `CERKEL_` one wins.
29
+ - **Your logins move by themselves.** The first command reads what `raizn` saved (keychain service `raizn-cli`, or
30
+ `~/.config/raizn/credentials`), saves it under `cerkel-cli` or `~/.config/cerkel/credentials`, and removes the old
31
+ copy. Nothing to do.
32
+ - **Folders you already pulled keep working.** A new folder keeps its state in `.cerkel/`; one pulled by `raizn`
33
+ keeps using its `.raizn/` where it is. Page files written by `raizn` (`<!--raizn …-->`) still push; new pulls write
34
+ `<!--cerkel …-->`.
35
+ - New logins appear in Settings → AI assistant connection (MCP) as "cerkel CLI — <computer> — <store>".
36
+
37
+ ## 0.3.0
38
+
39
+ - **The default host is now admin.cerkel.ai** (`https://admin.cerkel.ai/api/mcp`). ecom.raizn.com keeps serving
40
+ the same admin and API.
41
+ - **Run `cerkel login` again.** Logins are kept per host, so a login made on ecom.raizn.com is not used for
42
+ admin.cerkel.ai.
43
+ - **A theme folder pulled from ecom.raizn.com keeps using it.** The folder's recorded API outranks
44
+ `CERKEL_API_URL` and the default, so its commands still go to ecom.raizn.com with that host's login.
45
+ - **To keep the old host everywhere else**, set `CERKEL_API_URL=https://ecom.raizn.com/api/mcp`.
46
+
47
+ ## Install and log in
48
+
49
+ ```sh
50
+ npm i -g @cerkel/cli # or run any command with npx @cerkel/cli …
51
+ cerkel login --store justloofah-jo # opens the browser: sign in, check the store, press Allow, done
52
+ ```
53
+
54
+ **A login is for one store.** It reaches exactly the store you chose and nothing else: `cerkel
55
+ stores` and `cerkel whoami` show that one store, and a command for any other store is refused
56
+ without naming it. To work on another store, log in to that one too
57
+ (`cerkel login --store nahleh-jo`). Your other logins are kept, so a Cerkel-team member who works on
58
+ several stores logs in once per store and then just works: each theme folder uses the login of its
59
+ own store. Switching store means using that store's login (or its folder), never widening one.
60
+
61
+ Like Shopify CLI, the package is public and holds no secrets. Everything is decided by the server:
62
+ what you can do follows your staff role and stores in the Cerkel admin, on every call. Removing
63
+ someone in Settings → Staff cuts their CLI off on the next command.
64
+
65
+ `cerkel login` opens `/cli/authorize` on admin.cerkel.ai (or on the host the command uses: `--api`, the folder's
66
+ recorded API, or `CERKEL_API_URL`). You sign in as usual (WhatsApp code or
67
+ password + authenticator), pick the store (only stores where you can edit themes are listed;
68
+ `--store` pre-selects it, and inside a theme folder `cerkel login` means the folder's store) and press
69
+ **Allow**. The browser then hands a one-time code back to `cerkel` on this computer (127.0.0.1 only),
70
+ and `cerkel` trades it for your token. The code is useless to anyone else, because it needs a secret
71
+ that never leaves this process (PKCE). The token lasts 90 days and appears in Settings → AI
72
+ assistant connection (MCP) as "cerkel CLI — <computer> — <store>" (logins made before 1.0 keep their "raizn CLI — …" name), where you can revoke it.
73
+
74
+ **Logins from raizn 0.1.x** reached every store. The server no longer accepts them: the first command
75
+ says `This login is not tied to a store any more — run cerkel login --store <slug>` (naming the
76
+ folder's store when you are in one), forgets that login, and `cerkel login --store <slug>` makes a
77
+ proper one. A one-store token you once pasted with `cerkel login --token` keeps working: it is filed
78
+ under its store the first time you use it.
79
+
80
+ **macOS: `EACCES: permission denied` on `npm i -g`.** Node's installer makes the global folder
81
+ belong to the system. Don't use `sudo`. Either run commands through `npx @cerkel/cli …`, or point
82
+ npm's global folder at your home directory once:
83
+
84
+ ```sh
85
+ mkdir -p ~/.npm-global
86
+ npm config set prefix ~/.npm-global
87
+ echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
88
+ npm i -g @cerkel/cli
89
+ ```
90
+
91
+ No browser on this machine (a server over SSH)? Create a token for one store by hand in that
92
+ Settings page and run `cerkel login --token`, or pipe it in with `echo "$TOKEN" | cerkel login`. A token
93
+ that is not tied to exactly one store is refused and not saved.
94
+
95
+ **Releasing a new version** (maintainers): bump `version` in `packages/cli/package.json` in a PR,
96
+ merge it, then run the **Publish cerkel CLI** workflow (GitHub → Actions). There is no npm token to
97
+ manage: npm trusts that workflow in this repo directly (trusted publishing, set on npmjs.com →
98
+ `@cerkel/cli` → Settings).
99
+
100
+ Either way, `login` checks the token with the server before saving it, and prints who you are, the
101
+ one store it is for and its permissions. A token the server rejects is not saved.
102
+
103
+ Logins are stored in your OS keychain (service `cerkel-cli`, account = the API origin; the entry holds
104
+ one token per store). If there is no keychain, they go to `~/.config/cerkel/credentials`
105
+ (`%APPDATA%\cerkel\credentials` on Windows) with `0600` permissions.
106
+
107
+ ```sh
108
+ cerkel logins # your own logins on this computer, one per store, each checked with the server
109
+ cerkel logout # inside a theme folder: that store's login; with one login: that one
110
+ cerkel logout --store nahleh-jo # one store's login
111
+ cerkel logout --all # every login for this API
112
+ ```
113
+
114
+ Which login a command uses: the theme folder's store (`.cerkel/state.json`), else `--store <slug>`,
115
+ else your only login. With several logins and no folder or `--store`, the command stops and asks you
116
+ to pass `--store`; it does not list your stores (`cerkel logins` does, on purpose — that data is on
117
+ your own disk). `cerkel logout` only forgets a login on this computer; revoke the token itself in
118
+ Settings → MCP.
119
+
120
+
121
+ ## Workflow
122
+
123
+ ```sh
124
+ cerkel login --store nahleh # once per store
125
+ cerkel stores # the store this login is for
126
+ cerkel theme list --store nahleh # live + draft themes
127
+ mkdir nahleh-theme && cd nahleh-theme
128
+ cerkel theme pull --store nahleh # newest draft (offers to create one from live if none)
129
+ cerkel theme dev # push on save + preview link on the real store domain
130
+ cerkel theme check # validate changed files (nothing saved)
131
+ cerkel theme submit -m "Ramadan homepage" # send for approval; prints the admin link
132
+ ```
133
+
134
+ - **pull** downloads a draft into the current folder (or `--dir`). Use `--theme <id>` to choose a
135
+ specific draft. If the store has no draft, `pull` asks whether to create one from the live theme
136
+ (`--yes` answers for you, and scripts must pass it). If you have local changes, `pull` refuses
137
+ unless you pass `--force`. A folder holds one store's theme: pulling another store into it is
138
+ refused, so pull it into a folder of its own (`--dir`). Without `--store` outside a folder, `pull`
139
+ uses the store of your only login.
140
+ - **dev** pushes pending changes first, then watches the folder. A burst of saves goes to the server
141
+ as one request, about a second after you stop typing. Errors show inline as
142
+ `path:line:col message`. It prints a signed preview link and fetches a fresh one before the
143
+ 30-minute link expires. **Open each new link:** a tab still using the old one stops showing the
144
+ draft and quietly falls back to the live site after 30 minutes. Ctrl-C stops it.
145
+ - **push** uploads only the files you changed since the last pull or push. If someone else changed
146
+ a file on the server in the meantime (a teammate, or the admin editor), `push` refuses that file,
147
+ prints a diff (server vs yours) and exits 1. Merge the changes and push again, or use `--force`
148
+ to overwrite the server's copy. `--force` first lists each conflicting file with how many server
149
+ lines not in your file would be lost, and asks `y/N`. `--yes` answers for you, and scripts must
150
+ pass it: without a terminal, `--force` alone refuses. If the server's copy changes again while
151
+ you are answering, that file is refused as a new conflict instead of being overwritten.
152
+ - **preview** prints the signed preview link, and `--open` opens it in your browser.
153
+ - **submit** refuses while you have unpushed changes. For the **store's owner or an admin** (the
154
+ login carries `themes.publish` and its owner holds it in that store right now) and for **Cerkel's own
155
+ team** (a @raizn.net / @raizn.com email) the server approves
156
+ and publishes it in the same step: `submit` says the change is **LIVE** and prints the request link,
157
+ where Rollback is one click. Everyone else's request waits for someone with publish rights. It prints the request id and
158
+ `https://admin.cerkel.ai/themes/requests/<id>` (on the host the folder uses).
159
+
160
+ ### More theme commands (Shopify CLI parity)
161
+
162
+ ```sh
163
+ cerkel theme info # this folder's draft (or --theme <id>, or the live theme)
164
+ cerkel theme open # the admin's visual editor (--code: the code editor, --print: only the link)
165
+ cerkel theme rename "Ramadan 2027" # this folder's draft, or --theme <id>
166
+ cerkel theme delete --theme <id> # a DRAFT; type its name to confirm (--yes in scripts)
167
+ cerkel theme share --days 7 # a preview link for someone else, on the store's own domain (1–14 days)
168
+ cerkel theme console --path /products/x # Liquid against the store's real data: product.title, collections | size …
169
+ cerkel theme console -e "shop.name" # one shot (or pipe Liquid in)
170
+ cerkel theme profile --path / --runs 3 # each section's server render time, slowest marked
171
+ cerkel theme package # zip this folder's theme files (-o file.zip)
172
+ cerkel theme init my-theme # a new folder from a starter theme (--list shows them)
173
+ cd my-theme && cerkel theme push --new # → a NEW draft (copied from live) with these files; the folder becomes its checkout
174
+ ```
175
+
176
+ - **console** evaluates on the store's own storefront with the objects a section on `--path` gets, in
177
+ the theme of this folder (else the live one). Inside: `.path /collections/x`, `.locale en`, `.help`,
178
+ `.exit` (or Ctrl-D). It only reads.
179
+ - **profile** renders the page exactly as a shopper gets it, but section by section, and prints the
180
+ server time of each with what rendered it (a theme file, a built-in section) and any Liquid error.
181
+ - **share** links show the draft as it is on the server: push first. Deleting the draft revokes them.
182
+ - **delete --theme** never deletes the live theme or a draft waiting for review.
183
+
184
+ ### Example: Just Loofah
185
+
186
+ Every Just Loofah page is a store like Nahleh's — same file layout, same commands:
187
+
188
+ ```sh
189
+ cerkel theme pull --store justloofah-jo
190
+ # templates/index.json templates/product.json templates/page.products.json
191
+ # templates/page.habit.json templates/404.json
192
+ # layout/header.json layout/footer.json
193
+ # config/settings.json assets/custom.css
194
+ # locales/ar.json locales/en.json
195
+ ```
196
+
197
+ Text settings are **localized**: one setting holds every language as an object, never a bare string.
198
+ Editing `templates/index.json`'s loyalty section:
199
+
200
+ ```diff
201
+ { "id": "loyalty", "type": "jl-loyalty", "settings": {
202
+ "label": { "ar": "مكافأة الولاء", "en": "Loyalty reward" },
203
+ - "headline": { "ar": "اطلب مرّة… ووفّر للأبد", "en": "Order once. Save forever." },
204
+ + "headline": { "ar": "اطلب مرّة… ووفّر للأبد", "en": "Order once, save forever." },
205
+ "body": { "ar": "…", "en": "…" }
206
+ } }
207
+ ```
208
+
209
+ `cerkel theme push` validates and saves it to the draft; `cerkel theme dev` gives a live preview on the
210
+ store's own domain while you edit. If the store has never been seeded (no rows to pull), the admin's
211
+ Settings → Data sources → "تهيئة قالب Just Loofah" button (or re-running "استيراد باقات وإضافات Just
212
+ Loofah") creates them first — content identical to the built-in fallback, so nothing changes visually
213
+ until you edit something.
214
+
215
+ ### Built-in texts: `locales/<lang>.json`
216
+
217
+ The storefront's own texts — "Add to cart", cart and checkout labels, the account and tracking
218
+ pages, search, the default policies, a Just Loofah theme's SEO title and WhatsApp prefill — are one
219
+ file per store language (Kurdish too, when the store sells in it). `pull` writes every text the
220
+ language shows: the theme's own wording where it has one, the default everywhere else.
221
+
222
+ ```json
223
+ {
224
+ "platform": { "addToCart": "Add to cart", "trackTitle": "Track your order", "…": "…" },
225
+ "theme": { "product.addToCart": "Add to Cart", "whatsapp.prefill": "Hi, I have a question about Just Loofah products 🌿", "…": "…" }
226
+ }
227
+ ```
228
+
229
+ `platform` holds the texts every theme shares; `theme` holds the theme's own (only a theme whose
230
+ preset has its own dictionary, today Just Loofah). Change a value and `push`: only texts that differ
231
+ from the default are stored, a line you delete goes back to its default, and a file that names an
232
+ unknown key, drops or adds a `{placeholder}`, or contains HTML is refused with the line. Kurdish
233
+ files leave out the texts nobody has translated yet (the store shows Arabic there); add the line to
234
+ translate one. Merchants edit the same texts in the admin: Themes → ⋯ → Edit theme content.
235
+
236
+ ### Sections, blocks and snippets: new kinds of section with no deploy
237
+
238
+ A section is ONE file, `sections/<type>.liquid`: its Liquid, then `{% stylesheet %}`, then
239
+ `{% schema %}` (docs/liquid-themes.md §2). It belongs to the **theme** you pulled, like its templates:
240
+ it goes live when that draft is approved and published, and a rollback brings the previous files back.
241
+
242
+ ```sh
243
+ cerkel section new promo-strip --starter blank # writes sections/promo-strip.liquid locally
244
+ cerkel section duplicate promo-strip promo-v2 # a copy to redesign side by side ("(copy)" in its name)
245
+ cerkel theme push # adds them to the draft (or keep `cerkel theme dev` running)
246
+ cerkel section history promo-strip # every save in every theme of the store, newest first
247
+ cerkel section restore promo-strip 3 # the old file becomes a NEW version; history kept
248
+ cerkel section delete promo-strip # refused while one of the draft's templates places it
249
+ ```
250
+
251
+ Then place the section with the admin's visual editor (or in `templates/*.json`) and
252
+ `cerkel theme submit -m "…"`. A file `sections/<type>.liquid` whose type is a built-in section (e.g.
253
+ `jl-hero-video`) REPLACES that built-in section in this theme only — that is how a section is
254
+ redesigned one at a time.
255
+
256
+ `blocks/<name>.liquid`, `snippets/<name>.liquid` and `assets/<name>.css|svg` are theme files too:
257
+ pull, push, dev, `cerkel theme history <file>`, `cerkel theme restore <file> <version>` and
258
+ `cerkel theme delete <file…>` work on every one of them. `assets/*.generated.css` is written by Cerkel
259
+ from the theme's files: pulled for reference, never pushed. JavaScript files are not theme files —
260
+ interactivity comes from the platform's components (docs/liquid-themes.md §7).
261
+
262
+ **Upgrading from CLI 0.1.** Sections used to be three files (`.liquid`, `.schema.json`, `.css`). In an
263
+ old checkout, `push` refuses and says so; `cerkel theme pull` writes the one-file sections and removes
264
+ the old side files you did not change (an edited one is kept — move its content into the
265
+ `{% schema %}` / `{% stylesheet %}` of the `.liquid` file, then delete it).
266
+
267
+ ## Orders, customers and reviews
268
+
269
+ Every command runs against your login's store (or `--store <slug>`) and takes `--json`. Writes show
270
+ the server's change first and ask (`--yes` in scripts, `--dry-run` to only look); the server applies
271
+ the admin's rules and its refusal is printed as is.
272
+
273
+ ```bash
274
+ cerkel order list --status new,confirming # the confirmation queue's orders
275
+ cerkel order get JL-1001 # items, totals, customer, courier, timeline, next steps
276
+ cerkel order transition JL-1001 confirmed # confirming / unreachable / cancelled (--note) / delivered …
277
+ cerkel order call JL-1001 no_answer # logs the call, schedules the next one
278
+ cerkel order hold JL-1001 --reason customer_later --until 2026-10-20
279
+ cerkel order release JL-1001
280
+ cerkel order note JL-1001 Customer asked for an evening delivery
281
+ cerkel order tag JL-1001 --add gift --remove old
282
+ cerkel order edit JL-1001 --qty JL-90=3 --add JL-SOAP=1 --reason "Customer called" # --dry-run to preview
283
+ cerkel draft create --name Laila --phone 0791234567 --governorate JO-AM --address "Street 7" --item JL-90=2
284
+ cerkel draft complete <draft id> # places the order (number, stock, COD checks)
285
+ cerkel customer get JL-1001 # by phone or by any of their orders
286
+ cerkel customer flag 0791234567 blocked --note "Fake orders" # --off lifts it
287
+ cerkel customer update JL-1001 --name "Laila Haddad" --note "Prefers evenings"
288
+ cerkel review list --status pending
289
+ cerkel review moderate <review id> approved
290
+ cerkel review reply <review id> Thank you for your order!
291
+ ```
292
+
293
+ An order the courier already has takes no new items, and editing it needs an admin's approval:
294
+ `cerkel order edit` always submits the edit for approval (`--reason` required) — from any login, an
295
+ admin's included — and nothing changes until an admin approves it in the admin. Phones are masked unless your login may see
296
+ customer details.
297
+
298
+ ## Folder layout
299
+
300
+ ```
301
+ templates/index.json templates/<name>.json (product, product.<suffix>, collection…, page…, …)
302
+ layout/header.json layout/footer.json
303
+ config/settings.json theme settings (merged on write: deleting a key does not remove it)
304
+ assets/custom.css the theme's custom CSS
305
+ locales/<ar|en|ckb>.json the theme's built-in texts per language (the whole language: a deleted line goes back to its default)
306
+ sections/<type>.liquid a section: Liquid + {% stylesheet %} + {% schema %}, one file
307
+ blocks/<name>.liquid a theme block (Horizon-style; `_name` = private)
308
+ snippets/<name>.liquid a snippet for {% render %}
309
+ assets/<name>.css|svg plain CSS (loaded on every page of the theme) and sanitised SVG
310
+ assets/<name>.generated.css written by Cerkel (read-only)
311
+ .cerkel/state.json what was pulled: api, store, theme, and each file's server tag + local hash
312
+ .cerkel/.gitignore "*", so .cerkel never lands in git (a folder pulled before 1.0 keeps its .raizn/)
313
+ ```
314
+
315
+ Anything else in the folder, such as a README or a `.git` directory, is ignored. Files are written
316
+ exactly as the server sends them, so Arabic text round-trips byte for byte. A git checkout with
317
+ `core.autocrlf` does not count as modified, because line endings are normalised to LF before
318
+ comparing and sending. A leading UTF-8 BOM (Windows PowerShell 5.1 `Set-Content -Encoding utf8`
319
+ adds one) is dropped before comparing and sending, so a file that differs only by a BOM is not a
320
+ change. A section, block, snippet or asset file you delete locally is deleted from the draft on the
321
+ next `push` or by `dev` — only if the server still holds the copy you last synced, and after the
322
+ templates in the same push (so a template can stop placing a section and the section can go in one
323
+ push). `--nodelete` keeps them. Templates, layout, settings and `assets/custom.css` are never deleted.
324
+
325
+ A read-only reference copy of the **live** theme can be pulled into its own folder:
326
+
327
+ ```sh
328
+ cerkel theme pull --store nahleh --live --dir ../nahleh-live
329
+ ```
330
+
331
+ `push`, `dev`, `submit` and the section write commands refuse to run in that folder. The live theme
332
+ is never pulled for editing.
333
+
334
+ ## Store commands (catalogue, prices, stock, collections)
335
+
336
+ Each command is one MCP tool (`docs/mcp.md`, phase A); the server decides permission, store and
337
+ money rules and its refusal is printed verbatim. The store is your login's store (or `--store`).
338
+
339
+ **Every write shows the change and asks first.** It sends a dry run, prints the server's diff, asks
340
+ `Apply this change to <store>? [y/N]`, and only then applies. `--yes` answers for you, `--dry-run`
341
+ stops after the diff, `--json` prints the server's answer. Without a terminal (scripts, CI) a write
342
+ needs `--yes` and is refused before anything is sent.
343
+
344
+ ```sh
345
+ cerkel store info # currency (decimals, cash step), languages, domains, COD policy
346
+ cerkel price set JL-90 18.000 # shows "price: 15.000 JOD → 18.000 JOD", asks, applies
347
+ cerkel price set JL-90 - --compare-at none # "-" keeps the price; none removes the compare-at price
348
+ cerkel price import prices.csv --dry-run # sku,price,compare_at,cost — all or nothing, bad rows listed
349
+ cerkel product list rose --low # search; --status, --collection, --category, --published|--hidden
350
+ cerkel product get loofah-90 # variants with this store's prices and stock
351
+ cerkel product create --title "en:Rose soap" --title "ar:صابون الورد" --price 4.500 --sku JL-ROSE --quantity 12
352
+ cerkel product update rose-soap --tags soap,rose --category skin/soap --field scent=rose
353
+ cerkel product status active rose-soap body-soap # one or many
354
+ cerkel product publish rose-soap # show it in THIS store (unpublish hides it)
355
+ cerkel product duplicate rose-soap
356
+ cerkel variant options knee --option "Size=S,M,L" --lang en --create-variants
357
+ cerkel variant add knee --option Size=L --sku KS-L
358
+ cerkel image add loofah-90 https://…/a.jpg # or a media library id; image remove / image reorder
359
+ cerkel category set rose-soap skin/soap
360
+ cerkel offer set loofah-90 --label "en:5 + 1" --price 15.000 --line JL-SQ:5 --line JL-CYL:1
361
+ cerkel offer set loofah-90 deal-6 --inactive # change an existing tier by its key
362
+ cerkel addon set loofah-90 JL-SOAP --price 0 # a free gift with the order
363
+ cerkel inventory get loofah-90 # on hand / reserved / available per location
364
+ cerkel inventory adjust JL-SQ +24 --reason received # or -2 --reason damage; reasons: correction, damage, theft, received, return, count
365
+ cerkel inventory set JL-SQ 40 # an exact count
366
+ cerkel inventory tracking loofah-90 --track on --continue-selling off
367
+ cerkel inventory low
368
+ cerkel collection create --title "en:Loofahs" --product square-loofah --product loofah-90
369
+ cerkel collection reorder loofahs loofah-90 # listed first, the rest after
370
+ cerkel collection unpublish loofahs
371
+ ```
372
+
373
+ Amounts are typed in the store's currency exactly as you would write them — `18.000` or `18`
374
+ (JOD, 3 decimals), `12500` (IQD, steps of 250) — and parsed by the server; too many decimals or an
375
+ off-step IQD amount is refused, never rounded. Text flags take `en:Text` / `ar:نص` (repeat per
376
+ language) or plain text in `--lang` (default `ar`). `cerkel <command> --help` lists every flag.
377
+
378
+ | Group | Commands | Needs (in the admin) |
379
+ |---|---|---|
380
+ | `store` | `info` | any login |
381
+ | `product` | `list`, `get`, `create`, `update`, `status`, `publish`, `unpublish`, `duplicate` | view / edit products |
382
+ | `variant`, `image`, `category` | `set`, `add`, `delete`, `options` · `add`, `remove`, `reorder` · `list`, `set` | edit products |
383
+ | `price` | `set`, `import` | edit products |
384
+ | `offer`, `addon` | `list`, `set`, `delete` · `set`, `delete` | edit products + manage offers |
385
+ | `inventory` | `get`, `locations`, `low` · `adjust`, `set`, `tracking` | view stock · adjust stock |
386
+ | `collection` | `list`, `get` · `create`, `update`, `add`, `remove`, `reorder`, `publish`, `unpublish` | view products · manage content |
387
+ ## Store commands: content, discounts, delivery, reports
388
+
389
+ Every command acts on your login's store (or `--store <slug>`), prints a table (`--json` for
390
+ everything), and a command that changes something **shows the server's diff and asks first**:
391
+ `--yes` applies without asking (required in scripts), `--dry-run` only shows it. Amounts are in the
392
+ store's currency, as you would write them: `2.000` (JOD), `5000` (IQD, steps of 250).
393
+
394
+ ```sh
395
+ # Pages, policies and blog articles as files — like theme files
396
+ cerkel page pull --store justloofah-jo # pages/*.html, policies/*.html, blogs/<blog>/*.html
397
+ $EDITOR policies/refund-policy.html # settings in <!--cerkel {…} -->, bodies after <!--lang:ar--> / <!--lang:en-->
398
+ cerkel page push # only changed files; a page changed in the admin meanwhile is a conflict
399
+ cerkel page list --kind policy
400
+ cerkel page delete /pages/old-offer
401
+
402
+ cerkel menu pull && cerkel menu push # menus/<handle>.json — { "title", "items": [ … ] }
403
+
404
+ cerkel redirect add /old-offer /collections/all # --temporary for 302
405
+ cerkel redirect import redirects.csv # from,to[,301|302] per line
406
+ cerkel redirect list --sort hits
407
+
408
+ cerkel file upload https://example.com/hero.jpg # copied into the media library; prints its URL
409
+ cerkel file list --kind image
410
+
411
+ cerkel promo create EID10 --percent 10 --expires 2026-10-31 --once
412
+ cerkel promo create SHIP --free-delivery --min 20
413
+ cerkel promo deactivate EID10
414
+
415
+ cerkel zone list
416
+ cerkel zone set --name-ar "إربد" --name-en Irbid --governorates JO-IR --fee 3.000 --eta 2
417
+ cerkel zone set <zone-id> --fee 2.500
418
+
419
+ cerkel report sales --since 2026-10-01 --until 2026-10-07 [--grain week] [--compare] [--csv]
420
+ cerkel report products|governorates|funnel|couriers|customers|discounts|growth
421
+ cerkel report live # visitors right now
422
+ ```
423
+
424
+ **`cerkel call <tool> --args '{…}' [--allow-writes]`** calls any MCP tool with JSON arguments, like
425
+ `shopify store execute`. It is read-only by default: a tool the server marks as a write is refused
426
+ unless you pass `--allow-writes` (add `"dry_run": true` to see the change first). `cerkel call --list`
427
+ shows every tool and whether it reads or writes.
428
+
429
+ Reports need the reports permission; growth/ROAS and courier cash need finance access, `--csv` the
430
+ download permission, and customer phone numbers the permission to see them — exactly as in the admin.
431
+
432
+ **`cerkel whatsapp test <template> --to <+962…> [--values "v1|v2|…"] [--language ar]`** sends one approved
433
+ WhatsApp template from the store's number to a phone, with the values in `{{1}}, {{2}}…` order separated
434
+ by `|` — to try a template before customers get it. No order and no mapping are needed, and the store's
435
+ "Enable sending" switch does not apply; the store's token never leaves the server. It prints WhatsApp's
436
+ message id, or its error code (132001 = no such template in that language, 132000 = wrong number of
437
+ values). Needs the WhatsApp scope (`whatsapp.templates.manage`). A tap on the test message's buttons moves
438
+ no order (since 14 Oct 2026). Review buttons still rate that phone's latest real review request (72 h), so
439
+ tap those only from a phone with no recent order.
440
+
441
+ ```sh
442
+ cerkel whatsapp test raizn_confirmation_request --to +962780095281 --store justloofah \
443
+ --values "Qutiba|JL-TEST|13.000 د.أ|لوفة اسطوانية × 2"
444
+ ```
445
+
446
+ ## What this can never do
447
+
448
+ - Publish, unpublish or roll back a theme or a section with a command of its own. `cerkel theme
449
+ publish` explains that publishing happens in the admin after approval, or, for the store owner's
450
+ login and Cerkel team logins, immediately on `submit` (which prints the rollback link).
451
+ - Write to the live theme. The server refuses it (`not_a_draft`); that is the real check. As a
452
+ convenience, the CLI also refuses before sending anything when the folder's `.cerkel/state.json`
453
+ says it holds the live theme or anything other than a draft.
454
+ - Reach staff, roles or tokens, payments, refunds or courier cash, open or pause a store, delete an
455
+ order, or take an order to the courier (`dispatched`). The store commands below cover the catalogue,
456
+ prices, stock and collections, pages and menus, redirects, files, promo codes, delivery zones and
457
+ reports, and orders, customers and reviews — in the login's own store,
458
+ within what you may do there in the admin.
459
+ - Talk to any host but the configured API. `*.vercel.app` is refused (Jordanian ISPs reset those
460
+ connections), and plain `http` is only allowed for `localhost`.
461
+
462
+ The server authorises every call. The CLI's own checks are there for convenience, and when the
463
+ server refuses (`permission_denied`, `not_a_draft`, …), its message is shown verbatim. Two refusals
464
+ are about which login you used, and are said in one line instead, naming only this login's store and
465
+ the store you asked for: `This login is for X — run cerkel login --store Y` (`store_not_allowed`), and
466
+ `This login is not tied to a store any more — run cerkel login --store <slug>` (a 0.1.x login). A 401
467
+ (revoked or expired token) is never retried. On a 429, the CLI prints when you can retry: `dev` waits
468
+ it out and keeps your queued changes, and other commands exit 1.
469
+
470
+ ## CI
471
+
472
+ ```yaml
473
+ - run: npx @cerkel/cli theme check --store nahleh
474
+ env:
475
+ CERKEL_TOKEN: ${{ secrets.CERKEL_TOKEN }}
476
+ ```
477
+
478
+ When `CERKEL_TOKEN` is set, it is used instead of any stored login and is never saved. Create it in
479
+ Settings → MCP for the one store the pipeline checks. Without a `.cerkel/state.json`, `theme check
480
+ --store <slug>` validates every theme file in the folder. It exits 1 on any error; warnings alone
481
+ exit 0.
482
+
483
+ ## Options
484
+
485
+ | | |
486
+ |---|---|
487
+ | `--api <url>` / `CERKEL_API_URL` | API endpoint. The default is `https://admin.cerkel.ai/api/mcp` (ecom.raizn.com still serves). The URL a folder was pulled from is recorded in its state, and later commands in that folder reuse it. |
488
+ | `--store <slug>` | Which store's login to use (`login`, `logout`, `whoami`, `stores`, `theme list`, `theme pull`, `theme check`). Inside a theme folder the folder's store is used. |
489
+ | `CERKEL_TOKEN` | Token for CI (one store); takes precedence over every stored login. |
490
+ | `cerkel <command> --help` | Help for any command. |
491
+
492
+ The token is never printed, logged or written to project files. Every line the CLI prints passes
493
+ through a redactor.
494
+
495
+ ## Development
496
+
497
+ ```sh
498
+ pnpm --filter @cerkel/cli build # tsc → dist/, bin = dist/bin.js
499
+ pnpm --filter @cerkel/cli test # vitest, against a mocked endpoint (no network, no keychain)
500
+ node packages/cli/dist/bin.js --help
501
+ ```
502
+
503
+ The wire contract with the server is in `docs/theme-cli/contract.md`.
@@ -0,0 +1,42 @@
1
+ /** Where the CLI is allowed to send a token, decided once, before any request is built. */
2
+ export const DEFAULT_API_URL = "https://admin.cerkel.ai/api/mcp";
3
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
4
+ export class ApiUrlError extends Error {
5
+ name = "ApiUrlError";
6
+ }
7
+ /**
8
+ * Parse and vet an API URL. Refuses:
9
+ * - `*.vercel.app` — Jordanian ISPs reset any connection whose SNI or Host names it, so the
10
+ * command would hang or fail in the market we serve; custom domains only.
11
+ * - plain http anywhere but loopback — the token rides in a header and must not cross a network
12
+ * in clear text. Loopback http is allowed for local development against `pnpm dev:admin`.
13
+ * - credentials embedded in the URL, which would be sent alongside (and logged with) the request.
14
+ */
15
+ export function parseApiUrl(raw) {
16
+ let url;
17
+ try {
18
+ url = new URL(raw.trim());
19
+ }
20
+ catch {
21
+ throw new ApiUrlError(`"${raw}" is not a valid URL. Expected something like ${DEFAULT_API_URL}.`);
22
+ }
23
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
24
+ if (host === "vercel.app" || host.endsWith(".vercel.app")) {
25
+ throw new ApiUrlError(`Refusing ${url.origin}: *.vercel.app addresses are blocked by Jordanian ISPs. Use the store platform's custom domain (default ${DEFAULT_API_URL}).`);
26
+ }
27
+ if (url.username || url.password) {
28
+ throw new ApiUrlError("Refusing an API URL with a username or password in it. Use `cerkel login` for the token.");
29
+ }
30
+ if (url.protocol === "http:") {
31
+ if (!LOOPBACK.has(host)) {
32
+ throw new ApiUrlError(`Refusing ${url.origin}: the API must use https (plain http is only allowed for localhost).`);
33
+ }
34
+ }
35
+ else if (url.protocol !== "https:") {
36
+ throw new ApiUrlError(`Refusing ${url.protocol} URL: the API must use https.`);
37
+ }
38
+ url.hash = "";
39
+ return url;
40
+ }
41
+ /** Credentials are keyed by origin so `/api/mcp` vs `/api/mcp/` never splits one login in two. */
42
+ export const apiOrigin = (url) => url.origin;
package/dist/api.js ADDED
@@ -0,0 +1,48 @@
1
+ import { ServerError } from "./errors.js";
2
+ /**
3
+ * The one store a token is for, or null when it is not tied to exactly one (an old token that
4
+ * still reaches several stores, or none right now). Works with servers from before `store` existed.
5
+ */
6
+ export function oneStoreOf(r) {
7
+ if (r.store && typeof r.store.slug === "string")
8
+ return r.store;
9
+ if (r.store === undefined && Array.isArray(r.stores) && r.stores.length === 1)
10
+ return r.stores[0];
11
+ return null;
12
+ }
13
+ export const listStores = (c) => c.call("list_stores", {});
14
+ /**
15
+ * The server answers `list_themes` with a bare array today; accept `{ themes }` too so a later
16
+ * server that wraps it (structuredContent is meant to be an object) does not break old CLIs.
17
+ */
18
+ export async function listThemes(c, store) {
19
+ const r = await c.call("list_themes", { store });
20
+ if (Array.isArray(r))
21
+ return r;
22
+ if (r && typeof r === "object" && Array.isArray(r.themes))
23
+ return r.themes;
24
+ throw new ServerError("Unexpected list_themes answer from the server.", 200);
25
+ }
26
+ export const createDraft = (c, store, name, fromThemeId) => c.call("create_draft", { store, name, ...(fromThemeId ? { fromThemeId } : {}) });
27
+ export const getThemeFiles = (c, store, themeId) => c.call("get_theme_files", { store, ...(themeId ? { themeId } : {}) });
28
+ export const putThemeFiles = (c, store, themeId, files, force) => c.call("put_theme_files", { store, themeId, files, ...(force ? { force: true } : {}) });
29
+ export const validateThemeFiles = (c, store, themeId, files) => c.call("validate_theme_files", { store, ...(themeId ? { themeId } : {}), files });
30
+ export const getPreviewUrl = (c, store, themeId) => c.call("get_preview_url", { store, themeId });
31
+ export const submitForApproval = (c, store, themeId, title, description) => c.call("submit_for_approval", {
32
+ store,
33
+ themeId,
34
+ title,
35
+ ...(description ? { description } : {}),
36
+ });
37
+ export const listSectionStarters = (c) => c.call("list_section_starters", {});
38
+ export const deleteThemeFiles = (c, store, themeId, files) => c.call("delete_theme_files", { store, themeId, files });
39
+ export const listThemeFileVersions = (c, store, filePath, limit) => c.call("list_theme_file_versions", { store, path: filePath, ...(limit ? { limit } : {}) });
40
+ export const restoreThemeFileVersion = (c, store, themeId, filePath, version) => c.call("restore_theme_file_version", { store, themeId, path: filePath, version });
41
+ /** A localized name in the reader's language: English for a terminal, else Arabic, else whatever is there. */
42
+ export function nameOf(name) {
43
+ if (!name)
44
+ return "";
45
+ if (typeof name === "string")
46
+ return name;
47
+ return name["en"] ?? name["ar"] ?? Object.values(name)[0] ?? "";
48
+ }