@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 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.
Files changed (80) hide show
  1. package/LICENSE +7 -7
  2. package/README.md +770 -770
  3. package/agents/AGENTS.md +192 -192
  4. package/agents/project/custom.md +21 -21
  5. package/agents/project/next-mui.md +21 -21
  6. package/agents/project/next.md +24 -24
  7. package/agents/project/vanilla.md +22 -22
  8. package/agents/project/vite.md +19 -19
  9. package/bin/hawk-app.mjs +0 -0
  10. package/docs/bridge/README.md +1315 -1315
  11. package/docs/showcase/README.md +47 -47
  12. package/docs/showcase/functions/echo.ts +12 -12
  13. package/docs/showcase/functions/long-task.ts +31 -31
  14. package/docs/showcase/functions/showcase.ts +186 -186
  15. package/docs/showcase/hawk-app.json +73 -73
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
  18. package/docs/showcase/queries/searches-by-day.json +6 -6
  19. package/docs/showcase/queries/searches-by-day.sql +7 -7
  20. package/docs/showcase/src/App.tsx +1436 -1436
  21. package/docs/showcase/src/main.tsx +10 -10
  22. package/docs/showcase/src/styles.css +71 -71
  23. package/docs/showcase/src/useHawkApp.ts +44 -44
  24. package/docs/showcase/src/vite-env.d.ts +1 -1
  25. package/docs/ui/README.md +166 -166
  26. package/package.json +1 -1
  27. package/schema/hawk-app.schema.json +611 -611
  28. package/src/accessibility.mjs +105 -105
  29. package/src/agents.mjs +145 -145
  30. package/src/aliases.mjs +30 -30
  31. package/src/app-icon.mjs +82 -82
  32. package/src/channel.mjs +116 -116
  33. package/src/cli.mjs +825 -825
  34. package/src/clients.mjs +221 -221
  35. package/src/config.mjs +148 -148
  36. package/src/create.mjs +186 -186
  37. package/src/credentials.mjs +56 -56
  38. package/src/data.mjs +169 -169
  39. package/src/deploy.mjs +349 -349
  40. package/src/dev.mjs +459 -459
  41. package/src/doctor.mjs +176 -176
  42. package/src/env.mjs +140 -140
  43. package/src/functions.mjs +313 -313
  44. package/src/login.mjs +166 -166
  45. package/src/logs.mjs +64 -64
  46. package/src/manifest.mjs +425 -425
  47. package/src/mcp-docs.mjs +168 -168
  48. package/src/mcp-hawksearch.mjs +342 -342
  49. package/src/mcp-reports.mjs +130 -130
  50. package/src/mcp-rules.mjs +109 -109
  51. package/src/mcp.mjs +539 -539
  52. package/src/platform.mjs +116 -116
  53. package/src/project-checks.mjs +153 -153
  54. package/src/queries.mjs +289 -289
  55. package/src/scope-check.mjs +91 -91
  56. package/src/secrets.mjs +86 -86
  57. package/src/slug-guard.mjs +80 -80
  58. package/src/sso.mjs +182 -182
  59. package/src/telemetry.mjs +50 -50
  60. package/src/upgrade.mjs +406 -406
  61. package/src/versions.mjs +102 -102
  62. package/templates/next/next.config.ts +13 -13
  63. package/templates/next/package.json +26 -26
  64. package/templates/next/src/lib/useHawkApp.ts +46 -46
  65. package/templates/next-mui/next.config.ts +13 -13
  66. package/templates/next-mui/package.json +30 -30
  67. package/templates/next-mui/src/app/layout.tsx +19 -19
  68. package/templates/next-mui/src/app/page.tsx +78 -78
  69. package/templates/next-mui/src/app/providers.tsx +10 -10
  70. package/templates/preact/package.json +26 -26
  71. package/templates/preact/src/useHawkApp.ts +44 -44
  72. package/templates/preact/src/vite-env.d.ts +1 -1
  73. package/templates/preact/tsconfig.json +17 -17
  74. package/templates/preact/vite.config.ts +11 -11
  75. package/templates/react/package.json +29 -29
  76. package/templates/react/src/useHawkApp.ts +44 -44
  77. package/templates/react/src/vite-env.d.ts +1 -1
  78. package/templates/react/tsconfig.json +16 -16
  79. package/templates/react/vite.config.ts +11 -11
  80. package/templates/vanilla/package.json +15 -15
@@ -1,342 +1,342 @@
1
- // The MCP server's `hawksearch_api` tool: HawkSearch's own API reference (developerdocs.hawksearch.com), with what an app
2
- // can call of it and how. The operations come from the site's llms.txt; each operation's page embeds its OpenAPI
3
- // definition (path, parameters, body and response schemas), which is read when one operation is asked for. What apps
4
- // may call of it (which dashboard resources take writes, which Indexing API operations exist for apps, their scopes and
5
- // limits) is the platform's own GET /meta/rules (mcp-rules.mjs). Public docs: no key, no client data.
6
- import { FALLBACK } from './mcp-rules.mjs';
7
-
8
- export const INDEX_URL = 'https://developerdocs.hawksearch.com/llms.txt';
9
- const CACHE_MS = 10 * 60_000;
10
- const TIMEOUT_MS = 10_000;
11
- const MAX_TEXT = 8000;
12
-
13
- /** The bridge's own helpers for some Indexing API operations; the rest go through index.call(operation, body). */
14
- const INDEX_HELPERS = {
15
- indexes: 'index.indexes()',
16
- current: 'index.current()',
17
- 'index-items': 'index.items.upsert(items, { indexName })',
18
- 'update-items': 'index.items.update(items, { indexName })',
19
- 'delete-items': 'index.items.delete(ids, { indexName })',
20
- create: "index.create({ suffix: 'spring' })",
21
- 'set-current': 'index.setCurrent(indexName)',
22
- 'delete-index': 'index.deleteIndex(indexName)',
23
- 'rebuild-all': 'index.rebuildAll(indexName)',
24
- };
25
-
26
- export const APIS = {
27
- dashboard: 'Dashboard API: app.dashboard.get/post/put/delete',
28
- search: 'Search API: app.hawksearch.search / autocomplete',
29
- recommendations: 'Recommendation API: app.hawksearch.recommendations',
30
- indexing: 'Indexing API: app.hawksearch.index',
31
- tracking: 'Tracking API',
32
- };
33
-
34
- /** Which API an llms.txt "## API Reference: …" section is, or null for the overview. */
35
- function apiOf(section) {
36
- if (/overview/i.test(section)) return null;
37
- if (/hawksearch\.api\s*$/i.test(section)) return 'dashboard';
38
- if (/search\s*api/i.test(section)) return 'search';
39
- if (/recommend/i.test(section)) return 'recommendations';
40
- if (/index/i.test(section)) return 'indexing';
41
- if (/track/i.test(section)) return 'tracking';
42
- return section.trim();
43
- }
44
-
45
- /** The API reference operations in llms.txt: [{ id, title, url, summary, api, group }]. */
46
- export function operationsIn(text) {
47
- const operations = [];
48
- let api = null;
49
- let group = null;
50
- // Some summaries hold a bare \r (a line break in HawkSearch's own text): part of the line, not the end of it.
51
- for (const line of text.replace(/\r\n/g, '\n').replace(/\r/g, ' ').split('\n')) {
52
- const section = /^## (.*)$/.exec(line);
53
- if (section) {
54
- const reference = /^API Reference:\s*(.+)$/.exec(section[1]);
55
- api = reference ? apiOf(reference[1]) : null;
56
- group = null;
57
- continue;
58
- }
59
- if (!api) continue;
60
- const heading = /^- ([^[\]]+?)\s*$/.exec(line);
61
- if (heading) {
62
- group = heading[1];
63
- continue;
64
- }
65
- const link = /^\s*- \[(.+?)\]\((https:\/\/developerdocs\.hawksearch\.com\/reference\/([\w.-]+?)\.md)\)(?::\s*(.*))?$/.exec(line);
66
- if (link) operations.push({ id: link[3], title: link[1], url: link[2], summary: stripHtml(link[4] ?? ''), api, group: group ?? '' });
67
- }
68
- return operations;
69
- }
70
-
71
- const stripHtml = (text) => text.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
72
-
73
- /** What an app can do with a whole group of operations, for the list (the exact answer is per operation). */
74
- function groupAccess(api, group, rules) {
75
- const name = group.toLowerCase();
76
- switch (api) {
77
- case 'dashboard':
78
- if (name === 'index') return 'not through app.dashboard: app.hawksearch.index.rebuild() (hawksearch:index)';
79
- if (name.startsWith('report')) return 'reads with hawksearch:read; for reporting prefer app.analytics.report() (analytics_reports)';
80
- return writable(rules, name) ? 'reads hawksearch:read, writes hawksearch:write' : 'read only (hawksearch:read)';
81
- case 'search':
82
- if (name === 'searchv2') return 'app.hawksearch.search (hawksearch:search)';
83
- if (name === 'autocompletev2') return 'app.hawksearch.autocomplete (hawksearch:search)';
84
- return null;
85
- case 'recommendations':
86
- return rules.recommendations.available ? 'app.hawksearch.recommendations (hawksearch:recommendations)' : null;
87
- case 'indexing':
88
- if (['indexv2', 'hierarchy', 'mapping', 'embedding'].includes(name)) return 'some operations, scope per operation (hawksearch:index, :index:items, :index:manage)';
89
- return null;
90
- default:
91
- return null;
92
- }
93
- }
94
-
95
- const writable = (rules, resource) => rules.dashboard.writeResources.some((r) => r.toLowerCase() === resource.toLowerCase());
96
-
97
- function list(operations, api, rules, from) {
98
- const apis = api ? [api] : [...new Set(operations.map((o) => o.api))];
99
- const lines = [
100
- `HawkSearch's API reference (${INDEX_URL}), with what apps can call through the bridge (the platform's rules, from ${from}).`,
101
- api ? 'Call again with operation: "<id>" for its path, parameters, body and response fields, and the bridge call.' : 'Call again with api: "<name>" for each operation\'s title, or operation: "<id or words>" for one in full.',
102
- ];
103
- for (const name of apis) {
104
- const ofApi = operations.filter((o) => o.api === name);
105
- if (!ofApi.length) continue;
106
- lines.push('', `## ${name}: ${APIS[name] ?? name}`);
107
- for (const group of [...new Set(ofApi.map((o) => o.group))]) {
108
- const ofGroup = ofApi.filter((o) => o.group === group);
109
- const access = groupAccess(name, group, rules);
110
- if (!access) {
111
- lines.push(`- ${group || '(no group)'}: not available to apps (${ofGroup.length} operation${ofGroup.length === 1 ? '' : 's'})`);
112
- } else if (api) {
113
- lines.push(`- ${group}: ${access}`, ...ofGroup.map((o) => ` - ${o.id}: ${o.title}${o.summary && o.summary !== o.title ? ` (${o.summary.slice(0, 120)})` : ''}`));
114
- } else {
115
- lines.push(`- ${group}: ${access}. ${ofGroup.map((o) => o.id).join(', ')}`);
116
- }
117
- }
118
- }
119
- return lines.join('\n');
120
- }
121
-
122
- /** The OpenAPI definition a reference page embeds (its first ```json block), or null. */
123
- export function definitionIn(markdown) {
124
- const block = /```json\s*\n([\s\S]*?)\n```/.exec(markdown);
125
- if (!block) return null;
126
- try {
127
- return JSON.parse(block[1]);
128
- } catch {
129
- return null;
130
- }
131
- }
132
-
133
- const quote = (path) => (path.includes('{') ? `\`${path.replace(/\{(\w+)\}/g, (_, name) => `\${${name}}`)}\`` : `'${path}'`);
134
-
135
- /** A path with its {placeholders} blanked, to compare HawkSearch's paths with the platform's ({notificationId} vs {id}). */
136
- const shape = (path) => path.replace(/^\/+/, '').toLowerCase().replace(/\{[^}]*\}/g, '{}');
137
-
138
- /** How an app calls one operation under the platform's rules: { call, scope, note } or { unavailable }. */
139
- export function bridgeCall(api, method, path, rules = FALLBACK) {
140
- const m = method.toUpperCase();
141
- const rel = path.replace(/^\/+/, '');
142
- if (api === 'dashboard') {
143
- const { writeResources, writesPerMinute, maxBulkItems } = rules.dashboard;
144
- const resourcePath = rel.replace(/^api\/(v\d+\/)?/i, '');
145
- const resource = resourcePath.split('/')[0];
146
- if (m === 'GET') return { call: `await app.dashboard.get(${quote(resourcePath)})`, scope: 'hawksearch:read', note: 'Query parameters go in { query: { … } } as the second argument.' };
147
- if (resource.toLowerCase() === 'index') return { unavailable: 'Index rebuilds go through app.hawksearch.index.rebuild() (hawksearch:index, elevated), not the dashboard API.' };
148
- if (!writable(rules, resource)) return { unavailable: `Writes to ${resource} aren't allowed (403 path_not_allowed): apps write only ${writeResources.join(', ')}.` };
149
- if (m === 'DELETE' && resource.toLowerCase() === 'landingpage' && /^bulk/i.test(resourcePath.split('/')[1] ?? '')) return { unavailable: 'Bulk landing-page deletes are blocked (403 path_not_allowed).' };
150
- const verb = m.toLowerCase();
151
- return {
152
- call: `await app.dashboard.${verb}(${quote(resourcePath)}${verb === 'delete' ? '' : ', body'})`,
153
- scope: 'hawksearch:write (elevated)',
154
- note: `Site keys only; ${writesPerMinute} writes a minute; bulk paths up to ${maxBulkItems} items; audit-logged, never retried.`,
155
- };
156
- }
157
- if (api === 'search') {
158
- const limit = `${rules.search.perMinute} calls a minute.`;
159
- if (rel === 'api/v2/search') return { call: "await app.hawksearch.search({ Keyword: 'boots' })", scope: 'hawksearch:search', note: `The platform sets ClientGuid to the engine's; IsInPreview: true runs on the preview search API. ${limit}` };
160
- if (rel === 'api/v2/autocomplete') return { call: "await app.hawksearch.autocomplete({ Keyword: 'boo' })", scope: 'hawksearch:search', note: `The platform sets ClientGuid to the engine's. ${limit}` };
161
- return { unavailable: 'Apps can call only search and autocomplete of the Search API.' };
162
- }
163
- if (api === 'recommendations') {
164
- const { available, perMinute, maxBulkWidgets } = rules.recommendations;
165
- if (!available) return { unavailable: "This platform has no recommendation API set up (503 recommendations_not_configured)." };
166
- const note = `The platform sets clientGuid, adds visitorId/visitId when you send none, and keeps the call out of the recommendation reports. ${perMinute} calls a minute.`;
167
- if (rel === 'api/recommendation/v2/getwidgetitems') return { call: 'await app.hawksearch.recommendations({ widgetUids: [{ widgetGuid, uniqueId }] })', scope: 'hawksearch:recommendations', note };
168
- if (rel === 'api/recommendation/v2/getbulkwidgetitems') return { call: 'await app.hawksearch.recommendations({ widgets: [ … ] })', scope: 'hawksearch:recommendations', note: `Up to ${maxBulkWidgets} widgets. ${note}` };
169
- return { unavailable: 'Apps get recommendation widgets only (getwidgetitems and getbulkwidgetitems).' };
170
- }
171
- if (api === 'indexing') {
172
- const { operations, maxItemsPerCall, maxIdsPerCall, itemWritesPerMinute, managePerMinute } = rules.indexing;
173
- const found = operations.find((o) => o.method.toUpperCase() === m && shape(o.path) === shape(rel));
174
- if (!found) return { unavailable: "Not on the platform's Indexing API list (group indexing, index templates, unset-current and internal endpoints are left out on purpose)." };
175
- // The app's operation with HawkSearch's own placeholder names (embedding/status/{id} → `embedding/status/${notificationId}`).
176
- const names = [...rel.matchAll(/\{(\w+)\}/g)].map((p) => p[1]);
177
- const operation = found.operation.includes('{') ? quote(found.operation.replace(/\{[^}]*\}/g, () => `{${names.shift() ?? 'id'}}`)) : `'${found.operation}'`;
178
- const helper = INDEX_HELPERS[found.operation];
179
- const write = found.scope !== 'hawksearch:index';
180
- return {
181
- call: `await app.hawksearch.${helper ?? `index.call(${operation}${m === 'POST' ? ', body' : ''})`}`,
182
- scope: `${found.scope} (elevated)`,
183
- note: write
184
- ? `Site keys only; ${maxItemsPerCall} items (or ${maxIdsPerCall} ids) a call; ${found.scope === 'hawksearch:index:items' ? `${itemWritesPerMinute} item writes` : `${managePerMinute} management calls`} a minute; audit-logged, never retried.${found.scope === 'hawksearch:index:items' ? ' Item writes are live at once.' : ''}`
185
- : undefined,
186
- };
187
- }
188
- return { unavailable: "Apps can't call this API." };
189
- }
190
-
191
- const HIDDEN_PARAMETERS = /^x-hawksearch-(group)?apikey$/i;
192
-
193
- function resolve(schema, spec) {
194
- let current = schema;
195
- for (let i = 0; current?.$ref && i < 10; i++) current = spec.components?.schemas?.[current.$ref.split('/').pop()];
196
- return current ?? {};
197
- }
198
-
199
- function typeOf(schema) {
200
- if (!schema) return 'any';
201
- if (schema.$ref) return schema.$ref.split('/').pop();
202
- if (schema.type === 'array') return `${typeOf(schema.items)}[]`;
203
- if (schema.enum) return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
204
- if (schema.pattern && /^\^\([\w|]+\)\$$/.test(schema.pattern)) return schema.pattern.slice(2, -2).split('|').map((v) => `"${v}"`).join(' | ');
205
- if (schema.type === 'object' && schema.additionalProperties) return `{ [key]: ${typeOf(schema.additionalProperties)} }`;
206
- return [schema.type ?? 'object', schema.format].filter(Boolean).join(' ');
207
- }
208
-
209
- const refsOf = (schema) => {
210
- if (!schema) return [];
211
- if (schema.$ref) return [schema.$ref.split('/').pop()];
212
- if (schema.type === 'array') return refsOf(schema.items);
213
- if (schema.additionalProperties) return refsOf(schema.additionalProperties);
214
- return [];
215
- };
216
-
217
- /** A schema's fields, then those of the schemas it refers to (breadth first, each once). */
218
- function describeSchema(label, schema, spec) {
219
- const lines = [`${label}: ${typeOf(schema)}`];
220
- const queue = refsOf(schema);
221
- if (!queue.length) {
222
- const own = resolve(schema, spec);
223
- if (own.properties) lines.push(...fieldLines(own));
224
- return lines;
225
- }
226
- const seen = new Set();
227
- while (queue.length) {
228
- const name = queue.shift();
229
- if (seen.has(name)) continue;
230
- seen.add(name);
231
- const own = spec.components?.schemas?.[name];
232
- if (!own) continue;
233
- lines.push('', `${name}${own.description ? ` (${stripHtml(own.description)})` : ''}:`, ...fieldLines(own));
234
- for (const property of Object.values(own.properties ?? {})) queue.push(...refsOf(property));
235
- }
236
- return lines;
237
- }
238
-
239
- function fieldLines(schema) {
240
- const required = new Set(schema.required ?? []);
241
- return Object.entries(schema.properties ?? {}).map(([name, property]) => {
242
- const description = property.description ? `: ${stripHtml(property.description)}` : '';
243
- return `- ${name} (${typeOf(property)}${required.has(name) ? ', required' : ''}${property.nullable ? ', nullable' : ''})${description}`;
244
- });
245
- }
246
-
247
- const jsonContent = (content) => content?.['application/json'] ?? Object.values(content ?? {})[0];
248
-
249
- function detail(operation, definition, rules) {
250
- const [path, item] = Object.entries(definition?.paths ?? {})[0] ?? [];
251
- const [method, op] = Object.entries(item ?? {}).find(([key]) => /^(get|post|put|delete|patch)$/i.test(key)) ?? [];
252
- if (!op) return `# ${operation.title}\n\nThe page has no OpenAPI definition to read: ${operation.url.replace(/\.md$/, '')}`;
253
- const access = bridgeCall(operation.api, method, path, rules);
254
- const parameters = (op.parameters ?? []).filter((p) => !HIDDEN_PARAMETERS.test(p.name));
255
- const body = jsonContent(op.requestBody?.content)?.schema;
256
- const response = jsonContent(op.responses?.['200']?.content ?? op.responses?.['201']?.content)?.schema;
257
- return [
258
- `# ${operation.id}: ${op.summary ?? operation.title}`,
259
- `${method.toUpperCase()} ${path} (HawkSearch ${operation.api === 'dashboard' ? 'Dashboard' : operation.api} API, ${operation.group})`,
260
- '',
261
- stripHtml(op.description ?? operation.summary ?? ''),
262
- '',
263
- ...(access.unavailable
264
- ? [`Not available to apps: ${access.unavailable}`]
265
- : ['From an app:', access.call, `Scope: ${access.scope}.${access.note ? ` ${access.note}` : ''}`]),
266
- ...(parameters.length
267
- ? ['', 'Parameters:', ...parameters.map((p) => `- ${p.name} (${p.in}, ${typeOf(p.schema)}${p.required ? ', required' : ''})${p.description ? `: ${stripHtml(p.description)}` : ''}`)]
268
- : []),
269
- ...(body ? ['', ...describeSchema('Request body', body, definition)] : []),
270
- ...(response ? ['', ...describeSchema('Response', response, definition)] : []),
271
- '',
272
- `Source: ${operation.url.replace(/\.md$/, '')}`,
273
- ]
274
- .filter((line, i, all) => line !== '' || all[i - 1] !== '')
275
- .join('\n');
276
- }
277
-
278
- async function fetchText(url) {
279
- const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
280
- if (!response.ok) throw new Error(`${url} answered ${response.status}`);
281
- return response.text();
282
- }
283
-
284
- /** The platform's default rules, for a reference made without the platform (tests, or no platform at all). */
285
- const builtInRules = async () => ({ rules: FALLBACK, from: 'the CLI (the platform\'s defaults)' });
286
-
287
- /**
288
- * describe({ api, operation, offset }) for the tool. llms.txt and each page are cached for a while; when the site
289
- * can't be reached, an earlier copy is used if there is one. `rules` is mcp-rules.mjs's createPlatformRules().
290
- */
291
- export function createHawkSearchReference({ load = fetchText, now = Date.now, rules = builtInRules } = {}) {
292
- const cache = new Map();
293
- async function get(url) {
294
- const hit = cache.get(url);
295
- if (hit && now() - hit.at < CACHE_MS) return { text: hit.text };
296
- try {
297
- const text = await load(url);
298
- cache.set(url, { text, at: now() });
299
- return { text };
300
- } catch (error) {
301
- if (hit) return { text: hit.text, stale: `(developerdocs.hawksearch.com couldn't be reached just now: ${error.message}. This is the copy from earlier.)` };
302
- throw error;
303
- }
304
- }
305
-
306
- return async function describe({ api, operation, offset = 0 } = {}) {
307
- let index;
308
- try {
309
- index = await get(INDEX_URL);
310
- } catch (error) {
311
- return { ok: false, text: `Couldn't read HawkSearch's API reference (${error.message}). It's at https://developerdocs.hawksearch.com/reference/getting-started-with-your-api.` };
312
- }
313
- const operations = operationsIn(index.text);
314
- const platform = await rules();
315
- const stale = (loaded) => [loaded.stale, platform.note].filter(Boolean).map((note) => `\n\n${note}`).join('');
316
- if (api && !operations.some((o) => o.api === api)) return { ok: false, text: `No API "${api}". The APIs: ${Object.keys(APIS).join(', ')}.` };
317
- if (operation === undefined) return { ok: true, text: list(operations, api, platform.rules, platform.from) + stale(index) };
318
-
319
- const wanted = operation.trim().toLowerCase();
320
- const pool = api ? operations.filter((o) => o.api === api) : operations;
321
- let matches = pool.filter((o) => o.id.toLowerCase() === wanted);
322
- if (!matches.length) {
323
- const words = wanted.split(/[^a-z0-9_]+/).filter((w) => w.length > 1);
324
- matches = pool.filter((o) => words.length && words.every((w) => `${o.id} ${o.title} ${o.summary} ${o.group}`.toLowerCase().includes(w)));
325
- }
326
- if (!matches.length) return { ok: false, text: `No operation matches "${operation}". Call without operation for the list.` };
327
- if (matches.length > 1) {
328
- return { ok: true, text: `Several operations match "${operation}"; call again with one id:\n${matches.slice(0, 20).map((o) => `- ${o.id}: ${o.title} (${o.api}, ${o.group})`).join('\n')}${matches.length > 20 ? `\n… and ${matches.length - 20} more: use more words.` : ''}` };
329
- }
330
-
331
- const [found] = matches;
332
- let page;
333
- try {
334
- page = await get(found.url);
335
- } catch (error) {
336
- return { ok: false, text: `Couldn't read ${found.url} (${error.message}).` };
337
- }
338
- const text = detail(found, definitionIn(page.text), platform.rules).slice(offset);
339
- const cut = text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (more: call again with offset ${offset + MAX_TEXT})` : text;
340
- return { ok: true, text: cut + stale(page) };
341
- };
342
- }
1
+ // The MCP server's `hawksearch_api` tool: HawkSearch's own API reference (developerdocs.hawksearch.com), with what an app
2
+ // can call of it and how. The operations come from the site's llms.txt; each operation's page embeds its OpenAPI
3
+ // definition (path, parameters, body and response schemas), which is read when one operation is asked for. What apps
4
+ // may call of it (which dashboard resources take writes, which Indexing API operations exist for apps, their scopes and
5
+ // limits) is the platform's own GET /meta/rules (mcp-rules.mjs). Public docs: no key, no client data.
6
+ import { FALLBACK } from './mcp-rules.mjs';
7
+
8
+ export const INDEX_URL = 'https://developerdocs.hawksearch.com/llms.txt';
9
+ const CACHE_MS = 10 * 60_000;
10
+ const TIMEOUT_MS = 10_000;
11
+ const MAX_TEXT = 8000;
12
+
13
+ /** The bridge's own helpers for some Indexing API operations; the rest go through index.call(operation, body). */
14
+ const INDEX_HELPERS = {
15
+ indexes: 'index.indexes()',
16
+ current: 'index.current()',
17
+ 'index-items': 'index.items.upsert(items, { indexName })',
18
+ 'update-items': 'index.items.update(items, { indexName })',
19
+ 'delete-items': 'index.items.delete(ids, { indexName })',
20
+ create: "index.create({ suffix: 'spring' })",
21
+ 'set-current': 'index.setCurrent(indexName)',
22
+ 'delete-index': 'index.deleteIndex(indexName)',
23
+ 'rebuild-all': 'index.rebuildAll(indexName)',
24
+ };
25
+
26
+ export const APIS = {
27
+ dashboard: 'Dashboard API: app.dashboard.get/post/put/delete',
28
+ search: 'Search API: app.hawksearch.search / autocomplete',
29
+ recommendations: 'Recommendation API: app.hawksearch.recommendations',
30
+ indexing: 'Indexing API: app.hawksearch.index',
31
+ tracking: 'Tracking API',
32
+ };
33
+
34
+ /** Which API an llms.txt "## API Reference: …" section is, or null for the overview. */
35
+ function apiOf(section) {
36
+ if (/overview/i.test(section)) return null;
37
+ if (/hawksearch\.api\s*$/i.test(section)) return 'dashboard';
38
+ if (/search\s*api/i.test(section)) return 'search';
39
+ if (/recommend/i.test(section)) return 'recommendations';
40
+ if (/index/i.test(section)) return 'indexing';
41
+ if (/track/i.test(section)) return 'tracking';
42
+ return section.trim();
43
+ }
44
+
45
+ /** The API reference operations in llms.txt: [{ id, title, url, summary, api, group }]. */
46
+ export function operationsIn(text) {
47
+ const operations = [];
48
+ let api = null;
49
+ let group = null;
50
+ // Some summaries hold a bare \r (a line break in HawkSearch's own text): part of the line, not the end of it.
51
+ for (const line of text.replace(/\r\n/g, '\n').replace(/\r/g, ' ').split('\n')) {
52
+ const section = /^## (.*)$/.exec(line);
53
+ if (section) {
54
+ const reference = /^API Reference:\s*(.+)$/.exec(section[1]);
55
+ api = reference ? apiOf(reference[1]) : null;
56
+ group = null;
57
+ continue;
58
+ }
59
+ if (!api) continue;
60
+ const heading = /^- ([^[\]]+?)\s*$/.exec(line);
61
+ if (heading) {
62
+ group = heading[1];
63
+ continue;
64
+ }
65
+ const link = /^\s*- \[(.+?)\]\((https:\/\/developerdocs\.hawksearch\.com\/reference\/([\w.-]+?)\.md)\)(?::\s*(.*))?$/.exec(line);
66
+ if (link) operations.push({ id: link[3], title: link[1], url: link[2], summary: stripHtml(link[4] ?? ''), api, group: group ?? '' });
67
+ }
68
+ return operations;
69
+ }
70
+
71
+ const stripHtml = (text) => text.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
72
+
73
+ /** What an app can do with a whole group of operations, for the list (the exact answer is per operation). */
74
+ function groupAccess(api, group, rules) {
75
+ const name = group.toLowerCase();
76
+ switch (api) {
77
+ case 'dashboard':
78
+ if (name === 'index') return 'not through app.dashboard: app.hawksearch.index.rebuild() (hawksearch:index)';
79
+ if (name.startsWith('report')) return 'reads with hawksearch:read; for reporting prefer app.analytics.report() (analytics_reports)';
80
+ return writable(rules, name) ? 'reads hawksearch:read, writes hawksearch:write' : 'read only (hawksearch:read)';
81
+ case 'search':
82
+ if (name === 'searchv2') return 'app.hawksearch.search (hawksearch:search)';
83
+ if (name === 'autocompletev2') return 'app.hawksearch.autocomplete (hawksearch:search)';
84
+ return null;
85
+ case 'recommendations':
86
+ return rules.recommendations.available ? 'app.hawksearch.recommendations (hawksearch:recommendations)' : null;
87
+ case 'indexing':
88
+ if (['indexv2', 'hierarchy', 'mapping', 'embedding'].includes(name)) return 'some operations, scope per operation (hawksearch:index, :index:items, :index:manage)';
89
+ return null;
90
+ default:
91
+ return null;
92
+ }
93
+ }
94
+
95
+ const writable = (rules, resource) => rules.dashboard.writeResources.some((r) => r.toLowerCase() === resource.toLowerCase());
96
+
97
+ function list(operations, api, rules, from) {
98
+ const apis = api ? [api] : [...new Set(operations.map((o) => o.api))];
99
+ const lines = [
100
+ `HawkSearch's API reference (${INDEX_URL}), with what apps can call through the bridge (the platform's rules, from ${from}).`,
101
+ api ? 'Call again with operation: "<id>" for its path, parameters, body and response fields, and the bridge call.' : 'Call again with api: "<name>" for each operation\'s title, or operation: "<id or words>" for one in full.',
102
+ ];
103
+ for (const name of apis) {
104
+ const ofApi = operations.filter((o) => o.api === name);
105
+ if (!ofApi.length) continue;
106
+ lines.push('', `## ${name}: ${APIS[name] ?? name}`);
107
+ for (const group of [...new Set(ofApi.map((o) => o.group))]) {
108
+ const ofGroup = ofApi.filter((o) => o.group === group);
109
+ const access = groupAccess(name, group, rules);
110
+ if (!access) {
111
+ lines.push(`- ${group || '(no group)'}: not available to apps (${ofGroup.length} operation${ofGroup.length === 1 ? '' : 's'})`);
112
+ } else if (api) {
113
+ lines.push(`- ${group}: ${access}`, ...ofGroup.map((o) => ` - ${o.id}: ${o.title}${o.summary && o.summary !== o.title ? ` (${o.summary.slice(0, 120)})` : ''}`));
114
+ } else {
115
+ lines.push(`- ${group}: ${access}. ${ofGroup.map((o) => o.id).join(', ')}`);
116
+ }
117
+ }
118
+ }
119
+ return lines.join('\n');
120
+ }
121
+
122
+ /** The OpenAPI definition a reference page embeds (its first ```json block), or null. */
123
+ export function definitionIn(markdown) {
124
+ const block = /```json\s*\n([\s\S]*?)\n```/.exec(markdown);
125
+ if (!block) return null;
126
+ try {
127
+ return JSON.parse(block[1]);
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ const quote = (path) => (path.includes('{') ? `\`${path.replace(/\{(\w+)\}/g, (_, name) => `\${${name}}`)}\`` : `'${path}'`);
134
+
135
+ /** A path with its {placeholders} blanked, to compare HawkSearch's paths with the platform's ({notificationId} vs {id}). */
136
+ const shape = (path) => path.replace(/^\/+/, '').toLowerCase().replace(/\{[^}]*\}/g, '{}');
137
+
138
+ /** How an app calls one operation under the platform's rules: { call, scope, note } or { unavailable }. */
139
+ export function bridgeCall(api, method, path, rules = FALLBACK) {
140
+ const m = method.toUpperCase();
141
+ const rel = path.replace(/^\/+/, '');
142
+ if (api === 'dashboard') {
143
+ const { writeResources, writesPerMinute, maxBulkItems } = rules.dashboard;
144
+ const resourcePath = rel.replace(/^api\/(v\d+\/)?/i, '');
145
+ const resource = resourcePath.split('/')[0];
146
+ if (m === 'GET') return { call: `await app.dashboard.get(${quote(resourcePath)})`, scope: 'hawksearch:read', note: 'Query parameters go in { query: { … } } as the second argument.' };
147
+ if (resource.toLowerCase() === 'index') return { unavailable: 'Index rebuilds go through app.hawksearch.index.rebuild() (hawksearch:index, elevated), not the dashboard API.' };
148
+ if (!writable(rules, resource)) return { unavailable: `Writes to ${resource} aren't allowed (403 path_not_allowed): apps write only ${writeResources.join(', ')}.` };
149
+ if (m === 'DELETE' && resource.toLowerCase() === 'landingpage' && /^bulk/i.test(resourcePath.split('/')[1] ?? '')) return { unavailable: 'Bulk landing-page deletes are blocked (403 path_not_allowed).' };
150
+ const verb = m.toLowerCase();
151
+ return {
152
+ call: `await app.dashboard.${verb}(${quote(resourcePath)}${verb === 'delete' ? '' : ', body'})`,
153
+ scope: 'hawksearch:write (elevated)',
154
+ note: `Site keys only; ${writesPerMinute} writes a minute; bulk paths up to ${maxBulkItems} items; audit-logged, never retried.`,
155
+ };
156
+ }
157
+ if (api === 'search') {
158
+ const limit = `${rules.search.perMinute} calls a minute.`;
159
+ if (rel === 'api/v2/search') return { call: "await app.hawksearch.search({ Keyword: 'boots' })", scope: 'hawksearch:search', note: `The platform sets ClientGuid to the engine's; IsInPreview: true runs on the preview search API. ${limit}` };
160
+ if (rel === 'api/v2/autocomplete') return { call: "await app.hawksearch.autocomplete({ Keyword: 'boo' })", scope: 'hawksearch:search', note: `The platform sets ClientGuid to the engine's. ${limit}` };
161
+ return { unavailable: 'Apps can call only search and autocomplete of the Search API.' };
162
+ }
163
+ if (api === 'recommendations') {
164
+ const { available, perMinute, maxBulkWidgets } = rules.recommendations;
165
+ if (!available) return { unavailable: "This platform has no recommendation API set up (503 recommendations_not_configured)." };
166
+ const note = `The platform sets clientGuid, adds visitorId/visitId when you send none, and keeps the call out of the recommendation reports. ${perMinute} calls a minute.`;
167
+ if (rel === 'api/recommendation/v2/getwidgetitems') return { call: 'await app.hawksearch.recommendations({ widgetUids: [{ widgetGuid, uniqueId }] })', scope: 'hawksearch:recommendations', note };
168
+ if (rel === 'api/recommendation/v2/getbulkwidgetitems') return { call: 'await app.hawksearch.recommendations({ widgets: [ … ] })', scope: 'hawksearch:recommendations', note: `Up to ${maxBulkWidgets} widgets. ${note}` };
169
+ return { unavailable: 'Apps get recommendation widgets only (getwidgetitems and getbulkwidgetitems).' };
170
+ }
171
+ if (api === 'indexing') {
172
+ const { operations, maxItemsPerCall, maxIdsPerCall, itemWritesPerMinute, managePerMinute } = rules.indexing;
173
+ const found = operations.find((o) => o.method.toUpperCase() === m && shape(o.path) === shape(rel));
174
+ if (!found) return { unavailable: "Not on the platform's Indexing API list (group indexing, index templates, unset-current and internal endpoints are left out on purpose)." };
175
+ // The app's operation with HawkSearch's own placeholder names (embedding/status/{id} → `embedding/status/${notificationId}`).
176
+ const names = [...rel.matchAll(/\{(\w+)\}/g)].map((p) => p[1]);
177
+ const operation = found.operation.includes('{') ? quote(found.operation.replace(/\{[^}]*\}/g, () => `{${names.shift() ?? 'id'}}`)) : `'${found.operation}'`;
178
+ const helper = INDEX_HELPERS[found.operation];
179
+ const write = found.scope !== 'hawksearch:index';
180
+ return {
181
+ call: `await app.hawksearch.${helper ?? `index.call(${operation}${m === 'POST' ? ', body' : ''})`}`,
182
+ scope: `${found.scope} (elevated)`,
183
+ note: write
184
+ ? `Site keys only; ${maxItemsPerCall} items (or ${maxIdsPerCall} ids) a call; ${found.scope === 'hawksearch:index:items' ? `${itemWritesPerMinute} item writes` : `${managePerMinute} management calls`} a minute; audit-logged, never retried.${found.scope === 'hawksearch:index:items' ? ' Item writes are live at once.' : ''}`
185
+ : undefined,
186
+ };
187
+ }
188
+ return { unavailable: "Apps can't call this API." };
189
+ }
190
+
191
+ const HIDDEN_PARAMETERS = /^x-hawksearch-(group)?apikey$/i;
192
+
193
+ function resolve(schema, spec) {
194
+ let current = schema;
195
+ for (let i = 0; current?.$ref && i < 10; i++) current = spec.components?.schemas?.[current.$ref.split('/').pop()];
196
+ return current ?? {};
197
+ }
198
+
199
+ function typeOf(schema) {
200
+ if (!schema) return 'any';
201
+ if (schema.$ref) return schema.$ref.split('/').pop();
202
+ if (schema.type === 'array') return `${typeOf(schema.items)}[]`;
203
+ if (schema.enum) return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
204
+ if (schema.pattern && /^\^\([\w|]+\)\$$/.test(schema.pattern)) return schema.pattern.slice(2, -2).split('|').map((v) => `"${v}"`).join(' | ');
205
+ if (schema.type === 'object' && schema.additionalProperties) return `{ [key]: ${typeOf(schema.additionalProperties)} }`;
206
+ return [schema.type ?? 'object', schema.format].filter(Boolean).join(' ');
207
+ }
208
+
209
+ const refsOf = (schema) => {
210
+ if (!schema) return [];
211
+ if (schema.$ref) return [schema.$ref.split('/').pop()];
212
+ if (schema.type === 'array') return refsOf(schema.items);
213
+ if (schema.additionalProperties) return refsOf(schema.additionalProperties);
214
+ return [];
215
+ };
216
+
217
+ /** A schema's fields, then those of the schemas it refers to (breadth first, each once). */
218
+ function describeSchema(label, schema, spec) {
219
+ const lines = [`${label}: ${typeOf(schema)}`];
220
+ const queue = refsOf(schema);
221
+ if (!queue.length) {
222
+ const own = resolve(schema, spec);
223
+ if (own.properties) lines.push(...fieldLines(own));
224
+ return lines;
225
+ }
226
+ const seen = new Set();
227
+ while (queue.length) {
228
+ const name = queue.shift();
229
+ if (seen.has(name)) continue;
230
+ seen.add(name);
231
+ const own = spec.components?.schemas?.[name];
232
+ if (!own) continue;
233
+ lines.push('', `${name}${own.description ? ` (${stripHtml(own.description)})` : ''}:`, ...fieldLines(own));
234
+ for (const property of Object.values(own.properties ?? {})) queue.push(...refsOf(property));
235
+ }
236
+ return lines;
237
+ }
238
+
239
+ function fieldLines(schema) {
240
+ const required = new Set(schema.required ?? []);
241
+ return Object.entries(schema.properties ?? {}).map(([name, property]) => {
242
+ const description = property.description ? `: ${stripHtml(property.description)}` : '';
243
+ return `- ${name} (${typeOf(property)}${required.has(name) ? ', required' : ''}${property.nullable ? ', nullable' : ''})${description}`;
244
+ });
245
+ }
246
+
247
+ const jsonContent = (content) => content?.['application/json'] ?? Object.values(content ?? {})[0];
248
+
249
+ function detail(operation, definition, rules) {
250
+ const [path, item] = Object.entries(definition?.paths ?? {})[0] ?? [];
251
+ const [method, op] = Object.entries(item ?? {}).find(([key]) => /^(get|post|put|delete|patch)$/i.test(key)) ?? [];
252
+ if (!op) return `# ${operation.title}\n\nThe page has no OpenAPI definition to read: ${operation.url.replace(/\.md$/, '')}`;
253
+ const access = bridgeCall(operation.api, method, path, rules);
254
+ const parameters = (op.parameters ?? []).filter((p) => !HIDDEN_PARAMETERS.test(p.name));
255
+ const body = jsonContent(op.requestBody?.content)?.schema;
256
+ const response = jsonContent(op.responses?.['200']?.content ?? op.responses?.['201']?.content)?.schema;
257
+ return [
258
+ `# ${operation.id}: ${op.summary ?? operation.title}`,
259
+ `${method.toUpperCase()} ${path} (HawkSearch ${operation.api === 'dashboard' ? 'Dashboard' : operation.api} API, ${operation.group})`,
260
+ '',
261
+ stripHtml(op.description ?? operation.summary ?? ''),
262
+ '',
263
+ ...(access.unavailable
264
+ ? [`Not available to apps: ${access.unavailable}`]
265
+ : ['From an app:', access.call, `Scope: ${access.scope}.${access.note ? ` ${access.note}` : ''}`]),
266
+ ...(parameters.length
267
+ ? ['', 'Parameters:', ...parameters.map((p) => `- ${p.name} (${p.in}, ${typeOf(p.schema)}${p.required ? ', required' : ''})${p.description ? `: ${stripHtml(p.description)}` : ''}`)]
268
+ : []),
269
+ ...(body ? ['', ...describeSchema('Request body', body, definition)] : []),
270
+ ...(response ? ['', ...describeSchema('Response', response, definition)] : []),
271
+ '',
272
+ `Source: ${operation.url.replace(/\.md$/, '')}`,
273
+ ]
274
+ .filter((line, i, all) => line !== '' || all[i - 1] !== '')
275
+ .join('\n');
276
+ }
277
+
278
+ async function fetchText(url) {
279
+ const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
280
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`);
281
+ return response.text();
282
+ }
283
+
284
+ /** The platform's default rules, for a reference made without the platform (tests, or no platform at all). */
285
+ const builtInRules = async () => ({ rules: FALLBACK, from: 'the CLI (the platform\'s defaults)' });
286
+
287
+ /**
288
+ * describe({ api, operation, offset }) for the tool. llms.txt and each page are cached for a while; when the site
289
+ * can't be reached, an earlier copy is used if there is one. `rules` is mcp-rules.mjs's createPlatformRules().
290
+ */
291
+ export function createHawkSearchReference({ load = fetchText, now = Date.now, rules = builtInRules } = {}) {
292
+ const cache = new Map();
293
+ async function get(url) {
294
+ const hit = cache.get(url);
295
+ if (hit && now() - hit.at < CACHE_MS) return { text: hit.text };
296
+ try {
297
+ const text = await load(url);
298
+ cache.set(url, { text, at: now() });
299
+ return { text };
300
+ } catch (error) {
301
+ if (hit) return { text: hit.text, stale: `(developerdocs.hawksearch.com couldn't be reached just now: ${error.message}. This is the copy from earlier.)` };
302
+ throw error;
303
+ }
304
+ }
305
+
306
+ return async function describe({ api, operation, offset = 0 } = {}) {
307
+ let index;
308
+ try {
309
+ index = await get(INDEX_URL);
310
+ } catch (error) {
311
+ return { ok: false, text: `Couldn't read HawkSearch's API reference (${error.message}). It's at https://developerdocs.hawksearch.com/reference/getting-started-with-your-api.` };
312
+ }
313
+ const operations = operationsIn(index.text);
314
+ const platform = await rules();
315
+ const stale = (loaded) => [loaded.stale, platform.note].filter(Boolean).map((note) => `\n\n${note}`).join('');
316
+ if (api && !operations.some((o) => o.api === api)) return { ok: false, text: `No API "${api}". The APIs: ${Object.keys(APIS).join(', ')}.` };
317
+ if (operation === undefined) return { ok: true, text: list(operations, api, platform.rules, platform.from) + stale(index) };
318
+
319
+ const wanted = operation.trim().toLowerCase();
320
+ const pool = api ? operations.filter((o) => o.api === api) : operations;
321
+ let matches = pool.filter((o) => o.id.toLowerCase() === wanted);
322
+ if (!matches.length) {
323
+ const words = wanted.split(/[^a-z0-9_]+/).filter((w) => w.length > 1);
324
+ matches = pool.filter((o) => words.length && words.every((w) => `${o.id} ${o.title} ${o.summary} ${o.group}`.toLowerCase().includes(w)));
325
+ }
326
+ if (!matches.length) return { ok: false, text: `No operation matches "${operation}". Call without operation for the list.` };
327
+ if (matches.length > 1) {
328
+ return { ok: true, text: `Several operations match "${operation}"; call again with one id:\n${matches.slice(0, 20).map((o) => `- ${o.id}: ${o.title} (${o.api}, ${o.group})`).join('\n')}${matches.length > 20 ? `\n… and ${matches.length - 20} more: use more words.` : ''}` };
329
+ }
330
+
331
+ const [found] = matches;
332
+ let page;
333
+ try {
334
+ page = await get(found.url);
335
+ } catch (error) {
336
+ return { ok: false, text: `Couldn't read ${found.url} (${error.message}).` };
337
+ }
338
+ const text = detail(found, definitionIn(page.text), platform.rules).slice(offset);
339
+ const cut = text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (more: call again with offset ${offset + MAX_TEXT})` : text;
340
+ return { ok: true, text: cut + stale(page) };
341
+ };
342
+ }