@plaited/development-skills 0.4.1 → 0.6.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/{.claude → .plaited}/rules/accuracy.md +3 -10
  2. package/{.claude → .plaited}/rules/code-review.md +2 -41
  3. package/.plaited/rules/git-workflow.md +36 -0
  4. package/.plaited/rules/module-organization.md +92 -0
  5. package/{.claude → .plaited}/rules/testing.md +81 -1
  6. package/package.json +3 -2
  7. package/src/lsp-analyze.ts +2 -2
  8. package/src/lsp-find.ts +15 -34
  9. package/src/lsp-hover.ts +2 -2
  10. package/src/lsp-references.ts +2 -2
  11. package/src/lsp-symbols.ts +2 -2
  12. package/src/resolve-file-path.ts +18 -28
  13. package/src/scaffold-rules.ts +148 -204
  14. package/src/tests/resolve-file-path.spec.ts +90 -51
  15. package/src/tests/scaffold-rules.spec.ts +148 -118
  16. package/.claude/commands/lsp-analyze.md +0 -66
  17. package/.claude/commands/lsp-find.md +0 -51
  18. package/.claude/commands/lsp-hover.md +0 -48
  19. package/.claude/commands/lsp-refs.md +0 -55
  20. package/.claude/commands/scaffold-rules.md +0 -221
  21. package/.claude/commands/validate-skill.md +0 -29
  22. package/.claude/rules/git-workflow.md +0 -66
  23. package/.claude/skills/code-documentation/SKILL.md +0 -47
  24. package/.claude/skills/code-documentation/references/internal-templates.md +0 -113
  25. package/.claude/skills/code-documentation/references/maintenance.md +0 -164
  26. package/.claude/skills/code-documentation/references/public-api-templates.md +0 -100
  27. package/.claude/skills/code-documentation/references/type-documentation.md +0 -116
  28. package/.claude/skills/code-documentation/references/workflow.md +0 -60
  29. package/.claude/skills/scaffold-rules/SKILL.md +0 -97
  30. package/.claude/skills/typescript-lsp/SKILL.md +0 -239
  31. package/.claude/skills/validate-skill/SKILL.md +0 -105
  32. /package/{.claude → .plaited}/rules/bun-apis.md +0 -0
  33. /package/{.claude → .plaited}/rules/github.md +0 -0
@@ -2,40 +2,42 @@
2
2
  /**
3
3
  * Scaffold development rules from templates
4
4
  *
5
- * Reads bundled rule templates, processes template variables and conditionals,
5
+ * Reads bundled rule templates, processes template variables,
6
6
  * and outputs JSON for agent consumption.
7
7
  *
8
- * Supports 2 target formats:
9
- * - claude: Claude Code with .claude/rules/ directory
10
- * - agents-md: Universal AGENTS.md format with .plaited/rules/
11
- * (works with Cursor, Factory, Copilot, Windsurf, Cline, Aider, and 60,000+ others)
8
+ * All agents use `.plaited/rules/` as the unified default location.
9
+ * The output includes marker-wrapped sections for both CLAUDE.md and AGENTS.md,
10
+ * allowing programmatic updates without destroying user content.
12
11
  *
13
12
  * Options:
14
- * - --agent, -a: Target format (claude | agents-md)
15
- * - --rules-dir, -d: Custom rules directory path (overrides default)
16
- * - --agents-md-path, -m: Custom AGENTS.md file path (default: AGENTS.md)
13
+ * - --rules-dir, -d: Custom rules directory path (overrides default .plaited/rules)
17
14
  * - --rules, -r: Filter to specific rules (can be used multiple times)
15
+ * - --list, -l: List available rules without full output
16
+ *
17
+ * Output includes:
18
+ * - claudeMdSection: Marker-wrapped section with @ syntax for CLAUDE.md
19
+ * - agentsMdSection: Marker-wrapped section with markdown links for AGENTS.md
20
+ * - templates: Processed rule content for each selected rule
18
21
  *
19
22
  * Template syntax:
20
23
  * - {{LINK:rule-id}} - Cross-reference to another rule
21
- * - {{#if development-skills}}...{{/if}} - Conditional block
22
- * - {{#if capability}}...{{/if}} - Capability-based conditional
23
- * - {{^if condition}}...{{/if}} - Inverse conditional
24
+ * - {{#if development-skills}}...{{/if}} - Conditional block (always true when using CLI)
25
+ * - {{^if development-skills}}...{{/if}} - Inverse conditional
24
26
  * - <!-- RULE TEMPLATE ... --> - Template header (removed)
25
27
  *
26
- * Capabilities:
27
- * - has-sandbox: Agent runs in sandboxed environment (Claude Code only)
28
- * - supports-slash-commands: Agent has /command syntax (Claude Code only)
29
- *
30
28
  * @example
31
29
  * ```bash
32
- * # Default paths
33
- * bunx @plaited/development-skills scaffold-rules --agent=claude
34
- * bunx @plaited/development-skills scaffold-rules --agent=agents-md
30
+ * # Default: outputs to .plaited/rules/
31
+ * bunx @plaited/development-skills scaffold-rules
32
+ *
33
+ * # Custom rules directory
34
+ * bunx @plaited/development-skills scaffold-rules --rules-dir=.cursor/rules
35
35
  *
36
- * # Custom paths
37
- * bunx @plaited/development-skills scaffold-rules --agent=agents-md --rules-dir=.cursor/rules
38
- * bunx @plaited/development-skills scaffold-rules --agent=agents-md --agents-md-path=docs/AGENTS.md
36
+ * # Filter specific rules
37
+ * bunx @plaited/development-skills scaffold-rules --rules testing --rules bun-apis
38
+ *
39
+ * # List available rules
40
+ * bunx @plaited/development-skills scaffold-rules --list
39
41
  * ```
40
42
  */
41
43
 
@@ -44,144 +46,101 @@ import { join } from 'node:path'
44
46
  import { parseArgs } from 'node:util'
45
47
 
46
48
  /**
47
- * Supported agent targets
49
+ * Marker delimiters for programmatic file updates
48
50
  *
49
51
  * @remarks
50
- * - claude: Claude Code with its own .claude/ directory structure
51
- * - agents-md: Universal AGENTS.md format (works with Cursor, Factory, Copilot,
52
- * Windsurf, Cline, Aider, and 60,000+ other projects)
52
+ * These markers allow scaffold-rules to update CLAUDE.md and AGENTS.md
53
+ * without destroying user content outside the marked section.
54
+ *
55
+ * Use these markers to implement idempotent updates:
56
+ * 1. Find existing markers in file content
57
+ * 2. Replace content between markers (inclusive) with new section
58
+ * 3. If no markers exist, append section to end of file
59
+ *
60
+ * @property start - Opening marker to place before rules section
61
+ * @property end - Closing marker to place after rules section
62
+ *
63
+ * @public
53
64
  */
54
- type Agent = 'claude' | 'agents-md'
65
+ export const MARKERS = {
66
+ start: '<!-- PLAITED-RULES-START -->',
67
+ end: '<!-- PLAITED-RULES-END -->',
68
+ } as const
55
69
 
56
- type AgentCapabilities = {
57
- hasSandbox: boolean
58
- multiFileRules: boolean
59
- supportsSlashCommands: boolean
60
- supportsAgentsMd: boolean
61
- }
70
+ /**
71
+ * Unified rules directory path
72
+ *
73
+ * @remarks
74
+ * All agents use `.plaited/rules/` as the default location.
75
+ * This provides consistency and allows both CLAUDE.md and AGENTS.md
76
+ * to reference the same rule files.
77
+ */
78
+ const UNIFIED_RULES_PATH = '.plaited/rules' as const
62
79
 
63
80
  type TemplateContext = {
64
- agent: Agent
65
- capabilities: AgentCapabilities
66
- hasDevelopmentSkills: boolean
67
81
  rulesPath: string
68
82
  }
69
83
 
70
- type ProcessedTemplate = {
84
+ /**
85
+ * Processed template with filename, content, and description
86
+ */
87
+ export type ProcessedTemplate = {
71
88
  filename: string
72
89
  content: string
73
90
  description: string
74
91
  }
75
92
 
76
- type ScaffoldOutput = {
77
- agent: Agent
78
- rulesPath: string
79
- agentsMdPath: string
80
- format: 'multi-file' | 'agents-md'
81
- supportsAgentsMd: boolean
82
- agentsMdContent?: string
83
- templates: Record<string, ProcessedTemplate>
84
- }
85
-
86
93
  /**
87
- * Agent capabilities matrix
88
- *
89
- * @remarks
90
- * - hasSandbox: Runs in restricted environment (affects git commands, temp files)
91
- * - multiFileRules: Supports directory of rule files vs single file
92
- * - supportsSlashCommands: Has /command syntax for invoking tools
93
- * - supportsAgentsMd: Reads AGENTS.md format
94
- *
95
- * Note: We only support 2 targets now:
96
- * - claude: Claude Code with unique .claude/ directory system
97
- * - agents-md: Universal format for all AGENTS.md-compatible agents
98
- * (Cursor, Factory, Copilot, Windsurf, Cline, Aider, and 60,000+ others)
94
+ * Output from scaffold-rules CLI
99
95
  */
100
- const AGENT_CAPABILITIES: Record<Agent, AgentCapabilities> = {
101
- claude: {
102
- hasSandbox: true,
103
- multiFileRules: true,
104
- supportsSlashCommands: true,
105
- supportsAgentsMd: false,
106
- },
107
- 'agents-md': {
108
- hasSandbox: false,
109
- multiFileRules: true,
110
- supportsSlashCommands: false,
111
- supportsAgentsMd: true,
112
- },
113
- }
114
-
115
- /**
116
- * Evaluate a single condition against context
117
- */
118
- const evaluateCondition = (condition: string, context: TemplateContext): boolean => {
119
- // Check development-skills
120
- if (condition === 'development-skills') {
121
- return context.hasDevelopmentSkills
122
- }
123
-
124
- // Check capability-based conditions
125
- if (condition === 'has-sandbox') {
126
- return context.capabilities.hasSandbox
127
- }
128
- if (condition === 'multi-file-rules') {
129
- return context.capabilities.multiFileRules
130
- }
131
- if (condition === 'supports-slash-commands') {
132
- return context.capabilities.supportsSlashCommands
133
- }
134
- if (condition === 'supports-agents-md') {
135
- return context.capabilities.supportsAgentsMd
136
- }
137
-
138
- // Check agent-specific conditions (legacy: agent:name)
139
- const agentMatch = condition.match(/^agent:(\w+)$/)
140
- if (agentMatch) {
141
- return context.agent === agentMatch[1]
142
- }
143
-
144
- return false
96
+ export type ScaffoldOutput = {
97
+ rulesPath: string
98
+ /** Marker-wrapped section with @ syntax for CLAUDE.md */
99
+ claudeMdSection: string
100
+ /** Marker-wrapped section with markdown links for AGENTS.md */
101
+ agentsMdSection: string
102
+ templates: Record<string, ProcessedTemplate>
145
103
  }
146
104
 
147
105
  /**
148
106
  * Process template conditionals
149
107
  *
150
108
  * Handles:
151
- * - {{#if development-skills}}...{{/if}}
152
- * - {{#if capability}}...{{/if}} (has-sandbox, multi-file-rules, etc.)
153
- * - {{#if agent:name}}...{{/if}} (legacy, still supported)
154
- * - {{^if condition}}...{{/if}} (inverse)
109
+ * - {{#if development-skills}}...{{/if}} - Always true (using our CLI means dev-skills is installed)
110
+ * - {{^if development-skills}}...{{/if}} - Always false
155
111
  *
156
112
  * Processes iteratively to handle nested conditionals correctly.
157
113
  */
158
- const processConditionals = (content: string, context: TemplateContext): string => {
114
+ const processConditionals = (content: string): string => {
159
115
  let result = content
160
116
  let previousResult = ''
117
+ const maxIterations = 100
118
+ let iterations = 0
161
119
 
162
120
  // Process iteratively until no more changes (handles nested conditionals)
163
- while (result !== previousResult) {
121
+ while (result !== previousResult && iterations < maxIterations) {
164
122
  previousResult = result
123
+ iterations++
165
124
 
166
- // Process positive conditionals {{#if condition}}...{{/if}}
167
- // Use non-greedy match that doesn't cross other conditional boundaries
168
- // Note: Nested quantifiers are safe here - input is trusted bundled templates, not user content
125
+ // Process positive conditionals {{#if development-skills}}...{{/if}}
126
+ // Always true - include the block content
169
127
  result = result.replace(
170
- /\{\{#if ([\w:-]+)\}\}((?:(?!\{\{#if )(?!\{\{\^if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g,
171
- (_, condition, block) => {
172
- return evaluateCondition(condition, context) ? block : ''
173
- },
128
+ /\{\{#if development-skills\}\}((?:(?!\{\{#if )(?!\{\{\^if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g,
129
+ (_, block) => block,
174
130
  )
175
131
 
176
- // Process inverse conditionals {{^if condition}}...{{/if}}
132
+ // Process inverse conditionals {{^if development-skills}}...{{/if}}
133
+ // Always false - remove the block content
177
134
  result = result.replace(
178
- /\{\{\^if ([\w:-]+)\}\}((?:(?!\{\{#if )(?!\{\{\^if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g,
179
- (_, condition, block) => {
180
- return evaluateCondition(condition, context) ? '' : block
181
- },
135
+ /\{\{\^if development-skills\}\}((?:(?!\{\{#if )(?!\{\{\^if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g,
136
+ () => '',
182
137
  )
183
138
  }
184
139
 
140
+ if (iterations >= maxIterations) {
141
+ console.warn('Warning: Max iterations reached in template processing. Some conditionals may be unprocessed.')
142
+ }
143
+
185
144
  return result
186
145
  }
187
146
 
@@ -189,39 +148,23 @@ const processConditionals = (content: string, context: TemplateContext): string
189
148
  * Process template variables
190
149
  *
191
150
  * Handles:
192
- * - {{LINK:rule-id}} - Generate cross-reference
193
- * - {{AGENT_NAME}} - Agent name
151
+ * - {{LINK:rule-id}} - Generate cross-reference path
194
152
  * - {{RULES_PATH}} - Rules path
195
153
  */
196
154
  const processVariables = (content: string, context: TemplateContext): string => {
197
155
  let result = content
198
156
 
199
- // Replace {{LINK:rule-id}} with appropriate cross-reference
157
+ // Replace {{LINK:rule-id}} with path reference
200
158
  result = result.replace(/\{\{LINK:(\w+)\}\}/g, (_, ruleId) => {
201
- return generateCrossReference(ruleId, context)
159
+ return `${context.rulesPath}/${ruleId}.md`
202
160
  })
203
161
 
204
- // Replace {{AGENT_NAME}}
205
- result = result.replace(/\{\{AGENT_NAME\}\}/g, context.agent)
206
-
207
162
  // Replace {{RULES_PATH}}
208
163
  result = result.replace(/\{\{RULES_PATH\}\}/g, context.rulesPath)
209
164
 
210
165
  return result
211
166
  }
212
167
 
213
- /**
214
- * Generate cross-reference based on agent format
215
- */
216
- const generateCrossReference = (ruleId: string, context: TemplateContext): string => {
217
- if (context.agent === 'claude') {
218
- // Claude Code uses @ syntax for file references
219
- return `@${context.rulesPath}/${ruleId}.md`
220
- }
221
- // Use context.rulesPath for cross-references (supports custom --rules-dir)
222
- return `${context.rulesPath}/${ruleId}.md`
223
- }
224
-
225
168
  /**
226
169
  * Remove template headers
227
170
  */
@@ -238,7 +181,8 @@ const extractDescription = (content: string): string => {
238
181
  let description = ''
239
182
 
240
183
  for (let i = 1; i < lines.length; i++) {
241
- const line = lines[i]?.trim()
184
+ // Non-null assertion safe: loop condition guarantees i < lines.length
185
+ const line = lines[i]!.trim()
242
186
  if (line && !line.startsWith('#') && !line.startsWith('**')) {
243
187
  description = line
244
188
  break
@@ -258,7 +202,7 @@ const processTemplate = (content: string, context: TemplateContext): string => {
258
202
  result = removeTemplateHeaders(result)
259
203
 
260
204
  // 2. Process conditionals
261
- result = processConditionals(result, context)
205
+ result = processConditionals(result)
262
206
 
263
207
  // 3. Process variables
264
208
  result = processVariables(result, context)
@@ -270,27 +214,43 @@ const processTemplate = (content: string, context: TemplateContext): string => {
270
214
  }
271
215
 
272
216
  /**
273
- * Get rules path for agent
217
+ * Generate marker-wrapped section for CLAUDE.md with @ syntax
218
+ *
219
+ * @remarks
220
+ * Claude Code uses `@path/to/file.md` syntax to reference rule files.
221
+ * The markers allow this section to be updated without affecting other content.
274
222
  */
275
- const getRulesPath = (agent: Agent): string => {
276
- return agent === 'claude' ? '.claude/rules' : '.plaited/rules'
277
- }
223
+ const generateClaudeMdSection = (templates: Record<string, ProcessedTemplate>, rulesPath: string): string => {
224
+ const lines = [
225
+ MARKERS.start,
226
+ '',
227
+ '## Project Rules',
228
+ '',
229
+ `This project uses modular development rules stored in \`${rulesPath}/\`.`,
230
+ 'Each rule file covers a specific topic:',
231
+ '',
232
+ ]
278
233
 
279
- /**
280
- * Get output format for agent
281
- */
282
- const getOutputFormat = (agent: Agent): 'multi-file' | 'agents-md' => {
283
- return agent === 'claude' ? 'multi-file' : 'agents-md'
234
+ for (const [_ruleId, template] of Object.entries(templates)) {
235
+ lines.push(`- @${rulesPath}/${template.filename} - ${template.description}`)
236
+ }
237
+
238
+ lines.push('')
239
+ lines.push(MARKERS.end)
240
+
241
+ return lines.join('\n')
284
242
  }
285
243
 
286
244
  /**
287
- * Generate AGENTS.md content that links to rules directory
245
+ * Generate marker-wrapped section for AGENTS.md with markdown links
246
+ *
247
+ * @remarks
248
+ * AGENTS.md format uses standard markdown links to reference rule files.
249
+ * The markers allow this section to be updated without affecting other content.
288
250
  */
289
- const generateAgentsMd = (templates: Record<string, ProcessedTemplate>, rulesPath: string): string => {
251
+ const generateAgentsMdSection = (templates: Record<string, ProcessedTemplate>, rulesPath: string): string => {
290
252
  const lines = [
291
- '# AGENTS.md',
292
- '',
293
- 'Development rules for AI coding agents.',
253
+ MARKERS.start,
294
254
  '',
295
255
  '## Rules',
296
256
  '',
@@ -304,10 +264,7 @@ const generateAgentsMd = (templates: Record<string, ProcessedTemplate>, rulesPat
304
264
  }
305
265
 
306
266
  lines.push('')
307
- lines.push('## Quick Reference')
308
- lines.push('')
309
- lines.push('For detailed guidance on each topic, see the linked rule files above.')
310
- lines.push('')
267
+ lines.push(MARKERS.end)
311
268
 
312
269
  return lines.join('\n')
313
270
  }
@@ -319,16 +276,6 @@ export const scaffoldRules = async (args: string[]): Promise<void> => {
319
276
  const { values } = parseArgs({
320
277
  args,
321
278
  options: {
322
- agent: {
323
- type: 'string',
324
- short: 'a',
325
- default: 'claude',
326
- },
327
- format: {
328
- type: 'string',
329
- short: 'f',
330
- default: 'json',
331
- },
332
279
  rules: {
333
280
  type: 'string',
334
281
  short: 'r',
@@ -338,54 +285,55 @@ export const scaffoldRules = async (args: string[]): Promise<void> => {
338
285
  type: 'string',
339
286
  short: 'd',
340
287
  },
341
- 'agents-md-path': {
342
- type: 'string',
343
- short: 'm',
288
+ list: {
289
+ type: 'boolean',
290
+ short: 'l',
344
291
  },
345
292
  },
346
293
  allowPositionals: true,
347
294
  strict: false,
348
295
  })
349
296
 
350
- const agent = values.agent as Agent
351
297
  const rulesFilter = values.rules as string[] | undefined
352
298
  const customRulesDir = values['rules-dir'] as string | undefined
353
- const customAgentsMdPath = values['agents-md-path'] as string | undefined
354
-
355
- // Validate agent
356
- const validAgents: Agent[] = ['claude', 'agents-md']
357
- if (!validAgents.includes(agent)) {
358
- console.error(`Error: Invalid agent "${agent}". Must be one of: ${validAgents.join(', ')}`)
359
- console.error(
360
- 'Note: Use "agents-md" for Cursor, Factory, Copilot, Windsurf, Cline, Aider, and other AGENTS.md-compatible tools.',
361
- )
362
- process.exit(1)
363
- }
299
+ const listOnly = values.list as boolean | undefined
364
300
 
365
301
  // Get bundled templates directory
366
- const packageRulesDir = join(import.meta.dir, '../.claude/rules')
302
+ const packageRulesDir = join(import.meta.dir, '../.plaited/rules')
367
303
 
368
304
  // Read template files
369
305
  const templateFiles = await readdir(packageRulesDir)
370
306
 
371
307
  // Filter to .md files
372
308
  const mdFiles = templateFiles.filter((f) => f.endsWith('.md'))
309
+ const availableRuleIds = mdFiles.map((f) => f.replace('.md', ''))
310
+
311
+ // Validate requested rules exist
312
+ if (rulesFilter) {
313
+ const invalidRules = rulesFilter.filter((r) => !availableRuleIds.includes(r))
314
+ if (invalidRules.length > 0) {
315
+ console.error(`Warning: Unknown rules: ${invalidRules.join(', ')}`)
316
+ console.error(`Available rules: ${availableRuleIds.join(', ')}`)
317
+ }
318
+ }
319
+
320
+ // Handle --list flag: output available rules and exit
321
+ if (listOnly) {
322
+ const listOutput = availableRuleIds.map((id) => ({ id, filename: `${id}.md` }))
323
+ console.log(JSON.stringify(listOutput, null, 2))
324
+ return
325
+ }
373
326
 
374
327
  // Filter if specific rules requested
375
328
  const rulesToProcess = rulesFilter ? mdFiles.filter((f) => rulesFilter.includes(f.replace('.md', ''))) : mdFiles
376
329
 
377
330
  // Process each template
378
331
  const templates: Record<string, ProcessedTemplate> = {}
379
- const capabilities = AGENT_CAPABILITIES[agent]
380
332
 
381
- // Use custom paths or defaults
382
- const rulesPath = customRulesDir ?? getRulesPath(agent)
383
- const agentsMdPath = customAgentsMdPath ?? 'AGENTS.md'
333
+ // Use custom path or unified default
334
+ const rulesPath = customRulesDir ?? UNIFIED_RULES_PATH
384
335
 
385
336
  const context: TemplateContext = {
386
- agent,
387
- capabilities,
388
- hasDevelopmentSkills: true, // Always true when using our CLI
389
337
  rulesPath,
390
338
  }
391
339
 
@@ -405,27 +353,23 @@ export const scaffoldRules = async (args: string[]): Promise<void> => {
405
353
  description: extractDescription(processed),
406
354
  }
407
355
  } catch (error) {
408
- const message = error instanceof Error ? error.message : String(error)
409
- console.error(`Error processing template ${file}: ${message}`)
356
+ console.error(`Error processing template ${file}:`, error)
410
357
  process.exit(1)
411
358
  }
412
359
  }
413
360
 
361
+ // Generate marker-wrapped sections for both CLAUDE.md and AGENTS.md
362
+ const claudeMdSection = generateClaudeMdSection(templates, rulesPath)
363
+ const agentsMdSection = generateAgentsMdSection(templates, rulesPath)
364
+
414
365
  // Build output
415
366
  const output: ScaffoldOutput = {
416
- agent,
417
367
  rulesPath,
418
- agentsMdPath,
419
- format: getOutputFormat(agent),
420
- supportsAgentsMd: capabilities.supportsAgentsMd,
368
+ claudeMdSection,
369
+ agentsMdSection,
421
370
  templates,
422
371
  }
423
372
 
424
- // Generate AGENTS.md content for agents-md format
425
- if (agent === 'agents-md') {
426
- output.agentsMdContent = generateAgentsMd(templates, rulesPath)
427
- }
428
-
429
373
  console.log(JSON.stringify(output, null, 2))
430
374
  }
431
375