Skip to content

Commit fdceafd

Browse files
committed
feat(rules): introduce RuleInputPlugin and related functionality
Added the RuleInputPlugin to handle rule prompts with glob patterns, enabling project and global scope rules. Updated various components to integrate rule processing, including output plugins and configuration types. Version updated across package.json files to reflect new features. - Introduced validation for rule metadata. - Enhanced output plugins to support rule file generation. - Added tests for rule input and output functionalities. - Updated configuration types to include shadowRulesDir and rule-related structures.
1 parent 3dee5ce commit fdceafd

20 files changed

+891
-50
lines changed

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@truenine/memory-sync-cli",
33
"type": "module",
4-
"version": "2026.10214.1083059",
4+
"version": "2026.10217.12221",
55
"description": "TrueNine Memory Synchronization CLI",
66
"author": "TrueNine",
77
"license": "AGPL-3.0-only",

cli/src/PluginPipeline.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,11 @@ export class PluginPipeline {
647647
? [...base.skills ?? [], ...addition.skills]
648648
: base.skills
649649

650+
const rules: CollectedInputContext['rules'] | undefined
651+
= addition.rules != null
652+
? [...base.rules ?? [], ...addition.rules]
653+
: base.rules
654+
650655
const aiAgentIgnoreConfigFiles: CollectedInputContext['aiAgentIgnoreConfigFiles'] | undefined
651656
= addition.aiAgentIgnoreConfigFiles != null
652657
? [...base.aiAgentIgnoreConfigFiles ?? [], ...addition.aiAgentIgnoreConfigFiles]
@@ -675,6 +680,7 @@ export class PluginPipeline {
675680
...fastCommands != null ? {fastCommands} : {},
676681
...subAgents != null ? {subAgents} : {},
677682
...skills != null ? {skills} : {},
683+
...rules != null ? {rules} : {},
678684
...aiAgentIgnoreConfigFiles != null ? {aiAgentIgnoreConfigFiles} : {},
679685
...globalMemory != null ? {globalMemory} : {},
680686
...shadowSourceProjectDir != null ? {shadowSourceProjectDir} : {},

cli/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ function userConfigToPluginOptions(userConfig: UserConfigFile): Partial<PluginOp
3939
...userConfig.shadowSkillSourceDir != null ? {shadowSkillSourceDir: userConfig.shadowSkillSourceDir} : {},
4040
...userConfig.shadowFastCommandDir != null ? {shadowFastCommandDir: userConfig.shadowFastCommandDir} : {},
4141
...userConfig.shadowSubAgentDir != null ? {shadowSubAgentDir: userConfig.shadowSubAgentDir} : {},
42+
...userConfig.shadowRulesDir != null ? {shadowRulesDir: userConfig.shadowRulesDir} : {},
4243
...userConfig.globalMemoryFile != null ? {globalMemoryFile: userConfig.globalMemoryFile} : {},
4344
...userConfig.shadowProjectsDir != null ? {shadowProjectsDir: userConfig.shadowProjectsDir} : {},
4445
...userConfig.externalProjects != null ? {externalProjects: userConfig.externalProjects} : {},
@@ -232,6 +233,7 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions
232233
...merged.fastCommands != null && {fastCommands: merged.fastCommands},
233234
...merged.subAgents != null && {subAgents: merged.subAgents},
234235
...merged.skills != null && {skills: merged.skills},
236+
...merged.rules != null && {rules: merged.rules},
235237
...merged.globalMemory != null && {globalMemory: merged.globalMemory},
236238
...merged.aiAgentIgnoreConfigFiles != null && {aiAgentIgnoreConfigFiles: merged.aiAgentIgnoreConfigFiles},
237239
...merged.shadowSourceProjectDir != null && {shadowSourceProjectDir: merged.shadowSourceProjectDir},

cli/src/plugin.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {ProjectPromptInputPlugin} from '@/plugins/ProjectPromptInputPlugin'
2424
import {QoderIDEPluginOutputPlugin} from '@/plugins/QoderIDEPluginOutputPlugin'
2525
import {ReadmeMdConfigFileOutputPlugin} from '@/plugins/ReadmeMdConfigFileOutputPlugin'
2626
import {ReadmeMdInputPlugin} from '@/plugins/ReadmeMdInputPlugin'
27+
import {RuleInputPlugin} from '@/plugins/RuleInputPlugin'
2728
import {ShadowProjectInputPlugin} from '@/plugins/ShadowProjectInputPlugin'
2829
import {SkillInputPlugin} from '@/plugins/SkillInputPlugin'
2930
import {SkillNonSrcFileSyncEffectInputPlugin} from '@/plugins/SkillNonSrcFileSyncEffectInputPlugin'
@@ -68,6 +69,7 @@ export default defineConfig({
6869
new SkillInputPlugin(),
6970
new FastCommandInputPlugin(),
7071
new SubAgentInputPlugin(),
72+
new RuleInputPlugin(),
7173
new GlobalMemoryInputPlugin(),
7274
new ProjectPromptInputPlugin(),
7375
new ReadmeMdInputPlugin(),

cli/src/plugins/CursorOutputPlugin.ts

Lines changed: 141 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
OutputPluginContext,
44
OutputWriteContext,
55
Project,
6+
RulePrompt,
67
SkillPrompt,
78
WriteResult,
89
WriteResults
@@ -22,6 +23,7 @@ const RULES_SUBDIR = 'rules'
2223
const GLOBAL_RULE_FILE = 'global.mdc'
2324
const SKILLS_CURSOR_SUBDIR = 'skills-cursor'
2425
const SKILL_FILE_NAME = 'SKILL.md'
26+
const RULE_FILE_PREFIX = 'rule-'
2527

2628
const PRESERVED_SKILLS = new Set<string>([
2729
'create-rule',
@@ -75,7 +77,7 @@ export class CursorOutputPlugin extends AbstractOutputPlugin {
7577
async registerGlobalOutputDirs(ctx: OutputPluginContext): Promise<RelativePath[]> {
7678
const results: RelativePath[] = []
7779
const globalDir = this.getGlobalConfigDir()
78-
const {fastCommands, skills} = ctx.collectedInputContext
80+
const {fastCommands, skills, rules} = ctx.collectedInputContext
7981

8082
if (fastCommands != null && fastCommands.length > 0) {
8183
const commandsDir = this.getGlobalCommandsDir()
@@ -104,6 +106,17 @@ export class CursorOutputPlugin extends AbstractOutputPlugin {
104106
}
105107
}
106108

109+
const globalRules = rules?.filter(r => r.scope === 'global')
110+
if (globalRules == null || globalRules.length === 0) return results
111+
112+
const globalRulesDir = path.join(globalDir, RULES_SUBDIR)
113+
results.push({
114+
pathKind: FilePathKind.Relative,
115+
path: RULES_SUBDIR,
116+
basePath: globalDir,
117+
getDirectoryName: () => RULES_SUBDIR,
118+
getAbsolutePath: () => globalRulesDir
119+
})
107120
return results
108121
}
109122

@@ -140,6 +153,22 @@ export class CursorOutputPlugin extends AbstractOutputPlugin {
140153
}
141154
}
142155

156+
const globalRules = ctx.collectedInputContext.rules?.filter(r => r.scope === 'global')
157+
if (globalRules != null && globalRules.length > 0) {
158+
const globalRulesDir = path.join(globalDir, RULES_SUBDIR)
159+
for (const rule of globalRules) {
160+
const fileName = this.buildRuleFileName(rule)
161+
const fullPath = path.join(globalRulesDir, fileName)
162+
results.push({
163+
pathKind: FilePathKind.Relative,
164+
path: path.join(RULES_SUBDIR, fileName),
165+
basePath: globalDir,
166+
getDirectoryName: () => RULES_SUBDIR,
167+
getAbsolutePath: () => fullPath
168+
})
169+
}
170+
}
171+
143172
if (skills == null || skills.length === 0) return results
144173

145174
const skillsCursorDir = this.getSkillsCursorDir()
@@ -196,8 +225,10 @@ export class CursorOutputPlugin extends AbstractOutputPlugin {
196225

197226
async registerProjectOutputDirs(ctx: OutputPluginContext): Promise<RelativePath[]> {
198227
const results: RelativePath[] = []
199-
const {workspace, globalMemory} = ctx.collectedInputContext
200-
if (globalMemory == null) return results
228+
const {workspace, globalMemory, rules} = ctx.collectedInputContext
229+
const hasProjectRules = rules?.some(r => r.scope === 'project') ?? false
230+
231+
if (globalMemory == null && !hasProjectRules) return results
201232

202233
for (const project of workspace.projects) {
203234
const projectDir = project.dirFromWorkspacePath
@@ -209,35 +240,53 @@ export class CursorOutputPlugin extends AbstractOutputPlugin {
209240

210241
async registerProjectOutputFiles(ctx: OutputPluginContext): Promise<RelativePath[]> {
211242
const results: RelativePath[] = []
212-
const {workspace, globalMemory} = ctx.collectedInputContext
213-
if (globalMemory == null) return results
243+
const {workspace, globalMemory, rules} = ctx.collectedInputContext
244+
const projectRules = rules?.filter(r => r.scope === 'project')
245+
const hasProjectRules = projectRules != null && projectRules.length > 0
214246

215-
for (const project of workspace.projects) {
216-
const projectDir = project.dirFromWorkspacePath
217-
if (projectDir == null) continue
218-
results.push(this.createProjectRuleFileRelativePath(projectDir, GLOBAL_RULE_FILE))
247+
if (globalMemory == null && !hasProjectRules) return results
248+
249+
if (globalMemory != null) {
250+
for (const project of workspace.projects) {
251+
const projectDir = project.dirFromWorkspacePath
252+
if (projectDir == null) continue
253+
results.push(this.createProjectRuleFileRelativePath(projectDir, GLOBAL_RULE_FILE))
254+
}
255+
}
256+
257+
if (hasProjectRules) {
258+
for (const project of workspace.projects) {
259+
const projectDir = project.dirFromWorkspacePath
260+
if (projectDir == null) continue
261+
for (const rule of projectRules) {
262+
const fileName = this.buildRuleFileName(rule)
263+
results.push(this.createProjectRuleFileRelativePath(projectDir, fileName))
264+
}
265+
}
219266
}
267+
220268
results.push(...this.registerProjectIgnoreOutputFiles(workspace.projects))
221269
return results
222270
}
223271

224272
async canWrite(ctx: OutputWriteContext): Promise<boolean> {
225-
const {workspace, skills, fastCommands, globalMemory, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext
273+
const {workspace, skills, fastCommands, globalMemory, rules, aiAgentIgnoreConfigFiles} = ctx.collectedInputContext
226274
const hasSkills = (skills?.length ?? 0) > 0
227275
const hasFastCommands = (fastCommands?.length ?? 0) > 0
276+
const hasRules = (rules?.length ?? 0) > 0
228277
const hasGlobalRuleOutput
229278
= globalMemory != null
230279
&& workspace.projects.some(p => p.dirFromWorkspacePath != null)
231280
const hasCursorIgnore = aiAgentIgnoreConfigFiles?.some(f => f.fileName === '.cursorignore') ?? false
232281

233-
if (hasSkills || hasFastCommands || hasGlobalRuleOutput || hasCursorIgnore) return true
282+
if (hasSkills || hasFastCommands || hasGlobalRuleOutput || hasRules || hasCursorIgnore) return true
234283

235284
this.log.trace({action: 'skip', reason: 'noOutputs'})
236285
return false
237286
}
238287

239288
async writeGlobalOutputs(ctx: OutputWriteContext): Promise<WriteResults> {
240-
const {skills, fastCommands} = ctx.collectedInputContext
289+
const {skills, fastCommands, rules} = ctx.collectedInputContext
241290
const fileResults: WriteResult[] = []
242291
const dirResults: WriteResult[] = []
243292

@@ -255,20 +304,30 @@ export class CursorOutputPlugin extends AbstractOutputPlugin {
255304
}
256305
}
257306

258-
if (fastCommands == null || fastCommands.length === 0) return {files: fileResults, dirs: dirResults}
307+
if (fastCommands != null && fastCommands.length > 0) {
308+
const commandsDir = this.getGlobalCommandsDir()
309+
for (const cmd of fastCommands) {
310+
const result = await this.writeGlobalFastCommand(ctx, commandsDir, cmd)
311+
fileResults.push(result)
312+
}
313+
}
259314

260-
const commandsDir = this.getGlobalCommandsDir()
261-
for (const cmd of fastCommands) {
262-
const result = await this.writeGlobalFastCommand(ctx, commandsDir, cmd)
263-
fileResults.push(result)
315+
const globalRules = rules?.filter(r => r.scope === 'global')
316+
if (globalRules != null && globalRules.length > 0) {
317+
const globalRulesDir = path.join(this.getGlobalConfigDir(), RULES_SUBDIR)
318+
for (const rule of globalRules) {
319+
const result = await this.writeRuleMdcFile(ctx, globalRulesDir, rule, this.getGlobalConfigDir())
320+
fileResults.push(result)
321+
}
264322
}
323+
265324
return {files: fileResults, dirs: dirResults}
266325
}
267326

268327
async writeProjectOutputs(ctx: OutputWriteContext): Promise<WriteResults> {
269328
const fileResults: WriteResult[] = []
270329
const dirResults: WriteResult[] = []
271-
const {workspace, globalMemory} = ctx.collectedInputContext
330+
const {workspace, globalMemory, rules} = ctx.collectedInputContext
272331
if (globalMemory != null) {
273332
const content = this.buildGlobalRuleContent(globalMemory.content as string)
274333
for (const project of workspace.projects) {
@@ -279,6 +338,19 @@ export class CursorOutputPlugin extends AbstractOutputPlugin {
279338
}
280339
}
281340

341+
const projectRules = rules?.filter(r => r.scope === 'project')
342+
if (projectRules != null && projectRules.length > 0) {
343+
for (const project of workspace.projects) {
344+
const projectDir = project.dirFromWorkspacePath
345+
if (projectDir == null) continue
346+
const rulesDir = path.join(projectDir.basePath, projectDir.path, GLOBAL_CONFIG_DIR, RULES_SUBDIR)
347+
for (const rule of projectRules) {
348+
const result = await this.writeRuleMdcFile(ctx, rulesDir, rule, projectDir.basePath)
349+
fileResults.push(result)
350+
}
351+
}
352+
}
353+
282354
const ignoreResults = await this.writeProjectIgnoreFiles(ctx)
283355
fileResults.push(...ignoreResults)
284356

@@ -662,4 +734,55 @@ export class CursorOutputPlugin extends AbstractOutputPlugin {
662734
return {path: relativePath, success: false, error: error as Error}
663735
}
664736
}
737+
738+
private buildRuleFileName(rule: RulePrompt): string {
739+
return `${RULE_FILE_PREFIX}${rule.series}-${rule.ruleName}.mdc`
740+
}
741+
742+
private buildRuleMdcContent(rule: RulePrompt): string {
743+
const description = rule.yamlFrontMatter?.description ?? ''
744+
const fmData: Record<string, unknown> = {
745+
description,
746+
globs: [...rule.globs],
747+
alwaysApply: false
748+
}
749+
return buildMarkdownWithFrontMatter(fmData, rule.content)
750+
}
751+
752+
private async writeRuleMdcFile(
753+
ctx: OutputWriteContext,
754+
rulesDir: string,
755+
rule: RulePrompt,
756+
basePath: string
757+
): Promise<WriteResult> {
758+
const fileName = this.buildRuleFileName(rule)
759+
const fullPath = path.join(rulesDir, fileName)
760+
761+
const relativePath: RelativePath = {
762+
pathKind: FilePathKind.Relative,
763+
path: path.join(GLOBAL_CONFIG_DIR, RULES_SUBDIR, fileName),
764+
basePath,
765+
getDirectoryName: () => RULES_SUBDIR,
766+
getAbsolutePath: () => fullPath
767+
}
768+
769+
const content = this.buildRuleMdcContent(rule)
770+
771+
if (ctx.dryRun === true) {
772+
this.log.trace({action: 'dryRun', type: 'ruleFile', path: fullPath})
773+
return {path: relativePath, success: true, skipped: false}
774+
}
775+
776+
try {
777+
this.ensureDirectory(rulesDir)
778+
this.writeFileSync(fullPath, content)
779+
this.log.trace({action: 'write', type: 'ruleFile', path: fullPath})
780+
return {path: relativePath, success: true}
781+
}
782+
catch (error) {
783+
const errMsg = error instanceof Error ? error.message : String(error)
784+
this.log.error({action: 'write', type: 'ruleFile', path: fullPath, error: errMsg})
785+
return {path: relativePath, success: false, error: error as Error}
786+
}
787+
}
665788
}

0 commit comments

Comments
 (0)