dxai-cli 1.0.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/src/mcp-cmd.js ADDED
@@ -0,0 +1,282 @@
1
+ // Fast-path MCP commands: `dxai add <id...>` and `dxai remove <id...>`.
2
+ // No wizard — resolve target agents (from --agents or detection), validate the
3
+ // server IDs, and write/remove directly. Honours --project, --dry-run, --json,
4
+ // and --yes, matching the setup flow's conventions.
5
+ //
6
+ // `add` also accepts official MCP Registry names (`io.github.owner/server`):
7
+ // the record is resolved live and becomes a catalogue entry for this run.
8
+
9
+ import os from 'os';
10
+ import path from 'path';
11
+
12
+ import {
13
+ printBanner, sectionHeader, successMsg, warnMsg, infoMsg, theme, reportMcpResults,
14
+ } from './branding.js';
15
+ import { detectAgents, AGENT_DEFINITIONS } from './detect.js';
16
+ import { MCP_SERVERS, deriveConfigs } from './registry/mcp-servers.js';
17
+ import { fetchRegistryServer, pickTransport, slugForRegistryName } from './registry/mcp-registry.js';
18
+ import { isValidRegistryName, validateRegistryPayload } from './registry/validate.js';
19
+ import {
20
+ writeMcpConfigs, writeProjectMcpConfigs, previewMcpConfigs,
21
+ } from './config-writer.js';
22
+ import {
23
+ scanJsonMcpConfig, removeJsonMcpServers,
24
+ scanTomlMcpConfig, removeTomlMcpServers,
25
+ scanClaudeCodeMcpServers, removeClaudeCodeMcpServers,
26
+ } from './config-remover.js';
27
+ import {
28
+ recordSystemMcp, recordProjectMcp, unrecordSystemMcp, unrecordProjectMcp,
29
+ } from './manifest.js';
30
+ import { normalizeOptions, partitionByKnown } from './runtime.js';
31
+ import { collectMcpInputs } from './index.js';
32
+
33
+ // Validate a list of IDs against a registry; throw a "Known: ..." hint on any miss.
34
+ function requireKnown(ids, knownIds, label) {
35
+ const { valid, invalid } = partitionByKnown(ids, knownIds);
36
+ if (invalid.length) {
37
+ throw new Error(`Unknown ${label}: ${invalid.join(', ')}. Known: ${knownIds.join(', ')}`);
38
+ }
39
+ if (valid.length === 0) {
40
+ throw new Error(`No ${label} given.`);
41
+ }
42
+ return valid;
43
+ }
44
+
45
+ const KNOWN_MCP_IDS = MCP_SERVERS.map((s) => s.id);
46
+
47
+ // Turn a live registry record into a catalogue entry for this invocation only.
48
+ // Validated like fetched catalogue data before its configs are derived.
49
+ function entryFromRegistry(name, record) {
50
+ const r = pickTransport(record);
51
+ if (!r.transport) throw new Error(`${name}: no usable transport (${r.warnings.join('; ')})`);
52
+ const entry = {
53
+ id: slugForRegistryName(name),
54
+ name: r.title || slugForRegistryName(name),
55
+ description: r.description || '',
56
+ category: 'registry',
57
+ registry: { name, resolved: { version: r.registryVersion, at: new Date().toISOString(), fields: ['transport'] } },
58
+ transport: r.transport,
59
+ };
60
+ if (Object.keys(r.requiresEnv).length) entry.requiresEnv = r.requiresEnv;
61
+ if (Object.keys(r.requiresInput).length) entry.requiresInput = r.requiresInput;
62
+ if (r.stale) Object.assign(entry, { stale: true, staleReason: r.staleReason });
63
+ const problems = validateRegistryPayload('servers', [entry]);
64
+ if (problems.length) throw new Error(`${name}: ${problems.join('; ')}`);
65
+ return { ...entry, configs: deriveConfigs(entry) };
66
+ }
67
+
68
+ // Resolve the requested servers to catalogue ids plus the server list to use.
69
+ // Catalogue ids pass through. A registry name maps to the catalogue entry that
70
+ // links to it, or is looked up live and appended as a synthetic entry. Anything
71
+ // else is unknown.
72
+ async function resolveServerList(requested) {
73
+ const servers = [...MCP_SERVERS];
74
+ const ids = [];
75
+ const unknown = [];
76
+ const live = {};
77
+ for (const raw of requested) {
78
+ if (KNOWN_MCP_IDS.includes(raw)) { ids.push(raw); continue; }
79
+ if (!isValidRegistryName(raw)) { unknown.push(raw); continue; }
80
+ const linked = MCP_SERVERS.find((s) => s.registry?.name === raw);
81
+ if (linked) { ids.push(linked.id); continue; }
82
+ const slug = slugForRegistryName(raw);
83
+ if (KNOWN_MCP_IDS.includes(slug)) {
84
+ throw new Error(`${raw} would use the id "${slug}", which already names a different catalogue server. Use the catalogue id instead.`);
85
+ }
86
+ if (live[slug]) { ids.push(slug); continue; }
87
+ const record = await fetchRegistryServer(raw);
88
+ if (!record) throw new Error(`Not found in the MCP Registry: ${raw}`);
89
+ const entry = entryFromRegistry(raw, record);
90
+ servers.push(entry);
91
+ live[slug] = { registry: raw, requiresEnv: Object.keys(entry.requiresEnv || {}) };
92
+ ids.push(slug);
93
+ }
94
+ if (unknown.length) {
95
+ throw new Error(`Unknown MCP server(s): ${unknown.join(', ')}. Known: ${KNOWN_MCP_IDS.join(', ')} (or an MCP Registry name like io.github.owner/server)`);
96
+ }
97
+ if (ids.length === 0) throw new Error('No MCP server(s) given.');
98
+ return { ids: [...new Set(ids)], servers, live };
99
+ }
100
+
101
+ // Which agents to target: --agents wins (validated); otherwise detected+installed.
102
+ // For --project, keep only agents that support a project-level MCP path.
103
+ function resolveTargetAgents(runtime, home, { project = false } = {}) {
104
+ let agents;
105
+ if (runtime.agents && runtime.agents.length) {
106
+ const known = AGENT_DEFINITIONS.map((a) => a.id);
107
+ const valid = requireKnown(runtime.agents, known, 'agent(s)');
108
+ agents = AGENT_DEFINITIONS.filter((a) => valid.includes(a.id));
109
+ } else {
110
+ agents = detectAgents(home).filter((a) => a.installed);
111
+ }
112
+ if (project) agents = agents.filter((a) => typeof a.projectMcpPath === 'function');
113
+ return agents;
114
+ }
115
+
116
+ // ── dxai add <id...> ──
117
+ export async function addMcp(serverIds = [], opts = {}) {
118
+ const runtime = normalizeOptions(opts);
119
+ const project = !!opts.project;
120
+ const home = os.homedir();
121
+
122
+ const { ids, servers, live } = await resolveServerList(serverIds);
123
+ const agents = resolveTargetAgents(runtime, home, { project });
124
+ if (agents.length === 0) {
125
+ throw new Error(project
126
+ ? `No project-capable agents. Pass --agents with one of: ${AGENT_DEFINITIONS.filter((a) => a.projectMcpPath).map((a) => a.id).join(', ')}.`
127
+ : 'No target agents detected. Pass --agents <ids> or install a supported agent.');
128
+ }
129
+
130
+ const inputs = await collectMcpInputs(ids, servers, runtime);
131
+
132
+ if (!runtime.json) {
133
+ printBanner();
134
+ sectionHeader(`Add — ${ids.join(', ')} → ${project ? 'project' : 'system'}`);
135
+ }
136
+
137
+ if (runtime.dryRun && !project) {
138
+ const previews = previewMcpConfigs(agents, ids, servers, inputs);
139
+ if (runtime.json) {
140
+ process.stdout.write(JSON.stringify({ ok: true, dryRun: true, previews }, null, 2) + '\n');
141
+ return;
142
+ }
143
+ for (const p of Object.values(previews)) {
144
+ const tag = p.exists ? theme.dim('(merge)') : theme.dim('(create)');
145
+ console.log(` ${theme.label(p.agent)} ${tag} → ${p.path}`);
146
+ if (p.wouldAdd.length) console.log(` ${theme.success('+ would add:')} ${p.wouldAdd.join(', ')}`);
147
+ if (p.wouldSkip.length) console.log(` ${theme.dim('· already present:')} ${p.wouldSkip.join(', ')}`);
148
+ }
149
+ console.log();
150
+ warnMsg('Dry run — no files were changed.');
151
+ return;
152
+ }
153
+
154
+ if (runtime.dryRun && project) {
155
+ if (runtime.json) {
156
+ process.stdout.write(JSON.stringify({ ok: true, dryRun: true, project: true, wouldAdd: ids, agents: agents.map((a) => a.id) }, null, 2) + '\n');
157
+ return;
158
+ }
159
+ for (const a of agents) infoMsg(`Would add ${ids.join(', ')} to ${a.name} project config (${a.projectMcpPath()})`);
160
+ console.log();
161
+ warnMsg('Dry run — no files were changed.');
162
+ return;
163
+ }
164
+
165
+ const results = project
166
+ ? writeProjectMcpConfigs(agents, ids, servers, inputs)
167
+ : writeMcpConfigs(agents, ids, servers, inputs);
168
+ if (project) recordProjectMcp(results, undefined, live); else recordSystemMcp(results, live);
169
+
170
+ // Per-agent write failures land in results[*].errors — exit non-zero so
171
+ // scripted callers can detect a partial failure.
172
+ const errorCount = Object.values(results).reduce((n, r) => n + (r.errors || []).length, 0);
173
+ if (errorCount > 0) process.exitCode = 1;
174
+
175
+ if (runtime.json) {
176
+ process.stdout.write(JSON.stringify({ ok: errorCount === 0, added: ids, project, results }, null, 2) + '\n');
177
+ return;
178
+ }
179
+ reportMcpResults(results);
180
+ console.log();
181
+ }
182
+
183
+ // Scan one agent for which of `ids` are actually present in its config.
184
+ function scanPresent(agent, ids, home, project) {
185
+ if (project) {
186
+ const p = path.join(process.cwd(), agent.projectMcpPath());
187
+ const format = agent.projectConfigFormat || agent.configFormat;
188
+ return format === 'toml'
189
+ ? { path: p, present: scanTomlMcpConfig(p, ids) }
190
+ : { path: p, present: scanJsonMcpConfig(p, agent.projectMcpKey || agent.mcpKey, ids) };
191
+ }
192
+ switch (agent.configFormat) {
193
+ case 'json': {
194
+ const p = agent.globalMcpPath(home);
195
+ return { path: p, present: scanJsonMcpConfig(p, agent.mcpKey, ids) };
196
+ }
197
+ case 'toml': {
198
+ const p = agent.globalMcpPath(home);
199
+ return { path: p, present: scanTomlMcpConfig(p, ids) };
200
+ }
201
+ case 'cli':
202
+ return { path: null, present: scanClaudeCodeMcpServers(ids) };
203
+ default:
204
+ return { path: null, present: [] };
205
+ }
206
+ }
207
+
208
+ // Remove `ids` from one agent's config; returns the count removed.
209
+ function removeFrom(agent, ids, configPath) {
210
+ switch (agent.configFormat) {
211
+ case 'json':
212
+ return removeJsonMcpServers(configPath, agent.mcpKey, ids).removed;
213
+ case 'toml':
214
+ return removeTomlMcpServers(configPath, ids).removed;
215
+ case 'cli':
216
+ return removeClaudeCodeMcpServers(ids).removed;
217
+ default:
218
+ return 0;
219
+ }
220
+ }
221
+
222
+ // ── dxai remove <id...> ──
223
+ export async function removeMcp(serverIds = [], opts = {}) {
224
+ const runtime = normalizeOptions(opts);
225
+ const project = !!opts.project;
226
+ const home = os.homedir();
227
+
228
+ // Removal is by catalogue id or the slug a registry name was added under;
229
+ // no live lookup is needed to take something out of a config.
230
+ const ids = serverIds.map((raw) => (isValidRegistryName(raw) ? slugForRegistryName(raw) : raw));
231
+ if (ids.length === 0) throw new Error('No MCP server(s) given.');
232
+ const agents = resolveTargetAgents(runtime, home, { project });
233
+ if (agents.length === 0) {
234
+ throw new Error('No target agents detected. Pass --agents <ids> or install a supported agent.');
235
+ }
236
+
237
+ if (!runtime.json) {
238
+ printBanner();
239
+ sectionHeader(`Remove — ${ids.join(', ')} from ${project ? 'project' : 'system'}`);
240
+ }
241
+
242
+ const removed = [];
243
+ for (const agent of agents) {
244
+ const { path: configPath, present } = scanPresent(agent, ids, home, project);
245
+ if (present.length === 0) continue;
246
+
247
+ if (runtime.dryRun) {
248
+ removed.push({ agent: agent.name, agentId: agent.id, path: configPath, removed: present });
249
+ continue;
250
+ }
251
+
252
+ let count;
253
+ try {
254
+ count = removeFrom(agent, present, configPath);
255
+ } catch (err) {
256
+ if (!runtime.json) warnMsg(`${agent.name}: ${err.message}`);
257
+ continue;
258
+ }
259
+ if (count > 0) {
260
+ if (project) unrecordProjectMcp(agent.id, present);
261
+ else unrecordSystemMcp(agent.id, present);
262
+ removed.push({ agent: agent.name, agentId: agent.id, path: configPath, removed: present });
263
+ }
264
+ }
265
+
266
+ if (runtime.json) {
267
+ process.stdout.write(JSON.stringify({ ok: true, dryRun: runtime.dryRun, project, removed }, null, 2) + '\n');
268
+ return;
269
+ }
270
+
271
+ if (removed.length === 0) {
272
+ infoMsg(`None of [${ids.join(', ')}] were present in the targeted ${project ? 'project' : 'system'} configs.`);
273
+ console.log();
274
+ return;
275
+ }
276
+ for (const r of removed) {
277
+ const verb = runtime.dryRun ? 'would remove' : 'removed';
278
+ successMsg(`${r.agent}: ${verb} ${r.removed.join(', ')}` + (r.path ? ` → ${r.path}` : ''));
279
+ }
280
+ console.log();
281
+ if (runtime.dryRun) warnMsg('Dry run — no files were changed.');
282
+ }
package/src/net.js ADDED
@@ -0,0 +1,72 @@
1
+ // Shared network helpers: fetch with a per-attempt timeout, bounded retries, and
2
+ // exponential backoff. The registry refresh and skill downloads both route through
3
+ // here so timeout/retry behaviour lives in exactly one place instead of being
4
+ // re-implemented (or forgotten) at each call site.
5
+
6
+ const DEFAULT_TIMEOUT_MS = 15000;
7
+ const DEFAULT_RETRIES = 2; // total attempts = retries + 1
8
+ const DEFAULT_BACKOFF_MS = 300; // base delay, doubled each retry
9
+
10
+ // Injectable so tests can advance "time" without real waits.
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+
15
+ // Worth retrying? Transient failures (timeouts, dropped connections, 5xx, 429)
16
+ // are; a definitive 4xx (bad URL, 404, 403) is permanent, so retrying just
17
+ // wastes the caller's time and hammers the server.
18
+ export function isRetriable(err) {
19
+ if (!err) return false;
20
+ if (err.name === 'AbortError' || err.name === 'TimeoutError') return true;
21
+ if (typeof err.status === 'number') {
22
+ if (err.status === 429) return true;
23
+ if (err.status >= 500) return true;
24
+ if (err.status >= 400) return false; // other 4xx — don't retry
25
+ }
26
+ // fetch() rejected at the network layer (DNS, connection refused, TLS): retry.
27
+ return true;
28
+ }
29
+
30
+ // Fetch with a fresh AbortSignal.timeout per attempt and exponential backoff
31
+ // between retries. Returns the Response on the first 2xx; throws the last error
32
+ // once retries are exhausted (or immediately for a non-retriable status).
33
+ export async function fetchWithRetry(url, opts = {}) {
34
+ const {
35
+ timeoutMs = DEFAULT_TIMEOUT_MS,
36
+ retries = DEFAULT_RETRIES,
37
+ backoffMs = DEFAULT_BACKOFF_MS,
38
+ redirect = 'follow',
39
+ sleepFn = sleep,
40
+ } = opts;
41
+
42
+ let lastErr;
43
+ for (let attempt = 0; attempt <= retries; attempt++) {
44
+ try {
45
+ const res = await fetch(url, { redirect, signal: AbortSignal.timeout(timeoutMs) });
46
+ if (!res.ok) {
47
+ const err = new Error(`HTTP ${res.status} for ${url}`);
48
+ err.status = res.status;
49
+ throw err;
50
+ }
51
+ return res;
52
+ } catch (err) {
53
+ lastErr = err;
54
+ if (attempt < retries && isRetriable(err)) {
55
+ await sleepFn(backoffMs * 2 ** attempt);
56
+ continue;
57
+ }
58
+ throw err;
59
+ }
60
+ }
61
+ throw lastErr;
62
+ }
63
+
64
+ export async function fetchJson(url, opts) {
65
+ const res = await fetchWithRetry(url, opts);
66
+ return res.json();
67
+ }
68
+
69
+ export async function fetchText(url, opts) {
70
+ const res = await fetchWithRetry(url, opts);
71
+ return res.text();
72
+ }
package/src/profile.js ADDED
@@ -0,0 +1,139 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { writeJsonAtomic } from './fs-atomic.js';
5
+
6
+ const HOME = os.homedir();
7
+
8
+ // Keys allowed to live in a profile. Anything else is dropped silently
9
+ // (forward-compatibility for new keys without erroring on old ones).
10
+ export const PROFILE_KEYS = [
11
+ 'mode', // 'system' | 'project' | 'both'
12
+ 'agents', // string[]
13
+ 'mcp', // string[]
14
+ 'skills', // string[]
15
+ 'features', // string[]
16
+ 'stack', // string[]
17
+ 'mcpInputs', // { [serverId]: { [inputKey]: value } }
18
+ ];
19
+
20
+ // Default project-level profile location.
21
+ const PROJECT_PROFILE_PATH = path.join('.dxai', 'profile.json');
22
+
23
+ // Default user-level profile dir.
24
+ const USER_PROFILE_DIR = path.join(HOME, '.dxai', 'profiles');
25
+
26
+ // Auto-discovery: first existing wins.
27
+ function findDefaultProfile(cwd = process.cwd()) {
28
+ const candidates = [
29
+ path.join(cwd, '.dxai', 'profile.json'),
30
+ path.join(HOME, '.dxai', 'config.json'),
31
+ path.join(HOME, '.dxairc'),
32
+ path.join(HOME, '.dxairc.json'),
33
+ ];
34
+ for (const p of candidates) {
35
+ if (fs.existsSync(p)) return p;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // Resolve `nameOrPath` to a real file path:
41
+ // - if it ends with .json or contains a path separator, treat as path
42
+ // - otherwise look up by name in user/project dirs
43
+ export function resolveProfile(nameOrPath, cwd = process.cwd()) {
44
+ if (!nameOrPath) return findDefaultProfile(cwd);
45
+
46
+ // Treat as a path if it carries any separator. Check both `/` and `\` so a
47
+ // forward-slash path (e.g. `./team/dev`) is still recognized on Windows, where
48
+ // path.sep is `\`.
49
+ if (nameOrPath.includes('/') || nameOrPath.includes('\\') || nameOrPath.endsWith('.json')) {
50
+ const abs = path.isAbsolute(nameOrPath) ? nameOrPath : path.join(cwd, nameOrPath);
51
+ return fs.existsSync(abs) ? abs : null;
52
+ }
53
+
54
+ const candidates = [
55
+ path.join(cwd, '.dxai', `${nameOrPath}.json`),
56
+ path.join(USER_PROFILE_DIR, `${nameOrPath}.json`),
57
+ path.join(HOME, '.dxai', `${nameOrPath}.json`),
58
+ ];
59
+ for (const p of candidates) {
60
+ if (fs.existsSync(p)) return p;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function readProfile(filePath) {
66
+ if (!fs.existsSync(filePath)) {
67
+ throw new Error(`Profile not found: ${filePath}`);
68
+ }
69
+ let data;
70
+ try {
71
+ data = fs.readJsonSync(filePath);
72
+ } catch (err) {
73
+ throw new Error(`Failed to parse profile ${filePath}: ${err.message}`, { cause: err });
74
+ }
75
+ const out = {};
76
+ for (const k of PROFILE_KEYS) {
77
+ if (data[k] !== undefined) out[k] = data[k];
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // CLI flags take precedence over profile values.
83
+ // Only fills keys that are undefined on `cliOpts`.
84
+ export function mergeWithProfile(cliOpts, profile) {
85
+ if (!profile) return cliOpts;
86
+ const merged = { ...cliOpts };
87
+ for (const key of PROFILE_KEYS) {
88
+ if (merged[key] === undefined && profile[key] !== undefined) {
89
+ merged[key] = profile[key];
90
+ }
91
+ }
92
+ return merged;
93
+ }
94
+
95
+ // Save a profile. `target` is one of:
96
+ // { user: true, name } → ~/.dxai/profiles/<name>.json
97
+ // { here: true } → ./.dxai/profile.json
98
+ // { path: '...' } → exact path
99
+ export function saveProfile(data, target = { user: true, name: 'default' }) {
100
+ const filtered = {};
101
+ for (const k of PROFILE_KEYS) {
102
+ if (data[k] !== undefined) filtered[k] = data[k];
103
+ }
104
+
105
+ let outPath;
106
+ if (target.path) {
107
+ outPath = target.path;
108
+ } else if (target.here) {
109
+ outPath = path.join(process.cwd(), PROJECT_PROFILE_PATH);
110
+ } else {
111
+ if (!target.name) throw new Error('saveProfile: target.name is required for user profiles');
112
+ outPath = path.join(USER_PROFILE_DIR, `${target.name}.json`);
113
+ }
114
+
115
+ writeJsonAtomic(outPath, filtered, { spaces: 2 });
116
+ return outPath;
117
+ }
118
+
119
+ // List discoverable profiles (user dir + cwd .dxai dir).
120
+ export function listProfiles(cwd = process.cwd()) {
121
+ const found = [];
122
+
123
+ for (const dir of [USER_PROFILE_DIR, path.join(cwd, '.dxai')]) {
124
+ if (!fs.existsSync(dir)) continue;
125
+ try {
126
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
127
+ for (const f of files) {
128
+ found.push({
129
+ name: path.basename(f, '.json'),
130
+ path: path.join(dir, f),
131
+ scope: dir === USER_PROFILE_DIR ? 'user' : 'project',
132
+ });
133
+ }
134
+ } catch {
135
+ // skip
136
+ }
137
+ }
138
+ return found;
139
+ }
@@ -0,0 +1,6 @@
1
+ import { loadRegistry } from './loader.js';
2
+
3
+ const data = loadRegistry('automation-tools');
4
+
5
+ export const AUTOMATION_TOOL_CATEGORIES = data.categories;
6
+ export const AUTOMATION_TOOLS = data.tools;
@@ -0,0 +1,37 @@
1
+ {
2
+ "categories": [
3
+ {
4
+ "id": "automation",
5
+ "label": "🤖 Automation",
6
+ "description": "CLI tools that give AI agents browser & device control"
7
+ }
8
+ ],
9
+ "tools": [
10
+ {
11
+ "id": "agent-browser",
12
+ "name": "Agent Browser",
13
+ "description": "Browser automation CLI for AI agents",
14
+ "category": "automation",
15
+ "recommended": true,
16
+ "detectCommand": "agent-browser",
17
+ "installCommand": {
18
+ "macOS": "npm install -g agent-browser",
19
+ "Linux": "npm install -g agent-browser",
20
+ "Windows": "npm install -g agent-browser"
21
+ }
22
+ },
23
+ {
24
+ "id": "agent-device",
25
+ "name": "Agent Device",
26
+ "description": "iOS/Android device automation CLI for AI agents",
27
+ "category": "automation",
28
+ "recommended": true,
29
+ "detectCommand": "agent-device",
30
+ "installCommand": {
31
+ "macOS": "npm install -g agent-device",
32
+ "Linux": "npm install -g agent-device",
33
+ "Windows": "npm install -g agent-device"
34
+ }
35
+ }
36
+ ]
37
+ }