@skyelight/mcp 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 +120 -0
- package/package.json +40 -0
- package/src/client.js +96 -0
- package/src/config.js +112 -0
- package/src/index.js +24 -0
- package/src/itemCard.d.ts +23 -0
- package/src/itemCard.js +335 -0
- package/src/server.js +218 -0
- package/src/tools.d.ts +45 -0
- package/src/tools.js +500 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Plastr Lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# @skyelight/mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that lets a coding agent read the feedback people left on your
|
|
4
|
+
running app — the thread, the page, and the element they were pointing at.
|
|
5
|
+
|
|
6
|
+
Works with anything that speaks MCP over stdio: Claude Code, Cursor, Codex
|
|
7
|
+
CLI. For hosted agents that cannot spawn a local process, use the remote
|
|
8
|
+
endpoint instead (SKY-274).
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
> **Not published yet.** Until `@skyelight/mcp` is on npm, the `npx` form
|
|
13
|
+
> below will fail to resolve. Run it from a checkout instead — same server,
|
|
14
|
+
> same behaviour:
|
|
15
|
+
>
|
|
16
|
+
> ```bash
|
|
17
|
+
> claude mcp add skyelight \
|
|
18
|
+
> --env SKYELIGHT_API_URL=https://<deployment>.convex.site \
|
|
19
|
+
> --env SKYELIGHT_API_TOKEN=sk_live_... \
|
|
20
|
+
> -- node /path/to/skyelight-app/integrations/skyelight-mcp/src/index.js
|
|
21
|
+
> ```
|
|
22
|
+
>
|
|
23
|
+
> The connect snippet generated in Settings → API keys uses the `npx` form,
|
|
24
|
+
> which is what will be correct once this ships. Swap the command for the
|
|
25
|
+
> node path in the meantime.
|
|
26
|
+
|
|
27
|
+
Create a key under **Settings → API keys** in the Skyelight web app. Bind it
|
|
28
|
+
to a project if the agent only ever works on one — a bound key needs no
|
|
29
|
+
further configuration and cannot read anything else.
|
|
30
|
+
|
|
31
|
+
Then add the server to your client:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"skyelight": {
|
|
37
|
+
"command": "npx",
|
|
38
|
+
"args": ["-y", "@skyelight/mcp"],
|
|
39
|
+
"env": {
|
|
40
|
+
"SKYELIGHT_API_URL": "https://<deployment>.convex.site",
|
|
41
|
+
"SKYELIGHT_API_TOKEN": "sk_live_..."
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Claude Code users can skip the file:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
claude mcp add skyelight \
|
|
52
|
+
--env SKYELIGHT_API_URL=https://<deployment>.convex.site \
|
|
53
|
+
--env SKYELIGHT_API_TOKEN=sk_live_... \
|
|
54
|
+
-- npx -y @skyelight/mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Credentials
|
|
58
|
+
|
|
59
|
+
Checked in order, so an existing Skyelight setup keeps working:
|
|
60
|
+
|
|
61
|
+
1. `SKYELIGHT_API_TOKEN` / `SKYELIGHT_API_URL` in the environment
|
|
62
|
+
2. `.env.local`, then `.env`, in the working directory
|
|
63
|
+
3. `~/.skyelight/credentials` — `{"apiUrl": "...", "token": "sk_live_..."}`
|
|
64
|
+
|
|
65
|
+
### Binding a repo to a project
|
|
66
|
+
|
|
67
|
+
With a workspace-wide key used across several repos, drop a `.skyelight.json`
|
|
68
|
+
at the repo root:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{ "projectId": "j57abc...", "projectName": "Checkout rebuild" }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Tools then default to that project, and nobody has to pass an id. A
|
|
75
|
+
project-bound API key makes this unnecessary — the server already knows.
|
|
76
|
+
|
|
77
|
+
## Tools
|
|
78
|
+
|
|
79
|
+
| Tool | What it is for |
|
|
80
|
+
| -------------- | -------------------------------------------------------------- |
|
|
81
|
+
| `list_items` | What is outstanding. Filter by page, type, status, assignee. |
|
|
82
|
+
| `search_items` | Find items whose thread mentions some text, replies included. |
|
|
83
|
+
| `get_item` | Everything needed to work one item: full thread, page, anchor. |
|
|
84
|
+
|
|
85
|
+
`list_items` leads with a summary — totals, breakdown by type and by page —
|
|
86
|
+
so an agent can tell you the shape of the work before pulling any of it.
|
|
87
|
+
Rows are stubs; `get_item` is where the thread and the anchor live.
|
|
88
|
+
|
|
89
|
+
## What the agent sees
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
Alpha: 12 items total — 9 open, 3 resolved, 7 unassigned.
|
|
93
|
+
By type: bug (7), idea (4), feedback (1).
|
|
94
|
+
Busiest pages: /checkout (8), /settings (4).
|
|
95
|
+
|
|
96
|
+
2 matches:
|
|
97
|
+
- The pay button does nothing on the second click
|
|
98
|
+
bug · open · /checkout · unassigned · 2 replies — id i1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Deliberately prose rather than JSON. A tool that returns a bare array invites
|
|
102
|
+
a model to read the array out; this one invites it to summarise.
|
|
103
|
+
|
|
104
|
+
## Permissions
|
|
105
|
+
|
|
106
|
+
The key's role decides what the agent can do, and the API enforces it —
|
|
107
|
+
nothing here is client-side. A reviewer-role key can read but not write. A
|
|
108
|
+
project-bound key 404s on anything outside its project rather than reporting
|
|
109
|
+
that it exists but is forbidden.
|
|
110
|
+
|
|
111
|
+
## Supersedes
|
|
112
|
+
|
|
113
|
+
`integrations/skyelight-skill/`, the Python Claude Code skill — **removed** in
|
|
114
|
+
SKY-291 along with the `/api/v1/brief` and `/api/v1/feedback/ship` endpoints
|
|
115
|
+
it called.
|
|
116
|
+
|
|
117
|
+
That skill was brief-shaped: pull a synthesized decision brief, mark decisions
|
|
118
|
+
shipped. This is item-shaped, which is what an agent working a specific piece
|
|
119
|
+
of feedback actually needs, and it works across clients rather than Claude
|
|
120
|
+
Code alone.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skyelight/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Skyelight \u2014 read feedback items from your project as an agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skyelight-mcp": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"README.md",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test 'test/**/*.test.js'",
|
|
20
|
+
"prepublishOnly": "npm test"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"skyelight",
|
|
25
|
+
"feedback"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/plastrlab/skyelight.git",
|
|
31
|
+
"directory": "integrations/skyelight-mcp"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/plastrlab/skyelight/tree/main/integrations/skyelight-mcp#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/plastrlab/skyelight/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP calls to the Skyelight public API. No business logic — the server
|
|
3
|
+
* decides what a key may see; this just carries the bearer token and turns
|
|
4
|
+
* error bodies into readable messages.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class ApiError extends Error {
|
|
8
|
+
constructor(message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createClient({ apiUrl, token, fetchImpl = fetch }) {
|
|
15
|
+
async function post(path, body) {
|
|
16
|
+
let res;
|
|
17
|
+
try {
|
|
18
|
+
res = await fetchImpl(apiUrl + path, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw new ApiError(`Could not reach ${apiUrl}: ${err.message}`, 0);
|
|
28
|
+
}
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = text ? JSON.parse(text) : {};
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ApiError(
|
|
35
|
+
`${res.status} from ${path}, and the body was not JSON`,
|
|
36
|
+
res.status,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
throw new ApiError(
|
|
41
|
+
parsed.error ?? `Request failed (${res.status})`,
|
|
42
|
+
res.status,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function request(path, params = {}) {
|
|
49
|
+
const url = new URL(apiUrl + path);
|
|
50
|
+
for (const [k, v] of Object.entries(params)) {
|
|
51
|
+
if (v !== undefined && v !== null && v !== "") {
|
|
52
|
+
url.searchParams.set(k, String(v));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let res;
|
|
57
|
+
try {
|
|
58
|
+
res = await fetchImpl(url.toString(), {
|
|
59
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
throw new ApiError(`Could not reach ${apiUrl}: ${err.message}`, 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
let body;
|
|
67
|
+
try {
|
|
68
|
+
body = text ? JSON.parse(text) : {};
|
|
69
|
+
} catch {
|
|
70
|
+
throw new ApiError(
|
|
71
|
+
`${res.status} from ${path}, and the body was not JSON`,
|
|
72
|
+
res.status,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
// The API's own message is more useful than anything invented here —
|
|
78
|
+
// it says which role the key has, or that a project is out of scope.
|
|
79
|
+
throw new ApiError(
|
|
80
|
+
body.error ?? `Request failed (${res.status})`,
|
|
81
|
+
res.status,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return body;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
listWorkspaces: () => request("/api/v1/workspaces"),
|
|
89
|
+
listProjects: (params) => request("/api/v1/projects", params),
|
|
90
|
+
listItems: (params) => request("/api/v1/items", params),
|
|
91
|
+
getItem: (id) => request(`/api/v1/items/${encodeURIComponent(id)}`),
|
|
92
|
+
postUpdate: (body) => post("/api/v1/items/update", body),
|
|
93
|
+
createItem: (body) => post("/api/v1/items/create", body),
|
|
94
|
+
setStatus: (body) => post("/api/v1/items/status", body),
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the token, the API and the project binding come from.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order deliberately matches the Python skill this supersedes, so
|
|
5
|
+
* anyone who already has credentials on disk gets a working MCP server
|
|
6
|
+
* without moving anything:
|
|
7
|
+
*
|
|
8
|
+
* 1. SKYELIGHT_API_TOKEN / SKYELIGHT_API_URL in the environment
|
|
9
|
+
* 2. .env.local, then .env, in the working directory
|
|
10
|
+
* 3. ~/.skyelight/credentials ({"apiUrl": "...", "token": "..."})
|
|
11
|
+
*
|
|
12
|
+
* The project binding is separate and comes from `.skyelight.json` in the
|
|
13
|
+
* working directory, so a repo can declare which Skyelight project it is
|
|
14
|
+
* without every developer configuring it.
|
|
15
|
+
*
|
|
16
|
+
* A project-bound API KEY (SKY-269) needs none of this — the server already
|
|
17
|
+
* knows its project. `.skyelight.json` is for workspace-wide keys used
|
|
18
|
+
* against several repos.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
export class ConfigError extends Error {}
|
|
26
|
+
|
|
27
|
+
/** Minimal .env parser: KEY=VALUE, optional quotes, # comments. */
|
|
28
|
+
export function parseDotenv(text) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const rawLine of text.split("\n")) {
|
|
31
|
+
const line = rawLine.trim();
|
|
32
|
+
if (!line || line.startsWith("#")) continue;
|
|
33
|
+
const eq = line.indexOf("=");
|
|
34
|
+
if (eq === -1) continue;
|
|
35
|
+
const key = line.slice(0, eq).trim();
|
|
36
|
+
let value = line.slice(eq + 1).trim();
|
|
37
|
+
if (
|
|
38
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
39
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
40
|
+
) {
|
|
41
|
+
value = value.slice(1, -1);
|
|
42
|
+
}
|
|
43
|
+
if (key) out[key] = value;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readJsonIfPresent(path) {
|
|
49
|
+
if (!existsSync(path)) return null;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
52
|
+
} catch {
|
|
53
|
+
// A malformed config should not take the server down — it should be
|
|
54
|
+
// reported and skipped. stderr is safe: stdout is the protocol channel.
|
|
55
|
+
process.stderr.write(`[skyelight-mcp] ignoring malformed ${path}\n`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveConfig({
|
|
61
|
+
cwd = process.cwd(),
|
|
62
|
+
env = process.env,
|
|
63
|
+
// Injectable so tests do not read the developer's real credentials file
|
|
64
|
+
// and pass or fail depending on whose machine they run on.
|
|
65
|
+
home = homedir(),
|
|
66
|
+
} = {}) {
|
|
67
|
+
let token = env.SKYELIGHT_API_TOKEN || null;
|
|
68
|
+
let apiUrl = env.SKYELIGHT_API_URL || null;
|
|
69
|
+
|
|
70
|
+
if (!token || !apiUrl) {
|
|
71
|
+
for (const name of [".env.local", ".env"]) {
|
|
72
|
+
const path = join(cwd, name);
|
|
73
|
+
if (!existsSync(path)) continue;
|
|
74
|
+
const parsed = parseDotenv(readFileSync(path, "utf8"));
|
|
75
|
+
token = token || parsed.SKYELIGHT_API_TOKEN || null;
|
|
76
|
+
apiUrl = apiUrl || parsed.SKYELIGHT_API_URL || null;
|
|
77
|
+
if (token && apiUrl) break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!token || !apiUrl) {
|
|
82
|
+
const creds = readJsonIfPresent(join(home, ".skyelight", "credentials"));
|
|
83
|
+
if (creds) {
|
|
84
|
+
token = token || creds.token || null;
|
|
85
|
+
apiUrl = apiUrl || creds.apiUrl || null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!token || !apiUrl) {
|
|
90
|
+
throw new ConfigError(
|
|
91
|
+
[
|
|
92
|
+
"No Skyelight credentials found. Either:",
|
|
93
|
+
" • export SKYELIGHT_API_URL and SKYELIGHT_API_TOKEN, or",
|
|
94
|
+
" • put them in .env.local, or",
|
|
95
|
+
' • save ~/.skyelight/credentials as {"apiUrl": "...", "token": "sk_live_..."}',
|
|
96
|
+
"",
|
|
97
|
+
"Create a key under Settings → API keys in the Skyelight web app.",
|
|
98
|
+
].join("\n"),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const project = readJsonIfPresent(join(cwd, ".skyelight.json")) ?? {};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
token,
|
|
106
|
+
apiUrl: apiUrl.replace(/\/+$/, ""),
|
|
107
|
+
// Named projectId to match the API. A bound key overrides this anyway —
|
|
108
|
+
// the server ignores a projectId outside the key's binding.
|
|
109
|
+
projectId: project.projectId ?? null,
|
|
110
|
+
projectName: project.projectName ?? null,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Entry point. `npx @skyelight/mcp` — reads credentials, opens stdio.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveConfig, ConfigError } from "./config.js";
|
|
7
|
+
import { createClient } from "./client.js";
|
|
8
|
+
import { createServer, serveStdio } from "./server.js";
|
|
9
|
+
|
|
10
|
+
let config;
|
|
11
|
+
try {
|
|
12
|
+
config = resolveConfig();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
if (err instanceof ConfigError) {
|
|
15
|
+
// stderr, and a non-zero exit: an MCP client surfaces this as a failed
|
|
16
|
+
// server rather than hanging on a handshake that will never complete.
|
|
17
|
+
process.stderr.write(`${err.message}\n`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const client = createClient({ apiUrl: config.apiUrl, token: config.token });
|
|
24
|
+
serveStdio(createServer({ client, config }));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Types for itemCard.js — see tools.d.ts for why declarations are shipped. */
|
|
2
|
+
|
|
3
|
+
export declare const ITEM_CARD_URI: string;
|
|
4
|
+
export declare const MCP_APP_MIME_TYPE: string;
|
|
5
|
+
export declare const UI_EXTENSION_KEY: string;
|
|
6
|
+
export declare const APPS_PROTOCOL_VERSION: string;
|
|
7
|
+
|
|
8
|
+
/** Whether a client declared it can render an MCP Apps HTML resource. */
|
|
9
|
+
export declare function clientSupportsUi(source: unknown): boolean;
|
|
10
|
+
|
|
11
|
+
export declare function itemCardResource(): {
|
|
12
|
+
uri: string;
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
mimeType: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export declare function itemCardContents(): {
|
|
19
|
+
uri: string;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
text: string;
|
|
22
|
+
_meta: { ui: Record<string, unknown> };
|
|
23
|
+
};
|