@unoverse-platform/studio 0.1.4 → 0.1.5
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/index.html +1 -0
- package/local/authoring.ts +101 -0
- package/local/catalog.ts +6 -0
- package/local/mcp.ts +260 -0
- package/local/plugin.ts +249 -7
- package/local/source.ts +230 -0
- package/package.json +15 -2
- package/public/favicon.svg +4 -0
- package/screens/AtomsView.tsx +42 -11
- package/screens/ComponentsView.tsx +175 -18
- package/screens/SourceView.tsx +784 -0
- package/screens/TemplatesView.tsx +16 -4
- package/screens/index.ts +3 -0
- package/screens/source/FileTree.tsx +131 -0
- package/screens/source/completion.ts +152 -0
- package/screens/source/highlight.ts +72 -0
- package/screens/source/inspect.ts +103 -0
- package/screens/source/picker.ts +89 -0
- package/src/host.ts +26 -1
- package/vendor/sdk/appEngine.tsx +1 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +101 -12
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/client.ts +33 -6
- package/vendor/sdk/lib/primitives.tsx +33 -4
- package/vendor/sdk/lib/render.tsx +19 -4
- package/vendor/sdk/lib/streamed.tsx +12 -47
- package/vendor/sdk/lib/template.tsx +3 -3
package/index.html
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMPLETION VOCABULARY for the source pane.
|
|
3
|
+
*
|
|
4
|
+
* THERE IS ONE LINTER, and it is not here. The rules live in
|
|
5
|
+
* `@unoverse-platform/base/src/lint/rx/`, and the editor lints unsaved text by handing that
|
|
6
|
+
* library an overlay (`lintDefinitions(rxHome, { overlay })`), so the squiggles a developer
|
|
7
|
+
* sees are the same rules publish and CI apply. An approximation of those rules in the
|
|
8
|
+
* editor would be a second linter, which is exactly what the shared library exists to
|
|
9
|
+
* prevent. This file adds no rule and judges nothing.
|
|
10
|
+
*
|
|
11
|
+
* What it does serve is the closed vocabulary as DATA, for completion: the primitive set,
|
|
12
|
+
* the style keys, and the project's own token names. The sets are imported from the same
|
|
13
|
+
* library, so the list offered is the list allowed. Tokens come from the resolved theme,
|
|
14
|
+
* which is what the renderer actually resolves against, so a token offered is a token that
|
|
15
|
+
* renders. This is why completion is worth having here at all: with a closed vocabulary a
|
|
16
|
+
* completion list is not a hint, it is the whole legal set, and it knows the developer's
|
|
17
|
+
* real tokens in a way a JSON Schema in a text editor cannot.
|
|
18
|
+
*
|
|
19
|
+
* See docs/architecture/LOCAL_STUDIO.md §Authoring in Studio.
|
|
20
|
+
*/
|
|
21
|
+
import { resolve } from "path";
|
|
22
|
+
import { existsSync, readdirSync } from "fs";
|
|
23
|
+
|
|
24
|
+
/** The library's closed sets, imported rather than restated. */
|
|
25
|
+
export interface Vocabulary {
|
|
26
|
+
primitives: string[];
|
|
27
|
+
styleKeys: string[];
|
|
28
|
+
conditionKeys: string[];
|
|
29
|
+
dimensionKeys: string[];
|
|
30
|
+
/** Token names by group, from the project's own resolved theme. */
|
|
31
|
+
tokens: { color: string[]; space: string[]; font: string[]; lineHeight: string[]; radius: string[]; shadow: string[] };
|
|
32
|
+
/** Atom names, for `Ref.ref`. */
|
|
33
|
+
atoms: string[];
|
|
34
|
+
/** Component names, for `ComponentSlot` and component refs. */
|
|
35
|
+
components: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type LintLib = {
|
|
39
|
+
PRIMITIVES: Set<string>;
|
|
40
|
+
STYLE_KEYS: Set<string>;
|
|
41
|
+
CONDITION_KEYS: Set<string>;
|
|
42
|
+
DIMENSION_KEYS: Set<string>;
|
|
43
|
+
RAW_VALUE: RegExp;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The completion payload.
|
|
48
|
+
*
|
|
49
|
+
* Token groups come from the resolved theme, which is what the renderer actually resolves
|
|
50
|
+
* against, so a token offered here is a token that renders. A theme that fails to resolve
|
|
51
|
+
* yields empty groups rather than a guess.
|
|
52
|
+
*/
|
|
53
|
+
export function vocabulary(
|
|
54
|
+
vocab: LintLib,
|
|
55
|
+
theme: unknown,
|
|
56
|
+
atoms: { name?: string }[],
|
|
57
|
+
components: { name?: string }[],
|
|
58
|
+
): Vocabulary {
|
|
59
|
+
const group = (key: string): string[] => {
|
|
60
|
+
const t = (theme as Record<string, Record<string, unknown>> | null)?.[key];
|
|
61
|
+
return t && typeof t === "object" ? Object.keys(t).sort() : [];
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
primitives: [...vocab.PRIMITIVES].sort(),
|
|
65
|
+
styleKeys: [...vocab.STYLE_KEYS].sort(),
|
|
66
|
+
conditionKeys: [...vocab.CONDITION_KEYS].sort(),
|
|
67
|
+
dimensionKeys: [...vocab.DIMENSION_KEYS].sort(),
|
|
68
|
+
tokens: {
|
|
69
|
+
color: group("color"),
|
|
70
|
+
space: group("space"),
|
|
71
|
+
/**
|
|
72
|
+
* `font` RESOLVES AGAINST `theme.text`, not `theme.font`.
|
|
73
|
+
*
|
|
74
|
+
* The resolved theme has no `font` group: `fonts` holds families and `text` holds the
|
|
75
|
+
* semantic scale the style key actually uses (`style.ts`: `theme.text[s.font]`).
|
|
76
|
+
* Reading `font` returned an empty list, so `font:` was the one style key offering no
|
|
77
|
+
* completion at all, which is how an invalid token got typed in the first place.
|
|
78
|
+
*/
|
|
79
|
+
font: group("text"),
|
|
80
|
+
lineHeight: group("lineHeight"),
|
|
81
|
+
radius: group("radius"),
|
|
82
|
+
shadow: group("shadow"),
|
|
83
|
+
},
|
|
84
|
+
atoms: atoms.map((a) => a.name ?? "").filter(Boolean).sort(),
|
|
85
|
+
components: components.map((c) => c.name ?? "").filter(Boolean).sort(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Atom names off disk, for completion of `Ref.ref`. */
|
|
90
|
+
export function atomNames(projectRoot: string): { name: string }[] {
|
|
91
|
+
const dirs = [resolve(projectRoot, "rx/marketplace/atoms"), resolve(projectRoot, "rx/atoms")];
|
|
92
|
+
const names = new Set<string>();
|
|
93
|
+
for (const d of dirs) {
|
|
94
|
+
if (!existsSync(d)) continue;
|
|
95
|
+
for (const e of readdirSync(d)) {
|
|
96
|
+
const m = /^(.*)\.(ya?ml|json)$/.exec(e);
|
|
97
|
+
if (m) names.add(m[1]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return [...names].sort().map((name) => ({ name }));
|
|
101
|
+
}
|
package/local/catalog.ts
CHANGED
|
@@ -25,6 +25,10 @@ type Loaders = {
|
|
|
25
25
|
listThemeNames: (org: string) => string[];
|
|
26
26
|
resolveTheme: (name: string) => unknown;
|
|
27
27
|
DEFAULT_ORG: string;
|
|
28
|
+
/** A project's on-disk home. The writable set is built from these (see source.ts). */
|
|
29
|
+
projectDir: (name: string) => string;
|
|
30
|
+
/** A component id's on-disk folder, either tier. Null for a flat, folderless one. */
|
|
31
|
+
componentFolder: (ref: string) => string | null;
|
|
28
32
|
};
|
|
29
33
|
|
|
30
34
|
/**
|
|
@@ -101,5 +105,7 @@ export async function loadCatalog(
|
|
|
101
105
|
listThemeNames: theme.listThemeNames as Loaders["listThemeNames"],
|
|
102
106
|
resolveTheme: theme.resolveTheme as Loaders["resolveTheme"],
|
|
103
107
|
DEFAULT_ORG: theme.DEFAULT_ORG as string,
|
|
108
|
+
projectDir: defs.projectDir as Loaders["projectDir"],
|
|
109
|
+
componentFolder: defs.componentFolder as Loaders["componentFolder"],
|
|
104
110
|
};
|
|
105
111
|
}
|
package/local/mcp.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STUDIO MCP: a developer's Claude, authoring against their own folder.
|
|
3
|
+
*
|
|
4
|
+
* Same flow as Canvas. The developer clicks "Connect agent", Studio mints a short
|
|
5
|
+
* single-use code, they paste it into Claude, and `bindProject` redeems it. Until a session
|
|
6
|
+
* is bound, every other tool refuses.
|
|
7
|
+
*
|
|
8
|
+
* NO REDIS, and none needed: Canvas stores codes there because a universe is several
|
|
9
|
+
* processes serving many users. Studio is one process serving one developer, so the code
|
|
10
|
+
* lives in memory and dies with the server, which is the correct lifetime for it.
|
|
11
|
+
*
|
|
12
|
+
* THIS SERVER OWNS NO RULES. Saving goes through the same lint-gated chokepoint the editor
|
|
13
|
+
* uses (source.ts), linting is the shared library, and completion data is the same
|
|
14
|
+
* vocabulary. That is why it stays small: the guardrails already exist below it, and an
|
|
15
|
+
* agent gets exactly the ones the human gets.
|
|
16
|
+
*
|
|
17
|
+
* See docs/architecture/LOCAL_STUDIO.md §The connected agent.
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { resolve } from "node:path";
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
23
|
+
|
|
24
|
+
/** Human-typeable, unambiguous alphabet. The same one Canvas uses (no 0/O/1/I/L). */
|
|
25
|
+
const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
26
|
+
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
let pending: { code: string; expires: number } | null = null;
|
|
29
|
+
|
|
30
|
+
export function mintPairingCode(): { code: string; expiresInSeconds: number } {
|
|
31
|
+
const b = randomBytes(8);
|
|
32
|
+
let s = "";
|
|
33
|
+
for (let i = 0; i < 8; i++) s += CODE_ALPHABET[b[i] % CODE_ALPHABET.length];
|
|
34
|
+
const code = `${s.slice(0, 4)}-${s.slice(4)}`;
|
|
35
|
+
pending = { code, expires: Date.now() + CODE_TTL_MS };
|
|
36
|
+
return { code, expiresInSeconds: CODE_TTL_MS / 1000 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Redeem and burn. A lingering copy in a transcript is inert. */
|
|
40
|
+
function redeem(raw: string): boolean {
|
|
41
|
+
const code = raw.trim().toUpperCase();
|
|
42
|
+
if (!pending || pending.code !== code || Date.now() > pending.expires) return false;
|
|
43
|
+
pending = null;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface McpDeps {
|
|
48
|
+
projectRoot: string;
|
|
49
|
+
/** The catalog loaders, so the agent reads definitions the same way the screens do. */
|
|
50
|
+
catalog: () => Promise<{
|
|
51
|
+
listDefinitions: (kind: string, org?: string) => unknown[];
|
|
52
|
+
loadDefinition: (ref: string, kind: string) => unknown;
|
|
53
|
+
listOrgs: () => string[];
|
|
54
|
+
}>;
|
|
55
|
+
/** Files behind a definition, and their writability. */
|
|
56
|
+
sourceFiles: (uri: string) => { path: string; rel: string; writable: boolean }[];
|
|
57
|
+
readSource: (path: string) => { text: string; writable: boolean } | null;
|
|
58
|
+
/** The lint-gated write chokepoint. Identical to the one the editor presses. */
|
|
59
|
+
saveSource: (path: string, text: string) => Promise<{ saved: boolean; error?: string; findings?: unknown[] }>;
|
|
60
|
+
lintProject: () => Promise<{ problems: unknown[] }>;
|
|
61
|
+
/** What the developer last picked in the preview. */
|
|
62
|
+
selection: () => Record<string, unknown> | null;
|
|
63
|
+
vocabulary: () => Promise<unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SKILL_DIRS = ["vendor/skills/unoverse-create", "../../prompts/skills/unoverse-create"];
|
|
67
|
+
|
|
68
|
+
/** The vendored authoring skill, which is how an agent learns the rules before writing. */
|
|
69
|
+
function authoringSkill(appRoot: string): string {
|
|
70
|
+
for (const d of SKILL_DIRS) {
|
|
71
|
+
const p = resolve(appRoot, d, "SKILL.md");
|
|
72
|
+
if (existsSync(p)) return readFileSync(p, "utf8");
|
|
73
|
+
}
|
|
74
|
+
return "No authoring skill is vendored with this Studio build.";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Tool = {
|
|
78
|
+
description: string;
|
|
79
|
+
inputSchema: { type: "object"; properties: Record<string, unknown>; required?: string[] };
|
|
80
|
+
run: (args: Record<string, any>) => Promise<unknown>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The tool surface, deliberately short.
|
|
85
|
+
*
|
|
86
|
+
* Everything an agent needs to author and nothing that duplicates a rule: read the project,
|
|
87
|
+
* learn the rules, read a definition and its files, save through the gate, lint, and ask
|
|
88
|
+
* what the developer is pointing at.
|
|
89
|
+
*/
|
|
90
|
+
export function buildTools(deps: McpDeps, appRoot: string): Record<string, Tool> {
|
|
91
|
+
let bound = false;
|
|
92
|
+
const needsBinding = () => {
|
|
93
|
+
if (!bound) throw new Error("Not bound. Call bindProject with the code from Studio's 'Connect agent' button.");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
bindProject: {
|
|
98
|
+
description:
|
|
99
|
+
"Bind this session to the developer's Studio project using the pairing code shown by Studio's 'Connect agent' button. Call this first; nothing else works until you do.",
|
|
100
|
+
inputSchema: { type: "object", properties: { code: { type: "string" } }, required: ["code"] },
|
|
101
|
+
run: async ({ code }) => {
|
|
102
|
+
if (!redeem(String(code ?? ""))) throw new Error("Invalid or expired pairing code. Generate a fresh one in Studio.");
|
|
103
|
+
bound = true;
|
|
104
|
+
const c = await deps.catalog();
|
|
105
|
+
return {
|
|
106
|
+
bound: true,
|
|
107
|
+
projectRoot: deps.projectRoot,
|
|
108
|
+
projects: c.listOrgs(),
|
|
109
|
+
note: "Saves are lint-gated and installed assets are read-only. Read the authoring skill before writing.",
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
readAuthoringSkill: {
|
|
115
|
+
description: "The rules for authoring unoverse definitions. Read this before writing or editing any definition.",
|
|
116
|
+
inputSchema: { type: "object", properties: {} },
|
|
117
|
+
run: async () => {
|
|
118
|
+
needsBinding();
|
|
119
|
+
return { skill: authoringSkill(appRoot) };
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
getVocabulary: {
|
|
124
|
+
description:
|
|
125
|
+
"The closed vocabulary: the primitive set, the style keys, and THIS project's own token names from its resolved theme. A value outside these will fail lint.",
|
|
126
|
+
inputSchema: { type: "object", properties: {} },
|
|
127
|
+
run: async () => {
|
|
128
|
+
needsBinding();
|
|
129
|
+
return deps.vocabulary();
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
listDefinitions: {
|
|
134
|
+
description: "The project's components or templates.",
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: "object",
|
|
137
|
+
properties: { kind: { type: "string", enum: ["component", "template", "atom"] } },
|
|
138
|
+
required: ["kind"],
|
|
139
|
+
},
|
|
140
|
+
run: async ({ kind }) => {
|
|
141
|
+
needsBinding();
|
|
142
|
+
const c = await deps.catalog();
|
|
143
|
+
return { definitions: c.listDefinitions(String(kind)) };
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
readDefinition: {
|
|
148
|
+
description:
|
|
149
|
+
"One definition, composed, plus the source files behind it and whether each may be written. Installed assets read but never write.",
|
|
150
|
+
inputSchema: { type: "object", properties: { uri: { type: "string" } }, required: ["uri"] },
|
|
151
|
+
run: async ({ uri }) => {
|
|
152
|
+
needsBinding();
|
|
153
|
+
const c = await deps.catalog();
|
|
154
|
+
const ref = String(uri).replace(/^unoverse:\/\/(components|atoms|apps|templates)\//, "");
|
|
155
|
+
const kind = /\/(apps|templates)\//.test(String(uri)) ? "template" : /\/atoms\//.test(String(uri)) ? "atom" : "component";
|
|
156
|
+
return { definition: c.loadDefinition(ref, kind), files: deps.sourceFiles(String(uri)) };
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
readSource: {
|
|
161
|
+
description: "One source file's text.",
|
|
162
|
+
inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
163
|
+
run: async ({ path }) => {
|
|
164
|
+
needsBinding();
|
|
165
|
+
const found = deps.readSource(String(path));
|
|
166
|
+
if (!found) throw new Error(`Not readable: ${path}`);
|
|
167
|
+
return found;
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
saveDefinition: {
|
|
172
|
+
description:
|
|
173
|
+
"Write a source file. The linter runs and a failing write is ROLLED BACK, so a refusal means nothing changed on disk. Installed assets are refused outright.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: { path: { type: "string" }, text: { type: "string" } },
|
|
177
|
+
required: ["path", "text"],
|
|
178
|
+
},
|
|
179
|
+
run: async ({ path, text }) => {
|
|
180
|
+
needsBinding();
|
|
181
|
+
return deps.saveSource(String(path), String(text));
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
lintProject: {
|
|
186
|
+
description: "Lint every definition. The same rules that gate a save, a publish and CI.",
|
|
187
|
+
inputSchema: { type: "object", properties: {} },
|
|
188
|
+
run: async () => {
|
|
189
|
+
needsBinding();
|
|
190
|
+
return deps.lintProject();
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
getSelection: {
|
|
195
|
+
description:
|
|
196
|
+
"What the developer is pointing at: the element they picked in Studio's preview, as a file, an offset and the node's path. Call this when an instruction says 'this' or 'that'.",
|
|
197
|
+
inputSchema: { type: "object", properties: {} },
|
|
198
|
+
run: async () => {
|
|
199
|
+
needsBinding();
|
|
200
|
+
return deps.selection() ?? { empty: true, note: "Nothing picked. Ask the developer to use Studio's pick tool." };
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* JSON-RPC over a single POST, which is all a local MCP client needs.
|
|
208
|
+
*
|
|
209
|
+
* The SDK's transport exists for sessions, SSE and resumability; none of that applies to a
|
|
210
|
+
* loopback tool talking to one editor, so this handles `initialize`, `tools/list` and
|
|
211
|
+
* `tools/call` directly rather than carrying that machinery.
|
|
212
|
+
*/
|
|
213
|
+
export async function handleMcp(
|
|
214
|
+
req: IncomingMessage,
|
|
215
|
+
res: ServerResponse,
|
|
216
|
+
body: unknown,
|
|
217
|
+
tools: Record<string, Tool>,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
const send = (payload: unknown, status = 200) => {
|
|
220
|
+
res.statusCode = status;
|
|
221
|
+
res.setHeader("content-type", "application/json");
|
|
222
|
+
res.end(JSON.stringify(payload));
|
|
223
|
+
};
|
|
224
|
+
const msg = body as { id?: unknown; method?: string; params?: any };
|
|
225
|
+
const reply = (result: unknown) => send({ jsonrpc: "2.0", id: msg?.id ?? null, result });
|
|
226
|
+
const fail = (code: number, message: string) => send({ jsonrpc: "2.0", id: msg?.id ?? null, error: { code, message } });
|
|
227
|
+
|
|
228
|
+
switch (msg?.method) {
|
|
229
|
+
case "initialize":
|
|
230
|
+
return reply({
|
|
231
|
+
protocolVersion: "2024-11-05",
|
|
232
|
+
capabilities: { tools: {} },
|
|
233
|
+
serverInfo: { name: "unoverse-studio", version: "1" },
|
|
234
|
+
});
|
|
235
|
+
case "notifications/initialized":
|
|
236
|
+
res.statusCode = 202;
|
|
237
|
+
return void res.end();
|
|
238
|
+
case "tools/list":
|
|
239
|
+
return reply({
|
|
240
|
+
tools: Object.entries(tools).map(([name, t]) => ({
|
|
241
|
+
name,
|
|
242
|
+
description: t.description,
|
|
243
|
+
inputSchema: t.inputSchema,
|
|
244
|
+
})),
|
|
245
|
+
});
|
|
246
|
+
case "tools/call": {
|
|
247
|
+
const tool = tools[msg.params?.name];
|
|
248
|
+
if (!tool) return fail(-32601, `Unknown tool: ${msg.params?.name}`);
|
|
249
|
+
try {
|
|
250
|
+
const out = await tool.run(msg.params?.arguments ?? {});
|
|
251
|
+
return reply({ content: [{ type: "text", text: JSON.stringify(out, null, 2) }] });
|
|
252
|
+
} catch (e) {
|
|
253
|
+
// A refusal is a RESULT, not a protocol error: the agent should read why and adjust.
|
|
254
|
+
return reply({ content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
default:
|
|
258
|
+
return fail(-32601, `Unsupported method: ${msg?.method}`);
|
|
259
|
+
}
|
|
260
|
+
}
|