@tanqory/dev-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/bin/tanqory-dev-mcp.mjs +16 -0
- package/bundled/examples/blank/config/settings.json +3 -0
- package/bundled/examples/blank/locales/en.json +22 -0
- package/bundled/examples/blank/locales/th.json +22 -0
- package/bundled/examples/blank/regions/footer.json +35 -0
- package/bundled/examples/blank/regions/header.json +13 -0
- package/bundled/examples/blank/src/bundle.ts +15 -0
- package/bundled/examples/blank/src/components/parts.tsx +32 -0
- package/bundled/examples/blank/src/entry.client.tsx +6 -0
- package/bundled/examples/blank/src/entry.server.tsx +13 -0
- package/bundled/examples/blank/src/sections/chrome.tsx +132 -0
- package/bundled/examples/blank/src/sections/commerce.tsx +242 -0
- package/bundled/examples/blank/src/sections/content.tsx +111 -0
- package/bundled/examples/blank/src/sections/editorial.tsx +107 -0
- package/bundled/examples/blank/src/settings.ts +15 -0
- package/bundled/examples/blank/src/styles/theme.css +195 -0
- package/bundled/examples/blank/src/templates.ts +14 -0
- package/bundled/examples/blank/src/theme.tsx +76 -0
- package/bundled/examples/blank/template.json +24 -0
- package/bundled/examples/blank/templates/404.json +14 -0
- package/bundled/examples/blank/templates/account.json +14 -0
- package/bundled/examples/blank/templates/article.json +14 -0
- package/bundled/examples/blank/templates/blog.json +14 -0
- package/bundled/examples/blank/templates/cart.json +14 -0
- package/bundled/examples/blank/templates/collection.json +14 -0
- package/bundled/examples/blank/templates/index.json +36 -0
- package/bundled/examples/blank/templates/page.contact.json +18 -0
- package/bundled/examples/blank/templates/page.json +14 -0
- package/bundled/examples/blank/templates/product.json +18 -0
- package/bundled/examples/blank/templates/search.json +14 -0
- package/bundled/examples/blank/tests/theme.test.ts +49 -0
- package/bundled/theme-abi/contracts/render-context.v2.d.ts +849 -0
- package/bundled/theme-abi/contracts/theme-abi-v2.md +1833 -0
- package/bundled/theme-abi/generated/capabilities.json +80 -0
- package/bundled/theme-abi/generated/field-types.json +869 -0
- package/bundled/theme-abi/generated/limits.json +173 -0
- package/bundled/theme-abi/schemas/conformance-report.v2.schema.json +482 -0
- package/bundled/theme-abi/schemas/theme-artifact.v1.schema.json +230 -0
- package/bundled/theme-abi/schemas/theme-manifest.v2.schema.json +254 -0
- package/bundled/theme-conformance/docs/storefront-tier.md +250 -0
- package/bundled/theme-conformance/schema/conformance-report.v2.schema.json +482 -0
- package/bundled/theme-conformance/schema/storefront-report.v1.schema.json +110 -0
- package/bundled/theme-conformance/schema/x-tanqory-ai.v0.schema.json +70 -0
- package/bundled/theme-kit/docs/headless-api.md +220 -0
- package/bundled/theme-kit/generated/kit-strings.json +180 -0
- package/bundled/theme-kit/types/headless/index.d.ts +36 -0
- package/bundled/theme-kit/types/headless/theme.d.ts +99 -0
- package/bundled/theme-kit/types/sdk/schema.d.ts +197 -0
- package/bundled/versions.json +8 -0
- package/package.json +53 -3
- package/src/bm25.mjs +69 -0
- package/src/browser.mjs +123 -0
- package/src/cli.mjs +127 -0
- package/src/devserver.mjs +82 -0
- package/src/docs.mjs +329 -0
- package/src/index.mjs +33 -0
- package/src/kit.mjs +150 -0
- package/src/manifest.mjs +82 -0
- package/src/prompts.mjs +97 -0
- package/src/scaffold.mjs +471 -0
- package/src/schemas.mjs +127 -0
- package/src/server.mjs +595 -0
- package/src/theme.mjs +91 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tanqory/dev-mcp: the local MCP server for theme development (SPEC §3A). Knowledge and eyes for a coding agent
|
|
3
|
+
* that already has a shell: docs and schemas matched to the theme's installed packages, kit examples, a two-step
|
|
4
|
+
* section scaffold, the CLI's checks as structured JSON, and pages and screenshots of the running dev server.
|
|
5
|
+
*
|
|
6
|
+
* It never calls a model, never takes a store id (the store comes from tanqory.config.json and the CLI login), never
|
|
7
|
+
* publishes anything, and writes only inside the open theme, only after a confirmed plan.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import { Browser, BrowserUnavailable, VIEWPORTS } from './browser.mjs'
|
|
14
|
+
import { CliError, runCli } from './cli.mjs'
|
|
15
|
+
import { DOCS, DocCorpus } from './docs.mjs'
|
|
16
|
+
import { DevServerError, devGet, readDevServer, requireDevServer } from './devserver.mjs'
|
|
17
|
+
import { kitCatalogue, kitExamples } from './kit.mjs'
|
|
18
|
+
import { registerPrompts } from './prompts.mjs'
|
|
19
|
+
import { LAYOUTS, ScaffoldError, planSection, writeSection } from './scaffold.mjs'
|
|
20
|
+
import { SCHEMA_KINDS, getSchema } from './schemas.mjs'
|
|
21
|
+
import { loadTheme } from './theme.mjs'
|
|
22
|
+
import { validateManifestFile } from './manifest.mjs'
|
|
23
|
+
|
|
24
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
25
|
+
|
|
26
|
+
const INSTRUCTIONS = `Tanqory theme development (Theme ABI v2). Use these tools, not only the shell, for every theme change:
|
|
27
|
+
1. Start the dev server in the background and keep it running: \`npm run dev -- --mock\` (plain \`npm run dev\` when signed in). dev_server_status says when it is up.
|
|
28
|
+
2. New section: scaffold_section (plan, then confirm with the planDigest). Kit APIs: search_docs / get_kit_example, not node_modules.
|
|
29
|
+
3. Look at your work: screenshot (desktop and mobile, th and en) and get_page (HTML + console errors).
|
|
30
|
+
4. Verify with check_theme, not \`npm run check\`: fix every failure it reports, including ones you did not cause, until passed is true.
|
|
31
|
+
5. Stop the dev server with \`npm run dev -- --stop\` (the pid in .tanqory/dev-server.json). Never pkill, killall or a pid found by name.
|
|
32
|
+
You are not done until check_theme passes and you have seen the change in a screenshot. Every storefront function (cart, variants, search, filters, markets, forms, SEO, i18n, live edit) comes from @tanqory/theme-kit; the theme owns markup and CSS. This server never calls a model, never takes a store id and never publishes.`
|
|
33
|
+
|
|
34
|
+
const errorSchema = z.object({ code: z.string(), message: z.string(), hint: z.string().optional() }).passthrough()
|
|
35
|
+
const base = { ok: z.boolean().describe('false when the tool could not do what was asked; see error'), error: errorSchema.optional() }
|
|
36
|
+
|
|
37
|
+
const json = (v) => JSON.stringify(v, null, 2)
|
|
38
|
+
const done = (structured, text) => ({ content: [{ type: 'text', text: text ?? json(structured) }], structuredContent: { ok: true, ...structured } })
|
|
39
|
+
const fail = (code, message, hint, extra = {}) => {
|
|
40
|
+
const error = { code, message, ...(hint ? { hint } : {}), ...extra }
|
|
41
|
+
return { isError: true, content: [{ type: 'text', text: `${code}: ${message}${hint ? `\nWhat to do: ${hint}` : ''}` }], structuredContent: { ok: false, error } }
|
|
42
|
+
}
|
|
43
|
+
const fromError = (e) => {
|
|
44
|
+
if (e instanceof CliError || e instanceof DevServerError || e instanceof ScaffoldError || e instanceof BrowserUnavailable) {
|
|
45
|
+
const { code, hint, message, ...rest } = e
|
|
46
|
+
const extra = {}
|
|
47
|
+
for (const k of ['stderr', 'exitCode', 'planDigest', 'written']) if (rest[k] !== undefined) extra[k] = rest[k]
|
|
48
|
+
return fail(code, message, hint, extra)
|
|
49
|
+
}
|
|
50
|
+
return fail('internal_error', e?.message ?? String(e), 'This is a bug in @tanqory/dev-mcp; the message above says where.')
|
|
51
|
+
}
|
|
52
|
+
const guard = (fn) => async (args, extra) => {
|
|
53
|
+
try {
|
|
54
|
+
return await fn(args ?? {}, extra)
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return fromError(e)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
61
|
+
|
|
62
|
+
const failureSchema = z
|
|
63
|
+
.object({
|
|
64
|
+
tier: z.string(),
|
|
65
|
+
checkId: z.string(),
|
|
66
|
+
route: z.string().optional(),
|
|
67
|
+
locale: z.string().optional(),
|
|
68
|
+
message: z.string(),
|
|
69
|
+
hint: z.string().optional(),
|
|
70
|
+
file: z.string().optional(),
|
|
71
|
+
line: z.number().optional(),
|
|
72
|
+
})
|
|
73
|
+
.passthrough()
|
|
74
|
+
|
|
75
|
+
/** Shape a `theme check --json` envelope into the tool result. */
|
|
76
|
+
function checkResult(env, requestedTier) {
|
|
77
|
+
if (!env.data && env.error) return fail(env.error.code ?? 'check_failed', env.error.message ?? 'theme check failed', env.error.hint ?? 'Run `tanqory theme check` in a terminal for the full output.', { stderr: env.stderrTail })
|
|
78
|
+
const data = env.data ?? {}
|
|
79
|
+
const failures = Array.isArray(data.failures) ? data.failures : []
|
|
80
|
+
const shown = failures.slice(0, 60)
|
|
81
|
+
const tiers = data.tiers ?? {}
|
|
82
|
+
const passed = env.ok && failures.length === 0
|
|
83
|
+
const lines = [
|
|
84
|
+
`tanqory theme check${requestedTier ? ` --tier ${requestedTier}` : ''}: ${passed ? 'PASSED' : `FAILED (${failures.length} failure${failures.length === 1 ? '' : 's'})`}`,
|
|
85
|
+
...Object.entries(tiers).map(([t, v]) => ` ${t}: ${typeof v === 'object' && v ? (v.passed === true ? 'passed' : v.passed === false ? 'failed' : json(v)) : String(v)}`),
|
|
86
|
+
...shown.map((f) => `- [${f.tier}] ${f.checkId}${f.file ? ` ${f.file}${f.line ? `:${f.line}` : ''}` : ''}${f.route ? ` route ${f.route}` : ''}${f.locale ? ` (${f.locale})` : ''}: ${f.message}${f.hint ? `\n fix: ${f.hint}` : ''}`),
|
|
87
|
+
...(failures.length > shown.length ? [`… ${failures.length - shown.length} more (structuredContent.failures is complete)`] : []),
|
|
88
|
+
...(passed
|
|
89
|
+
? ['Next: if you have not looked at your change yet, call screenshot on the page you changed (viewport desktop and mobile). Stop the dev server with `npm run dev -- --stop` when you are finished, never pkill.']
|
|
90
|
+
: ['Fix every failure above, including ones you did not cause (look a check up with search_docs {query: "<checkId>"}), then call check_theme again.']),
|
|
91
|
+
]
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
94
|
+
structuredContent: { ok: true, passed, tiers, failures, failureCount: failures.length, ...(env.error ? { cliError: env.error } : {}) },
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {{ cwd?: string, cli?: { command: string, args?: string[], via?: string }, log?: (msg: string) => void }} [options]
|
|
100
|
+
*/
|
|
101
|
+
export function createTanqoryMcpServer(options = {}) {
|
|
102
|
+
const cwd = options.cwd ?? process.cwd()
|
|
103
|
+
const log = options.log ?? ((m) => process.stderr.write(`[tanqory-mcp] ${m}\n`))
|
|
104
|
+
const theme = loadTheme(cwd)
|
|
105
|
+
const corpus = new DocCorpus(theme)
|
|
106
|
+
const browser = new Browser(theme.root, log)
|
|
107
|
+
const cliOpts = { root: theme.root, cli: options.cli }
|
|
108
|
+
const server = new McpServer({ name: 'tanqory', title: 'Tanqory theme development', version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { logging: {} } })
|
|
109
|
+
|
|
110
|
+
const needTheme = () => {
|
|
111
|
+
if (!theme.root) throw new ScaffoldError('no_theme', 'No theme is open: this server was started outside a theme folder.', 'Start the agent in the theme root (the folder with tanqory.config.json), or set TANQORY_THEME_DIR.')
|
|
112
|
+
return theme.root
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── docs ──────────────────────────────────────────────────────────────────
|
|
116
|
+
server.registerTool(
|
|
117
|
+
'search_docs',
|
|
118
|
+
{
|
|
119
|
+
title: 'Search the theme platform docs',
|
|
120
|
+
description:
|
|
121
|
+
"Full-text search (BM25, offline, deterministic) over the docs that match this theme's installed packages: the Theme ABI v2 contract, the kit's headless API, the storefront-tier check spec, the manifest / field-type / limits / capabilities references, the kit strings and this theme's AGENTS.md. Use it before writing code and to look up a failing check id. Returns ranked sections with a snippet and a `path` to pass to get_doc. Never uses the network.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
query: z.string().min(1).max(400).describe('Words to find, e.g. "variant picker ?variant=", "cart:add hook", "editor:live-edit", "data:inventory"'),
|
|
124
|
+
limit: z.number().int().min(1).max(20).optional().describe('How many results (default 8)'),
|
|
125
|
+
},
|
|
126
|
+
outputSchema: {
|
|
127
|
+
...base,
|
|
128
|
+
results: z.array(z.object({ path: z.string(), title: z.string(), document: z.string(), score: z.number(), snippet: z.string(), source: z.string(), version: z.string().nullable() })).optional(),
|
|
129
|
+
},
|
|
130
|
+
annotations: READ,
|
|
131
|
+
},
|
|
132
|
+
guard(async ({ query, limit }) => {
|
|
133
|
+
const results = await corpus.search(query, limit ?? 8)
|
|
134
|
+
const text = results.length
|
|
135
|
+
? results.map((r, i) => `${i + 1}. ${r.title} — ${r.document}\n path: ${r.path} (${r.source}${r.version ? ` ${r.version}` : ''})\n ${r.snippet.replace(/\n/g, '\n ')}`).join('\n')
|
|
136
|
+
: `No match for "${query}". Try other words, or list the documents with get_doc {path: "index"}.`
|
|
137
|
+
return done({ results }, text)
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
server.registerTool(
|
|
142
|
+
'get_doc',
|
|
143
|
+
{
|
|
144
|
+
title: 'Read a doc or one section of it',
|
|
145
|
+
description:
|
|
146
|
+
'Read a document from the docs corpus by path: a document id (e.g. "kit/headless-api", "abi/contract", "conformance/storefront-tier", "abi/field-types", "theme/agents") or one section of it ("abi/contract#7-3-editor-contract"), as returned by search_docs. Path "index" lists every document. Long documents are paginated: pass nextOffset back as offset. The answer names its source: "installed" (the theme\'s node_modules, matches the versions it builds with) or "bundled" (this package\'s copy, used when the installed package lacks the file).',
|
|
147
|
+
inputSchema: {
|
|
148
|
+
path: z.string().min(1).describe('A doc id, doc#section, a tanqory://docs/… URI, or "index"'),
|
|
149
|
+
offset: z.number().int().min(0).optional().describe('Character offset to continue from (nextOffset of the previous call)'),
|
|
150
|
+
maxChars: z.number().int().min(500).max(40000).optional().describe('Characters to return (default 12000)'),
|
|
151
|
+
},
|
|
152
|
+
outputSchema: {
|
|
153
|
+
...base,
|
|
154
|
+
path: z.string().optional(),
|
|
155
|
+
title: z.string().optional(),
|
|
156
|
+
source: z.string().optional(),
|
|
157
|
+
package: z.string().nullable().optional(),
|
|
158
|
+
version: z.string().nullable().optional(),
|
|
159
|
+
installedVersion: z.string().nullable().optional(),
|
|
160
|
+
totalChars: z.number().optional(),
|
|
161
|
+
offset: z.number().optional(),
|
|
162
|
+
nextOffset: z.number().nullable().optional(),
|
|
163
|
+
text: z.string().optional(),
|
|
164
|
+
sections: z.array(z.string()).optional(),
|
|
165
|
+
documents: z.array(z.object({ path: z.string(), title: z.string(), summary: z.string(), source: z.string(), package: z.string().nullable(), version: z.string().nullable(), sections: z.number(), uri: z.string() })).optional(),
|
|
166
|
+
},
|
|
167
|
+
annotations: READ,
|
|
168
|
+
},
|
|
169
|
+
guard(async ({ path, offset, maxChars }) => {
|
|
170
|
+
if (path === 'index' || path === '') {
|
|
171
|
+
const documents = await corpus.list()
|
|
172
|
+
return done({ documents }, documents.map((d) => `${d.path} — ${d.title} (${d.source}${d.version ? ` ${d.version}` : ''}, ${d.sections} sections)\n ${d.summary}`).join('\n'))
|
|
173
|
+
}
|
|
174
|
+
const r = await corpus.get(path, { offset: offset ?? 0, maxChars: maxChars ?? 12000 })
|
|
175
|
+
if (r.error === 'unknown_doc') return fail('unknown_doc', `No document "${path}".`, `Known documents: ${r.known.join(', ')}.`)
|
|
176
|
+
if (r.error === 'unknown_section') return fail('unknown_section', `No section "${path}".`, `Sections: ${r.known.slice(0, 60).join(', ')}`)
|
|
177
|
+
const head = `# ${r.title}\n(${r.path} · ${r.source}${r.package ? ` ${r.package}@${r.version}` : ''}${r.source === 'bundled' && r.installedVersion && r.installedVersion !== r.version ? ` · the theme has ${r.installedVersion} installed; this copy may differ` : ''} · chars ${r.offset}–${r.offset + r.text.length} of ${r.totalChars}${r.nextOffset !== null ? ` · continue with offset ${r.nextOffset}` : ''})\n\n`
|
|
178
|
+
return done(r, head + r.text)
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
server.registerTool(
|
|
183
|
+
'get_schema',
|
|
184
|
+
{
|
|
185
|
+
title: 'Get a schema a theme author writes against',
|
|
186
|
+
description: `The exact shape of one thing a theme declares or produces, from the installed packages: ${SCHEMA_KINDS.map((k) => `"${k}"`).join(', ')}. "section"/"block" give the defineSection/defineBlock TypeScript declarations plus the manifest component schema; "field-registry" every setting type with its stored value and constraints; "locale" the locales/<lang>.json shape and the kit's own keys; "content-document" templates/*.json and regions/*.json. Read-only.`,
|
|
187
|
+
inputSchema: { kind: z.enum(SCHEMA_KINDS).describe('Which schema') },
|
|
188
|
+
outputSchema: { ...base, kind: z.string().optional(), source: z.string().nullable().optional(), version: z.string().nullable().optional(), schema: z.any().optional(), types: z.string().nullable().optional(), registry: z.string().nullable().optional(), kitKeys: z.array(z.string()).optional(), notes: z.array(z.string()).optional() },
|
|
189
|
+
annotations: READ,
|
|
190
|
+
},
|
|
191
|
+
guard(async ({ kind }) => {
|
|
192
|
+
const r = await getSchema(corpus, kind)
|
|
193
|
+
if (!r) return fail('unknown_kind', `No schema "${kind}".`, `One of: ${SCHEMA_KINDS.join(', ')}.`)
|
|
194
|
+
return done(r)
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
// ── kit ───────────────────────────────────────────────────────────────────
|
|
199
|
+
const catalogue = async () => {
|
|
200
|
+
const d = DOCS.find((x) => x.id === 'kit/headless-api')
|
|
201
|
+
const ex = DOCS.find((x) => x.id === 'kit/exports')
|
|
202
|
+
const docText = await corpus.read(d)
|
|
203
|
+
const exportsText = await corpus.read(ex)
|
|
204
|
+
return kitCatalogue({ docText: docText?.text, exportsDts: exportsText?.text, installedVersion: theme.versions['@tanqory/theme-kit'], source: docText?.source })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
server.registerTool(
|
|
208
|
+
'list_kit_components',
|
|
209
|
+
{
|
|
210
|
+
title: 'List the kit API',
|
|
211
|
+
description:
|
|
212
|
+
"Every hook, primitive, declaration helper and AI role of @tanqory/theme-kit (headless), from the installed kit's docs and export list: name, kind, signature, what it renders and does. Use it to find the kit piece for a storefront function before writing any of your own (cart, variants, filters, search, localization, forms, consent, SEO). Then get_kit_example {name} for real code.",
|
|
213
|
+
inputSchema: { kind: z.enum(['hook', 'primitive', 'declaration', 'helper', 'ai-role', 'entry', 'build']).optional().describe('Only this kind') },
|
|
214
|
+
outputSchema: {
|
|
215
|
+
...base,
|
|
216
|
+
kitVersion: z.string().nullable().optional(),
|
|
217
|
+
docSource: z.string().nullable().optional(),
|
|
218
|
+
components: z.array(z.object({ name: z.string(), kind: z.string(), signature: z.string(), summary: z.string(), importFrom: z.string(), exported: z.boolean().nullable() })).optional(),
|
|
219
|
+
undocumentedExports: z.array(z.string()).optional(),
|
|
220
|
+
},
|
|
221
|
+
annotations: READ,
|
|
222
|
+
},
|
|
223
|
+
guard(async ({ kind }) => {
|
|
224
|
+
const c = await catalogue()
|
|
225
|
+
const components = kind ? c.components.filter((x) => x.kind === kind) : c.components
|
|
226
|
+
const text = [`@tanqory/theme-kit ${c.kitVersion ?? '(not installed: bundled docs)'} — ${components.length} entries`, ...components.map((x) => `- ${x.name} [${x.kind}] ${x.signature}\n ${x.summary}`), c.undocumentedExports.length ? `Also exported (see kit/exports): ${c.undocumentedExports.join(', ')}` : ''].join('\n')
|
|
227
|
+
return done({ ...c, components }, text)
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
server.registerTool(
|
|
232
|
+
'get_kit_example',
|
|
233
|
+
{
|
|
234
|
+
title: 'Real code using a kit API',
|
|
235
|
+
description:
|
|
236
|
+
"Working snippets that use one kit API (e.g. ProductForm, VariantPicker, FacetFilters, CartDrawer, NewsletterForm, useCollection, defineSection, field): first from this theme's own src/, then from the Blank starter that `npm create tanqory-theme` writes (code that builds and passes every check). Each snippet is the whole enclosing declaration with its file and line numbers, plus the kit's description of the API.",
|
|
237
|
+
inputSchema: { name: z.string().min(1).max(80).describe('Kit name, e.g. "VariantPicker", "useCart", "defineSection", "ai.hero"'), limit: z.number().int().min(1).max(4).optional().describe('Snippets per source (default 2)') },
|
|
238
|
+
outputSchema: {
|
|
239
|
+
...base,
|
|
240
|
+
name: z.string().optional(),
|
|
241
|
+
api: z.any().optional(),
|
|
242
|
+
examples: z.array(z.object({ origin: z.string(), file: z.string(), startLine: z.number(), endLine: z.number(), import: z.string().nullable(), code: z.string() })).optional(),
|
|
243
|
+
},
|
|
244
|
+
annotations: READ,
|
|
245
|
+
},
|
|
246
|
+
guard(async ({ name, limit }) => {
|
|
247
|
+
const c = await catalogue()
|
|
248
|
+
const api = c.components.find((x) => x.name.toLowerCase() === name.toLowerCase()) ?? null
|
|
249
|
+
const canonical = api?.name ?? name
|
|
250
|
+
const examples = kitExamples({ name: canonical, themeRoot: theme.root, limit: limit ?? 2 })
|
|
251
|
+
if (!api && !examples.length) return fail('unknown_kit_name', `"${name}" is not a kit API this server knows and no code uses it.`, 'list_kit_components lists every name; search_docs finds prose.')
|
|
252
|
+
const text = [
|
|
253
|
+
api ? `${api.name} [${api.kind}] — import from ${api.importFrom}\n${api.signature}\n${api.summary}` : `${name}: not in the kit docs; usages found in code:`,
|
|
254
|
+
...examples.map((e) => `\n--- ${e.origin}: ${e.file}:${e.startLine}-${e.endLine}${e.import ? `\n${e.import}` : ''}\n${e.code}`),
|
|
255
|
+
examples.length ? '' : '\n(no usage in the theme or the Blank starter)',
|
|
256
|
+
].join('\n')
|
|
257
|
+
return done({ name: canonical, api, examples }, text)
|
|
258
|
+
}),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
// ── scaffold ──────────────────────────────────────────────────────────────
|
|
262
|
+
const fileChange = z.object({ path: z.string(), action: z.enum(['create', 'modify']), summary: z.string(), content: z.string().optional() })
|
|
263
|
+
server.registerTool(
|
|
264
|
+
'scaffold_section',
|
|
265
|
+
{
|
|
266
|
+
title: 'Scaffold a kit-wired section (two-step)',
|
|
267
|
+
description: `Create a new section in the open theme (Blank layout: src/theme.tsx with defineTheme): src/sections/<type>.tsx (defineSection with a settings schema, presets[0] for the editor's Add-section preview, kit primitives only, no inline styles), its registration in src/theme.tsx, locale keys in locales/en.json AND locales/th.json, CSS appended to src/styles/theme.css, and a vitest stub in tests/sections/. Optionally adds an instance to a template's starter content.
|
|
268
|
+
Two steps: call WITHOUT confirm to get the plan (every file, full new contents, planDigest; nothing is written). Then call again with the SAME arguments plus confirm: true and that planDigest to write exactly that plan. If the theme changed in between, it refuses with plan_changed. Layouts: ${LAYOUTS.join(', ')} (inferred from name + purpose when omitted). It never deletes a file and never touches anything outside the theme. Refuses for a Nova-fork layout. After writing: npm run build, npm test, check_theme.`,
|
|
269
|
+
inputSchema: {
|
|
270
|
+
name: z.string().min(1).max(60).describe('Human name; the section type is its kebab-case ("Lookbook" → "lookbook")'),
|
|
271
|
+
purpose: z.string().min(1).max(300).describe('One sentence: what it shows (becomes the editor description)'),
|
|
272
|
+
layout: z.enum(LAYOUTS).optional().describe('Markup pattern; inferred when omitted'),
|
|
273
|
+
template: z.string().max(60).optional().describe('Also place it in templates/<template>.json main region (e.g. "index")'),
|
|
274
|
+
titleTh: z.string().max(60).optional().describe('Thai name for the th label key (else the English name is used and flagged as a translation to do)'),
|
|
275
|
+
confirm: z.boolean().optional().describe('true = write the plan identified by planDigest'),
|
|
276
|
+
planDigest: z.string().optional().describe('From the plan step; required with confirm: true'),
|
|
277
|
+
},
|
|
278
|
+
outputSchema: {
|
|
279
|
+
...base,
|
|
280
|
+
mode: z.enum(['plan', 'written']).optional(),
|
|
281
|
+
type: z.string().optional(),
|
|
282
|
+
layout: z.string().optional(),
|
|
283
|
+
planDigest: z.string().optional(),
|
|
284
|
+
changes: z.array(fileChange).optional(),
|
|
285
|
+
written: z.array(z.object({ path: z.string(), action: z.string(), bytes: z.number() })).optional(),
|
|
286
|
+
localeKeys: z.array(z.string()).optional(),
|
|
287
|
+
translationTodo: z.array(z.string()).optional(),
|
|
288
|
+
nextSteps: z.array(z.string()).optional(),
|
|
289
|
+
},
|
|
290
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
291
|
+
},
|
|
292
|
+
guard(async ({ confirm, planDigest, ...input }) => {
|
|
293
|
+
if (!confirm) {
|
|
294
|
+
const p = planSection(theme, input)
|
|
295
|
+
const text = [
|
|
296
|
+
`PLAN (nothing written) for section "${p.type}" (layout ${p.layout}). To write it, call scaffold_section again with the same arguments plus confirm: true, planDigest: "${p.planDigest}".`,
|
|
297
|
+
...p.changes.map((c) => `- ${c.action} ${c.path}: ${c.summary}`),
|
|
298
|
+
...(p.translationTodo.length ? [`Translation to do: ${p.translationTodo.join('; ')}`] : []),
|
|
299
|
+
'',
|
|
300
|
+
...p.changes.filter((c) => c.action === 'create').map((c) => `--- ${c.path}\n${c.content}`),
|
|
301
|
+
].join('\n')
|
|
302
|
+
return done({ mode: 'plan', type: p.type, layout: p.layout, planDigest: p.planDigest, changes: p.changes.map(({ added, ...c }) => (c.action === 'create' ? c : { ...c, content: undefined })), localeKeys: p.localeKeys, translationTodo: p.translationTodo, nextSteps: p.nextSteps }, text)
|
|
303
|
+
}
|
|
304
|
+
if (!planDigest) return fail('plan_digest_required', 'confirm: true needs the planDigest of a plan you reviewed.', 'Call scaffold_section without confirm first.')
|
|
305
|
+
const r = writeSection(theme, input, planDigest)
|
|
306
|
+
return done(
|
|
307
|
+
{ mode: 'written', type: r.type, layout: r.layout, planDigest: r.planDigest, written: r.written, localeKeys: r.localeKeys, translationTodo: r.translationTodo, nextSteps: r.nextSteps },
|
|
308
|
+
[`Wrote section "${r.type}":`, ...r.written.map((w) => `- ${w.action} ${w.path} (${w.bytes} B)`), 'Next:', ...r.nextSteps.map((s) => `- ${s}`)].join('\n'),
|
|
309
|
+
)
|
|
310
|
+
}),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
// ── checks ────────────────────────────────────────────────────────────────
|
|
314
|
+
server.registerTool(
|
|
315
|
+
'validate_manifest',
|
|
316
|
+
{
|
|
317
|
+
title: 'Validate the built manifest',
|
|
318
|
+
description:
|
|
319
|
+
"Validate dist/abi/manifest.json (written by `npm run build`) with the theme's own @tanqory/theme-abi validateManifest, plus the JCS byte rule. Fast, no browser. Each problem names the JSON path and, for a component, the src/ file:line that declares it (the manifest is generated from your declarations; fix those, never the JSON). It does not rebuild: run `npm run build` first. For the full platform checks use check_theme.",
|
|
320
|
+
inputSchema: {},
|
|
321
|
+
outputSchema: { ...base, valid: z.boolean().optional(), manifestPath: z.string().optional(), abiVersion: z.string().nullable().optional(), errors: z.array(z.object({ path: z.string(), message: z.string(), code: z.string().optional(), file: z.string().optional(), line: z.number().optional(), hint: z.string().optional() })).optional() },
|
|
322
|
+
annotations: READ,
|
|
323
|
+
},
|
|
324
|
+
guard(async () => {
|
|
325
|
+
const r = await validateManifestFile(needTheme(), theme.versions['@tanqory/theme-abi'])
|
|
326
|
+
if (r.error) return fail(r.error.code, r.error.message, r.error.hint)
|
|
327
|
+
const text = r.valid ? `manifest valid (${r.manifestPath}, @tanqory/theme-abi ${r.abiVersion})` : [`manifest INVALID: ${r.errors.length} problem(s)`, ...r.errors.map((e) => `- ${e.path}: ${e.message}${e.file ? ` (${e.file}:${e.line})` : ''}${e.hint ? `\n fix: ${e.hint}` : ''}`)].join('\n')
|
|
328
|
+
return done(r, text)
|
|
329
|
+
}),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
const checkInput = { tier: z.enum(['security', 'storefront', 'budgets', 'all']).optional().describe('One tier, or all (default: what the CLI runs by default: all)') }
|
|
333
|
+
const checkOutput = { ...base, passed: z.boolean().optional(), tiers: z.record(z.any()).optional(), failures: z.array(failureSchema).optional(), failureCount: z.number().optional(), cliError: z.any().optional() }
|
|
334
|
+
const CHECK_ANN = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
335
|
+
|
|
336
|
+
server.registerTool(
|
|
337
|
+
'check_theme',
|
|
338
|
+
{
|
|
339
|
+
title: 'Run tanqory theme check',
|
|
340
|
+
description:
|
|
341
|
+
'Run `tanqory theme check --json` in the theme (the same checks the platform runs before it accepts a build): security (the 9 ABI suites), storefront (works like a real store: variants, cart, checkout hand-off, sort/filter/pagination, search, th/en strings, markets, SEO, a11y, live edit) and budgets. Returns every failure with tier, checkId, message, fix hint and file:line when known. Side effects are local only: it writes the generated dist/ and reports/. Takes minutes for all tiers. Needs Tanqory CLI v2.',
|
|
342
|
+
inputSchema: checkInput,
|
|
343
|
+
outputSchema: checkOutput,
|
|
344
|
+
annotations: CHECK_ANN,
|
|
345
|
+
},
|
|
346
|
+
guard(async ({ tier }) => {
|
|
347
|
+
needTheme()
|
|
348
|
+
const env = await runCli(['theme', 'check', ...(tier ? ['--tier', tier] : [])], cliOpts)
|
|
349
|
+
return checkResult(env, tier)
|
|
350
|
+
}),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
server.registerTool(
|
|
354
|
+
'run_storefront_checks',
|
|
355
|
+
{
|
|
356
|
+
title: 'Run the storefront-ready checks',
|
|
357
|
+
description:
|
|
358
|
+
'Only the storefront tier: `tanqory theme check --tier storefront --json` (a shopper picks a variant, adds to cart, edits lines, reaches checkout, sorts, filters, pages, searches, switches market and language; keyboard and axe; the merchant live-edits it). Same result shape as check_theme. Use it while iterating on storefront behaviour; run check_theme (all tiers) before saying done.',
|
|
359
|
+
inputSchema: {},
|
|
360
|
+
outputSchema: checkOutput,
|
|
361
|
+
annotations: CHECK_ANN,
|
|
362
|
+
},
|
|
363
|
+
guard(async () => {
|
|
364
|
+
needTheme()
|
|
365
|
+
return checkResult(await runCli(['theme', 'check', '--tier', 'storefront'], cliOpts), 'storefront')
|
|
366
|
+
}),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
// ── dev server ────────────────────────────────────────────────────────────
|
|
370
|
+
server.registerTool(
|
|
371
|
+
'dev_server_status',
|
|
372
|
+
{
|
|
373
|
+
title: 'Is `tanqory theme dev` running?',
|
|
374
|
+
description:
|
|
375
|
+
"Read .tanqory/dev-server.json and ask the running dev server for /__tq/status: url, store, mode (store = real dev-store data, mock = fixture data), the theme it serves and its last build with errors (file:line). Never starts or stops the dev server; when it is not running the answer says how to start it (in the background: `npm run dev -- --mock`).",
|
|
376
|
+
inputSchema: {},
|
|
377
|
+
outputSchema: {
|
|
378
|
+
...base,
|
|
379
|
+
running: z.boolean().optional(),
|
|
380
|
+
reason: z.string().optional(),
|
|
381
|
+
hint: z.string().optional(),
|
|
382
|
+
url: z.string().optional(),
|
|
383
|
+
port: z.number().optional(),
|
|
384
|
+
mode: z.string().optional(),
|
|
385
|
+
store: z.any().optional(),
|
|
386
|
+
startedAt: z.string().optional(),
|
|
387
|
+
theme: z.any().optional(),
|
|
388
|
+
lastBuild: z.any().optional(),
|
|
389
|
+
},
|
|
390
|
+
annotations: READ,
|
|
391
|
+
},
|
|
392
|
+
guard(async () => {
|
|
393
|
+
const s = readDevServer(theme.root)
|
|
394
|
+
if (!s.running) return done({ running: false, reason: s.reason, hint: s.hint }, `Dev server not running (${s.reason}). ${s.hint}`)
|
|
395
|
+
const { url, port, store, mode, startedAt } = s.record
|
|
396
|
+
const st = await devGet(url, '/__tq/status', { timeoutMs: 5000 })
|
|
397
|
+
const b = st.body ?? {}
|
|
398
|
+
const errs = b.lastBuild?.errors ?? []
|
|
399
|
+
const text = [`Dev server ${url} (${mode}${store?.handle ? `, store ${store.handle}` : ''}) serving ${b.theme?.name ?? '?'}`, `last build: ${b.lastBuild?.ok === false ? 'FAILED' : 'ok'}${b.lastBuild?.at ? ` at ${b.lastBuild.at}` : ''}`, ...errs.map((e) => `- ${e.file ?? '?'}${e.line ? `:${e.line}` : ''}: ${e.message}`)].join('\n')
|
|
400
|
+
return done({ running: true, url, port, mode, store, startedAt, theme: b.theme, lastBuild: b.lastBuild }, text)
|
|
401
|
+
}),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
const pageArgs = {
|
|
405
|
+
path: z.string().regex(/^\//, 'must start with /').max(500).describe('Storefront path, e.g. "/", "/products/linen-shirt", "/collections/all?sort=price-asc"'),
|
|
406
|
+
locale: z.string().max(10).optional().describe('Language, e.g. "th" or "en"'),
|
|
407
|
+
market: z.string().max(20).optional().describe('Market / country, e.g. "TH", "JP"'),
|
|
408
|
+
}
|
|
409
|
+
const viewportArg = z.union([z.enum(['mobile', 'desktop']), z.object({ width: z.number().int().min(240).max(2560), height: z.number().int().min(240).max(2560) })])
|
|
410
|
+
const browserUrl = (origin, { path, locale, market }) => {
|
|
411
|
+
const u = new URL(path, origin)
|
|
412
|
+
if (locale) u.searchParams.set('locale', locale)
|
|
413
|
+
if (market && /^[A-Za-z]{2}$/.test(market)) u.searchParams.set('country', market.toUpperCase())
|
|
414
|
+
return u.href
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
server.registerTool(
|
|
418
|
+
'get_page',
|
|
419
|
+
{
|
|
420
|
+
title: 'Rendered page from the dev server',
|
|
421
|
+
description:
|
|
422
|
+
'Render one storefront page on the running `tanqory theme dev` server (GET /__tq/page) and load it in headless Chromium: status, the server-rendered HTML (truncated to maxHtmlChars), server logs, and console errors / uncaught exceptions / failed requests from the browser after hydration. Use it to assert markup (data-tq-node, data-tq-* hooks, JSON-LD, lang) and to catch runtime errors. Needs the dev server running (dev_server_status).',
|
|
423
|
+
inputSchema: { ...pageArgs, viewport: viewportArg.optional().describe('Browser viewport (default desktop)'), maxHtmlChars: z.number().int().min(0).max(200000).optional().describe('HTML characters to return (default 20000; 0 = none)') },
|
|
424
|
+
outputSchema: {
|
|
425
|
+
...base,
|
|
426
|
+
url: z.string().optional(),
|
|
427
|
+
status: z.number().nullable().optional(),
|
|
428
|
+
html: z.string().optional(),
|
|
429
|
+
htmlLength: z.number().optional(),
|
|
430
|
+
serverLogs: z.array(z.any()).optional(),
|
|
431
|
+
consoleErrors: z.array(z.any()).optional(),
|
|
432
|
+
browser: z.object({ available: z.boolean(), reason: z.string().optional() }).optional(),
|
|
433
|
+
},
|
|
434
|
+
annotations: READ,
|
|
435
|
+
},
|
|
436
|
+
guard(async ({ path, locale, market, viewport, maxHtmlChars }) => {
|
|
437
|
+
const rec = requireDevServer(theme.root)
|
|
438
|
+
const q = new URLSearchParams({ path, ...(locale ? { locale } : {}), ...(market ? { market } : {}) })
|
|
439
|
+
const page = (await devGet(rec.url, `/__tq/page?${q}`)).body ?? {}
|
|
440
|
+
const html = typeof page.html === 'string' ? page.html : ''
|
|
441
|
+
const max = maxHtmlChars ?? 20000
|
|
442
|
+
const consoleErrors = [...(page.consoleErrors ?? []).map((m) => (typeof m === 'string' ? { kind: 'server-reported', message: m } : m))]
|
|
443
|
+
let browserInfo = { available: true }
|
|
444
|
+
try {
|
|
445
|
+
const r = await browser.withPage(browserUrl(rec.url, { path, locale, market }), { viewport: viewport ?? 'desktop' }, async () => null)
|
|
446
|
+
consoleErrors.push(...r.errors.map((e) => ({ ...e, kind: `browser-${e.kind}` })))
|
|
447
|
+
} catch (e) {
|
|
448
|
+
if (!(e instanceof BrowserUnavailable)) throw e
|
|
449
|
+
browserInfo = { available: false, reason: `${e.message} ${e.hint}` }
|
|
450
|
+
}
|
|
451
|
+
const result = { url: browserUrl(rec.url, { path, locale, market }), status: page.status ?? null, html: html.slice(0, max), htmlLength: html.length, serverLogs: page.serverLogs ?? [], consoleErrors, browser: browserInfo }
|
|
452
|
+
const text = [`${path} → ${result.status}${locale ? ` (${locale})` : ''}${market ? ` market ${market}` : ''}; HTML ${html.length} chars${html.length > max ? ` (first ${max} shown)` : ''}`, consoleErrors.length ? `console errors (${consoleErrors.length}):\n${consoleErrors.map((e) => `- [${e.kind}] ${e.message}`).join('\n')}` : 'no console errors', browserInfo.available ? '' : `browser not available: ${browserInfo.reason}`, max ? `\n${result.html}` : ''].filter(Boolean).join('\n')
|
|
453
|
+
return done(result, text)
|
|
454
|
+
}),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
server.registerTool(
|
|
458
|
+
'screenshot',
|
|
459
|
+
{
|
|
460
|
+
title: 'Screenshot a page of the dev server',
|
|
461
|
+
description:
|
|
462
|
+
'A PNG of one storefront page from the running `tanqory theme dev`, taken in local headless Chromium (Playwright), returned as an image so you can see your UI. mobile = 390×844 @2x touch, desktop = 1280×900. fullPage captures up to 4 viewport heights. Also reports console errors. Downloads Chromium once if it is missing. Needs the dev server running.',
|
|
463
|
+
inputSchema: { ...pageArgs, viewport: viewportArg.describe('mobile | desktop | {width, height}'), fullPage: z.boolean().optional().describe('Whole page (capped at 4 viewport heights) instead of the first screen') },
|
|
464
|
+
outputSchema: { ...base, url: z.string().optional(), status: z.number().nullable().optional(), viewport: z.object({ width: z.number(), height: z.number() }).optional(), bytes: z.number().optional(), consoleErrors: z.array(z.any()).optional() },
|
|
465
|
+
annotations: READ,
|
|
466
|
+
},
|
|
467
|
+
guard(async ({ path, locale, market, viewport, fullPage }) => {
|
|
468
|
+
const rec = requireDevServer(theme.root)
|
|
469
|
+
const url = browserUrl(rec.url, { path, locale, market })
|
|
470
|
+
const r = await browser.withPage(url, { viewport }, async (page) => {
|
|
471
|
+
if (!fullPage) return page.screenshot({ type: 'png' })
|
|
472
|
+
const vh = page.viewportSize().height
|
|
473
|
+
const h = await page.evaluate(() => document.documentElement.scrollHeight)
|
|
474
|
+
return page.screenshot({ type: 'png', fullPage: true, clip: { x: 0, y: 0, width: page.viewportSize().width, height: Math.min(h, vh * 4) } })
|
|
475
|
+
})
|
|
476
|
+
const png = r.result
|
|
477
|
+
const structured = { ok: true, url, status: r.status, viewport: r.viewport, bytes: png.length, consoleErrors: r.errors }
|
|
478
|
+
return {
|
|
479
|
+
content: [
|
|
480
|
+
{ type: 'image', data: png.toString('base64'), mimeType: 'image/png' },
|
|
481
|
+
{ type: 'text', text: `${url} → ${r.status} · ${r.viewport.width}×${r.viewport.height}${fullPage ? ' full page' : ''} · ${png.length} B · ${r.errors.length ? `console errors: ${r.errors.map((e) => e.message).join(' | ')}` : 'no console errors'}` },
|
|
482
|
+
],
|
|
483
|
+
structuredContent: structured,
|
|
484
|
+
}
|
|
485
|
+
}),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
// ── stores ────────────────────────────────────────────────────────────────
|
|
489
|
+
server.registerTool(
|
|
490
|
+
'list_dev_stores',
|
|
491
|
+
{
|
|
492
|
+
title: 'List your development stores',
|
|
493
|
+
description:
|
|
494
|
+
"The development stores your Tanqory login can use (`tanqory stores --json`), and which one this theme is linked to (tanqory.config.json). Read-only; it never creates a store and never takes a store id. When you are not logged in the answer says to run `tanqory login` in a terminal.",
|
|
495
|
+
inputSchema: {},
|
|
496
|
+
outputSchema: { ...base, stores: z.array(z.any()).optional(), linked: z.any().optional(), raw: z.any().optional() },
|
|
497
|
+
annotations: { ...READ, openWorldHint: true },
|
|
498
|
+
},
|
|
499
|
+
guard(async () => {
|
|
500
|
+
const env = await runCli(['stores'], cliOpts)
|
|
501
|
+
if (!env.ok) return fail(env.error?.code ?? 'stores_failed', env.error?.message ?? '`tanqory stores` failed', env.error?.hint ?? 'Run `tanqory login` in a terminal, then try again.')
|
|
502
|
+
const stores = Array.isArray(env.data) ? env.data : Array.isArray(env.data?.stores) ? env.data.stores : []
|
|
503
|
+
const linked = theme.config?.store ?? null
|
|
504
|
+
return done({ stores, linked, raw: Array.isArray(env.data) ? undefined : env.data }, [`${stores.length} store(s); this theme is linked to: ${linked ? json(linked) : 'none (tanqory.config.json store is null)'}`, ...stores.map((s) => `- ${s.handle ?? s.name ?? s.id}${s.name && s.handle ? ` (${s.name})` : ''}${s.type ? ` [${s.type}]` : ''}`)].join('\n'))
|
|
505
|
+
}),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
server.registerTool(
|
|
509
|
+
'get_store_data_summary',
|
|
510
|
+
{
|
|
511
|
+
title: 'What the dev store has to design against',
|
|
512
|
+
description:
|
|
513
|
+
'From the running dev server (GET /__tq/store-summary): the products, collections, markets/currencies, languages, menus, pages and blogs of the store `tanqory theme dev` serves (the linked dev store, or the fixture store in mock mode), so you design against real handles and data shapes. Read-only. Needs the dev server running.',
|
|
514
|
+
inputSchema: {},
|
|
515
|
+
outputSchema: { ...base, mode: z.string().optional(), store: z.any().optional(), summary: z.any().optional() },
|
|
516
|
+
annotations: READ,
|
|
517
|
+
},
|
|
518
|
+
guard(async () => {
|
|
519
|
+
const rec = requireDevServer(theme.root)
|
|
520
|
+
const r = await devGet(rec.url, '/__tq/store-summary')
|
|
521
|
+
if (r.status === 404) return fail('store_summary_unavailable', 'This dev server has no /__tq/store-summary.', 'Update @tanqory/cli and restart `tanqory theme dev`.')
|
|
522
|
+
return done({ mode: rec.mode, store: rec.store, summary: r.body })
|
|
523
|
+
}),
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
// ── draft push (backend not live yet: typed refusals) ─────────────────────
|
|
527
|
+
const REFUSAL = {
|
|
528
|
+
code: 'draft_push_unavailable',
|
|
529
|
+
message:
|
|
530
|
+
"Pushing a theme draft is not available yet: the platform's sandboxed build lane for developer themes, draft install and preview channel are not live. Nothing was uploaded, built, installed or published, and no preview URL exists.",
|
|
531
|
+
hint: 'Keep developing locally: `tanqory theme dev` + check_theme until every tier passes. When `tanqory theme push` ships, this tool will wrap it with the same two-step confirm.',
|
|
532
|
+
instead: ['tanqory theme dev (localhost with store data)', 'check_theme (all tiers)', 'screenshot / get_page against the dev server'],
|
|
533
|
+
}
|
|
534
|
+
const refusalOut = { ...base, refused: z.literal(true).optional() }
|
|
535
|
+
const refuse = () => ({ isError: true, content: [{ type: 'text', text: `${REFUSAL.code}: ${REFUSAL.message}\nWhat to do instead: ${REFUSAL.instead.join('; ')}. ${REFUSAL.hint}` }], structuredContent: { ok: false, refused: true, error: REFUSAL } })
|
|
536
|
+
|
|
537
|
+
server.registerTool(
|
|
538
|
+
'push_draft',
|
|
539
|
+
{
|
|
540
|
+
title: 'Push the theme as an unpublished draft (not available yet)',
|
|
541
|
+
description: `Would upload the theme for a sandboxed platform build and install it as an UNPUBLISHED draft on your dev store (two-step: a plan, then confirm). ALWAYS REFUSES TODAY with ${REFUSAL.code}: the platform build lane is not live. It never uploads, never installs, never publishes, and never returns a preview URL. Do not report a push as done.`,
|
|
542
|
+
inputSchema: { confirm: z.boolean().optional().describe('Second step (refused today)'), planDigest: z.string().optional() },
|
|
543
|
+
outputSchema: refusalOut,
|
|
544
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
545
|
+
},
|
|
546
|
+
guard(async () => refuse()),
|
|
547
|
+
)
|
|
548
|
+
server.registerTool(
|
|
549
|
+
'get_build_status',
|
|
550
|
+
{
|
|
551
|
+
title: 'Status of a pushed draft build (not available yet)',
|
|
552
|
+
description: `Would report the platform build of a pushed draft. ALWAYS REFUSES TODAY with ${REFUSAL.code}: no draft can be pushed yet, so no build exists.`,
|
|
553
|
+
inputSchema: { buildId: z.string().max(100).optional() },
|
|
554
|
+
outputSchema: refusalOut,
|
|
555
|
+
annotations: { ...READ, openWorldHint: true },
|
|
556
|
+
},
|
|
557
|
+
guard(async () => refuse()),
|
|
558
|
+
)
|
|
559
|
+
server.registerTool(
|
|
560
|
+
'get_preview_url',
|
|
561
|
+
{
|
|
562
|
+
title: 'Preview URL of a draft (not available yet)',
|
|
563
|
+
description: `Would return the shareable preview and editor URLs of a pushed draft. ALWAYS REFUSES TODAY with ${REFUSAL.code}: there is no draft and no preview URL. Never invent one; localhost (dev_server_status) is the only preview today.`,
|
|
564
|
+
inputSchema: {},
|
|
565
|
+
outputSchema: refusalOut,
|
|
566
|
+
annotations: { ...READ, openWorldHint: true },
|
|
567
|
+
},
|
|
568
|
+
guard(async () => refuse()),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
// ── resources: every document as tanqory://docs/<id> ──────────────────────
|
|
572
|
+
const resources = [...DOCS]
|
|
573
|
+
if (theme.root && existsSync(join(theme.root, 'AGENTS.md'))) resources.unshift({ id: 'theme/agents', title: "This theme's agent instructions (AGENTS.md)", summary: 'The rules, commands and layout of the open theme.', kind: 'markdown' })
|
|
574
|
+
for (const d of resources) {
|
|
575
|
+
server.registerResource(
|
|
576
|
+
d.id.replace(/\//g, '-'),
|
|
577
|
+
`tanqory://docs/${d.id}`,
|
|
578
|
+
{ title: d.title, description: d.summary, mimeType: d.kind === 'json' ? 'application/json' : d.kind === 'typescript' ? 'text/x-typescript' : 'text/markdown' },
|
|
579
|
+
async (uri) => {
|
|
580
|
+
const r = await corpus.get(d.id, { maxChars: 2_000_000 })
|
|
581
|
+
if (r.error) throw new Error(`${uri.href} is not available in this theme`)
|
|
582
|
+
return { contents: [{ uri: uri.href, mimeType: d.kind === 'json' ? 'application/json' : d.kind === 'typescript' ? 'text/x-typescript' : 'text/markdown', text: r.text }] }
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
registerPrompts(server)
|
|
588
|
+
|
|
589
|
+
const close = server.close.bind(server)
|
|
590
|
+
server.close = async () => {
|
|
591
|
+
await browser.close()
|
|
592
|
+
await close()
|
|
593
|
+
}
|
|
594
|
+
return { server, theme, browser }
|
|
595
|
+
}
|