@writepanda/cli 1.9.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamala Kannan Sankarraj
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,100 @@
1
+ # @writepanda/cli
2
+
3
+ The `pandastudio` command-line interface — drive [PandaStudio](https://www.writepanda.ai) (a desktop video editor for YouTube creators) from your terminal, shell scripts, or AI agents.
4
+
5
+ PandaStudio's signature feature — editing video by deleting words from the transcript — is now scriptable. Plus headless export, motion-graphic generation, FX overlays, lower-thirds, captions, AI title/description generation. Everything the human editor does, callable from the CLI.
6
+
7
+ > **You also need the desktop app installed.** The CLI is a thin HTTP client; the actual rendering happens in PandaStudio's localhost-only automation server. Get the app at [writepanda.ai](https://www.writepanda.ai).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g @writepanda/cli
13
+ # or run without installing:
14
+ npx @writepanda/cli system.status
15
+ ```
16
+
17
+ The same `pandastudio` binary also ships bundled inside every PandaStudio install (Settings → Local automation → Install to PATH). The npm version is convenient for AI agents and CI pipelines that want to script the editor without going through the in-app install button.
18
+
19
+ ## Quickstart
20
+
21
+ ```bash
22
+ # 1. Install + open PandaStudio (writepanda.ai). Settings → Local automation → toggle ON.
23
+
24
+ # 2. Confirm reachability:
25
+ pandastudio system.status
26
+
27
+ # 3. Discover the surface (62 verb-noun commands):
28
+ pandastudio commands
29
+
30
+ # 4. Render a motion graphic:
31
+ JOB=$(pandastudio motion.generate \
32
+ --templateId=title-card-vox \
33
+ --slots='{"title":"How I Built This","subtitle":"in 24 hours"}' \
34
+ --aspectRatio=16:9 \
35
+ --json | jq -r '.data.jobId')
36
+
37
+ pandastudio job.wait --id="$JOB" --json | jq '.data.job.result.outputPath'
38
+ ```
39
+
40
+ If PandaStudio isn't running, the CLI auto-launches it and waits up to 60s for the HTTP listener. Pass `--no-launch` to opt out.
41
+
42
+ ## The agentic edit flow
43
+
44
+ The 13-step "agent gets two videos and produces a polished MP4" workflow is documented in full at [writepanda.ai/cli](https://www.writepanda.ai/cli). Headline:
45
+
46
+ ```bash
47
+ pandastudio project.new --withMedia='[...]' --json
48
+ pandastudio transcript.transcribe --id=$ID
49
+ pandastudio transcript.remove-fillers --id=$ID
50
+ pandastudio motion.generate ...
51
+ pandastudio project.add-motion-graphic ...
52
+ pandastudio caption.toggle --enabled=true
53
+ pandastudio caption.set-template --templateId=bold
54
+ pandastudio export.start --id=$ID
55
+ ```
56
+
57
+ Headless. No editor window required. Same Skia native render-helper the in-app Export button uses.
58
+
59
+ ## For AI agents (Claude Code, Claude Desktop)
60
+
61
+ If you're an agent: install the **bundled Claude Skill** instead — it teaches you the full surface in one auto-discovered SKILL.md. From the running PandaStudio app: Settings → Local automation → Install Skill. The Skill copies to `~/.claude/skills/pandastudio/` and Claude auto-loads it.
62
+
63
+ For Cursor / Continue / Cline / other MCP clients, use the companion package: [`@writepanda/mcp`](https://www.npmjs.com/package/@writepanda/mcp).
64
+
65
+ ## Auth
66
+
67
+ Every PandaStudio launch generates a fresh 256-bit token, written 0600 to:
68
+
69
+ | Platform | Path |
70
+ |---|---|
71
+ | macOS / Linux | `~/.config/pandastudio/{token,port,audit.log}` |
72
+ | Windows | `%APPDATA%\pandastudio\{token,port,audit.log}` |
73
+
74
+ The CLI reads both files on every invocation; no env vars, no caching. Closing and reopening the desktop app rotates the token automatically. `PANDASTUDIO_CONFIG_DIR` overrides the path (used in tests).
75
+
76
+ ## Licensing
77
+
78
+ The CLI honours the same license gate the desktop app does:
79
+
80
+ | State | What's allowed |
81
+ |---|---|
82
+ | Licensed | Every command |
83
+ | Active trial | Every command |
84
+ | Trial expired, no license | Only `system.*` and `window.focus` |
85
+
86
+ Run `pandastudio system.status` to see `automationGated: true/false` in the response.
87
+
88
+ ## Output modes
89
+
90
+ - Default: pretty one-line summaries to stdout
91
+ - `--json`: raw `{ ok, data, error }` envelope — pipe through `jq` for scripting
92
+
93
+ ## Documentation
94
+
95
+ - Full command catalogue + recipes: [writepanda.ai/cli](https://www.writepanda.ai/cli)
96
+ - Source: [github.com/kamskans/openscreen](https://github.com/kamskans/openscreen)
97
+
98
+ ## License
99
+
100
+ MIT.
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+ // pandastudio — local CLI for the PandaStudio automation surface.
3
+ //
4
+ // Design goals (per the HuggingFace CLI-vs-MCP analysis):
5
+ // • Token-frugal output. By default we print a one-line success
6
+ // summary, with `--json` opting into machine-readable payloads.
7
+ // • Deterministic dispatch. Every command is a `verb.noun` that maps
8
+ // 1:1 to a registered handler in the main process.
9
+ // • Graceful auto-launch. If the app isn't running, we spawn the
10
+ // installed PandaStudio.app (or the dev binary) and poll
11
+ // `/v1/health` until it's ready before sending the actual call.
12
+ // • No third-party deps — pure Node so it's bundled as a single
13
+ // file inside the installer.
14
+ //
15
+ // Layout:
16
+ // ~/.config/pandastudio/{token,port,audit.log} ← well-known
17
+ // /usr/local/bin/pandastudio (mac, post-install symlink)
18
+ // %ProgramFiles%/PandaStudio/cli/pandastudio.exe (Windows installer)
19
+
20
+ import { spawn } from "node:child_process";
21
+ import fs from "node:fs/promises";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import process from "node:process";
25
+
26
+ const VERSION = "1.0.0";
27
+
28
+ function configDir() {
29
+ // PANDASTUDIO_CONFIG_DIR mirrors the override the main process
30
+ // honours in electron/automation/auth.ts. Useful for (a) integration
31
+ // tests that point both sides at a tmp dir, (b) power users with an
32
+ // alternate install location.
33
+ if (process.env.PANDASTUDIO_CONFIG_DIR) {
34
+ return process.env.PANDASTUDIO_CONFIG_DIR;
35
+ }
36
+ if (process.platform === "win32") {
37
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
38
+ return path.join(appData, "pandastudio");
39
+ }
40
+ return path.join(os.homedir(), ".config", "pandastudio");
41
+ }
42
+
43
+ const TOKEN_FILE = path.join(configDir(), "token");
44
+ const PORT_FILE = path.join(configDir(), "port");
45
+
46
+ function printHelp() {
47
+ process.stdout.write(`pandastudio v${VERSION} — local CLI for the PandaStudio automation surface
48
+
49
+ USAGE
50
+ pandastudio <command> [--key value …] Run a command
51
+ pandastudio --help This message
52
+ pandastudio --version Print CLI version
53
+ pandastudio commands List every available verb.noun
54
+
55
+ OPTIONS
56
+ --json Emit raw JSON to stdout (machine-readable mode).
57
+ --no-launch Don't auto-launch PandaStudio if it isn't running.
58
+ --timeout=<seconds> How long to wait for auto-launch readiness (default 60).
59
+ --token=<value> Override the on-disk token (rare; for tests).
60
+ --port=<n> Override the on-disk port (rare; for tests).
61
+ --app=<path> Override the path to the PandaStudio binary used for auto-launch.
62
+
63
+ EXAMPLES
64
+ pandastudio system.status
65
+ pandastudio system.status --json
66
+ pandastudio motion.list
67
+ pandastudio motion.generate --templateId=title-card-vox --slots='{"title":"Hello","subtitle":"World"}'
68
+ pandastudio job.wait --id=<jobId>
69
+
70
+ DOCS
71
+ Token + port files live in ${configDir()}.
72
+ Audit log: ${path.join(configDir(), "audit.log")}.
73
+ `);
74
+ }
75
+
76
+ function parseArgv(argv) {
77
+ const args = { _: [], flags: {}, json: false, noLaunch: false, timeoutSeconds: 60 };
78
+ for (const raw of argv) {
79
+ if (raw === "--help" || raw === "-h") {
80
+ args.help = true;
81
+ } else if (raw === "--version" || raw === "-v") {
82
+ args.version = true;
83
+ } else if (raw === "--json") {
84
+ args.json = true;
85
+ } else if (raw === "--no-launch") {
86
+ args.noLaunch = true;
87
+ } else if (raw.startsWith("--timeout=")) {
88
+ args.timeoutSeconds = Number(raw.slice("--timeout=".length)) || 60;
89
+ } else if (raw.startsWith("--token=")) {
90
+ args.tokenOverride = raw.slice("--token=".length);
91
+ } else if (raw.startsWith("--port=")) {
92
+ args.portOverride = Number(raw.slice("--port=".length));
93
+ } else if (raw.startsWith("--app=")) {
94
+ args.appOverride = raw.slice("--app=".length);
95
+ } else if (raw.startsWith("--")) {
96
+ // --key=value or --key value (we only support --key=value form
97
+ // to keep things unambiguous; --flag with no value sets true)
98
+ const eq = raw.indexOf("=");
99
+ if (eq < 0) {
100
+ args.flags[raw.slice(2)] = true;
101
+ } else {
102
+ const key = raw.slice(2, eq);
103
+ const value = raw.slice(eq + 1);
104
+ args.flags[key] = parseScalar(value);
105
+ }
106
+ } else {
107
+ args._.push(raw);
108
+ }
109
+ }
110
+ return args;
111
+ }
112
+
113
+ function parseScalar(value) {
114
+ // JSON pass-through for objects/arrays/booleans/numbers; fall back
115
+ // to the literal string. Lets `--slots='{"title":"x"}'` work without
116
+ // a separate `--slots-json` flag.
117
+ const trimmed = value.trim();
118
+ if (
119
+ trimmed.startsWith("{") ||
120
+ trimmed.startsWith("[") ||
121
+ trimmed === "true" ||
122
+ trimmed === "false" ||
123
+ trimmed === "null" ||
124
+ (/^-?\d/.test(trimmed) && !Number.isNaN(Number(trimmed)))
125
+ ) {
126
+ try {
127
+ return JSON.parse(trimmed);
128
+ } catch {
129
+ /* fall through */
130
+ }
131
+ }
132
+ return value;
133
+ }
134
+
135
+ async function readCredentials(opts) {
136
+ const port = opts.portOverride ?? Number((await fs.readFile(PORT_FILE, "utf-8")).trim());
137
+ const token = opts.tokenOverride ?? (await fs.readFile(TOKEN_FILE, "utf-8")).trim();
138
+ if (!Number.isInteger(port) || port <= 0) throw new Error(`invalid port in ${PORT_FILE}`);
139
+ if (!token) throw new Error(`empty token in ${TOKEN_FILE}`);
140
+ return { port, token };
141
+ }
142
+
143
+ async function fetchJson(port, pathSuffix, opts = {}) {
144
+ // Use the global fetch (Node 18+ ships it); we only require Node 22
145
+ // at runtime per the app's package.json engines block, so this is
146
+ // safe to assume.
147
+ const url = `http://127.0.0.1:${port}${pathSuffix}`;
148
+ const res = await fetch(url, opts);
149
+ const text = await res.text();
150
+ let parsed;
151
+ try {
152
+ parsed = text.length === 0 ? null : JSON.parse(text);
153
+ } catch {
154
+ parsed = { raw: text };
155
+ }
156
+ return { status: res.status, body: parsed };
157
+ }
158
+
159
+ async function probeHealth(port, timeoutMs) {
160
+ // Single short probe with a small AbortController; used as a
161
+ // liveness check before auto-launching, then in a poll loop after
162
+ // launch to wait for readiness.
163
+ const ctrl = new AbortController();
164
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
165
+ try {
166
+ const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
167
+ clearTimeout(t);
168
+ return res.ok;
169
+ } catch {
170
+ clearTimeout(t);
171
+ return false;
172
+ }
173
+ }
174
+
175
+ function findInstalledApp(appOverride) {
176
+ if (appOverride) return appOverride;
177
+ if (process.env.PANDASTUDIO_BIN) return process.env.PANDASTUDIO_BIN;
178
+
179
+ if (process.platform === "darwin") {
180
+ const macCandidates = [
181
+ "/Applications/PandaStudio.app/Contents/MacOS/PandaStudio",
182
+ path.join(
183
+ os.homedir(),
184
+ "Applications",
185
+ "PandaStudio.app",
186
+ "Contents",
187
+ "MacOS",
188
+ "PandaStudio",
189
+ ),
190
+ ];
191
+ return macCandidates[0]; // best-effort; spawn() will surface ENOENT clearly
192
+ }
193
+ if (process.platform === "win32") {
194
+ const programFiles = process.env["ProgramFiles"] ?? "C:\\Program Files";
195
+ return path.join(programFiles, "PandaStudio", "PandaStudio.exe");
196
+ }
197
+ // Linux is unsupported by the installer today, but scaffolding it
198
+ // here means the day we publish a .AppImage or .deb the auto-launch
199
+ // path is one PR away.
200
+ return "/opt/PandaStudio/pandastudio";
201
+ }
202
+
203
+ async function autoLaunchAndWait(opts, log) {
204
+ const bin = findInstalledApp(opts.appOverride);
205
+ log(`auto-launching ${bin}`);
206
+ try {
207
+ const child = spawn(bin, [], {
208
+ detached: true,
209
+ stdio: "ignore",
210
+ env: process.env,
211
+ });
212
+ child.unref();
213
+ } catch (err) {
214
+ throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
215
+ }
216
+
217
+ const deadline = Date.now() + opts.timeoutSeconds * 1000;
218
+ let credsKnown = null;
219
+ while (Date.now() < deadline) {
220
+ // Re-read credentials each iteration — the app writes them only
221
+ // after binding, so the file may not exist yet.
222
+ try {
223
+ credsKnown = await readCredentials(opts);
224
+ if (await probeHealth(credsKnown.port, 2000)) {
225
+ log(
226
+ `ready after ${Math.round((Date.now() - (deadline - opts.timeoutSeconds * 1000)) / 1000)}s`,
227
+ );
228
+ return credsKnown;
229
+ }
230
+ } catch {
231
+ /* token/port not written yet */
232
+ }
233
+ await new Promise((r) => setTimeout(r, 500));
234
+ }
235
+ throw new Error(
236
+ `PandaStudio did not become ready within ${opts.timeoutSeconds}s. Open the app and enable "Allow local automation" in Settings.`,
237
+ );
238
+ }
239
+
240
+ function emit(parsed, opts) {
241
+ if (opts.json) {
242
+ process.stdout.write(`${JSON.stringify(parsed.body, null, 2)}\n`);
243
+ return parsed.body?.ok === false ? 1 : 0;
244
+ }
245
+ if (parsed.status >= 400) {
246
+ process.stderr.write(`HTTP ${parsed.status}: ${parsed.body?.error ?? "unknown error"}\n`);
247
+ return 1;
248
+ }
249
+ if (parsed.body?.ok === false) {
250
+ process.stderr.write(`error: ${parsed.body.error}\n`);
251
+ if (parsed.body.details) {
252
+ process.stderr.write(`${JSON.stringify(parsed.body.details, null, 2)}\n`);
253
+ }
254
+ return 1;
255
+ }
256
+ prettyPrint(parsed.body?.data);
257
+ return 0;
258
+ }
259
+
260
+ function prettyPrint(data) {
261
+ // Heuristic table-ish output for common shapes. Anything we don't
262
+ // recognise falls back to JSON, which is still readable without
263
+ // blowing the user's terminal up.
264
+ if (data == null) {
265
+ process.stdout.write("ok\n");
266
+ return;
267
+ }
268
+ if (Array.isArray(data?.commands)) {
269
+ for (const c of data.commands) {
270
+ process.stdout.write(` ${c.command.padEnd(28)} ${c.summary}\n`);
271
+ }
272
+ return;
273
+ }
274
+ if (Array.isArray(data?.templates)) {
275
+ for (const t of data.templates) {
276
+ process.stdout.write(` ${t.id.padEnd(28)} ${t.name}\n`);
277
+ }
278
+ return;
279
+ }
280
+ if (Array.isArray(data?.themes)) {
281
+ for (const t of data.themes) {
282
+ process.stdout.write(` ${t.id.padEnd(20)} ${t.name}\n`);
283
+ }
284
+ return;
285
+ }
286
+ if (Array.isArray(data?.projects)) {
287
+ for (const p of data.projects) {
288
+ process.stdout.write(` ${p.modifiedAt} ${p.name}\n ${p.path}\n`);
289
+ }
290
+ return;
291
+ }
292
+ if (Array.isArray(data?.exports)) {
293
+ for (const e of data.exports) {
294
+ process.stdout.write(` ${new Date(e.exportedAt).toISOString()} ${e.fileName}\n`);
295
+ }
296
+ return;
297
+ }
298
+ if (data?.job) {
299
+ const j = data.job;
300
+ process.stdout.write(` job ${j.id}\n status: ${j.status}\n`);
301
+ if (j.progress?.message) process.stdout.write(` progress: ${j.progress.message}\n`);
302
+ if (j.error) process.stdout.write(` error: ${j.error}\n`);
303
+ if (j.result) process.stdout.write(` result: ${JSON.stringify(j.result)}\n`);
304
+ return;
305
+ }
306
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
307
+ }
308
+
309
+ async function main() {
310
+ const argv = process.argv.slice(2);
311
+ const opts = parseArgv(argv);
312
+
313
+ if (opts.version) {
314
+ process.stdout.write(`${VERSION}\n`);
315
+ return 0;
316
+ }
317
+ if (opts.help || (opts._.length === 0 && Object.keys(opts.flags).length === 0)) {
318
+ printHelp();
319
+ return 0;
320
+ }
321
+
322
+ const log = (msg) => {
323
+ if (!opts.json) process.stderr.write(`[pandastudio] ${msg}\n`);
324
+ };
325
+
326
+ let command = opts._[0];
327
+ if (!command) {
328
+ process.stderr.write("error: missing command. Try `pandastudio --help`.\n");
329
+ return 2;
330
+ }
331
+
332
+ // Friendly aliases — `pandastudio commands` → `system.list`.
333
+ if (command === "commands") command = "system.list";
334
+ if (command === "status") command = "system.status";
335
+ if (command === "ping") command = "system.ping";
336
+
337
+ if (!command.includes(".")) {
338
+ process.stderr.write(`error: command must be of the form 'verb.noun' (got '${command}')\n`);
339
+ return 2;
340
+ }
341
+
342
+ let creds;
343
+ try {
344
+ creds = await readCredentials(opts);
345
+ } catch {
346
+ creds = null;
347
+ }
348
+
349
+ let alive = creds ? await probeHealth(creds.port, 1500) : false;
350
+ if (!alive) {
351
+ if (opts.noLaunch) {
352
+ process.stderr.write(
353
+ "error: PandaStudio is not reachable and --no-launch was set.\n Open the app and enable 'Allow local automation' in Settings.\n",
354
+ );
355
+ return 3;
356
+ }
357
+ try {
358
+ creds = await autoLaunchAndWait(opts, log);
359
+ alive = true;
360
+ } catch (err) {
361
+ process.stderr.write(`${err?.message ?? err}\n`);
362
+ return 4;
363
+ }
364
+ }
365
+
366
+ // Build the call envelope. Everything after the command is mapped
367
+ // into the `args` object — the value `--slots='{"a":1}'` becomes
368
+ // `args.slots = {a:1}` thanks to parseScalar.
369
+ const callBody = { command, args: { ...opts.flags } };
370
+ const result = await fetchJson(creds.port, "/v1/call", {
371
+ method: "POST",
372
+ headers: {
373
+ "Content-Type": "application/json",
374
+ Authorization: `Bearer ${creds.token}`,
375
+ },
376
+ body: JSON.stringify(callBody),
377
+ });
378
+
379
+ return emit(result, opts);
380
+ }
381
+
382
+ // IMPORTANT: do NOT call process.exit() here. process.exit forces an
383
+ // immediate teardown that doesn't wait for stdout/stderr to drain — on
384
+ // macOS the OS pipe buffer caps at 64 KiB, so any response larger than
385
+ // that gets silently truncated mid-write (we hit this with export.list
386
+ // returning 144 KB of JSON — only the first 64 KiB reached the user).
387
+ //
388
+ // Setting process.exitCode and letting Node return naturally lets the
389
+ // streams flush properly. The "no top-level work pending" exit happens
390
+ // after stdout's drain queue is empty.
391
+ main()
392
+ .then((code) => {
393
+ process.exitCode = code ?? 0;
394
+ })
395
+ .catch((err) => {
396
+ process.stderr.write(`fatal: ${err?.stack ?? err}\n`);
397
+ process.exitCode = 99;
398
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@writepanda/cli",
3
+ "version": "1.9.3",
4
+ "description": "Drive PandaStudio (a desktop video editor for YouTube creators) from the command line. Talks to a localhost HTTP API the desktop app exposes.",
5
+ "keywords": [
6
+ "pandastudio",
7
+ "video-editor",
8
+ "cli",
9
+ "automation",
10
+ "agent",
11
+ "claude-code"
12
+ ],
13
+ "author": "Kamala Kannan Sankarraj",
14
+ "license": "MIT",
15
+ "homepage": "https://www.writepanda.ai/cli",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/kamskans/openscreen.git",
19
+ "directory": "packages/cli"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/kamskans/openscreen/issues"
23
+ },
24
+ "type": "module",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "bin": {
29
+ "pandastudio": "./bin/pandastudio.mjs"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "prepublishOnly": "node ../../scripts/sync-cli-to-package.mjs"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }