@plaited/development-skills 0.3.5
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/.claude/commands/lsp-analyze.md +66 -0
- package/.claude/commands/lsp-find.md +51 -0
- package/.claude/commands/lsp-hover.md +48 -0
- package/.claude/commands/lsp-refs.md +55 -0
- package/.claude/commands/scaffold-rules.md +221 -0
- package/.claude/commands/validate-skill.md +29 -0
- package/.claude/rules/accuracy.md +64 -0
- package/.claude/rules/bun-apis.md +80 -0
- package/.claude/rules/code-review.md +276 -0
- package/.claude/rules/git-workflow.md +66 -0
- package/.claude/rules/github.md +154 -0
- package/.claude/rules/testing.md +125 -0
- package/.claude/settings.local.json +47 -0
- package/.claude/skills/code-documentation/SKILL.md +47 -0
- package/.claude/skills/code-documentation/references/internal-templates.md +113 -0
- package/.claude/skills/code-documentation/references/maintenance.md +164 -0
- package/.claude/skills/code-documentation/references/public-api-templates.md +100 -0
- package/.claude/skills/code-documentation/references/type-documentation.md +116 -0
- package/.claude/skills/code-documentation/references/workflow.md +60 -0
- package/.claude/skills/scaffold-rules/SKILL.md +97 -0
- package/.claude/skills/typescript-lsp/SKILL.md +239 -0
- package/.claude/skills/validate-skill/SKILL.md +105 -0
- package/LICENSE +15 -0
- package/README.md +149 -0
- package/bin/cli.ts +109 -0
- package/package.json +57 -0
- package/src/lsp-analyze.ts +223 -0
- package/src/lsp-client.ts +400 -0
- package/src/lsp-find.ts +100 -0
- package/src/lsp-hover.ts +87 -0
- package/src/lsp-references.ts +83 -0
- package/src/lsp-symbols.ts +73 -0
- package/src/resolve-file-path.ts +28 -0
- package/src/scaffold-rules.ts +435 -0
- package/src/tests/fixtures/sample.ts +27 -0
- package/src/tests/lsp-client.spec.ts +180 -0
- package/src/tests/resolve-file-path.spec.ts +33 -0
- package/src/tests/scaffold-rules.spec.ts +286 -0
- package/src/tests/validate-skill.spec.ts +231 -0
- package/src/validate-skill.ts +492 -0
package/src/lsp-hover.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Get type information at a position in a TypeScript/JavaScript file
|
|
4
|
+
*
|
|
5
|
+
* Usage: bun lsp-hover.ts <file> <line> <character>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseArgs } from 'node:util'
|
|
9
|
+
import { LspClient } from './lsp-client.ts'
|
|
10
|
+
import { resolveFilePath } from './resolve-file-path.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get type information at a cursor position in TypeScript/JavaScript files
|
|
14
|
+
*
|
|
15
|
+
* @param args - Command line arguments [file, line, character]
|
|
16
|
+
*/
|
|
17
|
+
export const lspHover = async (args: string[]) => {
|
|
18
|
+
const { positionals } = parseArgs({
|
|
19
|
+
args,
|
|
20
|
+
allowPositionals: true,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const [filePath, lineStr, charStr] = positionals
|
|
24
|
+
|
|
25
|
+
if (!filePath || !lineStr || !charStr) {
|
|
26
|
+
console.error('Usage: lsp-hover <file> <line> <character>')
|
|
27
|
+
console.error(' file: Path to TypeScript/JavaScript file')
|
|
28
|
+
console.error(' line: Line number (0-indexed)')
|
|
29
|
+
console.error(' character: Character position (0-indexed)')
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const line = parseInt(lineStr, 10)
|
|
34
|
+
const character = parseInt(charStr, 10)
|
|
35
|
+
|
|
36
|
+
if (Number.isNaN(line) || Number.isNaN(character)) {
|
|
37
|
+
console.error('Error: line and character must be numbers')
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const absolutePath = await resolveFilePath(filePath)
|
|
42
|
+
const uri = `file://${absolutePath}`
|
|
43
|
+
const rootUri = `file://${process.cwd()}`
|
|
44
|
+
|
|
45
|
+
const client = new LspClient({ rootUri })
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await client.start()
|
|
49
|
+
|
|
50
|
+
const file = Bun.file(absolutePath)
|
|
51
|
+
if (!(await file.exists())) {
|
|
52
|
+
console.error(`Error: File not found: ${absolutePath}`)
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const text = await file.text()
|
|
57
|
+
const languageId = absolutePath.endsWith('.tsx')
|
|
58
|
+
? 'typescriptreact'
|
|
59
|
+
: absolutePath.endsWith('.ts')
|
|
60
|
+
? 'typescript'
|
|
61
|
+
: absolutePath.endsWith('.jsx')
|
|
62
|
+
? 'javascriptreact'
|
|
63
|
+
: 'javascript'
|
|
64
|
+
|
|
65
|
+
client.openDocument(uri, languageId, 1, text)
|
|
66
|
+
|
|
67
|
+
const result = await client.hover(uri, line, character)
|
|
68
|
+
|
|
69
|
+
client.closeDocument(uri)
|
|
70
|
+
await client.stop()
|
|
71
|
+
|
|
72
|
+
if (result) {
|
|
73
|
+
console.log(JSON.stringify(result, null, 2))
|
|
74
|
+
} else {
|
|
75
|
+
console.log('null')
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(`Error: ${error}`)
|
|
79
|
+
await client.stop()
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Keep executable entry point for direct execution
|
|
85
|
+
if (import.meta.main) {
|
|
86
|
+
await lspHover(Bun.argv.slice(2))
|
|
87
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Find all references to a symbol at a position
|
|
4
|
+
*
|
|
5
|
+
* Usage: bun lsp-references.ts <file> <line> <character>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseArgs } from 'node:util'
|
|
9
|
+
import { LspClient } from './lsp-client.ts'
|
|
10
|
+
import { resolveFilePath } from './resolve-file-path.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Find all references to a symbol at a cursor position
|
|
14
|
+
*
|
|
15
|
+
* @param args - Command line arguments [file, line, character]
|
|
16
|
+
*/
|
|
17
|
+
export const lspRefs = async (args: string[]) => {
|
|
18
|
+
const { positionals } = parseArgs({
|
|
19
|
+
args,
|
|
20
|
+
allowPositionals: true,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const [filePath, lineStr, charStr] = positionals
|
|
24
|
+
|
|
25
|
+
if (!filePath || !lineStr || !charStr) {
|
|
26
|
+
console.error('Usage: lsp-refs <file> <line> <character>')
|
|
27
|
+
console.error(' file: Path to TypeScript/JavaScript file')
|
|
28
|
+
console.error(' line: Line number (0-indexed)')
|
|
29
|
+
console.error(' character: Character position (0-indexed)')
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const line = parseInt(lineStr, 10)
|
|
34
|
+
const character = parseInt(charStr, 10)
|
|
35
|
+
|
|
36
|
+
if (Number.isNaN(line) || Number.isNaN(character)) {
|
|
37
|
+
console.error('Error: line and character must be numbers')
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const absolutePath = await resolveFilePath(filePath)
|
|
42
|
+
const uri = `file://${absolutePath}`
|
|
43
|
+
const rootUri = `file://${process.cwd()}`
|
|
44
|
+
|
|
45
|
+
const client = new LspClient({ rootUri })
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await client.start()
|
|
49
|
+
|
|
50
|
+
const file = Bun.file(absolutePath)
|
|
51
|
+
if (!(await file.exists())) {
|
|
52
|
+
console.error(`Error: File not found: ${absolutePath}`)
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const text = await file.text()
|
|
57
|
+
const languageId = absolutePath.endsWith('.tsx')
|
|
58
|
+
? 'typescriptreact'
|
|
59
|
+
: absolutePath.endsWith('.ts')
|
|
60
|
+
? 'typescript'
|
|
61
|
+
: absolutePath.endsWith('.jsx')
|
|
62
|
+
? 'javascriptreact'
|
|
63
|
+
: 'javascript'
|
|
64
|
+
|
|
65
|
+
client.openDocument(uri, languageId, 1, text)
|
|
66
|
+
|
|
67
|
+
const result = await client.references(uri, line, character, true)
|
|
68
|
+
|
|
69
|
+
client.closeDocument(uri)
|
|
70
|
+
await client.stop()
|
|
71
|
+
|
|
72
|
+
console.log(JSON.stringify(result, null, 2))
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error(`Error: ${error}`)
|
|
75
|
+
await client.stop()
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Keep executable entry point for direct execution
|
|
81
|
+
if (import.meta.main) {
|
|
82
|
+
await lspRefs(Bun.argv.slice(2))
|
|
83
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Get all symbols (functions, classes, types, etc.) in a TypeScript/JavaScript file
|
|
4
|
+
*
|
|
5
|
+
* Usage: bun lsp-symbols.ts <file>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseArgs } from 'node:util'
|
|
9
|
+
import { LspClient } from './lsp-client.ts'
|
|
10
|
+
import { resolveFilePath } from './resolve-file-path.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get all symbols in a TypeScript/JavaScript file
|
|
14
|
+
*
|
|
15
|
+
* @param args - Command line arguments [file]
|
|
16
|
+
*/
|
|
17
|
+
export const lspSymbols = async (args: string[]) => {
|
|
18
|
+
const { positionals } = parseArgs({
|
|
19
|
+
args,
|
|
20
|
+
allowPositionals: true,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const [filePath] = positionals
|
|
24
|
+
|
|
25
|
+
if (!filePath) {
|
|
26
|
+
console.error('Usage: lsp-symbols <file>')
|
|
27
|
+
console.error(' file: Path to TypeScript/JavaScript file')
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const absolutePath = await resolveFilePath(filePath)
|
|
32
|
+
const uri = `file://${absolutePath}`
|
|
33
|
+
const rootUri = `file://${process.cwd()}`
|
|
34
|
+
|
|
35
|
+
const client = new LspClient({ rootUri })
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await client.start()
|
|
39
|
+
|
|
40
|
+
const file = Bun.file(absolutePath)
|
|
41
|
+
if (!(await file.exists())) {
|
|
42
|
+
console.error(`Error: File not found: ${absolutePath}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const text = await file.text()
|
|
47
|
+
const languageId = absolutePath.endsWith('.tsx')
|
|
48
|
+
? 'typescriptreact'
|
|
49
|
+
: absolutePath.endsWith('.ts')
|
|
50
|
+
? 'typescript'
|
|
51
|
+
: absolutePath.endsWith('.jsx')
|
|
52
|
+
? 'javascriptreact'
|
|
53
|
+
: 'javascript'
|
|
54
|
+
|
|
55
|
+
client.openDocument(uri, languageId, 1, text)
|
|
56
|
+
|
|
57
|
+
const result = await client.documentSymbols(uri)
|
|
58
|
+
|
|
59
|
+
client.closeDocument(uri)
|
|
60
|
+
await client.stop()
|
|
61
|
+
|
|
62
|
+
console.log(JSON.stringify(result, null, 2))
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`Error: ${error}`)
|
|
65
|
+
await client.stop()
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Keep executable entry point for direct execution
|
|
71
|
+
if (import.meta.main) {
|
|
72
|
+
await lspSymbols(Bun.argv.slice(2))
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a file path to an absolute path
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Handles three types of paths:
|
|
6
|
+
* - Absolute paths (starting with `/`) - returned as-is
|
|
7
|
+
* - Relative paths (starting with `.`) - resolved from cwd
|
|
8
|
+
* - Package export paths (e.g., `plaited/workshop/get-paths.ts`) - resolved via Bun.resolve()
|
|
9
|
+
*/
|
|
10
|
+
export const resolveFilePath = async (filePath: string): Promise<string> => {
|
|
11
|
+
// Absolute path
|
|
12
|
+
if (filePath.startsWith('/')) {
|
|
13
|
+
return filePath
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Relative path from cwd
|
|
17
|
+
if (filePath.startsWith('.')) {
|
|
18
|
+
return `${process.cwd()}/${filePath}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Try package export path resolution
|
|
22
|
+
try {
|
|
23
|
+
return await Bun.resolve(filePath, process.cwd())
|
|
24
|
+
} catch {
|
|
25
|
+
// Fall back to relative path from cwd
|
|
26
|
+
return `${process.cwd()}/${filePath}`
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Scaffold development rules from templates
|
|
4
|
+
*
|
|
5
|
+
* Reads bundled rule templates, processes template variables and conditionals,
|
|
6
|
+
* and outputs JSON for agent consumption.
|
|
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)
|
|
12
|
+
*
|
|
13
|
+
* 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)
|
|
17
|
+
* - --rules, -r: Filter to specific rules (can be used multiple times)
|
|
18
|
+
*
|
|
19
|
+
* Template syntax:
|
|
20
|
+
* - {{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
|
+
* - <!-- RULE TEMPLATE ... --> - Template header (removed)
|
|
25
|
+
*
|
|
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
|
+
* @example
|
|
31
|
+
* ```bash
|
|
32
|
+
* # Default paths
|
|
33
|
+
* bunx @plaited/development-skills scaffold-rules --agent=claude
|
|
34
|
+
* bunx @plaited/development-skills scaffold-rules --agent=agents-md
|
|
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
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { readdir } from 'node:fs/promises'
|
|
43
|
+
import { join } from 'node:path'
|
|
44
|
+
import { parseArgs } from 'node:util'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Supported agent targets
|
|
48
|
+
*
|
|
49
|
+
* @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)
|
|
53
|
+
*/
|
|
54
|
+
type Agent = 'claude' | 'agents-md'
|
|
55
|
+
|
|
56
|
+
type AgentCapabilities = {
|
|
57
|
+
hasSandbox: boolean
|
|
58
|
+
multiFileRules: boolean
|
|
59
|
+
supportsSlashCommands: boolean
|
|
60
|
+
supportsAgentsMd: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type TemplateContext = {
|
|
64
|
+
agent: Agent
|
|
65
|
+
capabilities: AgentCapabilities
|
|
66
|
+
hasDevelopmentSkills: boolean
|
|
67
|
+
rulesPath: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type ProcessedTemplate = {
|
|
71
|
+
filename: string
|
|
72
|
+
content: string
|
|
73
|
+
description: string
|
|
74
|
+
}
|
|
75
|
+
|
|
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
|
+
/**
|
|
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)
|
|
99
|
+
*/
|
|
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
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Process template conditionals
|
|
149
|
+
*
|
|
150
|
+
* 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)
|
|
155
|
+
*
|
|
156
|
+
* Processes iteratively to handle nested conditionals correctly.
|
|
157
|
+
*/
|
|
158
|
+
const processConditionals = (content: string, context: TemplateContext): string => {
|
|
159
|
+
let result = content
|
|
160
|
+
let previousResult = ''
|
|
161
|
+
|
|
162
|
+
// Process iteratively until no more changes (handles nested conditionals)
|
|
163
|
+
while (result !== previousResult) {
|
|
164
|
+
previousResult = result
|
|
165
|
+
|
|
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
|
|
169
|
+
result = result.replace(
|
|
170
|
+
/\{\{#if ([\w:-]+)\}\}((?:(?!\{\{#if )(?!\{\{\^if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g,
|
|
171
|
+
(_, condition, block) => {
|
|
172
|
+
return evaluateCondition(condition, context) ? block : ''
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// Process inverse conditionals {{^if condition}}...{{/if}}
|
|
177
|
+
result = result.replace(
|
|
178
|
+
/\{\{\^if ([\w:-]+)\}\}((?:(?!\{\{#if )(?!\{\{\^if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g,
|
|
179
|
+
(_, condition, block) => {
|
|
180
|
+
return evaluateCondition(condition, context) ? '' : block
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return result
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Process template variables
|
|
190
|
+
*
|
|
191
|
+
* Handles:
|
|
192
|
+
* - {{LINK:rule-id}} - Generate cross-reference
|
|
193
|
+
* - {{AGENT_NAME}} - Agent name
|
|
194
|
+
* - {{RULES_PATH}} - Rules path
|
|
195
|
+
*/
|
|
196
|
+
const processVariables = (content: string, context: TemplateContext): string => {
|
|
197
|
+
let result = content
|
|
198
|
+
|
|
199
|
+
// Replace {{LINK:rule-id}} with appropriate cross-reference
|
|
200
|
+
result = result.replace(/\{\{LINK:(\w+)\}\}/g, (_, ruleId) => {
|
|
201
|
+
return generateCrossReference(ruleId, context)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Replace {{AGENT_NAME}}
|
|
205
|
+
result = result.replace(/\{\{AGENT_NAME\}\}/g, context.agent)
|
|
206
|
+
|
|
207
|
+
// Replace {{RULES_PATH}}
|
|
208
|
+
result = result.replace(/\{\{RULES_PATH\}\}/g, context.rulesPath)
|
|
209
|
+
|
|
210
|
+
return result
|
|
211
|
+
}
|
|
212
|
+
|
|
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
|
+
/**
|
|
226
|
+
* Remove template headers
|
|
227
|
+
*/
|
|
228
|
+
const removeTemplateHeaders = (content: string): string => {
|
|
229
|
+
return content.replace(/<!--[\s\S]*?-->\n*/g, '')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Extract description from rule content
|
|
234
|
+
*/
|
|
235
|
+
const extractDescription = (content: string): string => {
|
|
236
|
+
// Look for first paragraph or heading after main title
|
|
237
|
+
const lines = content.split('\n')
|
|
238
|
+
let description = ''
|
|
239
|
+
|
|
240
|
+
for (let i = 1; i < lines.length; i++) {
|
|
241
|
+
const line = lines[i]?.trim()
|
|
242
|
+
if (line && !line.startsWith('#') && !line.startsWith('**')) {
|
|
243
|
+
description = line
|
|
244
|
+
break
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return description || 'Development rule'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Process a template with context
|
|
253
|
+
*/
|
|
254
|
+
const processTemplate = (content: string, context: TemplateContext): string => {
|
|
255
|
+
let result = content
|
|
256
|
+
|
|
257
|
+
// 1. Remove template headers
|
|
258
|
+
result = removeTemplateHeaders(result)
|
|
259
|
+
|
|
260
|
+
// 2. Process conditionals
|
|
261
|
+
result = processConditionals(result, context)
|
|
262
|
+
|
|
263
|
+
// 3. Process variables
|
|
264
|
+
result = processVariables(result, context)
|
|
265
|
+
|
|
266
|
+
// 4. Clean up extra blank lines
|
|
267
|
+
result = result.replace(/\n{3,}/g, '\n\n')
|
|
268
|
+
|
|
269
|
+
return result
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get rules path for agent
|
|
274
|
+
*/
|
|
275
|
+
const getRulesPath = (agent: Agent): string => {
|
|
276
|
+
return agent === 'claude' ? '.claude/rules' : '.plaited/rules'
|
|
277
|
+
}
|
|
278
|
+
|
|
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'
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Generate AGENTS.md content that links to rules directory
|
|
288
|
+
*/
|
|
289
|
+
const generateAgentsMd = (templates: Record<string, ProcessedTemplate>, rulesPath: string): string => {
|
|
290
|
+
const lines = [
|
|
291
|
+
'# AGENTS.md',
|
|
292
|
+
'',
|
|
293
|
+
'Development rules for AI coding agents.',
|
|
294
|
+
'',
|
|
295
|
+
'## Rules',
|
|
296
|
+
'',
|
|
297
|
+
`This project uses modular development rules stored in \`${rulesPath}/\`.`,
|
|
298
|
+
'Each rule file covers a specific topic:',
|
|
299
|
+
'',
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
for (const [ruleId, template] of Object.entries(templates)) {
|
|
303
|
+
lines.push(`- [${ruleId}](${rulesPath}/${template.filename}) - ${template.description}`)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
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('')
|
|
311
|
+
|
|
312
|
+
return lines.join('\n')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Main scaffold-rules function
|
|
317
|
+
*/
|
|
318
|
+
export const scaffoldRules = async (args: string[]): Promise<void> => {
|
|
319
|
+
const { values } = parseArgs({
|
|
320
|
+
args,
|
|
321
|
+
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
|
+
rules: {
|
|
333
|
+
type: 'string',
|
|
334
|
+
short: 'r',
|
|
335
|
+
multiple: true,
|
|
336
|
+
},
|
|
337
|
+
'rules-dir': {
|
|
338
|
+
type: 'string',
|
|
339
|
+
short: 'd',
|
|
340
|
+
},
|
|
341
|
+
'agents-md-path': {
|
|
342
|
+
type: 'string',
|
|
343
|
+
short: 'm',
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
allowPositionals: true,
|
|
347
|
+
strict: false,
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
const agent = values.agent as Agent
|
|
351
|
+
const rulesFilter = values.rules as string[] | undefined
|
|
352
|
+
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
|
+
}
|
|
364
|
+
|
|
365
|
+
// Get bundled templates directory
|
|
366
|
+
const packageRulesDir = join(import.meta.dir, '../.claude/rules')
|
|
367
|
+
|
|
368
|
+
// Read template files
|
|
369
|
+
const templateFiles = await readdir(packageRulesDir)
|
|
370
|
+
|
|
371
|
+
// Filter to .md files
|
|
372
|
+
const mdFiles = templateFiles.filter((f) => f.endsWith('.md'))
|
|
373
|
+
|
|
374
|
+
// Filter if specific rules requested
|
|
375
|
+
const rulesToProcess = rulesFilter ? mdFiles.filter((f) => rulesFilter.includes(f.replace('.md', ''))) : mdFiles
|
|
376
|
+
|
|
377
|
+
// Process each template
|
|
378
|
+
const templates: Record<string, ProcessedTemplate> = {}
|
|
379
|
+
const capabilities = AGENT_CAPABILITIES[agent]
|
|
380
|
+
|
|
381
|
+
// Use custom paths or defaults
|
|
382
|
+
const rulesPath = customRulesDir ?? getRulesPath(agent)
|
|
383
|
+
const agentsMdPath = customAgentsMdPath ?? 'AGENTS.md'
|
|
384
|
+
|
|
385
|
+
const context: TemplateContext = {
|
|
386
|
+
agent,
|
|
387
|
+
capabilities,
|
|
388
|
+
hasDevelopmentSkills: true, // Always true when using our CLI
|
|
389
|
+
rulesPath,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const file of rulesToProcess) {
|
|
393
|
+
const templatePath = join(packageRulesDir, file)
|
|
394
|
+
const ruleId = file.replace('.md', '')
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const content = await Bun.file(templatePath).text()
|
|
398
|
+
|
|
399
|
+
// Process template
|
|
400
|
+
const processed = processTemplate(content, context)
|
|
401
|
+
|
|
402
|
+
templates[ruleId] = {
|
|
403
|
+
filename: file,
|
|
404
|
+
content: processed,
|
|
405
|
+
description: extractDescription(processed),
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
409
|
+
console.error(`Error processing template ${file}: ${message}`)
|
|
410
|
+
process.exit(1)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Build output
|
|
415
|
+
const output: ScaffoldOutput = {
|
|
416
|
+
agent,
|
|
417
|
+
rulesPath,
|
|
418
|
+
agentsMdPath,
|
|
419
|
+
format: getOutputFormat(agent),
|
|
420
|
+
supportsAgentsMd: capabilities.supportsAgentsMd,
|
|
421
|
+
templates,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Generate AGENTS.md content for agents-md format
|
|
425
|
+
if (agent === 'agents-md') {
|
|
426
|
+
output.agentsMdContent = generateAgentsMd(templates, rulesPath)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
console.log(JSON.stringify(output, null, 2))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// CLI entry point
|
|
433
|
+
if (import.meta.main) {
|
|
434
|
+
await scaffoldRules(Bun.argv.slice(2))
|
|
435
|
+
}
|