@cerkel/cli 0.0.0-stage → 1.0.0

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