kizlo 0.20.2 → 0.22.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 (47) hide show
  1. package/dist/category/index.js +3 -3
  2. package/dist/category/schema.d.ts +1 -1
  3. package/dist/cli/commands/_setup.js +5 -1
  4. package/dist/cli/commands/dev.js +63 -20
  5. package/dist/cli/daemon/config.js +27 -3
  6. package/dist/cli/daemon/generate.js +8 -2
  7. package/dist/cli/daemon/watch.js +11 -3
  8. package/dist/cli/mcp/index.js +46 -0
  9. package/dist/cli/mcp/routes.js +42 -0
  10. package/dist/cli/mcp/schema.js +212 -0
  11. package/dist/cli/mcp/search.js +255 -0
  12. package/dist/cli/mcp/server.js +248 -0
  13. package/dist/cli/mcp/state.js +7 -0
  14. package/dist/cli/mcp/tools.js +116 -0
  15. package/dist/comment/index.js +4 -4
  16. package/dist/config.d.ts +17 -1
  17. package/dist/email/service.js +1 -1
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +2 -2
  20. package/dist/integrations/astro/integration.js +2 -0
  21. package/dist/integrations/nextjs/integration.js +2 -0
  22. package/dist/integrations/node/integration.js +2 -0
  23. package/dist/integrations/tanstack-start/integration.js +2 -0
  24. package/dist/kizlo.js +4 -3
  25. package/dist/menu/utils.js +2 -2
  26. package/dist/package.js +1 -1
  27. package/dist/page/index.js +4 -4
  28. package/dist/page/schema.d.ts +1 -1
  29. package/dist/post/index.js +4 -4
  30. package/dist/post/schema.d.ts +1 -1
  31. package/dist/procedures.d.ts +7 -7
  32. package/dist/seo/index.js +13 -11
  33. package/dist/seo/types.d.ts +4 -4
  34. package/dist/settings/service.d.ts +11 -11
  35. package/dist/settings/service.js +15 -15
  36. package/dist/shared/constants.js +3 -1
  37. package/dist/shared/integration.d.ts +9 -1
  38. package/dist/shared/integration.js +6 -3
  39. package/dist/tag/index.js +3 -3
  40. package/dist/tag/schema.d.ts +1 -1
  41. package/dist/wordpress/endpoint.d.ts +13 -3
  42. package/dist/wordpress/endpoint.js +24 -12
  43. package/dist/wordpress/generate.js +57 -9
  44. package/dist/wordpress/index.d.ts +1 -1
  45. package/dist/wordpress/introspection.js +26 -11
  46. package/dist/wordpress/types.d.ts +16 -2
  47. package/package.json +4 -2
@@ -0,0 +1,212 @@
1
+ //#region src/cli/mcp/schema.ts
2
+ /** Where a converted definition is emitted, and the prefix a pointer to one carries. */
3
+ const DEFS = "$defs";
4
+ /**
5
+ * Keywords that mean the same in both dialects and are copied straight across. The validation half
6
+ * is what `SchemaCoercer` accepts on the WordPress side, so a bound the contract publishes reaches
7
+ * the client that has to satisfy it instead of being dropped in translation.
8
+ */
9
+ const COPIED = [
10
+ "title",
11
+ "description",
12
+ "deprecated",
13
+ "default",
14
+ "enum",
15
+ "format",
16
+ "pattern",
17
+ "minLength",
18
+ "maxLength",
19
+ "minItems",
20
+ "maxItems",
21
+ "minProperties",
22
+ "maxProperties",
23
+ "minimum",
24
+ "maximum",
25
+ "multipleOf",
26
+ "uniqueItems",
27
+ "exclusiveMinimum",
28
+ "exclusiveMaximum"
29
+ ];
30
+ function run(document) {
31
+ return {
32
+ document,
33
+ defs: /* @__PURE__ */ new Map(),
34
+ extending: /* @__PURE__ */ new Set()
35
+ };
36
+ }
37
+ /**
38
+ * Turn an introspection schema into the JSON Schema an MCP client validates a tool call against.
39
+ *
40
+ * Three things separate the two dialects. Introspection marks a field required on the field itself,
41
+ * where JSON Schema lists the names on the object holding them. `$ref` and `$extends` name entries in
42
+ * `document.schemas` rather than being JSON pointers, so both are rewritten here: a `$ref` becomes a
43
+ * pointer into `$defs`, and an `$extends` is flattened. And `file` is not a JSON Schema type; it
44
+ * becomes the `string`/`binary` pair OpenAPI uses for the same thing.
45
+ */
46
+ function toJsonSchema(schema, document) {
47
+ const current = run(document);
48
+ return withDefs(convert(schema, current), current);
49
+ }
50
+ /**
51
+ * The same, for an operation's request rather than one schema: each part it declares becomes a
52
+ * property, so a caller validates and builds `{ params, query, body }` — the shape the request
53
+ * builder takes. A part is required when the schema under it demands anything.
54
+ *
55
+ * The three parts share one run, so a definition two of them reach is emitted once at this root and
56
+ * referenced from both rather than copied into each.
57
+ */
58
+ function inputJsonSchema(input, document) {
59
+ const current = run(document);
60
+ const properties = {};
61
+ const required = [];
62
+ for (const part of [
63
+ "params",
64
+ "query",
65
+ "body"
66
+ ]) {
67
+ const schema = input[part];
68
+ if (!schema) continue;
69
+ properties[part] = convert(schema, current);
70
+ if (schema.type === "object" ? demandsProperties(schema, document, /* @__PURE__ */ new Set()) : schema.required !== false) required.push(part);
71
+ }
72
+ return withDefs({
73
+ type: "object",
74
+ properties,
75
+ ...required.length > 0 ? { required } : {}
76
+ }, current);
77
+ }
78
+ /** Attach what the result reached, so the schema a client receives can be read without the document. */
79
+ function withDefs(schema, current) {
80
+ if (current.defs.size === 0) return schema;
81
+ return {
82
+ ...schema,
83
+ [DEFS]: Object.fromEntries(current.defs)
84
+ };
85
+ }
86
+ /** Whether an object schema demands anything, through the references that carry its properties. */
87
+ function demandsProperties(schema, document, seen) {
88
+ if (Object.values(schema.properties ?? {}).some((child) => child.required)) return true;
89
+ for (const id of [...schema.$ref ? [schema.$ref] : [], ...names(schema.$extends)]) {
90
+ const target = document.schemas[id];
91
+ if (!target || seen.has(id)) continue;
92
+ if (demandsProperties(target, document, new Set([...seen, id]))) return true;
93
+ }
94
+ return false;
95
+ }
96
+ /**
97
+ * A schema is its own keywords laid over whatever it references, and the two kinds of reference are
98
+ * rewritten differently. `$extends` is an overlay whose purpose is redeclaring an inherited property,
99
+ * which a pointer cannot express, so its base is flattened and the schema's own keywords win.
100
+ * `$ref` names the whole shape, so it stays a reference: one definition, pointed at from everywhere
101
+ * that reaches it, which is what lets a schema describe its own recursion instead of being truncated.
102
+ */
103
+ function convert(schema, current) {
104
+ let result = own(schema, current);
105
+ for (const id of names(schema.$extends)) result = merge(inline(id, current), result);
106
+ if (schema.$ref !== void 0) {
107
+ const pointer = define(schema.$ref, current);
108
+ if (pointer !== void 0) result = {
109
+ ...result,
110
+ $ref: pointer
111
+ };
112
+ }
113
+ return schema.nullable ? widenToNull(result) : result;
114
+ }
115
+ /**
116
+ * Let a converted schema accept `null`. A type takes it in place; a schema whose shape came from a
117
+ * reference or a combinator has no type of its own to widen, so the union carries it instead.
118
+ */
119
+ function widenToNull(schema) {
120
+ const type = schema.type;
121
+ if (typeof type === "string") return {
122
+ ...schema,
123
+ type: [type, "null"]
124
+ };
125
+ if (Array.isArray(type)) return type.includes("null") ? schema : {
126
+ ...schema,
127
+ type: [...type, "null"]
128
+ };
129
+ return { anyOf: [schema, { type: "null" }] };
130
+ }
131
+ /**
132
+ * Ensure one `document.schemas` entry is defined, and answer with the pointer that addresses it.
133
+ * Absent for a reference the document does not carry, which the caller leaves inlined.
134
+ */
135
+ function define(id, current) {
136
+ const target = current.document.schemas[id];
137
+ if (!target) return void 0;
138
+ if (!current.defs.has(id)) {
139
+ current.defs.set(id, {});
140
+ current.defs.set(id, convert(target, current));
141
+ }
142
+ return `#/${DEFS}/${escapePointer(id)}`;
143
+ }
144
+ /** Resolve an `$extends` base for flattening, or the empty schema when it is absent or already open. */
145
+ function inline(id, current) {
146
+ const target = current.document.schemas[id];
147
+ if (!target || current.extending.has(id)) return {};
148
+ current.extending.add(id);
149
+ try {
150
+ return convert(target, current);
151
+ } finally {
152
+ current.extending.delete(id);
153
+ }
154
+ }
155
+ /** RFC 6901 escaping, so an id carrying `~` or `/` still addresses the entry it named. */
156
+ function escapePointer(id) {
157
+ return id.replace(/~/g, "~0").replace(/\//g, "~1");
158
+ }
159
+ function names(value) {
160
+ if (value === void 0) return [];
161
+ return Array.isArray(value) ? value : [value];
162
+ }
163
+ /**
164
+ * Lay `own` over `base`. Only the two keywords that describe an object's members combine: everything
165
+ * else is a plain override, since a schema restating a `type` or a `description` means to replace it.
166
+ */
167
+ function merge(base, own$1) {
168
+ const result = {
169
+ ...base,
170
+ ...own$1
171
+ };
172
+ const properties = {
173
+ ...base.properties,
174
+ ...own$1.properties
175
+ };
176
+ if (Object.keys(properties).length > 0) result.properties = properties;
177
+ const required = [...new Set([...base.required ?? [], ...own$1.required ?? []])];
178
+ if (required.length > 0) result.required = required;
179
+ return result;
180
+ }
181
+ /** The schema's own keywords, with the references it is built from left to {@link convert}. */
182
+ function own(schema, current) {
183
+ const result = {};
184
+ for (const keyword of COPIED) {
185
+ const value = schema[keyword];
186
+ if (value !== void 0) result[keyword] = value;
187
+ }
188
+ if (schema.type === "file") {
189
+ result.type = "string";
190
+ result.format = "binary";
191
+ } else if (schema.type) result.type = schema.type;
192
+ if (schema.properties) {
193
+ const properties = {};
194
+ const required = [];
195
+ for (const [name, child] of Object.entries(schema.properties)) {
196
+ properties[name] = convert(child, current);
197
+ if (child.required) required.push(name);
198
+ }
199
+ result.properties = properties;
200
+ if (required.length > 0) result.required = required;
201
+ }
202
+ if (schema.items) result.items = convert(schema.items, current);
203
+ if (schema.anyOf) result.anyOf = schema.anyOf.map((member) => convert(member, current));
204
+ if (schema.oneOf) result.oneOf = schema.oneOf.map((member) => convert(member, current));
205
+ if (typeof schema.additionalProperties === "boolean") result.additionalProperties = schema.additionalProperties;
206
+ else if (schema.additionalProperties) result.additionalProperties = convert(schema.additionalProperties, current);
207
+ if (schema.patternProperties) result.patternProperties = Object.fromEntries(Object.entries(schema.patternProperties).map(([pattern, child]) => [pattern, convert(child, current)]));
208
+ return result;
209
+ }
210
+
211
+ //#endregion
212
+ export { inputJsonSchema, toJsonSchema };
@@ -0,0 +1,255 @@
1
+ import { routesOf, sortRoutes } from "./routes.js";
2
+ import MiniSearch from "minisearch";
3
+
4
+ //#region src/cli/mcp/search.ts
5
+ /**
6
+ * How deep a schema is walked for searchable terms. Deep enough to reach the properties an agent
7
+ * would name, shallow enough that a broad contract does not turn one search into a tree walk.
8
+ */
9
+ const MAX_DEPTH = 6;
10
+ /** Methods that only read, which is what the read/write filter divides the catalog on. */
11
+ const READ_METHODS = new Set([
12
+ "GET",
13
+ "HEAD",
14
+ "OPTIONS"
15
+ ]);
16
+ /** Default page size, and the ceiling a caller may ask for. */
17
+ const DEFAULT_LIMIT = 20;
18
+ const MAX_LIMIT = 100;
19
+ /**
20
+ * How far below the best score in its tier a result may fall and still be returned.
21
+ *
22
+ * Terms are combined permissively, so one common token reaches an enormous number of routes: `post`
23
+ * or `rest` alone selects most of a WordPress contract. Without a floor the tail of those matches is
24
+ * counted as a match, and a caller told "504 of 504" learns nothing and has no reason to trust the
25
+ * ranking. The floor is relative and applied within a tier, so it trims each tier's tail without
26
+ * letting a strong identifier match suppress a weaker but differently-evidenced one.
27
+ */
28
+ const SCORE_FLOOR = .25;
29
+ /**
30
+ * The indexed fields, and the evidence each one reports when it matches. Two fields answer to
31
+ * `description` because a summary and a description are the same thing to someone reading a result.
32
+ */
33
+ const EVIDENCE = {
34
+ route: "route",
35
+ path: "path",
36
+ namespace: "namespace",
37
+ summary: "description",
38
+ description: "description",
39
+ errors: "error",
40
+ inputParams: "input.params.property",
41
+ inputQuery: "input.query.property",
42
+ inputBody: "input.body.property",
43
+ responses: "response.property"
44
+ };
45
+ /**
46
+ * Field weights. `settings` appears in dozens of route names and must count for almost nothing next
47
+ * to a term that appears in two input schemas, which is what TF-IDF gives by construction; these
48
+ * only express the tier preference on top of it.
49
+ */
50
+ const BOOST = {
51
+ route: 8,
52
+ path: 6,
53
+ namespace: 4,
54
+ inputParams: 3,
55
+ inputQuery: 3,
56
+ inputBody: 3,
57
+ errors: 3,
58
+ summary: 2,
59
+ description: 2,
60
+ responses: 1
61
+ };
62
+ /**
63
+ * Rank the document's routes against a query.
64
+ *
65
+ * The index is built per call, because the watcher replaces the document whenever WordPress moves
66
+ * and a cached index would answer for routes that no longer exist. Exact identifier matches are
67
+ * found before the index is consulted, so looking a route up by name is deterministic rather than
68
+ * something a scorer has to be trusted to get right.
69
+ */
70
+ function searchRoutes(document, options = {}) {
71
+ const all = sortRoutes(routesOf(document).values());
72
+ const namespaces = [...new Set(all.map((route) => route.namespace))].sort();
73
+ const ranked = rank(all.filter((route) => matchesFilters(route, options)), document, (options.query ?? "").trim());
74
+ const limit = Math.min(Math.max(options.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
75
+ const offset = decodeCursor(options.cursor);
76
+ const next = offset + limit;
77
+ return {
78
+ count: ranked.length,
79
+ routes: ranked.slice(offset, next),
80
+ ...next < ranked.length ? { nextCursor: encodeCursor(next) } : {},
81
+ namespaces
82
+ };
83
+ }
84
+ function matchesFilters(route, options) {
85
+ if (options.namespace !== void 0 && route.namespace !== options.namespace) return false;
86
+ if (options.method !== void 0 && route.method !== options.method.toUpperCase()) return false;
87
+ if (options.access === "read" && !READ_METHODS.has(route.method)) return false;
88
+ if (options.access === "write" && READ_METHODS.has(route.method)) return false;
89
+ return true;
90
+ }
91
+ /**
92
+ * An empty query browses through the wildcard rather than around the index, so one path answers both
93
+ * and a browse cannot drift from a search in ordering or shape.
94
+ */
95
+ function rank(routes, document, query) {
96
+ const browsing = query === "";
97
+ const index = new MiniSearch({
98
+ fields: Object.keys(BOOST),
99
+ storeFields: ["id"]
100
+ });
101
+ index.addAll(routes.map((route) => indexed(route, document)));
102
+ const byName = new Map(routes.map((route) => [route.name, route]));
103
+ const merged = /* @__PURE__ */ new Map();
104
+ const term = query.toLowerCase();
105
+ if (!browsing) for (const route of routes) {
106
+ const evidence = [];
107
+ if (route.name.toLowerCase() === term) evidence.push({
108
+ field: "route",
109
+ term: query
110
+ });
111
+ if (route.path.toLowerCase() === term) evidence.push({
112
+ field: "path",
113
+ term: query
114
+ });
115
+ if (route.namespace.toLowerCase() === term) evidence.push({
116
+ field: "namespace",
117
+ term: query
118
+ });
119
+ if (evidence.length > 0) merged.set(route.name, {
120
+ tier: 0,
121
+ score: Number.MAX_SAFE_INTEGER,
122
+ evidence
123
+ });
124
+ }
125
+ const found = browsing ? index.search(MiniSearch.wildcard) : index.search(query, {
126
+ prefix: true,
127
+ fuzzy: .2,
128
+ boost: BOOST
129
+ });
130
+ for (const result of found) {
131
+ if (!browsing && result.score <= 0) continue;
132
+ const name = String(result.id);
133
+ const evidence = evidenceOf(result.match);
134
+ const already = merged.get(name);
135
+ if (already) already.evidence = dedupe([...already.evidence, ...evidence]);
136
+ else merged.set(name, {
137
+ tier: tierOf(evidence),
138
+ score: result.score,
139
+ evidence
140
+ });
141
+ }
142
+ const entries = [...merged.entries()];
143
+ const bestByTier = /* @__PURE__ */ new Map();
144
+ for (const [, entry] of entries) bestByTier.set(entry.tier, Math.max(bestByTier.get(entry.tier) ?? 0, entry.score));
145
+ return (browsing ? entries : entries.filter(([, entry]) => entry.score >= (bestByTier.get(entry.tier) ?? 0) * SCORE_FLOOR)).sort(([leftName, left], [rightName, right]) => {
146
+ if (left.tier !== right.tier) return left.tier - right.tier;
147
+ if (left.score !== right.score) return right.score - left.score;
148
+ return leftName.localeCompare(rightName);
149
+ }).flatMap(([name, entry]) => {
150
+ const route = byName.get(name);
151
+ return route ? [describe(route, entry.evidence)] : [];
152
+ });
153
+ }
154
+ /**
155
+ * Which tier a hit belongs to, below the exact identifier matches that take tier 0.
156
+ *
157
+ * What a route takes as an argument comes first, because that is what someone searching for
158
+ * `custom_fields` means. Response properties rank behind it, since generic words such as `title`,
159
+ * `status`, and `date` otherwise select nearly every content route. A partial identifier or a word
160
+ * from a summary ranks last: prefix matching means `custom` reaches every `customers` route, and
161
+ * those must not crowd out the route that actually accepts the field.
162
+ */
163
+ function tierOf(evidence) {
164
+ const fields = new Set(evidence.map((item) => item.field));
165
+ if ([...fields].some((field) => field.startsWith("input.") || field === "error")) return 1;
166
+ if (fields.has("response.property")) return 2;
167
+ return 3;
168
+ }
169
+ function evidenceOf(match) {
170
+ const evidence = [];
171
+ for (const [term, fields] of Object.entries(match)) for (const field of fields) {
172
+ const named = EVIDENCE[field];
173
+ if (named) evidence.push({
174
+ field: named,
175
+ term
176
+ });
177
+ }
178
+ return dedupe(evidence);
179
+ }
180
+ function dedupe(evidence) {
181
+ const seen = /* @__PURE__ */ new Set();
182
+ return evidence.filter((item) => {
183
+ const key = `${item.field} ${item.term}`;
184
+ if (seen.has(key)) return false;
185
+ seen.add(key);
186
+ return true;
187
+ });
188
+ }
189
+ function describe(route, evidence) {
190
+ return {
191
+ name: route.name,
192
+ namespace: route.namespace,
193
+ method: route.method,
194
+ path: route.path,
195
+ ...route.summary !== void 0 ? { summary: route.summary } : {},
196
+ ...route.deprecated ? { deprecated: true } : {},
197
+ evidence
198
+ };
199
+ }
200
+ function indexed(route, document) {
201
+ const { input, responses, errors } = route.operation;
202
+ return {
203
+ id: route.name,
204
+ route: route.name.replace(/[.\-_]/g, " "),
205
+ path: route.path.replace(/[/{}\-_]/g, " "),
206
+ namespace: route.namespace,
207
+ summary: route.summary ?? "",
208
+ description: route.description ?? "",
209
+ errors: errors.join(" "),
210
+ inputParams: terms(input.params, document),
211
+ inputQuery: terms(input.query, document),
212
+ inputBody: terms(input.body, document),
213
+ responses: Object.values(responses).map((response) => `${terms(response.body, document)} ${terms(response.headers, document)}`).join(" ")
214
+ };
215
+ }
216
+ function terms(schema, document) {
217
+ return collect(schema, document, 0, /* @__PURE__ */ new Set()).join(" ");
218
+ }
219
+ /**
220
+ * Property names, titles, and descriptions reachable from a schema. Bounded by depth and guarded by
221
+ * the ids already open on this branch, so a schema describing its own nesting cannot stall a search.
222
+ */
223
+ function collect(schema, document, depth, seen) {
224
+ if (!schema || depth > MAX_DEPTH) return [];
225
+ const found = [];
226
+ if (schema.title) found.push(schema.title);
227
+ if (schema.description) found.push(schema.description);
228
+ for (const [name, child] of Object.entries(schema.properties ?? {})) found.push(name.replace(/[._-]/g, " "), ...collect(child, document, depth + 1, seen));
229
+ found.push(...collect(schema.items, document, depth + 1, seen));
230
+ for (const member of [...schema.anyOf ?? [], ...schema.oneOf ?? []]) found.push(...collect(member, document, depth + 1, seen));
231
+ if (typeof schema.additionalProperties === "object") found.push(...collect(schema.additionalProperties, document, depth + 1, seen));
232
+ for (const child of Object.values(schema.patternProperties ?? {})) found.push(...collect(child, document, depth + 1, seen));
233
+ for (const id of [...schema.$ref ? [schema.$ref] : [], ...refs(schema.$extends)]) {
234
+ if (seen.has(id)) continue;
235
+ found.push(...collect(document.schemas[id], document, depth + 1, new Set([...seen, id])));
236
+ }
237
+ return found;
238
+ }
239
+ function refs(value) {
240
+ if (value === void 0) return [];
241
+ return Array.isArray(value) ? value : [value];
242
+ }
243
+ /** An opaque page marker, so a caller pages by handing one back rather than doing arithmetic. */
244
+ function encodeCursor(offset) {
245
+ return Buffer.from(`offset:${offset}`, "utf8").toString("base64url");
246
+ }
247
+ function decodeCursor(cursor) {
248
+ if (cursor === void 0) return 0;
249
+ const decoded = Buffer.from(cursor, "base64url").toString("utf8");
250
+ const offset = Number.parseInt(decoded.replace(/^offset:/, ""), 10);
251
+ return Number.isSafeInteger(offset) && offset >= 0 ? offset : 0;
252
+ }
253
+
254
+ //#endregion
255
+ export { searchRoutes };