@greatstore/cli 0.0.26 → 0.0.28
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.
- package/CHANGELOG.md +40 -0
- package/README.md +69 -29
- package/dist/cli.js +1187 -641
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -92,7 +92,7 @@ async function captureLoopbackToken(options) {
|
|
|
92
92
|
const open = options.openBrowser ?? ((url) => openInBrowser(url, options.browserCmdEnv));
|
|
93
93
|
const server = http.createServer();
|
|
94
94
|
try {
|
|
95
|
-
await new Promise((
|
|
95
|
+
await new Promise((resolve8) => server.listen(0, "127.0.0.1", resolve8));
|
|
96
96
|
const address = server.address();
|
|
97
97
|
const redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
|
|
98
98
|
const authUrl = `${options.navBaseUrl}/connect_oauth_done?redirect_uri=${encodeURIComponent(redirectUri)}&state=${encodeURIComponent(state)}`;
|
|
@@ -104,7 +104,7 @@ async function captureLoopbackToken(options) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
function waitForCallback(server, expectedState, timeoutMs) {
|
|
107
|
-
return new Promise((
|
|
107
|
+
return new Promise((resolve8, reject) => {
|
|
108
108
|
let settled = false;
|
|
109
109
|
const settle = (fn) => {
|
|
110
110
|
if (settled) return;
|
|
@@ -143,7 +143,7 @@ function waitForCallback(server, expectedState, timeoutMs) {
|
|
|
143
143
|
res.writeHead(200, { "content-type": "text/html" });
|
|
144
144
|
res.end(SUCCESS_HTML);
|
|
145
145
|
clearTimeout(timer);
|
|
146
|
-
settle(() =>
|
|
146
|
+
settle(() => resolve8({ token }));
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
}
|
|
@@ -303,6 +303,15 @@ function findGsrc(cwd) {
|
|
|
303
303
|
dir = parent;
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
|
+
function resolveAdminStore(args) {
|
|
307
|
+
const explicit = flagString(args.flags, "store")?.trim();
|
|
308
|
+
if (explicit) return explicit;
|
|
309
|
+
const fromRc = findGsrc(process.cwd());
|
|
310
|
+
if (fromRc) return fromRc;
|
|
311
|
+
throw new StoreResolutionError(
|
|
312
|
+
"No store selected. Pass --store <slug>, or run from a project with a .gsrc."
|
|
313
|
+
);
|
|
314
|
+
}
|
|
306
315
|
function apiBaseFor(slug, env = process.env) {
|
|
307
316
|
const override = env.GS_API_BASE?.trim();
|
|
308
317
|
if (override) return stripTrailingSlash(override);
|
|
@@ -395,7 +404,7 @@ var KNOWN_ERROR_MESSAGES = {
|
|
|
395
404
|
// commitDraft() / promote() / unpublish() — StoreComponentFailure
|
|
396
405
|
not_found: { message: "Component not found on the server." },
|
|
397
406
|
concurrent_write: {
|
|
398
|
-
message: "Another change landed before this one. Run `gs pull` and try again."
|
|
407
|
+
message: "Another change landed before this one. Run `gs apps pull` and try again."
|
|
399
408
|
},
|
|
400
409
|
name_mismatch: {
|
|
401
410
|
message: "Manifest `name` does not match the component folder name.",
|
|
@@ -524,193 +533,614 @@ function whoamiCommand() {
|
|
|
524
533
|
return expired ? 1 : 0;
|
|
525
534
|
}
|
|
526
535
|
|
|
527
|
-
// src/commands/
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
`);
|
|
539
|
-
|
|
536
|
+
// src/commands/skill.ts
|
|
537
|
+
import * as fs3 from "fs";
|
|
538
|
+
import * as os2 from "os";
|
|
539
|
+
import * as path3 from "path";
|
|
540
|
+
|
|
541
|
+
// src/template.ts
|
|
542
|
+
import { readdirSync, readFileSync as readFileSync3 } from "fs";
|
|
543
|
+
import { fileURLToPath } from "url";
|
|
544
|
+
var cache = null;
|
|
545
|
+
function loadTemplate() {
|
|
546
|
+
if (cache) return cache;
|
|
547
|
+
cache = true ? JSON.parse('{"component/component.tsx":"import React from \\"react\\";\\n\\n// Add your tool args (matching manifest.json#inputSchema.properties)\\n// alongside the GreatStore-injected lifecycle props. See AGENTS.md for\\n// what each lifecycle prop does.\\ninterface Props {\\n onSendMessage: (text: string) => void;\\n onCallTool: (name: string, args: Record<string, unknown>) => void;\\n onClose: () => void;\\n}\\n\\n// See AGENTS.md for the design rules (em-based sizing, brand CSS\\n// variables), the lifecycle props, and how to write an async,\\n// backend-backed component.\\nexport default function __GS_PASCAL__(_props: Props): React.ReactElement {\\n return (\\n <div\\n style={{\\n padding: \\"1em\\",\\n border: \\"1px solid var(--color-border-default)\\",\\n borderRadius: \\"var(--radius-lg)\\",\\n background: \\"var(--color-surface)\\",\\n color: \\"var(--color-foreground)\\",\\n fontFamily: \\"var(--font-sans)\\",\\n }}\\n >\\n <strong>__GS_NAME__</strong> \u2014 hello from your component!\\n </div>\\n );\\n}\\n","component/manifest.json":"{\\n \\"name\\": \\"__GS_NAME__\\",\\n \\"displayName\\": \\"__GS_DISPLAY_NAME__\\",\\n \\"description\\": \\"Renders the __GS_NAME__ widget.\\",\\n \\"displayMode\\": \\"inline\\",\\n \\"inputSchema\\": {\\n \\"type\\": \\"object\\",\\n \\"properties\\": {}\\n }\\n}\\n","root/.gitignore":"node_modules/\\ncomponents/*/bundle.js\\ncomponents/*/.gssync.json\\n*.tsbuildinfo\\n.DS_Store\\n","root/.gsrc":"{\\n \\"store\\": \\"__GS_STORE__\\"\\n}\\n","root/AGENTS.md":"# AGENTS.md\\n\\nGuidance for AI coding agents working on the components in this project.\\n`CLAUDE.md` and `GEMINI.md` are symlinks to this file.\\n\\nThese components render inside arbitrary publisher websites (the embed\\nwidget mounts on a host page) as well as the GreatStore storefront. You\\ncontrol neither the host page\'s base font size nor its color scheme, and\\nthe store owner controls the look from GreatStore theme settings. The\\nrules below keep components portable across both.\\n\\n## Design requirements\\n\\n### Never use `rem` for fonts and sizing \u2014 always use `em`\\n\\n`rem` resolves against the host page\'s root font size, which is\\narbitrary and outside our control. A host that sets `html { font-size:\\n8px }` (or 200%) will shrink or blow up every `rem`-based dimension.\\n`em` resolves against the component\'s own font size, so it stays\\nself-consistent wherever the component is mounted.\\n\\nUse `em` for font sizes, padding, margins, gaps, widths, heights,\\nand any other length. Borders may stay in `px` (hairlines should not\\nscale with text).\\n\\n```tsx\\n// Bad \u2014 breaks on hosts with a non-default root font size\\n<div style={{ padding: \\"1rem\\", fontSize: \\"0.875rem\\" }} />\\n\\n// Good\\n<div style={{ padding: \\"1em\\", fontSize: \\"0.875em\\" }} />\\n```\\n\\n### Don\'t hardcode colors, fonts, or corner radius \u2014 use GreatStore brand variables\\n\\nThe store owner themes the assistant from GreatStore settings. Hardcoded\\nvalues ignore that and make the component clash with the rest of the\\nstore. Read from the CSS variables GreatStore injects on the surface\\ninstead, so the component restyles itself when the theme changes.\\n\\nAvailable variables:\\n\\n| Purpose | Variable |\\n| --- | --- |\\n| Surface background | `--color-surface` |\\n| Secondary surface | `--color-surface-secondary` |\\n| Accent surface | `--color-surface-accent` |\\n| Hover surface | `--color-surface-hover` |\\n| Foreground text | `--color-foreground` |\\n| Secondary text | `--color-foreground-secondary` |\\n| Muted text | `--color-foreground-muted` |\\n| Accent text | `--color-foreground-accent` |\\n| Border | `--color-border-default` |\\n| Focus border | `--color-border-focus` |\\n| Primary / brand | `--color-primary` |\\n| Text on primary | `--color-primary-foreground` |\\n| Primary hover | `--color-primary-hover` |\\n| Primary muted | `--color-primary-muted` |\\n| Text on primary muted | `--color-primary-muted-foreground` |\\n| Primary tint | `--color-primary-tint` |\\n| Link | `--color-link` |\\n| Font family | `--font-sans` |\\n| Corner radius (scale) | `--radius-xs` \u2026 `--radius-4xl`, `--radius-pill` |\\n\\n```tsx\\n// Bad \u2014 hardcoded, ignores the store\'s theme\\n<button style={{ background: \\"#4f46e5\\", borderRadius: 8, fontFamily: \\"Inter\\" }} />\\n\\n// Good\\n<button\\n style={{\\n background: \\"var(--color-primary)\\",\\n color: \\"var(--color-primary-foreground)\\",\\n borderRadius: \\"var(--radius-lg)\\",\\n fontFamily: \\"var(--font-sans)\\",\\n }}\\n/>\\n```\\n\\nProvide a sensible fallback only when a value might be absent, e.g.\\n`var(--color-primary, currentColor)`.\\n\\n## Component props\\n\\nEach component receives its tool args (the fields you declare in\\n`manifest.json#inputSchema.properties`) plus these GreatStore-injected\\nlifecycle props, which are always present:\\n\\n| Prop | What it does |\\n| --- | --- |\\n| `onSendMessage(text)` | Send text into the chat as if the shopper typed it. |\\n| `onCallTool(name, args)` | Chain into another remote-component tool by name. |\\n| `onClose()` | Dismiss the host slot. Over-input clears the overlay, fullscreen reverts the pane, inline is a no-op. |\\n\\n## Async components (backend-backed, render-blocking data)\\n\\nIf a component must load data from a backend/API before it can render\\ncorrectly, make it async \u2014 don\'t render an empty shell and fetch in a\\n`useEffect`. Set `\\"async\\": true` in `manifest.json` and export an async\\ndefault. GreatStore waits for your promise (showing a normal loading\\nstate, so you don\'t render your own placeholder), then renders what it\\nresolves to. Components that render purely from their props stay\\nsynchronous.\\n\\nA thrown error is a **retry signal**: the in-store AI sees it and\\nusually re-calls the tool. So only throw when a *different* call could\\nhelp:\\n\\n1. `await` the backend call, then return the finished JSX.\\n2. Validate the AI-passed props first and throw on bad input \u2014 the AI\\n can fix the args and retry. (Don\'t validate the API\'s *output* and\\n throw: the AI can\'t fix the backend, it\'ll just loop.)\\n3. Broadcast a backend failure ONLY when retrying differently could\\n succeed, and say what to change (e.g. empty search \u2192 \\"try a broader\\n keyword\\"). For idempotent failures (500, timeout, missing record)\\n re-running the same call changes nothing \u2014 render a graceful fallback\\n instead of throwing.\\n\\n```tsx\\nexport default async function Example(props: Props) {\\n if (!props.query?.trim()) throw new Error(\\"missing required prop: query\\");\\n const res = await fetch(`/api/search?q=${encodeURIComponent(props.query)}`);\\n if (res.ok) {\\n const { results } = await res.json();\\n if (results.length === 0)\\n throw new Error(`no results for \\"${props.query}\\" \u2014 try a broader keyword`);\\n return <ul>{/* render results */}</ul>;\\n }\\n return <p>Couldn\'t load results right now.</p>; // idempotent: don\'t throw\\n}\\n```\\n","root/README.md":"# GreatStore components\\n\\nCustom React components published to your GreatStore tenant. Each\\ncomponent lives in its own folder under `components/`.\\n\\n```\\nnpm install\\ngs apps init <component_name> # add a new component\\ngs apps build # builds every components/<name>/bundle.js\\ngs apps push # uploads every changed component as a draft\\ngs apps publish <component_name> # promote a specific component to live\\n```\\n\\n- `gs apps push` (no args) hashes each component and only uploads the ones\\n that have changed since the last sync.\\n- `gs apps pull` (no args) refreshes every remote component into\\n `components/<name>/`. Components with unsaved local edits are skipped\\n with a warning; pass `--force` to overwrite.\\n\\nSee `AGENTS.md` for the design rules every component must follow\\n(em-based sizing, brand CSS variables) \u2014 it doubles as guidance for AI\\ncoding agents (`CLAUDE.md` / `GEMINI.md` symlink to it).\\n","root/package.json":"{\\n \\"name\\": \\"greatstore-components\\",\\n \\"version\\": \\"0.0.1\\",\\n \\"private\\": true,\\n \\"type\\": \\"module\\",\\n \\"scripts\\": {\\n \\"build\\": \\"gs apps build\\",\\n \\"push\\": \\"gs apps build && gs apps push\\"\\n },\\n \\"dependencies\\": {\\n \\"react\\": \\"^19.0.0\\",\\n \\"react-dom\\": \\"^19.0.0\\"\\n },\\n \\"devDependencies\\": {\\n \\"@types/react\\": \\"^19.0.0\\",\\n \\"@types/react-dom\\": \\"^19.0.0\\",\\n \\"@vitejs/plugin-react\\": \\"^4.3.0\\",\\n \\"typescript\\": \\"^5.6.0\\",\\n \\"vite\\": \\"^5.4.0\\"\\n }\\n}\\n","root/tsconfig.json":"{\\n \\"compilerOptions\\": {\\n \\"target\\": \\"ES2022\\",\\n \\"module\\": \\"ESNext\\",\\n \\"moduleResolution\\": \\"Bundler\\",\\n \\"jsx\\": \\"react-jsx\\",\\n \\"lib\\": [\\"ES2022\\", \\"DOM\\"],\\n \\"strict\\": true,\\n \\"esModuleInterop\\": true,\\n \\"skipLibCheck\\": true,\\n \\"isolatedModules\\": true,\\n \\"noEmit\\": true\\n },\\n \\"include\\": [\\"components/**/component.tsx\\", \\"vite.config.ts\\"]\\n}\\n","root/vite.config.ts":"import { defineConfig } from \\"vite\\";\\nimport react from \\"@vitejs/plugin-react\\";\\n\\n// Real builds happen in `gs build` (one Vite invocation per\\n// component, externals + runtime shim paths owned by the CLI). This\\n// file exists only so editors / language servers can resolve the\\n// React plugin when inspecting components/*/component.tsx.\\nexport default defineConfig({\\n plugins: [react()],\\n});\\n"}') : readTreeFromDisk(new URL("../template/", import.meta.url));
|
|
548
|
+
return cache;
|
|
549
|
+
}
|
|
550
|
+
var TOKENS = {
|
|
551
|
+
name: "__GS_NAME__",
|
|
552
|
+
pascalName: "__GS_PASCAL__",
|
|
553
|
+
displayName: "__GS_DISPLAY_NAME__",
|
|
554
|
+
store: "__GS_STORE__"
|
|
555
|
+
};
|
|
556
|
+
function applyTemplate(content, vars) {
|
|
557
|
+
let out = content;
|
|
558
|
+
for (const key of Object.keys(TOKENS)) {
|
|
559
|
+
const value = vars[key];
|
|
560
|
+
if (value !== void 0) out = out.split(TOKENS[key]).join(value);
|
|
540
561
|
}
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
updated: c.live?.updatedAt ?? c.draft?.updatedAt ?? "",
|
|
546
|
-
permalink: c.permalink ?? ""
|
|
547
|
-
}));
|
|
548
|
-
const widths = {
|
|
549
|
-
name: Math.max(4, ...rows.map((r) => r.name.length)),
|
|
550
|
-
draft: Math.max(5, ...rows.map((r) => r.draft.length)),
|
|
551
|
-
live: Math.max(4, ...rows.map((r) => r.live.length)),
|
|
552
|
-
updated: Math.max(7, ...rows.map((r) => r.updated.length))
|
|
553
|
-
};
|
|
554
|
-
const header = `${pad("NAME", widths.name)} ${pad("DRAFT", widths.draft)} ${pad("LIVE", widths.live)} ${pad("UPDATED", widths.updated)} LINK`;
|
|
555
|
-
process.stdout.write(header + "\n");
|
|
556
|
-
for (const r of rows) {
|
|
557
|
-
process.stdout.write(
|
|
558
|
-
`${pad(r.name, widths.name)} ${pad(r.draft, widths.draft)} ${pad(r.live, widths.live)} ${pad(r.updated, widths.updated)} ${r.permalink}
|
|
559
|
-
`
|
|
562
|
+
const leftover = out.match(/__GS_[A-Z_]+__/);
|
|
563
|
+
if (leftover) {
|
|
564
|
+
throw new Error(
|
|
565
|
+
`internal: template placeholder ${leftover[0]} was not provided`
|
|
560
566
|
);
|
|
561
567
|
}
|
|
568
|
+
return out;
|
|
562
569
|
}
|
|
563
|
-
function pad(s, width) {
|
|
564
|
-
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// src/commands/pull.ts
|
|
568
|
-
import * as fs4 from "fs";
|
|
569
|
-
import * as path4 from "path";
|
|
570
570
|
|
|
571
|
-
// src/
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
function hashString(text) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
function hashFile(filePath) {
|
|
580
|
-
if (!fs3.existsSync(filePath)) return null;
|
|
581
|
-
return hashString(fs3.readFileSync(filePath, "utf8"));
|
|
582
|
-
}
|
|
583
|
-
function computeComponentHashes(componentDir) {
|
|
584
|
-
const manifestHash = hashFile(path3.join(componentDir, "manifest.json"));
|
|
585
|
-
if (manifestHash === null) {
|
|
586
|
-
throw new Error(`Missing manifest.json in ${componentDir}`);
|
|
571
|
+
// src/skill.ts
|
|
572
|
+
var SKILL_DIR_NAME = "greatstore";
|
|
573
|
+
var cache2 = null;
|
|
574
|
+
function loadSkill() {
|
|
575
|
+
if (cache2) return cache2;
|
|
576
|
+
cache2 = true ? JSON.parse('{"SKILL.md":"---\\nname: greatstore\\ndescription: Build AI-powered shopping experiences with GreatStore on a merchant\'s website and store. Use when installing the GreatStore chat widget on a site, generating AI content for custom UI with generateStructuredContent, adding chat entry points (sendMessage), letting the assistant act on the page via WebMCP page tools (document.modelContext.registerTool), authoring custom in-chat React components with the gs CLI, administering the store from the CLI (gs configure, gs connectors \u2014 origin allowlists, CSP hosts, MCP connectors), setting up web push re-engagement, or connecting AI agents to a store\'s MCP endpoints. Covers setup, schema design, caching behavior, component authoring, store administration, and ready-made recipes.\\n---\\n\\n# Building with GreatStore\\n\\nGreatStore gives a store an AI shopping assistant on two surfaces: a hosted\\nstorefront at `https://<slug>.greatstore.ai/`, and an embedded chat widget on\\nthe merchant\'s own site, installed with one script tag:\\n\\n```html\\n<script src=\\"https://my-store.greatstore.ai/embed.js\\"></script>\\n```\\n\\nEverything else a site can do with GreatStore is documented in the\\nreferences below. Read the one that matches the task before writing code \u2014\\neach facet has non-obvious rules (caching, grounding, result shapes, design\\nconstraints) that the references spell out.\\n\\n## Index\\n\\n| Goal | Use | Read |\\n|---|---|---|\\n| Install the widget; control the panel; readiness, events, troubleshooting | `window.GreatStore` SDK | [references/embed-api.md](references/embed-api.md) |\\n| AI-generated, catalog-grounded content rendered in **your own HTML/CSS** (highlights, comparisons, FAQs, gift guides) | `generateStructuredContent(schema, prompt)` | [references/structured-content.md](references/structured-content.md) |\\n| Copy-paste on-page experiences | recipes built on the SDK | the [Recipes](#recipes) table below |\\n| Contextual **conversation entry points** anywhere on the page | `sendMessage(text)`, `open()`, `?gs_chat=open` | [references/embed-api.md](references/embed-api.md) |\\n| Let the assistant **act on the page** (cart, navigation, filters) | WebMCP: `document.modelContext.registerTool(...)` | [references/embed-api.md](references/embed-api.md) |\\n| Custom **interactive UI inside the chat** (configurators, quizzes, size guides, booking forms) | remote components shipped with the `gs` CLI | [references/chat-components.md](references/chat-components.md) |\\n| **Re-engage shoppers** with browser notifications | merchant-hosted `gs.js` + `enableNotifications()` | [references/push-notifications.md](references/push-notifications.md) |\\n| Connect **AI agents** to the store (shopping tools over MCP, CLI docs for coding agents) | the store\'s MCP endpoints | [references/agents-and-mcp.md](references/agents-and-mcp.md) |\\n| **Administer the store** \u2014 read config/connectors to ground decisions; self-serve origin allowlists and CSP hosts | `gs configure`, `gs connectors` | [references/store-admin.md](references/store-admin.md) |\\n\\n## Three things to know before any of it\\n\\n- **Every code sample in this skill is a reference implementation, not a\\n drop-in.** Samples are framework-free vanilla JS so they stay portable \u2014\\n re-express the same logic in the conventions of the repo you\'re working\\n in (React, Vue, Shopify Liquid sections, Svelte, \u2026); never retrofit the\\n sample as-is into a codebase with its own framework.\\n- The page\'s domain **must be in the store\'s allowed domains**. If it isn\'t,\\n nothing works and the console shows\\n `[GreatStore] Chat is unavailable on <origin>\u2026` \u2014 check this first whenever\\n the embed appears dead. You can verify and fix it yourself with the `gs`\\n CLI (read-merge-write on `extraOrigins` \u2014 see\\n [references/store-admin.md](references/store-admin.md)).\\n- Every SDK call is safe immediately after the script tag \u2014 pre-mount calls\\n queue and replay in order, and the SDK pre-warms itself in the background.\\n\\n## Recipes\\n\\nComplete, framework-free implementations, one per file. Shared conventions:\\ncontainers start `hidden` and reveal only on success (a failed generation\\nchanges nothing); generated strings render via `textContent`, never\\n`innerHTML`; every recipe guards on `window.GreatStore`; prompts stay\\ndeterministic per page so repeat renders hit the cache.\\nInline real product/page data into prompts where the platform exposes it.\\n\\n| Recipe | What it builds | Use it for |\\n|---|---|---|\\n| [GreatStore launchers](recipes/launchers.md) | Horizontally scrollable AI-generated chips \u2014 engaging first-person questions about the current page; tap to ask the assistant. | Instant engagement on any page type \u2014 product, collection, blog, home. Simple yet effective; start here. |\\n| [Ask-about-this entry points](recipes/ask-about-this.md) | One-line `sendMessage` buttons wired to existing page elements. | Size guides, shipping rows, out-of-stock badges \u2014 anywhere a shopper hesitates. |\\n| [Product FAQ accordion](recipes/product-faq.md) | Grounded pre-purchase Q&A with an \\"ask us\\" handoff into chat. | Product pages; answering objections before they cost the sale. |\\n| [Comparison table](recipes/comparison-table.md) | AI-picked representative products compared on category-relevant criteria. | Collection pages where shoppers weigh options. |\\n| [Complete the look](recipes/complete-the-look.md) | Catalog-grounded cross-sell strip with a reason per pick. | Product pages; raising order value with genuine pairings. |\\n| [Campaign hero](recipes/campaign-hero.md) | Seasonal homepage hero copy, cache-keyed to the ISO week. | Fresh homepage/campaign copy without manual rewrites. |\\n| [Gift finder funnel](recipes/gift-finder-funnel.md) | Quiz teaser \u2192 chat handoff \u2192 page-tool navigation; the full funnel. | Gifting seasons, guided discovery, homepage engagement. |\\n| [Page-action suite](recipes/page-action-suite.md) | WebMCP cart/page tools every conversation can use. | Any site where the assistant should act, not just advise. |\\n| [Custom chat button](recipes/custom-chat-button.md) | Branded launcher synced via `ready` + `open`/`close` events. | Replacing the default launcher with the site\'s own UI. |\\n\\n## How the facets combine\\n\\nThe strongest pattern is the **teaser \u2192 conversation \u2192 action** funnel:\\n`generateStructuredContent` renders a grounded teaser in the merchant\'s\\ndesign; each option\'s click handler calls `sendMessage` with the shopper\'s\\nchoice, dropping them into a conversation with momentum; WebMCP page tools\\nand custom chat components let that conversation actually do things \u2014 add to\\ncart, configure a product, book a slot \u2014 so it ends in a conversion, not a\\ncopy-paste. The [gift finder funnel](recipes/gift-finder-funnel.md)\\nrecipe is this funnel end to end.\\n","recipes/ask-about-this.md":"# \\"Ask about this\\" entry points (`sendMessage` only)\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\nZero-generation, instant, and often the biggest engagement win per line of\\ncode. Sprinkle context-aware buttons wherever a shopper hesitates:\\n\\n```js\\nconst gs = window.GreatStore;\\nif (gs) {\\n sizeGuideLink.addEventListener(\\"click\\", (e) => {\\n e.preventDefault();\\n gs.sendMessage(`How does the sizing run on \\"${productName}\\"? I usually wear a medium.`);\\n });\\n\\n shippingRow.querySelector(\\".ask\\").addEventListener(\\"click\\", () => {\\n gs.sendMessage(`What are the shipping options and times for \\"${productName}\\"?`);\\n });\\n\\n outOfStockBadge?.addEventListener(\\"click\\", () => {\\n gs.sendMessage(`\\"${productName}\\" looks out of stock \u2014 is there anything similar in stock?`);\\n });\\n}\\n```\\n\\nWrite each message as something the shopper would plausibly say \u2014 it appears\\nin the transcript as their message.\\n","recipes/campaign-hero.md":"# Campaign hero with deliberate variation\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\nCache-friendly variation: key the prompt to a low-cardinality period, not to\\ntime itself.\\n\\n```js\\n// ISO week number \u2192 one generation per store per week, shared by everyone.\\nconst week = (d => {\\n const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));\\n t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7));\\n return `${t.getUTCFullYear()}-W${Math.ceil((((t - Date.UTC(t.getUTCFullYear(), 0, 1)) / 864e5) + 1) / 7)}`;\\n})(new Date());\\n\\nconst data = await window.GreatStore.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n headline: { type: \\"string\\", maxLength: 60 },\\n subline: { type: \\"string\\", maxLength: 120 },\\n featuredProductName: { type: \\"string\\", nullable: true },\\n ctaChatMessage: { type: \\"string\\", maxLength: 120 },\\n },\\n required: [\\"headline\\", \\"subline\\", \\"ctaChatMessage\\"],\\n },\\n `Variant ${week}. Write a homepage hero for this store: a headline and ` +\\n `subline spotlighting a real product or category that fits the current ` +\\n `season, plus ctaChatMessage \u2014 the first-person message a shopper ` +\\n `would send to start shopping for it.`\\n);\\n\\nheroHeadline.textContent = data.headline;\\nheroSubline.textContent = data.subline;\\nheroCta.addEventListener(\\"click\\", () =>\\n window.GreatStore.sendMessage(data.ctaChatMessage)\\n);\\nhero.hidden = false;\\n```\\n","recipes/comparison-table.md":"# Collection-page comparison table\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\n```html\\n<section id=\\"gs-compare\\" hidden>\\n <h3>Quick comparison</h3>\\n <table><thead id=\\"gsc-head\\"></thead><tbody id=\\"gsc-body\\"></tbody></table>\\n</section>\\n\\n<script>\\n (async () => {\\n if (!window.GreatStore?.generateStructuredContent) return;\\n const collection = \\"winter jackets\\"; // \u2190 your collection name\\n try {\\n const data = await window.GreatStore.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n criteria: {\\n type: \\"array\\", minItems: 2, maxItems: 4,\\n items: { type: \\"string\\", maxLength: 25 },\\n },\\n rows: {\\n type: \\"array\\", minItems: 2, maxItems: 4,\\n items: {\\n type: \\"object\\",\\n properties: {\\n productName: { type: \\"string\\" },\\n bestFor: { type: \\"string\\", maxLength: 60 },\\n values: {\\n type: \\"array\\",\\n items: { type: \\"string\\", maxLength: 60 },\\n },\\n },\\n required: [\\"productName\\", \\"bestFor\\", \\"values\\"],\\n },\\n },\\n },\\n required: [\\"criteria\\", \\"rows\\"],\\n },\\n `The shopper is browsing the \\"${collection}\\" collection. Pick the ` +\\n `3-4 most representative products and compare them. Choose the ` +\\n `criteria a shopper actually decides on for this category. ` +\\n `\\"values\\" must align with \\"criteria\\" by index. Add a one-line ` +\\n `\\"bestFor\\" verdict per product. Only use real products and facts.`\\n );\\n\\n const head = document.getElementById(\\"gsc-head\\");\\n const hr = document.createElement(\\"tr\\");\\n for (const h of [\\"Product\\", ...data.criteria, \\"Best for\\"]) {\\n const th = document.createElement(\\"th\\");\\n th.textContent = h;\\n hr.append(th);\\n }\\n head.append(hr);\\n\\n const body = document.getElementById(\\"gsc-body\\");\\n for (const row of data.rows) {\\n const tr = document.createElement(\\"tr\\");\\n const cells = [row.productName, ...(row.values ?? []), row.bestFor];\\n for (let i = 0; i < data.criteria.length + 2; i++) {\\n const td = document.createElement(\\"td\\");\\n td.textContent = cells[i] ?? \\"\u2014\\";\\n tr.append(td);\\n }\\n body.append(tr);\\n }\\n document.getElementById(\\"gs-compare\\").hidden = false;\\n } catch {}\\n })();\\n</script>\\n```\\n\\nNote the index-aligned `values`/`criteria` trick and the `?? \\"\u2014\\"` guard \u2014\\ngrounding means a value the catalog can\'t support may be missing.\\n","recipes/complete-the-look.md":"# \\"Complete the look\\" cross-sell strip\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\n```js\\nconst data = await window.GreatStore.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n intro: { type: \\"string\\", maxLength: 90 },\\n picks: {\\n type: \\"array\\", minItems: 2, maxItems: 4,\\n items: {\\n type: \\"object\\",\\n properties: {\\n productName: { type: \\"string\\" },\\n reason: { type: \\"string\\", maxLength: 90 },\\n imageUrl: { type: \\"string\\", nullable: true },\\n productUrl: { type: \\"string\\", nullable: true },\\n },\\n required: [\\"productName\\", \\"reason\\"],\\n },\\n },\\n },\\n required: [\\"picks\\"],\\n },\\n `The shopper is viewing \\"${productName}\\". From the store\'s real catalog, ` +\\n `pick 2-4 products that genuinely pair with it and say why each one ` +\\n `completes the look or use-case. Include image and product URLs only ` +\\n `if known.`\\n);\\n\\nfor (const pick of data.picks) {\\n const card = document.createElement(\\"a\\");\\n if (pick.productUrl) card.href = pick.productUrl;\\n if (pick.imageUrl) {\\n const img = document.createElement(\\"img\\");\\n img.src = pick.imageUrl;\\n img.alt = pick.productName;\\n img.loading = \\"lazy\\";\\n card.append(img);\\n }\\n const name = document.createElement(\\"strong\\");\\n name.textContent = pick.productName;\\n const why = document.createElement(\\"p\\");\\n why.textContent = pick.reason;\\n card.append(name, why);\\n strip.append(card);\\n}\\nstrip.hidden = false;\\n```\\n\\n`imageUrl`/`productUrl` are `nullable` and optional in the render \u2014 the\\ngrounding contract means they\'re only present when the catalog actually has\\nthem. Never `require` URLs.\\n","recipes/custom-chat-button.md":"# Custom chat button synced to panel state\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\nReplace the default launcher with your own UI using the lifecycle surface:\\n\\n```js\\nconst gs = window.GreatStore;\\nconst btn = document.getElementById(\\"my-chat-button\\");\\n\\ngs.ready.then(() => { btn.hidden = false; });\\nbtn.addEventListener(\\"click\\", () => gs.toggle());\\n\\ngs.on(\\"open\\", () => btn.setAttribute(\\"aria-expanded\\", \\"true\\"));\\ngs.on(\\"close\\", () => btn.setAttribute(\\"aria-expanded\\", \\"false\\"));\\n```\\n\\n`ready` resolves even when `.then()` is attached after mount, so script\\nordering doesn\'t matter. The `open`/`close` events also fire for opens the\\nSDK triggers itself (`sendMessage`, `?gs_chat=open`), keeping your button\\nstate honest.\\n","recipes/gift-finder-funnel.md":"# Gift finder funnel (teaser \u2192 conversation \u2192 action)\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\nThe flagship pattern: an AI-generated quiz card in your design, whose answers\\ndrop the shopper into a chat that can act on the page.\\n\\n```html\\n<section id=\\"gift-finder\\" hidden>\\n <h3 id=\\"gf-question\\"></h3>\\n <div id=\\"gf-options\\"></div>\\n</section>\\n\\n<script>\\n (async () => {\\n const gs = window.GreatStore;\\n if (!gs?.generateStructuredContent) return;\\n\\n // Tools the resulting conversation can use. Registered once the\\n // SDK is ready (so document.modelContext exists); the assistant\\n // discovers them on its next turn automatically.\\n gs.ready.then(() => {\\n document.modelContext.registerTool({\\n name: \\"go_to_product\\",\\n description:\\n \\"Navigate the shopper to a product page on this site. Use when \\" +\\n \\"the shopper picks a product they want to see.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: { url: { type: \\"string\\" } },\\n required: [\\"url\\"],\\n },\\n execute({ url }) {\\n const u = new URL(String(url), location.origin);\\n if (u.origin !== location.origin) throw new Error(\\"Only same-site URLs allowed\\");\\n location.assign(u.href);\\n return { content: [{ type: \\"text\\", text: \\"Navigating.\\" }] };\\n },\\n });\\n });\\n\\n try {\\n const data = await gs.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n question: { type: \\"string\\", maxLength: 80 },\\n options: {\\n type: \\"array\\",\\n minItems: 3,\\n maxItems: 4,\\n items: {\\n type: \\"object\\",\\n properties: {\\n label: { type: \\"string\\", maxLength: 30 },\\n chatMessage: { type: \\"string\\", maxLength: 140 },\\n },\\n required: [\\"label\\", \\"chatMessage\\"],\\n },\\n },\\n },\\n required: [\\"question\\", \\"options\\"],\\n },\\n \\"Create one engaging gift-finder opening question for this store, \\" +\\n \\"with 3-4 answer options that map to real areas of the catalog. \\" +\\n \\"For each option also write chatMessage: the message a shopper \\" +\\n \\"would send to a shopping assistant after picking it, phrased in \\" +\\n \\"first person (e.g. \\\\\\"I\'m shopping for my dad who loves hiking\\\\\\").\\"\\n );\\n\\n document.getElementById(\\"gf-question\\").textContent = data.question;\\n const wrap = document.getElementById(\\"gf-options\\");\\n for (const opt of data.options) {\\n const btn = document.createElement(\\"button\\");\\n btn.type = \\"button\\";\\n btn.textContent = opt.label;\\n btn.addEventListener(\\"click\\", () => gs.sendMessage(opt.chatMessage));\\n wrap.append(btn);\\n }\\n document.getElementById(\\"gift-finder\\").hidden = false;\\n } catch {}\\n })();\\n</script>\\n```\\n\\nWhy it works: the teaser costs one cached generation per page, each\\nclick opens a conversation that already has direction, and `go_to_product`\\nlets the conversation end on a product page instead of in a dead end.\\n","recipes/launchers.md":"# GreatStore launchers \u2014 AI question chips\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\nA horizontally scrollable row of chips, each a highly engaging first-person\\nquestion about the current page. Tapping a chip sends that question to the\\nassistant \u2014 `generateStructuredContent` writes the questions, `sendMessage`\\nfires them. Simple yet effective: it works on every page type, costs one\\ncached generation per page, and every tap starts a conversation that already\\nhas a great opening line.\\n\\n```html\\n<div id=\\"gs-launchers\\" hidden></div>\\n\\n<style>\\n #gs-launchers {\\n display: flex;\\n gap: 0.5em;\\n overflow-x: auto;\\n -webkit-overflow-scrolling: touch;\\n scrollbar-width: none;\\n padding: 0.5em 1em;\\n }\\n #gs-launchers::-webkit-scrollbar { display: none; }\\n #gs-launchers button {\\n flex: 0 0 auto;\\n white-space: nowrap;\\n border: 1px solid #ddd;\\n border-radius: 999px;\\n padding: 0.5em 0.9em;\\n background: #fff;\\n cursor: pointer;\\n }\\n</style>\\n\\n<script>\\n (async () => {\\n const gs = window.GreatStore;\\n if (!gs?.generateStructuredContent) return;\\n try {\\n const data = await gs.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n launchers: {\\n type: \\"array\\", minItems: 4, maxItems: 6,\\n items: {\\n type: \\"object\\",\\n properties: {\\n chip: { type: \\"string\\", maxLength: 32 },\\n question: { type: \\"string\\", maxLength: 140 },\\n },\\n required: [\\"chip\\", \\"question\\"],\\n },\\n },\\n },\\n required: [\\"launchers\\"],\\n },\\n `The shopper is on the page \\"${document.title}\\". Write 4-6 launcher ` +\\n `chips for a shopping assistant. For each, \\"question\\" is a highly ` +\\n `engaging first-person question this shopper would genuinely want ` +\\n `answered on this page \u2014 specific to its product, category, or ` +\\n `content, never generic \u2014 and \\"chip\\" is a 2-4 word teaser of it. ` +\\n `Vary the angles: fit and use, comparisons, gifting, care, what\'s ` +\\n `popular.`\\n );\\n\\n const row = document.getElementById(\\"gs-launchers\\");\\n for (const { chip, question } of data.launchers) {\\n const btn = document.createElement(\\"button\\");\\n btn.type = \\"button\\";\\n btn.textContent = chip;\\n btn.title = question;\\n btn.addEventListener(\\"click\\", () => gs.sendMessage(question));\\n row.append(btn);\\n }\\n row.hidden = false;\\n } catch {}\\n })();\\n</script>\\n```\\n\\nWhy it works:\\n\\n- **The chip is the teaser, the question is the payload.** A 2-4 word chip\\n scans instantly; the full first-person question lands in the transcript\\n reading like something the shopper typed, and gives the assistant a\\n well-formed prompt. The `title` attribute previews the full question on\\n hover.\\n- **Per-page for free.** The page URL is part of the generation context and\\n the cache key, so one site-wide snippet yields different chips on every\\n page \u2014 each served from cache on repeat renders.\\n- **Placement is the lever.** Under the product title, above the grid on\\n collections, at the end of a blog post \u2014 wherever a shopper pauses to\\n wonder, the chips name the question for them.\\n\\nTips:\\n\\n- Inline the product or collection name into the prompt when the platform\\n exposes it \u2014 it beats relying on `document.title`.\\n- Restyle the chips to the site\'s design system; the CSS above is just the\\n scroll mechanics (flex row, `overflow-x: auto`, hidden scrollbars,\\n `white-space: nowrap`).\\n- Resist adding more than ~6 chips \u2014 a launcher row is an invitation, not a\\n sitemap.\\n","recipes/page-action-suite.md":"# Page-action suite (WebMCP)\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\nGive every conversation on the site real capabilities. Register once in a\\nshared snippet, after the SDK is ready (which guarantees\\n`document.modelContext` exists):\\n\\n```js\\nwindow.GreatStore?.ready.then(() => {\\n const text = (value) => ({\\n content: [{ type: \\"text\\", text: JSON.stringify(value) }],\\n });\\n\\n document.modelContext.registerTool({\\n name: \\"get_cart\\",\\n description:\\n \\"Read the shopper\'s current cart on this site: items, quantities, \\" +\\n \\"and totals. Use before answering any cart question.\\",\\n inputSchema: { type: \\"object\\", properties: {} },\\n async execute() {\\n return text(await (await fetch(\\"/cart.js\\")).json());\\n },\\n });\\n\\n document.modelContext.registerTool({\\n name: \\"add_to_cart\\",\\n description:\\n \\"Add a product variant to the cart on this site. Use when the \\" +\\n \\"shopper asks to add or buy something. Confirm the variant with \\" +\\n \\"the shopper first if ambiguous.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n variantId: { type: \\"string\\" },\\n quantity: { type: \\"integer\\", minimum: 1, maximum: 10 },\\n },\\n required: [\\"variantId\\"],\\n },\\n async execute({ variantId, quantity }) {\\n if (!variantId) throw new Error(\\"variantId is required\\");\\n const res = await fetch(\\"/cart/add.js\\", {\\n method: \\"POST\\",\\n headers: { \\"Content-Type\\": \\"application/json\\" },\\n body: JSON.stringify({ id: variantId, quantity: quantity ?? 1 }),\\n });\\n if (!res.ok) throw new Error(`Could not add to cart (${res.status})`);\\n document.dispatchEvent(new CustomEvent(\\"cart:refresh\\"));\\n return text(await res.json());\\n },\\n });\\n\\n document.modelContext.registerTool({\\n name: \\"get_current_page\\",\\n description:\\n \\"Read what page the shopper is currently on, including structured \\" +\\n \\"product data when on a product page.\\",\\n inputSchema: { type: \\"object\\", properties: {} },\\n execute() {\\n return text({\\n url: location.href,\\n title: document.title,\\n productJson: document.querySelector(\\"#product-json\\")?.textContent ?? null,\\n });\\n },\\n });\\n});\\n```\\n\\nPrinciples at work: throw on failure (the assistant explains and recovers),\\nreturn fresh state after mutations (the assistant confirms accurately), cap\\nquantities in the schema, and notify your own UI (`cart:refresh`) so the\\npage reflects what the AI did.\\n\\nFor a product-page-only tool, register with an `AbortSignal` and abort on\\nSPA navigation:\\n\\n```js\\nconst ac = new AbortController();\\ndocument.modelContext.registerTool(reviewsTool, { signal: ac.signal });\\nrouter.onLeave(\\"/products/:handle\\", () => ac.abort());\\n```\\n","recipes/product-faq.md":"# Product FAQ accordion\\n\\n> **Treat this code as a reference, not a drop-in.** Samples are\\n> framework-free vanilla JS so they stay portable \u2014 re-express the same\\n> logic in the conventions of the repo you\'re working in (React, Vue,\\n> Shopify Liquid sections, Svelte, \u2026) instead of retrofitting the sample\\n> as-is.\\n\\n```js\\nconst data = await window.GreatStore.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n faqs: {\\n type: \\"array\\", minItems: 3, maxItems: 5,\\n items: {\\n type: \\"object\\",\\n properties: {\\n question: { type: \\"string\\", maxLength: 90 },\\n answer: { type: \\"string\\", maxLength: 300 },\\n },\\n required: [\\"question\\", \\"answer\\"],\\n },\\n },\\n },\\n required: [\\"faqs\\"],\\n },\\n `Generate the questions shoppers most plausibly have before buying the ` +\\n `product \\"${productName}\\", with accurate answers grounded in the real ` +\\n `product details and store policies. Skip any question the store data ` +\\n `can\'t answer confidently.`\\n);\\n\\nconst wrap = document.getElementById(\\"gs-faq\\");\\nfor (const { question, answer } of data.faqs) {\\n const details = document.createElement(\\"details\\");\\n const summary = document.createElement(\\"summary\\");\\n summary.textContent = question;\\n const p = document.createElement(\\"p\\");\\n p.textContent = answer;\\n details.append(summary, p);\\n wrap.append(details);\\n}\\nwrap.hidden = false;\\n```\\n\\nEngagement bonus \u2014 append a hand-off row so unanswered questions become\\nconversations:\\n\\n```js\\nconst ask = document.createElement(\\"button\\");\\nask.type = \\"button\\";\\nask.textContent = \\"Have a different question? Ask us\\";\\nask.addEventListener(\\"click\\", () =>\\n window.GreatStore.sendMessage(`I have a question about \\"${productName}\\".`)\\n);\\nwrap.append(ask);\\n```\\n","references/agents-and-mcp.md":"# AI agents and the store\'s MCP endpoints\\n\\nBeyond the widget, every GreatStore store is reachable by AI agents directly\\nover the Model Context Protocol. Three URLs, all on the store\'s subdomain,\\nnone requiring authentication:\\n\\n| URL | What it is |\\n|---|---|\\n| `https://<slug>.greatstore.ai/.well-known/mcp/server-card.json` | Standard MCP server card \u2014 machine-readable discovery document for the store. |\\n| `https://<slug>.greatstore.ai/mcp` | The store\'s **shopping tools** over MCP \u2014 product search and related catalog actions, the same capabilities the assistant itself shops with. |\\n| `https://<slug>.greatstore.ai/admin-mcp` | A **documentation server for coding agents**: its tools return usage docs for the `gs` CLI. |\\n\\n## Shopping tools \u2014 `/mcp`\\n\\nConnect any MCP-capable agent to let it browse and shop the store\\nprogrammatically. This is the integration point for agentic-shopping\\nclients, comparison bots, or the merchant\'s own automations that need live\\ncatalog answers.\\n\\n## CLI docs for coding agents \u2014 `/admin-mcp`\\n\\nA stateless HTTP MCP whose tools hand back markdown documentation for `gs`\\nCLI commands. When accessed via the store\'s subdomain, every example comes\\npre-pinned to that store\'s slug, so the agent never has to ask which store\\nto target.\\n\\n```\\nclaude mcp add --transport http greatstore https://my-store.greatstore.ai/admin-mcp\\n```\\n\\nUseful when a coding agent is shipping chat components\\n([chat-components.md](chat-components.md)) and needs the exact command for\\nthe next step. If this skill is installed, the agent already has the\\nworkflow \u2014 the MCP is the self-serve alternative for agents that don\'t.\\n\\n## What to use when\\n\\n- **Building the merchant\'s site** \u2192 this skill\'s other references (the SDK,\\n structured content, page tools).\\n- **A coding agent shipping chat components** \u2192 the `gs` CLI, with\\n `/admin-mcp` as its built-in documentation.\\n- **Reading or changing store settings** (origin allowlists, CSP hosts, MCP\\n connectors, brand config) \u2192 the `gs` CLI\'s admin commands \u2014\\n [store-admin.md](store-admin.md), including which changes are safe to make\\n without asking the merchant.\\n- **An agent that needs to *shop* the store** (search products, read catalog\\n data) from outside any web page \u2192 `/mcp`.\\n","references/chat-components.md":"# Custom chat components \u2014 authoring with the `gs` CLI\\n\\nRemote components are React components the assistant renders **inside the\\nconversation** \u2014 product configurators, quizzes, size guides, booking forms,\\nanything richer than text. Each component is an AI-callable tool: the\\nmanifest\'s `description` tells the assistant *when* to show it, its\\n`inputSchema` declares the props the assistant fills in, and a `displayMode`\\npicks where it appears.\\n\\nAuthoring requires store-owner access (`gs login` signs in with the store\\nowner\'s account).\\n\\n## Workflow\\n\\n```\\nnpm install -g @greatstore/cli # or npx @greatstore/cli <command>\\ngs login # browser sign-in\\ngs apps init --store my-store # scaffold a project root\\ncd <project> && npm install\\ngs apps init size_guide # scaffold components/size_guide/\\n# \u2026 edit components/size_guide/{component.tsx,manifest.json} \u2026\\ngs apps build # bundle every component\\ngs apps push # upload changed components as drafts\\ngs apps publish size_guide # promote to live\\n```\\n\\n`gs apps list` shows what\'s deployed (with dashboard links); `gs apps pull`\\nround-trips remote components back to disk. `gs apps push` hashes components\\nand only uploads what changed. (The subcommands also work at the top level \u2014\\n`gs push` == `gs apps push`.)\\n\\nThe scaffold writes an `AGENTS.md` into the project (with `CLAUDE.md` /\\n`GEMINI.md` symlinked) containing the complete design rules and brand\\nvariable table \u2014 your coding agent picks it up automatically when working in\\nthe project. The store\'s `/admin-mcp` endpoint serves the same CLI docs to\\nagents over MCP (see [agents-and-mcp.md](agents-and-mcp.md)).\\n\\n## `manifest.json`\\n\\n```json\\n{\\n \\"name\\": \\"size_guide\\",\\n \\"displayName\\": \\"Size guide\\",\\n \\"description\\": \\"Interactive size guide. Show when the shopper asks about sizing or fit for apparel.\\",\\n \\"displayMode\\": \\"inline\\",\\n \\"inputSchema\\": {\\n \\"type\\": \\"object\\",\\n \\"properties\\": {\\n \\"productName\\": { \\"type\\": \\"string\\" },\\n \\"category\\": { \\"type\\": \\"string\\" }\\n },\\n \\"required\\": [\\"productName\\"]\\n }\\n}\\n```\\n\\n| Field | Meaning |\\n|---|---|\\n| `name` | Tool name, snake_case (`^[a-z][a-z0-9_]*$`), matches the folder under `components/`. |\\n| `displayName` | Friendly label shown in chat UI. |\\n| `description` | **Load-bearing** \u2014 how the assistant decides when to render the component. Say what it shows *and* when to use it, like any good tool description. |\\n| `displayMode` | Where it renders \u2014 see below. |\\n| `inputSchema` | JSON Schema for the props the assistant fills. Keep it tight; required fields the AI can\'t infer cause bad calls. |\\n| `async` | Set `true` for backend-backed components (see Async below). |\\n\\n### Display modes\\n\\n- `inline` \u2014 a bubble inside the chat transcript; persists with the message\\n log.\\n- `over-input` \u2014 floats above the chat input (like a question overlay);\\n cleared by the next user turn or an explicit close.\\n- `fullscreen` \u2014 takes over the full preview surface; persists until the\\n next widget-emitting tool call or an explicit close.\\n\\n## The component contract\\n\\n`component.tsx` default-exports a React component. Its props are the\\n`inputSchema` fields the assistant filled, plus three GreatStore-injected\\nlifecycle props (always present):\\n\\n| Prop | What it does |\\n|---|---|\\n| `onSendMessage(text)` | Send text into the chat as if the shopper typed it \u2014 lets the component drive the conversation (\\"Selected size M, what\'s the return policy?\\"). |\\n| `onCallTool(name, args)` | Chain into another remote-component tool by name. |\\n| `onClose()` | Dismiss the host slot. `over-input` clears the overlay, `fullscreen` reverts the pane, `inline` is a no-op. |\\n\\n```tsx\\nimport React from \\"react\\";\\n\\ninterface Props {\\n productName: string;\\n category?: string;\\n onSendMessage: (text: string) => void;\\n onCallTool: (name: string, args: Record<string, unknown>) => void;\\n onClose: () => void;\\n}\\n\\nexport default function SizeGuide({ productName, onSendMessage }: Props) {\\n return (\\n <div\\n style={{\\n padding: \\"1em\\",\\n border: \\"1px solid var(--color-border-default)\\",\\n borderRadius: \\"var(--radius-lg)\\",\\n background: \\"var(--color-surface)\\",\\n color: \\"var(--color-foreground)\\",\\n fontFamily: \\"var(--font-sans)\\",\\n }}\\n >\\n {/* \u2026 sizes for {productName} \u2026 */}\\n <button onClick={() => onSendMessage(`Size M of \\"${productName}\\" \u2014 is it in stock?`)}>\\n Check size M\\n </button>\\n </div>\\n );\\n}\\n```\\n\\n## Design rules (non-negotiable)\\n\\nComponents render inside arbitrary publisher pages *and* the GreatStore\\nstorefront; you control neither the host\'s root font size nor its colors.\\n\\n1. **Size in `em`, never `rem`** \u2014 `rem` resolves against the host page\'s\\n root font size, which is arbitrary (`html { font-size: 8px }` breaks every\\n `rem` dimension). `em` stays self-consistent anywhere. Borders may stay\\n `px`.\\n2. **Never hardcode colors, fonts, or radii** \u2014 read the brand CSS variables\\n GreatStore injects (`--color-primary`, `--color-surface`,\\n `--color-foreground`, `--color-border-default`, `--font-sans`,\\n `--radius-lg`, \u2026) so the component restyles itself with the store\'s\\n theme. The scaffolded `AGENTS.md` has the full variable table.\\n\\n## Async components (backend-backed data)\\n\\nIf a component must load data before it can render correctly, don\'t render a\\nshell and fetch in `useEffect` \u2014 set `\\"async\\": true` in the manifest and\\nexport an **async** default. GreatStore shows its own loading state, awaits\\nyour promise, and renders what it resolves to.\\n\\nA thrown error is a **retry signal**: the assistant sees it and usually\\nre-calls the tool. So only throw when a *different* call could help:\\n\\n1. Validate the AI-passed props first and throw on bad input \u2014 the AI can\\n fix the args and retry. (Don\'t validate the API\'s *output* and throw: the\\n AI can\'t fix your backend, it\'ll just loop.)\\n2. Throw on failures where retrying differently could succeed, and say what\\n to change (e.g. empty search \u2192 `\\"no results for X \u2014 try a broader keyword\\"`).\\n3. For idempotent failures (500, timeout, missing record) render a graceful\\n fallback instead of throwing \u2014 re-running the same call changes nothing.\\n\\n```tsx\\nexport default async function Results(props: Props) {\\n if (!props.query?.trim()) throw new Error(\\"missing required prop: query\\");\\n const res = await fetch(`/api/search?q=${encodeURIComponent(props.query)}`);\\n if (res.ok) {\\n const { results } = await res.json();\\n if (results.length === 0)\\n throw new Error(`no results for \\"${props.query}\\" \u2014 try a broader keyword`);\\n return <ul>{/* render results */}</ul>;\\n }\\n return <p>Couldn\'t load results right now.</p>; // idempotent: don\'t throw\\n}\\n```\\n\\n## When to build a component vs. the other facets\\n\\n- Content for the **merchant\'s page** \u2192 `generateStructuredContent`\\n ([structured-content.md](structured-content.md)).\\n- Letting the assistant **act on the page** \u2192 WebMCP page tools\\n ([embed-api.md](embed-api.md)).\\n- Rich, interactive UI **inside the conversation itself**, available on the\\n storefront and every embed without page changes \u2192 a chat component.\\n","references/embed-api.md":"# `window.GreatStore` API reference\\n\\n## Setup\\n\\n```html\\n<script src=\\"https://my-store.greatstore.ai/embed.js\\"></script>\\n```\\n\\nOne script tag, anywhere on the page (end of `<body>` preferred), with the\\nstore\'s slug in the host. The `window.GreatStore` object exists synchronously\\nonce the script executes; every method below is safe to call before the chat\\nUI has finished loading \u2014 pre-mount calls are queued and replayed in order\\nonce it mounts. The SDK pre-warms its chat bundle in the background\\nautomatically; the panel stays closed until `open()` / `toggle()` /\\n`sendMessage()` is called or the shopper clicks the launcher.\\n\\nRequirements:\\n\\n- The page\'s domain must be in the store\'s **allowed domains** (GreatStore\\n store settings) \u2014 see Troubleshooting below for the failure signature.\\n- If the store also wants push notifications, host `gs.js` at the site root\\n and load that instead of `embed.js` \u2014 it injects the embed for you. See\\n [push-notifications.md](push-notifications.md).\\n\\nCode samples throughout are framework-free reference implementations \u2014\\nre-express them in the host repo\'s framework (React, Vue, Shopify Liquid,\\n\u2026) rather than retrofitting them as-is.\\n\\n## Properties\\n\\n| Property | Type | Description |\\n|---|---|---|\\n| `slug` | `string` | The store identifier the script was loaded for. |\\n| `host` | `string` | `\\"greatstore.ai\\"`. |\\n| `embedHost` | `string` | Origin the embed assets load from, e.g. `https://<slug>.greatstore.ai`. |\\n| `ready` | `Promise<void>` | Resolves when the chat UI has mounted and `open()` would render instantly. Resolved promises replay, so `.then()` works no matter when it\'s attached. The recommended readiness signal. |\\n| `isReady` | `boolean` | Synchronous alternative to `ready`. `false` until mount. |\\n\\nA `greatstore:ready` `CustomEvent` (with the SDK object as `detail`) is also\\ndispatched on `window` at the moment `ready` resolves, for declarative\\ntooling. Unlike the promise, the listener must be attached before mount\\ncompletes \u2014 attach it before (or immediately after) the embed script tag.\\n\\n## Methods\\n\\n### `load(): void`\\n\\nPre-warms the chat bundle and identity in the background without opening the\\npanel. Called automatically when `embed.js` runs, so you rarely need it.\\nIdempotent.\\n\\n### `open(): void` / `close(): void` / `toggle(): void`\\n\\nOpen, close, or toggle the chat panel. On desktop the panel is a floating\\nside panel; under 768px viewport width it\'s a full-height drawer. All three\\nqueue if called before mount.\\n\\n### `sendMessage(text: string): void`\\n\\nSends `text` as the shopper\'s own visible chat message and **opens the panel\\nif it\'s closed**. The text is trimmed; empty or whitespace-only strings are\\nsilently dropped. Queues if called before mount.\\n\\nThis is the highest-leverage one-liner in the SDK: any element on the page\\ncan become a conversation entry point with context baked into the question.\\n\\n```js\\ndocument.querySelector(\\"#ask-fit\\").addEventListener(\\"click\\", () => {\\n window.GreatStore.sendMessage(\\n `I\'m looking at \\"${productName}\\" \u2014 how does the sizing run?`\\n );\\n});\\n```\\n\\nBecause the message renders as if the shopper typed it, write it in the\\nshopper\'s voice. It is not a hidden-context channel \u2014 don\'t stuff it with\\ninvisible instructions or data dumps.\\n\\n### `on(event: string, handler: (...args) => void): () => void`\\n\\nSubscribe to SDK events. Returns an unsubscribe function. Listeners attached\\nbefore mount are queued and wired up at mount. Handler exceptions are caught\\nand reported \u2014 they won\'t break the chat.\\n\\nEvents emitted:\\n\\n| Event | Fired when |\\n|---|---|\\n| `\\"open\\"` | Panel transitions closed \u2192 open (including via `sendMessage` or the shopper\'s own click). |\\n| `\\"close\\"` | Panel transitions open \u2192 closed. |\\n\\n### `generateStructuredContent(schema: object, prompt: string): Promise<unknown>`\\n\\nGenerates JSON matching `schema` from `prompt`, grounded in the store\'s live\\ncatalog. Resolves to the generated data object itself. Rejects with `Error`\\non any failure (invalid input, decline, validation failure, rate limit,\\nnetwork). See [structured-content.md](structured-content.md) for the full\\ncontract, schema support, caching, and error semantics.\\n\\nAccepts either a plain JSON Schema object or any object exposing a\\n`.toJSONSchema()` method (e.g. Zod schemas) \u2014 the conversion is called for\\nyou.\\n\\nThrows synchronously (rejects) if `prompt` is not a non-empty string or\\n`schema` is not an object.\\n\\n### `enableNotifications(): Promise<{ ok: boolean }>`\\n\\nOpts this browser into Web Push notifications from the store. Requirements:\\n\\n- Must be called from a user gesture (e.g. a click handler).\\n- The site must host GreatStore\'s `gs.js` service-worker file. By default the\\n SDK looks for it at `/gs.js`; if it\'s hosted elsewhere, point to it via an\\n attribute on the embed script tag:\\n `<script src=\\"\u2026/embed.js\\" data-push-sw-path=\\"/path/to/gs.js\\"></script>`.\\n\\nResolves `{ ok: true }` on success and `{ ok: false }` on any failure\\n(unsupported browser, no service worker hosted, permission denied). It never\\nrejects.\\n\\n## Page tools \u2014 WebMCP (`document.modelContext`)\\n\\nThe recommended way to expose page capabilities to the assistant is the\\nWebMCP standard. The GreatStore assistant discovers every tool registered on\\n`document.modelContext`, re-reading the list on each conversational turn \u2014\\nso tools registered mid-session appear on the next message without a reload.\\n\\n### Availability\\n\\nIf the browser implements WebMCP natively, `document.modelContext` is just\\nthere. Otherwise the SDK installs a spec-tracking polyfill on the page\\nautomatically \u2014 but asynchronously, so at your script\'s first run\\n`document.modelContext` may not exist yet. Two robust patterns:\\n\\n```js\\n// 1. Register once GreatStore is ready (polyfill is in place by then):\\nwindow.GreatStore?.ready.then(() => {\\n document.modelContext.registerTool(/* \u2026 */);\\n});\\n\\n// 2. Or ship your own polyfill (npm: @mcp-b/webmcp-polyfill) and register\\n// immediately \u2014 the SDK detects an existing implementation and uses it.\\n```\\n\\n(`navigator.modelContext` is a deprecated alias for the same object; use\\n`document.modelContext` in new code.)\\n\\n### `registerTool(tool, options?)`\\n\\n```ts\\ndocument.modelContext.registerTool(\\n {\\n name: string, // required, non-empty, unique on the page\\n description: string, // required \u2014 how the AI decides when to call it\\n inputSchema?: object, // JSON Schema for execute\'s args;\\n // defaults to { type: \\"object\\", properties: {} }\\n execute(args): Result | Promise<Result>,\\n },\\n options?: { signal?: AbortSignal }, // abort to unregister\\n);\\n```\\n\\n- **Result shape**: `execute` returns MCP content blocks \u2014\\n `{ content: [{ type: \\"text\\", text: \\"\u2026\\" }] }`. For structured data,\\n `JSON.stringify` it into `text`. Add `isError: true` to mark a handled\\n failure.\\n- **Errors**: a thrown error or rejected promise is delivered to the\\n assistant as a *failed* tool call carrying the error message \u2014 the\\n assistant can explain or adapt. Errors never escape into your page.\\n- **Duplicate names throw.** To replace a tool, abort its registration first.\\n- **Unregistration is `AbortSignal`-driven**: pass `{ signal }` and call\\n `abort()` when the tool\'s context goes away (SPA navigation, modal close).\\n A pre-aborted signal skips registration. (A legacy\\n `unregisterTool(name)` exists but is deprecated in the spec.)\\n- **Treat `args` as untrusted input**: values are AI-generated. Validate\\n before passing to your own APIs, and never `eval` anything from them.\\n\\nA complete tool, registered once GreatStore is ready:\\n\\n```js\\nwindow.GreatStore?.ready.then(() => {\\n document.modelContext.registerTool({\\n name: \\"add_to_cart\\",\\n description:\\n \\"Add a product variant to the shopper\'s cart on this site. \\" +\\n \\"Use when the shopper asks to add, buy, or get a product.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n variantId: { type: \\"string\\" },\\n quantity: { type: \\"integer\\", minimum: 1 },\\n },\\n required: [\\"variantId\\"],\\n },\\n async execute({ variantId, quantity }) {\\n const res = await fetch(\\"/cart/add.js\\", {\\n method: \\"POST\\",\\n headers: { \\"Content-Type\\": \\"application/json\\" },\\n body: JSON.stringify({ id: variantId, quantity: quantity ?? 1 }),\\n });\\n if (!res.ok) throw new Error(`Cart add failed (${res.status})`);\\n const cart = await res.json();\\n return { content: [{ type: \\"text\\", text: JSON.stringify(cart) }] };\\n },\\n });\\n});\\n```\\n\\nReturning the fresh cart state after the mutation lets the assistant confirm\\naccurately. Good tool families: cart (`get_cart`, `add_to_cart`), navigation\\n(`go_to_page`), page state (`get_current_product`, `apply_filters`), UI\\n(`highlight_section`, `scroll_to_reviews`).\\n\\nAnd a context-scoped tool, unregistered via `AbortSignal`:\\n\\n```js\\nconst ac = new AbortController();\\ndocument.modelContext.registerTool(\\n {\\n name: \\"get_product_reviews\\",\\n description: \\"Read the reviews shown on the current product page.\\",\\n inputSchema: { type: \\"object\\", properties: {} },\\n execute: () => ({\\n content: [{ type: \\"text\\", text: JSON.stringify(collectReviews()) }],\\n }),\\n },\\n { signal: ac.signal },\\n);\\n\\n// On SPA route change away from the product page:\\nac.abort();\\n```\\n\\n## URL parameter: `?gs_chat=open`\\n\\nWhen the page URL carries `gs_chat=open`, the panel opens automatically once\\nthe embed mounts. The param is consumed and stripped from the URL via\\n`history.replaceState`, so a manual reload doesn\'t re-open the panel. Use it\\nin campaign links, emails, and post-login redirects.\\n\\n## Shopify login gate\\n\\nStores can enable a per-store \\"log in to chat\\" gate that applies **only to\\nthe embed** (never the store\'s GreatStore storefront). When enabled, the chat\\ninput is replaced with a login prompt until a Shopify customer signal is\\ndetected on the host page (standard Shopify globals/meta tags). The login\\nbutton sends the shopper through Shopify\'s customer login and back with\\n`?gs_chat=open` appended. This is a courtesy UX gate, not a security\\nboundary \u2014 don\'t rely on it to protect anything sensitive.\\n\\n## Troubleshooting\\n\\n| Symptom | Likely cause |\\n|---|---|\\n| Console: `[GreatStore] Chat is unavailable on <origin>\u2026 this domain isn\'t in the store\'s allowed domains` | The page\'s origin isn\'t in the store\'s allowed domains. Add it in store settings. Until then every SDK network call fails. |\\n| Console: `[GreatStore] Embed script must be loaded from <slug>.greatstore.ai/embed.js` | The script was copied/self-hosted instead of loaded from the store\'s embed URL. Always load it from `https://<slug>.greatstore.ai/embed.js`. |\\n| `generateStructuredContent` rejects with a rate-limit message | More than ~20 requests/minute from one visitor. Consolidate calls into fewer, richer schemas. |\\n| Panel won\'t auto-open on mobile after returning to the page | Intentional: the mobile drawer never auto-opens on resume \u2014 it would cover the content the shopper is reading. The transcript is preserved; they\'ll see it when they tap the launcher. |\\n| Tools registered but the assistant doesn\'t use them | Check the `description` \u2014 it\'s the only signal for *when* to call. Also confirm the registration ran (`document.modelContext` exists after `ready`) on the same page the conversation is on. |\\n","references/push-notifications.md":"# Web push re-engagement\\n\\nShoppers who opt in receive browser notifications from the store \u2014 under the\\nmerchant\'s own domain and branding, with the permission prompt shown inline\\non the merchant\'s page. Setup is two pieces: a single file hosted at the site\\nroot, and an opt-in button.\\n\\n## 1. Host `gs.js` at the site root\\n\\nDownload the store\'s loader and serve it at `/gs.js` on the merchant\'s\\ndomain:\\n\\n```\\nhttps://<slug>.greatstore.ai/gs.js \u2192 https://www.merchant-site.com/gs.js\\n```\\n\\nAlways download it from the **store\'s own subdomain** \u2014 the file is built for\\nthat store; don\'t copy one from elsewhere.\\n\\nThen load it with one tag (replacing the `embed.js` tag \u2014 `gs.js` injects the\\nembed for you and registers itself as the service worker):\\n\\n```html\\n<script src=\\"/gs.js\\"></script>\\n```\\n\\nHosting this file is what enables push. Without it, push is simply off \u2014\\n`enableNotifications()` returns `{ ok: false }` and nothing else changes.\\n\\n### Non-root hosting\\n\\nIf the platform can\'t serve files at the site root (e.g. Shopify themes\\nserve assets under a path), keep the regular `embed.js` tag and point it at\\nwhere the file lives \u2014 the path must be on the merchant\'s own origin:\\n\\n```html\\n<script\\n src=\\"https://my-store.greatstore.ai/embed.js\\"\\n data-push-sw-path=\\"/cdn/shop/files/gs.js\\"\\n></script>\\n```\\n\\n## 2. Offer the opt-in from a user gesture\\n\\n```js\\noptInButton.addEventListener(\\"click\\", async () => {\\n const { ok } = await window.GreatStore.enableNotifications();\\n optInButton.hidden = ok; // done \u2014 or quietly keep the button\\n});\\n```\\n\\nRules that make this work well:\\n\\n- **Always call it from a click** \u2014 browsers ignore or penalize permission\\n prompts that aren\'t user-initiated, and the call is designed for gesture\\n context.\\n- **Never prompt on page load.** Tie the button to a moment where\\n notifications have obvious value (\\"Notify me when this is back in stock\\",\\n post-purchase, after a chat conversation).\\n- `{ ok: false }` covers every failure the same way \u2014 unsupported browser, no\\n `gs.js` hosted, permission denied. It never rejects, and there\'s no popup\\n fallback. Design the button so a decline just leaves the page as it was;\\n don\'t show an error.\\n- The promise resolving `{ ok: true }` means this browser is subscribed.\\n There\'s nothing else to wire \u2014 notification delivery is handled by\\n GreatStore.\\n","references/store-admin.md":"# Store administration from the CLI\\n\\nThe `gs` CLI is a full admin surface for a GreatStore store, mirroring the\\nmerchant\'s admin dashboard one-to-one: `gs configure` \u2194 the Configure panel,\\n`gs connectors` \u2194 the Connectors panel, `gs apps` \u2194 the Apps panel. Same\\nfields, same behaviour \u2014 anything you change is what the merchant sees in\\ntheir dashboard.\\n\\nThat makes the CLI the way a coding agent grounds and unblocks its own work:\\nread the store\'s configuration to make better decisions, and make the narrow\\nclass of additive, integration-enabling changes yourself instead of telling\\nthe user to go click through a dashboard.\\n\\n## Setup\\n\\n```\\nnpm install -g @greatstore/cli # or npx @greatstore/cli <command>\\ngs login # one-time browser sign-in (needs a human)\\n```\\n\\nCredentials persist across runs. Admin commands take `--store <slug>`\\ndirectly, or read the nearest `.gsrc` (`{\\"store\\":\\"my-store\\"}`) \u2014 so they work\\nfrom any repo, not just a scaffolded component project. Every read supports\\n`--json` for machine-readable output.\\n\\n## Read freely \u2014 always safe\\n\\nReading store state is never destructive. Do it whenever it would improve a\\ndecision:\\n\\n```\\ngs configure --store my-store --json # brand config\\ngs connectors --store my-store --json # MCP connectors feeding the assistant\\ngs connectors health --store my-store # live-probe them (reports tool counts)\\ngs apps list --store my-store --json # deployed custom chat components\\n```\\n\\nWhat each read is good for:\\n\\n- **`gs configure`** \u2014 the store\'s `extraOrigins` (is the site you\'re\\n integrating actually allowlisted? \u2014 see the embed troubleshooting note in\\n [embed-api.md](embed-api.md)), `theme` (match generated on-page UI to the\\n brand\'s colors/radius), `storeLink`, CSP host lists, and whether the\\n Shopify login gate is on.\\n- **`gs connectors`** \u2014 which MCP connectors the assistant has, and whether\\n the Maker MCP is on. This tells you what the assistant can actually *do*:\\n check it before building features that lean on catalog data (e.g.\\n [structured content](structured-content.md) grounding), and before\\n registering WebMCP page tools that would duplicate a capability a\\n server-side connector already provides.\\n- **`gs connectors health`** \u2014 a connector being listed doesn\'t mean it\'s\\n reachable; this probes each one live. Run it when assistant behaviour\\n suggests a tool isn\'t responding.\\n\\n## Safe to change without asking\\n\\nWhen the task the user gave you requires it, these changes are yours to\\nmake: they\'re additive, easy to undo, and invisible to shoppers.\\n\\n- **`extraOrigins`** \u2014 the origin allowlist for the embed. Installing the\\n widget on a new site/staging domain? Add that origin yourself instead of\\n reporting \\"ask the merchant to allowlist it\\".\\n- **`cspScriptHosts` / `cspConnectHosts`** \u2014 hosts the storefront accepts\\n for scripts/connections. Add a host when a custom chat component\\n legitimately needs a third-party SDK or API.\\n\\n**List flags replace the stored list \u2014 always read-merge-write.** Fetch the\\ncurrent value, append yours, write the union:\\n\\n```\\ngs configure --store my-store --json\\n# extraOrigins is [\\"https://shop.example.com\\"] \u2192 write both, comma-separated:\\ngs configure set --store my-store \\\\\\n --extraOrigins \\"https://shop.example.com,https://staging.example.com\\"\\n```\\n\\nNever drop an entry you didn\'t add, and say what you changed (and why) when\\nyou report back to the user.\\n\\n## Ask the merchant first\\n\\nEverything below is shopper-visible or changes what the live assistant can\\ndo for every shopper. Propose it, don\'t do it unprompted:\\n\\n- **Brand-visible config** \u2014 `displayName`, `assistantName`, `salesGuide`,\\n `storeLink`, `theme`, `shopifyLoginGate`, and asset uploads\\n (`gs configure upload|clear icon|logoLight|logoDark`).\\n- **Connector mutations** \u2014 `gs connectors add|remove|enable|disable` and\\n `gs connectors maker on|off`. (`add` probes the connector for tool\\n discovery before saving and refuses if it doesn\'t answer; `--force`\\n overrides. Still: adding capabilities to the merchant\'s assistant is the\\n merchant\'s call.)\\n- **Anything that clears** \u2014 passing `\\"\\"` to empty a field, removing list\\n entries, `clear`ing assets.\\n\\nWhen the user has *explicitly asked* for one of these (\\"set the assistant\'s\\nname to Voyager\\", \\"connect this MCP server\\"), that\'s the go-ahead \u2014 do it\\nand confirm the result with a read.\\n\\n## Command reference\\n\\n```\\ngs configure [--json] show configuration\\ngs configure set --<field> <value> displayName, assistantName,\\n salesGuide (--salesGuideFile <path>),\\n storeLink, extraOrigins a,b,\\n shopifyLoginGate true|false,\\n theme \'<json>\' (--themeFile <path>),\\n cspScriptHosts a,b, cspConnectHosts a,b\\n (\\"\\" clears a field)\\ngs configure upload <kind> <file> icon | logoLight | logoDark (.png/.jpg/.webp)\\ngs configure clear <kind> remove an uploaded asset\\n\\ngs connectors [--json] list Maker toggle + custom connectors\\ngs connectors add <name> --url <url> [--token <t>] [--profileUrl <u>]\\n [--disabled] [--force]\\ngs connectors remove <name|id>\\ngs connectors enable|disable <name|id>\\ngs connectors maker on|off\\ngs connectors health [<name|id>] [--json]\\n\\ngs apps <init|build|list|pull|push|publish|unpublish|delete>\\n see chat-components.md for the workflow\\n```\\n","references/structured-content.md":"# `generateStructuredContent` deep dive\\n\\nAI-generated, catalog-grounded JSON for UI you render yourself \u2014 the chat\\npanel is not involved.\\n\\n```js\\nconst data = await window.GreatStore.generateStructuredContent(schema, prompt);\\n```\\n\\n- `schema` \u2014 a JSON Schema describing the output (or a Zod schema exposing\\n `.toJSONSchema()`).\\n- `prompt` \u2014 what to generate.\\n- Resolves to **the generated JSON object itself**, matching the schema.\\n Rejects with an `Error` on any failure.\\n\\nCode samples here are framework-free reference implementations \u2014 re-express\\nthem in the host repo\'s framework (React, Vue, Shopify Liquid, \u2026) rather\\nthan retrofitting them as-is.\\n\\n## The rules that make it work well\\n\\nEach is unpacked in the sections below; this is the checklist.\\n\\n1. **Top level must be an object.** Want a list? Wrap it:\\n `{ type: \\"object\\", properties: { items: { type: \\"array\\", \u2026 } }, required: [\\"items\\"] }`.\\n2. **Steer with the prompt, not schema `description`s** \u2014 free-text schema\\n fields are stripped before the AI sees them. Use self-explanatory\\n property names (`benefitHeadline`, not `text1`).\\n3. **Only `require` what\'s guaranteed.** `required` is strictly enforced; if\\n the catalog can\'t ground a required field the whole call can fail. Require\\n structural fields, keep per-product details (image URLs, prices) optional,\\n and make rendering tolerate missing values.\\n4. **Point, don\'t paste.** GreatStore researches the store\'s live catalog on\\n its own \u2014 name the entity (`` `\u2026the product \\"${productName}\\" (SKU ${sku})` ``)\\n and let it look the facts up. Don\'t fetch specs/prices/descriptions\\n yourself and paste them into the prompt. The one thing it *can\'t* see is\\n your page, so page-only context (which page the shopper is on, what the\\n section is for) does belong in the prompt.\\n5. **Research is bounded by the store\'s connectors \u2014 validate before you\\n build.** The AI can only look up what the store\'s MCP connectors actually\\n provide. Ask for something outside them \u2014 currency conversion, live\\n shipping rates, review data the store never wired up \u2014 and it has nothing\\n to ground on, so it will decline, omit\u2026 or hallucinate. Before writing a\\n prompt or schema that depends on a data capability, run `gs connectors`\\n (and `gs connectors health`, [store-admin.md](store-admin.md)) and confirm\\n a connector for it exists; if it doesn\'t, don\'t ask for it.\\n6. **Responses are cached** for up to ~24h per page + prompt + schema \u2014\\n shared across anonymous visitors, per-shopper for identified ones. Keep\\n prompts deterministic per page \u2014 no timestamps, random values, or\\n per-visitor data (GreatStore already knows who the shopper is; see below).\\n7. **Progressive enhancement, always.** Generate after the page renders into\\n a hidden container, reveal on success, leave the fallback on error. Render\\n generated strings via `textContent`, never `innerHTML`.\\n8. **One rich call beats many small ones** \u2014 there\'s a per-visitor rate limit\\n (~20 requests/minute); fetch multiple surfaces with one combined schema.\\n\\n## What actually happens\\n\\n1. The SDK posts your schema + prompt to the store\'s GreatStore endpoint,\\n along with the current **page URL and page title** (sent automatically \u2014\\n you don\'t pass them, and you can\'t override them).\\n2. GreatStore first **researches**: it looks up real data from the store\'s\\n live catalog (products, prices, availability, store info) using read-only\\n lookups. The page URL/title serve as hints about which product or category\\n to look up \u2014 they are *not* treated as a source of product data, and the\\n page\'s DOM is never read. Research happens on GreatStore\'s side \u2014 your\\n prompt only needs to *point* it at the right SKU, product, or collection,\\n not carry the material. Its reach is exactly the store\'s MCP connectors:\\n validate with `gs connectors` ([store-admin.md](store-admin.md)) that a\\n connector for the data you want actually exists before you build on it \u2014\\n research can\'t exceed the wired-up connectors, and prompts that assume\\n otherwise invite hallucinated filler.\\n3. The AI then fills your schema from the researched data, under a strict\\n grounding contract: it must not invent product names, prices, images, IDs,\\n or descriptions. Fields it can\'t ground are omitted or `null`. For an\\n identified shopper this step also sees their shopper profile \u2014 the same\\n identity the chat assistant has \u2014 so the output can be subtly personalized\\n without you passing anything about the visitor.\\n4. The output is validated against your schema (with internal retries) before\\n being returned and cached.\\n\\nGreatStore already knows who\'s reading: identity rides the request the same\\nway it does for chat, and responses for identified shoppers are cached just\\nfor them (anonymous visitors share one entry). The practical consequence:\\nnever put shopper data in the prompt \u2014 it\'s redundant, and it poisons the\\ncache key.\\n\\n## Schema support\\n\\nTop level **must describe an object**: `type: \\"object\\"` (or a bare\\n`properties` / `anyOf`). To get a list, wrap it in an object property.\\n\\nSupported keywords (anything else is tolerated but ignored):\\n\\n- Types: `object`, `array`, `string`, `number`, `integer`, `boolean`, `null`\\n- Structure: `properties`, `required`, `items`, `additionalProperties`\\n- Choice: `enum`, `const`, `anyOf`, `nullable`\\n- Constraints: `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`,\\n `maxItems`, `pattern`, `format`, `default`\\n\\nValidation of the output is real: `required` is enforced, `enum`/`const`\\nmust match, numeric and length bounds are checked, and\\n`additionalProperties: false` rejects extra keys. Constraints are therefore a\\n*tool* \u2014 `maxItems: 4` reliably caps a list, `enum` reliably restricts a\\nfield \u2014 but every constraint is also a way for generation to fail, so apply\\nthem only where you\'d rather have no content than non-conforming content.\\n\\nZod schemas (or anything with a `.toJSONSchema()` method) are accepted and\\nconverted automatically.\\n\\n### Free-text schema fields are stripped\\n\\n`description`, `title`, and `example` are removed from the schema before the\\nAI sees it (they\'re a prompt-injection surface, so they\'re filtered\\nserver-side). Consequences:\\n\\n- Schema descriptions **cannot** steer generation. All steering lives in the\\n prompt string.\\n- Property *names* are the only in-schema signal of intent \u2014 make them\\n self-documenting: `ctaLabel`, `warmthRating`, `priceJustification`.\\n\\n## Prompting guide\\n\\nThe prompt is the entire instruction channel. A good prompt states, in order:\\n\\n1. **Context** \u2014 what page/situation the shopper is in, naming the entity so\\n research targets the right thing (the AI can\'t see your DOM, so identify\\n it explicitly):\\n `The shopper is viewing the product \\"Aurora Down Parka\\" (SKU AUR-021) on its product page.`\\n2. **Task** \u2014 what to generate, mapped loosely onto your schema\'s fields:\\n `Write a heading and 3 reasons to love it; each reason has a short title and one supporting sentence.`\\n3. **Grounding expectations** \u2014 what store data to draw on:\\n `Base every claim on the product\'s real materials, features, and price.`\\n4. **Voice** \u2014 tone and constraints:\\n `Warm and concrete. No exclamation marks, no generic marketing filler.`\\n\\nNote what\'s *not* in that prompt: no pasted specs, prices, or descriptions.\\nPointing at the SKU is enough \u2014 GreatStore researches the rest itself, from\\ndata that\'s live rather than whatever was true when you wrote the prompt.\\n\\nAnti-patterns:\\n\\n- **Pasting researched material into the prompt** (specs, prices,\\n descriptions you fetched from your platform\'s API). GreatStore researches\\n the live catalog itself \u2014 point it at the SKU/product/collection and let\\n it look things up. Pasted facts go stale, bloat the cache key, and compete\\n with the fresher data research returns.\\n- **Per-visitor or per-moment data in the prompt** (names, cart contents,\\n timestamps, `Math.random()`): destroys caching, so every visitor pays full\\n generation latency and the store pays for every call \u2014 and it\'s redundant,\\n because GreatStore already knows the shopper and personalizes for\\n identified ones server-side. If you need per-shopper *interaction*, that\'s\\n what `sendMessage` and the chat panel are for.\\n- **Asking for data no connector provides** (\\"convert the price to EUR\\",\\n \\"estimate delivery to the shopper\'s city\\"): research can\'t exceed the\\n store\'s MCP connectors, and the AI may hallucinate plausible-looking\\n values rather than leave the field empty. Validate first \u2014 `gs connectors`\\n shows what\'s wired up ([store-admin.md](store-admin.md)); if there\'s no\\n connector for it, don\'t ask for it.\\n- **Asking it to read the page** (\\"summarize the reviews shown below\\") \u2014 it\\n can\'t. Page-only data (review snippets, UGC, things that exist nowhere but\\n the DOM) is the one kind worth inlining \u2014 keep it stable per page so\\n caching still works.\\n- **Asking for minute-fresh operational data** (exact live stock counts,\\n delivery countdowns). Even when a connector could answer, responses cache\\n for up to ~24h \u2014 display operational data from your own platform APIs and\\n use GreatStore for *editorial intelligence over the catalog*.\\n- **Burying instructions in schema descriptions** \u2014 stripped, see above.\\n\\n## Caching: design for it\\n\\nResponses are cached server-side for up to **24 hours**, keyed by the\\ncombination of page URL + page title + prompt + schema. Anonymous visitors\\nshare one entry per key; identified shoppers each get their own (their\\noutput may be personalized, so it\'s only ever served back to them).\\n(Tracking query params like `utm_*`/`gclid` and the URL fragment are\\nignored, so ad-tagged visits share the campaign-free page\'s cache entry.\\nMeaningful params like `?product=123` are part of the key.)\\n\\nPractical consequences:\\n\\n- **The first render pays, repeats fly.** Expect a few seconds on a cache\\n miss and near-instant responses after \u2014 shared across all anonymous\\n traffic, per-shopper for identified traffic (the research underneath is\\n cached briefly and shared, so even those misses are cheaper than cold).\\n Design loading states for the miss case.\\n- **Same call on different pages = different content**, automatically \u2014 the\\n page URL is in the key and in the AI\'s hints. A single site-wide snippet\\n with a constant prompt yields per-page content for free.\\n- **Content refreshes roughly daily.** Don\'t build experiences that assume\\n minute-level freshness.\\n- **To force different content, change the prompt or schema** (e.g. a\\n campaign variant string that changes weekly \u2014 deliberate, low-cardinality\\n variation is fine; per-visitor cardinality is not).\\n\\nThe catalog research underneath is also cached briefly, so several distinct\\nsurfaces on the same page (different prompts/schemas) stay cheap even on\\ncold cache.\\n\\n## Errors and how to handle them\\n\\nThe promise rejects with `new Error(message)`. The message is\\ndeveloper-facing \u2014 never render it to shoppers. Cases:\\n\\n| Case | Message you\'ll see | Retry? |\\n|---|---|---|\\n| Bad input (empty prompt, non-object schema) | thrown immediately by the SDK | Fix the call |\\n| Invalid schema shape | `Invalid schema: \u2026` | Fix the schema |\\n| AI declined the request | `The assistant declined to generate content for this request.` | No \u2014 permanent for that prompt/schema. Rework the prompt. |\\n| Output couldn\'t satisfy the schema | `Failed to produce valid structured content` | No \u2014 usually `required`/constraints demand data the catalog lacks. Loosen the schema. |\\n| Rate limit (~20/min per visitor) | rate-limit message | Later \u2014 and consolidate calls |\\n| Network / server | varies | Next page load |\\n\\nThe uniform shopper-facing strategy: render into a hidden-by-default\\ncontainer, reveal on success, leave hidden (or show your static fallback) on\\nany rejection. One `try/catch`, no case analysis needed unless you\'re\\nlogging.\\n\\n## Performance pattern\\n\\nFire generation as early as possible without blocking render \u2014 top of your\\ndeferred script, before other work:\\n\\n```js\\nconst highlightsPromise = window.GreatStore?.generateStructuredContent\\n ? window.GreatStore.generateStructuredContent(schema, prompt).catch(() => null)\\n : Promise.resolve(null);\\n\\n// \u2026rest of page setup\u2026\\n\\nconst data = await highlightsPromise;\\nif (data) renderHighlights(data);\\n```\\n\\nThe `.catch(() => null)` attached immediately avoids unhandled-rejection\\nnoise while keeping a single render path.\\n\\nFor multiple surfaces on one page, prefer **one call with a combined\\nschema** over parallel calls \u2014 it\'s one research pass, one cache entry, and\\nno rate-limit pressure:\\n\\n```js\\nconst schema = {\\n type: \\"object\\",\\n properties: {\\n highlights: { /* \u2026 */ },\\n faq: { /* \u2026 */ },\\n crossSell: { /* \u2026 */ },\\n },\\n required: [\\"highlights\\"],\\n};\\n```\\n"}') : readTreeFromDisk(new URL("../../skill/", import.meta.url));
|
|
577
|
+
if (!cache2["SKILL.md"]) {
|
|
578
|
+
throw new Error("internal: agent skill tree is missing SKILL.md");
|
|
587
579
|
}
|
|
588
|
-
return
|
|
589
|
-
manifestHash,
|
|
590
|
-
sourceHash: hashFile(path3.join(componentDir, "component.tsx"))
|
|
591
|
-
};
|
|
580
|
+
return cache2;
|
|
592
581
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
version: parsed.version,
|
|
601
|
-
manifestHash: parsed.manifestHash,
|
|
602
|
-
sourceHash: parsed.sourceHash
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
return null;
|
|
606
|
-
} catch {
|
|
607
|
-
return null;
|
|
582
|
+
|
|
583
|
+
// src/commands/skill.ts
|
|
584
|
+
function skillCommand(args) {
|
|
585
|
+
const global = flagBool(args.flags, "global");
|
|
586
|
+
const dirFlag = flagString(args.flags, "dir");
|
|
587
|
+
if (global && dirFlag !== void 0) {
|
|
588
|
+
throw new Error("Pass either --global or --dir <path>, not both.");
|
|
608
589
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
fs3.
|
|
612
|
-
|
|
613
|
-
|
|
590
|
+
const skillsDir = dirFlag !== void 0 ? path3.resolve(dirFlag) : global ? path3.join(os2.homedir(), ".claude", "skills") : path3.resolve(".claude", "skills");
|
|
591
|
+
const dest = path3.join(skillsDir, SKILL_DIR_NAME);
|
|
592
|
+
const existed = fs3.existsSync(path3.join(dest, "SKILL.md"));
|
|
593
|
+
for (const [rel, content] of Object.entries(loadSkill())) {
|
|
594
|
+
const full = path3.join(dest, ...rel.split("/"));
|
|
595
|
+
fs3.mkdirSync(path3.dirname(full), { recursive: true });
|
|
596
|
+
fs3.writeFileSync(full, content);
|
|
597
|
+
}
|
|
598
|
+
process.stdout.write(
|
|
599
|
+
[
|
|
600
|
+
`${existed ? "Updated" : "Installed"} the GreatStore agent skill at ${displayPath(dest)}.`,
|
|
601
|
+
"",
|
|
602
|
+
"Your coding agent picks it up automatically. Try asking it:",
|
|
603
|
+
` "Add an AI-powered gift finder to my product page with GreatStore."`,
|
|
604
|
+
""
|
|
605
|
+
].join("\n")
|
|
614
606
|
);
|
|
615
607
|
}
|
|
616
|
-
function
|
|
617
|
-
const
|
|
618
|
-
if (
|
|
619
|
-
|
|
620
|
-
if (hashes.manifestHash !== state.manifestHash) return true;
|
|
621
|
-
const localSource = hashes.sourceHash ?? "";
|
|
622
|
-
const storedSource = state.sourceHash ?? "";
|
|
623
|
-
return localSource !== storedSource;
|
|
624
|
-
}
|
|
625
|
-
function safeComputeHashes(componentDir) {
|
|
626
|
-
try {
|
|
627
|
-
return computeComponentHashes(componentDir);
|
|
628
|
-
} catch {
|
|
629
|
-
return null;
|
|
608
|
+
function displayPath(dest) {
|
|
609
|
+
const home = os2.homedir();
|
|
610
|
+
if (dest.startsWith(home + path3.sep)) {
|
|
611
|
+
return `~${dest.slice(home.length)}`;
|
|
630
612
|
}
|
|
613
|
+
const rel = path3.relative(process.cwd(), dest);
|
|
614
|
+
return rel && !rel.startsWith("..") ? rel : dest;
|
|
631
615
|
}
|
|
632
616
|
|
|
633
|
-
// src/commands/
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
if (
|
|
640
|
-
|
|
617
|
+
// src/commands/apps/init.ts
|
|
618
|
+
import * as fs4 from "fs";
|
|
619
|
+
import * as path4 from "path";
|
|
620
|
+
var NAME_REGEX = /^[a-z][a-z0-9_]*$/;
|
|
621
|
+
function initCommand(args) {
|
|
622
|
+
const name = args.positional[0];
|
|
623
|
+
if (name !== void 0 && !NAME_REGEX.test(name)) {
|
|
624
|
+
throw new Error(`Invalid component name: "${name}" (must match ${NAME_REGEX}).`);
|
|
641
625
|
}
|
|
642
|
-
|
|
626
|
+
const force = flagBool(args.flags, "force");
|
|
627
|
+
const storeFlag = flagString(args.flags, "store");
|
|
628
|
+
const outRel = flagString(args.flags, "out") ?? ".";
|
|
629
|
+
const root = path4.resolve(outRel);
|
|
630
|
+
const rootExisted = hasRootScaffold(root);
|
|
631
|
+
if (rootExisted) {
|
|
632
|
+
if (storeFlag !== void 0) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
`Project at ${root} is already pinned to a store via .gsrc. Drop --store; a project folder ships to exactly one store.`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
} else if (storeFlag === void 0) {
|
|
638
|
+
const example = name ? `gs apps init ${name} --store <slug>` : `gs apps init --store <slug>`;
|
|
643
639
|
throw new Error(
|
|
644
|
-
|
|
640
|
+
`--store <slug> is required when scaffolding a new project root. Run \`${example}\`.`
|
|
645
641
|
);
|
|
646
642
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
throw new Error(
|
|
668
|
-
"--version is per-component; pass it together with `gs pull <name>`."
|
|
643
|
+
const writtenRootFiles = ensureRoot(root, rootExisted, force, { store: storeFlag });
|
|
644
|
+
if (!name) {
|
|
645
|
+
const lines = [];
|
|
646
|
+
if (rootExisted) {
|
|
647
|
+
lines.push(`Updated GreatStore project root in ${root}.`);
|
|
648
|
+
if (writtenRootFiles.length > 0) {
|
|
649
|
+
lines.push("", `Added: ${writtenRootFiles.join(", ")}`);
|
|
650
|
+
} else {
|
|
651
|
+
lines.push("", "Already up to date \u2014 nothing to add.");
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
lines.push(`Scaffolded GreatStore project root in ${root}.`);
|
|
655
|
+
}
|
|
656
|
+
lines.push(
|
|
657
|
+
"",
|
|
658
|
+
"Next steps:",
|
|
659
|
+
` cd ${path4.relative(process.cwd(), root) || "."}`,
|
|
660
|
+
" npm install",
|
|
661
|
+
" gs apps init <component_name> # add your first component",
|
|
662
|
+
""
|
|
669
663
|
);
|
|
670
|
-
|
|
671
|
-
ensureComponentsDir(root);
|
|
672
|
-
const revisionQuery = buildRevisionQuery(args);
|
|
673
|
-
const listUrl = `${apiBaseFor(slug)}/api/builder/components`;
|
|
674
|
-
const list = await request(listUrl);
|
|
675
|
-
if (list.components.length === 0) {
|
|
676
|
-
process.stdout.write(`(no components in ${slug})
|
|
677
|
-
`);
|
|
664
|
+
process.stdout.write(lines.join("\n"));
|
|
678
665
|
return;
|
|
679
666
|
}
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const outcome = await pullOne({
|
|
684
|
-
slug,
|
|
685
|
-
componentDir,
|
|
686
|
-
name: entry.name,
|
|
687
|
-
revisionQuery,
|
|
688
|
-
force
|
|
689
|
-
});
|
|
690
|
-
outcomes.push(outcome);
|
|
691
|
-
printOutcome(outcome, force);
|
|
692
|
-
}
|
|
693
|
-
printPullSummary(outcomes, slug);
|
|
694
|
-
if (outcomes.some((o) => o.status === "failed")) process.exitCode = 1;
|
|
695
|
-
}
|
|
696
|
-
function printPullSummary(outcomes, slug) {
|
|
697
|
-
const pulled = outcomes.filter((o) => o.status === "pulled").length;
|
|
698
|
-
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
699
|
-
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
700
|
-
const summary = [`${pulled} pulled`, `${skipped} skipped`];
|
|
701
|
-
if (failed > 0) summary.push(`${failed} failed`);
|
|
667
|
+
const componentDir = path4.join(root, "components", name);
|
|
668
|
+
ensureComponent(componentDir, name, force);
|
|
669
|
+
const projectLabel = path4.relative(process.cwd(), root) || ".";
|
|
702
670
|
process.stdout.write(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
`
|
|
671
|
+
[
|
|
672
|
+
`Added component "${name}" at components/${name}/.`,
|
|
673
|
+
...rootExisted && writtenRootFiles.length > 0 ? ["", `Also added to the project root: ${writtenRootFiles.join(", ")}`] : [],
|
|
674
|
+
"",
|
|
675
|
+
"Next steps:",
|
|
676
|
+
...rootExisted ? [] : [` cd ${projectLabel}`, " npm install"],
|
|
677
|
+
` # edit components/${name}/component.tsx and manifest.json`,
|
|
678
|
+
" gs apps build",
|
|
679
|
+
" gs apps push",
|
|
680
|
+
""
|
|
681
|
+
].join("\n")
|
|
706
682
|
);
|
|
707
683
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
684
|
+
function hasRootScaffold(dir) {
|
|
685
|
+
return fs4.existsSync(path4.join(dir, "package.json")) && fs4.existsSync(path4.join(dir, ".gsrc"));
|
|
686
|
+
}
|
|
687
|
+
function ensureRoot(root, rootExisted, force, opts) {
|
|
688
|
+
fs4.mkdirSync(root, { recursive: true });
|
|
689
|
+
if (!rootExisted && !force) {
|
|
690
|
+
const entries = fs4.readdirSync(root).filter((e) => e !== ".gsrc");
|
|
691
|
+
if (entries.length > 0) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
`Refusing to scaffold project root into non-empty directory ${root}. Pass --force to override.`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const files = rootExisted ? existingRootFiles() : rootFiles({ store: opts.store });
|
|
698
|
+
const written = writeRootFiles(root, files, force);
|
|
699
|
+
fs4.mkdirSync(path4.join(root, "components"), { recursive: true });
|
|
700
|
+
return written;
|
|
701
|
+
}
|
|
702
|
+
function writeRootFiles(root, files, force) {
|
|
703
|
+
const written = [];
|
|
704
|
+
for (const [relPath, content] of files) {
|
|
705
|
+
const full = path4.join(root, relPath);
|
|
706
|
+
fs4.mkdirSync(path4.dirname(full), { recursive: true });
|
|
707
|
+
if (force || !fs4.existsSync(full)) {
|
|
708
|
+
fs4.writeFileSync(full, content);
|
|
709
|
+
written.push(relPath);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
for (const link of ["CLAUDE.md", "GEMINI.md"]) {
|
|
713
|
+
const linkPath = path4.join(root, link);
|
|
714
|
+
if (force && pathExists(linkPath)) fs4.rmSync(linkPath);
|
|
715
|
+
if (!pathExists(linkPath)) {
|
|
716
|
+
fs4.symlinkSync("AGENTS.md", linkPath);
|
|
717
|
+
written.push(link);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return written;
|
|
721
|
+
}
|
|
722
|
+
function pathExists(p) {
|
|
723
|
+
try {
|
|
724
|
+
fs4.lstatSync(p);
|
|
725
|
+
return true;
|
|
726
|
+
} catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function ensureComponent(componentDir, name, force) {
|
|
731
|
+
if (fs4.existsSync(componentDir) && !force) {
|
|
732
|
+
const entries = fs4.readdirSync(componentDir);
|
|
733
|
+
if (entries.length > 0) {
|
|
734
|
+
throw new Error(
|
|
735
|
+
`Refusing to overwrite existing components/${name}/. Pass --force to override.`
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
fs4.mkdirSync(componentDir, { recursive: true });
|
|
740
|
+
for (const [relPath, content] of componentFiles(name)) {
|
|
741
|
+
fs4.writeFileSync(path4.join(componentDir, relPath), content);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function templateFiles(prefix, vars, exclude = /* @__PURE__ */ new Set()) {
|
|
745
|
+
const tree = loadTemplate();
|
|
746
|
+
return Object.entries(tree).filter(([rel]) => rel.startsWith(prefix)).map(([rel, content]) => [rel.slice(prefix.length), content]).filter(([dest]) => !exclude.has(dest)).map(([dest, content]) => [dest, applyTemplate(content, vars)]);
|
|
747
|
+
}
|
|
748
|
+
function rootFiles(opts) {
|
|
749
|
+
if (!opts.store) {
|
|
750
|
+
throw new Error("internal: rootFiles called without a store slug");
|
|
751
|
+
}
|
|
752
|
+
return templateFiles("root/", { store: opts.store });
|
|
753
|
+
}
|
|
754
|
+
function existingRootFiles() {
|
|
755
|
+
return templateFiles("root/", {}, /* @__PURE__ */ new Set([".gsrc"]));
|
|
756
|
+
}
|
|
757
|
+
function componentFiles(name) {
|
|
758
|
+
return templateFiles("component/", {
|
|
759
|
+
name,
|
|
760
|
+
pascalName: pascal(name),
|
|
761
|
+
displayName: defaultDisplayName(name)
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
function defaultDisplayName(name) {
|
|
765
|
+
const words = name.split("_").filter(Boolean);
|
|
766
|
+
if (words.length === 0) return name;
|
|
767
|
+
const first = words[0];
|
|
768
|
+
return first.charAt(0).toUpperCase() + first.slice(1) + (words.length > 1 ? " " + words.slice(1).join(" ") : "");
|
|
769
|
+
}
|
|
770
|
+
function pascal(name) {
|
|
771
|
+
return name.split(/[_-]/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join("");
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/commands/apps/build.ts
|
|
775
|
+
import * as fs5 from "fs";
|
|
776
|
+
import * as path5 from "path";
|
|
777
|
+
import { createRequire } from "module";
|
|
778
|
+
var COMPONENTS_DIR = "components";
|
|
779
|
+
async function buildCommand(args) {
|
|
780
|
+
const root = process.cwd();
|
|
781
|
+
if (!fs5.existsSync(path5.join(root, "package.json")) || !fs5.existsSync(path5.join(root, COMPONENTS_DIR))) {
|
|
782
|
+
throw new Error(
|
|
783
|
+
`\`gs apps build\` must run from a project root (contains \`package.json\` and \`${COMPONENTS_DIR}/\`). Current dir: ${root}`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
const components = listComponents(root);
|
|
787
|
+
if (components.length === 0) {
|
|
788
|
+
process.stdout.write(
|
|
789
|
+
`(no components in ./${COMPONENTS_DIR} \u2014 run \`gs apps init <name>\` to add one)
|
|
790
|
+
`
|
|
791
|
+
);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const target = args.positional[0];
|
|
795
|
+
const queue = target ? components.filter((c) => c === target) : components;
|
|
796
|
+
if (target && queue.length === 0) {
|
|
797
|
+
throw new Error(`No component named "${target}" in ./${COMPONENTS_DIR}`);
|
|
798
|
+
}
|
|
799
|
+
const { build, reactPlugin, transformWithEsbuild } = await loadVite(root);
|
|
800
|
+
for (const name of queue) {
|
|
801
|
+
const dir = path5.join(root, COMPONENTS_DIR, name);
|
|
802
|
+
const bundlePath = path5.join(dir, "bundle.js");
|
|
803
|
+
await build({
|
|
804
|
+
plugins: [reactPlugin()],
|
|
805
|
+
logLevel: "warn",
|
|
806
|
+
build: {
|
|
807
|
+
lib: {
|
|
808
|
+
entry: path5.join(dir, "component.tsx"),
|
|
809
|
+
formats: ["es"],
|
|
810
|
+
fileName: () => "bundle.js"
|
|
811
|
+
},
|
|
812
|
+
outDir: dir,
|
|
813
|
+
emptyOutDir: false,
|
|
814
|
+
rollupOptions: {
|
|
815
|
+
external: ["react", "react-dom", "react/jsx-runtime"],
|
|
816
|
+
output: {
|
|
817
|
+
entryFileNames: "bundle.js",
|
|
818
|
+
paths: {
|
|
819
|
+
react: "/assets/remote-components/_runtime.js",
|
|
820
|
+
"react-dom": "/assets/remote-components/_runtime.js",
|
|
821
|
+
"react/jsx-runtime": "/assets/remote-components/_runtime.js"
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
minify: true,
|
|
826
|
+
sourcemap: false
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
const beforeBytes = fs5.statSync(bundlePath).size;
|
|
830
|
+
const src = fs5.readFileSync(bundlePath, "utf8");
|
|
831
|
+
const { code } = await transformWithEsbuild(src, bundlePath, {
|
|
832
|
+
minify: true,
|
|
833
|
+
legalComments: "none",
|
|
834
|
+
target: "esnext",
|
|
835
|
+
loader: "js",
|
|
836
|
+
sourcemap: false
|
|
837
|
+
});
|
|
838
|
+
fs5.writeFileSync(bundlePath, code);
|
|
839
|
+
const afterBytes = Buffer.byteLength(code, "utf8");
|
|
840
|
+
process.stdout.write(
|
|
841
|
+
`built ${COMPONENTS_DIR}/${name}/bundle.js (${formatBytes(afterBytes)}, ${pctSmaller(beforeBytes, afterBytes)} smaller)
|
|
842
|
+
`
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
process.stdout.write(
|
|
846
|
+
`
|
|
847
|
+
Built locally \u2014 nothing uploaded yet. Next: \`gs apps push\` to upload, then \`gs apps publish <name>\` to ship. If you're an AI assistant: confirm with the user before running these (or just run them if they already asked you to ship end-to-end).
|
|
848
|
+
`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
function listComponents(root) {
|
|
852
|
+
const dir = path5.join(root, COMPONENTS_DIR);
|
|
853
|
+
return fs5.readdirSync(dir).filter((entry) => {
|
|
854
|
+
const candidate = path5.join(dir, entry);
|
|
855
|
+
return fs5.statSync(candidate).isDirectory() && fs5.existsSync(path5.join(candidate, "component.tsx"));
|
|
856
|
+
}).sort();
|
|
857
|
+
}
|
|
858
|
+
function formatBytes(n) {
|
|
859
|
+
if (n < 1024) return `${n} B`;
|
|
860
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
861
|
+
}
|
|
862
|
+
function pctSmaller(before, after) {
|
|
863
|
+
if (before === 0) return "0%";
|
|
864
|
+
return `${Math.round((1 - after / before) * 100)}%`;
|
|
865
|
+
}
|
|
866
|
+
async function loadVite(root) {
|
|
867
|
+
const localRequire = createRequire(path5.join(root, "package.json"));
|
|
868
|
+
const vitePath = resolveEsmEntry(localRequire, "vite");
|
|
869
|
+
if (!vitePath) {
|
|
870
|
+
throw new Error(
|
|
871
|
+
"Cannot find `vite` in this project. Run `npm install` first."
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
const reactPath = resolveEsmEntry(localRequire, "@vitejs/plugin-react");
|
|
875
|
+
if (!reactPath) {
|
|
876
|
+
throw new Error(
|
|
877
|
+
"Cannot find `@vitejs/plugin-react` in this project. Run `npm install` first."
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
const viteMod = await import(vitePath);
|
|
881
|
+
const build = viteMod.build ?? viteMod.default?.build;
|
|
882
|
+
if (typeof build !== "function") {
|
|
883
|
+
throw new Error(
|
|
884
|
+
`Loaded \`vite\` from ${vitePath} but couldn't find its \`build()\` export. Reinstall vite (>= 5) and retry.`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const transformWithEsbuild = viteMod.transformWithEsbuild ?? viteMod.default?.transformWithEsbuild;
|
|
888
|
+
if (typeof transformWithEsbuild !== "function") {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`Loaded \`vite\` from ${vitePath} but couldn't find its \`transformWithEsbuild()\` export. Reinstall vite (>= 5) and retry.`
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
const reactMod = await import(reactPath);
|
|
894
|
+
const reactPlugin = reactMod.default ?? reactMod;
|
|
895
|
+
if (typeof reactPlugin !== "function") {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`Loaded \`@vitejs/plugin-react\` from ${reactPath} but couldn't find its default export.`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
return {
|
|
901
|
+
build,
|
|
902
|
+
reactPlugin,
|
|
903
|
+
transformWithEsbuild
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function resolveEsmEntry(req, specifier) {
|
|
907
|
+
let anchor;
|
|
908
|
+
try {
|
|
909
|
+
anchor = req.resolve(specifier);
|
|
910
|
+
} catch {
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
const pkgJsonPath = findOwningPackageJson(anchor, specifier);
|
|
914
|
+
if (!pkgJsonPath) return null;
|
|
915
|
+
const pkgDir = path5.dirname(pkgJsonPath);
|
|
916
|
+
let pkg;
|
|
917
|
+
try {
|
|
918
|
+
pkg = JSON.parse(fs5.readFileSync(pkgJsonPath, "utf8"));
|
|
919
|
+
} catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
const fromExports = pickImportEntry(pkg.exports);
|
|
923
|
+
const entry = fromExports ?? (typeof pkg.module === "string" ? pkg.module : null) ?? (typeof pkg.main === "string" ? pkg.main : null);
|
|
924
|
+
if (!entry) return null;
|
|
925
|
+
return path5.resolve(pkgDir, entry);
|
|
926
|
+
}
|
|
927
|
+
function findOwningPackageJson(start, specifier) {
|
|
928
|
+
let dir = path5.dirname(start);
|
|
929
|
+
while (true) {
|
|
930
|
+
const candidate = path5.join(dir, "package.json");
|
|
931
|
+
if (fs5.existsSync(candidate)) {
|
|
932
|
+
try {
|
|
933
|
+
const parsed = JSON.parse(fs5.readFileSync(candidate, "utf8"));
|
|
934
|
+
if (parsed.name === specifier) return candidate;
|
|
935
|
+
} catch {
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const parent = path5.dirname(dir);
|
|
939
|
+
if (parent === dir) return null;
|
|
940
|
+
dir = parent;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
function pickImportEntry(exports) {
|
|
944
|
+
if (typeof exports !== "object" || exports === null) return null;
|
|
945
|
+
const map = exports;
|
|
946
|
+
const dot = map["."] ?? exports;
|
|
947
|
+
if (typeof dot !== "object" || dot === null) return null;
|
|
948
|
+
const imp = dot.import;
|
|
949
|
+
if (typeof imp === "string") return imp;
|
|
950
|
+
if (typeof imp === "object" && imp !== null) {
|
|
951
|
+
const def = imp.default;
|
|
952
|
+
if (typeof def === "string") return def;
|
|
953
|
+
}
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/commands/apps/list.ts
|
|
958
|
+
async function listCommand(args) {
|
|
959
|
+
const slug = requireProjectStore();
|
|
960
|
+
const url = `${apiBaseFor(slug)}/api/builder/components`;
|
|
961
|
+
const data = await request(url);
|
|
962
|
+
if (flagBool(args.flags, "json")) {
|
|
963
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (data.components.length === 0) {
|
|
967
|
+
process.stdout.write(`(no components in ${slug})
|
|
968
|
+
`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const rows = data.components.map((c) => ({
|
|
972
|
+
name: c.name,
|
|
973
|
+
draft: c.draft ? `v${c.draft.version}` : "\u2014",
|
|
974
|
+
live: c.live ? `v${c.live.version}` : "\u2014",
|
|
975
|
+
updated: c.live?.updatedAt ?? c.draft?.updatedAt ?? "",
|
|
976
|
+
permalink: c.permalink ?? ""
|
|
977
|
+
}));
|
|
978
|
+
const widths = {
|
|
979
|
+
name: Math.max(4, ...rows.map((r) => r.name.length)),
|
|
980
|
+
draft: Math.max(5, ...rows.map((r) => r.draft.length)),
|
|
981
|
+
live: Math.max(4, ...rows.map((r) => r.live.length)),
|
|
982
|
+
updated: Math.max(7, ...rows.map((r) => r.updated.length))
|
|
983
|
+
};
|
|
984
|
+
const header = `${pad("NAME", widths.name)} ${pad("DRAFT", widths.draft)} ${pad("LIVE", widths.live)} ${pad("UPDATED", widths.updated)} LINK`;
|
|
985
|
+
process.stdout.write(header + "\n");
|
|
986
|
+
for (const r of rows) {
|
|
987
|
+
process.stdout.write(
|
|
988
|
+
`${pad(r.name, widths.name)} ${pad(r.draft, widths.draft)} ${pad(r.live, widths.live)} ${pad(r.updated, widths.updated)} ${r.permalink}
|
|
989
|
+
`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function pad(s, width) {
|
|
994
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/commands/apps/pull.ts
|
|
998
|
+
import * as fs7 from "fs";
|
|
999
|
+
import * as path7 from "path";
|
|
1000
|
+
|
|
1001
|
+
// src/sync.ts
|
|
1002
|
+
import * as crypto2 from "crypto";
|
|
1003
|
+
import * as fs6 from "fs";
|
|
1004
|
+
import * as path6 from "path";
|
|
1005
|
+
var SYNC_FILE = ".gssync.json";
|
|
1006
|
+
function hashString(text) {
|
|
1007
|
+
return crypto2.createHash("sha256").update(text).digest("hex");
|
|
1008
|
+
}
|
|
1009
|
+
function hashFile(filePath) {
|
|
1010
|
+
if (!fs6.existsSync(filePath)) return null;
|
|
1011
|
+
return hashString(fs6.readFileSync(filePath, "utf8"));
|
|
1012
|
+
}
|
|
1013
|
+
function computeComponentHashes(componentDir) {
|
|
1014
|
+
const manifestHash = hashFile(path6.join(componentDir, "manifest.json"));
|
|
1015
|
+
if (manifestHash === null) {
|
|
1016
|
+
throw new Error(`Missing manifest.json in ${componentDir}`);
|
|
1017
|
+
}
|
|
1018
|
+
return {
|
|
1019
|
+
manifestHash,
|
|
1020
|
+
sourceHash: hashFile(path6.join(componentDir, "component.tsx"))
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
function readSyncState(componentDir) {
|
|
1024
|
+
const file = path6.join(componentDir, SYNC_FILE);
|
|
1025
|
+
if (!fs6.existsSync(file)) return null;
|
|
1026
|
+
try {
|
|
1027
|
+
const parsed = JSON.parse(fs6.readFileSync(file, "utf8"));
|
|
1028
|
+
if (typeof parsed.version === "number" && typeof parsed.manifestHash === "string" && typeof parsed.sourceHash === "string") {
|
|
1029
|
+
return {
|
|
1030
|
+
version: parsed.version,
|
|
1031
|
+
manifestHash: parsed.manifestHash,
|
|
1032
|
+
sourceHash: parsed.sourceHash
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
return null;
|
|
1036
|
+
} catch {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function writeSyncState(componentDir, state) {
|
|
1041
|
+
fs6.writeFileSync(
|
|
1042
|
+
path6.join(componentDir, SYNC_FILE),
|
|
1043
|
+
JSON.stringify(state, null, 2) + "\n"
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
function hasLocalChanges(componentDir, state) {
|
|
1047
|
+
const hashes = safeComputeHashes(componentDir);
|
|
1048
|
+
if (!hashes) return false;
|
|
1049
|
+
if (state === null) return true;
|
|
1050
|
+
if (hashes.manifestHash !== state.manifestHash) return true;
|
|
1051
|
+
const localSource = hashes.sourceHash ?? "";
|
|
1052
|
+
const storedSource = state.sourceHash ?? "";
|
|
1053
|
+
return localSource !== storedSource;
|
|
1054
|
+
}
|
|
1055
|
+
function safeComputeHashes(componentDir) {
|
|
1056
|
+
try {
|
|
1057
|
+
return computeComponentHashes(componentDir);
|
|
1058
|
+
} catch {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// src/commands/apps/pull.ts
|
|
1064
|
+
async function pullCommand(args) {
|
|
1065
|
+
const slug = requireProjectStore();
|
|
1066
|
+
const force = flagBool(args.flags, "force");
|
|
1067
|
+
const targets = args.positional;
|
|
1068
|
+
const root = process.cwd();
|
|
1069
|
+
if (targets.length === 0 || targets.length === 1 && targets[0] === "*") {
|
|
1070
|
+
return pullAll({ slug, root, args, force });
|
|
1071
|
+
}
|
|
1072
|
+
if (targets.length > 1 && flagString(args.flags, "version")) {
|
|
1073
|
+
throw new Error(
|
|
1074
|
+
"--version is per-component; pass it together with a single `gs apps pull <name>`."
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
ensureComponentsDir(root);
|
|
1078
|
+
const revisionQuery = buildRevisionQuery(args);
|
|
1079
|
+
const outcomes = [];
|
|
1080
|
+
for (const name of targets) {
|
|
1081
|
+
const outcome = await pullOne({
|
|
1082
|
+
slug,
|
|
1083
|
+
componentDir: path7.join(root, "components", name),
|
|
1084
|
+
name,
|
|
1085
|
+
revisionQuery,
|
|
1086
|
+
force
|
|
1087
|
+
});
|
|
1088
|
+
outcomes.push(outcome);
|
|
1089
|
+
printOutcome(outcome, force);
|
|
1090
|
+
}
|
|
1091
|
+
if (outcomes.length > 1) printPullSummary(outcomes, slug);
|
|
1092
|
+
if (outcomes.some((o) => o.status === "failed")) process.exitCode = 1;
|
|
1093
|
+
}
|
|
1094
|
+
async function pullAll(opts) {
|
|
1095
|
+
const { slug, root, args, force } = opts;
|
|
1096
|
+
if (flagString(args.flags, "version")) {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
"--version is per-component; pass it together with `gs apps pull <name>`."
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
ensureComponentsDir(root);
|
|
1102
|
+
const revisionQuery = buildRevisionQuery(args);
|
|
1103
|
+
const listUrl = `${apiBaseFor(slug)}/api/builder/components`;
|
|
1104
|
+
const list = await request(listUrl);
|
|
1105
|
+
if (list.components.length === 0) {
|
|
1106
|
+
process.stdout.write(`(no components in ${slug})
|
|
1107
|
+
`);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const outcomes = [];
|
|
1111
|
+
for (const entry of list.components) {
|
|
1112
|
+
const componentDir = path7.join(root, "components", entry.name);
|
|
1113
|
+
const outcome = await pullOne({
|
|
1114
|
+
slug,
|
|
1115
|
+
componentDir,
|
|
1116
|
+
name: entry.name,
|
|
1117
|
+
revisionQuery,
|
|
1118
|
+
force
|
|
1119
|
+
});
|
|
1120
|
+
outcomes.push(outcome);
|
|
1121
|
+
printOutcome(outcome, force);
|
|
1122
|
+
}
|
|
1123
|
+
printPullSummary(outcomes, slug);
|
|
1124
|
+
if (outcomes.some((o) => o.status === "failed")) process.exitCode = 1;
|
|
1125
|
+
}
|
|
1126
|
+
function printPullSummary(outcomes, slug) {
|
|
1127
|
+
const pulled = outcomes.filter((o) => o.status === "pulled").length;
|
|
1128
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
1129
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
1130
|
+
const summary = [`${pulled} pulled`, `${skipped} skipped`];
|
|
1131
|
+
if (failed > 0) summary.push(`${failed} failed`);
|
|
1132
|
+
process.stdout.write(
|
|
1133
|
+
`
|
|
1134
|
+
${summary.join(", ")} (of ${outcomes.length}) \u2190 ${slug}
|
|
1135
|
+
`
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
async function pullOne(opts) {
|
|
1139
|
+
const { slug, componentDir, name, revisionQuery, force } = opts;
|
|
1140
|
+
if (!force && fs7.existsSync(componentDir)) {
|
|
1141
|
+
const sync = readSyncState(componentDir);
|
|
1142
|
+
if (hasLocalChanges(componentDir, sync)) {
|
|
1143
|
+
return {
|
|
714
1144
|
name,
|
|
715
1145
|
status: "skipped",
|
|
716
1146
|
message: "local has uncommitted changes; pass --force to overwrite"
|
|
@@ -728,14 +1158,14 @@ async function pullOne(opts) {
|
|
|
728
1158
|
message: err instanceof Error ? err.message : String(err)
|
|
729
1159
|
};
|
|
730
1160
|
}
|
|
731
|
-
|
|
1161
|
+
fs7.mkdirSync(componentDir, { recursive: true });
|
|
732
1162
|
const manifestText = JSON.stringify(data.manifest, null, 2) + "\n";
|
|
733
|
-
|
|
734
|
-
|
|
1163
|
+
fs7.writeFileSync(path7.join(componentDir, "manifest.json"), manifestText);
|
|
1164
|
+
fs7.writeFileSync(path7.join(componentDir, "bundle.js"), data.bundle);
|
|
735
1165
|
const wrote = ["manifest.json", "bundle.js"];
|
|
736
1166
|
let sourceHash = "";
|
|
737
1167
|
if (data.source !== null) {
|
|
738
|
-
|
|
1168
|
+
fs7.writeFileSync(path7.join(componentDir, "component.tsx"), data.source);
|
|
739
1169
|
wrote.push("component.tsx");
|
|
740
1170
|
sourceHash = hashString(data.source);
|
|
741
1171
|
}
|
|
@@ -753,7 +1183,7 @@ async function pullOne(opts) {
|
|
|
753
1183
|
return { name, status: "pulled", version: data.version, wrote };
|
|
754
1184
|
}
|
|
755
1185
|
function ensureComponentsDir(root) {
|
|
756
|
-
|
|
1186
|
+
fs7.mkdirSync(path7.join(root, "components"), { recursive: true });
|
|
757
1187
|
}
|
|
758
1188
|
function buildRevisionQuery(args) {
|
|
759
1189
|
const version = flagString(args.flags, "version");
|
|
@@ -786,17 +1216,17 @@ function printOutcome(outcome, force) {
|
|
|
786
1216
|
void force;
|
|
787
1217
|
}
|
|
788
1218
|
|
|
789
|
-
// src/commands/push.ts
|
|
790
|
-
import * as
|
|
791
|
-
import * as
|
|
1219
|
+
// src/commands/apps/push.ts
|
|
1220
|
+
import * as fs8 from "fs";
|
|
1221
|
+
import * as path8 from "path";
|
|
792
1222
|
async function pushCommand(args) {
|
|
793
1223
|
const slug = requireProjectStore();
|
|
794
1224
|
const root = process.cwd();
|
|
795
1225
|
rejectLegacyLayout(root);
|
|
796
|
-
const componentsDir =
|
|
797
|
-
if (!
|
|
1226
|
+
const componentsDir = path8.join(root, "components");
|
|
1227
|
+
if (!fs8.existsSync(componentsDir)) {
|
|
798
1228
|
throw new Error(
|
|
799
|
-
"No components/ directory here. Run `gs init <name>` to scaffold the project root and your first component."
|
|
1229
|
+
"No components/ directory here. Run `gs apps init <name>` to scaffold the project root and your first component."
|
|
800
1230
|
);
|
|
801
1231
|
}
|
|
802
1232
|
const named = args.positional;
|
|
@@ -804,7 +1234,7 @@ async function pushCommand(args) {
|
|
|
804
1234
|
const bundleOverride = flagString(args.flags, "bundle");
|
|
805
1235
|
if (named.length !== 1 && (manifestOverride || bundleOverride)) {
|
|
806
1236
|
throw new Error(
|
|
807
|
-
"--manifest / --bundle can only be used together with a single `gs push <name>`."
|
|
1237
|
+
"--manifest / --bundle can only be used together with a single `gs apps push <name>`."
|
|
808
1238
|
);
|
|
809
1239
|
}
|
|
810
1240
|
if (named.length > 0) {
|
|
@@ -828,7 +1258,7 @@ async function pushCommand(args) {
|
|
|
828
1258
|
const names = listLocalComponents(componentsDir);
|
|
829
1259
|
if (names.length === 0) {
|
|
830
1260
|
process.stdout.write(
|
|
831
|
-
"(no components in ./components \u2014 run `gs init <name>` to add one)\n"
|
|
1261
|
+
"(no components in ./components \u2014 run `gs apps init <name>` to add one)\n"
|
|
832
1262
|
);
|
|
833
1263
|
return;
|
|
834
1264
|
}
|
|
@@ -853,28 +1283,28 @@ ${summary.join(", ")} (of ${outcomes.length}) \u2192 ${slug}
|
|
|
853
1283
|
}
|
|
854
1284
|
async function pushOne(opts) {
|
|
855
1285
|
const { slug, root, name, force } = opts;
|
|
856
|
-
const componentDir =
|
|
857
|
-
if (!
|
|
1286
|
+
const componentDir = path8.join(root, "components", name);
|
|
1287
|
+
if (!fs8.existsSync(componentDir)) {
|
|
858
1288
|
return {
|
|
859
1289
|
name,
|
|
860
1290
|
status: "failed",
|
|
861
1291
|
message: `components/${name}/ does not exist`
|
|
862
1292
|
};
|
|
863
1293
|
}
|
|
864
|
-
const manifestPath = opts.manifestPath ?
|
|
865
|
-
const bundlePath = opts.bundlePath ?
|
|
866
|
-
const sourcePath =
|
|
867
|
-
if (!
|
|
1294
|
+
const manifestPath = opts.manifestPath ? path8.resolve(opts.manifestPath) : path8.join(componentDir, "manifest.json");
|
|
1295
|
+
const bundlePath = opts.bundlePath ? path8.resolve(opts.bundlePath) : path8.join(componentDir, "bundle.js");
|
|
1296
|
+
const sourcePath = path8.join(componentDir, "component.tsx");
|
|
1297
|
+
if (!fs8.existsSync(manifestPath)) {
|
|
868
1298
|
return { name, status: "failed", message: `manifest not found: ${manifestPath}` };
|
|
869
1299
|
}
|
|
870
|
-
if (!
|
|
1300
|
+
if (!fs8.existsSync(bundlePath)) {
|
|
871
1301
|
return {
|
|
872
1302
|
name,
|
|
873
1303
|
status: "failed",
|
|
874
1304
|
message: `bundle not found: ${bundlePath} (did you run \`npm run build\`?)`
|
|
875
1305
|
};
|
|
876
1306
|
}
|
|
877
|
-
const manifestText =
|
|
1307
|
+
const manifestText = fs8.readFileSync(manifestPath, "utf8");
|
|
878
1308
|
let manifestName;
|
|
879
1309
|
try {
|
|
880
1310
|
const parsed = JSON.parse(manifestText);
|
|
@@ -901,8 +1331,8 @@ async function pushOne(opts) {
|
|
|
901
1331
|
return { name, status: "unchanged" };
|
|
902
1332
|
}
|
|
903
1333
|
}
|
|
904
|
-
const bundleText =
|
|
905
|
-
const sourceText =
|
|
1334
|
+
const bundleText = fs8.readFileSync(bundlePath, "utf8");
|
|
1335
|
+
const sourceText = fs8.existsSync(sourcePath) ? fs8.readFileSync(sourcePath, "utf8") : null;
|
|
906
1336
|
const form = new FormData();
|
|
907
1337
|
form.append(
|
|
908
1338
|
"manifest",
|
|
@@ -946,20 +1376,20 @@ async function pushOne(opts) {
|
|
|
946
1376
|
};
|
|
947
1377
|
}
|
|
948
1378
|
function listLocalComponents(componentsDir) {
|
|
949
|
-
return
|
|
950
|
-
const dir =
|
|
951
|
-
return
|
|
1379
|
+
return fs8.readdirSync(componentsDir).filter((entry) => {
|
|
1380
|
+
const dir = path8.join(componentsDir, entry);
|
|
1381
|
+
return fs8.statSync(dir).isDirectory() && fs8.existsSync(path8.join(dir, "manifest.json"));
|
|
952
1382
|
}).sort();
|
|
953
1383
|
}
|
|
954
1384
|
function rejectLegacyLayout(root) {
|
|
955
|
-
const rootManifest =
|
|
956
|
-
const componentsDir =
|
|
957
|
-
if (
|
|
1385
|
+
const rootManifest = path8.join(root, "manifest.json");
|
|
1386
|
+
const componentsDir = path8.join(root, "components");
|
|
1387
|
+
if (fs8.existsSync(rootManifest) && !fs8.existsSync(componentsDir)) {
|
|
958
1388
|
throw new Error(
|
|
959
1389
|
[
|
|
960
1390
|
"Detected the old single-component layout (manifest.json at the project root).",
|
|
961
1391
|
"The Builder CLI now uses a multi-component layout: scaffold a fresh dir with",
|
|
962
|
-
"`gs init`, then move your component into `components/<name>/`. Re-run `gs push`",
|
|
1392
|
+
"`gs apps init`, then move your component into `components/<name>/`. Re-run `gs apps push`",
|
|
963
1393
|
"from the new project root."
|
|
964
1394
|
].join(" ")
|
|
965
1395
|
);
|
|
@@ -986,11 +1416,11 @@ function printOutcome2(outcome) {
|
|
|
986
1416
|
}
|
|
987
1417
|
}
|
|
988
1418
|
|
|
989
|
-
// src/commands/publish.ts
|
|
1419
|
+
// src/commands/apps/publish.ts
|
|
990
1420
|
async function publishCommand(args) {
|
|
991
1421
|
const names = args.positional;
|
|
992
1422
|
if (names.length === 0) {
|
|
993
|
-
throw new Error("Usage: gs publish <name> [<name>...] [--version N]");
|
|
1423
|
+
throw new Error("Usage: gs apps publish <name> [<name>...] [--version N]");
|
|
994
1424
|
}
|
|
995
1425
|
const slug = requireProjectStore();
|
|
996
1426
|
const versionFlag = flagString(args.flags, "version");
|
|
@@ -998,7 +1428,7 @@ async function publishCommand(args) {
|
|
|
998
1428
|
if (versionFlag !== void 0) {
|
|
999
1429
|
if (names.length > 1) {
|
|
1000
1430
|
throw new Error(
|
|
1001
|
-
"--version is per-component; pass it together with a single `gs publish <name>`."
|
|
1431
|
+
"--version is per-component; pass it together with a single `gs apps publish <name>`."
|
|
1002
1432
|
);
|
|
1003
1433
|
}
|
|
1004
1434
|
const n = Number.parseInt(versionFlag, 10);
|
|
@@ -1067,10 +1497,10 @@ function printOutcome3(outcome) {
|
|
|
1067
1497
|
);
|
|
1068
1498
|
}
|
|
1069
1499
|
|
|
1070
|
-
// src/commands/unpublish.ts
|
|
1500
|
+
// src/commands/apps/unpublish.ts
|
|
1071
1501
|
async function unpublishCommand(args) {
|
|
1072
1502
|
const name = args.positional[0];
|
|
1073
|
-
if (!name) throw new Error("Usage: gs unpublish <name>");
|
|
1503
|
+
if (!name) throw new Error("Usage: gs apps unpublish <name>");
|
|
1074
1504
|
const slug = requireProjectStore();
|
|
1075
1505
|
const url = `${apiBaseFor(slug)}/api/builder/components/${encodeURIComponent(name)}/unpublish`;
|
|
1076
1506
|
await request(url, { method: "POST", body: {} });
|
|
@@ -1078,11 +1508,11 @@ async function unpublishCommand(args) {
|
|
|
1078
1508
|
`);
|
|
1079
1509
|
}
|
|
1080
1510
|
|
|
1081
|
-
// src/commands/delete.ts
|
|
1511
|
+
// src/commands/apps/delete.ts
|
|
1082
1512
|
import * as readline from "readline";
|
|
1083
1513
|
async function deleteCommand(args) {
|
|
1084
1514
|
const name = args.positional[0];
|
|
1085
|
-
if (!name) throw new Error("Usage: gs delete <name> [--yes]");
|
|
1515
|
+
if (!name) throw new Error("Usage: gs apps delete <name> [--yes]");
|
|
1086
1516
|
const slug = requireProjectStore();
|
|
1087
1517
|
if (!flagBool(args.flags, "yes")) {
|
|
1088
1518
|
const confirmed = await prompt(
|
|
@@ -1099,436 +1529,541 @@ async function deleteCommand(args) {
|
|
|
1099
1529
|
`);
|
|
1100
1530
|
}
|
|
1101
1531
|
function prompt(question) {
|
|
1102
|
-
return new Promise((
|
|
1532
|
+
return new Promise((resolve8) => {
|
|
1103
1533
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1104
1534
|
rl.question(question, (answer) => {
|
|
1105
1535
|
rl.close();
|
|
1106
|
-
|
|
1536
|
+
resolve8(answer);
|
|
1107
1537
|
});
|
|
1108
1538
|
});
|
|
1109
1539
|
}
|
|
1110
1540
|
|
|
1111
|
-
// src/commands/
|
|
1112
|
-
|
|
1113
|
-
import * as path6 from "path";
|
|
1541
|
+
// src/commands/apps/index.ts
|
|
1542
|
+
var APPS_HELP = `gs apps \u2014 author and ship custom in-chat apps
|
|
1114
1543
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
import { fileURLToPath } from "url";
|
|
1118
|
-
var cache = null;
|
|
1119
|
-
function loadTemplate() {
|
|
1120
|
-
if (cache) return cache;
|
|
1121
|
-
cache = true ? JSON.parse('{"component/component.tsx":"import React from \\"react\\";\\n\\n// Add your tool args (matching manifest.json#inputSchema.properties)\\n// alongside the GreatStore-injected lifecycle props. See AGENTS.md for\\n// what each lifecycle prop does.\\ninterface Props {\\n onSendMessage: (text: string) => void;\\n onCallTool: (name: string, args: Record<string, unknown>) => void;\\n onClose: () => void;\\n}\\n\\n// See AGENTS.md for the design rules (em-based sizing, brand CSS\\n// variables), the lifecycle props, and how to write an async,\\n// backend-backed component.\\nexport default function __GS_PASCAL__(_props: Props): React.ReactElement {\\n return (\\n <div\\n style={{\\n padding: \\"1em\\",\\n border: \\"1px solid var(--color-border-default)\\",\\n borderRadius: \\"var(--radius-lg)\\",\\n background: \\"var(--color-surface)\\",\\n color: \\"var(--color-foreground)\\",\\n fontFamily: \\"var(--font-sans)\\",\\n }}\\n >\\n <strong>__GS_NAME__</strong> \u2014 hello from your component!\\n </div>\\n );\\n}\\n","component/manifest.json":"{\\n \\"name\\": \\"__GS_NAME__\\",\\n \\"displayName\\": \\"__GS_DISPLAY_NAME__\\",\\n \\"description\\": \\"Renders the __GS_NAME__ widget.\\",\\n \\"displayMode\\": \\"inline\\",\\n \\"inputSchema\\": {\\n \\"type\\": \\"object\\",\\n \\"properties\\": {}\\n }\\n}\\n","root/.gitignore":"node_modules/\\ncomponents/*/bundle.js\\ncomponents/*/.gssync.json\\n*.tsbuildinfo\\n.DS_Store\\n","root/.gsrc":"{\\n \\"store\\": \\"__GS_STORE__\\"\\n}\\n","root/AGENTS.md":"# AGENTS.md\\n\\nGuidance for AI coding agents working on the components in this project.\\n`CLAUDE.md` and `GEMINI.md` are symlinks to this file.\\n\\nThese components render inside arbitrary publisher websites (the embed\\nwidget mounts on a host page) as well as the GreatStore storefront. You\\ncontrol neither the host page\'s base font size nor its color scheme, and\\nthe store owner controls the look from GreatStore theme settings. The\\nrules below keep components portable across both.\\n\\n## Design requirements\\n\\n### Never use `rem` for fonts and sizing \u2014 always use `em`\\n\\n`rem` resolves against the host page\'s root font size, which is\\narbitrary and outside our control. A host that sets `html { font-size:\\n8px }` (or 200%) will shrink or blow up every `rem`-based dimension.\\n`em` resolves against the component\'s own font size, so it stays\\nself-consistent wherever the component is mounted.\\n\\nUse `em` for font sizes, padding, margins, gaps, widths, heights,\\nand any other length. Borders may stay in `px` (hairlines should not\\nscale with text).\\n\\n```tsx\\n// Bad \u2014 breaks on hosts with a non-default root font size\\n<div style={{ padding: \\"1rem\\", fontSize: \\"0.875rem\\" }} />\\n\\n// Good\\n<div style={{ padding: \\"1em\\", fontSize: \\"0.875em\\" }} />\\n```\\n\\n### Don\'t hardcode colors, fonts, or corner radius \u2014 use GreatStore brand variables\\n\\nThe store owner themes the assistant from GreatStore settings. Hardcoded\\nvalues ignore that and make the component clash with the rest of the\\nstore. Read from the CSS variables GreatStore injects on the surface\\ninstead, so the component restyles itself when the theme changes.\\n\\nAvailable variables:\\n\\n| Purpose | Variable |\\n| --- | --- |\\n| Surface background | `--color-surface` |\\n| Secondary surface | `--color-surface-secondary` |\\n| Accent surface | `--color-surface-accent` |\\n| Hover surface | `--color-surface-hover` |\\n| Foreground text | `--color-foreground` |\\n| Secondary text | `--color-foreground-secondary` |\\n| Muted text | `--color-foreground-muted` |\\n| Accent text | `--color-foreground-accent` |\\n| Border | `--color-border-default` |\\n| Focus border | `--color-border-focus` |\\n| Primary / brand | `--color-primary` |\\n| Text on primary | `--color-primary-foreground` |\\n| Primary hover | `--color-primary-hover` |\\n| Primary muted | `--color-primary-muted` |\\n| Text on primary muted | `--color-primary-muted-foreground` |\\n| Primary tint | `--color-primary-tint` |\\n| Link | `--color-link` |\\n| Font family | `--font-sans` |\\n| Corner radius (scale) | `--radius-xs` \u2026 `--radius-4xl`, `--radius-pill` |\\n\\n```tsx\\n// Bad \u2014 hardcoded, ignores the store\'s theme\\n<button style={{ background: \\"#4f46e5\\", borderRadius: 8, fontFamily: \\"Inter\\" }} />\\n\\n// Good\\n<button\\n style={{\\n background: \\"var(--color-primary)\\",\\n color: \\"var(--color-primary-foreground)\\",\\n borderRadius: \\"var(--radius-lg)\\",\\n fontFamily: \\"var(--font-sans)\\",\\n }}\\n/>\\n```\\n\\nProvide a sensible fallback only when a value might be absent, e.g.\\n`var(--color-primary, currentColor)`.\\n\\n## Component props\\n\\nEach component receives its tool args (the fields you declare in\\n`manifest.json#inputSchema.properties`) plus these GreatStore-injected\\nlifecycle props, which are always present:\\n\\n| Prop | What it does |\\n| --- | --- |\\n| `onSendMessage(text)` | Send text into the chat as if the shopper typed it. |\\n| `onCallTool(name, args)` | Chain into another remote-component tool by name. |\\n| `onClose()` | Dismiss the host slot. Over-input clears the overlay, fullscreen reverts the pane, inline is a no-op. |\\n\\n## Async components (backend-backed, render-blocking data)\\n\\nIf a component must load data from a backend/API before it can render\\ncorrectly, make it async \u2014 don\'t render an empty shell and fetch in a\\n`useEffect`. Set `\\"async\\": true` in `manifest.json` and export an async\\ndefault. GreatStore waits for your promise (showing a normal loading\\nstate, so you don\'t render your own placeholder), then renders what it\\nresolves to. Components that render purely from their props stay\\nsynchronous.\\n\\nA thrown error is a **retry signal**: the in-store AI sees it and\\nusually re-calls the tool. So only throw when a *different* call could\\nhelp:\\n\\n1. `await` the backend call, then return the finished JSX.\\n2. Validate the AI-passed props first and throw on bad input \u2014 the AI\\n can fix the args and retry. (Don\'t validate the API\'s *output* and\\n throw: the AI can\'t fix the backend, it\'ll just loop.)\\n3. Broadcast a backend failure ONLY when retrying differently could\\n succeed, and say what to change (e.g. empty search \u2192 \\"try a broader\\n keyword\\"). For idempotent failures (500, timeout, missing record)\\n re-running the same call changes nothing \u2014 render a graceful fallback\\n instead of throwing.\\n\\n```tsx\\nexport default async function Example(props: Props) {\\n if (!props.query?.trim()) throw new Error(\\"missing required prop: query\\");\\n const res = await fetch(`/api/search?q=${encodeURIComponent(props.query)}`);\\n if (res.ok) {\\n const { results } = await res.json();\\n if (results.length === 0)\\n throw new Error(`no results for \\"${props.query}\\" \u2014 try a broader keyword`);\\n return <ul>{/* render results */}</ul>;\\n }\\n return <p>Couldn\'t load results right now.</p>; // idempotent: don\'t throw\\n}\\n```\\n","root/README.md":"# GreatStore components\\n\\nCustom React components published to your GreatStore tenant. Each\\ncomponent lives in its own folder under `components/`.\\n\\n```\\nnpm install\\ngs init <component_name> # add a new component\\ngs build # builds every components/<name>/bundle.js\\ngs push # uploads every changed component as a draft\\ngs publish <component_name> # promote a specific component to live\\n```\\n\\n- `gs push` (no args) hashes each component and only uploads the ones\\n that have changed since the last sync.\\n- `gs pull` (no args) refreshes every remote component into\\n `components/<name>/`. Components with unsaved local edits are skipped\\n with a warning; pass `--force` to overwrite.\\n\\nSee `AGENTS.md` for the design rules every component must follow\\n(em-based sizing, brand CSS variables) \u2014 it doubles as guidance for AI\\ncoding agents (`CLAUDE.md` / `GEMINI.md` symlink to it).\\n","root/package.json":"{\\n \\"name\\": \\"greatstore-components\\",\\n \\"version\\": \\"0.0.1\\",\\n \\"private\\": true,\\n \\"type\\": \\"module\\",\\n \\"scripts\\": {\\n \\"build\\": \\"gs build\\",\\n \\"push\\": \\"gs build && gs push\\"\\n },\\n \\"dependencies\\": {\\n \\"react\\": \\"^19.0.0\\",\\n \\"react-dom\\": \\"^19.0.0\\"\\n },\\n \\"devDependencies\\": {\\n \\"@types/react\\": \\"^19.0.0\\",\\n \\"@types/react-dom\\": \\"^19.0.0\\",\\n \\"@vitejs/plugin-react\\": \\"^4.3.0\\",\\n \\"typescript\\": \\"^5.6.0\\",\\n \\"vite\\": \\"^5.4.0\\"\\n }\\n}\\n","root/tsconfig.json":"{\\n \\"compilerOptions\\": {\\n \\"target\\": \\"ES2022\\",\\n \\"module\\": \\"ESNext\\",\\n \\"moduleResolution\\": \\"Bundler\\",\\n \\"jsx\\": \\"react-jsx\\",\\n \\"lib\\": [\\"ES2022\\", \\"DOM\\"],\\n \\"strict\\": true,\\n \\"esModuleInterop\\": true,\\n \\"skipLibCheck\\": true,\\n \\"isolatedModules\\": true,\\n \\"noEmit\\": true\\n },\\n \\"include\\": [\\"components/**/component.tsx\\", \\"vite.config.ts\\"]\\n}\\n","root/vite.config.ts":"import { defineConfig } from \\"vite\\";\\nimport react from \\"@vitejs/plugin-react\\";\\n\\n// Real builds happen in `gs build` (one Vite invocation per\\n// component, externals + runtime shim paths owned by the CLI). This\\n// file exists only so editors / language servers can resolve the\\n// React plugin when inspecting components/*/component.tsx.\\nexport default defineConfig({\\n plugins: [react()],\\n});\\n"}') : readTreeFromDisk(new URL("../template/", import.meta.url));
|
|
1122
|
-
return cache;
|
|
1123
|
-
}
|
|
1124
|
-
var TOKENS = {
|
|
1125
|
-
name: "__GS_NAME__",
|
|
1126
|
-
pascalName: "__GS_PASCAL__",
|
|
1127
|
-
displayName: "__GS_DISPLAY_NAME__",
|
|
1128
|
-
store: "__GS_STORE__"
|
|
1129
|
-
};
|
|
1130
|
-
function applyTemplate(content, vars) {
|
|
1131
|
-
let out = content;
|
|
1132
|
-
for (const key of Object.keys(TOKENS)) {
|
|
1133
|
-
const value = vars[key];
|
|
1134
|
-
if (value !== void 0) out = out.split(TOKENS[key]).join(value);
|
|
1135
|
-
}
|
|
1136
|
-
const leftover = out.match(/__GS_[A-Z_]+__/);
|
|
1137
|
-
if (leftover) {
|
|
1138
|
-
throw new Error(
|
|
1139
|
-
`internal: template placeholder ${leftover[0]} was not provided`
|
|
1140
|
-
);
|
|
1141
|
-
}
|
|
1142
|
-
return out;
|
|
1143
|
-
}
|
|
1544
|
+
Usage:
|
|
1545
|
+
gs apps <subcommand> [args] [flags]
|
|
1144
1546
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
);
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
);
|
|
1190
|
-
process.stdout.write(lines.join("\n"));
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
const componentDir = path6.join(root, "components", name);
|
|
1194
|
-
ensureComponent(componentDir, name, force);
|
|
1195
|
-
const projectLabel = path6.relative(process.cwd(), root) || ".";
|
|
1196
|
-
process.stdout.write(
|
|
1197
|
-
[
|
|
1198
|
-
`Added component "${name}" at components/${name}/.`,
|
|
1199
|
-
...rootExisted && writtenRootFiles.length > 0 ? ["", `Also added to the project root: ${writtenRootFiles.join(", ")}`] : [],
|
|
1200
|
-
"",
|
|
1201
|
-
"Next steps:",
|
|
1202
|
-
...rootExisted ? [] : [` cd ${projectLabel}`, " npm install"],
|
|
1203
|
-
` # edit components/${name}/component.tsx and manifest.json`,
|
|
1204
|
-
" gs build",
|
|
1205
|
-
" gs push",
|
|
1206
|
-
""
|
|
1207
|
-
].join("\n")
|
|
1208
|
-
);
|
|
1209
|
-
}
|
|
1210
|
-
function hasRootScaffold(dir) {
|
|
1211
|
-
return fs6.existsSync(path6.join(dir, "package.json")) && fs6.existsSync(path6.join(dir, ".gsrc"));
|
|
1212
|
-
}
|
|
1213
|
-
function ensureRoot(root, rootExisted, force, opts) {
|
|
1214
|
-
fs6.mkdirSync(root, { recursive: true });
|
|
1215
|
-
if (!rootExisted && !force) {
|
|
1216
|
-
const entries = fs6.readdirSync(root).filter((e) => e !== ".gsrc");
|
|
1217
|
-
if (entries.length > 0) {
|
|
1218
|
-
throw new Error(
|
|
1219
|
-
`Refusing to scaffold project root into non-empty directory ${root}. Pass --force to override.`
|
|
1220
|
-
);
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
const files = rootExisted ? existingRootFiles() : rootFiles({ store: opts.store });
|
|
1224
|
-
const written = writeRootFiles(root, files, force);
|
|
1225
|
-
fs6.mkdirSync(path6.join(root, "components"), { recursive: true });
|
|
1226
|
-
return written;
|
|
1227
|
-
}
|
|
1228
|
-
function writeRootFiles(root, files, force) {
|
|
1229
|
-
const written = [];
|
|
1230
|
-
for (const [relPath, content] of files) {
|
|
1231
|
-
const full = path6.join(root, relPath);
|
|
1232
|
-
fs6.mkdirSync(path6.dirname(full), { recursive: true });
|
|
1233
|
-
if (force || !fs6.existsSync(full)) {
|
|
1234
|
-
fs6.writeFileSync(full, content);
|
|
1235
|
-
written.push(relPath);
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
for (const link of ["CLAUDE.md", "GEMINI.md"]) {
|
|
1239
|
-
const linkPath = path6.join(root, link);
|
|
1240
|
-
if (force && pathExists(linkPath)) fs6.rmSync(linkPath);
|
|
1241
|
-
if (!pathExists(linkPath)) {
|
|
1242
|
-
fs6.symlinkSync("AGENTS.md", linkPath);
|
|
1243
|
-
written.push(link);
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
return written;
|
|
1247
|
-
}
|
|
1248
|
-
function pathExists(p) {
|
|
1249
|
-
try {
|
|
1250
|
-
fs6.lstatSync(p);
|
|
1251
|
-
return true;
|
|
1252
|
-
} catch {
|
|
1253
|
-
return false;
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
function ensureComponent(componentDir, name, force) {
|
|
1257
|
-
if (fs6.existsSync(componentDir) && !force) {
|
|
1258
|
-
const entries = fs6.readdirSync(componentDir);
|
|
1259
|
-
if (entries.length > 0) {
|
|
1260
|
-
throw new Error(
|
|
1261
|
-
`Refusing to overwrite existing components/${name}/. Pass --force to override.`
|
|
1262
|
-
);
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
fs6.mkdirSync(componentDir, { recursive: true });
|
|
1266
|
-
for (const [relPath, content] of componentFiles(name)) {
|
|
1267
|
-
fs6.writeFileSync(path6.join(componentDir, relPath), content);
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
function templateFiles(prefix, vars, exclude = /* @__PURE__ */ new Set()) {
|
|
1271
|
-
const tree = loadTemplate();
|
|
1272
|
-
return Object.entries(tree).filter(([rel]) => rel.startsWith(prefix)).map(([rel, content]) => [rel.slice(prefix.length), content]).filter(([dest]) => !exclude.has(dest)).map(([dest, content]) => [dest, applyTemplate(content, vars)]);
|
|
1273
|
-
}
|
|
1274
|
-
function rootFiles(opts) {
|
|
1275
|
-
if (!opts.store) {
|
|
1276
|
-
throw new Error("internal: rootFiles called without a store slug");
|
|
1277
|
-
}
|
|
1278
|
-
return templateFiles("root/", { store: opts.store });
|
|
1279
|
-
}
|
|
1280
|
-
function existingRootFiles() {
|
|
1281
|
-
return templateFiles("root/", {}, /* @__PURE__ */ new Set([".gsrc"]));
|
|
1282
|
-
}
|
|
1283
|
-
function componentFiles(name) {
|
|
1284
|
-
return templateFiles("component/", {
|
|
1285
|
-
name,
|
|
1286
|
-
pascalName: pascal(name),
|
|
1287
|
-
displayName: defaultDisplayName(name)
|
|
1288
|
-
});
|
|
1289
|
-
}
|
|
1290
|
-
function defaultDisplayName(name) {
|
|
1291
|
-
const words = name.split("_").filter(Boolean);
|
|
1292
|
-
if (words.length === 0) return name;
|
|
1293
|
-
const first = words[0];
|
|
1294
|
-
return first.charAt(0).toUpperCase() + first.slice(1) + (words.length > 1 ? " " + words.slice(1).join(" ") : "");
|
|
1295
|
-
}
|
|
1296
|
-
function pascal(name) {
|
|
1297
|
-
return name.split(/[_-]/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join("");
|
|
1298
|
-
}
|
|
1547
|
+
Subcommands:
|
|
1548
|
+
init [<name>] Scaffold project root; add \`components/<name>/\` if name given.
|
|
1549
|
+
build [<name>] Build component bundle(s) via the project's Vite. No args = all.
|
|
1550
|
+
list List components in the current store.
|
|
1551
|
+
pull [<name>...|*] Download remote component(s) into \`components/<name>/\`.
|
|
1552
|
+
push [<name>...] Upload changed components from \`components/\`.
|
|
1553
|
+
publish <name>... Promote the draft(s) (or --version N) to live.
|
|
1554
|
+
unpublish <name> Clear the live pointer.
|
|
1555
|
+
delete <name> Soft-delete the component.
|
|
1556
|
+
`;
|
|
1557
|
+
async function appsCommand(args) {
|
|
1558
|
+
const sub = args.positional[0];
|
|
1559
|
+
const rest = { ...args, positional: args.positional.slice(1) };
|
|
1560
|
+
switch (sub) {
|
|
1561
|
+
case void 0:
|
|
1562
|
+
case "help":
|
|
1563
|
+
process.stdout.write(APPS_HELP);
|
|
1564
|
+
return 0;
|
|
1565
|
+
case "init":
|
|
1566
|
+
initCommand(rest);
|
|
1567
|
+
return 0;
|
|
1568
|
+
case "build":
|
|
1569
|
+
await buildCommand(rest);
|
|
1570
|
+
return 0;
|
|
1571
|
+
case "list":
|
|
1572
|
+
await listCommand(rest);
|
|
1573
|
+
return 0;
|
|
1574
|
+
case "pull":
|
|
1575
|
+
await pullCommand(rest);
|
|
1576
|
+
return 0;
|
|
1577
|
+
case "push":
|
|
1578
|
+
await pushCommand(rest);
|
|
1579
|
+
return 0;
|
|
1580
|
+
case "publish":
|
|
1581
|
+
await publishCommand(rest);
|
|
1582
|
+
return 0;
|
|
1583
|
+
case "unpublish":
|
|
1584
|
+
await unpublishCommand(rest);
|
|
1585
|
+
return 0;
|
|
1586
|
+
case "delete":
|
|
1587
|
+
await deleteCommand(rest);
|
|
1588
|
+
return 0;
|
|
1589
|
+
default:
|
|
1590
|
+
process.stderr.write(`Unknown apps subcommand: ${sub}
|
|
1299
1591
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
import * as path7 from "path";
|
|
1303
|
-
import { createRequire } from "module";
|
|
1304
|
-
var COMPONENTS_DIR = "components";
|
|
1305
|
-
async function buildCommand(args) {
|
|
1306
|
-
const root = process.cwd();
|
|
1307
|
-
if (!fs7.existsSync(path7.join(root, "package.json")) || !fs7.existsSync(path7.join(root, COMPONENTS_DIR))) {
|
|
1308
|
-
throw new Error(
|
|
1309
|
-
`\`gs build\` must run from a project root (contains \`package.json\` and \`${COMPONENTS_DIR}/\`). Current dir: ${root}`
|
|
1310
|
-
);
|
|
1311
|
-
}
|
|
1312
|
-
const components = listComponents(root);
|
|
1313
|
-
if (components.length === 0) {
|
|
1314
|
-
process.stdout.write(
|
|
1315
|
-
`(no components in ./${COMPONENTS_DIR} \u2014 run \`gs init <name>\` to add one)
|
|
1316
|
-
`
|
|
1317
|
-
);
|
|
1318
|
-
return;
|
|
1319
|
-
}
|
|
1320
|
-
const target = args.positional[0];
|
|
1321
|
-
const queue = target ? components.filter((c) => c === target) : components;
|
|
1322
|
-
if (target && queue.length === 0) {
|
|
1323
|
-
throw new Error(`No component named "${target}" in ./${COMPONENTS_DIR}`);
|
|
1324
|
-
}
|
|
1325
|
-
const { build, reactPlugin, transformWithEsbuild } = await loadVite(root);
|
|
1326
|
-
for (const name of queue) {
|
|
1327
|
-
const dir = path7.join(root, COMPONENTS_DIR, name);
|
|
1328
|
-
const bundlePath = path7.join(dir, "bundle.js");
|
|
1329
|
-
await build({
|
|
1330
|
-
plugins: [reactPlugin()],
|
|
1331
|
-
logLevel: "warn",
|
|
1332
|
-
build: {
|
|
1333
|
-
lib: {
|
|
1334
|
-
entry: path7.join(dir, "component.tsx"),
|
|
1335
|
-
formats: ["es"],
|
|
1336
|
-
fileName: () => "bundle.js"
|
|
1337
|
-
},
|
|
1338
|
-
outDir: dir,
|
|
1339
|
-
emptyOutDir: false,
|
|
1340
|
-
rollupOptions: {
|
|
1341
|
-
external: ["react", "react-dom", "react/jsx-runtime"],
|
|
1342
|
-
output: {
|
|
1343
|
-
entryFileNames: "bundle.js",
|
|
1344
|
-
paths: {
|
|
1345
|
-
react: "/assets/remote-components/_runtime.js",
|
|
1346
|
-
"react-dom": "/assets/remote-components/_runtime.js",
|
|
1347
|
-
"react/jsx-runtime": "/assets/remote-components/_runtime.js"
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
},
|
|
1351
|
-
minify: true,
|
|
1352
|
-
sourcemap: false
|
|
1353
|
-
}
|
|
1354
|
-
});
|
|
1355
|
-
const beforeBytes = fs7.statSync(bundlePath).size;
|
|
1356
|
-
const src = fs7.readFileSync(bundlePath, "utf8");
|
|
1357
|
-
const { code } = await transformWithEsbuild(src, bundlePath, {
|
|
1358
|
-
minify: true,
|
|
1359
|
-
legalComments: "none",
|
|
1360
|
-
target: "esnext",
|
|
1361
|
-
loader: "js",
|
|
1362
|
-
sourcemap: false
|
|
1363
|
-
});
|
|
1364
|
-
fs7.writeFileSync(bundlePath, code);
|
|
1365
|
-
const afterBytes = Buffer.byteLength(code, "utf8");
|
|
1366
|
-
process.stdout.write(
|
|
1367
|
-
`built ${COMPONENTS_DIR}/${name}/bundle.js (${formatBytes(afterBytes)}, ${pctSmaller(beforeBytes, afterBytes)} smaller)
|
|
1368
|
-
`
|
|
1369
|
-
);
|
|
1592
|
+
${APPS_HELP}`);
|
|
1593
|
+
return 1;
|
|
1370
1594
|
}
|
|
1371
|
-
process.stdout.write(
|
|
1372
|
-
`
|
|
1373
|
-
Built locally \u2014 nothing uploaded yet. Next: \`gs push\` to upload, then \`gs publish <name>\` to ship. If you're an AI assistant: confirm with the user before running these (or just run them if they already asked you to ship end-to-end).
|
|
1374
|
-
`
|
|
1375
|
-
);
|
|
1376
1595
|
}
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1596
|
+
|
|
1597
|
+
// src/commands/configure/show.ts
|
|
1598
|
+
async function showConfig(args) {
|
|
1599
|
+
const slug = resolveAdminStore(args);
|
|
1600
|
+
const url = `${apiBaseFor(slug)}/admin/configure/config`;
|
|
1601
|
+
const data = await request(url);
|
|
1602
|
+
if (flagBool(args.flags, "json")) {
|
|
1603
|
+
process.stdout.write(JSON.stringify(data.brand, null, 2) + "\n");
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
printBrand(slug, data.brand);
|
|
1607
|
+
}
|
|
1608
|
+
function printBrand(slug, brand) {
|
|
1609
|
+
const lines = [`Configuration for ${slug}:`, ""];
|
|
1610
|
+
lines.push(` displayName ${brand.displayName ?? "\u2014"}`);
|
|
1611
|
+
lines.push(` assistantName ${brand.assistantName ?? "\u2014"}`);
|
|
1612
|
+
lines.push(` storeLink ${brand.storeLink ?? "\u2014"}`);
|
|
1613
|
+
lines.push(` extraOrigins ${formatList(brand.extraOrigins)}`);
|
|
1614
|
+
lines.push(` shopifyLoginGate ${brand.shopifyLoginGate ? "on" : "off"}`);
|
|
1615
|
+
lines.push(` icon ${brand.icon?.url ?? "\u2014"}`);
|
|
1616
|
+
lines.push(` logoLight ${brand.logo?.light?.url ?? "\u2014"}`);
|
|
1617
|
+
lines.push(` logoDark ${brand.logo?.dark?.url ?? "\u2014"}`);
|
|
1618
|
+
lines.push(` theme ${brand.theme ? JSON.stringify(brand.theme) : "\u2014"}`);
|
|
1619
|
+
lines.push(` cspScriptHosts ${formatList(brand.cspScriptHosts)}`);
|
|
1620
|
+
lines.push(` cspConnectHosts ${formatList(brand.cspConnectHosts)}`);
|
|
1621
|
+
lines.push(` salesGuide ${formatSalesGuide(brand.salesGuide)}`);
|
|
1622
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1383
1623
|
}
|
|
1384
|
-
function
|
|
1385
|
-
if (
|
|
1386
|
-
return
|
|
1624
|
+
function formatList(list) {
|
|
1625
|
+
if (!list || list.length === 0) return "\u2014";
|
|
1626
|
+
return list.join(", ");
|
|
1387
1627
|
}
|
|
1388
|
-
function
|
|
1389
|
-
if (
|
|
1390
|
-
|
|
1628
|
+
function formatSalesGuide(guide) {
|
|
1629
|
+
if (!guide) return "\u2014";
|
|
1630
|
+
const flat = guide.replace(/\s+/g, " ").trim();
|
|
1631
|
+
return flat.length > 60 ? `${flat.slice(0, 57)}\u2026 (${guide.length} chars)` : flat;
|
|
1391
1632
|
}
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1633
|
+
|
|
1634
|
+
// src/commands/configure/set.ts
|
|
1635
|
+
import * as fs9 from "fs";
|
|
1636
|
+
import * as path9 from "path";
|
|
1637
|
+
var TEXT_FIELDS = [
|
|
1638
|
+
"displayName",
|
|
1639
|
+
"assistantName",
|
|
1640
|
+
"salesGuide",
|
|
1641
|
+
"storeLink"
|
|
1642
|
+
];
|
|
1643
|
+
var LIST_FIELDS = ["extraOrigins", "cspScriptHosts", "cspConnectHosts"];
|
|
1644
|
+
async function setConfig(args) {
|
|
1645
|
+
const slug = resolveAdminStore(args);
|
|
1646
|
+
const body = {};
|
|
1647
|
+
for (const field of TEXT_FIELDS) {
|
|
1648
|
+
if (!(field in args.flags)) continue;
|
|
1649
|
+
const v = args.flags[field];
|
|
1650
|
+
if (typeof v !== "string") {
|
|
1651
|
+
throw new Error(`--${field} requires a value (use --${field} "" to clear).`);
|
|
1652
|
+
}
|
|
1653
|
+
body[field] = v === "" ? null : v;
|
|
1399
1654
|
}
|
|
1400
|
-
const
|
|
1401
|
-
if (
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1655
|
+
const salesGuideFile = flagString(args.flags, "salesGuideFile");
|
|
1656
|
+
if (salesGuideFile !== void 0) {
|
|
1657
|
+
if ("salesGuide" in body) {
|
|
1658
|
+
throw new Error("Pass either --salesGuide or --salesGuideFile, not both.");
|
|
1659
|
+
}
|
|
1660
|
+
body["salesGuide"] = readTextFile(salesGuideFile, "--salesGuideFile");
|
|
1405
1661
|
}
|
|
1406
|
-
const
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1662
|
+
for (const field of LIST_FIELDS) {
|
|
1663
|
+
if (!(field in args.flags)) continue;
|
|
1664
|
+
const v = args.flags[field];
|
|
1665
|
+
if (typeof v !== "string") {
|
|
1666
|
+
throw new Error(
|
|
1667
|
+
`--${field} requires a comma-separated value (use --${field} "" to clear).`
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
const entries = v.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1671
|
+
body[field] = entries.length === 0 ? null : entries;
|
|
1412
1672
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1673
|
+
if ("shopifyLoginGate" in args.flags) {
|
|
1674
|
+
const v = args.flags["shopifyLoginGate"];
|
|
1675
|
+
if (v !== true && v !== "true" && v !== "false") {
|
|
1676
|
+
throw new Error("--shopifyLoginGate must be true or false.");
|
|
1677
|
+
}
|
|
1678
|
+
body["shopifyLoginGate"] = v === true || v === "true";
|
|
1418
1679
|
}
|
|
1419
|
-
const
|
|
1420
|
-
|
|
1421
|
-
|
|
1680
|
+
const themeFile = flagString(args.flags, "themeFile");
|
|
1681
|
+
if ("theme" in args.flags && themeFile !== void 0) {
|
|
1682
|
+
throw new Error("Pass either --theme or --themeFile, not both.");
|
|
1683
|
+
}
|
|
1684
|
+
if ("theme" in args.flags) {
|
|
1685
|
+
const v = args.flags["theme"];
|
|
1686
|
+
if (typeof v !== "string") {
|
|
1687
|
+
throw new Error('--theme requires a JSON value (use --theme "" to clear).');
|
|
1688
|
+
}
|
|
1689
|
+
body["theme"] = v === "" ? null : parseThemeJson(v, "--theme");
|
|
1690
|
+
} else if (themeFile !== void 0) {
|
|
1691
|
+
body["theme"] = parseThemeJson(readTextFile(themeFile, "--themeFile"), "--themeFile");
|
|
1692
|
+
}
|
|
1693
|
+
if (Object.keys(body).length === 0) {
|
|
1422
1694
|
throw new Error(
|
|
1423
|
-
|
|
1695
|
+
'Nothing to save. Pass at least one field flag, e.g. `gs configure set --displayName "Acme"`.'
|
|
1424
1696
|
);
|
|
1425
1697
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
};
|
|
1698
|
+
const url = `${apiBaseFor(slug)}/admin/configure/save`;
|
|
1699
|
+
const data = await request(url, {
|
|
1700
|
+
method: "POST",
|
|
1701
|
+
body
|
|
1702
|
+
});
|
|
1703
|
+
if (flagBool(args.flags, "json")) {
|
|
1704
|
+
process.stdout.write(JSON.stringify(data.config, null, 2) + "\n");
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
process.stdout.write(`Saved ${Object.keys(body).join(", ")} \u2192 ${slug}
|
|
1708
|
+
`);
|
|
1431
1709
|
}
|
|
1432
|
-
function
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
} catch {
|
|
1437
|
-
return null;
|
|
1710
|
+
function readTextFile(filePath, flag) {
|
|
1711
|
+
const resolved = path9.resolve(filePath);
|
|
1712
|
+
if (!fs9.existsSync(resolved)) {
|
|
1713
|
+
throw new Error(`${flag}: file not found: ${resolved}`);
|
|
1438
1714
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
let
|
|
1715
|
+
return fs9.readFileSync(resolved, "utf8");
|
|
1716
|
+
}
|
|
1717
|
+
function parseThemeJson(raw, flag) {
|
|
1718
|
+
let parsed;
|
|
1443
1719
|
try {
|
|
1444
|
-
|
|
1445
|
-
} catch {
|
|
1446
|
-
|
|
1720
|
+
parsed = JSON.parse(raw);
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
throw new Error(`${flag}: not valid JSON: ${err.message}`);
|
|
1447
1723
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
if (!entry) return null;
|
|
1451
|
-
return path7.resolve(pkgDir, entry);
|
|
1452
|
-
}
|
|
1453
|
-
function findOwningPackageJson(start, specifier) {
|
|
1454
|
-
let dir = path7.dirname(start);
|
|
1455
|
-
while (true) {
|
|
1456
|
-
const candidate = path7.join(dir, "package.json");
|
|
1457
|
-
if (fs7.existsSync(candidate)) {
|
|
1458
|
-
try {
|
|
1459
|
-
const parsed = JSON.parse(fs7.readFileSync(candidate, "utf8"));
|
|
1460
|
-
if (parsed.name === specifier) return candidate;
|
|
1461
|
-
} catch {
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
const parent = path7.dirname(dir);
|
|
1465
|
-
if (parent === dir) return null;
|
|
1466
|
-
dir = parent;
|
|
1724
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1725
|
+
throw new Error(`${flag}: theme must be a JSON object.`);
|
|
1467
1726
|
}
|
|
1727
|
+
return parsed;
|
|
1468
1728
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1729
|
+
|
|
1730
|
+
// src/commands/configure/upload.ts
|
|
1731
|
+
import * as fs10 from "fs";
|
|
1732
|
+
import * as path10 from "path";
|
|
1733
|
+
|
|
1734
|
+
// src/commands/configure/shared.ts
|
|
1735
|
+
var ASSET_KINDS = ["icon", "logoLight", "logoDark"];
|
|
1736
|
+
function parseAssetKind(raw) {
|
|
1737
|
+
if (raw && ASSET_KINDS.includes(raw)) {
|
|
1738
|
+
return raw;
|
|
1479
1739
|
}
|
|
1480
|
-
|
|
1740
|
+
throw new Error(`Asset kind must be one of: ${ASSET_KINDS.join(", ")}.`);
|
|
1481
1741
|
}
|
|
1482
1742
|
|
|
1483
|
-
// src/commands/
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1743
|
+
// src/commands/configure/upload.ts
|
|
1744
|
+
var MIME_BY_EXT = {
|
|
1745
|
+
".png": "image/png",
|
|
1746
|
+
".jpg": "image/jpeg",
|
|
1747
|
+
".jpeg": "image/jpeg",
|
|
1748
|
+
".webp": "image/webp"
|
|
1749
|
+
};
|
|
1750
|
+
async function uploadAsset(args) {
|
|
1751
|
+
const slug = resolveAdminStore(args);
|
|
1752
|
+
const kind = parseAssetKind(args.positional[0]);
|
|
1753
|
+
const filePath = args.positional[1];
|
|
1754
|
+
if (!filePath) {
|
|
1755
|
+
throw new Error(`Usage: gs configure upload <${ASSET_KINDS.join("|")}> <file>`);
|
|
1756
|
+
}
|
|
1757
|
+
const resolved = path10.resolve(filePath);
|
|
1758
|
+
if (!fs10.existsSync(resolved)) {
|
|
1759
|
+
throw new Error(`File not found: ${resolved}`);
|
|
1760
|
+
}
|
|
1761
|
+
const mime = MIME_BY_EXT[path10.extname(resolved).toLowerCase()];
|
|
1762
|
+
if (!mime) {
|
|
1763
|
+
throw new Error("Unsupported image type. Use .png, .jpg, or .webp.");
|
|
1764
|
+
}
|
|
1765
|
+
const bytes = fs10.readFileSync(resolved);
|
|
1766
|
+
const form = new FormData();
|
|
1767
|
+
form.append(
|
|
1768
|
+
"file",
|
|
1769
|
+
new Blob([new Uint8Array(bytes)], { type: mime }),
|
|
1770
|
+
path10.basename(resolved)
|
|
1771
|
+
);
|
|
1772
|
+
const url = `${apiBaseFor(slug)}/admin/configure/upload/${kind}`;
|
|
1773
|
+
const data = await request(url, {
|
|
1774
|
+
method: "POST",
|
|
1775
|
+
multipart: form
|
|
1776
|
+
});
|
|
1777
|
+
process.stdout.write(`Uploaded ${kind} \u2192 ${data.url}
|
|
1778
|
+
`);
|
|
1779
|
+
}
|
|
1487
1780
|
|
|
1488
|
-
// src/
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
cache2 = true ? JSON.parse('{"SKILL.md":"---\\nname: greatstore\\ndescription: Build AI-powered shopping experiences with GreatStore on a merchant\'s website and store. Use when installing the GreatStore chat widget on a site, generating AI content for custom UI with generateStructuredContent, adding chat entry points (sendMessage), letting the assistant act on the page via WebMCP page tools (document.modelContext.registerTool), authoring custom in-chat React components with the gs CLI, setting up web push re-engagement, or connecting AI agents to a store\'s MCP endpoints. Covers setup, schema design, caching behavior, component authoring, and ready-made recipes.\\n---\\n\\n# Building with GreatStore\\n\\nGreatStore gives a store an AI shopping assistant on two surfaces: a hosted\\nstorefront at `https://<slug>.greatstore.ai/`, and an embedded chat widget on\\nthe merchant\'s own site, installed with one script tag:\\n\\n```html\\n<script src=\\"https://my-store.greatstore.ai/embed.js\\"></script>\\n```\\n\\nEverything else a site can do with GreatStore is documented in the\\nreferences below. Read the one that matches the task before writing code \u2014\\neach facet has non-obvious rules (caching, grounding, result shapes, design\\nconstraints) that the references spell out.\\n\\n## Index\\n\\n| Goal | Use | Read |\\n|---|---|---|\\n| Install the widget; control the panel; readiness, events, troubleshooting | `window.GreatStore` SDK | [references/embed-api.md](references/embed-api.md) |\\n| AI-generated, catalog-grounded content rendered in **your own HTML/CSS** (highlights, comparisons, FAQs, gift guides) | `generateStructuredContent(schema, prompt)` | [references/structured-content.md](references/structured-content.md) |\\n| Copy-paste on-page experiences | recipes built on the SDK | the [Recipes](#recipes) table below |\\n| Contextual **conversation entry points** anywhere on the page | `sendMessage(text)`, `open()`, `?gs_chat=open` | [references/embed-api.md](references/embed-api.md) |\\n| Let the assistant **act on the page** (cart, navigation, filters) | WebMCP: `document.modelContext.registerTool(...)` | [references/embed-api.md](references/embed-api.md) |\\n| Custom **interactive UI inside the chat** (configurators, quizzes, size guides, booking forms) | remote components shipped with the `gs` CLI | [references/chat-components.md](references/chat-components.md) |\\n| **Re-engage shoppers** with browser notifications | merchant-hosted `gs.js` + `enableNotifications()` | [references/push-notifications.md](references/push-notifications.md) |\\n| Connect **AI agents** to the store (shopping tools over MCP, CLI docs for coding agents) | the store\'s MCP endpoints | [references/agents-and-mcp.md](references/agents-and-mcp.md) |\\n\\n## Two things to know before any of it\\n\\n- The page\'s domain **must be in the store\'s allowed domains** (GreatStore\\n store settings). If it isn\'t, nothing works and the console shows\\n `[GreatStore] Chat is unavailable on <origin>\u2026` \u2014 check this first whenever\\n the embed appears dead.\\n- Every SDK call is safe immediately after the script tag \u2014 pre-mount calls\\n queue and replay in order, and the SDK pre-warms itself in the background.\\n\\n## Recipes\\n\\nComplete, framework-free implementations, one per file. Shared conventions:\\ncontainers start `hidden` and reveal only on success (a failed generation\\nchanges nothing); generated strings render via `textContent`, never\\n`innerHTML`; every recipe guards on `window.GreatStore`; prompts stay\\ndeterministic per page so every visitor after the first hits the cache.\\nInline real product/page data into prompts where the platform exposes it.\\n\\n| Recipe | What it builds | Use it for |\\n|---|---|---|\\n| [GreatStore launchers](recipes/launchers.md) | Horizontally scrollable AI-generated chips \u2014 engaging first-person questions about the current page; tap to ask the assistant. | Instant engagement on any page type \u2014 product, collection, blog, home. Simple yet effective; start here. |\\n| [Ask-about-this entry points](recipes/ask-about-this.md) | One-line `sendMessage` buttons wired to existing page elements. | Size guides, shipping rows, out-of-stock badges \u2014 anywhere a shopper hesitates. |\\n| [Product FAQ accordion](recipes/product-faq.md) | Grounded pre-purchase Q&A with an \\"ask us\\" handoff into chat. | Product pages; answering objections before they cost the sale. |\\n| [Comparison table](recipes/comparison-table.md) | AI-picked representative products compared on category-relevant criteria. | Collection pages where shoppers weigh options. |\\n| [Complete the look](recipes/complete-the-look.md) | Catalog-grounded cross-sell strip with a reason per pick. | Product pages; raising order value with genuine pairings. |\\n| [Campaign hero](recipes/campaign-hero.md) | Seasonal homepage hero copy, cache-keyed to the ISO week. | Fresh homepage/campaign copy without manual rewrites. |\\n| [Gift finder funnel](recipes/gift-finder-funnel.md) | Quiz teaser \u2192 chat handoff \u2192 page-tool navigation; the full funnel. | Gifting seasons, guided discovery, homepage engagement. |\\n| [Page-action suite](recipes/page-action-suite.md) | WebMCP cart/page tools every conversation can use. | Any site where the assistant should act, not just advise. |\\n| [Custom chat button](recipes/custom-chat-button.md) | Branded launcher synced via `ready` + `open`/`close` events. | Replacing the default launcher with the site\'s own UI. |\\n\\n## How the facets combine\\n\\nThe strongest pattern is the **teaser \u2192 conversation \u2192 action** funnel:\\n`generateStructuredContent` renders a grounded teaser in the merchant\'s\\ndesign; each option\'s click handler calls `sendMessage` with the shopper\'s\\nchoice, dropping them into a conversation with momentum; WebMCP page tools\\nand custom chat components let that conversation actually do things \u2014 add to\\ncart, configure a product, book a slot \u2014 so it ends in a conversion, not a\\ncopy-paste. The [gift finder funnel](recipes/gift-finder-funnel.md)\\nrecipe is this funnel end to end.\\n","recipes/ask-about-this.md":"# \\"Ask about this\\" entry points (`sendMessage` only)\\n\\nZero-generation, instant, and often the biggest engagement win per line of\\ncode. Sprinkle context-aware buttons wherever a shopper hesitates:\\n\\n```js\\nconst gs = window.GreatStore;\\nif (gs) {\\n sizeGuideLink.addEventListener(\\"click\\", (e) => {\\n e.preventDefault();\\n gs.sendMessage(`How does the sizing run on \\"${productName}\\"? I usually wear a medium.`);\\n });\\n\\n shippingRow.querySelector(\\".ask\\").addEventListener(\\"click\\", () => {\\n gs.sendMessage(`What are the shipping options and times for \\"${productName}\\"?`);\\n });\\n\\n outOfStockBadge?.addEventListener(\\"click\\", () => {\\n gs.sendMessage(`\\"${productName}\\" looks out of stock \u2014 is there anything similar in stock?`);\\n });\\n}\\n```\\n\\nWrite each message as something the shopper would plausibly say \u2014 it appears\\nin the transcript as their message.\\n","recipes/campaign-hero.md":"# Campaign hero with deliberate variation\\n\\nCache-friendly variation: key the prompt to a low-cardinality period, not to\\ntime itself.\\n\\n```js\\n// ISO week number \u2192 one generation per store per week, shared by everyone.\\nconst week = (d => {\\n const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));\\n t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7));\\n return `${t.getUTCFullYear()}-W${Math.ceil((((t - Date.UTC(t.getUTCFullYear(), 0, 1)) / 864e5) + 1) / 7)}`;\\n})(new Date());\\n\\nconst data = await window.GreatStore.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n headline: { type: \\"string\\", maxLength: 60 },\\n subline: { type: \\"string\\", maxLength: 120 },\\n featuredProductName: { type: \\"string\\", nullable: true },\\n ctaChatMessage: { type: \\"string\\", maxLength: 120 },\\n },\\n required: [\\"headline\\", \\"subline\\", \\"ctaChatMessage\\"],\\n },\\n `Variant ${week}. Write a homepage hero for this store: a headline and ` +\\n `subline spotlighting a real product or category that fits the current ` +\\n `season, plus ctaChatMessage \u2014 the first-person message a shopper ` +\\n `would send to start shopping for it.`\\n);\\n\\nheroHeadline.textContent = data.headline;\\nheroSubline.textContent = data.subline;\\nheroCta.addEventListener(\\"click\\", () =>\\n window.GreatStore.sendMessage(data.ctaChatMessage)\\n);\\nhero.hidden = false;\\n```\\n","recipes/comparison-table.md":"# Collection-page comparison table\\n\\n```html\\n<section id=\\"gs-compare\\" hidden>\\n <h3>Quick comparison</h3>\\n <table><thead id=\\"gsc-head\\"></thead><tbody id=\\"gsc-body\\"></tbody></table>\\n</section>\\n\\n<script>\\n (async () => {\\n if (!window.GreatStore?.generateStructuredContent) return;\\n const collection = \\"winter jackets\\"; // \u2190 your collection name\\n try {\\n const data = await window.GreatStore.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n criteria: {\\n type: \\"array\\", minItems: 2, maxItems: 4,\\n items: { type: \\"string\\", maxLength: 25 },\\n },\\n rows: {\\n type: \\"array\\", minItems: 2, maxItems: 4,\\n items: {\\n type: \\"object\\",\\n properties: {\\n productName: { type: \\"string\\" },\\n bestFor: { type: \\"string\\", maxLength: 60 },\\n values: {\\n type: \\"array\\",\\n items: { type: \\"string\\", maxLength: 60 },\\n },\\n },\\n required: [\\"productName\\", \\"bestFor\\", \\"values\\"],\\n },\\n },\\n },\\n required: [\\"criteria\\", \\"rows\\"],\\n },\\n `The shopper is browsing the \\"${collection}\\" collection. Pick the ` +\\n `3-4 most representative products and compare them. Choose the ` +\\n `criteria a shopper actually decides on for this category. ` +\\n `\\"values\\" must align with \\"criteria\\" by index. Add a one-line ` +\\n `\\"bestFor\\" verdict per product. Only use real products and facts.`\\n );\\n\\n const head = document.getElementById(\\"gsc-head\\");\\n const hr = document.createElement(\\"tr\\");\\n for (const h of [\\"Product\\", ...data.criteria, \\"Best for\\"]) {\\n const th = document.createElement(\\"th\\");\\n th.textContent = h;\\n hr.append(th);\\n }\\n head.append(hr);\\n\\n const body = document.getElementById(\\"gsc-body\\");\\n for (const row of data.rows) {\\n const tr = document.createElement(\\"tr\\");\\n const cells = [row.productName, ...(row.values ?? []), row.bestFor];\\n for (let i = 0; i < data.criteria.length + 2; i++) {\\n const td = document.createElement(\\"td\\");\\n td.textContent = cells[i] ?? \\"\u2014\\";\\n tr.append(td);\\n }\\n body.append(tr);\\n }\\n document.getElementById(\\"gs-compare\\").hidden = false;\\n } catch {}\\n })();\\n</script>\\n```\\n\\nNote the index-aligned `values`/`criteria` trick and the `?? \\"\u2014\\"` guard \u2014\\ngrounding means a value the catalog can\'t support may be missing.\\n","recipes/complete-the-look.md":"# \\"Complete the look\\" cross-sell strip\\n\\n```js\\nconst data = await window.GreatStore.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n intro: { type: \\"string\\", maxLength: 90 },\\n picks: {\\n type: \\"array\\", minItems: 2, maxItems: 4,\\n items: {\\n type: \\"object\\",\\n properties: {\\n productName: { type: \\"string\\" },\\n reason: { type: \\"string\\", maxLength: 90 },\\n imageUrl: { type: \\"string\\", nullable: true },\\n productUrl: { type: \\"string\\", nullable: true },\\n },\\n required: [\\"productName\\", \\"reason\\"],\\n },\\n },\\n },\\n required: [\\"picks\\"],\\n },\\n `The shopper is viewing \\"${productName}\\". From the store\'s real catalog, ` +\\n `pick 2-4 products that genuinely pair with it and say why each one ` +\\n `completes the look or use-case. Include image and product URLs only ` +\\n `if known.`\\n);\\n\\nfor (const pick of data.picks) {\\n const card = document.createElement(\\"a\\");\\n if (pick.productUrl) card.href = pick.productUrl;\\n if (pick.imageUrl) {\\n const img = document.createElement(\\"img\\");\\n img.src = pick.imageUrl;\\n img.alt = pick.productName;\\n img.loading = \\"lazy\\";\\n card.append(img);\\n }\\n const name = document.createElement(\\"strong\\");\\n name.textContent = pick.productName;\\n const why = document.createElement(\\"p\\");\\n why.textContent = pick.reason;\\n card.append(name, why);\\n strip.append(card);\\n}\\nstrip.hidden = false;\\n```\\n\\n`imageUrl`/`productUrl` are `nullable` and optional in the render \u2014 the\\ngrounding contract means they\'re only present when the catalog actually has\\nthem. Never `require` URLs.\\n","recipes/custom-chat-button.md":"# Custom chat button synced to panel state\\n\\nReplace the default launcher with your own UI using the lifecycle surface:\\n\\n```js\\nconst gs = window.GreatStore;\\nconst btn = document.getElementById(\\"my-chat-button\\");\\n\\ngs.ready.then(() => { btn.hidden = false; });\\nbtn.addEventListener(\\"click\\", () => gs.toggle());\\n\\ngs.on(\\"open\\", () => btn.setAttribute(\\"aria-expanded\\", \\"true\\"));\\ngs.on(\\"close\\", () => btn.setAttribute(\\"aria-expanded\\", \\"false\\"));\\n```\\n\\n`ready` resolves even when `.then()` is attached after mount, so script\\nordering doesn\'t matter. The `open`/`close` events also fire for opens the\\nSDK triggers itself (`sendMessage`, `?gs_chat=open`), keeping your button\\nstate honest.\\n","recipes/gift-finder-funnel.md":"# Gift finder funnel (teaser \u2192 conversation \u2192 action)\\n\\nThe flagship pattern: an AI-generated quiz card in your design, whose answers\\ndrop the shopper into a chat that can act on the page.\\n\\n```html\\n<section id=\\"gift-finder\\" hidden>\\n <h3 id=\\"gf-question\\"></h3>\\n <div id=\\"gf-options\\"></div>\\n</section>\\n\\n<script>\\n (async () => {\\n const gs = window.GreatStore;\\n if (!gs?.generateStructuredContent) return;\\n\\n // Tools the resulting conversation can use. Registered once the\\n // SDK is ready (so document.modelContext exists); the assistant\\n // discovers them on its next turn automatically.\\n gs.ready.then(() => {\\n document.modelContext.registerTool({\\n name: \\"go_to_product\\",\\n description:\\n \\"Navigate the shopper to a product page on this site. Use when \\" +\\n \\"the shopper picks a product they want to see.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: { url: { type: \\"string\\" } },\\n required: [\\"url\\"],\\n },\\n execute({ url }) {\\n const u = new URL(String(url), location.origin);\\n if (u.origin !== location.origin) throw new Error(\\"Only same-site URLs allowed\\");\\n location.assign(u.href);\\n return { content: [{ type: \\"text\\", text: \\"Navigating.\\" }] };\\n },\\n });\\n });\\n\\n try {\\n const data = await gs.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n question: { type: \\"string\\", maxLength: 80 },\\n options: {\\n type: \\"array\\",\\n minItems: 3,\\n maxItems: 4,\\n items: {\\n type: \\"object\\",\\n properties: {\\n label: { type: \\"string\\", maxLength: 30 },\\n chatMessage: { type: \\"string\\", maxLength: 140 },\\n },\\n required: [\\"label\\", \\"chatMessage\\"],\\n },\\n },\\n },\\n required: [\\"question\\", \\"options\\"],\\n },\\n \\"Create one engaging gift-finder opening question for this store, \\" +\\n \\"with 3-4 answer options that map to real areas of the catalog. \\" +\\n \\"For each option also write chatMessage: the message a shopper \\" +\\n \\"would send to a shopping assistant after picking it, phrased in \\" +\\n \\"first person (e.g. \\\\\\"I\'m shopping for my dad who loves hiking\\\\\\").\\"\\n );\\n\\n document.getElementById(\\"gf-question\\").textContent = data.question;\\n const wrap = document.getElementById(\\"gf-options\\");\\n for (const opt of data.options) {\\n const btn = document.createElement(\\"button\\");\\n btn.type = \\"button\\";\\n btn.textContent = opt.label;\\n btn.addEventListener(\\"click\\", () => gs.sendMessage(opt.chatMessage));\\n wrap.append(btn);\\n }\\n document.getElementById(\\"gift-finder\\").hidden = false;\\n } catch {}\\n })();\\n</script>\\n```\\n\\nWhy it works: the teaser costs one cached generation for all visitors, each\\nclick opens a conversation that already has direction, and `go_to_product`\\nlets the conversation end on a product page instead of in a dead end.\\n","recipes/launchers.md":"# GreatStore launchers \u2014 AI question chips\\n\\nA horizontally scrollable row of chips, each a highly engaging first-person\\nquestion about the current page. Tapping a chip sends that question to the\\nassistant \u2014 `generateStructuredContent` writes the questions, `sendMessage`\\nfires them. Simple yet effective: it works on every page type, costs one\\ncached generation per page, and every tap starts a conversation that already\\nhas a great opening line.\\n\\n```html\\n<div id=\\"gs-launchers\\" hidden></div>\\n\\n<style>\\n #gs-launchers {\\n display: flex;\\n gap: 0.5em;\\n overflow-x: auto;\\n -webkit-overflow-scrolling: touch;\\n scrollbar-width: none;\\n padding: 0.5em 1em;\\n }\\n #gs-launchers::-webkit-scrollbar { display: none; }\\n #gs-launchers button {\\n flex: 0 0 auto;\\n white-space: nowrap;\\n border: 1px solid #ddd;\\n border-radius: 999px;\\n padding: 0.5em 0.9em;\\n background: #fff;\\n cursor: pointer;\\n }\\n</style>\\n\\n<script>\\n (async () => {\\n const gs = window.GreatStore;\\n if (!gs?.generateStructuredContent) return;\\n try {\\n const data = await gs.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n launchers: {\\n type: \\"array\\", minItems: 4, maxItems: 6,\\n items: {\\n type: \\"object\\",\\n properties: {\\n chip: { type: \\"string\\", maxLength: 32 },\\n question: { type: \\"string\\", maxLength: 140 },\\n },\\n required: [\\"chip\\", \\"question\\"],\\n },\\n },\\n },\\n required: [\\"launchers\\"],\\n },\\n `The shopper is on the page \\"${document.title}\\". Write 4-6 launcher ` +\\n `chips for a shopping assistant. For each, \\"question\\" is a highly ` +\\n `engaging first-person question this shopper would genuinely want ` +\\n `answered on this page \u2014 specific to its product, category, or ` +\\n `content, never generic \u2014 and \\"chip\\" is a 2-4 word teaser of it. ` +\\n `Vary the angles: fit and use, comparisons, gifting, care, what\'s ` +\\n `popular.`\\n );\\n\\n const row = document.getElementById(\\"gs-launchers\\");\\n for (const { chip, question } of data.launchers) {\\n const btn = document.createElement(\\"button\\");\\n btn.type = \\"button\\";\\n btn.textContent = chip;\\n btn.title = question;\\n btn.addEventListener(\\"click\\", () => gs.sendMessage(question));\\n row.append(btn);\\n }\\n row.hidden = false;\\n } catch {}\\n })();\\n</script>\\n```\\n\\nWhy it works:\\n\\n- **The chip is the teaser, the question is the payload.** A 2-4 word chip\\n scans instantly; the full first-person question lands in the transcript\\n reading like something the shopper typed, and gives the assistant a\\n well-formed prompt. The `title` attribute previews the full question on\\n hover.\\n- **Per-page for free.** The page URL is part of the generation context and\\n the cache key, so one site-wide snippet yields different chips on every\\n page \u2014 each cached for all visitors.\\n- **Placement is the lever.** Under the product title, above the grid on\\n collections, at the end of a blog post \u2014 wherever a shopper pauses to\\n wonder, the chips name the question for them.\\n\\nTips:\\n\\n- Inline the product or collection name into the prompt when the platform\\n exposes it \u2014 it beats relying on `document.title`.\\n- Restyle the chips to the site\'s design system; the CSS above is just the\\n scroll mechanics (flex row, `overflow-x: auto`, hidden scrollbars,\\n `white-space: nowrap`).\\n- Resist adding more than ~6 chips \u2014 a launcher row is an invitation, not a\\n sitemap.\\n","recipes/page-action-suite.md":"# Page-action suite (WebMCP)\\n\\nGive every conversation on the site real capabilities. Register once in a\\nshared snippet, after the SDK is ready (which guarantees\\n`document.modelContext` exists):\\n\\n```js\\nwindow.GreatStore?.ready.then(() => {\\n const text = (value) => ({\\n content: [{ type: \\"text\\", text: JSON.stringify(value) }],\\n });\\n\\n document.modelContext.registerTool({\\n name: \\"get_cart\\",\\n description:\\n \\"Read the shopper\'s current cart on this site: items, quantities, \\" +\\n \\"and totals. Use before answering any cart question.\\",\\n inputSchema: { type: \\"object\\", properties: {} },\\n async execute() {\\n return text(await (await fetch(\\"/cart.js\\")).json());\\n },\\n });\\n\\n document.modelContext.registerTool({\\n name: \\"add_to_cart\\",\\n description:\\n \\"Add a product variant to the cart on this site. Use when the \\" +\\n \\"shopper asks to add or buy something. Confirm the variant with \\" +\\n \\"the shopper first if ambiguous.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n variantId: { type: \\"string\\" },\\n quantity: { type: \\"integer\\", minimum: 1, maximum: 10 },\\n },\\n required: [\\"variantId\\"],\\n },\\n async execute({ variantId, quantity }) {\\n if (!variantId) throw new Error(\\"variantId is required\\");\\n const res = await fetch(\\"/cart/add.js\\", {\\n method: \\"POST\\",\\n headers: { \\"Content-Type\\": \\"application/json\\" },\\n body: JSON.stringify({ id: variantId, quantity: quantity ?? 1 }),\\n });\\n if (!res.ok) throw new Error(`Could not add to cart (${res.status})`);\\n document.dispatchEvent(new CustomEvent(\\"cart:refresh\\"));\\n return text(await res.json());\\n },\\n });\\n\\n document.modelContext.registerTool({\\n name: \\"get_current_page\\",\\n description:\\n \\"Read what page the shopper is currently on, including structured \\" +\\n \\"product data when on a product page.\\",\\n inputSchema: { type: \\"object\\", properties: {} },\\n execute() {\\n return text({\\n url: location.href,\\n title: document.title,\\n productJson: document.querySelector(\\"#product-json\\")?.textContent ?? null,\\n });\\n },\\n });\\n});\\n```\\n\\nPrinciples at work: throw on failure (the assistant explains and recovers),\\nreturn fresh state after mutations (the assistant confirms accurately), cap\\nquantities in the schema, and notify your own UI (`cart:refresh`) so the\\npage reflects what the AI did.\\n\\nFor a product-page-only tool, register with an `AbortSignal` and abort on\\nSPA navigation:\\n\\n```js\\nconst ac = new AbortController();\\ndocument.modelContext.registerTool(reviewsTool, { signal: ac.signal });\\nrouter.onLeave(\\"/products/:handle\\", () => ac.abort());\\n```\\n","recipes/product-faq.md":"# Product FAQ accordion\\n\\n```js\\nconst data = await window.GreatStore.generateStructuredContent(\\n {\\n type: \\"object\\",\\n properties: {\\n faqs: {\\n type: \\"array\\", minItems: 3, maxItems: 5,\\n items: {\\n type: \\"object\\",\\n properties: {\\n question: { type: \\"string\\", maxLength: 90 },\\n answer: { type: \\"string\\", maxLength: 300 },\\n },\\n required: [\\"question\\", \\"answer\\"],\\n },\\n },\\n },\\n required: [\\"faqs\\"],\\n },\\n `Generate the questions shoppers most plausibly have before buying the ` +\\n `product \\"${productName}\\", with accurate answers grounded in the real ` +\\n `product details and store policies. Skip any question the store data ` +\\n `can\'t answer confidently.`\\n);\\n\\nconst wrap = document.getElementById(\\"gs-faq\\");\\nfor (const { question, answer } of data.faqs) {\\n const details = document.createElement(\\"details\\");\\n const summary = document.createElement(\\"summary\\");\\n summary.textContent = question;\\n const p = document.createElement(\\"p\\");\\n p.textContent = answer;\\n details.append(summary, p);\\n wrap.append(details);\\n}\\nwrap.hidden = false;\\n```\\n\\nEngagement bonus \u2014 append a hand-off row so unanswered questions become\\nconversations:\\n\\n```js\\nconst ask = document.createElement(\\"button\\");\\nask.type = \\"button\\";\\nask.textContent = \\"Have a different question? Ask us\\";\\nask.addEventListener(\\"click\\", () =>\\n window.GreatStore.sendMessage(`I have a question about \\"${productName}\\".`)\\n);\\nwrap.append(ask);\\n```\\n","references/agents-and-mcp.md":"# AI agents and the store\'s MCP endpoints\\n\\nBeyond the widget, every GreatStore store is reachable by AI agents directly\\nover the Model Context Protocol. Three URLs, all on the store\'s subdomain,\\nnone requiring authentication:\\n\\n| URL | What it is |\\n|---|---|\\n| `https://<slug>.greatstore.ai/.well-known/mcp/server-card.json` | Standard MCP server card \u2014 machine-readable discovery document for the store. |\\n| `https://<slug>.greatstore.ai/mcp` | The store\'s **shopping tools** over MCP \u2014 product search and related catalog actions, the same capabilities the assistant itself shops with. |\\n| `https://<slug>.greatstore.ai/admin-mcp` | A **documentation server for coding agents**: its tools return usage docs for the `gs` CLI (login, init, build, push, publish). |\\n\\n## Shopping tools \u2014 `/mcp`\\n\\nConnect any MCP-capable agent to let it browse and shop the store\\nprogrammatically. This is the integration point for agentic-shopping\\nclients, comparison bots, or the merchant\'s own automations that need live\\ncatalog answers.\\n\\n## CLI docs for coding agents \u2014 `/admin-mcp`\\n\\nA stateless HTTP MCP whose tools hand back markdown documentation for `gs`\\nCLI commands. When accessed via the store\'s subdomain, every example comes\\npre-pinned to that store\'s slug, so the agent never has to ask which store\\nto target.\\n\\n```\\nclaude mcp add --transport http greatstore https://my-store.greatstore.ai/admin-mcp\\n```\\n\\nUseful when a coding agent is shipping chat components\\n([chat-components.md](chat-components.md)) and needs the exact command for\\nthe next step. If this skill is installed, the agent already has the\\nworkflow \u2014 the MCP is the self-serve alternative for agents that don\'t.\\n\\n## What to use when\\n\\n- **Building the merchant\'s site** \u2192 this skill\'s other references (the SDK,\\n structured content, page tools).\\n- **A coding agent shipping chat components** \u2192 the `gs` CLI, with\\n `/admin-mcp` as its built-in documentation.\\n- **An agent that needs to *shop* the store** (search products, read catalog\\n data) from outside any web page \u2192 `/mcp`.\\n","references/chat-components.md":"# Custom chat components \u2014 authoring with the `gs` CLI\\n\\nRemote components are React components the assistant renders **inside the\\nconversation** \u2014 product configurators, quizzes, size guides, booking forms,\\nanything richer than text. Each component is an AI-callable tool: the\\nmanifest\'s `description` tells the assistant *when* to show it, its\\n`inputSchema` declares the props the assistant fills in, and a `displayMode`\\npicks where it appears.\\n\\nAuthoring requires store-owner access (`gs login` signs in with the store\\nowner\'s account).\\n\\n## Workflow\\n\\n```\\nnpm install -g @greatstore/cli # or npx @greatstore/cli <command>\\ngs login # browser sign-in\\ngs init --store my-store # scaffold a project root\\ncd <project> && npm install\\ngs init size_guide # scaffold components/size_guide/\\n# \u2026 edit components/size_guide/{component.tsx,manifest.json} \u2026\\ngs build # bundle every component\\ngs push # upload changed components as drafts\\ngs publish size_guide # promote to live\\n```\\n\\n`gs list` shows what\'s deployed (with dashboard links); `gs pull` round-trips\\nremote components back to disk. `gs push` hashes components and only uploads\\nwhat changed.\\n\\nThe scaffold writes an `AGENTS.md` into the project (with `CLAUDE.md` /\\n`GEMINI.md` symlinked) containing the complete design rules and brand\\nvariable table \u2014 your coding agent picks it up automatically when working in\\nthe project. The store\'s `/admin-mcp` endpoint serves the same CLI docs to\\nagents over MCP (see [agents-and-mcp.md](agents-and-mcp.md)).\\n\\n## `manifest.json`\\n\\n```json\\n{\\n \\"name\\": \\"size_guide\\",\\n \\"displayName\\": \\"Size guide\\",\\n \\"description\\": \\"Interactive size guide. Show when the shopper asks about sizing or fit for apparel.\\",\\n \\"displayMode\\": \\"inline\\",\\n \\"inputSchema\\": {\\n \\"type\\": \\"object\\",\\n \\"properties\\": {\\n \\"productName\\": { \\"type\\": \\"string\\" },\\n \\"category\\": { \\"type\\": \\"string\\" }\\n },\\n \\"required\\": [\\"productName\\"]\\n }\\n}\\n```\\n\\n| Field | Meaning |\\n|---|---|\\n| `name` | Tool name, snake_case (`^[a-z][a-z0-9_]*$`), matches the folder under `components/`. |\\n| `displayName` | Friendly label shown in chat UI. |\\n| `description` | **Load-bearing** \u2014 how the assistant decides when to render the component. Say what it shows *and* when to use it, like any good tool description. |\\n| `displayMode` | Where it renders \u2014 see below. |\\n| `inputSchema` | JSON Schema for the props the assistant fills. Keep it tight; required fields the AI can\'t infer cause bad calls. |\\n| `async` | Set `true` for backend-backed components (see Async below). |\\n\\n### Display modes\\n\\n- `inline` \u2014 a bubble inside the chat transcript; persists with the message\\n log.\\n- `over-input` \u2014 floats above the chat input (like a question overlay);\\n cleared by the next user turn or an explicit close.\\n- `fullscreen` \u2014 takes over the full preview surface; persists until the\\n next widget-emitting tool call or an explicit close.\\n\\n## The component contract\\n\\n`component.tsx` default-exports a React component. Its props are the\\n`inputSchema` fields the assistant filled, plus three GreatStore-injected\\nlifecycle props (always present):\\n\\n| Prop | What it does |\\n|---|---|\\n| `onSendMessage(text)` | Send text into the chat as if the shopper typed it \u2014 lets the component drive the conversation (\\"Selected size M, what\'s the return policy?\\"). |\\n| `onCallTool(name, args)` | Chain into another remote-component tool by name. |\\n| `onClose()` | Dismiss the host slot. `over-input` clears the overlay, `fullscreen` reverts the pane, `inline` is a no-op. |\\n\\n```tsx\\nimport React from \\"react\\";\\n\\ninterface Props {\\n productName: string;\\n category?: string;\\n onSendMessage: (text: string) => void;\\n onCallTool: (name: string, args: Record<string, unknown>) => void;\\n onClose: () => void;\\n}\\n\\nexport default function SizeGuide({ productName, onSendMessage }: Props) {\\n return (\\n <div\\n style={{\\n padding: \\"1em\\",\\n border: \\"1px solid var(--color-border-default)\\",\\n borderRadius: \\"var(--radius-lg)\\",\\n background: \\"var(--color-surface)\\",\\n color: \\"var(--color-foreground)\\",\\n fontFamily: \\"var(--font-sans)\\",\\n }}\\n >\\n {/* \u2026 sizes for {productName} \u2026 */}\\n <button onClick={() => onSendMessage(`Size M of \\"${productName}\\" \u2014 is it in stock?`)}>\\n Check size M\\n </button>\\n </div>\\n );\\n}\\n```\\n\\n## Design rules (non-negotiable)\\n\\nComponents render inside arbitrary publisher pages *and* the GreatStore\\nstorefront; you control neither the host\'s root font size nor its colors.\\n\\n1. **Size in `em`, never `rem`** \u2014 `rem` resolves against the host page\'s\\n root font size, which is arbitrary (`html { font-size: 8px }` breaks every\\n `rem` dimension). `em` stays self-consistent anywhere. Borders may stay\\n `px`.\\n2. **Never hardcode colors, fonts, or radii** \u2014 read the brand CSS variables\\n GreatStore injects (`--color-primary`, `--color-surface`,\\n `--color-foreground`, `--color-border-default`, `--font-sans`,\\n `--radius-lg`, \u2026) so the component restyles itself with the store\'s\\n theme. The scaffolded `AGENTS.md` has the full variable table.\\n\\n## Async components (backend-backed data)\\n\\nIf a component must load data before it can render correctly, don\'t render a\\nshell and fetch in `useEffect` \u2014 set `\\"async\\": true` in the manifest and\\nexport an **async** default. GreatStore shows its own loading state, awaits\\nyour promise, and renders what it resolves to.\\n\\nA thrown error is a **retry signal**: the assistant sees it and usually\\nre-calls the tool. So only throw when a *different* call could help:\\n\\n1. Validate the AI-passed props first and throw on bad input \u2014 the AI can\\n fix the args and retry. (Don\'t validate the API\'s *output* and throw: the\\n AI can\'t fix your backend, it\'ll just loop.)\\n2. Throw on failures where retrying differently could succeed, and say what\\n to change (e.g. empty search \u2192 `\\"no results for X \u2014 try a broader keyword\\"`).\\n3. For idempotent failures (500, timeout, missing record) render a graceful\\n fallback instead of throwing \u2014 re-running the same call changes nothing.\\n\\n```tsx\\nexport default async function Results(props: Props) {\\n if (!props.query?.trim()) throw new Error(\\"missing required prop: query\\");\\n const res = await fetch(`/api/search?q=${encodeURIComponent(props.query)}`);\\n if (res.ok) {\\n const { results } = await res.json();\\n if (results.length === 0)\\n throw new Error(`no results for \\"${props.query}\\" \u2014 try a broader keyword`);\\n return <ul>{/* render results */}</ul>;\\n }\\n return <p>Couldn\'t load results right now.</p>; // idempotent: don\'t throw\\n}\\n```\\n\\n## When to build a component vs. the other facets\\n\\n- Content for the **merchant\'s page** \u2192 `generateStructuredContent`\\n ([structured-content.md](structured-content.md)).\\n- Letting the assistant **act on the page** \u2192 WebMCP page tools\\n ([embed-api.md](embed-api.md)).\\n- Rich, interactive UI **inside the conversation itself**, available on the\\n storefront and every embed without page changes \u2192 a chat component.\\n","references/embed-api.md":"# `window.GreatStore` API reference\\n\\n## Setup\\n\\n```html\\n<script src=\\"https://my-store.greatstore.ai/embed.js\\"></script>\\n```\\n\\nOne script tag, anywhere on the page (end of `<body>` preferred), with the\\nstore\'s slug in the host. The `window.GreatStore` object exists synchronously\\nonce the script executes; every method below is safe to call before the chat\\nUI has finished loading \u2014 pre-mount calls are queued and replayed in order\\nonce it mounts. The SDK pre-warms its chat bundle in the background\\nautomatically; the panel stays closed until `open()` / `toggle()` /\\n`sendMessage()` is called or the shopper clicks the launcher.\\n\\nRequirements:\\n\\n- The page\'s domain must be in the store\'s **allowed domains** (GreatStore\\n store settings) \u2014 see Troubleshooting below for the failure signature.\\n- If the store also wants push notifications, host `gs.js` at the site root\\n and load that instead of `embed.js` \u2014 it injects the embed for you. See\\n [push-notifications.md](push-notifications.md).\\n\\n## Properties\\n\\n| Property | Type | Description |\\n|---|---|---|\\n| `slug` | `string` | The store identifier the script was loaded for. |\\n| `host` | `string` | `\\"greatstore.ai\\"`. |\\n| `embedHost` | `string` | Origin the embed assets load from, e.g. `https://<slug>.greatstore.ai`. |\\n| `ready` | `Promise<void>` | Resolves when the chat UI has mounted and `open()` would render instantly. Resolved promises replay, so `.then()` works no matter when it\'s attached. The recommended readiness signal. |\\n| `isReady` | `boolean` | Synchronous alternative to `ready`. `false` until mount. |\\n\\nA `greatstore:ready` `CustomEvent` (with the SDK object as `detail`) is also\\ndispatched on `window` at the moment `ready` resolves, for declarative\\ntooling. Unlike the promise, the listener must be attached before mount\\ncompletes \u2014 attach it before (or immediately after) the embed script tag.\\n\\n## Methods\\n\\n### `load(): void`\\n\\nPre-warms the chat bundle and identity in the background without opening the\\npanel. Called automatically when `embed.js` runs, so you rarely need it.\\nIdempotent.\\n\\n### `open(): void` / `close(): void` / `toggle(): void`\\n\\nOpen, close, or toggle the chat panel. On desktop the panel is a floating\\nside panel; under 768px viewport width it\'s a full-height drawer. All three\\nqueue if called before mount.\\n\\n### `sendMessage(text: string): void`\\n\\nSends `text` as the shopper\'s own visible chat message and **opens the panel\\nif it\'s closed**. The text is trimmed; empty or whitespace-only strings are\\nsilently dropped. Queues if called before mount.\\n\\nThis is the highest-leverage one-liner in the SDK: any element on the page\\ncan become a conversation entry point with context baked into the question.\\n\\n```js\\ndocument.querySelector(\\"#ask-fit\\").addEventListener(\\"click\\", () => {\\n window.GreatStore.sendMessage(\\n `I\'m looking at \\"${productName}\\" \u2014 how does the sizing run?`\\n );\\n});\\n```\\n\\nBecause the message renders as if the shopper typed it, write it in the\\nshopper\'s voice. It is not a hidden-context channel \u2014 don\'t stuff it with\\ninvisible instructions or data dumps.\\n\\n### `on(event: string, handler: (...args) => void): () => void`\\n\\nSubscribe to SDK events. Returns an unsubscribe function. Listeners attached\\nbefore mount are queued and wired up at mount. Handler exceptions are caught\\nand reported \u2014 they won\'t break the chat.\\n\\nEvents emitted:\\n\\n| Event | Fired when |\\n|---|---|\\n| `\\"open\\"` | Panel transitions closed \u2192 open (including via `sendMessage` or the shopper\'s own click). |\\n| `\\"close\\"` | Panel transitions open \u2192 closed. |\\n\\n### `generateStructuredContent(schema: object, prompt: string): Promise<unknown>`\\n\\nGenerates JSON matching `schema` from `prompt`, grounded in the store\'s live\\ncatalog. Resolves to the generated data object itself. Rejects with `Error`\\non any failure (invalid input, decline, validation failure, rate limit,\\nnetwork). See [structured-content.md](structured-content.md) for the full\\ncontract, schema support, caching, and error semantics.\\n\\nAccepts either a plain JSON Schema object or any object exposing a\\n`.toJSONSchema()` method (e.g. Zod schemas) \u2014 the conversion is called for\\nyou.\\n\\nThrows synchronously (rejects) if `prompt` is not a non-empty string or\\n`schema` is not an object.\\n\\n### `enableNotifications(): Promise<{ ok: boolean }>`\\n\\nOpts this browser into Web Push notifications from the store. Requirements:\\n\\n- Must be called from a user gesture (e.g. a click handler).\\n- The site must host GreatStore\'s `gs.js` service-worker file. By default the\\n SDK looks for it at `/gs.js`; if it\'s hosted elsewhere, point to it via an\\n attribute on the embed script tag:\\n `<script src=\\"\u2026/embed.js\\" data-push-sw-path=\\"/path/to/gs.js\\"></script>`.\\n\\nResolves `{ ok: true }` on success and `{ ok: false }` on any failure\\n(unsupported browser, no service worker hosted, permission denied). It never\\nrejects.\\n\\n## Page tools \u2014 WebMCP (`document.modelContext`)\\n\\nThe recommended way to expose page capabilities to the assistant is the\\nWebMCP standard. The GreatStore assistant discovers every tool registered on\\n`document.modelContext`, re-reading the list on each conversational turn \u2014\\nso tools registered mid-session appear on the next message without a reload.\\n\\n### Availability\\n\\nIf the browser implements WebMCP natively, `document.modelContext` is just\\nthere. Otherwise the SDK installs a spec-tracking polyfill on the page\\nautomatically \u2014 but asynchronously, so at your script\'s first run\\n`document.modelContext` may not exist yet. Two robust patterns:\\n\\n```js\\n// 1. Register once GreatStore is ready (polyfill is in place by then):\\nwindow.GreatStore?.ready.then(() => {\\n document.modelContext.registerTool(/* \u2026 */);\\n});\\n\\n// 2. Or ship your own polyfill (npm: @mcp-b/webmcp-polyfill) and register\\n// immediately \u2014 the SDK detects an existing implementation and uses it.\\n```\\n\\n(`navigator.modelContext` is a deprecated alias for the same object; use\\n`document.modelContext` in new code.)\\n\\n### `registerTool(tool, options?)`\\n\\n```ts\\ndocument.modelContext.registerTool(\\n {\\n name: string, // required, non-empty, unique on the page\\n description: string, // required \u2014 how the AI decides when to call it\\n inputSchema?: object, // JSON Schema for execute\'s args;\\n // defaults to { type: \\"object\\", properties: {} }\\n execute(args): Result | Promise<Result>,\\n },\\n options?: { signal?: AbortSignal }, // abort to unregister\\n);\\n```\\n\\n- **Result shape**: `execute` returns MCP content blocks \u2014\\n `{ content: [{ type: \\"text\\", text: \\"\u2026\\" }] }`. For structured data,\\n `JSON.stringify` it into `text`. Add `isError: true` to mark a handled\\n failure.\\n- **Errors**: a thrown error or rejected promise is delivered to the\\n assistant as a *failed* tool call carrying the error message \u2014 the\\n assistant can explain or adapt. Errors never escape into your page.\\n- **Duplicate names throw.** To replace a tool, abort its registration first.\\n- **Unregistration is `AbortSignal`-driven**: pass `{ signal }` and call\\n `abort()` when the tool\'s context goes away (SPA navigation, modal close).\\n A pre-aborted signal skips registration. (A legacy\\n `unregisterTool(name)` exists but is deprecated in the spec.)\\n- **Treat `args` as untrusted input**: values are AI-generated. Validate\\n before passing to your own APIs, and never `eval` anything from them.\\n\\nA complete tool, registered once GreatStore is ready:\\n\\n```js\\nwindow.GreatStore?.ready.then(() => {\\n document.modelContext.registerTool({\\n name: \\"add_to_cart\\",\\n description:\\n \\"Add a product variant to the shopper\'s cart on this site. \\" +\\n \\"Use when the shopper asks to add, buy, or get a product.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n variantId: { type: \\"string\\" },\\n quantity: { type: \\"integer\\", minimum: 1 },\\n },\\n required: [\\"variantId\\"],\\n },\\n async execute({ variantId, quantity }) {\\n const res = await fetch(\\"/cart/add.js\\", {\\n method: \\"POST\\",\\n headers: { \\"Content-Type\\": \\"application/json\\" },\\n body: JSON.stringify({ id: variantId, quantity: quantity ?? 1 }),\\n });\\n if (!res.ok) throw new Error(`Cart add failed (${res.status})`);\\n const cart = await res.json();\\n return { content: [{ type: \\"text\\", text: JSON.stringify(cart) }] };\\n },\\n });\\n});\\n```\\n\\nReturning the fresh cart state after the mutation lets the assistant confirm\\naccurately. Good tool families: cart (`get_cart`, `add_to_cart`), navigation\\n(`go_to_page`), page state (`get_current_product`, `apply_filters`), UI\\n(`highlight_section`, `scroll_to_reviews`).\\n\\nAnd a context-scoped tool, unregistered via `AbortSignal`:\\n\\n```js\\nconst ac = new AbortController();\\ndocument.modelContext.registerTool(\\n {\\n name: \\"get_product_reviews\\",\\n description: \\"Read the reviews shown on the current product page.\\",\\n inputSchema: { type: \\"object\\", properties: {} },\\n execute: () => ({\\n content: [{ type: \\"text\\", text: JSON.stringify(collectReviews()) }],\\n }),\\n },\\n { signal: ac.signal },\\n);\\n\\n// On SPA route change away from the product page:\\nac.abort();\\n```\\n\\n## URL parameter: `?gs_chat=open`\\n\\nWhen the page URL carries `gs_chat=open`, the panel opens automatically once\\nthe embed mounts. The param is consumed and stripped from the URL via\\n`history.replaceState`, so a manual reload doesn\'t re-open the panel. Use it\\nin campaign links, emails, and post-login redirects.\\n\\n## Shopify login gate\\n\\nStores can enable a per-store \\"log in to chat\\" gate that applies **only to\\nthe embed** (never the store\'s GreatStore storefront). When enabled, the chat\\ninput is replaced with a login prompt until a Shopify customer signal is\\ndetected on the host page (standard Shopify globals/meta tags). The login\\nbutton sends the shopper through Shopify\'s customer login and back with\\n`?gs_chat=open` appended. This is a courtesy UX gate, not a security\\nboundary \u2014 don\'t rely on it to protect anything sensitive.\\n\\n## Troubleshooting\\n\\n| Symptom | Likely cause |\\n|---|---|\\n| Console: `[GreatStore] Chat is unavailable on <origin>\u2026 this domain isn\'t in the store\'s allowed domains` | The page\'s origin isn\'t in the store\'s allowed domains. Add it in store settings. Until then every SDK network call fails. |\\n| Console: `[GreatStore] Embed script must be loaded from <slug>.greatstore.ai/embed.js` | The script was copied/self-hosted instead of loaded from the store\'s embed URL. Always load it from `https://<slug>.greatstore.ai/embed.js`. |\\n| `generateStructuredContent` rejects with a rate-limit message | More than ~20 requests/minute from one visitor. Consolidate calls into fewer, richer schemas. |\\n| Panel won\'t auto-open on mobile after returning to the page | Intentional: the mobile drawer never auto-opens on resume \u2014 it would cover the content the shopper is reading. The transcript is preserved; they\'ll see it when they tap the launcher. |\\n| Tools registered but the assistant doesn\'t use them | Check the `description` \u2014 it\'s the only signal for *when* to call. Also confirm the registration ran (`document.modelContext` exists after `ready`) on the same page the conversation is on. |\\n","references/push-notifications.md":"# Web push re-engagement\\n\\nShoppers who opt in receive browser notifications from the store \u2014 under the\\nmerchant\'s own domain and branding, with the permission prompt shown inline\\non the merchant\'s page. Setup is two pieces: a single file hosted at the site\\nroot, and an opt-in button.\\n\\n## 1. Host `gs.js` at the site root\\n\\nDownload the store\'s loader and serve it at `/gs.js` on the merchant\'s\\ndomain:\\n\\n```\\nhttps://<slug>.greatstore.ai/gs.js \u2192 https://www.merchant-site.com/gs.js\\n```\\n\\nAlways download it from the **store\'s own subdomain** \u2014 the file is built for\\nthat store; don\'t copy one from elsewhere.\\n\\nThen load it with one tag (replacing the `embed.js` tag \u2014 `gs.js` injects the\\nembed for you and registers itself as the service worker):\\n\\n```html\\n<script src=\\"/gs.js\\"></script>\\n```\\n\\nHosting this file is what enables push. Without it, push is simply off \u2014\\n`enableNotifications()` returns `{ ok: false }` and nothing else changes.\\n\\n### Non-root hosting\\n\\nIf the platform can\'t serve files at the site root (e.g. Shopify themes\\nserve assets under a path), keep the regular `embed.js` tag and point it at\\nwhere the file lives \u2014 the path must be on the merchant\'s own origin:\\n\\n```html\\n<script\\n src=\\"https://my-store.greatstore.ai/embed.js\\"\\n data-push-sw-path=\\"/cdn/shop/files/gs.js\\"\\n></script>\\n```\\n\\n## 2. Offer the opt-in from a user gesture\\n\\n```js\\noptInButton.addEventListener(\\"click\\", async () => {\\n const { ok } = await window.GreatStore.enableNotifications();\\n optInButton.hidden = ok; // done \u2014 or quietly keep the button\\n});\\n```\\n\\nRules that make this work well:\\n\\n- **Always call it from a click** \u2014 browsers ignore or penalize permission\\n prompts that aren\'t user-initiated, and the call is designed for gesture\\n context.\\n- **Never prompt on page load.** Tie the button to a moment where\\n notifications have obvious value (\\"Notify me when this is back in stock\\",\\n post-purchase, after a chat conversation).\\n- `{ ok: false }` covers every failure the same way \u2014 unsupported browser, no\\n `gs.js` hosted, permission denied. It never rejects, and there\'s no popup\\n fallback. Design the button so a decline just leaves the page as it was;\\n don\'t show an error.\\n- The promise resolving `{ ok: true }` means this browser is subscribed.\\n There\'s nothing else to wire \u2014 notification delivery is handled by\\n GreatStore.\\n","references/structured-content.md":"# `generateStructuredContent` deep dive\\n\\nAI-generated, catalog-grounded JSON for UI you render yourself \u2014 the chat\\npanel is not involved.\\n\\n```js\\nconst data = await window.GreatStore.generateStructuredContent(schema, prompt);\\n```\\n\\n- `schema` \u2014 a JSON Schema describing the output (or a Zod schema exposing\\n `.toJSONSchema()`).\\n- `prompt` \u2014 what to generate.\\n- Resolves to **the generated JSON object itself**, matching the schema.\\n Rejects with an `Error` on any failure.\\n\\n## The rules that make it work well\\n\\nEach is unpacked in the sections below; this is the checklist.\\n\\n1. **Top level must be an object.** Want a list? Wrap it:\\n `{ type: \\"object\\", properties: { items: { type: \\"array\\", \u2026 } }, required: [\\"items\\"] }`.\\n2. **Steer with the prompt, not schema `description`s** \u2014 free-text schema\\n fields are stripped before the AI sees them. Use self-explanatory\\n property names (`benefitHeadline`, not `text1`).\\n3. **Only `require` what\'s guaranteed.** `required` is strictly enforced; if\\n the catalog can\'t ground a required field the whole call can fail. Require\\n structural fields, keep per-product details (image URLs, prices) optional,\\n and make rendering tolerate missing values.\\n4. **The AI can\'t see your page.** If page context matters, put it in the\\n prompt explicitly: `` `Generate care tips for the product \\"${productName}\\".` ``\\n5. **Responses are cached and shared** for up to ~24h across all visitors of\\n the same page + prompt + schema. Keep prompts deterministic per page \u2014\\n no timestamps, random values, or per-visitor data.\\n6. **Progressive enhancement, always.** Generate after the page renders into\\n a hidden container, reveal on success, leave the fallback on error. Render\\n generated strings via `textContent`, never `innerHTML`.\\n7. **One rich call beats many small ones** \u2014 there\'s a per-visitor rate limit\\n (~20 requests/minute); fetch multiple surfaces with one combined schema.\\n\\n## What actually happens\\n\\n1. The SDK posts your schema + prompt to the store\'s GreatStore endpoint,\\n along with the current **page URL and page title** (sent automatically \u2014\\n you don\'t pass them, and you can\'t override them).\\n2. GreatStore first **researches**: it looks up real data from the store\'s\\n live catalog (products, prices, availability, store info) using read-only\\n lookups. The page URL/title serve as hints about which product or category\\n to look up \u2014 they are *not* treated as a source of product data, and the\\n page\'s DOM is never read.\\n3. The AI then fills your schema from the researched data, under a strict\\n grounding contract: it must not invent product names, prices, images, IDs,\\n or descriptions. Fields it can\'t ground are omitted or `null`.\\n4. The output is validated against your schema (with internal retries) before\\n being returned and cached.\\n\\nThe request is anonymous by design \u2014 no shopper identity is attached \u2014 which\\nis what makes the response cacheable across all visitors.\\n\\n## Schema support\\n\\nTop level **must describe an object**: `type: \\"object\\"` (or a bare\\n`properties` / `anyOf`). To get a list, wrap it in an object property.\\n\\nSupported keywords (anything else is tolerated but ignored):\\n\\n- Types: `object`, `array`, `string`, `number`, `integer`, `boolean`, `null`\\n- Structure: `properties`, `required`, `items`, `additionalProperties`\\n- Choice: `enum`, `const`, `anyOf`, `nullable`\\n- Constraints: `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`,\\n `maxItems`, `pattern`, `format`, `default`\\n\\nValidation of the output is real: `required` is enforced, `enum`/`const`\\nmust match, numeric and length bounds are checked, and\\n`additionalProperties: false` rejects extra keys. Constraints are therefore a\\n*tool* \u2014 `maxItems: 4` reliably caps a list, `enum` reliably restricts a\\nfield \u2014 but every constraint is also a way for generation to fail, so apply\\nthem only where you\'d rather have no content than non-conforming content.\\n\\nZod schemas (or anything with a `.toJSONSchema()` method) are accepted and\\nconverted automatically.\\n\\n### Free-text schema fields are stripped\\n\\n`description`, `title`, and `example` are removed from the schema before the\\nAI sees it (they\'re a prompt-injection surface, so they\'re filtered\\nserver-side). Consequences:\\n\\n- Schema descriptions **cannot** steer generation. All steering lives in the\\n prompt string.\\n- Property *names* are the only in-schema signal of intent \u2014 make them\\n self-documenting: `ctaLabel`, `warmthRating`, `priceJustification`.\\n\\n## Prompting guide\\n\\nThe prompt is the entire instruction channel. A good prompt states, in order:\\n\\n1. **Context** \u2014 what page/situation the shopper is in, with concrete page\\n data inlined (the AI can\'t see your DOM):\\n `The shopper is viewing the product \\"Aurora Down Parka\\" on its product page.`\\n2. **Task** \u2014 what to generate, mapped loosely onto your schema\'s fields:\\n `Write a heading and 3 reasons to love it; each reason has a short title and one supporting sentence.`\\n3. **Grounding expectations** \u2014 what store data to draw on:\\n `Base every claim on the product\'s real materials, features, and price.`\\n4. **Voice** \u2014 tone and constraints:\\n `Warm and concrete. No exclamation marks, no generic marketing filler.`\\n\\nAnti-patterns:\\n\\n- **Per-visitor or per-moment data in the prompt** (names, cart contents,\\n timestamps, `Math.random()`): destroys the shared cache, so every visitor\\n pays full generation latency and the store pays for every call. If you\\n need per-shopper interaction, that\'s what `sendMessage` and the chat panel\\n are for.\\n- **Asking it to read the page** (\\"summarize the reviews shown below\\") \u2014 it\\n can\'t. Inline the data into the prompt instead, and keep what you inline\\n stable per page so caching still works.\\n- **Asking for data you should fetch yourself** (exact live stock numbers,\\n shipping ETAs). Use your own platform APIs for operational data; use\\n GreatStore for *editorial intelligence over the catalog*.\\n- **Burying instructions in schema descriptions** \u2014 stripped, see above.\\n\\n## Caching: design for it\\n\\nResponses are cached server-side for up to **24 hours**, keyed by the\\ncombination of page URL + page title + prompt + schema, and shared across all\\nvisitors. (Tracking query params like `utm_*`/`gclid` and the URL fragment\\nare ignored, so ad-tagged visits share the campaign-free page\'s cache entry.\\nMeaningful params like `?product=123` are part of the key.)\\n\\nPractical consequences:\\n\\n- **First visitor pays, the rest fly.** Expect a few seconds on a cache miss\\n and near-instant responses after. Design loading states for the miss case.\\n- **Same call on different pages = different content**, automatically \u2014 the\\n page URL is in the key and in the AI\'s hints. A single site-wide snippet\\n with a constant prompt yields per-page content for free.\\n- **Content refreshes roughly daily.** Don\'t build experiences that assume\\n minute-level freshness.\\n- **To force different content, change the prompt or schema** (e.g. a\\n campaign variant string that changes weekly \u2014 deliberate, low-cardinality\\n variation is fine; per-visitor cardinality is not).\\n\\nThe catalog research underneath is also cached briefly, so several distinct\\nsurfaces on the same page (different prompts/schemas) stay cheap even on\\ncold cache.\\n\\n## Errors and how to handle them\\n\\nThe promise rejects with `new Error(message)`. The message is\\ndeveloper-facing \u2014 never render it to shoppers. Cases:\\n\\n| Case | Message you\'ll see | Retry? |\\n|---|---|---|\\n| Bad input (empty prompt, non-object schema) | thrown immediately by the SDK | Fix the call |\\n| Invalid schema shape | `Invalid schema: \u2026` | Fix the schema |\\n| AI declined the request | `The assistant declined to generate content for this request.` | No \u2014 permanent for that prompt/schema. Rework the prompt. |\\n| Output couldn\'t satisfy the schema | `Failed to produce valid structured content` | No \u2014 usually `required`/constraints demand data the catalog lacks. Loosen the schema. |\\n| Rate limit (~20/min per visitor) | rate-limit message | Later \u2014 and consolidate calls |\\n| Network / server | varies | Next page load |\\n\\nThe uniform shopper-facing strategy: render into a hidden-by-default\\ncontainer, reveal on success, leave hidden (or show your static fallback) on\\nany rejection. One `try/catch`, no case analysis needed unless you\'re\\nlogging.\\n\\n## Performance pattern\\n\\nFire generation as early as possible without blocking render \u2014 top of your\\ndeferred script, before other work:\\n\\n```js\\nconst highlightsPromise = window.GreatStore?.generateStructuredContent\\n ? window.GreatStore.generateStructuredContent(schema, prompt).catch(() => null)\\n : Promise.resolve(null);\\n\\n// \u2026rest of page setup\u2026\\n\\nconst data = await highlightsPromise;\\nif (data) renderHighlights(data);\\n```\\n\\nThe `.catch(() => null)` attached immediately avoids unhandled-rejection\\nnoise while keeping a single render path.\\n\\nFor multiple surfaces on one page, prefer **one call with a combined\\nschema** over parallel calls \u2014 it\'s one research pass, one cache entry, and\\nno rate-limit pressure:\\n\\n```js\\nconst schema = {\\n type: \\"object\\",\\n properties: {\\n highlights: { /* \u2026 */ },\\n faq: { /* \u2026 */ },\\n crossSell: { /* \u2026 */ },\\n },\\n required: [\\"highlights\\"],\\n};\\n```\\n"}') : readTreeFromDisk(new URL("../../skill/", import.meta.url));
|
|
1494
|
-
|
|
1495
|
-
|
|
1781
|
+
// src/commands/configure/clear.ts
|
|
1782
|
+
async function clearAsset(args) {
|
|
1783
|
+
const slug = resolveAdminStore(args);
|
|
1784
|
+
const kind = parseAssetKind(args.positional[0]);
|
|
1785
|
+
const url = `${apiBaseFor(slug)}/admin/configure/delete/${kind}`;
|
|
1786
|
+
await request(url, { method: "POST", body: {} });
|
|
1787
|
+
process.stdout.write(`Cleared ${kind} for ${slug}
|
|
1788
|
+
`);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// src/commands/configure/index.ts
|
|
1792
|
+
async function configureCommand(args) {
|
|
1793
|
+
const sub = args.positional[0] ?? "show";
|
|
1794
|
+
const rest = { ...args, positional: args.positional.slice(1) };
|
|
1795
|
+
switch (sub) {
|
|
1796
|
+
case "show":
|
|
1797
|
+
await showConfig(rest);
|
|
1798
|
+
return;
|
|
1799
|
+
case "set":
|
|
1800
|
+
await setConfig(rest);
|
|
1801
|
+
return;
|
|
1802
|
+
case "upload":
|
|
1803
|
+
await uploadAsset(rest);
|
|
1804
|
+
return;
|
|
1805
|
+
case "clear":
|
|
1806
|
+
await clearAsset(rest);
|
|
1807
|
+
return;
|
|
1808
|
+
default:
|
|
1809
|
+
throw new Error(
|
|
1810
|
+
`Unknown configure subcommand: ${sub}. Use \`gs configure [set|upload|clear]\`.`
|
|
1811
|
+
);
|
|
1496
1812
|
}
|
|
1497
|
-
return cache2;
|
|
1498
1813
|
}
|
|
1499
1814
|
|
|
1500
|
-
// src/commands/
|
|
1501
|
-
function
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1815
|
+
// src/commands/connectors/shared.ts
|
|
1816
|
+
async function fetchConfig(slug) {
|
|
1817
|
+
const url = `${apiBaseFor(slug)}/admin/connectors/config`;
|
|
1818
|
+
return request(url);
|
|
1819
|
+
}
|
|
1820
|
+
function echoConnector(row) {
|
|
1821
|
+
return {
|
|
1822
|
+
id: row.id,
|
|
1823
|
+
name: row.name,
|
|
1824
|
+
url: row.url,
|
|
1825
|
+
transport: row.transport,
|
|
1826
|
+
authKind: row.authKind,
|
|
1827
|
+
enabled: row.enabled,
|
|
1828
|
+
profileUrl: row.profileUrl
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
async function saveConfig(slug, config, connectors, makerEnabled = config.makerEnabled) {
|
|
1832
|
+
const url = `${apiBaseFor(slug)}/admin/connectors/save`;
|
|
1833
|
+
return request(url, {
|
|
1834
|
+
method: "POST",
|
|
1835
|
+
body: {
|
|
1836
|
+
makerEnabled,
|
|
1837
|
+
// Maker's slot is only valid in [0, N]; removing a connector can
|
|
1838
|
+
// shrink the list below the stored priority.
|
|
1839
|
+
makerPriority: Math.min(config.makerPriority, connectors.length),
|
|
1840
|
+
connectors
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
function findConnector(config, ref) {
|
|
1845
|
+
const byId = config.connectors.find((r) => r.id === ref);
|
|
1846
|
+
if (byId) return byId;
|
|
1847
|
+
const byName = config.connectors.filter((r) => r.name === ref);
|
|
1848
|
+
if (byName.length === 1) return byName[0];
|
|
1849
|
+
if (byName.length > 1) {
|
|
1850
|
+
const ids = byName.map((r) => r.id).join(", ");
|
|
1851
|
+
throw new Error(
|
|
1852
|
+
`Multiple connectors are named "${ref}". Use an id instead: ${ids}`
|
|
1853
|
+
);
|
|
1506
1854
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1855
|
+
throw new Error(`No connector named "${ref}". Run \`gs connectors\` to see them.`);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// src/commands/connectors/list.ts
|
|
1859
|
+
async function listConnectors(args) {
|
|
1860
|
+
const slug = resolveAdminStore(args);
|
|
1861
|
+
const config = await fetchConfig(slug);
|
|
1862
|
+
if (flagBool(args.flags, "json")) {
|
|
1863
|
+
process.stdout.write(JSON.stringify(config, null, 2) + "\n");
|
|
1864
|
+
return;
|
|
1514
1865
|
}
|
|
1515
1866
|
process.stdout.write(
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1867
|
+
`Maker MCP: ${config.makerEnabled ? "on" : "off"} (position ${config.makerPriority + 1})
|
|
1868
|
+
`
|
|
1869
|
+
);
|
|
1870
|
+
if (config.connectors.length === 0) {
|
|
1871
|
+
process.stdout.write(`(no custom connectors in ${slug})
|
|
1872
|
+
`);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const rows = config.connectors.map((r) => ({
|
|
1876
|
+
name: r.name,
|
|
1877
|
+
enabled: r.enabled ? "on" : "off",
|
|
1878
|
+
auth: r.authKind === "bearer" ? r.hasToken ? "bearer" : "bearer (no token)" : "none",
|
|
1879
|
+
url: r.url
|
|
1880
|
+
}));
|
|
1881
|
+
const widths = {
|
|
1882
|
+
name: Math.max(4, ...rows.map((r) => r.name.length)),
|
|
1883
|
+
enabled: Math.max(7, ...rows.map((r) => r.enabled.length)),
|
|
1884
|
+
auth: Math.max(4, ...rows.map((r) => r.auth.length))
|
|
1885
|
+
};
|
|
1886
|
+
process.stdout.write(
|
|
1887
|
+
`${pad2("NAME", widths.name)} ${pad2("ENABLED", widths.enabled)} ${pad2("AUTH", widths.auth)} URL
|
|
1888
|
+
`
|
|
1523
1889
|
);
|
|
1890
|
+
for (const r of rows) {
|
|
1891
|
+
process.stdout.write(
|
|
1892
|
+
`${pad2(r.name, widths.name)} ${pad2(r.enabled, widths.enabled)} ${pad2(r.auth, widths.auth)} ${r.url}
|
|
1893
|
+
`
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1524
1896
|
}
|
|
1525
|
-
function
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1897
|
+
function pad2(s, width) {
|
|
1898
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// src/commands/connectors/add.ts
|
|
1902
|
+
async function addConnector(args) {
|
|
1903
|
+
const slug = resolveAdminStore(args);
|
|
1904
|
+
const name = args.positional[0];
|
|
1905
|
+
if (!name) {
|
|
1906
|
+
throw new Error("Usage: gs connectors add <name> --url <url> [--token <token>]");
|
|
1907
|
+
}
|
|
1908
|
+
const url = flagString(args.flags, "url");
|
|
1909
|
+
if (!url) {
|
|
1910
|
+
throw new Error("--url is required for `gs connectors add`.");
|
|
1911
|
+
}
|
|
1912
|
+
const token = flagString(args.flags, "token");
|
|
1913
|
+
const profileUrl = flagString(args.flags, "profileUrl") ?? null;
|
|
1914
|
+
const enabled = !flagBool(args.flags, "disabled");
|
|
1915
|
+
if (!flagBool(args.flags, "force")) {
|
|
1916
|
+
const probeUrl = `${apiBaseFor(slug)}/admin/connectors/probe`;
|
|
1917
|
+
const probe = await request(
|
|
1918
|
+
probeUrl,
|
|
1919
|
+
{
|
|
1920
|
+
method: "POST",
|
|
1921
|
+
body: {
|
|
1922
|
+
url,
|
|
1923
|
+
...token !== void 0 ? { token } : {},
|
|
1924
|
+
...profileUrl !== null ? { profileUrl } : {}
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
);
|
|
1928
|
+
if (!probe.ok) {
|
|
1929
|
+
throw new Error(
|
|
1930
|
+
`Connector did not answer tool discovery${probe.error ? `: ${probe.error}` : ""}. Fix the URL/token or pass --force to save it anyway.`
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
process.stdout.write(`Probe ok \u2014 ${probe.toolCount ?? 0} tool(s) discovered.
|
|
1934
|
+
`);
|
|
1935
|
+
}
|
|
1936
|
+
const config = await fetchConfig(slug);
|
|
1937
|
+
const next = {
|
|
1938
|
+
// The server treats an id it doesn't recognize as "mint a fresh one".
|
|
1939
|
+
id: "",
|
|
1940
|
+
name,
|
|
1941
|
+
url,
|
|
1942
|
+
transport: "http",
|
|
1943
|
+
authKind: token ? "bearer" : "none",
|
|
1944
|
+
enabled,
|
|
1945
|
+
profileUrl,
|
|
1946
|
+
...token !== void 0 ? { token } : {}
|
|
1947
|
+
};
|
|
1948
|
+
const saved = await saveConfig(slug, config, [
|
|
1949
|
+
...config.connectors.map(echoConnector),
|
|
1950
|
+
next
|
|
1951
|
+
]);
|
|
1952
|
+
const row = saved.connectors.find((r) => r.name === name);
|
|
1953
|
+
process.stdout.write(
|
|
1954
|
+
`Added connector "${name}"${row ? ` (${row.id})` : ""} \u2192 ${slug}
|
|
1955
|
+
`
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// src/commands/connectors/remove.ts
|
|
1960
|
+
async function removeConnector(args) {
|
|
1961
|
+
const slug = resolveAdminStore(args);
|
|
1962
|
+
const ref = args.positional[0];
|
|
1963
|
+
if (!ref) throw new Error("Usage: gs connectors remove <name|id>");
|
|
1964
|
+
const config = await fetchConfig(slug);
|
|
1965
|
+
const target = findConnector(config, ref);
|
|
1966
|
+
const kept = config.connectors.filter((r) => r.id !== target.id).map(echoConnector);
|
|
1967
|
+
await saveConfig(slug, config, kept);
|
|
1968
|
+
process.stdout.write(`Removed connector "${target.name}" from ${slug}
|
|
1969
|
+
`);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// src/commands/connectors/enable.ts
|
|
1973
|
+
async function setConnectorEnabled(args, enabled) {
|
|
1974
|
+
const slug = resolveAdminStore(args);
|
|
1975
|
+
const ref = args.positional[0];
|
|
1976
|
+
if (!ref) {
|
|
1977
|
+
throw new Error(`Usage: gs connectors ${enabled ? "enable" : "disable"} <name|id>`);
|
|
1978
|
+
}
|
|
1979
|
+
const config = await fetchConfig(slug);
|
|
1980
|
+
const target = findConnector(config, ref);
|
|
1981
|
+
const next = config.connectors.map(
|
|
1982
|
+
(r) => r.id === target.id ? { ...echoConnector(r), enabled } : echoConnector(r)
|
|
1983
|
+
);
|
|
1984
|
+
await saveConfig(slug, config, next);
|
|
1985
|
+
process.stdout.write(
|
|
1986
|
+
`Connector "${target.name}" is now ${enabled ? "enabled" : "disabled"}
|
|
1987
|
+
`
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// src/commands/connectors/maker.ts
|
|
1992
|
+
async function setMaker(args) {
|
|
1993
|
+
const slug = resolveAdminStore(args);
|
|
1994
|
+
const state = args.positional[0];
|
|
1995
|
+
if (state !== "on" && state !== "off") {
|
|
1996
|
+
throw new Error("Usage: gs connectors maker on|off");
|
|
1997
|
+
}
|
|
1998
|
+
const config = await fetchConfig(slug);
|
|
1999
|
+
await saveConfig(slug, config, config.connectors.map(echoConnector), state === "on");
|
|
2000
|
+
process.stdout.write(`Maker MCP is now ${state}
|
|
2001
|
+
`);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// src/commands/connectors/health.ts
|
|
2005
|
+
async function healthCheck(args) {
|
|
2006
|
+
const slug = resolveAdminStore(args);
|
|
2007
|
+
const config = await fetchConfig(slug);
|
|
2008
|
+
let onlyId = null;
|
|
2009
|
+
const ref = args.positional[0];
|
|
2010
|
+
if (ref) onlyId = findConnector(config, ref).id;
|
|
2011
|
+
if (config.connectors.length === 0) {
|
|
2012
|
+
process.stdout.write(`(no custom connectors in ${slug})
|
|
2013
|
+
`);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
const url = `${apiBaseFor(slug)}/admin/connectors/health`;
|
|
2017
|
+
const data = await request(url, { method: "POST", body: onlyId ? { id: onlyId } : {} });
|
|
2018
|
+
if (flagBool(args.flags, "json")) {
|
|
2019
|
+
process.stdout.write(JSON.stringify(data.health, null, 2) + "\n");
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
const nameById = new Map(config.connectors.map((r) => [r.id, r.name]));
|
|
2023
|
+
for (const result of data.health) {
|
|
2024
|
+
const name = nameById.get(result.id) ?? result.id;
|
|
2025
|
+
if (result.ok) {
|
|
2026
|
+
process.stdout.write(` ok ${name} (${result.toolCount ?? 0} tools)
|
|
2027
|
+
`);
|
|
2028
|
+
} else {
|
|
2029
|
+
process.stdout.write(` failed ${name}: ${result.error ?? "discovery failed"}
|
|
2030
|
+
`);
|
|
2031
|
+
process.exitCode = 1;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// src/commands/connectors/index.ts
|
|
2037
|
+
async function connectorsCommand(args) {
|
|
2038
|
+
const sub = args.positional[0] ?? "list";
|
|
2039
|
+
const rest = { ...args, positional: args.positional.slice(1) };
|
|
2040
|
+
switch (sub) {
|
|
2041
|
+
case "list":
|
|
2042
|
+
await listConnectors(rest);
|
|
2043
|
+
return;
|
|
2044
|
+
case "add":
|
|
2045
|
+
await addConnector(rest);
|
|
2046
|
+
return;
|
|
2047
|
+
case "remove":
|
|
2048
|
+
await removeConnector(rest);
|
|
2049
|
+
return;
|
|
2050
|
+
case "enable":
|
|
2051
|
+
await setConnectorEnabled(rest, true);
|
|
2052
|
+
return;
|
|
2053
|
+
case "disable":
|
|
2054
|
+
await setConnectorEnabled(rest, false);
|
|
2055
|
+
return;
|
|
2056
|
+
case "maker":
|
|
2057
|
+
await setMaker(rest);
|
|
2058
|
+
return;
|
|
2059
|
+
case "health":
|
|
2060
|
+
await healthCheck(rest);
|
|
2061
|
+
return;
|
|
2062
|
+
default:
|
|
2063
|
+
throw new Error(
|
|
2064
|
+
`Unknown connectors subcommand: ${sub}. Use \`gs connectors [add|remove|enable|disable|maker|health]\`.`
|
|
2065
|
+
);
|
|
1529
2066
|
}
|
|
1530
|
-
const rel = path8.relative(process.cwd(), dest);
|
|
1531
|
-
return rel && !rel.startsWith("..") ? rel : dest;
|
|
1532
2067
|
}
|
|
1533
2068
|
|
|
1534
2069
|
// src/changelog.ts
|
|
@@ -1550,9 +2085,9 @@ function recentChangelog(text, minItems = 15) {
|
|
|
1550
2085
|
}
|
|
1551
2086
|
|
|
1552
2087
|
// src/version-check.ts
|
|
1553
|
-
import * as
|
|
2088
|
+
import * as fs11 from "fs";
|
|
1554
2089
|
import * as os3 from "os";
|
|
1555
|
-
import * as
|
|
2090
|
+
import * as path11 from "path";
|
|
1556
2091
|
var NOTICE = "GreatStore CLI is still in early beta and we constantly pushing new features and security update. It is advised to update to the latest version whenever possible.";
|
|
1557
2092
|
var PKG = "@greatstore/cli";
|
|
1558
2093
|
var REGISTRY_URL = `https://registry.npmjs.org/${PKG}/latest`;
|
|
@@ -1606,11 +2141,11 @@ async function fetchLatest() {
|
|
|
1606
2141
|
}
|
|
1607
2142
|
}
|
|
1608
2143
|
function cachePath(home) {
|
|
1609
|
-
return
|
|
2144
|
+
return path11.join(home, ".greatstore", "version-check.json");
|
|
1610
2145
|
}
|
|
1611
2146
|
function readCache(home) {
|
|
1612
2147
|
try {
|
|
1613
|
-
const raw =
|
|
2148
|
+
const raw = fs11.readFileSync(cachePath(home), "utf8");
|
|
1614
2149
|
const parsed = JSON.parse(raw);
|
|
1615
2150
|
if (typeof parsed.latest === "string" && typeof parsed.checkedAt === "number") {
|
|
1616
2151
|
return { latest: parsed.latest, checkedAt: parsed.checkedAt };
|
|
@@ -1622,8 +2157,8 @@ function readCache(home) {
|
|
|
1622
2157
|
function writeCache(home, cache3) {
|
|
1623
2158
|
try {
|
|
1624
2159
|
const file = cachePath(home);
|
|
1625
|
-
|
|
1626
|
-
|
|
2160
|
+
fs11.mkdirSync(path11.dirname(file), { recursive: true });
|
|
2161
|
+
fs11.writeFileSync(file, JSON.stringify(cache3));
|
|
1627
2162
|
} catch {
|
|
1628
2163
|
}
|
|
1629
2164
|
}
|
|
@@ -1645,36 +2180,47 @@ function parseVer(v) {
|
|
|
1645
2180
|
}
|
|
1646
2181
|
|
|
1647
2182
|
// src/index.ts
|
|
1648
|
-
var VERSION = true ? "0.0.
|
|
1649
|
-
var CHANGELOG = true ? "# Changelog\n\nAll notable changes to `@greatstore/cli` are recorded here. The format\nfollows [Keep a Changelog](https://keepachangelog.com/).\n\n## 0.0.26 \u2014 2026-06-11\n\n### Added\n- The agent skill gains a \"GreatStore launchers\" recipe: a horizontally\n scrollable row of AI-generated chips, each an engaging first-person\n question about the current page that's sent to the assistant on tap.\n\n### Changed\n- Skill recipes are now one file each under `recipes/`, indexed from\n SKILL.md by a table with description and use-case columns.\n\n## 0.0.25 \u2014 2026-06-11\n\n### Added\n- `gs skill` installs the GreatStore agent skill \u2014 a guide AI coding\n agents use to build with GreatStore: AI content for your own UI, chat\n entry points, page tools, custom in-chat components, push\n notifications, and the store's MCP endpoints. Installs into\n `./.claude/skills/`; use `--global` for `~/.claude/skills/`, or\n `--dir <path>` for agents that read skills from somewhere else. Run\n it again any time to update an installed copy.\n\n## 0.0.24 \u2014 2026-06-03\n\n### Changed\n- `gs init` in an existing project now fills in any scaffold files that\n are missing (for example, the `AGENTS.md` design guide added in\n 0.0.23) and leaves your own files alone. Pass `--force` to refresh\n every scaffold file to the latest version. Your pinned store\n (`.gsrc`) is never rewritten either way.\n\n## 0.0.23 \u2014 2026-06-03\n\n### Added\n- `gs init` now scaffolds an `AGENTS.md` (with `CLAUDE.md` and\n `GEMINI.md` symlinked to it) documenting the design rules every\n component should follow \u2014 use `em` rather than `rem` for sizing, and\n style from the provided brand CSS variables so components match the\n store's theme. It doubles as guidance for AI coding agents.\n\n## 0.0.22 \u2014 2026-06-02\n\n### Added\n- `gs list` now shows a link to each component's page in the dashboard,\n so you can jump straight to a component to preview or publish it. The\n link is also included in `gs list --json`.\n\n## 0.0.21 \u2014 2026-05-31\n\n### Changed\n- The `gs init` component scaffold now shows how to write **async**\n components that load data before they render \u2014 including validating\n inputs up front and signalling a failure by throwing. The scaffolded\n component no longer includes an `onError` prop; throw from an async\n component to report a failure instead.\n\n## 0.0.20 \u2014 2026-05-29\n\n### Added\n- `gs pull`, `gs push`, and `gs publish` now accept several component\n names at once (e.g. `gs publish header footer cart`). Each component\n is reported on its own line and one failure no longer stops the rest.\n\n## 0.0.19 \u2014 2026-05-28\n\n### Fixed\n- `gs login` on Windows no longer opens a sign-in URL with parameters\n stripped, which surfaced as a \"Missing redirect_uri or state\n parameter\" page in the browser.\n\n## 0.0.18 \u2014 2026-05-28\n\n### Changed\n- Push and publish errors now name the specific reason \u2014 including\n every failing field in `manifest.json` \u2014 instead of the previous\n generic message.\n\n## 0.0.17 \u2014 2026-05-28\n\n### Added\n- Each command now prints a one-line upgrade notice when a newer\n `@greatstore/cli` is available on npm.\n\n## 0.0.16 \u2014 2026-05-28\n\n### Changed\n- Simplified error messages.\n\n## 0.0.15 \u2014 2026-05-24\n\n### Added\n- Scaffolded `component.tsx` now declares the four injected lifecycle\n props (`onSendMessage`, `onCallTool`, `onClose`, `onError`) on\n `Props`. Use `onError(message)` to report expected failures (failed\n fetch, host action rejected, invalid host state) so the AI can\n recover on its next turn. Render-time crashes are reported for you.\n\n## 0.0.14 \u2014 2026-05-24\n\n### Changed\n- `gs build` output is now whitespace-minified \u2014 typically ~50% smaller.\n\n## 0.0.13 \u2014 2026-05-24\n\n### Changed\n- `gs build` prints the next-step hint (`gs push`, then `gs publish`).\n\n## 0.0.12 \u2014 2026-05-24\n\n### Fixed\n- `gs build` failing to load Vite in some setups.\n\n## 0.0.11 \u2014 2026-05-24\n\n### Added\n- Multi-component projects. `gs init` (no args) scaffolds the project\n root; `gs init <name>` adds a component under `components/<name>/`.\n- `gs build [<name>]` \u2014 compiles every `components/<name>/bundle.js`.\n- `gs push` (no args) uploads only the components that changed.\n- `gs pull` (no args, or `*`) downloads every component. Locally\n edited components are skipped; pass `--force` to overwrite.\n- Public `CHANGELOG.md`; `gs --version` prints recent entries.\n\n### Changed\n- A project folder ships to exactly one store. Only `gs init` accepts\n `--store`; every other command reads the slug from `.gsrc`. The old\n single-component layout is rejected with a migration hint.\n- `gs init` requires `--store <slug>` for a fresh root, and rejects\n `--store` on an existing root.\n- `gs init` no longer writes `build.mjs` \u2014 scripts call `gs build`.\n\n## 0.0.10 \u2014 2026-05-23\n\n### Changed\n- The sign-in browser tab auto-closes once `gs login` finishes.\n\n## 0.0.9 \u2014 2026-05-23\n\n### Changed\n- Scaffolded manifests include a `displayName` so the admin UI has a\n friendlier label.\n\n## 0.0.8 \u2014 2026-05-23\n\n### Changed\n- `gs push` and `gs publish` print a link to view the component.\n\n## 0.0.6 \u2014 2026-05-23\n\n### Changed\n- `gs --version` reads from the published package version.\n\n## 0.0.4 \u2014 2026-05-23\n\n### Changed\n- Scaffolded projects produce browser-ready bundles out of the box.\n\n## 0.0.3 \u2014 2026-05-23\n\n### Changed\n- Trimmed public README to the essentials.\n\n## 0.0.2 \u2014 2026-05-23\n\n### Fixed\n- Sign-in callback parameter handling.\n" : "";
|
|
2183
|
+
var VERSION = true ? "0.0.28" : "0.0.0-dev";
|
|
2184
|
+
var CHANGELOG = true ? "# Changelog\n\nAll notable changes to `@greatstore/cli` are recorded here. The format\nfollows [Keep a Changelog](https://keepachangelog.com/).\n\n## 0.0.28 \u2014 2026-06-12\n\n### Added\n- `gs configure` \u2014 view and edit the store configuration from the CLI:\n display name, assistant name, sales guide, store link, extra origins,\n Shopify login gate, theme, CSP host lists, and icon/logo uploads. Same\n fields and behaviour as the dashboard's Configure panel.\n- `gs connectors` \u2014 manage the store's MCP connectors: list, add (with\n a discovery probe before saving), remove, enable/disable, toggle the\n Maker MCP, and health-check. Same behaviour as the dashboard's\n Connectors panel.\n- `--store <slug>` on the new admin commands, so they work outside a\n scaffolded component project (a `.gsrc` is still used when present).\n- The agent skill gains a store-administration reference: coding agents\n can read the store's configuration and connectors to ground their\n work, self-serve additive changes like origin allowlists and CSP\n hosts (read-merge-write), and are told which changes need the\n merchant's go-ahead first.\n\n### Changed\n- Component commands now live under `gs apps` (`gs apps push`,\n `gs apps build`, \u2026), matching the dashboard's Apps panel. The old\n top-level forms keep working as aliases, so existing scripts and\n scaffolded projects are unaffected.\n- The skill's structured-content guide now teaches \"point, don't\n paste\": name the SKU/product/collection and let GreatStore research\n the catalog itself instead of inlining fetched specs; validate with\n `gs connectors` that a connector exists for the data a prompt or\n schema depends on (research can't exceed the wired-up connectors);\n and never put shopper data in prompts \u2014 GreatStore already knows the\n shopper, and identified shoppers get per-shopper cached responses.\n\n## 0.0.27 \u2014 2026-06-11\n\n### Changed\n- Skill code samples now carry an explicit reference-only disclaimer:\n coding agents are told to re-express the logic in the host repo's\n framework (React, Vue, Shopify Liquid, Svelte, \u2026) instead of\n retrofitting the framework-free samples as-is.\n\n## 0.0.26 \u2014 2026-06-11\n\n### Added\n- The agent skill gains a \"GreatStore launchers\" recipe: a horizontally\n scrollable row of AI-generated chips, each an engaging first-person\n question about the current page that's sent to the assistant on tap.\n\n### Changed\n- Skill recipes are now one file each under `recipes/`, indexed from\n SKILL.md by a table with description and use-case columns.\n\n## 0.0.25 \u2014 2026-06-11\n\n### Added\n- `gs skill` installs the GreatStore agent skill \u2014 a guide AI coding\n agents use to build with GreatStore: AI content for your own UI, chat\n entry points, page tools, custom in-chat components, push\n notifications, and the store's MCP endpoints. Installs into\n `./.claude/skills/`; use `--global` for `~/.claude/skills/`, or\n `--dir <path>` for agents that read skills from somewhere else. Run\n it again any time to update an installed copy.\n\n## 0.0.24 \u2014 2026-06-03\n\n### Changed\n- `gs init` in an existing project now fills in any scaffold files that\n are missing (for example, the `AGENTS.md` design guide added in\n 0.0.23) and leaves your own files alone. Pass `--force` to refresh\n every scaffold file to the latest version. Your pinned store\n (`.gsrc`) is never rewritten either way.\n\n## 0.0.23 \u2014 2026-06-03\n\n### Added\n- `gs init` now scaffolds an `AGENTS.md` (with `CLAUDE.md` and\n `GEMINI.md` symlinked to it) documenting the design rules every\n component should follow \u2014 use `em` rather than `rem` for sizing, and\n style from the provided brand CSS variables so components match the\n store's theme. It doubles as guidance for AI coding agents.\n\n## 0.0.22 \u2014 2026-06-02\n\n### Added\n- `gs list` now shows a link to each component's page in the dashboard,\n so you can jump straight to a component to preview or publish it. The\n link is also included in `gs list --json`.\n\n## 0.0.21 \u2014 2026-05-31\n\n### Changed\n- The `gs init` component scaffold now shows how to write **async**\n components that load data before they render \u2014 including validating\n inputs up front and signalling a failure by throwing. The scaffolded\n component no longer includes an `onError` prop; throw from an async\n component to report a failure instead.\n\n## 0.0.20 \u2014 2026-05-29\n\n### Added\n- `gs pull`, `gs push`, and `gs publish` now accept several component\n names at once (e.g. `gs publish header footer cart`). Each component\n is reported on its own line and one failure no longer stops the rest.\n\n## 0.0.19 \u2014 2026-05-28\n\n### Fixed\n- `gs login` on Windows no longer opens a sign-in URL with parameters\n stripped, which surfaced as a \"Missing redirect_uri or state\n parameter\" page in the browser.\n\n## 0.0.18 \u2014 2026-05-28\n\n### Changed\n- Push and publish errors now name the specific reason \u2014 including\n every failing field in `manifest.json` \u2014 instead of the previous\n generic message.\n\n## 0.0.17 \u2014 2026-05-28\n\n### Added\n- Each command now prints a one-line upgrade notice when a newer\n `@greatstore/cli` is available on npm.\n\n## 0.0.16 \u2014 2026-05-28\n\n### Changed\n- Simplified error messages.\n\n## 0.0.15 \u2014 2026-05-24\n\n### Added\n- Scaffolded `component.tsx` now declares the four injected lifecycle\n props (`onSendMessage`, `onCallTool`, `onClose`, `onError`) on\n `Props`. Use `onError(message)` to report expected failures (failed\n fetch, host action rejected, invalid host state) so the AI can\n recover on its next turn. Render-time crashes are reported for you.\n\n## 0.0.14 \u2014 2026-05-24\n\n### Changed\n- `gs build` output is now whitespace-minified \u2014 typically ~50% smaller.\n\n## 0.0.13 \u2014 2026-05-24\n\n### Changed\n- `gs build` prints the next-step hint (`gs push`, then `gs publish`).\n\n## 0.0.12 \u2014 2026-05-24\n\n### Fixed\n- `gs build` failing to load Vite in some setups.\n\n## 0.0.11 \u2014 2026-05-24\n\n### Added\n- Multi-component projects. `gs init` (no args) scaffolds the project\n root; `gs init <name>` adds a component under `components/<name>/`.\n- `gs build [<name>]` \u2014 compiles every `components/<name>/bundle.js`.\n- `gs push` (no args) uploads only the components that changed.\n- `gs pull` (no args, or `*`) downloads every component. Locally\n edited components are skipped; pass `--force` to overwrite.\n- Public `CHANGELOG.md`; `gs --version` prints recent entries.\n\n### Changed\n- A project folder ships to exactly one store. Only `gs init` accepts\n `--store`; every other command reads the slug from `.gsrc`. The old\n single-component layout is rejected with a migration hint.\n- `gs init` requires `--store <slug>` for a fresh root, and rejects\n `--store` on an existing root.\n- `gs init` no longer writes `build.mjs` \u2014 scripts call `gs build`.\n\n## 0.0.10 \u2014 2026-05-23\n\n### Changed\n- The sign-in browser tab auto-closes once `gs login` finishes.\n\n## 0.0.9 \u2014 2026-05-23\n\n### Changed\n- Scaffolded manifests include a `displayName` so the admin UI has a\n friendlier label.\n\n## 0.0.8 \u2014 2026-05-23\n\n### Changed\n- `gs push` and `gs publish` print a link to view the component.\n\n## 0.0.6 \u2014 2026-05-23\n\n### Changed\n- `gs --version` reads from the published package version.\n\n## 0.0.4 \u2014 2026-05-23\n\n### Changed\n- Scaffolded projects produce browser-ready bundles out of the box.\n\n## 0.0.3 \u2014 2026-05-23\n\n### Changed\n- Trimmed public README to the essentials.\n\n## 0.0.2 \u2014 2026-05-23\n\n### Fixed\n- Sign-in callback parameter handling.\n" : "";
|
|
1650
2185
|
var HELP = `gs \u2014 GreatStore CLI (v${VERSION})
|
|
1651
2186
|
|
|
1652
2187
|
Usage:
|
|
1653
2188
|
gs <command> [args] [flags]
|
|
1654
2189
|
|
|
1655
|
-
|
|
2190
|
+
Account:
|
|
1656
2191
|
login Open browser, capture nav token, persist credentials.
|
|
1657
2192
|
logout Wipe ~/.greatstore/credentials.json.
|
|
1658
2193
|
whoami Print the identity stored locally.
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
2194
|
+
|
|
2195
|
+
Store administration:
|
|
2196
|
+
configure Show the store configuration.
|
|
2197
|
+
configure set --<field> <val> Edit it (same fields as the admin Configure panel).
|
|
2198
|
+
configure upload <kind> <file> Upload icon / logoLight / logoDark.
|
|
2199
|
+
configure clear <kind> Remove an uploaded asset.
|
|
2200
|
+
connectors List the store's MCP connectors.
|
|
2201
|
+
connectors add <name> --url \u2026 Add a connector (probed before saving; --force skips).
|
|
2202
|
+
connectors remove <name|id> Delete a connector.
|
|
2203
|
+
connectors enable|disable <n> Toggle a connector without deleting it.
|
|
2204
|
+
connectors maker on|off Toggle the Maker MCP.
|
|
2205
|
+
connectors health [<name>] Live-probe saved connectors.
|
|
2206
|
+
apps <subcommand> Author and ship custom in-chat apps (init, build,
|
|
2207
|
+
list, pull, push, publish, unpublish, delete).
|
|
2208
|
+
\`gs apps help\` lists them; each also works at the
|
|
2209
|
+
top level (\`gs push\` == \`gs apps push\`).
|
|
2210
|
+
|
|
2211
|
+
Agent tooling:
|
|
1667
2212
|
skill Install the GreatStore agent skill for AI coding agents.
|
|
1668
2213
|
|
|
1669
2214
|
Common flags:
|
|
1670
|
-
--store <slug>
|
|
1671
|
-
|
|
1672
|
-
--
|
|
1673
|
-
|
|
1674
|
-
--
|
|
1675
|
-
--
|
|
1676
|
-
--
|
|
1677
|
-
--
|
|
2215
|
+
--store <slug> Target store for admin commands (default: nearest .gsrc).
|
|
2216
|
+
For \`apps init\` it writes the slug into .gsrc.
|
|
2217
|
+
--json Machine-readable output for read commands.
|
|
2218
|
+
--draft | --live | --version N Revision selector for \`apps pull\`.
|
|
2219
|
+
-o, --out <dir> Output directory for \`apps pull\` / \`apps init\`.
|
|
2220
|
+
--manifest <path> Path to manifest for \`apps push\`.
|
|
2221
|
+
--bundle <path> Path to bundle for \`apps push\`.
|
|
2222
|
+
--force Overwrite for \`apps init\` / \`apps pull\`; skip confirmation for \`apps delete\`.
|
|
2223
|
+
--yes Skip confirmation for \`apps delete\`.
|
|
1678
2224
|
--global \`skill\` only \u2014 install to ~/.claude/skills instead of ./.claude/skills.
|
|
1679
2225
|
--dir <path> \`skill\` only \u2014 install into a custom skills directory.
|
|
1680
2226
|
-h, --help Show this help.
|
|
@@ -1685,6 +2231,16 @@ Environment:
|
|
|
1685
2231
|
GS_NAV_BASE Override nav OAuth base.
|
|
1686
2232
|
GS_BROWSER_CMD Override the browser-open command.
|
|
1687
2233
|
`;
|
|
2234
|
+
var APPS_ALIASES = /* @__PURE__ */ new Set([
|
|
2235
|
+
"init",
|
|
2236
|
+
"build",
|
|
2237
|
+
"list",
|
|
2238
|
+
"pull",
|
|
2239
|
+
"push",
|
|
2240
|
+
"publish",
|
|
2241
|
+
"unpublish",
|
|
2242
|
+
"delete"
|
|
2243
|
+
]);
|
|
1688
2244
|
async function main() {
|
|
1689
2245
|
const argv = process.argv.slice(2);
|
|
1690
2246
|
const parsed = parseArgs(argv);
|
|
@@ -1702,6 +2258,12 @@ async function main() {
|
|
|
1702
2258
|
process.stdout.write(HELP);
|
|
1703
2259
|
return 0;
|
|
1704
2260
|
}
|
|
2261
|
+
if (parsed.command !== null && APPS_ALIASES.has(parsed.command)) {
|
|
2262
|
+
return appsCommand({
|
|
2263
|
+
...parsed,
|
|
2264
|
+
positional: [parsed.command, ...parsed.positional]
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
1705
2267
|
switch (parsed.command) {
|
|
1706
2268
|
case "login":
|
|
1707
2269
|
await loginCommand();
|
|
@@ -1711,33 +2273,17 @@ async function main() {
|
|
|
1711
2273
|
return 0;
|
|
1712
2274
|
case "whoami":
|
|
1713
2275
|
return whoamiCommand();
|
|
1714
|
-
case "
|
|
1715
|
-
|
|
2276
|
+
case "apps":
|
|
2277
|
+
return appsCommand(parsed);
|
|
2278
|
+
case "configure":
|
|
2279
|
+
await configureCommand(parsed);
|
|
1716
2280
|
return 0;
|
|
1717
|
-
case "
|
|
1718
|
-
await
|
|
2281
|
+
case "connectors":
|
|
2282
|
+
await connectorsCommand(parsed);
|
|
1719
2283
|
return 0;
|
|
1720
2284
|
case "skill":
|
|
1721
2285
|
skillCommand(parsed);
|
|
1722
2286
|
return 0;
|
|
1723
|
-
case "list":
|
|
1724
|
-
await listCommand(parsed);
|
|
1725
|
-
return 0;
|
|
1726
|
-
case "pull":
|
|
1727
|
-
await pullCommand(parsed);
|
|
1728
|
-
return 0;
|
|
1729
|
-
case "push":
|
|
1730
|
-
await pushCommand(parsed);
|
|
1731
|
-
return 0;
|
|
1732
|
-
case "publish":
|
|
1733
|
-
await publishCommand(parsed);
|
|
1734
|
-
return 0;
|
|
1735
|
-
case "unpublish":
|
|
1736
|
-
await unpublishCommand(parsed);
|
|
1737
|
-
return 0;
|
|
1738
|
-
case "delete":
|
|
1739
|
-
await deleteCommand(parsed);
|
|
1740
|
-
return 0;
|
|
1741
2287
|
default:
|
|
1742
2288
|
process.stderr.write(`Unknown command: ${parsed.command}
|
|
1743
2289
|
|