@plaited/development-skills 0.6.3 → 0.6.4

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.
@@ -1,269 +1,45 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Scaffold development rules from templates
3
+ * Scaffold development rules - Copy bundled rules and create symlinks
4
4
  *
5
- * Reads bundled rule templates, processes template variables,
6
- * and outputs JSON for agent consumption.
5
+ * Copies rules from the package to `.plaited/rules/` (canonical location),
6
+ * creates symlinks for `.claude/` and `.cursor/` agent directories,
7
+ * and falls back to appending links in `AGENTS.md` if no agent dirs exist.
7
8
  *
8
- * All agents use `.plaited/rules/` as the unified default location.
9
- * AGENTS.md serves as the single source of truth for rules content,
10
- * while CLAUDE.md simply references it via @AGENTS.md syntax.
11
- *
12
- * Options:
13
- * - --rules-dir, -d: Custom rules directory path (overrides default .plaited/rules)
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
- * - agentsMdSection: Marker-wrapped section with markdown links for AGENTS.md
19
- * - claudeMdReference: Short reference snippet pointing to @AGENTS.md
20
- * - templates: Processed rule content for each selected rule
21
- *
22
- * Template syntax:
23
- * - {{LINK:rule-id}} - Cross-reference to another rule
24
- * - {{#if development-skills}}...{{/if}} - Conditional block (always true when using CLI)
25
- * - {{^if development-skills}}...{{/if}} - Inverse conditional
26
- * - <!-- RULE TEMPLATE ... --> - Template header (removed)
27
- *
28
- * @example
29
- * ```bash
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
- *
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
41
- * ```
9
+ * @throws When source rules directory cannot be read
10
+ * @throws When target directory cannot be created (permissions)
11
+ * @throws When symlink creation fails (existing file, not directory)
42
12
  */
43
13
 
44
- import { readdir } from 'node:fs/promises'
14
+ import { mkdir, readdir, readlink, stat, symlink } from 'node:fs/promises'
45
15
  import { join } from 'node:path'
46
16
  import { parseArgs } from 'node:util'
47
17
 
48
- /**
49
- * Marker delimiters for programmatic file updates
50
- *
51
- * @remarks
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
64
- */
65
- export const MARKERS = {
66
- start: '<!-- PLAITED-RULES-START -->',
67
- end: '<!-- PLAITED-RULES-END -->',
68
- } as const
18
+ /** Agents that get symlinks to .plaited/rules (not .plaited itself) */
19
+ const SYMLINK_AGENTS = ['.claude', '.cursor'] as const
69
20
 
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
21
+ /** All supported agent directories (including .plaited which gets direct copy) */
22
+ const ALL_AGENTS = ['.plaited', ...SYMLINK_AGENTS] as const
79
23
 
80
- type TemplateContext = {
81
- rulesPath: string
82
- }
24
+ /** Canonical rules location */
25
+ const TARGET_RULES = '.plaited/rules' as const
83
26
 
84
27
  /**
85
- * Processed template with filename, content, and description
28
+ * NOTE: This tool only scaffolds RULES, not skills.
29
+ * Skills symlinks (.claude/skills -> ../.plaited/skills) are managed separately
30
+ * via the skills-installer or manual setup.
86
31
  */
87
- export type ProcessedTemplate = {
88
- filename: string
89
- content: string
90
- description: string
91
- }
92
32
 
93
33
  /**
94
- * Output from scaffold-rules CLI
95
- */
96
- export type ScaffoldOutput = {
97
- rulesPath: string
98
- /** Marker-wrapped section with markdown links for AGENTS.md */
99
- agentsMdSection: string
100
- /** Short reference snippet for CLAUDE.md pointing to AGENTS.md */
101
- claudeMdReference: string
102
- templates: Record<string, ProcessedTemplate>
103
- }
104
-
105
- /**
106
- * Process template conditionals
107
- *
108
- * Handles:
109
- * - {{#if development-skills}}...{{/if}} - Always true (using our CLI means dev-skills is installed)
110
- * - {{^if development-skills}}...{{/if}} - Always false
111
- *
112
- * Processes iteratively to handle nested conditionals correctly.
34
+ * Check if path is a directory
113
35
  */
114
- const processConditionals = (content: string): string => {
115
- let result = content
116
- let previousResult = ''
117
- const maxIterations = 100
118
- let iterations = 0
119
-
120
- // Process iteratively until no more changes (handles nested conditionals)
121
- while (result !== previousResult && iterations < maxIterations) {
122
- previousResult = result
123
- iterations++
124
-
125
- // Process positive conditionals {{#if development-skills}}...{{/if}}
126
- // Always true - include the block content
127
- result = result.replace(
128
- /\{\{#if development-skills\}\}((?:(?!\{\{#if )(?!\{\{\^if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g,
129
- (_, block) => block,
130
- )
131
-
132
- // Process inverse conditionals {{^if development-skills}}...{{/if}}
133
- // Always false - remove the block content
134
- result = result.replace(
135
- /\{\{\^if development-skills\}\}((?:(?!\{\{#if )(?!\{\{\^if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g,
136
- () => '',
137
- )
138
- }
139
-
140
- if (iterations >= maxIterations) {
141
- console.warn('Warning: Max iterations reached in template processing. Some conditionals may be unprocessed.')
36
+ const isDirectory = async (path: string): Promise<boolean> => {
37
+ try {
38
+ const s = await stat(path)
39
+ return s.isDirectory()
40
+ } catch {
41
+ return false
142
42
  }
143
-
144
- return result
145
- }
146
-
147
- /**
148
- * Process template variables
149
- *
150
- * Handles:
151
- * - {{LINK:rule-id}} - Generate cross-reference path
152
- * - {{RULES_PATH}} - Rules path
153
- */
154
- const processVariables = (content: string, context: TemplateContext): string => {
155
- let result = content
156
-
157
- // Replace {{LINK:rule-id}} with path reference
158
- result = result.replace(/\{\{LINK:(\w+)\}\}/g, (_, ruleId) => {
159
- return `${context.rulesPath}/${ruleId}.md`
160
- })
161
-
162
- // Replace {{RULES_PATH}}
163
- result = result.replace(/\{\{RULES_PATH\}\}/g, context.rulesPath)
164
-
165
- return result
166
- }
167
-
168
- /**
169
- * Remove template headers
170
- */
171
- const removeTemplateHeaders = (content: string): string => {
172
- return content.replace(/<!--[\s\S]*?-->\n*/g, '')
173
- }
174
-
175
- /**
176
- * Extract description from rule content
177
- */
178
- const extractDescription = (content: string): string => {
179
- // Look for first paragraph or heading after main title
180
- const lines = content.split('\n')
181
- let description = ''
182
-
183
- for (let i = 1; i < lines.length; i++) {
184
- // Non-null assertion safe: loop condition guarantees i < lines.length
185
- const line = lines[i]!.trim()
186
- if (line && !line.startsWith('#') && !line.startsWith('**')) {
187
- description = line
188
- break
189
- }
190
- }
191
-
192
- return description || 'Development rule'
193
- }
194
-
195
- /**
196
- * Process a template with context
197
- */
198
- const processTemplate = (content: string, context: TemplateContext): string => {
199
- let result = content
200
-
201
- // 1. Remove template headers
202
- result = removeTemplateHeaders(result)
203
-
204
- // 2. Process conditionals
205
- result = processConditionals(result)
206
-
207
- // 3. Process variables
208
- result = processVariables(result, context)
209
-
210
- // 4. Clean up extra blank lines
211
- result = result.replace(/\n{3,}/g, '\n\n')
212
-
213
- return result
214
- }
215
-
216
- /**
217
- * Generate marker-wrapped reference snippet for CLAUDE.md
218
- *
219
- * @remarks
220
- * Claude Code uses `@file.md` syntax to include file contents.
221
- * This generates a short reference pointing to AGENTS.md as the single source of truth.
222
- * The markers allow this section to be updated without affecting other content.
223
- */
224
- const generateClaudeMdReference = (): string => {
225
- const lines = [
226
- MARKERS.start,
227
- '',
228
- '## Project Rules',
229
- '',
230
- 'See @AGENTS.md for shared development rules.',
231
- '',
232
- MARKERS.end,
233
- ]
234
-
235
- return lines.join('\n')
236
- }
237
-
238
- /**
239
- * Generate marker-wrapped section for AGENTS.md with dual format
240
- *
241
- * @remarks
242
- * AGENTS.md uses both formats for maximum compatibility:
243
- * - `@path` syntax for Claude Code to load file contents
244
- * - `[name](path)` markdown links for other tools and GitHub rendering
245
- * The markers allow this section to be updated without affecting other content.
246
- */
247
- const generateAgentsMdSection = (templates: Record<string, ProcessedTemplate>, rulesPath: string): string => {
248
- const lines = [
249
- MARKERS.start,
250
- '',
251
- '## Rules',
252
- '',
253
- `This project uses modular development rules stored in \`${rulesPath}/\`.`,
254
- 'Each rule file covers a specific topic:',
255
- '',
256
- ]
257
-
258
- for (const [ruleId, template] of Object.entries(templates)) {
259
- // Dual format: @ syntax for Claude Code, markdown link for other tools
260
- lines.push(`- @${rulesPath}/${template.filename} - [${ruleId}](${rulesPath}/${template.filename})`)
261
- }
262
-
263
- lines.push('')
264
- lines.push(MARKERS.end)
265
-
266
- return lines.join('\n')
267
43
  }
268
44
 
269
45
  /**
@@ -273,101 +49,102 @@ export const scaffoldRules = async (args: string[]): Promise<void> => {
273
49
  const { values } = parseArgs({
274
50
  args,
275
51
  options: {
276
- rules: {
277
- type: 'string',
278
- short: 'r',
279
- multiple: true,
280
- },
281
- 'rules-dir': {
282
- type: 'string',
283
- short: 'd',
284
- },
285
- list: {
286
- type: 'boolean',
287
- short: 'l',
288
- },
52
+ list: { type: 'boolean', short: 'l' },
53
+ 'dry-run': { type: 'boolean', short: 'n' },
289
54
  },
290
55
  allowPositionals: true,
291
56
  strict: false,
292
57
  })
293
58
 
294
- const rulesFilter = values.rules as string[] | undefined
295
- const customRulesDir = values['rules-dir'] as string | undefined
59
+ const dryRun = values['dry-run'] as boolean | undefined
296
60
  const listOnly = values.list as boolean | undefined
297
61
 
298
- // Get bundled templates directory
299
- const packageRulesDir = join(import.meta.dir, '../.plaited/rules')
300
-
301
- // Read template files
302
- const templateFiles = await readdir(packageRulesDir)
303
-
304
- // Filter to .md files
305
- const mdFiles = templateFiles.filter((f) => f.endsWith('.md'))
306
- const availableRuleIds = mdFiles.map((f) => f.replace('.md', ''))
62
+ const sourceRules = join(import.meta.dir, '../.plaited/rules')
63
+ const cwd = process.cwd()
307
64
 
308
- // Validate requested rules exist
309
- if (rulesFilter) {
310
- const invalidRules = rulesFilter.filter((r) => !availableRuleIds.includes(r))
311
- if (invalidRules.length > 0) {
312
- console.error(`Warning: Unknown rules: ${invalidRules.join(', ')}`)
313
- console.error(`Available rules: ${availableRuleIds.join(', ')}`)
314
- }
315
- }
65
+ // Get available rules
66
+ const files = await readdir(sourceRules)
67
+ const rules = files.filter((f) => f.endsWith('.md'))
316
68
 
317
- // Handle --list flag: output available rules and exit
69
+ // --list: just output available rules
318
70
  if (listOnly) {
319
- const listOutput = availableRuleIds.map((id) => ({ id, filename: `${id}.md` }))
320
- console.log(JSON.stringify(listOutput, null, 2))
71
+ console.log(JSON.stringify({ rules: rules.map((f) => f.replace('.md', '')) }))
321
72
  return
322
73
  }
323
74
 
324
- // Filter if specific rules requested
325
- const rulesToProcess = rulesFilter ? mdFiles.filter((f) => rulesFilter.includes(f.replace('.md', ''))) : mdFiles
75
+ const actions: string[] = []
326
76
 
327
- // Process each template
328
- const templates: Record<string, ProcessedTemplate> = {}
329
-
330
- // Use custom path or unified default
331
- const rulesPath = customRulesDir ?? UNIFIED_RULES_PATH
332
-
333
- const context: TemplateContext = {
334
- rulesPath,
77
+ // Check for agent directories BEFORE copying (since copy creates .plaited/)
78
+ // This determines whether to fall back to AGENTS.md append
79
+ let hadAgentDirBeforeScaffold = false
80
+ for (const agent of ALL_AGENTS) {
81
+ if (await isDirectory(join(cwd, agent))) {
82
+ hadAgentDirBeforeScaffold = true
83
+ break
84
+ }
335
85
  }
336
86
 
337
- for (const file of rulesToProcess) {
338
- const templatePath = join(packageRulesDir, file)
339
- const ruleId = file.replace('.md', '')
87
+ // 1. Copy rules to .plaited/rules/ (canonical location, serves .plaited agent)
88
+ const targetDir = join(cwd, TARGET_RULES)
89
+ if (!dryRun) {
90
+ await mkdir(targetDir, { recursive: true })
91
+ }
340
92
 
341
- try {
342
- const content = await Bun.file(templatePath).text()
93
+ for (const file of rules) {
94
+ const src = join(sourceRules, file)
95
+ const dest = join(targetDir, file)
96
+ if (!dryRun) {
97
+ await Bun.write(dest, await Bun.file(src).text())
98
+ }
99
+ actions.push(`copy: ${TARGET_RULES}/${file}`)
100
+ }
343
101
 
344
- // Process template
345
- const processed = processTemplate(content, context)
102
+ // 2. Symlink for other agents (.claude, .cursor)
103
+ for (const agent of SYMLINK_AGENTS) {
104
+ const agentDir = join(cwd, agent)
105
+ if (await isDirectory(agentDir)) {
106
+ const rulesLink = join(agentDir, 'rules')
107
+
108
+ // Check if symlink already exists and points to right place
109
+ try {
110
+ const existing = await readlink(rulesLink)
111
+ if (existing === '../.plaited/rules') {
112
+ actions.push(`skip: ${agent}/rules (symlink exists)`)
113
+ continue
114
+ }
115
+ } catch {
116
+ // Doesn't exist or not a symlink - proceed to create
117
+ }
346
118
 
347
- templates[ruleId] = {
348
- filename: file,
349
- content: processed,
350
- description: extractDescription(processed),
119
+ if (!dryRun) {
120
+ await symlink('../.plaited/rules', rulesLink)
351
121
  }
352
- } catch (error) {
353
- console.error(`Error processing template ${file}:`, error)
354
- process.exit(1)
122
+ actions.push(`symlink: ${agent}/rules -> ../.plaited/rules`)
355
123
  }
356
124
  }
357
125
 
358
- // Generate marker-wrapped section for AGENTS.md and reference for CLAUDE.md
359
- const agentsMdSection = generateAgentsMdSection(templates, rulesPath)
360
- const claudeMdReference = generateClaudeMdReference()
361
-
362
- // Build output
363
- const output: ScaffoldOutput = {
364
- rulesPath,
365
- agentsMdSection,
366
- claudeMdReference,
367
- templates,
126
+ // 3. Fallback: append to AGENTS.md only if NO agent directories existed before copy
127
+ if (!hadAgentDirBeforeScaffold) {
128
+ const agentsMdPath = join(cwd, 'AGENTS.md')
129
+ const agentsMd = Bun.file(agentsMdPath)
130
+
131
+ if (await agentsMd.exists()) {
132
+ const content = await agentsMd.text()
133
+ if (content.includes('.plaited/rules')) {
134
+ actions.push('skip: AGENTS.md (already has rules)')
135
+ } else {
136
+ const links = rules.map((f) => `- [${f.replace('.md', '')}](${TARGET_RULES}/${f})`).join('\n')
137
+ const section = `\n## Rules\n\n${links}\n`
138
+
139
+ if (!dryRun) {
140
+ await Bun.write(agentsMdPath, content + section)
141
+ }
142
+ actions.push('append: AGENTS.md (rules section)')
143
+ }
144
+ }
368
145
  }
369
146
 
370
- console.log(JSON.stringify(output, null, 2))
147
+ console.log(JSON.stringify({ dryRun: !!dryRun, targetRules: TARGET_RULES, actions }, null, 2))
371
148
  }
372
149
 
373
150
  // CLI entry point