@every-env/compound-plugin 0.7.0 → 0.8.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 (33) hide show
  1. package/.cursor-plugin/marketplace.json +25 -0
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +14 -8
  4. package/bun.lock +1 -0
  5. package/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md +117 -0
  6. package/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md +30 -0
  7. package/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md +328 -0
  8. package/docs/specs/copilot.md +122 -0
  9. package/package.json +1 -1
  10. package/plugins/coding-tutor/.cursor-plugin/plugin.json +21 -0
  11. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  12. package/plugins/compound-engineering/.cursor-plugin/plugin.json +31 -0
  13. package/plugins/compound-engineering/.mcp.json +8 -0
  14. package/plugins/compound-engineering/CHANGELOG.md +10 -0
  15. package/plugins/compound-engineering/commands/lfg.md +3 -3
  16. package/plugins/compound-engineering/commands/slfg.md +2 -2
  17. package/plugins/compound-engineering/commands/workflows/plan.md +15 -1
  18. package/src/commands/install.ts +5 -1
  19. package/src/commands/sync.ts +8 -8
  20. package/src/converters/{claude-to-cursor.ts → claude-to-copilot.ts} +93 -49
  21. package/src/sync/{cursor.ts → copilot.ts} +36 -14
  22. package/src/targets/copilot.ts +48 -0
  23. package/src/targets/index.ts +9 -9
  24. package/src/types/copilot.ts +31 -0
  25. package/src/utils/frontmatter.ts +1 -1
  26. package/tests/copilot-converter.test.ts +467 -0
  27. package/tests/copilot-writer.test.ts +189 -0
  28. package/tests/sync-copilot.test.ts +148 -0
  29. package/src/targets/cursor.ts +0 -48
  30. package/src/types/cursor.ts +0 -29
  31. package/tests/cursor-converter.test.ts +0 -347
  32. package/tests/cursor-writer.test.ts +0 -137
  33. package/tests/sync-cursor.test.ts +0 -92
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { promises as fs } from "fs"
3
+ import path from "path"
4
+ import os from "os"
5
+ import { syncToCopilot } from "../src/sync/copilot"
6
+ import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
7
+
8
+ describe("syncToCopilot", () => {
9
+ test("symlinks skills to .github/skills/", async () => {
10
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-"))
11
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
12
+
13
+ const config: ClaudeHomeConfig = {
14
+ skills: [
15
+ {
16
+ name: "skill-one",
17
+ sourceDir: fixtureSkillDir,
18
+ skillPath: path.join(fixtureSkillDir, "SKILL.md"),
19
+ },
20
+ ],
21
+ mcpServers: {},
22
+ }
23
+
24
+ await syncToCopilot(config, tempRoot)
25
+
26
+ const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
27
+ const linkedStat = await fs.lstat(linkedSkillPath)
28
+ expect(linkedStat.isSymbolicLink()).toBe(true)
29
+ })
30
+
31
+ test("skips skills with invalid names", async () => {
32
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-"))
33
+
34
+ const config: ClaudeHomeConfig = {
35
+ skills: [
36
+ {
37
+ name: "../escape-attempt",
38
+ sourceDir: "/tmp/bad-skill",
39
+ skillPath: "/tmp/bad-skill/SKILL.md",
40
+ },
41
+ ],
42
+ mcpServers: {},
43
+ }
44
+
45
+ await syncToCopilot(config, tempRoot)
46
+
47
+ const skillsDir = path.join(tempRoot, "skills")
48
+ const entries = await fs.readdir(skillsDir).catch(() => [])
49
+ expect(entries).toHaveLength(0)
50
+ })
51
+
52
+ test("merges MCP config with existing file", async () => {
53
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-"))
54
+ const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
55
+
56
+ await fs.writeFile(
57
+ mcpPath,
58
+ JSON.stringify({
59
+ mcpServers: {
60
+ existing: { type: "local", command: "node", args: ["server.js"], tools: ["*"] },
61
+ },
62
+ }, null, 2),
63
+ )
64
+
65
+ const config: ClaudeHomeConfig = {
66
+ skills: [],
67
+ mcpServers: {
68
+ context7: { url: "https://mcp.context7.com/mcp" },
69
+ },
70
+ }
71
+
72
+ await syncToCopilot(config, tempRoot)
73
+
74
+ const merged = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
75
+ mcpServers: Record<string, { command?: string; url?: string; type: string }>
76
+ }
77
+
78
+ expect(merged.mcpServers.existing?.command).toBe("node")
79
+ expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
80
+ })
81
+
82
+ test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => {
83
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-env-"))
84
+
85
+ const config: ClaudeHomeConfig = {
86
+ skills: [],
87
+ mcpServers: {
88
+ server: {
89
+ command: "echo",
90
+ args: ["hello"],
91
+ env: { API_KEY: "secret", COPILOT_MCP_TOKEN: "already-prefixed" },
92
+ },
93
+ },
94
+ }
95
+
96
+ await syncToCopilot(config, tempRoot)
97
+
98
+ const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
99
+ const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
100
+ mcpServers: Record<string, { env?: Record<string, string> }>
101
+ }
102
+
103
+ expect(mcpConfig.mcpServers.server?.env).toEqual({
104
+ COPILOT_MCP_API_KEY: "secret",
105
+ COPILOT_MCP_TOKEN: "already-prefixed",
106
+ })
107
+ })
108
+
109
+ test("writes MCP config with restricted permissions", async () => {
110
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-perms-"))
111
+
112
+ const config: ClaudeHomeConfig = {
113
+ skills: [],
114
+ mcpServers: {
115
+ server: { command: "echo", args: ["hello"] },
116
+ },
117
+ }
118
+
119
+ await syncToCopilot(config, tempRoot)
120
+
121
+ const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
122
+ const stat = await fs.stat(mcpPath)
123
+ // Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms)
124
+ const perms = stat.mode & 0o777
125
+ expect(perms).toBe(0o600)
126
+ })
127
+
128
+ test("does not write MCP config when no MCP servers", async () => {
129
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-nomcp-"))
130
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
131
+
132
+ const config: ClaudeHomeConfig = {
133
+ skills: [
134
+ {
135
+ name: "skill-one",
136
+ sourceDir: fixtureSkillDir,
137
+ skillPath: path.join(fixtureSkillDir, "SKILL.md"),
138
+ },
139
+ ],
140
+ mcpServers: {},
141
+ }
142
+
143
+ await syncToCopilot(config, tempRoot)
144
+
145
+ const mcpExists = await fs.access(path.join(tempRoot, "copilot-mcp-config.json")).then(() => true).catch(() => false)
146
+ expect(mcpExists).toBe(false)
147
+ })
148
+ })
@@ -1,48 +0,0 @@
1
- import path from "path"
2
- import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
3
- import type { CursorBundle } from "../types/cursor"
4
-
5
- export async function writeCursorBundle(outputRoot: string, bundle: CursorBundle): Promise<void> {
6
- const paths = resolveCursorPaths(outputRoot)
7
- await ensureDir(paths.cursorDir)
8
-
9
- if (bundle.rules.length > 0) {
10
- const rulesDir = path.join(paths.cursorDir, "rules")
11
- for (const rule of bundle.rules) {
12
- await writeText(path.join(rulesDir, `${rule.name}.mdc`), rule.content + "\n")
13
- }
14
- }
15
-
16
- if (bundle.commands.length > 0) {
17
- const commandsDir = path.join(paths.cursorDir, "commands")
18
- for (const command of bundle.commands) {
19
- await writeText(path.join(commandsDir, `${command.name}.md`), command.content + "\n")
20
- }
21
- }
22
-
23
- if (bundle.skillDirs.length > 0) {
24
- const skillsDir = path.join(paths.cursorDir, "skills")
25
- for (const skill of bundle.skillDirs) {
26
- await copyDir(skill.sourceDir, path.join(skillsDir, skill.name))
27
- }
28
- }
29
-
30
- if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
31
- const mcpPath = path.join(paths.cursorDir, "mcp.json")
32
- const backupPath = await backupFile(mcpPath)
33
- if (backupPath) {
34
- console.log(`Backed up existing mcp.json to ${backupPath}`)
35
- }
36
- await writeJson(mcpPath, { mcpServers: bundle.mcpServers })
37
- }
38
- }
39
-
40
- function resolveCursorPaths(outputRoot: string) {
41
- const base = path.basename(outputRoot)
42
- // If already pointing at .cursor, write directly into it
43
- if (base === ".cursor") {
44
- return { cursorDir: outputRoot }
45
- }
46
- // Otherwise nest under .cursor
47
- return { cursorDir: path.join(outputRoot, ".cursor") }
48
- }
@@ -1,29 +0,0 @@
1
- export type CursorRule = {
2
- name: string
3
- content: string
4
- }
5
-
6
- export type CursorCommand = {
7
- name: string
8
- content: string
9
- }
10
-
11
- export type CursorSkillDir = {
12
- name: string
13
- sourceDir: string
14
- }
15
-
16
- export type CursorMcpServer = {
17
- command?: string
18
- args?: string[]
19
- env?: Record<string, string>
20
- url?: string
21
- headers?: Record<string, string>
22
- }
23
-
24
- export type CursorBundle = {
25
- rules: CursorRule[]
26
- commands: CursorCommand[]
27
- skillDirs: CursorSkillDir[]
28
- mcpServers?: Record<string, CursorMcpServer>
29
- }
@@ -1,347 +0,0 @@
1
- import { describe, expect, test, spyOn } from "bun:test"
2
- import { convertClaudeToCursor, transformContentForCursor } from "../src/converters/claude-to-cursor"
3
- import { parseFrontmatter } from "../src/utils/frontmatter"
4
- import type { ClaudePlugin } from "../src/types/claude"
5
-
6
- const fixturePlugin: ClaudePlugin = {
7
- root: "/tmp/plugin",
8
- manifest: { name: "fixture", version: "1.0.0" },
9
- agents: [
10
- {
11
- name: "Security Reviewer",
12
- description: "Security-focused code review agent",
13
- capabilities: ["Threat modeling", "OWASP"],
14
- model: "claude-sonnet-4-20250514",
15
- body: "Focus on vulnerabilities.",
16
- sourcePath: "/tmp/plugin/agents/security-reviewer.md",
17
- },
18
- ],
19
- commands: [
20
- {
21
- name: "workflows:plan",
22
- description: "Planning command",
23
- argumentHint: "[FOCUS]",
24
- model: "inherit",
25
- allowedTools: ["Read"],
26
- body: "Plan the work.",
27
- sourcePath: "/tmp/plugin/commands/workflows/plan.md",
28
- },
29
- ],
30
- skills: [
31
- {
32
- name: "existing-skill",
33
- description: "Existing skill",
34
- sourceDir: "/tmp/plugin/skills/existing-skill",
35
- skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
36
- },
37
- ],
38
- hooks: undefined,
39
- mcpServers: undefined,
40
- }
41
-
42
- const defaultOptions = {
43
- agentMode: "subagent" as const,
44
- inferTemperature: false,
45
- permissions: "none" as const,
46
- }
47
-
48
- describe("convertClaudeToCursor", () => {
49
- test("converts agents to rules with .mdc frontmatter", () => {
50
- const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
51
-
52
- expect(bundle.rules).toHaveLength(1)
53
- const rule = bundle.rules[0]
54
- expect(rule.name).toBe("security-reviewer")
55
-
56
- const parsed = parseFrontmatter(rule.content)
57
- expect(parsed.data.description).toBe("Security-focused code review agent")
58
- expect(parsed.data.alwaysApply).toBe(false)
59
- // globs is omitted (Agent Requested mode doesn't need it)
60
- expect(parsed.body).toContain("Capabilities")
61
- expect(parsed.body).toContain("Threat modeling")
62
- expect(parsed.body).toContain("Focus on vulnerabilities.")
63
- })
64
-
65
- test("agent with empty description gets default", () => {
66
- const plugin: ClaudePlugin = {
67
- ...fixturePlugin,
68
- agents: [
69
- {
70
- name: "basic-agent",
71
- body: "Do things.",
72
- sourcePath: "/tmp/plugin/agents/basic.md",
73
- },
74
- ],
75
- }
76
-
77
- const bundle = convertClaudeToCursor(plugin, defaultOptions)
78
- const parsed = parseFrontmatter(bundle.rules[0].content)
79
- expect(parsed.data.description).toBe("Converted from Claude agent basic-agent")
80
- })
81
-
82
- test("agent with empty body gets default body", () => {
83
- const plugin: ClaudePlugin = {
84
- ...fixturePlugin,
85
- agents: [
86
- {
87
- name: "empty-agent",
88
- description: "Empty agent",
89
- body: "",
90
- sourcePath: "/tmp/plugin/agents/empty.md",
91
- },
92
- ],
93
- }
94
-
95
- const bundle = convertClaudeToCursor(plugin, defaultOptions)
96
- const parsed = parseFrontmatter(bundle.rules[0].content)
97
- expect(parsed.body).toContain("Instructions converted from the empty-agent agent.")
98
- })
99
-
100
- test("agent capabilities are prepended to body", () => {
101
- const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
102
- const parsed = parseFrontmatter(bundle.rules[0].content)
103
- expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/)
104
- })
105
-
106
- test("agent model field is silently dropped", () => {
107
- const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
108
- const parsed = parseFrontmatter(bundle.rules[0].content)
109
- expect(parsed.data.model).toBeUndefined()
110
- })
111
-
112
- test("flattens namespaced command names", () => {
113
- const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
114
-
115
- expect(bundle.commands).toHaveLength(1)
116
- const command = bundle.commands[0]
117
- expect(command.name).toBe("plan")
118
- })
119
-
120
- test("commands are plain markdown without frontmatter", () => {
121
- const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
122
- const command = bundle.commands[0]
123
-
124
- // Should NOT start with ---
125
- expect(command.content.startsWith("---")).toBe(false)
126
- // Should include the description as a comment
127
- expect(command.content).toContain("<!-- Planning command -->")
128
- expect(command.content).toContain("Plan the work.")
129
- })
130
-
131
- test("command name collision after flattening is deduplicated", () => {
132
- const plugin: ClaudePlugin = {
133
- ...fixturePlugin,
134
- commands: [
135
- {
136
- name: "workflows:plan",
137
- description: "Workflow plan",
138
- body: "Plan body.",
139
- sourcePath: "/tmp/plugin/commands/workflows/plan.md",
140
- },
141
- {
142
- name: "plan",
143
- description: "Top-level plan",
144
- body: "Top plan body.",
145
- sourcePath: "/tmp/plugin/commands/plan.md",
146
- },
147
- ],
148
- agents: [],
149
- skills: [],
150
- }
151
-
152
- const bundle = convertClaudeToCursor(plugin, defaultOptions)
153
- const names = bundle.commands.map((c) => c.name)
154
- expect(names).toEqual(["plan", "plan-2"])
155
- })
156
-
157
- test("command with disable-model-invocation is still included", () => {
158
- const plugin: ClaudePlugin = {
159
- ...fixturePlugin,
160
- commands: [
161
- {
162
- name: "setup",
163
- description: "Setup command",
164
- disableModelInvocation: true,
165
- body: "Setup body.",
166
- sourcePath: "/tmp/plugin/commands/setup.md",
167
- },
168
- ],
169
- agents: [],
170
- skills: [],
171
- }
172
-
173
- const bundle = convertClaudeToCursor(plugin, defaultOptions)
174
- expect(bundle.commands).toHaveLength(1)
175
- expect(bundle.commands[0].name).toBe("setup")
176
- })
177
-
178
- test("command allowedTools is silently dropped", () => {
179
- const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
180
- const command = bundle.commands[0]
181
- expect(command.content).not.toContain("allowedTools")
182
- expect(command.content).not.toContain("Read")
183
- })
184
-
185
- test("command with argument-hint gets Arguments section", () => {
186
- const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
187
- const command = bundle.commands[0]
188
- expect(command.content).toContain("## Arguments")
189
- expect(command.content).toContain("[FOCUS]")
190
- })
191
-
192
- test("passes through skill directories", () => {
193
- const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
194
-
195
- expect(bundle.skillDirs).toHaveLength(1)
196
- expect(bundle.skillDirs[0].name).toBe("existing-skill")
197
- expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill")
198
- })
199
-
200
- test("converts MCP servers to JSON config", () => {
201
- const plugin: ClaudePlugin = {
202
- ...fixturePlugin,
203
- agents: [],
204
- commands: [],
205
- skills: [],
206
- mcpServers: {
207
- playwright: {
208
- command: "npx",
209
- args: ["-y", "@anthropic/mcp-playwright"],
210
- env: { DISPLAY: ":0" },
211
- },
212
- },
213
- }
214
-
215
- const bundle = convertClaudeToCursor(plugin, defaultOptions)
216
- expect(bundle.mcpServers).toBeDefined()
217
- expect(bundle.mcpServers!.playwright.command).toBe("npx")
218
- expect(bundle.mcpServers!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"])
219
- expect(bundle.mcpServers!.playwright.env).toEqual({ DISPLAY: ":0" })
220
- })
221
-
222
- test("MCP headers pass through for remote servers", () => {
223
- const plugin: ClaudePlugin = {
224
- ...fixturePlugin,
225
- agents: [],
226
- commands: [],
227
- skills: [],
228
- mcpServers: {
229
- remote: {
230
- url: "https://mcp.example.com/sse",
231
- headers: { Authorization: "Bearer token" },
232
- },
233
- },
234
- }
235
-
236
- const bundle = convertClaudeToCursor(plugin, defaultOptions)
237
- expect(bundle.mcpServers!.remote.url).toBe("https://mcp.example.com/sse")
238
- expect(bundle.mcpServers!.remote.headers).toEqual({ Authorization: "Bearer token" })
239
- })
240
-
241
- test("warns when hooks are present", () => {
242
- const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
243
-
244
- const plugin: ClaudePlugin = {
245
- ...fixturePlugin,
246
- agents: [],
247
- commands: [],
248
- skills: [],
249
- hooks: {
250
- hooks: {
251
- PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }],
252
- },
253
- },
254
- }
255
-
256
- convertClaudeToCursor(plugin, defaultOptions)
257
- expect(warnSpy).toHaveBeenCalledWith(
258
- "Warning: Cursor does not support hooks. Hooks were skipped during conversion.",
259
- )
260
-
261
- warnSpy.mockRestore()
262
- })
263
-
264
- test("no warning when hooks are absent", () => {
265
- const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
266
-
267
- convertClaudeToCursor(fixturePlugin, defaultOptions)
268
- expect(warnSpy).not.toHaveBeenCalled()
269
-
270
- warnSpy.mockRestore()
271
- })
272
-
273
- test("plugin with zero agents produces empty rules array", () => {
274
- const plugin: ClaudePlugin = {
275
- ...fixturePlugin,
276
- agents: [],
277
- }
278
-
279
- const bundle = convertClaudeToCursor(plugin, defaultOptions)
280
- expect(bundle.rules).toHaveLength(0)
281
- })
282
-
283
- test("plugin with only skills works", () => {
284
- const plugin: ClaudePlugin = {
285
- ...fixturePlugin,
286
- agents: [],
287
- commands: [],
288
- }
289
-
290
- const bundle = convertClaudeToCursor(plugin, defaultOptions)
291
- expect(bundle.rules).toHaveLength(0)
292
- expect(bundle.commands).toHaveLength(0)
293
- expect(bundle.skillDirs).toHaveLength(1)
294
- })
295
- })
296
-
297
- describe("transformContentForCursor", () => {
298
- test("rewrites .claude/ paths to .cursor/", () => {
299
- const input = "Read `.claude/compound-engineering.local.md` for config."
300
- const result = transformContentForCursor(input)
301
- expect(result).toContain(".cursor/compound-engineering.local.md")
302
- expect(result).not.toContain(".claude/")
303
- })
304
-
305
- test("rewrites ~/.claude/ paths to ~/.cursor/", () => {
306
- const input = "Global config at ~/.claude/settings.json"
307
- const result = transformContentForCursor(input)
308
- expect(result).toContain("~/.cursor/settings.json")
309
- expect(result).not.toContain("~/.claude/")
310
- })
311
-
312
- test("transforms Task agent calls to skill references", () => {
313
- const input = `Run agents:
314
-
315
- - Task repo-research-analyst(feature_description)
316
- - Task learnings-researcher(feature_description)
317
-
318
- Task best-practices-researcher(topic)`
319
-
320
- const result = transformContentForCursor(input)
321
- expect(result).toContain("Use the repo-research-analyst skill to: feature_description")
322
- expect(result).toContain("Use the learnings-researcher skill to: feature_description")
323
- expect(result).toContain("Use the best-practices-researcher skill to: topic")
324
- expect(result).not.toContain("Task repo-research-analyst(")
325
- })
326
-
327
- test("flattens slash commands", () => {
328
- const input = `1. Run /deepen-plan to enhance
329
- 2. Start /workflows:work to implement
330
- 3. File at /tmp/output.md`
331
-
332
- const result = transformContentForCursor(input)
333
- expect(result).toContain("/deepen-plan")
334
- expect(result).toContain("/work")
335
- expect(result).not.toContain("/workflows:work")
336
- // File paths preserved
337
- expect(result).toContain("/tmp/output.md")
338
- })
339
-
340
- test("transforms @agent references to rule references", () => {
341
- const input = "Have @security-sentinel and @dhh-rails-reviewer check the code."
342
- const result = transformContentForCursor(input)
343
- expect(result).toContain("the security-sentinel rule")
344
- expect(result).toContain("the dhh-rails-reviewer rule")
345
- expect(result).not.toContain("@security-sentinel")
346
- })
347
- })