@gxp-dev/tools 2.0.83 → 2.0.85

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/README.md CHANGED
@@ -93,12 +93,13 @@ It does **not** overwrite your source files (`src/`, `theme-layouts/`, etc.).
93
93
  - **Config linting** — AJV-based JSON Schema validation of `configuration.json` (form-builder definitions) and `app-manifest.json` (plugin metadata + defaults).
94
94
  - **Pre-commit hook** — `.githooks/pre-commit` runs Prettier, ESLint, and the GxP linter on staged files; configured automatically via the `prepare` npm script.
95
95
  - **Unit testing** — Vitest + `@vue/test-utils` wired out of the box; scaffolded tests via the MCP server.
96
- - **MCP server** — 33 tools for AI coding assistants (see below).
96
+ - **MCP server** — 32 tools for AI coding assistants (see below). UIKit component/story tools are served by the uikit's own Storybook MCP, auto-registered when you run `gxdev storybook`.
97
97
  - **AI scaffolding** — pre-wired configs for Claude Code, Codex, and Gemini during `init`.
98
+ - **Experience-flow demo** — `template/src/DemoExperience.vue` is an annotated kiosk flow built with `@gxp-dev/uikit`'s state-machine orchestrator: branching paths, default + inline actions, `callApi` adapter, slot overrides, and a live state inspector. Linked from `DemoPage.vue`.
98
99
 
99
100
  ## MCP Server for AI assistants
100
101
 
101
- The toolkit ships `mcp-serve` (bin `@gxp-dev/tools/mcp/mcp-serve.js`), an MCP server exposing 33 tools across seven families. It speaks MCP over stdio via the official `@modelcontextprotocol/sdk` `StdioServerTransport`. Point your AI assistant at it to get API-aware, schema-aware, test-aware help inside plugin projects:
102
+ The toolkit ships `mcp-serve` (bin `@gxp-dev/tools/mcp/mcp-serve.js`), an MCP server exposing 32 tools across six families. It speaks MCP over stdio via the official `@modelcontextprotocol/sdk` `StdioServerTransport`. Point your AI assistant at it to get API-aware, schema-aware, test-aware help inside plugin projects:
102
103
 
103
104
  | Family | Tools |
104
105
  | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -108,7 +109,8 @@ The toolkit ships `mcp-serve` (bin `@gxp-dev/tools/mcp/mcp-serve.js`), an MCP se
108
109
  | **Docs search** (3) | `docs_list_pages`, `docs_search`, `docs_get_page` — full-text across `docs.gxp.dev` via its sitemap |
109
110
  | **Test helpers** (2) | `test_scaffold_component_test`, `test_api_route` |
110
111
  | **Data models** (1) | `describe_data_models` — enumerate or detail OpenAPI `components.schemas` with $ref + allOf flattening |
111
- | **UIKit** (1) | `list_uikit_components` — list components exported by `@gxp-dev/uikit` installed in the current project |
112
+
113
+ UIKit component introspection (and story preview/run-tests) is served by the uikit itself via `@storybook/addon-mcp`. Run `gxdev storybook` from a plugin project and the addon exposes an HTTP MCP server at `http://localhost:6006/mcp` — `template/mcp.json` registers it as `gxp-uikit-storybook` alongside `gxp-api`, so AI assistants pick it up automatically when storybook is running.
112
114
 
113
115
  The previous bin name `gxp-api-server` still ships as a deprecation shim — it prints a stderr notice and forwards to the same server. Update your `.mcp.json` / `.gemini/settings.json` to use `mcp-serve` going forward.
114
116
 
@@ -121,8 +123,9 @@ After `gxdev init`:
121
123
  ```
122
124
  my-plugin/
123
125
  ├── src/
124
- │ ├── Plugin.vue # App entry point
125
- │ ├── DemoPage.vue # Example component
126
+ │ ├── Plugin.vue # App entry point + lightweight in-app routing
127
+ │ ├── DemoPage.vue # Example component (gxp-* directives, sockets, store)
128
+ │ ├── DemoExperience.vue # Annotated kiosk flow using @gxp-dev/uikit experience-flow
126
129
  │ ├── public/ # Static assets (served at /src/public/*)
127
130
  │ └── stores/
128
131
  │ └── index.js # Pinia store setup
package/bin/lib/cli.js CHANGED
@@ -25,6 +25,7 @@ const {
25
25
  extractConfigCommand,
26
26
  addDependencyCommand,
27
27
  lintCommand,
28
+ storybookCommand,
28
29
  } = require("./commands")
29
30
 
30
31
  // Load global configuration
@@ -350,6 +351,19 @@ const cli = yargs
350
351
  },
351
352
  lintCommand,
352
353
  )
354
+ .command(
355
+ "storybook",
356
+ "Run @gxp-dev/uikit Storybook from this project (port 6006)",
357
+ {
358
+ build: {
359
+ describe:
360
+ "Build a static Storybook bundle instead of starting the dev server",
361
+ type: "boolean",
362
+ default: false,
363
+ },
364
+ },
365
+ storybookCommand,
366
+ )
353
367
  .command(
354
368
  "add-dependency",
355
369
  "Add an API dependency to app-manifest.json via interactive wizard",
@@ -21,6 +21,7 @@ const {
21
21
  const { extractConfigCommand } = require("./extract-config")
22
22
  const { addDependencyCommand } = require("./add-dependency")
23
23
  const { lintCommand } = require("./lint")
24
+ const { storybookCommand } = require("./storybook")
24
25
 
25
26
  module.exports = {
26
27
  initCommand,
@@ -38,4 +39,5 @@ module.exports = {
38
39
  extractConfigCommand,
39
40
  addDependencyCommand,
40
41
  lintCommand,
42
+ storybookCommand,
41
43
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Storybook Command
3
+ *
4
+ * Runs the @gxp-dev/uikit Storybook instance from the plugin project. The
5
+ * uikit ships its own storybook config + @storybook/addon-mcp but declares
6
+ * the storybook ecosystem as *optional* peerDependencies, so plugin projects
7
+ * stay lean by default. This command resolves the installed uikit, detects
8
+ * any missing storybook peers, prompts to install them in the plugin
9
+ * project's node_modules, then delegates to the uikit's storybook script.
10
+ *
11
+ * When `storybook dev` is running, the addon-mcp also exposes an HTTP MCP
12
+ * server at http://localhost:6006/mcp — the template's mcp.json registers it
13
+ * as a second MCP server alongside the toolkit's stdio one.
14
+ */
15
+
16
+ const path = require("path")
17
+ const fs = require("fs")
18
+ const shell = require("shelljs")
19
+ const readline = require("readline")
20
+ const { findProjectRoot } = require("../utils")
21
+ const { resolveUikit } = require("../utils/uikit")
22
+
23
+ const STORYBOOK_PEERS = [
24
+ "storybook",
25
+ "@storybook/vue3-vite",
26
+ "@storybook/addon-mcp",
27
+ "@storybook/addon-a11y",
28
+ "@storybook/addon-themes",
29
+ ]
30
+
31
+ function isInstalled(pkgName, fromDir) {
32
+ let dir = path.resolve(fromDir)
33
+ while (dir) {
34
+ const candidate = path.join(dir, "node_modules", pkgName, "package.json")
35
+ if (fs.existsSync(candidate)) return true
36
+ const parent = path.dirname(dir)
37
+ if (parent === dir) break
38
+ dir = parent
39
+ }
40
+ return false
41
+ }
42
+
43
+ function findMissingPeers(uikitRoot, pkg) {
44
+ const peerSpecs = (pkg && pkg.peerDependencies) || {}
45
+ return STORYBOOK_PEERS.filter((name) => {
46
+ if (!peerSpecs[name]) return false
47
+ return !isInstalled(name, uikitRoot)
48
+ }).map((name) => `${name}@${peerSpecs[name]}`)
49
+ }
50
+
51
+ async function confirm(question) {
52
+ return new Promise((resolve) => {
53
+ const rl = readline.createInterface({
54
+ input: process.stdin,
55
+ output: process.stdout,
56
+ })
57
+ rl.question(`${question} (Y/n) `, (answer) => {
58
+ rl.close()
59
+ const trimmed = (answer || "").trim().toLowerCase()
60
+ resolve(trimmed === "" || trimmed === "y" || trimmed === "yes")
61
+ })
62
+ })
63
+ }
64
+
65
+ async function storybookCommand(argv) {
66
+ const resolved = resolveUikit(process.cwd())
67
+ if (!resolved) {
68
+ console.error(
69
+ "❌ Could not find @gxp-dev/uikit in this project's node_modules.",
70
+ )
71
+ console.log("💡 Install it with: npm install @gxp-dev/uikit")
72
+ process.exit(1)
73
+ }
74
+
75
+ const { root: uikitRoot, pkg } = resolved
76
+ const wantsBuild = Boolean(argv.build)
77
+ const scriptName = wantsBuild ? "storybook:build" : "storybook"
78
+ const script = pkg.scripts && pkg.scripts[scriptName]
79
+ const storybookConfig = path.join(uikitRoot, ".storybook")
80
+
81
+ if (!script || !fs.existsSync(storybookConfig)) {
82
+ console.error(
83
+ `❌ @gxp-dev/uikit@${pkg.version || "?"} at ${uikitRoot} does not ship a Storybook setup.`,
84
+ )
85
+ console.log(
86
+ "💡 Upgrade the uikit to a version whose `files` field includes `.storybook` and `src`.",
87
+ )
88
+ process.exit(1)
89
+ }
90
+
91
+ const missing = findMissingPeers(uikitRoot, pkg)
92
+ if (missing.length > 0) {
93
+ console.log("📦 Storybook tooling is not installed yet.")
94
+ console.log(" The uikit lists these as optional peerDependencies:")
95
+ for (const spec of missing) {
96
+ console.log(` • ${spec}`)
97
+ }
98
+ const proceed = await confirm(
99
+ " Install them now in this project (npm install --save-dev)?",
100
+ )
101
+ if (!proceed) {
102
+ console.log(
103
+ "⏭ Skipped. Install manually and re-run: npm install --save-dev " +
104
+ missing.join(" "),
105
+ )
106
+ process.exit(1)
107
+ }
108
+
109
+ const projectRoot = findProjectRoot()
110
+ const installCmd = `npm install --save-dev --no-fund --no-audit ${missing.map((m) => `"${m}"`).join(" ")}`
111
+ console.log(`▶ ${installCmd}`)
112
+ const installResult = shell.exec(installCmd, { cwd: projectRoot })
113
+ if (installResult.code !== 0) {
114
+ console.error("❌ Install failed. Resolve the errors and try again.")
115
+ process.exit(installResult.code)
116
+ }
117
+ }
118
+
119
+ const label = wantsBuild
120
+ ? "📚 Building Storybook from @gxp-dev/uikit..."
121
+ : "📚 Starting Storybook from @gxp-dev/uikit (http://localhost:6006)"
122
+ console.log(label)
123
+ console.log(`📁 UIKit path: ${uikitRoot}`)
124
+
125
+ const result = shell.exec(`npm run ${scriptName}`, { cwd: uikitRoot })
126
+ if (result.code !== 0) {
127
+ process.exit(result.code)
128
+ }
129
+ }
130
+
131
+ module.exports = { storybookCommand }
@@ -48,6 +48,8 @@ const DEFAULT_SCRIPTS = {
48
48
  "datastore:add": "gxdev datastore add",
49
49
  "datastore:scan": "gxdev datastore scan-strings",
50
50
  "datastore:config": "gxdev datastore config",
51
+ storybook: "gxdev storybook",
52
+ "storybook:build": "gxdev storybook --build",
51
53
  }
52
54
 
53
55
  // Default ports
@@ -997,7 +997,7 @@ function buildInteractiveInitialPrompt(projectName, description, provider) {
997
997
  "- **Docs search** — `docs_search`, `docs_get_page`, `docs_list_pages` (full-text search across docs.gxp.dev).",
998
998
  "- **Test helpers** — `test_scaffold_component_test`, `test_api_route`.",
999
999
  "- **Data models** — `describe_data_models` (enumerate or detail OpenAPI components.schemas; walks allOf and resolves $ref by name).",
1000
- "- **UIKit** — `list_uikit_components` (list components exported by `@gxp-dev/uikit` installed in this project).",
1000
+ "- **UIKit (via Storybook MCP)** — when `npm run storybook` is running, the uikit's `@storybook/addon-mcp` exposes `preview-stories`, `get-storybook-story-instructions`, `get-documentation`, `list-all-documentation`, and `run-story-tests` at http://localhost:6006/mcp (registered in mcp.json as `gxp-uikit-storybook`). Use these to discover available components and stories.",
1001
1001
  "",
1002
1002
  "Follow the full workflow from the instructions: (1) understand the feature, (2) discover data sources via MCP, (3) plan including the admin configuration form, (4) implement, (5) **sync the manifest and build the admin form**, (6) test with real broadcasts, (7) final `gxdev lint --all`.",
1003
1003
  "",
@@ -11,6 +11,7 @@ const prompts = require("./prompts")
11
11
  const aiScaffold = require("./ai-scaffold")
12
12
  const extractConfig = require("./extract-config")
13
13
  const versionCheck = require("./version-check")
14
+ const uikit = require("./uikit")
14
15
 
15
16
  module.exports = {
16
17
  ...paths,
@@ -20,4 +21,5 @@ module.exports = {
20
21
  ...aiScaffold,
21
22
  ...extractConfig,
22
23
  ...versionCheck,
24
+ ...uikit,
23
25
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * UIKit resolution helpers.
3
+ *
4
+ * Resolves `@gxp-dev/uikit` from a plugin project's node_modules by walking
5
+ * up the directory tree (works with npm, pnpm, yarn workspaces). We mimic the
6
+ * walk-up rather than calling require.resolve("@gxp-dev/uikit/...") because
7
+ * the uikit's package.json declares a strict `exports` field with only
8
+ * `import` + `types` conditions; from a CJS context require.resolve fails
9
+ * against that exports map even though the directory exists on disk.
10
+ */
11
+
12
+ const fs = require("fs")
13
+ const path = require("path")
14
+
15
+ /**
16
+ * @returns {{ root: string, pkg: object } | null}
17
+ */
18
+ function resolveUikit(cwd = process.cwd()) {
19
+ let dir = path.resolve(cwd)
20
+ while (dir) {
21
+ const candidate = path.join(dir, "node_modules", "@gxp-dev", "uikit")
22
+ const pkgPath = path.join(candidate, "package.json")
23
+ if (fs.existsSync(pkgPath)) {
24
+ try {
25
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
26
+ if (pkg && pkg.name === "@gxp-dev/uikit") {
27
+ return { root: candidate, pkg }
28
+ }
29
+ } catch {
30
+ // malformed package.json — keep walking
31
+ }
32
+ }
33
+ const parent = path.dirname(dir)
34
+ if (parent === dir) break
35
+ dir = parent
36
+ }
37
+ return null
38
+ }
39
+
40
+ module.exports = { resolveUikit }
package/mcp/lib/server.js CHANGED
@@ -13,7 +13,12 @@
13
13
  * - Docs tools (docs-tools.js)
14
14
  * - Test tools (test-tools.js)
15
15
  * - Model tools (model-tools.js)
16
- * - UIKit tools (uikit-tools.js)
16
+ *
17
+ * UIKit component introspection has moved out of this server. The uikit
18
+ * ships @storybook/addon-mcp; when developers run `gxdev storybook` the
19
+ * uikit's Storybook exposes its own HTTP MCP server at
20
+ * http://localhost:6006/mcp, registered as `gxp-uikit-storybook` in the
21
+ * plugin project's mcp.json.
17
22
  */
18
23
 
19
24
  const { fetchSpec, getEnvironment, getEnvUrls } = require("./specs")
@@ -34,11 +39,6 @@ const {
34
39
  handleModelToolCall,
35
40
  isModelTool,
36
41
  } = require("./model-tools")
37
- const {
38
- UIKIT_TOOLS,
39
- handleUikitToolCall,
40
- isUikitTool,
41
- } = require("./uikit-tools")
42
42
 
43
43
  const SERVER_INFO = {
44
44
  name: "gxp-mcp-serve",
@@ -46,7 +46,7 @@ const SERVER_INFO = {
46
46
  }
47
47
 
48
48
  const SERVER_DESCRIPTION =
49
- "GxP toolkit MCP server: API specs, data models, UIKit components, config/manifest editing, documentation search, and plugin test helpers for AI coding assistants."
49
+ "GxP toolkit MCP server: API specs, data models, config/manifest editing, documentation search, and plugin test helpers for AI coding assistants. UIKit component/story tools are served by the uikit's own Storybook MCP (gxdev storybook)."
50
50
 
51
51
  /* -------------------- API spec search helpers (in-file) ------------------- */
52
52
 
@@ -247,7 +247,6 @@ const TOOLS = [
247
247
  ...DOCS_TOOLS,
248
248
  ...TEST_TOOLS,
249
249
  ...MODEL_TOOLS,
250
- ...UIKIT_TOOLS,
251
250
  ]
252
251
 
253
252
  /* ------------------------------ tool dispatch ----------------------------- */
@@ -258,7 +257,6 @@ async function handleToolCall(name, args = {}) {
258
257
  if (isDocsTool(name)) return handleDocsToolCall(name, args)
259
258
  if (isTestTool(name)) return handleTestToolCall(name, args)
260
259
  if (isModelTool(name)) return handleModelToolCall(name, args)
261
- if (isUikitTool(name)) return handleUikitToolCall(name, args)
262
260
 
263
261
  switch (name) {
264
262
  case "get_openapi_spec": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gxp-dev/tools",
3
- "version": "2.0.83",
3
+ "version": "2.0.85",
4
4
  "description": "Dev tools to create platform plugins",
5
5
  "type": "commonjs",
6
6
  "publishConfig": {
@@ -58,11 +58,13 @@
58
58
  "dependencies": {
59
59
  "@faker-js/faker": "^9.9.0",
60
60
  "@fal-works/esbuild-plugin-global-externals": "^2.1.2",
61
+ "@gxp-dev/uikit": "^0.1.0",
61
62
  "@modelcontextprotocol/sdk": "^1.29.0",
62
63
  "@vitejs/plugin-vue": "^6.0.6",
63
64
  "adm-zip": "^0.5.17",
64
65
  "ajv": "^8.18.0",
65
66
  "ajv-formats": "^3.0.1",
67
+ "axios": "^1.16.0",
66
68
  "chrome-launcher": "^1.2.1",
67
69
  "concurrently": "^9.2.1",
68
70
  "cors": "^2.8.6",
package/template/mcp.json CHANGED
@@ -3,6 +3,10 @@
3
3
  "gxp-api": {
4
4
  "command": "mcp-serve",
5
5
  "args": []
6
+ },
7
+ "gxp-uikit-storybook": {
8
+ "type": "http",
9
+ "url": "http://localhost:6006/mcp"
6
10
  }
7
11
  }
8
12
  }
@@ -0,0 +1,660 @@
1
+ <!--
2
+ DemoExperience.vue — A teaching demo for @gxp-dev/uikit's experience-flow system.
3
+
4
+ WHAT IT IS
5
+ ──────────
6
+ A state-machine orchestrator + a set of prebuilt page components for building
7
+ multi-stage interactive apps (kiosks, photo booths, AI flows, check-ins).
8
+
9
+ The whole flow below is driven by ONE configuration object passed to
10
+ `useExperience`. Pages emit data; the flow stores it in a reactive `context`;
11
+ optional `action`s run between pages to call APIs or do work.
12
+
13
+ WHAT THIS DEMO SHOWS
14
+ ────────────────────
15
+ 1. `useExperience({ pages, … })` — the orchestrator composable
16
+ 2. `useExperienceApi({ callApi })` — adapter that maps named ops to `callApi`
17
+ 3. `<ExperienceFlow :flow />` — renderer with built-in loading / error UI
18
+ 4. Prebuilt pages (Welcome, Terms, Options, Camera, CameraReview, Drawing,
19
+ Notepad, Loading, Final) with their default slots overridden
20
+ 5. Branching paths via `when: (ctx) => …`
21
+ 6. Async actions in three forms: named, inline function, and disabled
22
+ 7. Live state side-panel — current page, busy/error refs, context dump
23
+ 8. Reset, replay, jump-to-page controls — to make the state model visible
24
+
25
+ PRODUCTION USE
26
+ ──────────────
27
+ In a real plugin, `callApi` comes from `gxpStore.callApi(operationId, perm, data)`.
28
+ The uikit's adapter expects `(endpoint, payload) => Promise<T>` shape — we
29
+ bridge that below with a tiny lambda. Replace the mock endpoints with your
30
+ real operationIds and you're done.
31
+ -->
32
+
33
+ <template>
34
+ <div class="demo-experience-page">
35
+ <!-- Side panel with live flow state — great for understanding what's happening -->
36
+ <aside class="state-panel">
37
+ <header class="state-panel__header">
38
+ <button class="back-link" @click="$emit('navigate', 'home')">
39
+ ← Back to Demo
40
+ </button>
41
+ <h2>Live Flow State</h2>
42
+ </header>
43
+
44
+ <section class="state-panel__section">
45
+ <h3>Current page</h3>
46
+ <code class="state-panel__value">{{ flow.pageName.value ?? "—" }}</code>
47
+ <div class="state-panel__meta">
48
+ index {{ flow.index.value }} / {{ flow.pages.value.length - 1 }} ·
49
+ <span :class="{ pill: true, 'pill--on': flow.busy.value }">
50
+ busy: {{ flow.busy.value }}
51
+ </span>
52
+ ·
53
+ <span :class="{ pill: true, 'pill--err': flow.error.value }">
54
+ error: {{ flow.error.value?.message ?? "null" }}
55
+ </span>
56
+ </div>
57
+ </section>
58
+
59
+ <section class="state-panel__section">
60
+ <h3>Context (reactive)</h3>
61
+ <pre class="state-panel__dump">{{ contextSummary }}</pre>
62
+ </section>
63
+
64
+ <section class="state-panel__section">
65
+ <h3>Controls</h3>
66
+ <div class="state-panel__controls">
67
+ <button
68
+ class="ctrl"
69
+ @click="flow.back()"
70
+ :disabled="flow.isFirst.value"
71
+ >
72
+ ← Back
73
+ </button>
74
+ <button class="ctrl" @click="flow.reset()">↺ Reset</button>
75
+ <button class="ctrl" @click="forceError">Force error</button>
76
+ </div>
77
+ <div class="state-panel__controls">
78
+ <button
79
+ v-for="p in flow.pages.value"
80
+ :key="p.name"
81
+ class="ctrl ctrl--sm"
82
+ :class="{ 'ctrl--active': flow.pageName.value === p.name }"
83
+ @click="safeGoTo(p.name)"
84
+ :title="`Jump to '${p.name}'`"
85
+ >
86
+ {{ p.name }}
87
+ </button>
88
+ </div>
89
+ </section>
90
+
91
+ <section class="state-panel__section">
92
+ <h3>Tips</h3>
93
+ <ul class="state-panel__tips">
94
+ <li>
95
+ Branching is via <code>when: (ctx) =&gt; …</code> on each page def.
96
+ </li>
97
+ <li>
98
+ Actions can be a string (named op), an inline function, or
99
+ <code>false</code> to disable.
100
+ </li>
101
+ <li>
102
+ The QR + final page show how to read back from
103
+ <code>flow.context</code>.
104
+ </li>
105
+ </ul>
106
+ </section>
107
+ </aside>
108
+
109
+ <!-- The renderer. Slots forward through to the current page's slots. -->
110
+ <main class="flow-area">
111
+ <ExperienceFlow :flow="flow">
112
+ <!--
113
+ Custom loading + error UI — replaces the default overlays.
114
+ These slots receive scoped props: `loading` gets nothing, `error`
115
+ gets `{ error, retry }`.
116
+ -->
117
+ <template #loading>
118
+ <div class="custom-loading">
119
+ <div class="custom-loading__spinner" />
120
+ <p>Talking to the platform…</p>
121
+ </div>
122
+ </template>
123
+
124
+ <template #error="{ error, retry }">
125
+ <div class="custom-error">
126
+ <h3>Something went wrong</h3>
127
+ <p>{{ error.message }}</p>
128
+ <button class="custom-error__btn" @click="retry">Dismiss</button>
129
+ </div>
130
+ </template>
131
+ </ExperienceFlow>
132
+ </main>
133
+ </div>
134
+ </template>
135
+
136
+ <script setup>
137
+ import { computed, reactive } from "vue"
138
+ import {
139
+ useExperience,
140
+ useExperienceApi,
141
+ withExperienceDefaults,
142
+ ExperienceFlow,
143
+ WelcomePage,
144
+ TermsPage,
145
+ OptionsPage,
146
+ CameraPage,
147
+ CameraReviewPage,
148
+ DrawingPage,
149
+ NotepadPage,
150
+ LoadingPage,
151
+ FinalPage,
152
+ } from "@gxp-dev/uikit"
153
+ // The uikit ships Tailwind utilities + theme tokens used by every page below.
154
+ // Imported here so the styles only load when the experience demo opens.
155
+ import "@gxp-dev/uikit/styles"
156
+ import { useGxpStore } from "@/stores/gxpPortalConfigStore"
157
+
158
+ defineEmits(["navigate"])
159
+
160
+ const gxpStore = useGxpStore()
161
+
162
+ /* ─── Step 1: bridge gxpStore.callApi to the uikit's callApi shape ──────────
163
+ *
164
+ * uikit expects: (endpoint, payload) => Promise<T>
165
+ * gxpStore offers: (operationId, permissionIdentifier, data) => Promise<T>
166
+ *
167
+ * For the demo we mock the network so it works offline. In production replace
168
+ * the mock with the real `gxpStore.callApi(endpoint, '', payload)` line below.
169
+ */
170
+ const MOCK_LATENCY_MS = 800
171
+
172
+ async function mockCallApi(endpoint, payload) {
173
+ console.log("[demo] callApi", endpoint, payload)
174
+ await new Promise((r) => setTimeout(r, MOCK_LATENCY_MS))
175
+
176
+ // Simulate the {data: …} envelope the real platform returns.
177
+ if (endpoint === "social_stream.createPost") {
178
+ return {
179
+ data: {
180
+ id: Math.floor(Math.random() * 10_000),
181
+ file_url:
182
+ payload instanceof FormData
183
+ ? URL.createObjectURL(payload.get("blob"))
184
+ : "https://placehold.co/600x400?text=Demo+Post",
185
+ caption: payload?.caption ?? null,
186
+ },
187
+ }
188
+ }
189
+ return { data: { ok: true } }
190
+ }
191
+
192
+ // Real wiring would look like this:
193
+ // const callApi = (endpoint, payload) => gxpStore.callApi(endpoint, "", payload)
194
+ const callApi = mockCallApi
195
+
196
+ /* ─── Step 2: build the API adapter ────────────────────────────────────────
197
+ *
198
+ * useExperienceApi turns `callApi` into typed named operations:
199
+ * api.publishPost(data) → callApi('social_stream.createPost', data)
200
+ * api.createPrintJob({…})
201
+ * api.processImage({blob, prompt})
202
+ * etc.
203
+ *
204
+ * You can override specific operations or remap endpoints — useful when your
205
+ * backend uses different operationIds than the defaults.
206
+ */
207
+ const api = useExperienceApi({
208
+ callApi,
209
+
210
+ // Per-op override example: replace the whole publishPost impl.
211
+ // overrides: {
212
+ // publishPost: async (data) => ({ id: 1, file_url: '...' }),
213
+ // },
214
+
215
+ // Endpoint remap example: keep default behavior but call a different op.
216
+ // endpoints: {
217
+ // publishPost: 'legacy.posts.create',
218
+ // },
219
+ })
220
+
221
+ /* ─── Step 3: custom-tag a page with defaults ───────────────────────────────
222
+ *
223
+ * `withExperienceDefaults` attaches a default `action` + `resultKey` to a page
224
+ * component, so the flow knows what to do when that page emits `next(data)`.
225
+ *
226
+ * Built-in pages already declare their defaults. For your own pages, wrap them.
227
+ */
228
+ const TaggedNotepad = withExperienceDefaults(NotepadPage, {
229
+ action: async (payload) => {
230
+ // Inline function action — perfect for one-off work or composing API calls.
231
+ console.log("[demo] note submitted:", payload.caption)
232
+ return { savedAt: Date.now(), caption: payload.caption }
233
+ },
234
+ resultKey: "note",
235
+ })
236
+
237
+ /* ─── Step 4: define the flow ──────────────────────────────────────────────
238
+ *
239
+ * `pages` is an ordered array. Each entry has:
240
+ * - name: string key (used by goTo and as default resultKey)
241
+ * - component: any Vue component (built-in or your own)
242
+ * - props: passed straight through to the page
243
+ * - when: (ctx) => boolean — skip the page when this returns false
244
+ * - action: 'publishPost' | (data, ctx, api) => Promise<T> | false
245
+ * - resultKey: where to stash the action's return value in `flow.context`
246
+ *
247
+ * Three action shapes are demonstrated below.
248
+ */
249
+ const flow = useExperience({
250
+ api,
251
+
252
+ // Seed values on the context so pages can read from them.
253
+ initialContext: {
254
+ brand: "Acme",
255
+ attendeeName: gxpStore.getSetting("company_name") || "Friend",
256
+ },
257
+
258
+ pages: [
259
+ // ── Intro ────────────────────────────────────────────────────────────
260
+ {
261
+ name: "welcome",
262
+ component: WelcomePage,
263
+ props: {
264
+ title: "Welcome to the Demo Kiosk",
265
+ subtitle: "A guided tour of the experience-flow system",
266
+ ctaText: "Let's begin →",
267
+ },
268
+ },
269
+
270
+ {
271
+ name: "terms",
272
+ component: TermsPage,
273
+ // Skip terms entirely if the user already accepted them in a prior run.
274
+ when: (ctx) => !ctx.termsAccepted,
275
+ props: {
276
+ title: "Quick consent",
277
+ html: `
278
+ <p>By tapping <strong>Accept</strong> you agree this is a demo and
279
+ no real data leaves your browser.</p>
280
+ <p>Hit <em>Reset</em> in the side panel to start over at any time.</p>
281
+ `,
282
+ checkboxLabel: "I'm in",
283
+ },
284
+ // No action — TermsPage emits { accepted: true } and the flow stashes
285
+ // it under resultKey 'termsAccepted' (its built-in default).
286
+ },
287
+
288
+ // ── Path picker (branching) ──────────────────────────────────────────
289
+ {
290
+ name: "pick",
291
+ component: OptionsPage,
292
+ props: {
293
+ title: "Pick how you'd like to participate",
294
+ subtitle: "We'll show a different flow for each",
295
+ options: [
296
+ { key: "photo", label: "📷 Take a photo" },
297
+ { key: "drawing", label: "🎨 Draw something" },
298
+ { key: "note", label: "✍️ Leave a note" },
299
+ ],
300
+ emitKeyOnly: true, // stash just the key string in ctx.choice
301
+ },
302
+ },
303
+
304
+ // ── Photo branch ─────────────────────────────────────────────────────
305
+ {
306
+ name: "capture",
307
+ component: CameraPage,
308
+ when: (ctx) => ctx.choice === "photo",
309
+ props: {
310
+ countdown: 3,
311
+ mirrored: true,
312
+ },
313
+ // CameraPage emits next(blob: Blob) → ctx.photoBlob (its default resultKey)
314
+ },
315
+ {
316
+ name: "review",
317
+ component: CameraReviewPage,
318
+ when: (ctx) => ctx.choice === "photo",
319
+ props: { allowCaption: true },
320
+ // ⚡ This page's built-in default action is 'publishPost' — i.e. it
321
+ // looks up api.publishPost and calls it with the emitted payload. The
322
+ // result is stashed in ctx.post. No wiring needed here.
323
+ },
324
+
325
+ // ── Drawing branch ───────────────────────────────────────────────────
326
+ {
327
+ name: "draw",
328
+ component: DrawingPage,
329
+ when: (ctx) => ctx.choice === "drawing",
330
+ props: {
331
+ penColors: ["#000000", "#dc2626", "#2563eb", "#16a34a", "#facc15"],
332
+ backgroundColors: ["#ffffff", "#fef3c7", "#dbeafe"],
333
+ submitLabel: "Submit drawing",
334
+ },
335
+ // Inline function action that uploads the rendered blob.
336
+ action: async (blob, _ctx, apiArg) => {
337
+ return await apiArg.publishPost({ blob, caption: "From the easel" })
338
+ },
339
+ resultKey: "post",
340
+ },
341
+
342
+ // ── Note branch (uses our pre-tagged version) ────────────────────────
343
+ {
344
+ name: "note",
345
+ component: TaggedNotepad,
346
+ when: (ctx) => ctx.choice === "note",
347
+ props: { title: "Write your message", maxCharacters: 140 },
348
+ },
349
+
350
+ // ── Async wait (only for paths that produced media) ──────────────────
351
+ {
352
+ name: "saving",
353
+ component: LoadingPage,
354
+ // Only show this for paths where we produced a post; the 'note' path
355
+ // already finished its custom action.
356
+ when: (ctx) => Boolean(ctx.post),
357
+ props: {
358
+ messages: [
359
+ "Hanging your masterpiece in the gallery…",
360
+ "Almost there…",
361
+ "Polishing the frame…",
362
+ ],
363
+ messageInterval: 1.4,
364
+ // The LoadingPage's `task` runs as soon as the page mounts. Resolves
365
+ // → next(result); rejects → flow.error. Great for polling.
366
+ task: async (ctx) => {
367
+ await new Promise((r) => setTimeout(r, 1800))
368
+ return { hangedAt: Date.now(), postId: ctx.post?.id }
369
+ },
370
+ },
371
+ },
372
+
373
+ // ── Outcome ─────────────────────────────────────────────────────────
374
+ {
375
+ name: "final",
376
+ component: FinalPage,
377
+ props: {
378
+ title: "✨ All done!",
379
+ qrCaption: "Scan to take it home",
380
+ qrUrl: "https://placehold.co/200x200/000/fff?text=QR",
381
+ actions: [
382
+ { key: "email", label: "Email me", variant: "primary" },
383
+ { key: "download", label: "Download", variant: "secondary" },
384
+ ],
385
+ showRestart: true,
386
+ },
387
+ // FinalPage emits `next` (restart) and `action(key)`. The flow's
388
+ // `onComplete` (below) fires when we step past the last visible page.
389
+ },
390
+ ],
391
+
392
+ // Fires when next() is called on the last visible page. Great place to
393
+ // analytics-log or kick off a post-flow handoff.
394
+ onComplete: (ctx) => {
395
+ console.log("[demo] flow complete; final context:", { ...ctx })
396
+ // Restart from the top so the kiosk is ready for the next user.
397
+ flow.reset()
398
+ },
399
+
400
+ // Optional global error trap. Return `true` to swallow so the flow stays
401
+ // on the current page (otherwise the error appears in the error overlay).
402
+ onError: (err, ctx) => {
403
+ console.warn("[demo] action threw:", err, { ctx: { ...ctx } })
404
+ // Return nothing → show the error overlay.
405
+ },
406
+ })
407
+
408
+ /* ─── Side-panel helpers ───────────────────────────────────────────────── */
409
+
410
+ const reactiveCtx = reactive(flow.context)
411
+
412
+ const contextSummary = computed(() => {
413
+ const snapshot = {}
414
+ for (const [k, v] of Object.entries(reactiveCtx)) {
415
+ if (v instanceof Blob) {
416
+ snapshot[k] = `Blob<${v.type || "binary"}, ${v.size}B>`
417
+ } else if (v && typeof v === "object") {
418
+ snapshot[k] = v
419
+ } else {
420
+ snapshot[k] = v
421
+ }
422
+ }
423
+ return JSON.stringify(snapshot, null, 2)
424
+ })
425
+
426
+ async function safeGoTo(name) {
427
+ try {
428
+ await flow.goTo(name)
429
+ } catch (err) {
430
+ // goTo throws if the target's `when` predicate is currently false.
431
+ console.warn(err.message)
432
+ }
433
+ }
434
+
435
+ function forceError() {
436
+ // Tap into the same surface a thrown action would. Useful for previewing
437
+ // the custom #error slot.
438
+ flow.error.value = new Error("Demo error — dismiss to continue")
439
+ }
440
+ </script>
441
+
442
+ <style scoped>
443
+ .demo-experience-page {
444
+ display: grid;
445
+ grid-template-columns: 320px 1fr;
446
+ height: 100vh;
447
+ min-height: 600px;
448
+ background: #0f172a;
449
+ color: #e2e8f0;
450
+ font-family:
451
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
452
+ }
453
+
454
+ /* ─── State panel ─────────────────────────────────────────────────────── */
455
+ .state-panel {
456
+ background: #111827;
457
+ border-right: 1px solid #1f2937;
458
+ padding: 20px;
459
+ overflow-y: auto;
460
+ }
461
+
462
+ .state-panel__header {
463
+ margin-bottom: 20px;
464
+ }
465
+
466
+ .back-link {
467
+ background: none;
468
+ border: 0;
469
+ color: #93c5fd;
470
+ font-size: 13px;
471
+ cursor: pointer;
472
+ padding: 0;
473
+ margin-bottom: 12px;
474
+ }
475
+ .back-link:hover {
476
+ color: #bfdbfe;
477
+ }
478
+
479
+ .state-panel h2 {
480
+ margin: 0;
481
+ font-size: 16px;
482
+ color: #f1f5f9;
483
+ }
484
+
485
+ .state-panel__section {
486
+ margin-top: 20px;
487
+ padding-top: 16px;
488
+ border-top: 1px solid #1f2937;
489
+ }
490
+ .state-panel__section h3 {
491
+ margin: 0 0 8px 0;
492
+ font-size: 11px;
493
+ font-weight: 600;
494
+ text-transform: uppercase;
495
+ letter-spacing: 0.06em;
496
+ color: #94a3b8;
497
+ }
498
+
499
+ .state-panel__value {
500
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
501
+ font-size: 14px;
502
+ color: #fbbf24;
503
+ }
504
+
505
+ .state-panel__meta {
506
+ margin-top: 6px;
507
+ font-size: 11px;
508
+ color: #64748b;
509
+ line-height: 1.6;
510
+ }
511
+
512
+ .pill {
513
+ display: inline-block;
514
+ padding: 1px 6px;
515
+ border-radius: 4px;
516
+ background: #1e293b;
517
+ color: #cbd5e1;
518
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
519
+ }
520
+ .pill--on {
521
+ background: #1e3a8a;
522
+ color: #bfdbfe;
523
+ }
524
+ .pill--err {
525
+ background: #7f1d1d;
526
+ color: #fecaca;
527
+ }
528
+
529
+ .state-panel__dump {
530
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
531
+ font-size: 11px;
532
+ line-height: 1.5;
533
+ color: #cbd5e1;
534
+ background: #0b1220;
535
+ padding: 10px;
536
+ border-radius: 6px;
537
+ border: 1px solid #1f2937;
538
+ max-height: 220px;
539
+ overflow: auto;
540
+ white-space: pre-wrap;
541
+ margin: 0;
542
+ }
543
+
544
+ .state-panel__controls {
545
+ display: flex;
546
+ flex-wrap: wrap;
547
+ gap: 6px;
548
+ margin-top: 8px;
549
+ }
550
+ .ctrl {
551
+ font-size: 12px;
552
+ background: #1f2937;
553
+ color: #e2e8f0;
554
+ border: 1px solid #334155;
555
+ border-radius: 5px;
556
+ padding: 5px 9px;
557
+ cursor: pointer;
558
+ }
559
+ .ctrl:hover {
560
+ background: #374151;
561
+ }
562
+ .ctrl:disabled {
563
+ opacity: 0.4;
564
+ cursor: not-allowed;
565
+ }
566
+ .ctrl--sm {
567
+ font-size: 11px;
568
+ padding: 3px 7px;
569
+ }
570
+ .ctrl--active {
571
+ background: #1e40af;
572
+ border-color: #3b82f6;
573
+ color: #dbeafe;
574
+ }
575
+
576
+ .state-panel__tips {
577
+ font-size: 12px;
578
+ color: #94a3b8;
579
+ padding-left: 18px;
580
+ margin: 0;
581
+ line-height: 1.6;
582
+ }
583
+ .state-panel__tips code {
584
+ background: #0b1220;
585
+ padding: 1px 5px;
586
+ border-radius: 3px;
587
+ font-size: 11px;
588
+ color: #fbbf24;
589
+ }
590
+
591
+ /* ─── Flow area ──────────────────────────────────────────────────────── */
592
+ .flow-area {
593
+ background: var(--background, #f8fafc);
594
+ color: var(--foreground, #0f172a);
595
+ position: relative;
596
+ overflow: hidden;
597
+ }
598
+
599
+ /* ─── Custom loading / error overrides ───────────────────────────────── */
600
+ .custom-loading {
601
+ display: flex;
602
+ flex-direction: column;
603
+ align-items: center;
604
+ gap: 14px;
605
+ color: #1e40af;
606
+ font-weight: 500;
607
+ }
608
+ .custom-loading__spinner {
609
+ width: 48px;
610
+ height: 48px;
611
+ border: 4px solid #93c5fd;
612
+ border-right-color: transparent;
613
+ border-radius: 50%;
614
+ animation: demo-spin 0.9s linear infinite;
615
+ }
616
+ @keyframes demo-spin {
617
+ to {
618
+ transform: rotate(360deg);
619
+ }
620
+ }
621
+
622
+ .custom-error {
623
+ max-width: 420px;
624
+ padding: 24px;
625
+ background: white;
626
+ border: 2px solid #dc2626;
627
+ border-radius: 10px;
628
+ text-align: center;
629
+ }
630
+ .custom-error h3 {
631
+ margin: 0 0 8px 0;
632
+ color: #b91c1c;
633
+ }
634
+ .custom-error p {
635
+ margin: 0 0 14px 0;
636
+ color: #374151;
637
+ }
638
+ .custom-error__btn {
639
+ background: #dc2626;
640
+ color: white;
641
+ border: 0;
642
+ padding: 8px 18px;
643
+ border-radius: 6px;
644
+ cursor: pointer;
645
+ font-weight: 500;
646
+ }
647
+
648
+ /* Stack on narrow screens (mobile preview in dev) */
649
+ @media (max-width: 720px) {
650
+ .demo-experience-page {
651
+ grid-template-columns: 1fr;
652
+ grid-template-rows: auto 1fr;
653
+ }
654
+ .state-panel {
655
+ border-right: 0;
656
+ border-bottom: 1px solid #1f2937;
657
+ max-height: 240px;
658
+ }
659
+ }
660
+ </style>
@@ -22,6 +22,19 @@
22
22
  </p>
23
23
  </header>
24
24
 
25
+ <!-- Cross-page demo: navigate to the experience-flow demo -->
26
+ <section class="panel demo-link-panel">
27
+ <h2>Experience Flow Demo <span class="tag">@gxp-dev/uikit</span></h2>
28
+ <p class="hint">
29
+ A full state-machine-driven kiosk flow showing how to use
30
+ <code>useExperience</code>, the page library, async actions, branching
31
+ paths, and slot customization — all in one annotated file.
32
+ </p>
33
+ <button class="btn primary" @click="$emit('navigate', 'experience')">
34
+ Open Experience Demo →
35
+ </button>
36
+ </section>
37
+
25
38
  <!-- gxp-settings + gxp-string: read from manifest.settings instead of strings -->
26
39
  <section class="panel">
27
40
  <h2>Settings <span class="tag">gxp-settings</span></h2>
@@ -154,6 +167,8 @@
154
167
 
155
168
  .hero h1 {
156
169
  margin: 0 0 8px 0;
170
+ font-size: 28px;
171
+ font-weight: 700;
157
172
  color: v-bind('gxpStore.getSetting("primary_color")');
158
173
  }
159
174
 
@@ -345,6 +360,8 @@ defineOptions({
345
360
  inheritAttrs: false,
346
361
  })
347
362
 
363
+ defineEmits(["navigate"])
364
+
348
365
  import { ref, onMounted, onUnmounted } from "vue"
349
366
  import { useGxpStore } from "@/stores/gxpPortalConfigStore"
350
367
 
@@ -1,11 +1,17 @@
1
1
  <template>
2
2
  <!-- Your Custom Plugin Content -->
3
- <DemoPage :router="mockRouter" />
3
+ <DemoPage v-if="route === 'home'" :router="mockRouter" @navigate="navigate" />
4
+ <DemoExperience v-else-if="route === 'experience'" @navigate="navigate" />
4
5
  </template>
5
6
 
6
7
  <script setup>
7
8
  import { ref, shallowRef } from "vue"
8
9
  import DemoPage from "@/DemoPage.vue"
10
+ import DemoExperience from "@/DemoExperience.vue"
11
+
12
+ // Note: @gxp-dev/uikit ships its own stylesheet (Tailwind + theme tokens). The
13
+ // experience-flow demo imports it inside DemoExperience.vue so it only loads
14
+ // when that page is opened.
9
15
 
10
16
  // Initialize the GxP store
11
17
  // This import is externalized to window.useGxpStore during build for platform compatibility
@@ -15,6 +21,13 @@ const gxpStore = useGxpStore()
15
21
  gxpStore.sockets?.primary.listenForStateChange((event) => {
16
22
  console.log("🔗 GXP Store: State change event received", event)
17
23
  })
24
+
25
+ // Lightweight in-app routing between the two demo pages. Swap for vue-router
26
+ // in a real plugin if you need URL-aware nav.
27
+ const route = ref("home")
28
+ const navigate = (next) => {
29
+ route.value = next
30
+ }
18
31
  </script>
19
32
 
20
33
  <style>
@@ -1,181 +0,0 @@
1
- /**
2
- * MCP tools for @gxp-dev/uikit introspection.
3
- *
4
- * - list_uikit_components : enumerate components exported by the version
5
- * of @gxp-dev/uikit installed in the *plugin project's* node_modules
6
- * (not the toolkit's). Resolves the package relative to process.cwd(),
7
- * parses named exports out of dist/index.d.ts, and returns sorted
8
- * PascalCase names plus the package version.
9
- *
10
- * We parse the .d.ts with a regex pair (no TS AST) because the file is the
11
- * built output and is shaped by the package's own build, not by us. Edge
12
- * cases that the regex misses (e.g. nested namespace exports) are
13
- * acceptable: the agent gets a strong starting set and can fall back to
14
- * docs_search for anything unusual.
15
- */
16
-
17
- const fs = require("fs")
18
- const path = require("path")
19
-
20
- function contentResult(obj) {
21
- return {
22
- content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
23
- }
24
- }
25
-
26
- /**
27
- * Resolve @gxp-dev/uikit from the given cwd. Returns { root, pkg } where
28
- * root is the absolute path to the uikit package directory and pkg is the
29
- * parsed package.json, or null if uikit is not installed at any level.
30
- *
31
- * We mimic Node's node_modules walk-up rather than calling
32
- * require.resolve("@gxp-dev/uikit/...") because the uikit's package.json
33
- * declares a strict `exports` field with only `import` and `types`
34
- * conditions. From a CJS context, require.resolve fails against that
35
- * exports map even though the directory exists on disk. Walking the
36
- * filesystem directly sidesteps it and works under npm, pnpm (where
37
- * @gxp-dev/uikit is a symlink into .pnpm), and yarn workspaces.
38
- */
39
- function resolveUikit(cwd = process.cwd()) {
40
- let dir = path.resolve(cwd)
41
- while (dir) {
42
- const candidate = path.join(dir, "node_modules", "@gxp-dev", "uikit")
43
- const pkgPath = path.join(candidate, "package.json")
44
- if (fs.existsSync(pkgPath)) {
45
- try {
46
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
47
- if (pkg && pkg.name === "@gxp-dev/uikit") {
48
- return { root: candidate, pkg }
49
- }
50
- } catch {
51
- // malformed package.json — keep walking
52
- }
53
- }
54
- const parent = path.dirname(dir)
55
- if (parent === dir) break
56
- dir = parent
57
- }
58
- return null
59
- }
60
-
61
- /**
62
- * Pull named exports out of a .d.ts source string. Picks up:
63
- * - export declare const/let/var Foo
64
- * - export declare function Foo
65
- * - export declare class Foo
66
- * - export declare interface Foo
67
- * - export declare type Foo
68
- * - export declare enum Foo
69
- * - export { Foo, Bar as Baz }
70
- */
71
- function parseNamedExports(source) {
72
- const names = new Set()
73
-
74
- const declRe =
75
- /export\s+(?:declare\s+)?(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g
76
- let m
77
- while ((m = declRe.exec(source)) !== null) {
78
- names.add(m[1])
79
- }
80
-
81
- const braceRe = /export\s*\{([^}]+)\}/g
82
- while ((m = braceRe.exec(source)) !== null) {
83
- const inner = m[1]
84
- for (const rawPart of inner.split(",")) {
85
- const part = rawPart.trim()
86
- if (!part) continue
87
- const aliasMatch = part.match(
88
- /^[A-Za-z_$][A-Za-z0-9_$]*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
89
- )
90
- if (aliasMatch) {
91
- names.add(aliasMatch[1])
92
- continue
93
- }
94
- const nameMatch = part.match(/^([A-Za-z_$][A-Za-z0-9_$]*)/)
95
- if (nameMatch) names.add(nameMatch[1])
96
- }
97
- }
98
-
99
- return Array.from(names)
100
- }
101
-
102
- function listUikitComponents({ filter, cwd } = {}) {
103
- const resolved = resolveUikit(cwd || process.cwd())
104
- if (!resolved) {
105
- return {
106
- ok: false,
107
- error:
108
- "Could not resolve @gxp-dev/uikit from the current project. Install it with `npm install @gxp-dev/uikit` and re-run mcp-serve from the plugin project root.",
109
- }
110
- }
111
- const { root, pkg } = resolved
112
- const dtsPath = path.join(root, "dist", "index.d.ts")
113
- if (!fs.existsSync(dtsPath)) {
114
- return {
115
- ok: false,
116
- error: `@gxp-dev/uikit is installed at ${root} but dist/index.d.ts is missing. The package may not have been built.`,
117
- resolved: root,
118
- }
119
- }
120
-
121
- const src = fs.readFileSync(dtsPath, "utf-8")
122
- const all = parseNamedExports(src)
123
- const pascal = all.filter((n) => /^[A-Z]/.test(n)).sort()
124
-
125
- let filtered = pascal
126
- if (filter) {
127
- const f = String(filter).toLowerCase()
128
- filtered = pascal.filter((n) => n.toLowerCase().includes(f))
129
- }
130
-
131
- return {
132
- ok: true,
133
- package: {
134
- name: pkg.name,
135
- version: pkg.version || null,
136
- root,
137
- },
138
- count: filtered.length,
139
- components: filtered,
140
- }
141
- }
142
-
143
- const UIKIT_TOOLS = [
144
- {
145
- name: "list_uikit_components",
146
- description:
147
- "Enumerate components exported by the @gxp-dev/uikit package installed in the current plugin project. Reads named exports from the built dist/index.d.ts and returns the PascalCase names plus the package version. Resolves uikit relative to the project (process.cwd()), not the toolkit, so the agent sees exactly what the project can import. Pass `filter` for a case-insensitive substring match.",
148
- inputSchema: {
149
- type: "object",
150
- properties: {
151
- filter: {
152
- type: "string",
153
- description:
154
- "Case-insensitive substring filter applied to component names.",
155
- },
156
- },
157
- },
158
- },
159
- ]
160
-
161
- async function handleUikitToolCall(name, args = {}) {
162
- switch (name) {
163
- case "list_uikit_components":
164
- return contentResult(listUikitComponents(args))
165
- default:
166
- throw new Error(`Unknown uikit tool: ${name}`)
167
- }
168
- }
169
-
170
- function isUikitTool(name) {
171
- return UIKIT_TOOLS.some((t) => t.name === name)
172
- }
173
-
174
- module.exports = {
175
- UIKIT_TOOLS,
176
- handleUikitToolCall,
177
- isUikitTool,
178
- listUikitComponents,
179
- parseNamedExports,
180
- resolveUikit,
181
- }